Skip to content
Warlock.js v4.16.0

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 only
await inApp.countUnread(user); // cached — backs the badge
await inApp.find(user, "ntf_123"); // one — for a detail view
await inApp.markAsRead(user, "ntf_123"); // one
await inApp.markAsRead(user); // all unread for this user
await inApp.markAsUnread(user, "ntf_123");
await inApp.dismiss(user, "ntf_123"); // delete one
await inApp.dismiss(user); // clear all for this user

Every 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.

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.

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:

  • readAt only → unread is read_at IS NULL; marking read stamps it.
  • isRead only → unread is is_read = false; no timestamp.
  • both → is_read is the indexed filter flag, read_at records 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 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:

extra methods only
class NotificationsRepository extends BaseNotificationsRepository<Notification> {
public source = Notification; // its columnMap still drives the filter + write mapping
// …app-specific finders
}
inApp.configure({ repository: new NotificationsRepository() });

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}` });