@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
- Messages without
sourcekeep legacy behavior: no idempotency key is stored, and identical calls create separate rows.idempotencyKeyhas 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). - Messages with
sourceandscheduledAtmay omitidempotencyKey; the client derives one from source app, source intent, primary entity, recipient, and the exact schedule. - Messages with
sourceand noscheduledAtare immediate messages and must pass an explicitidempotencyKey. 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 toscheduledwith a laternext_attempt_at(backoff) — there is no separateretrystatus.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.
