The in-app store
The database channel persists notifications and powers the in-app notification center — unread badges, a dropdown list, mark-as-read. Three pieces: a model, a repository, and the inApp facade.
The read API — recipient-scoped by construction
Section titled “The read API — recipient-scoped by construction”import { inApp } from "@warlock.js/notifications";
await inApp.list(user, { type: "order.shipped" }); // all — optional filter + paging (page / limit)await inApp.listUnread(user); // unread onlyawait inApp.countUnread(user); // cached — backs the badgeawait inApp.find(user, "ntf_123"); // one — for a detail view
await inApp.markAsRead(user, "ntf_123"); // oneawait inApp.markAsRead(user); // all unread for this userawait inApp.markAsUnread(user, "ntf_123");await inApp.dismiss(user, "ntf_123"); // delete oneawait inApp.dismiss(user); // clear all for this userEvery method forces the recipient id into the query. markAsRead(user, id) with an id belonging to a different user matches zero rows — IDOR-safe by construction, not by a guard you might forget. All methods accept a cascade model or a raw id.
The write side is IDOR-safe too (since 4.15.0)
Section titled “The write side is IDOR-safe too (since 4.15.0)”createFor(recipientId, input, tenantId?) treats recipientId as the trusted argument and applies it after input — server-owned keys (id, recipientId, tenant, readAt, isRead, and their resolved columnMap physical column names) are stripped from input before the row is built. Before this fix, an input payload assembled from untyped data (request JSON, a channel’s send args) that happened to carry a recipientId key — or the model’s physical recipient column name — could redirect the write into another recipient’s inbox. createManyFor inherits the same protection. type / title / body / payload / idempotencyKey pass through unchanged — you don’t need to sanitize input yourself.
The model
Section titled “The model”Extend the shipped base and declare your physical columns once via columnMap — the accessors are the stable contract, the column names are yours. The schema mirrors the migration columns; cascade validates + casts every write against it:
import { v } from "@warlock.js/seal";import { DatabaseNotification, type NotificationColumnMap } from "@warlock.js/notifications";
const notificationSchema = v.object({ user_id: v.string(), type: v.string(), title: v.string(), body: v.string().nullish(), payload: v.record(v.any()).nullish(), read_at: v.date().nullish(), idempotency_key: v.string().nullish(),});
@RegisterModel()export class Notification extends DatabaseNotification { public static table = "notifications"; public static schema = notificationSchema;
// Single-tenant, read_at-only. The accessors, repo, and migration all follow this. public static columnMap: NotificationColumnMap = { readAt: "read_at" };}DatabaseNotification exposes recipientId / tenantId / isRead / readAt / markRead() — all derived from columnMap. You rarely touch the class directly; the read/write API is the inApp facade.
columnMap — map roles to columns
Section titled “columnMap — map roles to columns”type NotificationColumnMap = { recipient?: string; // recipient FK. Default "user_id" tenant?: string; // multi-tenant scope, written from the recipient. Omit → single-tenant readAt?: string; // read-timestamp column isRead?: string; // read-flag column (indexed)};Which read-state keys are present chooses the representation:
readAtonly → unread isread_at IS NULL; marking read stamps it.isReadonly → unread isis_read = false; no timestamp.- both →
is_readis the indexed filter flag,read_atrecords when.
Declare neither and you get the readAt-only default. For multi-tenant, add tenant: "organization_id" — the database channel reads that column off the recipient and writes it on every row, no custom channel needed.
The repository — derived from columnMap
Section titled “The repository — derived from columnMap”The package ships a concrete BaseNotificationsRepository; inApp.configure({ model }) instantiates it for you. Its constructor resolves the model’s columnMap and builds both sides from it:
- the read filter (
filterBy) — logical keys (recipientId,unread,type, …) mapped to your columns; - the write / update / delete mapping used by
createFor/markRead/dismiss.
Because both come from one columnMap, reads and writes can never disagree on a column name. The unread filter is mode-agnostic — it resolves to is_read = false or read_at IS NULL depending on your map.
Subclass the repository only for extra query methods — column naming already lives on the model:
class NotificationsRepository extends BaseNotificationsRepository<Notification> { public source = Notification; // its columnMap still drives the filter + write mapping // …app-specific finders}inApp.configure({ repository: new NotificationsRepository() });Idempotency
Section titled “Idempotency”Pass idempotencyKey and createFor does find-or-create — a retried send returns the existing row instead of inserting a duplicate. A unique constraint on the column is the race backstop (the repo re-fetches on conflict).
await orderShipped.send(user, { order }, { idempotencyKey: `ship:${order.id}` });- Define a notification — write the database renderer.
- Observability — track sending / sent / failed / skipped.
- API reference — full
inAppsurface.