npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@koreaedu/solapi-inbox-client

v0.2.1

Published

Client library for the SOLAPI notification inbox schema.

Readme

@koreaedu/solapi-inbox-client

ESM-only client library for the notif schema used by the SOLAPI notification inbox and dashboard.

Quickstart

Install the package next to pg, then run migrations before enqueueing messages.

On a fresh database no notification types are registered yet, so assertTypesRegistered below fails until you register your types once — see Type Registration.

import pg from "pg";
import {
  assertTypesRegistered,
  enqueue,
  migrate,
  withPool,
} from "@koreaedu/solapi-inbox-client";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

await withPool(pool, migrate);
await assertTypesRegistered(pool, ["video.lesson.reminder"]);

const messageId = await enqueue(pool, {
  notificationType: "video.lesson.reminder",
  recipient: "01000000000",
  templateVars: { name: "Ada" },
  scheduledAt: new Date("2026-07-06T00:00:00.000Z"),
  source: {
    app: "libera",
    intent: "video-lesson.reminder-1h.student",
    entity: { type: "video_lesson", id: "lesson-1" },
  },
});

After the worker processes the row, use the dashboard to inspect state, delivery attempts, retries, dead messages, suppressed messages, and send logs.

Bound Client

Use bindInbox(db) when passing the same database handle through a module.

import { bindInbox } from "@koreaedu/solapi-inbox-client";

const inbox = bindInbox(pool);

await inbox.assertTypesRegistered(["video.lesson.reminder"]);
await inbox.enqueue({
  notificationType: "video.lesson.reminder",
  recipient: "01000000000",
  scheduledAt: "2026-07-06T00:00:00.000Z",
  source: {
    app: "libera",
    intent: "video-lesson.reminder-1h.student",
    entity: { type: "video_lesson", id: "lesson-1" },
  },
});

bindInbox only curries db. It does not open, commit, roll back, or release connections.

Transactions

Every core API accepts a structural Queryable, so callers can pass a transaction client and keep notification writes atomic with domain writes.

const client = await pool.connect();

try {
  await client.query("BEGIN");
  await client.query(
    `INSERT INTO lessons (id, starts_at) VALUES ($1, $2)`,
    ["lesson-1", "2026-07-06T01:00:00.000Z"],
  );
  await enqueue(client, {
    notificationType: "video.lesson.reminder",
    recipient: "01000000000",
    scheduledAt: "2026-07-06T00:00:00.000Z",
    source: {
      app: "libera",
      intent: "video-lesson.reminder-1h.student",
      entity: { type: "video_lesson", id: "lesson-1" },
    },
  });
  await client.query("COMMIT");
} catch (error) {
  await client.query("ROLLBACK");
  throw error;
} finally {
  client.release();
}

Idempotency Rules

  1. Messages without source keep legacy behavior: no idempotency key is stored, and identical calls create separate rows. idempotencyKey has no effect on these calls — the client discards it rather than persisting it, so do not rely on TypeScript to reject it (union member overlap lets it type-check).
  2. Messages with source and scheduledAt may omit idempotencyKey; the client derives one from source app, source intent, primary entity, recipient, and the exact schedule.
  3. Messages with source and no scheduledAt are immediate messages and must pass an explicit idempotencyKey. This is enforced by TypeScript and checked again at runtime.

The immediate idempotency key format is app:intent:entityType:entityId:recipient[:discriminator], where app and entityType scope the key to prevent collisions across applications or entity types. The scheduled idempotency key is a SHA-256 digest of app, intent, entityType, entityId, recipient, and the exact scheduled timestamp.

Use deriveIdempotencyKey(source, recipient, scheduledAt) for scheduled sourced messages with deterministic, schedule-scoped keys. Use immediateIdempotencyKey(source, recipient, discriminator?) for sourced immediate messages that need an explicit key; repeatable intents should pass a stable discriminator.

Cancellation frees an idempotency key only after the daemon finalizes the cancel request; the daemon's sweep finalizes pending cancels on scheduled rows within one poll interval; a re-reservation deriving the same key can still dedupe against the not-yet-swept row within that interval — change the schedule or pass an explicit key if immediate re-reservation of the identical slot is required.

Type Registration

Every notificationType must have a row in notif.type_mappings before the worker can deliver it. Register types either through the dashboard Types screen, or directly in SQL during provisioning:

INSERT INTO notif.type_mappings (notification_type, template_id, enabled)
VALUES ('video.lesson.reminder', 'KA01TP-your-solapi-template-id', true);

template_id is the SOLAPI (Kakao AlimTalk) template id; it may be filled in later from the dashboard, but the mapping must be enabled with a template before delivery succeeds.

enqueue does not validate notificationType against notif.type_mappings. Keep that write path lean and call assertTypesRegistered(db, types) once at boot instead.

await assertTypesRegistered(pool, [
  "video.lesson.reminder",
  "thread.comment",
]);

If a queued message reaches the worker with no mapping row, the worker marks it dead with detail no_mapping. If the mapping exists but is disabled, or the message exceeds max_delay_seconds, the worker marks it suppressed unless the message was forced. Enqueue success means the row was accepted into the inbox; it does not guarantee delivery.

Delivery Semantics

Message rows move through six statuses: scheduled, sending, sent, dead, canceled, and suppressed.

  • scheduled: accepted and waiting for processing. A transient send failure with attempts remaining also returns the row to scheduled with a later next_attempt_at (backoff) — there is no separate retry status.
  • sending: claimed by a worker.
  • sent: SOLAPI accepted the send attempt.
  • dead: unrecoverable worker outcome, including missing mapping, missing template, or exhausted retries.
  • suppressed: mapping policy blocked delivery, such as disabled type or exceeded max delay.
  • canceled: reservation was canceled before delivery.

Send attempts are recorded in notif.send_log when the worker reaches the send path. Missing mappings and suppression decisions happen before SOLAPI is called.

Migrations and PgBouncer

migrate(session) must receive one dedicated PostgreSQL session for the full call. It uses a session-scoped advisory lock, so PgBouncer transaction pooling does not preserve the required connection affinity. Use a direct session or PgBouncer session pooling for migrations.

Upgrading to 008+

Deploy order matters for migration 008_idempotency_key_active_unique.sql: ship the upgraded client code to every writer first, then apply the migration. Old client code inserts with ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL, which cannot infer migration 008's narrower partial index (WHERE idempotency_key IS NOT NULL AND status <> 'canceled') — every sourced insert would hard-fail once 008 is applied ahead of the code. New code's ON CONFLICT predicate is inferable by the old (pre-008) index, so code-before-migration is the safe order.

Runtime Requirements

This package is ESM-only and declares node >=22.