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, recipientType, 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.
Recipient types — identities that share an id
Section titled “Recipient types — identities that share an id”A notification belongs to a recipient type and id, not an id alone. With several identity models (staff, customers, platform users) that each count ids from 1, staff 5 and customer 5 are different recipients and must never read each other’s notifications.
Declare the type column in the model’s columnMap and every read, count, mark-read, mark-unread, and delete filters on type and id:
public static columnMap: NotificationColumnMap = { recipientType: "recipient_type", readAt: "read_at",};The type comes from the recipient model: an auth model’s userType (the same name used in the auth config), otherwise the model’s table name. The database channel writes it for you.
await inApp.listUnread(staffUser); // type "staff", id 5await inApp.listUnread({ type: "customer", id: 5 }); // by reference, when you only have the idawait inApp.listUnread(5); // throws RecipientTypeRequiredErrorWhen the table stores a type, a bare id is rejected with RecipientTypeRequiredError rather than guessed: an id alone cannot say which identity it belongs to. Pass the model or { type, id }.
Without recipientType in the columnMap, nothing changes: ids are the only key and bare ids are accepted. That is correct only for an app with a single identity type, so any app with more than one should declare the column.
Upgrading an existing table
Section titled “Upgrading an existing table”-
Add the column in a migration (nullable first, so existing rows survive):
import { Migration, string } from "@warlock.js/cascade";import { Notification } from "../notification.model";export default Migration.alter(Notification, {add: { recipient_type: string().index().nullable() },}); -
Backfill existing rows with the type that owned them, for example
UPDATE notifications SET recipient_type = 'staff'. With several identity types the old rows are already ambiguous: assign them from whatever you know about each row, or delete the ones you cannot attribute. -
Add
recipientType: "recipient_type"to the model’scolumnMap(andrecipient_typeto the modelschema).
Rows whose type is still empty match no typed read, so they stop appearing until backfilled; this is deliberate, since showing them would reintroduce the cross-type leak. New tables get the column from notificationColumns(Notification) once the columnMap declares it.
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" recipientType?: string; // recipient identity type column. Omit → ids are the only key 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.