@warlock.js/notifications 4.14.0 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/cjs/index.cjs +33 -3
- package/cjs/index.cjs.map +1 -1
- package/esm/in-app/base-notifications-repository.d.mts +15 -1
- package/esm/in-app/base-notifications-repository.d.mts.map +1 -1
- package/esm/in-app/base-notifications-repository.mjs +33 -3
- package/esm/in-app/base-notifications-repository.mjs.map +1 -1
- package/llms-full.txt +5 -1
- package/package.json +14 -11
- package/skills/send-ad-hoc/SKILL.md +1 -1
- package/skills/use-in-app/SKILL.md +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ All notable changes to `@warlock.js/notifications` are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
|
|
6
6
|
|
|
7
|
+
## 4.16.0 - 2026-08-18
|
|
8
|
+
|
|
9
|
+
### Security
|
|
10
|
+
|
|
11
|
+
- `BaseNotificationsRepository.createFor` no longer lets the channel payload override server-owned row keys. The payload was spread after the trusted `recipientId`, so an untyped caller (e.g. a payload assembled from request JSON, or `notify.channel(name).send`) carrying a `recipientId` key — or the model's physical recipient column name — could write the notification into another recipient's inbox, contradicting the recipient-scoping guarantee. Server-owned keys (`id`, `recipientId`, `tenant`, `readAt`, `isRead`, and their resolved physical columns) are now stripped from the payload at runtime and the trusted arguments are applied last; legitimate fields (`type`, `title`, `body`, `payload`, `idempotencyKey`) pass through unchanged. `createManyFor` inherits the fix.
|
|
12
|
+
|
|
7
13
|
## 4.12.0
|
|
8
14
|
|
|
9
15
|
### Changed
|
package/cjs/index.cjs
CHANGED
|
@@ -654,9 +654,39 @@ var BaseNotificationsRepository = class extends _warlock_js_core.RepositoryManag
|
|
|
654
654
|
return row;
|
|
655
655
|
}
|
|
656
656
|
/**
|
|
657
|
+
* Runtime backstop for the compile-time `DatabasePayload` `Omit`: untyped
|
|
658
|
+
* callers (`notify.channel(name).send`, payloads assembled from request
|
|
659
|
+
* JSON) can smuggle server-owned keys past the type system, and spreading
|
|
660
|
+
* them into the row would let a payload redirect the notification into
|
|
661
|
+
* another recipient's inbox (recipient spoofing), cross tenants, pre-set
|
|
662
|
+
* read-state, or clobber the primary key. Drops BOTH the logical names and
|
|
663
|
+
* the model's resolved physical columns — `toRow` passes unmapped keys
|
|
664
|
+
* through verbatim, so a payload carrying `user_id` would otherwise reach
|
|
665
|
+
* the recipient column directly.
|
|
666
|
+
*/
|
|
667
|
+
stripServerOwnedKeys(input) {
|
|
668
|
+
const { recipient, tenant, readAt, isRead } = this.columns;
|
|
669
|
+
const serverOwned = new Set([
|
|
670
|
+
"id",
|
|
671
|
+
"recipientId",
|
|
672
|
+
"tenant",
|
|
673
|
+
"readAt",
|
|
674
|
+
"isRead",
|
|
675
|
+
recipient,
|
|
676
|
+
tenant,
|
|
677
|
+
readAt,
|
|
678
|
+
isRead
|
|
679
|
+
]);
|
|
680
|
+
const safe = {};
|
|
681
|
+
for (const [key, value] of Object.entries(input)) if (!serverOwned.has(key)) safe[key] = value;
|
|
682
|
+
return safe;
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
657
685
|
* Create one row for the recipient. New rows start unread; the tenant column
|
|
658
686
|
* (when the model declares one) is written from `tenantId`, which the
|
|
659
|
-
* database channel reads off the recipient.
|
|
687
|
+
* database channel reads off the recipient. `recipientId` and the other
|
|
688
|
+
* server-owned keys always come from the trusted arguments — matching keys
|
|
689
|
+
* in `input` are stripped, never merged.
|
|
660
690
|
*
|
|
661
691
|
* Idempotency: when `input.idempotencyKey` is set, find-or-create — return
|
|
662
692
|
* the existing row instead of inserting a duplicate. A unique index on the
|
|
@@ -669,8 +699,8 @@ var BaseNotificationsRepository = class extends _warlock_js_core.RepositoryManag
|
|
|
669
699
|
if (existing) return existing;
|
|
670
700
|
}
|
|
671
701
|
const row = {
|
|
672
|
-
|
|
673
|
-
|
|
702
|
+
...this.stripServerOwnedKeys(input),
|
|
703
|
+
recipientId
|
|
674
704
|
};
|
|
675
705
|
if (this.columns.isRead) row.isRead = false;
|
|
676
706
|
if (this.columns.tenant && tenantId !== void 0) row.tenant = tenantId;
|
package/cjs/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":["RepositoryManager","Model"],"sources":["../../../../../../notifications/src/errors.ts","../../../../../../notifications/src/config.ts","../../../../../../notifications/src/dispatch/define-channel.ts","../../../../../../notifications/src/dispatch/dispatch-id.ts","../../../../../../notifications/src/dispatch/notifications-event-bus.ts","../../../../../../notifications/src/dispatch/dispatcher.ts","../../../../../../notifications/src/dispatch/define-notification.ts","../../../../../../notifications/src/dispatch/notify.ts","../../../../../../notifications/src/channels/database-channel.ts","../../../../../../notifications/src/channels/mail-channel.ts","../../../../../../notifications/src/in-app/column-map.ts","../../../../../../notifications/src/in-app/base-notifications-repository.ts","../../../../../../notifications/src/in-app/database-notification.ts","../../../../../../notifications/src/in-app/in-app.ts","../../../../../../notifications/src/migration/notification-columns.ts","../../../../../../notifications/src/queue/load-herald.ts","../../../../../../notifications/src/queue/herald-queue.ts","../../../../../../notifications/src/queue/notifications-worker.ts"],"sourcesContent":["/**\n * Domain errors for `@warlock.js/notifications`. All extend `Error` directly\n * (no framework HttpError) so the package stays runtime-agnostic — the host\n * app maps them to its own error shape via `instanceof` if needed.\n *\n * Each captures a clean V8 stack (drops the Error-constructor frame) to match\n * the cascade convention.\n */\n\nfunction captureStack(target: object, ctor: new (...args: any[]) => unknown): void {\n // V8 only; guarded for non-V8 runtimes.\n (Error as unknown as { captureStackTrace?: (t: object, c: unknown) => void }).captureStackTrace?.(\n target,\n ctor,\n );\n}\n\nexport class NotificationsNotConfiguredError extends Error {\n public constructor() {\n super(\n \"Notifications not configured — add `src/config/notifications.ts` with a \" +\n \"default-exported config; the notifications connector registers it at boot.\",\n );\n this.name = \"NotificationsNotConfiguredError\";\n captureStack(this, NotificationsNotConfiguredError);\n }\n}\n\nexport class ChannelNotFoundError extends Error {\n public constructor(channelName: string) {\n super(\n `Channel \"${channelName}\" is not configured. Add it to the ` +\n \"`channels` map in `src/config/notifications.ts`.\",\n );\n this.name = \"ChannelNotFoundError\";\n captureStack(this, ChannelNotFoundError);\n }\n}\n\nexport class NoQueueDispatcherError extends Error {\n public constructor(channelName: string) {\n super(\n `Cannot queue to channel \"${channelName}\" — no queue dispatcher configured. ` +\n \"Add a `queue` to `src/config/notifications.ts`, or call `.send()` instead of `.queue()`.\",\n );\n this.name = \"NoQueueDispatcherError\";\n captureStack(this, NoQueueDispatcherError);\n }\n}\n\nexport class MissingRendererError extends Error {\n public constructor(notificationType: string, channelName: string) {\n super(\n `Notification \"${notificationType}\" routes to channel \"${channelName}\" via ` +\n `\\`via\\` but defines no \\`${channelName}\\` renderer.`,\n );\n this.name = \"MissingRendererError\";\n captureStack(this, MissingRendererError);\n }\n}\n\n/**\n * Thrown when a channel's `route()` resolver runs but returns `undefined` for\n * a recipient (e.g. a mail recipient with no email). Distinct from \"the\n * channel declares no resolver\" — that case falls back to `{ id }`.\n */\nexport class UnresolvableRouteError extends Error {\n public constructor(channelName: string, recipientId?: unknown) {\n super(\n `Channel \"${channelName}\" could not resolve a route for recipient ` +\n `${recipientId ?? \"(unknown)\"}. The channel's \\`route()\\` returned undefined — ` +\n \"the recipient is missing the address column this channel needs.\",\n );\n this.name = \"UnresolvableRouteError\";\n captureStack(this, UnresolvableRouteError);\n }\n}\n","/**\n * Notification configuration holder — the runtime singleton the dispatch core\n * reads from. In a Warlock app you do NOT call `setNotificationConfig`\n * directly: `src/config/notifications.ts` exports a `NotificationConfig` as its\n * default, and the notifications connector calls this at boot. Tests (and\n * non-Warlock embeddings) may call it directly.\n *\n * @example src/config/notifications.ts — declarative; the connector registers it\n * import { type NotificationConfig, mailChannel, inApp } from \"@warlock.js/notifications\";\n * import { Notification } from \"app/notifications/notification.model\";\n *\n * const config: NotificationConfig = {\n * channels: {\n * mail: mailChannel({ from: \"no-reply@store.com\" }),\n * database: inApp.configure({ model: Notification }),\n * },\n * // preferences / rateLimit / queue all optional\n * };\n *\n * export default config;\n */\nimport type {\n Channel,\n PreferenceProvider,\n QueueDispatcher,\n RateLimiter,\n} from \"./contracts\";\nimport { NotificationsNotConfiguredError } from \"./errors\";\nimport type { ChannelName, NotificationChannels } from \"./types\";\n\n/**\n * Channel registry — each key is a registered channel name, each value the\n * channel whose payload type matches the registry. Typed as a partial map\n * over `ChannelName` so `setNotificationConfig` rejects a channel whose\n * payload doesn't line up with its registry entry, AND so a custom channel\n * (declaration-merged into `NotificationChannels`) is accepted with its real\n * payload type rather than `any`.\n */\nexport type ChannelMap = Partial<{\n [C in ChannelName]: Channel<NotificationChannels[C]>;\n}>;\n\n/**\n * Runtime configuration the dispatcher reads on every send. `queue`,\n * `preferences`, and `rateLimit` are all OPTIONAL slots — the package\n * reserves their shape but ships no defaults.\n */\nexport type NotificationConfig = {\n /** Channel registry — keys correlate to `NotificationChannels` payloads. */\n channels: ChannelMap;\n /** Async dispatcher backing `.queue()`. Phase 2 ships a herald impl. */\n queue?: QueueDispatcher;\n /** Pre-send gate: drops channels the recipient opted out of. */\n preferences?: PreferenceProvider;\n /** Pre-send gate: drops channels exceeding per-recipient budgets. */\n rateLimit?: RateLimiter;\n};\n\nlet activeConfig: NotificationConfig | undefined;\n\n/**\n * Set the active notifications configuration. In a Warlock app the\n * notifications connector calls this at boot with the default export of\n * `src/config/notifications.ts`. Subsequent calls REPLACE the active config\n * (does not merge) — pass the complete config, not a partial.\n */\nexport function setNotificationConfig(config: NotificationConfig): void {\n activeConfig = config;\n}\n\n/**\n * Get the active notifications configuration. Throws if not yet configured —\n * fail loudly at the first send rather than silently doing nothing.\n */\nexport function getNotificationConfig(): NotificationConfig {\n if (!activeConfig) {\n throw new NotificationsNotConfiguredError();\n }\n return activeConfig;\n}\n\n/**\n * Clear the active configuration. Intended for tests; not part of the runtime\n * surface. Production code should never call this.\n */\nexport function resetNotificationConfig(): void {\n activeConfig = undefined;\n}\n","import type { Channel } from \"../contracts\";\n\n/**\n * Identity helper that narrows the channel type to its payload. Use it when\n * you want TypeScript to enforce the `send` payload shape against the\n * payload type you declared.\n *\n * Built-in channels (`mail`, `database`) ship as factories; custom channels\n * are usually defined with this helper.\n *\n * @example\n * import { defineChannel } from \"@warlock.js/notifications\";\n *\n * type DiscordPayload = { content: string };\n *\n * export const discordChannel = () =>\n * defineChannel<DiscordPayload>({\n * name: \"discord\",\n * route: (n) => n.get(\"discord_webhook\"),\n * async send({ payload, route }) {\n * await fetch(route as string, {\n * method: \"POST\",\n * headers: { \"content-type\": \"application/json\" },\n * body: JSON.stringify(payload),\n * });\n * },\n * });\n *\n * // Then teach the registry:\n * declare module \"@warlock.js/notifications\" {\n * interface NotificationChannels { discord: DiscordPayload }\n * }\n */\nexport function defineChannel<P>(channel: Channel<P>): Channel<P> {\n return channel;\n}\n","import { randomUUID } from \"node:crypto\";\n\n/**\n * A unique id for one (channel, recipient) dispatch. `sending` and its terminal\n * `sent` / `failed` event share it so observers can pair them — for tracing\n * spans, latency, or spotting a `sending` with no terminal (a hung send).\n */\nexport function newDispatchId(): string {\n return randomUUID();\n}\n","/**\n * Typed event bus for notifications observability. Emits `sent`, `failed`,\n * and `skipped` events per (channel, recipient) — fan-out emits N events.\n *\n * Intentionally minimal — a `Map<event, Set<handler>>` keeps tests trivial\n * and avoids pulling event-emitter machinery for what is fundamentally\n * pub/sub. Handler exceptions are logged-and-swallowed so one bad listener\n * cannot break the dispatcher.\n *\n * @example\n * notifications.on(\"sent\", ({ channel }) => metrics.inc(`notif.${channel}.sent`));\n * notifications.on(\"skipped\", ({ reason }) => metrics.inc(`notif.skipped.${reason}`));\n * const off = notifications.on(\"failed\", logErr);\n * off(); // unsubscribe\n */\nimport { log } from \"@warlock.js/logger\";\nimport type { NotificationEvents } from \"../types\";\n\ntype EventName = keyof NotificationEvents;\ntype Handler<E extends EventName> = (data: NotificationEvents[E]) => void | Promise<void>;\n\nconst handlers: { [E in EventName]: Set<Handler<E>> } = {\n sending: new Set(),\n sent: new Set(),\n failed: new Set(),\n skipped: new Set(),\n};\n\n/**\n * Public observability surface.\n *\n * `on(event, handler)` returns an unsubscribe function. `off` exists for\n * symmetry with libraries that hold handler references for later removal.\n */\nexport const notifications = {\n on<E extends EventName>(event: E, handler: Handler<E>): () => void {\n handlers[event].add(handler as Handler<EventName>);\n return () => {\n handlers[event].delete(handler as Handler<EventName>);\n };\n },\n off<E extends EventName>(event: E, handler: Handler<E>): void {\n handlers[event].delete(handler as Handler<EventName>);\n },\n};\n\n/**\n * Internal emit — used by the dispatcher. Not exported from the package.\n * Handlers run in registration order; an awaited Promise.allSettled isolates\n * each listener so one throw cannot block the others.\n */\nexport async function emit<E extends EventName>(\n event: E,\n data: NotificationEvents[E],\n): Promise<void> {\n const set = handlers[event];\n if (set.size === 0) return;\n\n // The `async` wrapper turns SYNC handler throws into promise rejections so\n // `Promise.allSettled` can isolate them. Without it, a `throw` inside the\n // handler escapes the array map before allSettled gets a chance to catch.\n const results = await Promise.allSettled(\n Array.from(set).map(async (handler) => {\n await (handler as Handler<E>)(data);\n }),\n );\n\n for (const result of results) {\n if (result.status === \"rejected\") {\n log.error(\"notifications\", \"event-handler\", result.reason);\n }\n }\n}\n","/**\n * Internal dispatch core — shared by `defineNotification` and `notify.<channel>`.\n *\n * Responsibilities:\n * 1. Look up the channel in the configured registry (throws if missing).\n * 2. Run the rate-limit gate (if a notification type is known) — drop with a\n * `skipped` event when the limiter refuses.\n * 3. Resolve the route: raw string > channel.route(notifiable) > { id }.\n * 4. Inject the per-send `idempotencyKey` into the database payload.\n * 5. Dispatch (sync via channel.send OR async via queue.dispatch).\n * 6. Emit `sent` on success, `failed` on throw.\n *\n * Per-channel failure isolation is the CALLER's job (it wraps a list of\n * `dispatchChannel` calls in `Promise.allSettled`).\n */\nimport { getNotificationConfig } from \"../config\";\nimport { ChannelNotFoundError, NoQueueDispatcherError, UnresolvableRouteError } from \"../errors\";\nimport type { ChannelName, Id, Notifiable, SendOptions } from \"../types\";\nimport { newDispatchId } from \"./dispatch-id\";\nimport { emit } from \"./notifications-event-bus\";\n\nexport type DispatchMode = \"send\" | \"queue\";\n\nexport type DispatchChannelArgs = {\n channelName: string;\n payload: unknown;\n /** The recipient model — undefined when dispatching to a raw route string. */\n to: Notifiable | undefined;\n /** Raw route override (ad-hoc `notify.mail(\"x@y.com\", …)`). */\n rawRoute?: string;\n /** Notification type — drives the rate-limit gate. May be undefined. */\n notificationType: string | undefined;\n mode: DispatchMode;\n options: SendOptions;\n};\n\n/**\n * Dispatch ONE rendered payload to ONE recipient through ONE channel.\n * Does NOT consult `PreferenceProvider` — that gate runs at the caller level.\n *\n * Throws `ChannelNotFoundError` (channel not registered) and\n * `UnresolvableRouteError` (channel's `route()` returned undefined) — both are\n * configuration/data errors that should surface loudly. Transport failures\n * (channel.send throwing) are emitted as `failed` AND rethrown.\n */\nexport async function dispatchChannel(args: DispatchChannelArgs): Promise<void> {\n const config = getNotificationConfig();\n const channel = config.channels[args.channelName as ChannelName];\n\n if (!channel) {\n throw new ChannelNotFoundError(args.channelName);\n }\n\n // One id for this (channel, recipient) dispatch — `sending` and its terminal\n // `sent` / `failed` share it so observers can pair them.\n const dispatchId = newDispatchId();\n\n // Rate-limit gate. Only applies when we have a notifiable + a notification\n // type (otherwise there's nothing to rate-limit on). `force` does NOT\n // bypass — rate limits are a safety valve, not a UX preference.\n //\n // NOTE (Phase-2 seam): for mode \"queue\" the budget is consumed at ENQUEUE\n // time here, not at delivery. When the herald worker lands it should run\n // this gate at delivery instead; see RateLimiter docstring.\n if (args.to && args.notificationType && config.rateLimit) {\n const allowed = await config.rateLimit.allow(\n args.to,\n args.channelName as ChannelName,\n args.notificationType,\n );\n\n if (!allowed) {\n await emit(\"skipped\", {\n dispatchId,\n channel: args.channelName,\n notifiable: args.to,\n reason: \"rate-limit\",\n options: args.options,\n });\n return;\n }\n }\n\n const route = resolveRoute(args, channel.route);\n\n // Inject idempotency key into the database payload (so the repo can dedupe).\n let payload = args.payload;\n if (\n args.channelName === \"database\" &&\n args.options.idempotencyKey &&\n payload &&\n typeof payload === \"object\"\n ) {\n payload = { ...(payload as object), idempotencyKey: args.options.idempotencyKey };\n }\n\n // `sending` fires after the gates pass + the route resolves, BEFORE the\n // transport — awaited so it always precedes the send. Observers can't abort\n // the send, but a slow handler delays it; keep them fast.\n await emit(\"sending\", {\n dispatchId,\n channel: args.channelName,\n notifiable: args.to,\n payload,\n options: args.options,\n });\n\n const startedAt = Date.now();\n\n try {\n if (args.mode === \"queue\") {\n if (!config.queue) {\n throw new NoQueueDispatcherError(args.channelName);\n }\n await config.queue.dispatch({\n channel: args.channelName,\n route,\n payload,\n options: args.options,\n });\n } else {\n await channel.send({\n payload: payload as never,\n route,\n notifiable: args.to,\n options: args.options,\n });\n }\n\n await emit(\"sent\", {\n dispatchId,\n channel: args.channelName,\n notifiable: args.to,\n payload,\n options: args.options,\n durationMs: Date.now() - startedAt,\n });\n } catch (error) {\n await emit(\"failed\", {\n dispatchId,\n channel: args.channelName,\n notifiable: args.to,\n payload,\n error: error as Error,\n options: args.options,\n durationMs: Date.now() - startedAt,\n });\n throw error;\n }\n}\n\n/**\n * Route resolution priority:\n * 1. raw route string (ad-hoc `notify.mail(\"x@y.com\", …)`)\n * 2. the channel's `route(notifiable)` resolver\n * 3. `{ id: notifiable.id }` — ONLY when the channel declares no resolver\n *\n * When a channel HAS a resolver but it returns undefined (e.g. a mail\n * recipient with no email), we throw `UnresolvableRouteError` rather than\n * silently coercing to `{ id }` — that would hand a string-route channel an\n * object and fail deep inside the transport.\n */\nfunction resolveRoute(\n args: DispatchChannelArgs,\n resolver: ((notifiable: Notifiable) => string | { id: Id } | undefined) | undefined,\n): string | { id: Id } {\n if (args.rawRoute !== undefined) {\n return args.rawRoute;\n }\n\n if (!args.to) {\n throw new UnresolvableRouteError(args.channelName);\n }\n\n // No resolver declared → the channel addresses by id (database/internal).\n if (!resolver) {\n return { id: args.to.id };\n }\n\n const resolved = resolver(args.to);\n\n if (resolved === undefined) {\n throw new UnresolvableRouteError(args.channelName, args.to.id);\n }\n\n return resolved;\n}\n","/**\n * `defineNotification` — reusable, type-safe, multi-channel notification.\n *\n * Functional, not class-based: pass `via` + a renderer per channel + a\n * stable `type`. The returned object exposes `.send` / `.queue` / `.only`.\n *\n * Renderer signature is `(data, to, ctx)` where `ctx` carries `locale` and\n * `meta` from `SendOptions`. The database renderer's `type` is OPTIONAL — the\n * dispatcher injects `def.type`, keeping the type defined in ONE place.\n *\n * ## Error policy\n * - **Config errors** (missing renderer, no queue dispatcher, unknown channel)\n * are programmer mistakes → they REJECT `.send()`/`.queue()` (and a static\n * `via` with a missing renderer throws at definition time). Consistent with\n * `notify`, which rejects `ChannelNotFoundError`.\n * - **Transport errors** (a channel's `send` throwing) stay ISOLATED — emitted\n * as a `failed` event, never aborting sibling channels/recipients.\n * - **`via()` / preferences throws** for one recipient are logged and that\n * recipient is dropped (the rest of a fan-out still go).\n *\n * @example\n * export const orderShipped = defineNotification<{ order: Order }>({\n * type: \"order.shipped\",\n * via: [\"mail\", \"database\"],\n * mail: ({ order }, to) => ({ subject: `#${order.number} shipped`, html: \"…\" }),\n * database: ({ order }) => ({ title: \"Your order shipped\", payload: { id: order.id } }),\n * });\n * await orderShipped.send(user, { order });\n * await orderShipped.queue([buyer, salesRep], { order }, { delay: \"10m\" });\n * await orderShipped.only(\"mail\").send(user, { order });\n */\nimport { log } from \"@warlock.js/logger\";\nimport { getNotificationConfig } from \"../config\";\nimport {\n ChannelNotFoundError,\n MissingRendererError,\n NoQueueDispatcherError,\n} from \"../errors\";\nimport type {\n ChannelName,\n Notifiable,\n NotificationChannels,\n RenderContext,\n SendOptions,\n} from \"../types\";\nimport { newDispatchId } from \"./dispatch-id\";\nimport { dispatchChannel, type DispatchMode } from \"./dispatcher\";\nimport { emit } from \"./notifications-event-bus\";\n\n/**\n * Per-channel renderer return type. The `database` renderer may OMIT `type`\n * — it inherits from `NotificationDef.type` at dispatch time.\n */\ntype RendererReturn<C extends ChannelName> = C extends \"database\"\n ? Omit<NotificationChannels[\"database\"], \"type\">\n : NotificationChannels[C];\n\ntype Renderer<Data, C extends ChannelName> = (\n data: Data,\n to: Notifiable,\n ctx: RenderContext,\n) => RendererReturn<C>;\n\nexport type NotificationDef<Data> = {\n type: string;\n via: ChannelName[] | ((data: Data, to: Notifiable) => ChannelName[]);\n} & {\n [C in ChannelName]?: Renderer<Data, C>;\n};\n\nexport interface DefinedNotification<Data> {\n send(to: Notifiable | Notifiable[], data: Data, options?: SendOptions): Promise<void>;\n queue(to: Notifiable | Notifiable[], data: Data, options?: SendOptions): Promise<void>;\n only(...channels: ChannelName[]): DefinedNotification<Data>;\n}\n\n/** Errors that represent a misconfiguration and must surface (reject), not isolate. */\nfunction isConfigError(error: unknown): boolean {\n return (\n error instanceof MissingRendererError ||\n error instanceof NoQueueDispatcherError ||\n error instanceof ChannelNotFoundError\n );\n}\n\nexport function defineNotification<Data>(def: NotificationDef<Data>): DefinedNotification<Data> {\n const make = (restrict?: ChannelName[]): DefinedNotification<Data> => {\n // Validate at definition time when the channel set is static — a missing\n // renderer is a programmer error, surfaced loudly at import.\n if (Array.isArray(def.via)) {\n const channels = restrict ? def.via.filter((c) => restrict.includes(c)) : def.via;\n for (const channel of channels) {\n if (!def[channel]) {\n throw new MissingRendererError(def.type, channel);\n }\n }\n }\n\n return {\n send(to, data, options) {\n return dispatchAll(def, to, data, options ?? {}, \"send\", restrict);\n },\n queue(to, data, options) {\n return dispatchAll(def, to, data, options ?? {}, \"queue\", restrict);\n },\n only(...channels) {\n return make(channels);\n },\n };\n };\n\n return make();\n}\n\n/**\n * Fan-out to all recipients. Each recipient dispatches independently; one\n * recipient's transport failure never aborts the others. CONFIG errors,\n * however, propagate — they reject the returned promise.\n */\nasync function dispatchAll<Data>(\n def: NotificationDef<Data>,\n to: Notifiable | Notifiable[],\n data: Data,\n options: SendOptions,\n mode: DispatchMode,\n restrict?: ChannelName[],\n): Promise<void> {\n const recipients = Array.isArray(to) ? to : [to];\n\n const results = await Promise.allSettled(\n recipients.map((recipient) =>\n dispatchToRecipient(def, recipient, data, options, mode, restrict),\n ),\n );\n\n rethrowConfigErrors(results);\n}\n\nasync function dispatchToRecipient<Data>(\n def: NotificationDef<Data>,\n to: Notifiable,\n data: Data,\n options: SendOptions,\n mode: DispatchMode,\n restrict?: ChannelName[],\n): Promise<void> {\n const config = getNotificationConfig();\n\n // Resolve channels (via + preferences). A throw here drops THIS recipient\n // with a log — it's a per-recipient data problem, not a config error.\n let channels: ChannelName[];\n try {\n channels = typeof def.via === \"function\" ? def.via(data, to) : [...def.via];\n\n if (restrict) {\n channels = channels.filter((c) => restrict.includes(c));\n }\n\n if (!options.force && config.preferences) {\n const allowed = await config.preferences.resolveChannels(to, def.type, channels);\n for (const channel of channels.filter((c) => !allowed.includes(c))) {\n await emit(\"skipped\", {\n dispatchId: newDispatchId(),\n channel,\n notifiable: to,\n reason: \"preference\",\n options,\n });\n }\n channels = channels.filter((c) => allowed.includes(c));\n }\n } catch (error) {\n log.error(\"notifications\", `resolve.${def.type}`, error as Error);\n return;\n }\n\n const renderCtx: RenderContext = { locale: options.locale, meta: options.meta };\n\n const results = await Promise.allSettled(\n channels.map((name) => {\n const renderer = def[name] as Renderer<Data, ChannelName> | undefined;\n if (!renderer) {\n // Dynamic via chose a channel with no renderer — surface it.\n return Promise.reject(new MissingRendererError(def.type, name));\n }\n\n let payload: unknown = renderer(data, to, renderCtx);\n\n // Default the database channel's `type` from `def.type` when omitted.\n if (name === \"database\" && payload && typeof payload === \"object\" && !(\"type\" in payload)) {\n payload = { ...(payload as object), type: def.type };\n }\n\n return dispatchChannel({\n channelName: name,\n payload,\n to,\n notificationType: def.type,\n mode,\n options,\n });\n }),\n );\n\n rethrowConfigErrors(results);\n}\n\n/** Collect config-class rejections and rethrow (single or AggregateError). */\nfunction rethrowConfigErrors(results: PromiseSettledResult<unknown>[]): void {\n const configErrors = results\n .filter((r): r is PromiseRejectedResult => r.status === \"rejected\" && isConfigError(r.reason))\n .map((r) => r.reason);\n\n if (configErrors.length === 1) {\n throw configErrors[0];\n }\n if (configErrors.length > 1) {\n throw new AggregateError(configErrors, \"Multiple notification configuration errors\");\n }\n}\n","/**\n * `notify` — ad-hoc, per-channel send facade.\n *\n * A Proxy keyed by the channel registry. `notify.<channel>(to, payload,\n * options?)` works for ANY channel name registered in\n * `NotificationChannels` — built-in or custom (via declaration merging).\n *\n * For multi-channel sends, use `defineNotification` — that's the reusable\n * pattern and the only mental model for \"send through several channels at\n * once\" (no inline multi-channel overload here on purpose).\n *\n * `notify.channel(name)` is the runtime escape when the channel name isn't a\n * compile-time literal.\n *\n * @example\n * await notify.mail(user, { subject: \"Welcome\", html: \"<p>Hi!</p>\" });\n * await notify.database(user, { type: \"welcome\", title: \"Welcome!\" });\n * await notify.mail(\"guest@example.com\", { subject: \"…\", html: \"…\" }); // raw target\n * await notify.channel(dynamicName).send(user, payload); // dynamic name\n */\nimport { getNotificationConfig } from \"../config\";\nimport type { ChannelName, Notifiable, NotificationChannels, SendOptions } from \"../types\";\nimport { newDispatchId } from \"./dispatch-id\";\nimport { dispatchChannel } from \"./dispatcher\";\nimport { emit } from \"./notifications-event-bus\";\n\n/**\n * Public shape of `notify`. The mapped portion gives `notify.<channel>` per\n * registered channel; `channel(name)` is the runtime escape.\n */\nexport type Notify = {\n channel(name: string): {\n send(\n to: Notifiable | string,\n payload: unknown,\n options?: SendOptions,\n ): Promise<void>;\n };\n} & {\n [C in ChannelName]: (\n to: Notifiable | string,\n payload: NotificationChannels[C],\n options?: SendOptions,\n ) => Promise<void>;\n};\n\n/**\n * Per-call dispatch for `notify.<channel>`. Mirrors `defineNotification`'s\n * pipeline but without rendering: payload is already concrete.\n *\n * Preferences gate runs ONLY when a notification `type` is known — explicit\n * via `options.type`, or implicit from a database payload's `type` field.\n * Without a type we have nothing to gate on, and ad-hoc sends bypass\n * preferences silently (use `defineNotification` if you want guaranteed\n * gating).\n */\nasync function notifyImpl(\n channelName: string,\n to: Notifiable | string,\n payload: unknown,\n options: SendOptions = {},\n): Promise<void> {\n const isRawTarget = typeof to === \"string\";\n const notifiable: Notifiable | undefined = isRawTarget ? undefined : to;\n\n // Derive the type: explicit `options.type` wins, then the payload's\n // `type` field (database channel convention).\n let notificationType: string | undefined = options.type;\n if (\n notificationType === undefined &&\n payload &&\n typeof payload === \"object\" &&\n \"type\" in payload &&\n typeof (payload as { type: unknown }).type === \"string\"\n ) {\n notificationType = (payload as { type: string }).type;\n }\n\n // Preferences gate — only when notifiable + type are both available.\n if (notifiable && notificationType && !options.force) {\n const config = getNotificationConfig();\n\n if (config.preferences) {\n const allowed = await config.preferences.resolveChannels(\n notifiable,\n notificationType,\n [channelName as ChannelName],\n );\n\n if (!allowed.includes(channelName as ChannelName)) {\n await emit(\"skipped\", {\n dispatchId: newDispatchId(),\n channel: channelName,\n notifiable,\n reason: \"preference\",\n options,\n });\n return;\n }\n }\n }\n\n await dispatchChannel({\n channelName,\n payload,\n to: notifiable,\n rawRoute: isRawTarget ? to : undefined,\n notificationType,\n mode: \"send\",\n options,\n });\n}\n\n/**\n * Build the `notify` proxy. `channel(name)` returns a `{ send }` object;\n * any other property access returns a one-shot dispatcher for that channel.\n */\nexport const notify: Notify = new Proxy({} as Notify, {\n get(_target, prop) {\n if (typeof prop === \"symbol\") {\n return undefined;\n }\n\n // Don't masquerade as a thenable — awaiting or Promise-wrapping `notify`\n // must NOT invoke notify.then(...) as a channel send. Also keep common\n // serialization probes from being treated as channel names.\n if (prop === \"then\" || prop === \"catch\" || prop === \"finally\" || prop === \"toJSON\") {\n return undefined;\n }\n\n if (prop === \"channel\") {\n return (name: string) => ({\n send: (to: Notifiable | string, payload: unknown, options?: SendOptions) =>\n notifyImpl(name, to, payload, options),\n });\n }\n\n return (to: Notifiable | string, payload: unknown, options?: SendOptions) =>\n notifyImpl(prop, to, payload, options);\n },\n});\n","/**\n * `databaseChannel` — persists notifications via the bound repository.\n *\n * NOT created by the dev directly — `inApp.configure({ model })` builds it\n * and returns it. That way the read side (`inApp.listUnread`, etc.) and the\n * write side (this channel's `send`) share ONE repository instance, which\n * means cache invalidation just works.\n *\n * Route is `{ id: notifiable.id }` — the recipient's id, used to set the\n * recipient column on the persisted row.\n *\n * Multi-tenant: when the model declares a `tenant` column, this channel reads\n * the tenant value off the recipient model (`notifiable.get(tenantColumn)`)\n * and hands it to `createFor`. The recipient model is only present on the\n * synchronous send path; a tenant-scoped store should send (not queue) the\n * database channel so the recipient — and thus the tenant — is in scope.\n *\n * The dispatcher has already injected `SendOptions.idempotencyKey` into the\n * payload (when set), so `createFor` receives a payload that includes it.\n */\nimport type { NotificationContract } from \"../contracts\";\nimport type { Channel } from \"../contracts\";\nimport { defineChannel } from \"../dispatch/define-channel\";\n// Type-only import to avoid a runtime cycle with `in-app/in-app.ts`.\nimport type { BaseNotificationsRepository } from \"../in-app/base-notifications-repository\";\nimport type { DatabasePayload, Id } from \"../types\";\n\nexport function databaseChannel<TModel extends NotificationContract = NotificationContract>(\n repo: BaseNotificationsRepository<TModel>,\n): Channel<DatabasePayload> {\n return defineChannel<DatabasePayload>({\n name: \"database\",\n route: (notifiable) => ({ id: notifiable.id }),\n async send({ payload, route, notifiable }) {\n const id: Id = typeof route === \"object\" ? route.id : route;\n\n const { tenantColumn } = repo;\n const tenantId =\n tenantColumn && notifiable ? (notifiable.get(tenantColumn) as Id) : undefined;\n\n await repo.createFor(id, payload, tenantId);\n },\n });\n}\n","/**\n * `mailChannel` — wraps `@warlock.js/core` `sendMail`.\n *\n * Route is `notifiable.email` by convention; override via `config.route`\n * when the column is named differently or computed.\n *\n * The renderer's payload is forwarded verbatim (`subject`, `html`, `text`,\n * `cc`). `from` is set once at channel-config time; per-send overrides go\n * through the payload itself (you can include `from` in `MailPayload` if\n * you extend the registry — but the convention is one channel = one\n * sender identity).\n *\n * @example\n * // src/config/notifications.ts\n * channels: {\n * mail: mailChannel({ from: \"no-reply@store.com\" }),\n * }\n *\n * // anywhere\n * await notify.mail(user, { subject: \"Welcome\", html: \"<p>Hi!</p>\" });\n */\nimport { sendMail } from \"@warlock.js/core\";\nimport type { Channel } from \"../contracts\";\nimport { defineChannel } from \"../dispatch/define-channel\";\nimport type { MailPayload, Notifiable } from \"../types\";\n\nexport type MailChannelConfig = {\n /** Default `from` address for every send through this channel. */\n from?: string;\n /** Route resolver. Defaults to `notifiable.get(\"email\")`. */\n route?: (notifiable: Notifiable) => string;\n};\n\nexport function mailChannel(config: MailChannelConfig = {}): Channel<MailPayload> {\n const resolveRoute = config.route ?? ((n) => n.get(\"email\") as string);\n\n return defineChannel<MailPayload>({\n name: \"mail\",\n route: (notifiable) => resolveRoute(notifiable),\n async send({ payload, route }) {\n await sendMail({\n to: route as string,\n from: config.from,\n ...payload,\n });\n },\n });\n}\n","/**\n * `columnMap` — how a notification model's logical roles map to the physical\n * columns of ITS table. The ONE source the model getters, the repository\n * (read filter + write mapping), the database channel, and the migration\n * factory all read, so they can never drift apart.\n *\n * Declared as a `static columnMap` on the model and ejected to userland, so\n * the dev owns it:\n *\n * @RegisterModel()\n * export class Notification extends DatabaseNotification {\n * public static table = \"notifications\";\n * public static columnMap: NotificationColumnMap = {\n * tenant: \"organization_id\", // omit → single-tenant\n * readAt: \"read_at\", // read-state (see below)\n * };\n * }\n *\n * ## Read-state is chosen by which keys are PRESENT\n *\n * - `readAt` only → unread = `read_at IS NULL`; marking read stamps it.\n * - `isRead` only → unread = `is_read = false`; no timestamp.\n * - both → `is_read` is the indexed flag used for filtering, `read_at`\n * records WHEN; marking read sets both.\n *\n * Declare neither and you get the `readAt`-only default — so a model can omit\n * `columnMap` entirely and still work.\n */\n\n/**\n * The logical → physical column bindings a notification model may declare.\n * Every key is optional; `resolveColumnMap` fills the defaults. Values are the\n * app's real column names (rename-friendly: `readAt: \"seen_at\"`).\n */\nexport type NotificationColumnMap = {\n /** Recipient FK column. Default: `\"user_id\"`. */\n recipient?: string;\n /** Multi-tenant scope column, written from the recipient. Omit → single-tenant. */\n tenant?: string;\n /** Read-timestamp column. Present → read-state records \"when\". */\n readAt?: string;\n /** Read-flag column (indexed → fast unread filter). Present → read-state is a boolean. */\n isRead?: string;\n};\n\n/**\n * A `NotificationColumnMap` with the defaults applied. `recipient` is always\n * set, and at least one of `readAt` / `isRead` is always present (the resolver\n * falls back to `readAt`), so consumers never face a \"no read-state\" map.\n */\nexport type ResolvedNotificationColumnMap = {\n recipient: string;\n tenant?: string;\n readAt?: string;\n isRead?: string;\n};\n\n/** A model class that may carry the `columnMap` static. */\nexport type NotificationColumnMapHost = {\n columnMap?: NotificationColumnMap;\n};\n\nconst DEFAULT_RECIPIENT_COLUMN = \"user_id\";\nconst DEFAULT_READ_AT_COLUMN = \"read_at\";\n\n/**\n * Resolve a model's declared `columnMap` into a complete map: default the\n * recipient column, and fall back to a `read_at` timestamp when the model\n * declares no read-state column. The fallback guarantees the result always\n * has a usable read-state representation, so no boot-time validation is needed.\n */\nexport function resolveColumnMap(\n map: NotificationColumnMap | undefined,\n): ResolvedNotificationColumnMap {\n const recipient = map?.recipient ?? DEFAULT_RECIPIENT_COLUMN;\n const tenant = map?.tenant;\n\n if (!map?.readAt && !map?.isRead) {\n return { recipient, tenant, readAt: DEFAULT_READ_AT_COLUMN };\n }\n\n return {\n recipient,\n tenant,\n readAt: map.readAt,\n isRead: map.isRead,\n };\n}\n","/**\n * Shipped behavior repository for the in-app store.\n *\n * Concrete (NOT abstract) so `inApp.configure({ model })` can\n * `new BaseNotificationsRepository(model)` without subclassing. The dev only\n * extends this when they need extra query methods.\n *\n * ## The single source of truth: the model's `columnMap`\n *\n * The model declares its physical columns ONCE via `static columnMap`. The\n * constructor resolves that map and derives BOTH sides from it:\n *\n * - the READ path — `filterBy` maps logical keys (`recipientId`, `unread`,\n * `type`, …) onto the model's columns; `RepositoryManager` applies it.\n * - the WRITE / UPDATE / DELETE path — `createFor` / `markRead` / … translate\n * logical keys through the same `filterBy` via `toRow`.\n *\n * Because both come from one `columnMap`, reads and writes can never target\n * different columns. `unread` is mode-agnostic: it filters `is_read = false`\n * when the model has that flag, otherwise `read_at IS NULL`.\n *\n * @example default (SQL snake_case — no subclass needed)\n * inApp.configure({ model: Notification })\n *\n * @example custom columns — declare them on the model, not here\n * class Notification extends DatabaseNotification {\n * public static columnMap = { recipient: \"audience_id\", isRead: \"seen\" };\n * }\n */\nimport { type FilterRules, RepositoryManager } from \"@warlock.js/core\";\nimport type { NotificationContract } from \"../contracts\";\nimport type { DatabasePayload, Id } from \"../types\";\nimport {\n type NotificationColumnMapHost,\n type ResolvedNotificationColumnMap,\n resolveColumnMap,\n} from \"./column-map\";\nimport type { DatabaseNotification } from \"./database-notification\";\n\n/**\n * Filter shape — logical keys the repo understands. `filterBy` maps each to a\n * physical column (or a predicate) derived from the model's `columnMap`.\n */\nexport type NotificationsFilter = {\n id?: Id;\n type?: string;\n /** Mode-agnostic unread filter — resolves to the model's read-state column. */\n unread?: boolean;\n recipientId?: Id;\n idempotencyKey?: string;\n};\n\n/** Class form of a `DatabaseNotification` subclass — what `configure()` accepts. */\nexport type NotificationModelClass = (new (...args: any[]) => DatabaseNotification) &\n NotificationColumnMapHost;\n\nexport class BaseNotificationsRepository<\n TModel extends NotificationContract = NotificationContract,\n> extends RepositoryManager<TModel, NotificationsFilter> {\n /** Resolved physical columns for the bound model — the single source. */\n private readonly columns: ResolvedNotificationColumnMap;\n\n public filterBy: FilterRules;\n\n public constructor(model?: NotificationModelClass) {\n super();\n\n if (model) {\n this.source = model;\n }\n\n this.columns = resolveColumnMap(model?.columnMap);\n this.filterBy = this.buildFilterBy();\n }\n\n /** The tenant column for this model, or `undefined` for single-tenant. */\n public get tenantColumn(): string | undefined {\n return this.columns.tenant;\n }\n\n /**\n * Build `filterBy` from the resolved column map. `unread` is the\n * mode-agnostic read filter; `isRead` / `readAt` / `tenant` are exposed for\n * direct filtering only when the model declares those columns.\n */\n private buildFilterBy(): FilterRules {\n const { recipient, tenant, readAt, isRead } = this.columns;\n\n const rules: FilterRules = {\n id: \"=\",\n type: \"=\",\n recipientId: [\"=\", recipient],\n idempotencyKey: [\"=\", \"idempotency_key\"],\n unread: (value, query) => {\n if (value !== true && value !== \"true\") {\n return;\n }\n\n if (isRead) {\n query.where(isRead, false);\n } else if (readAt) {\n query.whereNull(readAt);\n }\n },\n };\n\n if (isRead) {\n rules.isRead = [\"=\", isRead];\n }\n\n if (readAt) {\n rules.readAt = [\"=\", readAt];\n }\n\n if (tenant) {\n rules.tenant = [\"=\", tenant];\n }\n\n return rules;\n }\n\n /**\n * Translate an object keyed by LOGICAL names into one keyed by PHYSICAL\n * columns, using `filterBy` as the lookup. Keys with no `[op, column]` tuple\n * (e.g. `title`, `body`, `payload`, `type`, `id`) pass through unchanged —\n * they're already physical. Used for every write / update / delete payload +\n * filter (the paths `RepositoryManager` does NOT map).\n */\n protected toRow(obj: Record<string, unknown>): Record<string, unknown> {\n const rules = this.filterBy as Record<string, unknown>;\n const row: Record<string, unknown> = {};\n\n for (const [logical, value] of Object.entries(obj)) {\n const rule = rules[logical];\n const column = Array.isArray(rule) ? (rule[1] as string) : logical;\n row[column] = value;\n }\n\n return row;\n }\n\n /**\n * Create one row for the recipient. New rows start unread; the tenant column\n * (when the model declares one) is written from `tenantId`, which the\n * database channel reads off the recipient.\n *\n * Idempotency: when `input.idempotencyKey` is set, find-or-create — return\n * the existing row instead of inserting a duplicate. A unique index on the\n * key is the backstop for the rare insert race (we re-fetch on conflict).\n */\n public async createFor(\n recipientId: Id,\n input: DatabasePayload,\n tenantId?: Id,\n ): Promise<TModel> {\n const { idempotencyKey } = input;\n\n if (idempotencyKey) {\n const existing = await this.firstByIdempotencyKey(recipientId, idempotencyKey);\n if (existing) {\n return existing;\n }\n }\n\n const row: Record<string, unknown> = { recipientId, ...input };\n\n if (this.columns.isRead) {\n row.isRead = false;\n }\n\n if (this.columns.tenant && tenantId !== undefined) {\n row.tenant = tenantId;\n }\n\n try {\n return await this.create(this.toRow(row));\n } catch (error) {\n // Lost the insert race to a concurrent send with the same key → re-fetch.\n if (idempotencyKey) {\n const existing = await this.firstByIdempotencyKey(recipientId, idempotencyKey);\n if (existing) {\n return existing;\n }\n }\n\n throw error;\n }\n }\n\n /**\n * Bulk-create one row per recipient sharing the same input + tenant. Reserved\n * for the Phase-2 `Channel.sendMany` fan-out hook; not on the per-recipient\n * send path yet.\n */\n public createManyFor(\n recipientIds: Id[],\n input: DatabasePayload,\n tenantId?: Id,\n ): Promise<TModel[]> {\n return Promise.all(\n recipientIds.map((recipientId) => this.createFor(recipientId, input, tenantId)),\n );\n }\n\n /**\n * Mark rows read — recipient-scoped. `id` omitted → all unread rows for the\n * recipient; `id` given → that one row, still scoped to the recipient. Scoped\n * to unread so a re-mark never overwrites an earlier `read_at` timestamp.\n * Sets whichever read-state column(s) the model declares.\n */\n public markRead(recipientId: Id, id?: Id): Promise<number> {\n const { isRead, readAt } = this.columns;\n const unreadScope = isRead ? { isRead: false } : { readAt: null };\n\n const data: Record<string, unknown> = {};\n\n if (isRead) {\n data.isRead = true;\n }\n\n if (readAt) {\n data.readAt = new Date();\n }\n\n return this.updateMany(\n this.toRow({ recipientId, ...unreadScope, ...(id !== undefined ? { id } : {}) }),\n this.toRow(data),\n );\n }\n\n /**\n * Inverse of `markRead`, same recipient-scoping. No unread scope needed —\n * clearing the read-state of an already-unread row is a no-op.\n */\n public markUnread(recipientId: Id, id?: Id): Promise<number> {\n const { isRead, readAt } = this.columns;\n const data: Record<string, unknown> = {};\n\n if (isRead) {\n data.isRead = false;\n }\n\n if (readAt) {\n data.readAt = null;\n }\n\n return this.updateMany(\n this.toRow({ recipientId, ...(id !== undefined ? { id } : {}) }),\n this.toRow(data),\n );\n }\n\n /** Find one row for a recipient (read path — `filterBy` maps the keys). */\n public findFor(recipientId: Id, id: Id): Promise<TModel | null> {\n return this.first({ recipientId, id } as NotificationsFilter);\n }\n\n /**\n * Delete rows — recipient-scoped. `id` omitted → clear all for the\n * recipient; `id` given → that one row.\n */\n public deleteFor(recipientId: Id, id?: Id): Promise<number> {\n return this.deleteMany(this.toRow({ recipientId, ...(id !== undefined ? { id } : {}) }));\n }\n\n /** Read path — `filterBy` maps `recipientId`/`idempotencyKey` to columns. */\n private firstByIdempotencyKey(recipientId: Id, idempotencyKey: string): Promise<TModel | null> {\n return this.first({ recipientId, idempotencyKey } as NotificationsFilter);\n }\n}\n","/**\n * Shipped BASE in-app notification model. The scaffolded user model\n * `extends DatabaseNotification`, sets `static table`, and declares its\n * physical columns ONCE via `static columnMap`. Every accessor below derives\n * from that map, so the model, the repository, and the migration agree by\n * construction.\n *\n * The package ships NO concrete table or migration — those eject to userland\n * (thin eject). See `notificationColumns()` for the matching migration factory.\n *\n * @example\n * @RegisterModel()\n * export class Notification extends DatabaseNotification {\n * public static table = \"notifications\";\n * public static columnMap: NotificationColumnMap = {\n * tenant: \"organization_id\",\n * readAt: \"read_at\",\n * };\n * }\n */\nimport { Model } from \"@warlock.js/cascade\";\nimport type { NotificationContract } from \"../contracts\";\nimport type { Id } from \"../types\";\nimport {\n type NotificationColumnMap,\n type ResolvedNotificationColumnMap,\n resolveColumnMap,\n} from \"./column-map\";\n\nexport abstract class DatabaseNotification extends Model implements NotificationContract {\n /**\n * Physical column bindings for THIS model's table. Override in the subclass;\n * the empty default resolves to `{ recipient: \"user_id\", readAt: \"read_at\" }`.\n */\n public static columnMap: NotificationColumnMap = {};\n\n /** The resolved column map for this row's model — defaults applied. */\n protected get columns(): ResolvedNotificationColumnMap {\n return resolveColumnMap((this.constructor as typeof DatabaseNotification).columnMap);\n }\n\n public get recipientId(): Id {\n return this.get(this.columns.recipient);\n }\n\n /** Tenant scope value, or `undefined` for single-tenant models. */\n public get tenantId(): Id | undefined {\n const { tenant } = this.columns;\n return tenant ? this.get(tenant) : undefined;\n }\n\n public get type(): string {\n return this.get(\"type\");\n }\n\n public get isRead(): boolean {\n const { isRead, readAt } = this.columns;\n\n if (isRead) {\n return Boolean(this.get(isRead));\n }\n\n return readAt ? this.get(readAt) != null : false;\n }\n\n public get readAt(): Date | null {\n const { readAt } = this.columns;\n return readAt ? (this.get(readAt) ?? null) : null;\n }\n\n /**\n * Mark this row read. Sets whichever read-state column(s) the model\n * declares — the boolean (fast unread filtering) and/or the timestamp\n * (when it was read). Persists via `save()`.\n */\n public async markRead(): Promise<void> {\n const { isRead, readAt } = this.columns;\n\n if (isRead) {\n this.set(isRead, true);\n }\n\n if (readAt) {\n this.set(readAt, new Date());\n }\n\n await this.save();\n }\n}\n","/**\n * `inApp` — app-facing facade for the in-app/database channel.\n *\n * `inApp.configure({ model | repository })` binds the in-app store AND\n * returns the `database` channel — so the read side and the dispatched\n * write side share ONE repository instance.\n * • `{ model: Notification }` — 90% case; default repo built internally.\n * • `{ repository: myRepo }` — 10% case; custom column mapping / extras.\n *\n * No zero-arg form: the package ships no concrete model/table (thin eject),\n * so the dev MUST tell us which model to use.\n *\n * Every read + mutation is RECIPIENT-SCOPED by construction — `markAsRead(user,\n * id)` cannot flip a row belonging to a different recipient even if handed a\n * foreign id (the recipient id is forced into the filter → 0 rows match).\n *\n * @example\n * // src/config/notifications.ts:\n * inApp.configure({ model: Notification }),\n *\n * // anywhere:\n * const unread = await inApp.listUnread(user);\n * const badge = await inApp.countUnread(user);\n * const one = await inApp.find(user, \"ntf_123\");\n * await inApp.markAsRead(user, \"ntf_123\");\n * await inApp.markAsRead(user); // mark ALL unread\n * await inApp.dismiss(user, \"ntf_123\"); // delete one\n * await inApp.dismiss(user); // clear all for this recipient\n */\nimport type { TypedRepositoryOptionsWithPages } from \"@warlock.js/core\";\nimport { databaseChannel } from \"../channels/database-channel\";\nimport type { Channel } from \"../contracts\";\nimport type { DatabasePayload, Id, Notifiable } from \"../types\";\nimport {\n BaseNotificationsRepository,\n type NotificationModelClass,\n type NotificationsFilter,\n} from \"./base-notifications-repository\";\n\n/**\n * List options for the read methods — the repository's paginated options\n * (`page` / `limit` / `orderBy` / filter keys). `recipientId` is always forced\n * from the recipient argument, so passing it here has no effect.\n */\nexport type NotificationsListOptions = TypedRepositoryOptionsWithPages<NotificationsFilter>;\n\n/**\n * Configure options — discriminated so `model` and `repository` are mutually\n * exclusive at the type level. Future knobs (cache / realtime / naming) slot\n * in here without breaking the signature.\n */\nexport type ConfigureOptions =\n | { model: NotificationModelClass; repository?: never }\n | { repository: BaseNotificationsRepository; model?: never };\n\nconst idOf = (recipient: Notifiable | Id): Id =>\n typeof recipient === \"object\" ? recipient.id : recipient;\n\nclass InApp {\n private repo?: BaseNotificationsRepository;\n\n /**\n * Bind the in-app store AND return the `database` channel. Called once\n * from `config/notifications.ts`. Subsequent calls REPLACE the binding.\n */\n public configure(options: ConfigureOptions): Channel<DatabasePayload> {\n this.repo =\n \"repository\" in options && options.repository\n ? options.repository\n : new BaseNotificationsRepository(options.model);\n return databaseChannel(this.repo);\n }\n\n private get repository(): BaseNotificationsRepository {\n if (!this.repo) {\n throw new Error(\n \"In-app notifications not configured — add \" +\n \"`database: inApp.configure({ model: Notification })` to config/notifications.ts\",\n );\n }\n return this.repo;\n }\n\n /** General list — scoped to recipient; pass `options` for paging + filters. */\n public list(recipient: Notifiable | Id, options?: NotificationsListOptions) {\n return this.repository.list({ ...options, recipientId: idOf(recipient) });\n }\n\n /** Unread-only list — common case for badges + dashboards. */\n public listUnread(recipient: Notifiable | Id, options?: NotificationsListOptions) {\n return this.repository.list({ ...options, recipientId: idOf(recipient), unread: true });\n }\n\n /**\n * Cached unread count — backs the badge. Auto-invalidated by the repo's\n * model create/update events (`RepositoryManager.registerEvents`).\n */\n public countUnread(recipient: Notifiable | Id) {\n return this.repository.countCached({ recipientId: idOf(recipient), unread: true });\n }\n\n /** Find one notification for a recipient — for a detail view. */\n public find(recipient: Notifiable | Id, id: Id) {\n return this.repository.findFor(idOf(recipient), id);\n }\n\n /** Mark read — `id` omitted = all unread for this recipient. */\n public markAsRead(recipient: Notifiable | Id, id?: Id) {\n return this.repository.markRead(idOf(recipient), id);\n }\n\n /** Mark unread — same recipient-scoping. */\n public markAsUnread(recipient: Notifiable | Id, id?: Id) {\n return this.repository.markUnread(idOf(recipient), id);\n }\n\n /** Delete/dismiss — `id` omitted = clear all for this recipient. */\n public dismiss(recipient: Notifiable | Id, id?: Id) {\n return this.repository.deleteFor(idOf(recipient), id);\n }\n}\n\n/** Single in-app facade — bound at boot via `inApp.configure(...)`. */\nexport const inApp = new InApp();\n","/**\n * Column factory for the notifications table, driven by the model's\n * `columnMap`. The recipient / read-state / tenant columns take the NAMES the\n * model declares, so the table, the repository, and the model accessors all\n * agree by construction. The stable lexical columns (`type`, `title`, `body`,\n * `payload`, `idempotency_key`) keep fixed names.\n *\n * Read-state follows the map's presence rules (see `NotificationColumnMap`):\n * `readAt` → a nullable timestamp, `isRead` → an indexed boolean, both → both.\n *\n * Spread + extend for app-specific extras (FK references, composite indexes).\n *\n * @example\n * import { Migration } from \"@warlock.js/cascade\";\n * import { notificationColumns } from \"@warlock.js/notifications\";\n * import { Notification } from \"../notification.model\";\n *\n * export default Migration.create(Notification, notificationColumns(Notification));\n *\n * @example // with extras (multi-tenant FK)\n * export default Migration.create(Notification, {\n * ...notificationColumns(Notification),\n * organization_id: uuid().references(Organization.table).notNullable(),\n * });\n */\nimport { boolCol, type ColumnMap, json, string, text, timestamp, uuid } from \"@warlock.js/cascade\";\nimport { type NotificationColumnMapHost, resolveColumnMap } from \"../in-app/column-map\";\n\n/**\n * Returns the column map for a notification table, named from the model's\n * `columnMap`. Without a model, falls back to the resolved defaults\n * (`user_id` recipient + `read_at` read-state).\n */\nexport function notificationColumns(model?: NotificationColumnMapHost): ColumnMap {\n const { recipient, tenant, readAt, isRead } = resolveColumnMap(model?.columnMap);\n\n const columns: ColumnMap = {\n [recipient]: uuid().index().notNullable(),\n type: string().index().notNullable(),\n title: string().notNullable(),\n body: text().nullable(),\n payload: json().nullable(),\n };\n\n if (readAt) {\n columns[readAt] = timestamp().nullable();\n }\n\n if (isRead) {\n columns[isRead] = boolCol().default(false).index();\n }\n\n if (tenant) {\n columns[tenant] = uuid().index().notNullable();\n }\n\n // Unique so a retried send can't insert a duplicate. Nullable: most rows\n // have no key (NULLs are distinct in a unique index). `createFor` does\n // find-or-create; this constraint is the race backstop.\n columns.idempotency_key = string().nullable().unique();\n\n return columns;\n}\n","/**\n * Lazily load `@warlock.js/herald` — the optional peer backing `.queue()`.\n *\n * Memoized: the dynamic import runs at most once. A missing package surfaces a\n * curated install message at use time rather than a boot-time resolution error\n * (the lazy-optional-peer pattern used across the framework's drivers).\n */\nlet heraldModule: typeof import(\"@warlock.js/herald\") | undefined;\n\nconst INSTALL_INSTRUCTIONS = `\nThe notifications queue requires the @warlock.js/herald package.\nInstall it with:\n\n npm install @warlock.js/herald\n\nOr with your preferred package manager:\n\n pnpm add @warlock.js/herald\n yarn add @warlock.js/herald\n`.trim();\n\nexport async function loadHerald(): Promise<typeof import(\"@warlock.js/herald\")> {\n if (heraldModule) {\n return heraldModule;\n }\n\n try {\n heraldModule = await import(\"@warlock.js/herald\");\n return heraldModule;\n } catch {\n throw new Error(INSTALL_INSTRUCTIONS);\n }\n}\n\n/** The herald channel notification jobs are published to / consumed from. */\nexport const DEFAULT_QUEUE_CHANNEL = \"notifications.dispatch\";\n","/**\n * Herald-backed `QueueDispatcher` — the production backend for `.queue()`.\n *\n * `defineNotification` renders payloads + resolves routes BEFORE handing a job\n * to the dispatcher, so the job is fully serializable (`{ channel, route,\n * payload, options }`) — no model re-hydration or closure serialization. This\n * dispatcher just publishes the job onto a herald channel; the worker\n * (`startNotificationsWorker`) consumes it and runs `channel.send`.\n *\n * Connection is the app's job — the `@warlock.js/herald` connector connects the\n * broker from `config/herald.ts` at boot; this dispatcher only calls `herald()`.\n *\n * @example src/config/notifications.ts — declarative; the connector registers it\n * import { type NotificationConfig, heraldQueue, inApp, mailChannel } from \"@warlock.js/notifications\";\n *\n * const config: NotificationConfig = {\n * channels: { mail: mailChannel(), database: inApp.configure({ model: Notification }) },\n * queue: heraldQueue(), // → `.queue()` now works\n * };\n *\n * export default config;\n */\nimport type { QueueDispatcher } from \"../contracts\";\nimport { DEFAULT_QUEUE_CHANNEL, loadHerald } from \"./load-herald\";\n\nexport type HeraldQueueOptions = {\n /** Herald channel to publish jobs to. Default `\"notifications.dispatch\"`. */\n channel?: string;\n /** Herald broker name (multi-broker setups). Default broker if omitted. */\n broker?: string;\n};\n\nexport function heraldQueue(options: HeraldQueueOptions = {}): QueueDispatcher {\n const channelName = options.channel ?? DEFAULT_QUEUE_CHANNEL;\n\n return {\n async dispatch(job) {\n const { herald } = await loadHerald();\n await herald(options.broker).channel(channelName).publish(job);\n },\n };\n}\n","/**\n * Notification queue worker — consumes the jobs `heraldQueue` publishes and\n * runs the actual `channel.send`. Call this once in a worker process (or the\n * web process) after the notifications config + herald broker are up.\n *\n * The job carries an ALREADY-RENDERED payload + resolved route, so the worker\n * only looks the channel up by name and dispatches — no notifiable needed.\n *\n * @example\n * // in a worker entrypoint, after the notifications config + broker are up\n * import { startNotificationsWorker } from \"@warlock.js/notifications\";\n * await startNotificationsWorker();\n */\nimport { log } from \"@warlock.js/logger\";\nimport { getNotificationConfig } from \"../config\";\nimport type { ChannelName, SendOptions } from \"../types\";\nimport { DEFAULT_QUEUE_CHANNEL, loadHerald } from \"./load-herald\";\n\ntype NotificationJob = {\n channel: string;\n route: unknown;\n payload: unknown;\n options: SendOptions;\n};\n\nexport type WorkerOptions = {\n /** Herald channel to consume from. Must match the dispatcher. Default `\"notifications.dispatch\"`. */\n channel?: string;\n /** Herald broker name. Default broker if omitted. */\n broker?: string;\n};\n\nexport async function startNotificationsWorker(options: WorkerOptions = {}): Promise<void> {\n const channelName = options.channel ?? DEFAULT_QUEUE_CHANNEL;\n const { herald } = await loadHerald();\n\n await herald(options.broker)\n .channel(channelName)\n .subscribe(async (message: { payload: unknown }, ctx: { ack(): Promise<void> }) => {\n const job = message.payload as NotificationJob;\n\n try {\n const channel = getNotificationConfig().channels[job.channel as ChannelName];\n\n if (!channel) {\n // Unknown channel — log and ack to avoid a poison-message loop.\n log.error(\"notifications\", \"queue.worker\", `Unknown channel \"${job.channel}\" — dropping job`);\n await ctx.ack();\n return;\n }\n\n await channel.send({\n payload: job.payload as never,\n route: job.route as never,\n options: job.options,\n });\n\n await ctx.ack();\n } catch (error) {\n // Phase 2: log + ack (no retry/DLQ yet — that lands with the\n // delay-aware worker). A dead-letter strategy is a follow-up.\n log.error(\"notifications\", \"queue.worker\", error as Error);\n await ctx.ack();\n }\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["RepositoryManager","Model"],"sources":["../../../../../../notifications/src/errors.ts","../../../../../../notifications/src/config.ts","../../../../../../notifications/src/dispatch/define-channel.ts","../../../../../../notifications/src/dispatch/dispatch-id.ts","../../../../../../notifications/src/dispatch/notifications-event-bus.ts","../../../../../../notifications/src/dispatch/dispatcher.ts","../../../../../../notifications/src/dispatch/define-notification.ts","../../../../../../notifications/src/dispatch/notify.ts","../../../../../../notifications/src/channels/database-channel.ts","../../../../../../notifications/src/channels/mail-channel.ts","../../../../../../notifications/src/in-app/column-map.ts","../../../../../../notifications/src/in-app/base-notifications-repository.ts","../../../../../../notifications/src/in-app/database-notification.ts","../../../../../../notifications/src/in-app/in-app.ts","../../../../../../notifications/src/migration/notification-columns.ts","../../../../../../notifications/src/queue/load-herald.ts","../../../../../../notifications/src/queue/herald-queue.ts","../../../../../../notifications/src/queue/notifications-worker.ts"],"sourcesContent":["/**\n * Domain errors for `@warlock.js/notifications`. All extend `Error` directly\n * (no framework HttpError) so the package stays runtime-agnostic — the host\n * app maps them to its own error shape via `instanceof` if needed.\n *\n * Each captures a clean V8 stack (drops the Error-constructor frame) to match\n * the cascade convention.\n */\n\nfunction captureStack(target: object, ctor: new (...args: any[]) => unknown): void {\n // V8 only; guarded for non-V8 runtimes.\n (Error as unknown as { captureStackTrace?: (t: object, c: unknown) => void }).captureStackTrace?.(\n target,\n ctor,\n );\n}\n\nexport class NotificationsNotConfiguredError extends Error {\n public constructor() {\n super(\n \"Notifications not configured — add `src/config/notifications.ts` with a \" +\n \"default-exported config; the notifications connector registers it at boot.\",\n );\n this.name = \"NotificationsNotConfiguredError\";\n captureStack(this, NotificationsNotConfiguredError);\n }\n}\n\nexport class ChannelNotFoundError extends Error {\n public constructor(channelName: string) {\n super(\n `Channel \"${channelName}\" is not configured. Add it to the ` +\n \"`channels` map in `src/config/notifications.ts`.\",\n );\n this.name = \"ChannelNotFoundError\";\n captureStack(this, ChannelNotFoundError);\n }\n}\n\nexport class NoQueueDispatcherError extends Error {\n public constructor(channelName: string) {\n super(\n `Cannot queue to channel \"${channelName}\" — no queue dispatcher configured. ` +\n \"Add a `queue` to `src/config/notifications.ts`, or call `.send()` instead of `.queue()`.\",\n );\n this.name = \"NoQueueDispatcherError\";\n captureStack(this, NoQueueDispatcherError);\n }\n}\n\nexport class MissingRendererError extends Error {\n public constructor(notificationType: string, channelName: string) {\n super(\n `Notification \"${notificationType}\" routes to channel \"${channelName}\" via ` +\n `\\`via\\` but defines no \\`${channelName}\\` renderer.`,\n );\n this.name = \"MissingRendererError\";\n captureStack(this, MissingRendererError);\n }\n}\n\n/**\n * Thrown when a channel's `route()` resolver runs but returns `undefined` for\n * a recipient (e.g. a mail recipient with no email). Distinct from \"the\n * channel declares no resolver\" — that case falls back to `{ id }`.\n */\nexport class UnresolvableRouteError extends Error {\n public constructor(channelName: string, recipientId?: unknown) {\n super(\n `Channel \"${channelName}\" could not resolve a route for recipient ` +\n `${recipientId ?? \"(unknown)\"}. The channel's \\`route()\\` returned undefined — ` +\n \"the recipient is missing the address column this channel needs.\",\n );\n this.name = \"UnresolvableRouteError\";\n captureStack(this, UnresolvableRouteError);\n }\n}\n","/**\n * Notification configuration holder — the runtime singleton the dispatch core\n * reads from. In a Warlock app you do NOT call `setNotificationConfig`\n * directly: `src/config/notifications.ts` exports a `NotificationConfig` as its\n * default, and the notifications connector calls this at boot. Tests (and\n * non-Warlock embeddings) may call it directly.\n *\n * @example src/config/notifications.ts — declarative; the connector registers it\n * import { type NotificationConfig, mailChannel, inApp } from \"@warlock.js/notifications\";\n * import { Notification } from \"app/notifications/notification.model\";\n *\n * const config: NotificationConfig = {\n * channels: {\n * mail: mailChannel({ from: \"no-reply@store.com\" }),\n * database: inApp.configure({ model: Notification }),\n * },\n * // preferences / rateLimit / queue all optional\n * };\n *\n * export default config;\n */\nimport type {\n Channel,\n PreferenceProvider,\n QueueDispatcher,\n RateLimiter,\n} from \"./contracts\";\nimport { NotificationsNotConfiguredError } from \"./errors\";\nimport type { ChannelName, NotificationChannels } from \"./types\";\n\n/**\n * Channel registry — each key is a registered channel name, each value the\n * channel whose payload type matches the registry. Typed as a partial map\n * over `ChannelName` so `setNotificationConfig` rejects a channel whose\n * payload doesn't line up with its registry entry, AND so a custom channel\n * (declaration-merged into `NotificationChannels`) is accepted with its real\n * payload type rather than `any`.\n */\nexport type ChannelMap = Partial<{\n [C in ChannelName]: Channel<NotificationChannels[C]>;\n}>;\n\n/**\n * Runtime configuration the dispatcher reads on every send. `queue`,\n * `preferences`, and `rateLimit` are all OPTIONAL slots — the package\n * reserves their shape but ships no defaults.\n */\nexport type NotificationConfig = {\n /** Channel registry — keys correlate to `NotificationChannels` payloads. */\n channels: ChannelMap;\n /** Async dispatcher backing `.queue()`. Phase 2 ships a herald impl. */\n queue?: QueueDispatcher;\n /** Pre-send gate: drops channels the recipient opted out of. */\n preferences?: PreferenceProvider;\n /** Pre-send gate: drops channels exceeding per-recipient budgets. */\n rateLimit?: RateLimiter;\n};\n\nlet activeConfig: NotificationConfig | undefined;\n\n/**\n * Set the active notifications configuration. In a Warlock app the\n * notifications connector calls this at boot with the default export of\n * `src/config/notifications.ts`. Subsequent calls REPLACE the active config\n * (does not merge) — pass the complete config, not a partial.\n */\nexport function setNotificationConfig(config: NotificationConfig): void {\n activeConfig = config;\n}\n\n/**\n * Get the active notifications configuration. Throws if not yet configured —\n * fail loudly at the first send rather than silently doing nothing.\n */\nexport function getNotificationConfig(): NotificationConfig {\n if (!activeConfig) {\n throw new NotificationsNotConfiguredError();\n }\n return activeConfig;\n}\n\n/**\n * Clear the active configuration. Intended for tests; not part of the runtime\n * surface. Production code should never call this.\n */\nexport function resetNotificationConfig(): void {\n activeConfig = undefined;\n}\n","import type { Channel } from \"../contracts\";\n\n/**\n * Identity helper that narrows the channel type to its payload. Use it when\n * you want TypeScript to enforce the `send` payload shape against the\n * payload type you declared.\n *\n * Built-in channels (`mail`, `database`) ship as factories; custom channels\n * are usually defined with this helper.\n *\n * @example\n * import { defineChannel } from \"@warlock.js/notifications\";\n *\n * type DiscordPayload = { content: string };\n *\n * export const discordChannel = () =>\n * defineChannel<DiscordPayload>({\n * name: \"discord\",\n * route: (n) => n.get(\"discord_webhook\"),\n * async send({ payload, route }) {\n * await fetch(route as string, {\n * method: \"POST\",\n * headers: { \"content-type\": \"application/json\" },\n * body: JSON.stringify(payload),\n * });\n * },\n * });\n *\n * // Then teach the registry:\n * declare module \"@warlock.js/notifications\" {\n * interface NotificationChannels { discord: DiscordPayload }\n * }\n */\nexport function defineChannel<P>(channel: Channel<P>): Channel<P> {\n return channel;\n}\n","import { randomUUID } from \"node:crypto\";\n\n/**\n * A unique id for one (channel, recipient) dispatch. `sending` and its terminal\n * `sent` / `failed` event share it so observers can pair them — for tracing\n * spans, latency, or spotting a `sending` with no terminal (a hung send).\n */\nexport function newDispatchId(): string {\n return randomUUID();\n}\n","/**\n * Typed event bus for notifications observability. Emits `sent`, `failed`,\n * and `skipped` events per (channel, recipient) — fan-out emits N events.\n *\n * Intentionally minimal — a `Map<event, Set<handler>>` keeps tests trivial\n * and avoids pulling event-emitter machinery for what is fundamentally\n * pub/sub. Handler exceptions are logged-and-swallowed so one bad listener\n * cannot break the dispatcher.\n *\n * @example\n * notifications.on(\"sent\", ({ channel }) => metrics.inc(`notif.${channel}.sent`));\n * notifications.on(\"skipped\", ({ reason }) => metrics.inc(`notif.skipped.${reason}`));\n * const off = notifications.on(\"failed\", logErr);\n * off(); // unsubscribe\n */\nimport { log } from \"@warlock.js/logger\";\nimport type { NotificationEvents } from \"../types\";\n\ntype EventName = keyof NotificationEvents;\ntype Handler<E extends EventName> = (data: NotificationEvents[E]) => void | Promise<void>;\n\nconst handlers: { [E in EventName]: Set<Handler<E>> } = {\n sending: new Set(),\n sent: new Set(),\n failed: new Set(),\n skipped: new Set(),\n};\n\n/**\n * Public observability surface.\n *\n * `on(event, handler)` returns an unsubscribe function. `off` exists for\n * symmetry with libraries that hold handler references for later removal.\n */\nexport const notifications = {\n on<E extends EventName>(event: E, handler: Handler<E>): () => void {\n handlers[event].add(handler as Handler<EventName>);\n return () => {\n handlers[event].delete(handler as Handler<EventName>);\n };\n },\n off<E extends EventName>(event: E, handler: Handler<E>): void {\n handlers[event].delete(handler as Handler<EventName>);\n },\n};\n\n/**\n * Internal emit — used by the dispatcher. Not exported from the package.\n * Handlers run in registration order; an awaited Promise.allSettled isolates\n * each listener so one throw cannot block the others.\n */\nexport async function emit<E extends EventName>(\n event: E,\n data: NotificationEvents[E],\n): Promise<void> {\n const set = handlers[event];\n if (set.size === 0) return;\n\n // The `async` wrapper turns SYNC handler throws into promise rejections so\n // `Promise.allSettled` can isolate them. Without it, a `throw` inside the\n // handler escapes the array map before allSettled gets a chance to catch.\n const results = await Promise.allSettled(\n Array.from(set).map(async (handler) => {\n await (handler as Handler<E>)(data);\n }),\n );\n\n for (const result of results) {\n if (result.status === \"rejected\") {\n log.error(\"notifications\", \"event-handler\", result.reason);\n }\n }\n}\n","/**\n * Internal dispatch core — shared by `defineNotification` and `notify.<channel>`.\n *\n * Responsibilities:\n * 1. Look up the channel in the configured registry (throws if missing).\n * 2. Run the rate-limit gate (if a notification type is known) — drop with a\n * `skipped` event when the limiter refuses.\n * 3. Resolve the route: raw string > channel.route(notifiable) > { id }.\n * 4. Inject the per-send `idempotencyKey` into the database payload.\n * 5. Dispatch (sync via channel.send OR async via queue.dispatch).\n * 6. Emit `sent` on success, `failed` on throw.\n *\n * Per-channel failure isolation is the CALLER's job (it wraps a list of\n * `dispatchChannel` calls in `Promise.allSettled`).\n */\nimport { getNotificationConfig } from \"../config\";\nimport { ChannelNotFoundError, NoQueueDispatcherError, UnresolvableRouteError } from \"../errors\";\nimport type { ChannelName, Id, Notifiable, SendOptions } from \"../types\";\nimport { newDispatchId } from \"./dispatch-id\";\nimport { emit } from \"./notifications-event-bus\";\n\nexport type DispatchMode = \"send\" | \"queue\";\n\nexport type DispatchChannelArgs = {\n channelName: string;\n payload: unknown;\n /** The recipient model — undefined when dispatching to a raw route string. */\n to: Notifiable | undefined;\n /** Raw route override (ad-hoc `notify.mail(\"x@y.com\", …)`). */\n rawRoute?: string;\n /** Notification type — drives the rate-limit gate. May be undefined. */\n notificationType: string | undefined;\n mode: DispatchMode;\n options: SendOptions;\n};\n\n/**\n * Dispatch ONE rendered payload to ONE recipient through ONE channel.\n * Does NOT consult `PreferenceProvider` — that gate runs at the caller level.\n *\n * Throws `ChannelNotFoundError` (channel not registered) and\n * `UnresolvableRouteError` (channel's `route()` returned undefined) — both are\n * configuration/data errors that should surface loudly. Transport failures\n * (channel.send throwing) are emitted as `failed` AND rethrown.\n */\nexport async function dispatchChannel(args: DispatchChannelArgs): Promise<void> {\n const config = getNotificationConfig();\n const channel = config.channels[args.channelName as ChannelName];\n\n if (!channel) {\n throw new ChannelNotFoundError(args.channelName);\n }\n\n // One id for this (channel, recipient) dispatch — `sending` and its terminal\n // `sent` / `failed` share it so observers can pair them.\n const dispatchId = newDispatchId();\n\n // Rate-limit gate. Only applies when we have a notifiable + a notification\n // type (otherwise there's nothing to rate-limit on). `force` does NOT\n // bypass — rate limits are a safety valve, not a UX preference.\n //\n // NOTE (Phase-2 seam): for mode \"queue\" the budget is consumed at ENQUEUE\n // time here, not at delivery. When the herald worker lands it should run\n // this gate at delivery instead; see RateLimiter docstring.\n if (args.to && args.notificationType && config.rateLimit) {\n const allowed = await config.rateLimit.allow(\n args.to,\n args.channelName as ChannelName,\n args.notificationType,\n );\n\n if (!allowed) {\n await emit(\"skipped\", {\n dispatchId,\n channel: args.channelName,\n notifiable: args.to,\n reason: \"rate-limit\",\n options: args.options,\n });\n return;\n }\n }\n\n const route = resolveRoute(args, channel.route);\n\n // Inject idempotency key into the database payload (so the repo can dedupe).\n let payload = args.payload;\n if (\n args.channelName === \"database\" &&\n args.options.idempotencyKey &&\n payload &&\n typeof payload === \"object\"\n ) {\n payload = { ...(payload as object), idempotencyKey: args.options.idempotencyKey };\n }\n\n // `sending` fires after the gates pass + the route resolves, BEFORE the\n // transport — awaited so it always precedes the send. Observers can't abort\n // the send, but a slow handler delays it; keep them fast.\n await emit(\"sending\", {\n dispatchId,\n channel: args.channelName,\n notifiable: args.to,\n payload,\n options: args.options,\n });\n\n const startedAt = Date.now();\n\n try {\n if (args.mode === \"queue\") {\n if (!config.queue) {\n throw new NoQueueDispatcherError(args.channelName);\n }\n await config.queue.dispatch({\n channel: args.channelName,\n route,\n payload,\n options: args.options,\n });\n } else {\n await channel.send({\n payload: payload as never,\n route,\n notifiable: args.to,\n options: args.options,\n });\n }\n\n await emit(\"sent\", {\n dispatchId,\n channel: args.channelName,\n notifiable: args.to,\n payload,\n options: args.options,\n durationMs: Date.now() - startedAt,\n });\n } catch (error) {\n await emit(\"failed\", {\n dispatchId,\n channel: args.channelName,\n notifiable: args.to,\n payload,\n error: error as Error,\n options: args.options,\n durationMs: Date.now() - startedAt,\n });\n throw error;\n }\n}\n\n/**\n * Route resolution priority:\n * 1. raw route string (ad-hoc `notify.mail(\"x@y.com\", …)`)\n * 2. the channel's `route(notifiable)` resolver\n * 3. `{ id: notifiable.id }` — ONLY when the channel declares no resolver\n *\n * When a channel HAS a resolver but it returns undefined (e.g. a mail\n * recipient with no email), we throw `UnresolvableRouteError` rather than\n * silently coercing to `{ id }` — that would hand a string-route channel an\n * object and fail deep inside the transport.\n */\nfunction resolveRoute(\n args: DispatchChannelArgs,\n resolver: ((notifiable: Notifiable) => string | { id: Id } | undefined) | undefined,\n): string | { id: Id } {\n if (args.rawRoute !== undefined) {\n return args.rawRoute;\n }\n\n if (!args.to) {\n throw new UnresolvableRouteError(args.channelName);\n }\n\n // No resolver declared → the channel addresses by id (database/internal).\n if (!resolver) {\n return { id: args.to.id };\n }\n\n const resolved = resolver(args.to);\n\n if (resolved === undefined) {\n throw new UnresolvableRouteError(args.channelName, args.to.id);\n }\n\n return resolved;\n}\n","/**\n * `defineNotification` — reusable, type-safe, multi-channel notification.\n *\n * Functional, not class-based: pass `via` + a renderer per channel + a\n * stable `type`. The returned object exposes `.send` / `.queue` / `.only`.\n *\n * Renderer signature is `(data, to, ctx)` where `ctx` carries `locale` and\n * `meta` from `SendOptions`. The database renderer's `type` is OPTIONAL — the\n * dispatcher injects `def.type`, keeping the type defined in ONE place.\n *\n * ## Error policy\n * - **Config errors** (missing renderer, no queue dispatcher, unknown channel)\n * are programmer mistakes → they REJECT `.send()`/`.queue()` (and a static\n * `via` with a missing renderer throws at definition time). Consistent with\n * `notify`, which rejects `ChannelNotFoundError`.\n * - **Transport errors** (a channel's `send` throwing) stay ISOLATED — emitted\n * as a `failed` event, never aborting sibling channels/recipients.\n * - **`via()` / preferences throws** for one recipient are logged and that\n * recipient is dropped (the rest of a fan-out still go).\n *\n * @example\n * export const orderShipped = defineNotification<{ order: Order }>({\n * type: \"order.shipped\",\n * via: [\"mail\", \"database\"],\n * mail: ({ order }, to) => ({ subject: `#${order.number} shipped`, html: \"…\" }),\n * database: ({ order }) => ({ title: \"Your order shipped\", payload: { id: order.id } }),\n * });\n * await orderShipped.send(user, { order });\n * await orderShipped.queue([buyer, salesRep], { order }, { delay: \"10m\" });\n * await orderShipped.only(\"mail\").send(user, { order });\n */\nimport { log } from \"@warlock.js/logger\";\nimport { getNotificationConfig } from \"../config\";\nimport {\n ChannelNotFoundError,\n MissingRendererError,\n NoQueueDispatcherError,\n} from \"../errors\";\nimport type {\n ChannelName,\n Notifiable,\n NotificationChannels,\n RenderContext,\n SendOptions,\n} from \"../types\";\nimport { newDispatchId } from \"./dispatch-id\";\nimport { dispatchChannel, type DispatchMode } from \"./dispatcher\";\nimport { emit } from \"./notifications-event-bus\";\n\n/**\n * Per-channel renderer return type. The `database` renderer may OMIT `type`\n * — it inherits from `NotificationDef.type` at dispatch time.\n */\ntype RendererReturn<C extends ChannelName> = C extends \"database\"\n ? Omit<NotificationChannels[\"database\"], \"type\">\n : NotificationChannels[C];\n\ntype Renderer<Data, C extends ChannelName> = (\n data: Data,\n to: Notifiable,\n ctx: RenderContext,\n) => RendererReturn<C>;\n\nexport type NotificationDef<Data> = {\n type: string;\n via: ChannelName[] | ((data: Data, to: Notifiable) => ChannelName[]);\n} & {\n [C in ChannelName]?: Renderer<Data, C>;\n};\n\nexport interface DefinedNotification<Data> {\n send(to: Notifiable | Notifiable[], data: Data, options?: SendOptions): Promise<void>;\n queue(to: Notifiable | Notifiable[], data: Data, options?: SendOptions): Promise<void>;\n only(...channels: ChannelName[]): DefinedNotification<Data>;\n}\n\n/** Errors that represent a misconfiguration and must surface (reject), not isolate. */\nfunction isConfigError(error: unknown): boolean {\n return (\n error instanceof MissingRendererError ||\n error instanceof NoQueueDispatcherError ||\n error instanceof ChannelNotFoundError\n );\n}\n\nexport function defineNotification<Data>(def: NotificationDef<Data>): DefinedNotification<Data> {\n const make = (restrict?: ChannelName[]): DefinedNotification<Data> => {\n // Validate at definition time when the channel set is static — a missing\n // renderer is a programmer error, surfaced loudly at import.\n if (Array.isArray(def.via)) {\n const channels = restrict ? def.via.filter((c) => restrict.includes(c)) : def.via;\n for (const channel of channels) {\n if (!def[channel]) {\n throw new MissingRendererError(def.type, channel);\n }\n }\n }\n\n return {\n send(to, data, options) {\n return dispatchAll(def, to, data, options ?? {}, \"send\", restrict);\n },\n queue(to, data, options) {\n return dispatchAll(def, to, data, options ?? {}, \"queue\", restrict);\n },\n only(...channels) {\n return make(channels);\n },\n };\n };\n\n return make();\n}\n\n/**\n * Fan-out to all recipients. Each recipient dispatches independently; one\n * recipient's transport failure never aborts the others. CONFIG errors,\n * however, propagate — they reject the returned promise.\n */\nasync function dispatchAll<Data>(\n def: NotificationDef<Data>,\n to: Notifiable | Notifiable[],\n data: Data,\n options: SendOptions,\n mode: DispatchMode,\n restrict?: ChannelName[],\n): Promise<void> {\n const recipients = Array.isArray(to) ? to : [to];\n\n const results = await Promise.allSettled(\n recipients.map((recipient) =>\n dispatchToRecipient(def, recipient, data, options, mode, restrict),\n ),\n );\n\n rethrowConfigErrors(results);\n}\n\nasync function dispatchToRecipient<Data>(\n def: NotificationDef<Data>,\n to: Notifiable,\n data: Data,\n options: SendOptions,\n mode: DispatchMode,\n restrict?: ChannelName[],\n): Promise<void> {\n const config = getNotificationConfig();\n\n // Resolve channels (via + preferences). A throw here drops THIS recipient\n // with a log — it's a per-recipient data problem, not a config error.\n let channels: ChannelName[];\n try {\n channels = typeof def.via === \"function\" ? def.via(data, to) : [...def.via];\n\n if (restrict) {\n channels = channels.filter((c) => restrict.includes(c));\n }\n\n if (!options.force && config.preferences) {\n const allowed = await config.preferences.resolveChannels(to, def.type, channels);\n for (const channel of channels.filter((c) => !allowed.includes(c))) {\n await emit(\"skipped\", {\n dispatchId: newDispatchId(),\n channel,\n notifiable: to,\n reason: \"preference\",\n options,\n });\n }\n channels = channels.filter((c) => allowed.includes(c));\n }\n } catch (error) {\n log.error(\"notifications\", `resolve.${def.type}`, error as Error);\n return;\n }\n\n const renderCtx: RenderContext = { locale: options.locale, meta: options.meta };\n\n const results = await Promise.allSettled(\n channels.map((name) => {\n const renderer = def[name] as Renderer<Data, ChannelName> | undefined;\n if (!renderer) {\n // Dynamic via chose a channel with no renderer — surface it.\n return Promise.reject(new MissingRendererError(def.type, name));\n }\n\n let payload: unknown = renderer(data, to, renderCtx);\n\n // Default the database channel's `type` from `def.type` when omitted.\n if (name === \"database\" && payload && typeof payload === \"object\" && !(\"type\" in payload)) {\n payload = { ...(payload as object), type: def.type };\n }\n\n return dispatchChannel({\n channelName: name,\n payload,\n to,\n notificationType: def.type,\n mode,\n options,\n });\n }),\n );\n\n rethrowConfigErrors(results);\n}\n\n/** Collect config-class rejections and rethrow (single or AggregateError). */\nfunction rethrowConfigErrors(results: PromiseSettledResult<unknown>[]): void {\n const configErrors = results\n .filter((r): r is PromiseRejectedResult => r.status === \"rejected\" && isConfigError(r.reason))\n .map((r) => r.reason);\n\n if (configErrors.length === 1) {\n throw configErrors[0];\n }\n if (configErrors.length > 1) {\n throw new AggregateError(configErrors, \"Multiple notification configuration errors\");\n }\n}\n","/**\n * `notify` — ad-hoc, per-channel send facade.\n *\n * A Proxy keyed by the channel registry. `notify.<channel>(to, payload,\n * options?)` works for ANY channel name registered in\n * `NotificationChannels` — built-in or custom (via declaration merging).\n *\n * For multi-channel sends, use `defineNotification` — that's the reusable\n * pattern and the only mental model for \"send through several channels at\n * once\" (no inline multi-channel overload here on purpose).\n *\n * `notify.channel(name)` is the runtime escape when the channel name isn't a\n * compile-time literal.\n *\n * @example\n * await notify.mail(user, { subject: \"Welcome\", html: \"<p>Hi!</p>\" });\n * await notify.database(user, { type: \"welcome\", title: \"Welcome!\" });\n * await notify.mail(\"guest@example.com\", { subject: \"…\", html: \"…\" }); // raw target\n * await notify.channel(dynamicName).send(user, payload); // dynamic name\n */\nimport { getNotificationConfig } from \"../config\";\nimport type { ChannelName, Notifiable, NotificationChannels, SendOptions } from \"../types\";\nimport { newDispatchId } from \"./dispatch-id\";\nimport { dispatchChannel } from \"./dispatcher\";\nimport { emit } from \"./notifications-event-bus\";\n\n/**\n * Public shape of `notify`. The mapped portion gives `notify.<channel>` per\n * registered channel; `channel(name)` is the runtime escape.\n */\nexport type Notify = {\n channel(name: string): {\n send(\n to: Notifiable | string,\n payload: unknown,\n options?: SendOptions,\n ): Promise<void>;\n };\n} & {\n [C in ChannelName]: (\n to: Notifiable | string,\n payload: NotificationChannels[C],\n options?: SendOptions,\n ) => Promise<void>;\n};\n\n/**\n * Per-call dispatch for `notify.<channel>`. Mirrors `defineNotification`'s\n * pipeline but without rendering: payload is already concrete.\n *\n * Preferences gate runs ONLY when a notification `type` is known — explicit\n * via `options.type`, or implicit from a database payload's `type` field.\n * Without a type we have nothing to gate on, and ad-hoc sends bypass\n * preferences silently (use `defineNotification` if you want guaranteed\n * gating).\n */\nasync function notifyImpl(\n channelName: string,\n to: Notifiable | string,\n payload: unknown,\n options: SendOptions = {},\n): Promise<void> {\n const isRawTarget = typeof to === \"string\";\n const notifiable: Notifiable | undefined = isRawTarget ? undefined : to;\n\n // Derive the type: explicit `options.type` wins, then the payload's\n // `type` field (database channel convention).\n let notificationType: string | undefined = options.type;\n if (\n notificationType === undefined &&\n payload &&\n typeof payload === \"object\" &&\n \"type\" in payload &&\n typeof (payload as { type: unknown }).type === \"string\"\n ) {\n notificationType = (payload as { type: string }).type;\n }\n\n // Preferences gate — only when notifiable + type are both available.\n if (notifiable && notificationType && !options.force) {\n const config = getNotificationConfig();\n\n if (config.preferences) {\n const allowed = await config.preferences.resolveChannels(\n notifiable,\n notificationType,\n [channelName as ChannelName],\n );\n\n if (!allowed.includes(channelName as ChannelName)) {\n await emit(\"skipped\", {\n dispatchId: newDispatchId(),\n channel: channelName,\n notifiable,\n reason: \"preference\",\n options,\n });\n return;\n }\n }\n }\n\n await dispatchChannel({\n channelName,\n payload,\n to: notifiable,\n rawRoute: isRawTarget ? to : undefined,\n notificationType,\n mode: \"send\",\n options,\n });\n}\n\n/**\n * Build the `notify` proxy. `channel(name)` returns a `{ send }` object;\n * any other property access returns a one-shot dispatcher for that channel.\n */\nexport const notify: Notify = new Proxy({} as Notify, {\n get(_target, prop) {\n if (typeof prop === \"symbol\") {\n return undefined;\n }\n\n // Don't masquerade as a thenable — awaiting or Promise-wrapping `notify`\n // must NOT invoke notify.then(...) as a channel send. Also keep common\n // serialization probes from being treated as channel names.\n if (prop === \"then\" || prop === \"catch\" || prop === \"finally\" || prop === \"toJSON\") {\n return undefined;\n }\n\n if (prop === \"channel\") {\n return (name: string) => ({\n send: (to: Notifiable | string, payload: unknown, options?: SendOptions) =>\n notifyImpl(name, to, payload, options),\n });\n }\n\n return (to: Notifiable | string, payload: unknown, options?: SendOptions) =>\n notifyImpl(prop, to, payload, options);\n },\n});\n","/**\n * `databaseChannel` — persists notifications via the bound repository.\n *\n * NOT created by the dev directly — `inApp.configure({ model })` builds it\n * and returns it. That way the read side (`inApp.listUnread`, etc.) and the\n * write side (this channel's `send`) share ONE repository instance, which\n * means cache invalidation just works.\n *\n * Route is `{ id: notifiable.id }` — the recipient's id, used to set the\n * recipient column on the persisted row.\n *\n * Multi-tenant: when the model declares a `tenant` column, this channel reads\n * the tenant value off the recipient model (`notifiable.get(tenantColumn)`)\n * and hands it to `createFor`. The recipient model is only present on the\n * synchronous send path; a tenant-scoped store should send (not queue) the\n * database channel so the recipient — and thus the tenant — is in scope.\n *\n * The dispatcher has already injected `SendOptions.idempotencyKey` into the\n * payload (when set), so `createFor` receives a payload that includes it.\n */\nimport type { NotificationContract } from \"../contracts\";\nimport type { Channel } from \"../contracts\";\nimport { defineChannel } from \"../dispatch/define-channel\";\n// Type-only import to avoid a runtime cycle with `in-app/in-app.ts`.\nimport type { BaseNotificationsRepository } from \"../in-app/base-notifications-repository\";\nimport type { DatabasePayload, Id } from \"../types\";\n\nexport function databaseChannel<TModel extends NotificationContract = NotificationContract>(\n repo: BaseNotificationsRepository<TModel>,\n): Channel<DatabasePayload> {\n return defineChannel<DatabasePayload>({\n name: \"database\",\n route: (notifiable) => ({ id: notifiable.id }),\n async send({ payload, route, notifiable }) {\n const id: Id = typeof route === \"object\" ? route.id : route;\n\n const { tenantColumn } = repo;\n const tenantId =\n tenantColumn && notifiable ? (notifiable.get(tenantColumn) as Id) : undefined;\n\n await repo.createFor(id, payload, tenantId);\n },\n });\n}\n","/**\n * `mailChannel` — wraps `@warlock.js/core` `sendMail`.\n *\n * Route is `notifiable.email` by convention; override via `config.route`\n * when the column is named differently or computed.\n *\n * The renderer's payload is forwarded verbatim (`subject`, `html`, `text`,\n * `cc`). `from` is set once at channel-config time; per-send overrides go\n * through the payload itself (you can include `from` in `MailPayload` if\n * you extend the registry — but the convention is one channel = one\n * sender identity).\n *\n * @example\n * // src/config/notifications.ts\n * channels: {\n * mail: mailChannel({ from: \"no-reply@store.com\" }),\n * }\n *\n * // anywhere\n * await notify.mail(user, { subject: \"Welcome\", html: \"<p>Hi!</p>\" });\n */\nimport { sendMail } from \"@warlock.js/core\";\nimport type { Channel } from \"../contracts\";\nimport { defineChannel } from \"../dispatch/define-channel\";\nimport type { MailPayload, Notifiable } from \"../types\";\n\nexport type MailChannelConfig = {\n /** Default `from` address for every send through this channel. */\n from?: string;\n /** Route resolver. Defaults to `notifiable.get(\"email\")`. */\n route?: (notifiable: Notifiable) => string;\n};\n\nexport function mailChannel(config: MailChannelConfig = {}): Channel<MailPayload> {\n const resolveRoute = config.route ?? ((n) => n.get(\"email\") as string);\n\n return defineChannel<MailPayload>({\n name: \"mail\",\n route: (notifiable) => resolveRoute(notifiable),\n async send({ payload, route }) {\n await sendMail({\n to: route as string,\n from: config.from,\n ...payload,\n });\n },\n });\n}\n","/**\n * `columnMap` — how a notification model's logical roles map to the physical\n * columns of ITS table. The ONE source the model getters, the repository\n * (read filter + write mapping), the database channel, and the migration\n * factory all read, so they can never drift apart.\n *\n * Declared as a `static columnMap` on the model and ejected to userland, so\n * the dev owns it:\n *\n * @RegisterModel()\n * export class Notification extends DatabaseNotification {\n * public static table = \"notifications\";\n * public static columnMap: NotificationColumnMap = {\n * tenant: \"organization_id\", // omit → single-tenant\n * readAt: \"read_at\", // read-state (see below)\n * };\n * }\n *\n * ## Read-state is chosen by which keys are PRESENT\n *\n * - `readAt` only → unread = `read_at IS NULL`; marking read stamps it.\n * - `isRead` only → unread = `is_read = false`; no timestamp.\n * - both → `is_read` is the indexed flag used for filtering, `read_at`\n * records WHEN; marking read sets both.\n *\n * Declare neither and you get the `readAt`-only default — so a model can omit\n * `columnMap` entirely and still work.\n */\n\n/**\n * The logical → physical column bindings a notification model may declare.\n * Every key is optional; `resolveColumnMap` fills the defaults. Values are the\n * app's real column names (rename-friendly: `readAt: \"seen_at\"`).\n */\nexport type NotificationColumnMap = {\n /** Recipient FK column. Default: `\"user_id\"`. */\n recipient?: string;\n /** Multi-tenant scope column, written from the recipient. Omit → single-tenant. */\n tenant?: string;\n /** Read-timestamp column. Present → read-state records \"when\". */\n readAt?: string;\n /** Read-flag column (indexed → fast unread filter). Present → read-state is a boolean. */\n isRead?: string;\n};\n\n/**\n * A `NotificationColumnMap` with the defaults applied. `recipient` is always\n * set, and at least one of `readAt` / `isRead` is always present (the resolver\n * falls back to `readAt`), so consumers never face a \"no read-state\" map.\n */\nexport type ResolvedNotificationColumnMap = {\n recipient: string;\n tenant?: string;\n readAt?: string;\n isRead?: string;\n};\n\n/** A model class that may carry the `columnMap` static. */\nexport type NotificationColumnMapHost = {\n columnMap?: NotificationColumnMap;\n};\n\nconst DEFAULT_RECIPIENT_COLUMN = \"user_id\";\nconst DEFAULT_READ_AT_COLUMN = \"read_at\";\n\n/**\n * Resolve a model's declared `columnMap` into a complete map: default the\n * recipient column, and fall back to a `read_at` timestamp when the model\n * declares no read-state column. The fallback guarantees the result always\n * has a usable read-state representation, so no boot-time validation is needed.\n */\nexport function resolveColumnMap(\n map: NotificationColumnMap | undefined,\n): ResolvedNotificationColumnMap {\n const recipient = map?.recipient ?? DEFAULT_RECIPIENT_COLUMN;\n const tenant = map?.tenant;\n\n if (!map?.readAt && !map?.isRead) {\n return { recipient, tenant, readAt: DEFAULT_READ_AT_COLUMN };\n }\n\n return {\n recipient,\n tenant,\n readAt: map.readAt,\n isRead: map.isRead,\n };\n}\n","/**\n * Shipped behavior repository for the in-app store.\n *\n * Concrete (NOT abstract) so `inApp.configure({ model })` can\n * `new BaseNotificationsRepository(model)` without subclassing. The dev only\n * extends this when they need extra query methods.\n *\n * ## The single source of truth: the model's `columnMap`\n *\n * The model declares its physical columns ONCE via `static columnMap`. The\n * constructor resolves that map and derives BOTH sides from it:\n *\n * - the READ path — `filterBy` maps logical keys (`recipientId`, `unread`,\n * `type`, …) onto the model's columns; `RepositoryManager` applies it.\n * - the WRITE / UPDATE / DELETE path — `createFor` / `markRead` / … translate\n * logical keys through the same `filterBy` via `toRow`.\n *\n * Because both come from one `columnMap`, reads and writes can never target\n * different columns. `unread` is mode-agnostic: it filters `is_read = false`\n * when the model has that flag, otherwise `read_at IS NULL`.\n *\n * @example default (SQL snake_case — no subclass needed)\n * inApp.configure({ model: Notification })\n *\n * @example custom columns — declare them on the model, not here\n * class Notification extends DatabaseNotification {\n * public static columnMap = { recipient: \"audience_id\", isRead: \"seen\" };\n * }\n */\nimport { type FilterRules, RepositoryManager } from \"@warlock.js/core\";\nimport type { NotificationContract } from \"../contracts\";\nimport type { DatabasePayload, Id } from \"../types\";\nimport {\n type NotificationColumnMapHost,\n type ResolvedNotificationColumnMap,\n resolveColumnMap,\n} from \"./column-map\";\nimport type { DatabaseNotification } from \"./database-notification\";\n\n/**\n * Filter shape — logical keys the repo understands. `filterBy` maps each to a\n * physical column (or a predicate) derived from the model's `columnMap`.\n */\nexport type NotificationsFilter = {\n id?: Id;\n type?: string;\n /** Mode-agnostic unread filter — resolves to the model's read-state column. */\n unread?: boolean;\n recipientId?: Id;\n idempotencyKey?: string;\n};\n\n/** Class form of a `DatabaseNotification` subclass — what `configure()` accepts. */\nexport type NotificationModelClass = (new (...args: any[]) => DatabaseNotification) &\n NotificationColumnMapHost;\n\nexport class BaseNotificationsRepository<\n TModel extends NotificationContract = NotificationContract,\n> extends RepositoryManager<TModel, NotificationsFilter> {\n /** Resolved physical columns for the bound model — the single source. */\n private readonly columns: ResolvedNotificationColumnMap;\n\n public filterBy: FilterRules;\n\n public constructor(model?: NotificationModelClass) {\n super();\n\n if (model) {\n this.source = model;\n }\n\n this.columns = resolveColumnMap(model?.columnMap);\n this.filterBy = this.buildFilterBy();\n }\n\n /** The tenant column for this model, or `undefined` for single-tenant. */\n public get tenantColumn(): string | undefined {\n return this.columns.tenant;\n }\n\n /**\n * Build `filterBy` from the resolved column map. `unread` is the\n * mode-agnostic read filter; `isRead` / `readAt` / `tenant` are exposed for\n * direct filtering only when the model declares those columns.\n */\n private buildFilterBy(): FilterRules {\n const { recipient, tenant, readAt, isRead } = this.columns;\n\n const rules: FilterRules = {\n id: \"=\",\n type: \"=\",\n recipientId: [\"=\", recipient],\n idempotencyKey: [\"=\", \"idempotency_key\"],\n unread: (value, query) => {\n if (value !== true && value !== \"true\") {\n return;\n }\n\n if (isRead) {\n query.where(isRead, false);\n } else if (readAt) {\n query.whereNull(readAt);\n }\n },\n };\n\n if (isRead) {\n rules.isRead = [\"=\", isRead];\n }\n\n if (readAt) {\n rules.readAt = [\"=\", readAt];\n }\n\n if (tenant) {\n rules.tenant = [\"=\", tenant];\n }\n\n return rules;\n }\n\n /**\n * Translate an object keyed by LOGICAL names into one keyed by PHYSICAL\n * columns, using `filterBy` as the lookup. Keys with no `[op, column]` tuple\n * (e.g. `title`, `body`, `payload`, `type`, `id`) pass through unchanged —\n * they're already physical. Used for every write / update / delete payload +\n * filter (the paths `RepositoryManager` does NOT map).\n */\n protected toRow(obj: Record<string, unknown>): Record<string, unknown> {\n const rules = this.filterBy as Record<string, unknown>;\n const row: Record<string, unknown> = {};\n\n for (const [logical, value] of Object.entries(obj)) {\n const rule = rules[logical];\n const column = Array.isArray(rule) ? (rule[1] as string) : logical;\n row[column] = value;\n }\n\n return row;\n }\n\n /**\n * Runtime backstop for the compile-time `DatabasePayload` `Omit`: untyped\n * callers (`notify.channel(name).send`, payloads assembled from request\n * JSON) can smuggle server-owned keys past the type system, and spreading\n * them into the row would let a payload redirect the notification into\n * another recipient's inbox (recipient spoofing), cross tenants, pre-set\n * read-state, or clobber the primary key. Drops BOTH the logical names and\n * the model's resolved physical columns — `toRow` passes unmapped keys\n * through verbatim, so a payload carrying `user_id` would otherwise reach\n * the recipient column directly.\n */\n private stripServerOwnedKeys(input: DatabasePayload): Record<string, unknown> {\n const { recipient, tenant, readAt, isRead } = this.columns;\n const serverOwned = new Set<string | undefined>([\n \"id\",\n \"recipientId\",\n \"tenant\",\n \"readAt\",\n \"isRead\",\n recipient,\n tenant,\n readAt,\n isRead,\n ]);\n\n const safe: Record<string, unknown> = {};\n\n for (const [key, value] of Object.entries(input)) {\n if (!serverOwned.has(key)) {\n safe[key] = value;\n }\n }\n\n return safe;\n }\n\n /**\n * Create one row for the recipient. New rows start unread; the tenant column\n * (when the model declares one) is written from `tenantId`, which the\n * database channel reads off the recipient. `recipientId` and the other\n * server-owned keys always come from the trusted arguments — matching keys\n * in `input` are stripped, never merged.\n *\n * Idempotency: when `input.idempotencyKey` is set, find-or-create — return\n * the existing row instead of inserting a duplicate. A unique index on the\n * key is the backstop for the rare insert race (we re-fetch on conflict).\n */\n public async createFor(\n recipientId: Id,\n input: DatabasePayload,\n tenantId?: Id,\n ): Promise<TModel> {\n const { idempotencyKey } = input;\n\n if (idempotencyKey) {\n const existing = await this.firstByIdempotencyKey(recipientId, idempotencyKey);\n if (existing) {\n return existing;\n }\n }\n\n const row: Record<string, unknown> = { ...this.stripServerOwnedKeys(input), recipientId };\n\n if (this.columns.isRead) {\n row.isRead = false;\n }\n\n if (this.columns.tenant && tenantId !== undefined) {\n row.tenant = tenantId;\n }\n\n try {\n return await this.create(this.toRow(row));\n } catch (error) {\n // Lost the insert race to a concurrent send with the same key → re-fetch.\n if (idempotencyKey) {\n const existing = await this.firstByIdempotencyKey(recipientId, idempotencyKey);\n if (existing) {\n return existing;\n }\n }\n\n throw error;\n }\n }\n\n /**\n * Bulk-create one row per recipient sharing the same input + tenant. Reserved\n * for the Phase-2 `Channel.sendMany` fan-out hook; not on the per-recipient\n * send path yet.\n */\n public createManyFor(\n recipientIds: Id[],\n input: DatabasePayload,\n tenantId?: Id,\n ): Promise<TModel[]> {\n return Promise.all(\n recipientIds.map((recipientId) => this.createFor(recipientId, input, tenantId)),\n );\n }\n\n /**\n * Mark rows read — recipient-scoped. `id` omitted → all unread rows for the\n * recipient; `id` given → that one row, still scoped to the recipient. Scoped\n * to unread so a re-mark never overwrites an earlier `read_at` timestamp.\n * Sets whichever read-state column(s) the model declares.\n */\n public markRead(recipientId: Id, id?: Id): Promise<number> {\n const { isRead, readAt } = this.columns;\n const unreadScope = isRead ? { isRead: false } : { readAt: null };\n\n const data: Record<string, unknown> = {};\n\n if (isRead) {\n data.isRead = true;\n }\n\n if (readAt) {\n data.readAt = new Date();\n }\n\n return this.updateMany(\n this.toRow({ recipientId, ...unreadScope, ...(id !== undefined ? { id } : {}) }),\n this.toRow(data),\n );\n }\n\n /**\n * Inverse of `markRead`, same recipient-scoping. No unread scope needed —\n * clearing the read-state of an already-unread row is a no-op.\n */\n public markUnread(recipientId: Id, id?: Id): Promise<number> {\n const { isRead, readAt } = this.columns;\n const data: Record<string, unknown> = {};\n\n if (isRead) {\n data.isRead = false;\n }\n\n if (readAt) {\n data.readAt = null;\n }\n\n return this.updateMany(\n this.toRow({ recipientId, ...(id !== undefined ? { id } : {}) }),\n this.toRow(data),\n );\n }\n\n /** Find one row for a recipient (read path — `filterBy` maps the keys). */\n public findFor(recipientId: Id, id: Id): Promise<TModel | null> {\n return this.first({ recipientId, id } as NotificationsFilter);\n }\n\n /**\n * Delete rows — recipient-scoped. `id` omitted → clear all for the\n * recipient; `id` given → that one row.\n */\n public deleteFor(recipientId: Id, id?: Id): Promise<number> {\n return this.deleteMany(this.toRow({ recipientId, ...(id !== undefined ? { id } : {}) }));\n }\n\n /** Read path — `filterBy` maps `recipientId`/`idempotencyKey` to columns. */\n private firstByIdempotencyKey(recipientId: Id, idempotencyKey: string): Promise<TModel | null> {\n return this.first({ recipientId, idempotencyKey } as NotificationsFilter);\n }\n}\n","/**\n * Shipped BASE in-app notification model. The scaffolded user model\n * `extends DatabaseNotification`, sets `static table`, and declares its\n * physical columns ONCE via `static columnMap`. Every accessor below derives\n * from that map, so the model, the repository, and the migration agree by\n * construction.\n *\n * The package ships NO concrete table or migration — those eject to userland\n * (thin eject). See `notificationColumns()` for the matching migration factory.\n *\n * @example\n * @RegisterModel()\n * export class Notification extends DatabaseNotification {\n * public static table = \"notifications\";\n * public static columnMap: NotificationColumnMap = {\n * tenant: \"organization_id\",\n * readAt: \"read_at\",\n * };\n * }\n */\nimport { Model } from \"@warlock.js/cascade\";\nimport type { NotificationContract } from \"../contracts\";\nimport type { Id } from \"../types\";\nimport {\n type NotificationColumnMap,\n type ResolvedNotificationColumnMap,\n resolveColumnMap,\n} from \"./column-map\";\n\nexport abstract class DatabaseNotification extends Model implements NotificationContract {\n /**\n * Physical column bindings for THIS model's table. Override in the subclass;\n * the empty default resolves to `{ recipient: \"user_id\", readAt: \"read_at\" }`.\n */\n public static columnMap: NotificationColumnMap = {};\n\n /** The resolved column map for this row's model — defaults applied. */\n protected get columns(): ResolvedNotificationColumnMap {\n return resolveColumnMap((this.constructor as typeof DatabaseNotification).columnMap);\n }\n\n public get recipientId(): Id {\n return this.get(this.columns.recipient);\n }\n\n /** Tenant scope value, or `undefined` for single-tenant models. */\n public get tenantId(): Id | undefined {\n const { tenant } = this.columns;\n return tenant ? this.get(tenant) : undefined;\n }\n\n public get type(): string {\n return this.get(\"type\");\n }\n\n public get isRead(): boolean {\n const { isRead, readAt } = this.columns;\n\n if (isRead) {\n return Boolean(this.get(isRead));\n }\n\n return readAt ? this.get(readAt) != null : false;\n }\n\n public get readAt(): Date | null {\n const { readAt } = this.columns;\n return readAt ? (this.get(readAt) ?? null) : null;\n }\n\n /**\n * Mark this row read. Sets whichever read-state column(s) the model\n * declares — the boolean (fast unread filtering) and/or the timestamp\n * (when it was read). Persists via `save()`.\n */\n public async markRead(): Promise<void> {\n const { isRead, readAt } = this.columns;\n\n if (isRead) {\n this.set(isRead, true);\n }\n\n if (readAt) {\n this.set(readAt, new Date());\n }\n\n await this.save();\n }\n}\n","/**\n * `inApp` — app-facing facade for the in-app/database channel.\n *\n * `inApp.configure({ model | repository })` binds the in-app store AND\n * returns the `database` channel — so the read side and the dispatched\n * write side share ONE repository instance.\n * • `{ model: Notification }` — 90% case; default repo built internally.\n * • `{ repository: myRepo }` — 10% case; custom column mapping / extras.\n *\n * No zero-arg form: the package ships no concrete model/table (thin eject),\n * so the dev MUST tell us which model to use.\n *\n * Every read + mutation is RECIPIENT-SCOPED by construction — `markAsRead(user,\n * id)` cannot flip a row belonging to a different recipient even if handed a\n * foreign id (the recipient id is forced into the filter → 0 rows match).\n *\n * @example\n * // src/config/notifications.ts:\n * inApp.configure({ model: Notification }),\n *\n * // anywhere:\n * const unread = await inApp.listUnread(user);\n * const badge = await inApp.countUnread(user);\n * const one = await inApp.find(user, \"ntf_123\");\n * await inApp.markAsRead(user, \"ntf_123\");\n * await inApp.markAsRead(user); // mark ALL unread\n * await inApp.dismiss(user, \"ntf_123\"); // delete one\n * await inApp.dismiss(user); // clear all for this recipient\n */\nimport type { TypedRepositoryOptionsWithPages } from \"@warlock.js/core\";\nimport { databaseChannel } from \"../channels/database-channel\";\nimport type { Channel } from \"../contracts\";\nimport type { DatabasePayload, Id, Notifiable } from \"../types\";\nimport {\n BaseNotificationsRepository,\n type NotificationModelClass,\n type NotificationsFilter,\n} from \"./base-notifications-repository\";\n\n/**\n * List options for the read methods — the repository's paginated options\n * (`page` / `limit` / `orderBy` / filter keys). `recipientId` is always forced\n * from the recipient argument, so passing it here has no effect.\n */\nexport type NotificationsListOptions = TypedRepositoryOptionsWithPages<NotificationsFilter>;\n\n/**\n * Configure options — discriminated so `model` and `repository` are mutually\n * exclusive at the type level. Future knobs (cache / realtime / naming) slot\n * in here without breaking the signature.\n */\nexport type ConfigureOptions =\n | { model: NotificationModelClass; repository?: never }\n | { repository: BaseNotificationsRepository; model?: never };\n\nconst idOf = (recipient: Notifiable | Id): Id =>\n typeof recipient === \"object\" ? recipient.id : recipient;\n\nclass InApp {\n private repo?: BaseNotificationsRepository;\n\n /**\n * Bind the in-app store AND return the `database` channel. Called once\n * from `config/notifications.ts`. Subsequent calls REPLACE the binding.\n */\n public configure(options: ConfigureOptions): Channel<DatabasePayload> {\n this.repo =\n \"repository\" in options && options.repository\n ? options.repository\n : new BaseNotificationsRepository(options.model);\n return databaseChannel(this.repo);\n }\n\n private get repository(): BaseNotificationsRepository {\n if (!this.repo) {\n throw new Error(\n \"In-app notifications not configured — add \" +\n \"`database: inApp.configure({ model: Notification })` to config/notifications.ts\",\n );\n }\n return this.repo;\n }\n\n /** General list — scoped to recipient; pass `options` for paging + filters. */\n public list(recipient: Notifiable | Id, options?: NotificationsListOptions) {\n return this.repository.list({ ...options, recipientId: idOf(recipient) });\n }\n\n /** Unread-only list — common case for badges + dashboards. */\n public listUnread(recipient: Notifiable | Id, options?: NotificationsListOptions) {\n return this.repository.list({ ...options, recipientId: idOf(recipient), unread: true });\n }\n\n /**\n * Cached unread count — backs the badge. Auto-invalidated by the repo's\n * model create/update events (`RepositoryManager.registerEvents`).\n */\n public countUnread(recipient: Notifiable | Id) {\n return this.repository.countCached({ recipientId: idOf(recipient), unread: true });\n }\n\n /** Find one notification for a recipient — for a detail view. */\n public find(recipient: Notifiable | Id, id: Id) {\n return this.repository.findFor(idOf(recipient), id);\n }\n\n /** Mark read — `id` omitted = all unread for this recipient. */\n public markAsRead(recipient: Notifiable | Id, id?: Id) {\n return this.repository.markRead(idOf(recipient), id);\n }\n\n /** Mark unread — same recipient-scoping. */\n public markAsUnread(recipient: Notifiable | Id, id?: Id) {\n return this.repository.markUnread(idOf(recipient), id);\n }\n\n /** Delete/dismiss — `id` omitted = clear all for this recipient. */\n public dismiss(recipient: Notifiable | Id, id?: Id) {\n return this.repository.deleteFor(idOf(recipient), id);\n }\n}\n\n/** Single in-app facade — bound at boot via `inApp.configure(...)`. */\nexport const inApp = new InApp();\n","/**\n * Column factory for the notifications table, driven by the model's\n * `columnMap`. The recipient / read-state / tenant columns take the NAMES the\n * model declares, so the table, the repository, and the model accessors all\n * agree by construction. The stable lexical columns (`type`, `title`, `body`,\n * `payload`, `idempotency_key`) keep fixed names.\n *\n * Read-state follows the map's presence rules (see `NotificationColumnMap`):\n * `readAt` → a nullable timestamp, `isRead` → an indexed boolean, both → both.\n *\n * Spread + extend for app-specific extras (FK references, composite indexes).\n *\n * @example\n * import { Migration } from \"@warlock.js/cascade\";\n * import { notificationColumns } from \"@warlock.js/notifications\";\n * import { Notification } from \"../notification.model\";\n *\n * export default Migration.create(Notification, notificationColumns(Notification));\n *\n * @example // with extras (multi-tenant FK)\n * export default Migration.create(Notification, {\n * ...notificationColumns(Notification),\n * organization_id: uuid().references(Organization.table).notNullable(),\n * });\n */\nimport { boolCol, type ColumnMap, json, string, text, timestamp, uuid } from \"@warlock.js/cascade\";\nimport { type NotificationColumnMapHost, resolveColumnMap } from \"../in-app/column-map\";\n\n/**\n * Returns the column map for a notification table, named from the model's\n * `columnMap`. Without a model, falls back to the resolved defaults\n * (`user_id` recipient + `read_at` read-state).\n */\nexport function notificationColumns(model?: NotificationColumnMapHost): ColumnMap {\n const { recipient, tenant, readAt, isRead } = resolveColumnMap(model?.columnMap);\n\n const columns: ColumnMap = {\n [recipient]: uuid().index().notNullable(),\n type: string().index().notNullable(),\n title: string().notNullable(),\n body: text().nullable(),\n payload: json().nullable(),\n };\n\n if (readAt) {\n columns[readAt] = timestamp().nullable();\n }\n\n if (isRead) {\n columns[isRead] = boolCol().default(false).index();\n }\n\n if (tenant) {\n columns[tenant] = uuid().index().notNullable();\n }\n\n // Unique so a retried send can't insert a duplicate. Nullable: most rows\n // have no key (NULLs are distinct in a unique index). `createFor` does\n // find-or-create; this constraint is the race backstop.\n columns.idempotency_key = string().nullable().unique();\n\n return columns;\n}\n","/**\n * Lazily load `@warlock.js/herald` — the optional peer backing `.queue()`.\n *\n * Memoized: the dynamic import runs at most once. A missing package surfaces a\n * curated install message at use time rather than a boot-time resolution error\n * (the lazy-optional-peer pattern used across the framework's drivers).\n */\nlet heraldModule: typeof import(\"@warlock.js/herald\") | undefined;\n\nconst INSTALL_INSTRUCTIONS = `\nThe notifications queue requires the @warlock.js/herald package.\nInstall it with:\n\n npm install @warlock.js/herald\n\nOr with your preferred package manager:\n\n pnpm add @warlock.js/herald\n yarn add @warlock.js/herald\n`.trim();\n\nexport async function loadHerald(): Promise<typeof import(\"@warlock.js/herald\")> {\n if (heraldModule) {\n return heraldModule;\n }\n\n try {\n heraldModule = await import(\"@warlock.js/herald\");\n return heraldModule;\n } catch {\n throw new Error(INSTALL_INSTRUCTIONS);\n }\n}\n\n/** The herald channel notification jobs are published to / consumed from. */\nexport const DEFAULT_QUEUE_CHANNEL = \"notifications.dispatch\";\n","/**\n * Herald-backed `QueueDispatcher` — the production backend for `.queue()`.\n *\n * `defineNotification` renders payloads + resolves routes BEFORE handing a job\n * to the dispatcher, so the job is fully serializable (`{ channel, route,\n * payload, options }`) — no model re-hydration or closure serialization. This\n * dispatcher just publishes the job onto a herald channel; the worker\n * (`startNotificationsWorker`) consumes it and runs `channel.send`.\n *\n * Connection is the app's job — the `@warlock.js/herald` connector connects the\n * broker from `config/herald.ts` at boot; this dispatcher only calls `herald()`.\n *\n * @example src/config/notifications.ts — declarative; the connector registers it\n * import { type NotificationConfig, heraldQueue, inApp, mailChannel } from \"@warlock.js/notifications\";\n *\n * const config: NotificationConfig = {\n * channels: { mail: mailChannel(), database: inApp.configure({ model: Notification }) },\n * queue: heraldQueue(), // → `.queue()` now works\n * };\n *\n * export default config;\n */\nimport type { QueueDispatcher } from \"../contracts\";\nimport { DEFAULT_QUEUE_CHANNEL, loadHerald } from \"./load-herald\";\n\nexport type HeraldQueueOptions = {\n /** Herald channel to publish jobs to. Default `\"notifications.dispatch\"`. */\n channel?: string;\n /** Herald broker name (multi-broker setups). Default broker if omitted. */\n broker?: string;\n};\n\nexport function heraldQueue(options: HeraldQueueOptions = {}): QueueDispatcher {\n const channelName = options.channel ?? DEFAULT_QUEUE_CHANNEL;\n\n return {\n async dispatch(job) {\n const { herald } = await loadHerald();\n await herald(options.broker).channel(channelName).publish(job);\n },\n };\n}\n","/**\n * Notification queue worker — consumes the jobs `heraldQueue` publishes and\n * runs the actual `channel.send`. Call this once in a worker process (or the\n * web process) after the notifications config + herald broker are up.\n *\n * The job carries an ALREADY-RENDERED payload + resolved route, so the worker\n * only looks the channel up by name and dispatches — no notifiable needed.\n *\n * @example\n * // in a worker entrypoint, after the notifications config + broker are up\n * import { startNotificationsWorker } from \"@warlock.js/notifications\";\n * await startNotificationsWorker();\n */\nimport { log } from \"@warlock.js/logger\";\nimport { getNotificationConfig } from \"../config\";\nimport type { ChannelName, SendOptions } from \"../types\";\nimport { DEFAULT_QUEUE_CHANNEL, loadHerald } from \"./load-herald\";\n\ntype NotificationJob = {\n channel: string;\n route: unknown;\n payload: unknown;\n options: SendOptions;\n};\n\nexport type WorkerOptions = {\n /** Herald channel to consume from. Must match the dispatcher. Default `\"notifications.dispatch\"`. */\n channel?: string;\n /** Herald broker name. Default broker if omitted. */\n broker?: string;\n};\n\nexport async function startNotificationsWorker(options: WorkerOptions = {}): Promise<void> {\n const channelName = options.channel ?? DEFAULT_QUEUE_CHANNEL;\n const { herald } = await loadHerald();\n\n await herald(options.broker)\n .channel(channelName)\n .subscribe(async (message: { payload: unknown }, ctx: { ack(): Promise<void> }) => {\n const job = message.payload as NotificationJob;\n\n try {\n const channel = getNotificationConfig().channels[job.channel as ChannelName];\n\n if (!channel) {\n // Unknown channel — log and ack to avoid a poison-message loop.\n log.error(\"notifications\", \"queue.worker\", `Unknown channel \"${job.channel}\" — dropping job`);\n await ctx.ack();\n return;\n }\n\n await channel.send({\n payload: job.payload as never,\n route: job.route as never,\n options: job.options,\n });\n\n await ctx.ack();\n } catch (error) {\n // Phase 2: log + ack (no retry/DLQ yet — that lands with the\n // delay-aware worker). A dead-letter strategy is a follow-up.\n log.error(\"notifications\", \"queue.worker\", error as Error);\n await ctx.ack();\n }\n });\n}\n"],"mappings":"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"}
|
|
@@ -39,10 +39,24 @@ declare class BaseNotificationsRepository<TModel extends NotificationContract =
|
|
|
39
39
|
* filter (the paths `RepositoryManager` does NOT map).
|
|
40
40
|
*/
|
|
41
41
|
protected toRow(obj: Record<string, unknown>): Record<string, unknown>;
|
|
42
|
+
/**
|
|
43
|
+
* Runtime backstop for the compile-time `DatabasePayload` `Omit`: untyped
|
|
44
|
+
* callers (`notify.channel(name).send`, payloads assembled from request
|
|
45
|
+
* JSON) can smuggle server-owned keys past the type system, and spreading
|
|
46
|
+
* them into the row would let a payload redirect the notification into
|
|
47
|
+
* another recipient's inbox (recipient spoofing), cross tenants, pre-set
|
|
48
|
+
* read-state, or clobber the primary key. Drops BOTH the logical names and
|
|
49
|
+
* the model's resolved physical columns — `toRow` passes unmapped keys
|
|
50
|
+
* through verbatim, so a payload carrying `user_id` would otherwise reach
|
|
51
|
+
* the recipient column directly.
|
|
52
|
+
*/
|
|
53
|
+
private stripServerOwnedKeys;
|
|
42
54
|
/**
|
|
43
55
|
* Create one row for the recipient. New rows start unread; the tenant column
|
|
44
56
|
* (when the model declares one) is written from `tenantId`, which the
|
|
45
|
-
* database channel reads off the recipient.
|
|
57
|
+
* database channel reads off the recipient. `recipientId` and the other
|
|
58
|
+
* server-owned keys always come from the trusted arguments — matching keys
|
|
59
|
+
* in `input` are stripped, never merged.
|
|
46
60
|
*
|
|
47
61
|
* Idempotency: when `input.idempotencyKey` is set, find-or-create — return
|
|
48
62
|
* the existing row instead of inserting a duplicate. A unique index on the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"base-notifications-repository.d.mts","names":[],"sources":["../../../../../../../notifications/src/in-app/base-notifications-repository.ts"],"mappings":";;;;;;;;;;;KA2CY,mBAAA;EACV,EAAA,GAAK,EAAA;EACL,IAAA,
|
|
1
|
+
{"version":3,"file":"base-notifications-repository.d.mts","names":[],"sources":["../../../../../../../notifications/src/in-app/base-notifications-repository.ts"],"mappings":";;;;;;;;;;;KA2CY,mBAAA;EACV,EAAA,GAAK,EAAA;EACL,IAAA,WAgJe;EA9If,MAAA;EACA,WAAA,GAAc,EAAE;EAChB,cAAA;AAAA;;KAIU,sBAAA,YAAkC,IAAA,YAAgB,oBAAA,IAC5D,yBAAyB;AAAA,cAEd,2BAAA,gBACI,oBAAA,GAAuB,oBAAA,UAC9B,iBAAA,CAAkB,MAAA,EAAQ,mBAAA;EAkLvB;EAAA,iBAhLM,OAAA;EAEV,QAAA,EAAU,WAAA;cAEE,KAAA,GAAQ,sBAAA;EAwLgB;EAAA,IA5KhC,YAAA;EAoM6B;;;;;EAAA,QA3LhC,aAAA;EAsNsB;;;;;;;EAAA,UA3KpB,KAAA,CAAM,GAAA,EAAK,MAAA,oBAA0B,MAAA;EAvET;;;;;;;;;;;EAAA,QA+F9B,oBAAA;EAxBE;;;;;;;;;;;EA4DG,SAAA,CACX,WAAA,EAAa,EAAA,EACb,KAAA,EAAO,eAAA,EACP,QAAA,GAAW,EAAA,GACV,OAAA,CAAQ,MAAA;EAAR;;;;;EAwCI,aAAA,CACL,YAAA,EAAc,EAAA,IACd,KAAA,EAAO,eAAA,EACP,QAAA,GAAW,EAAA,GACV,OAAA,CAAQ,MAAA;EAFT;;;;;;EAcK,QAAA,CAAS,WAAA,EAAa,EAAA,EAAI,EAAA,GAAK,EAAA,GAAK,OAAA;EAA3B;;;;EAwBT,UAAA,CAAW,WAAA,EAAa,EAAA,EAAI,EAAA,GAAK,EAAA,GAAK,OAAA;EAAd;EAmBxB,OAAA,CAAQ,WAAA,EAAa,EAAA,EAAI,EAAA,EAAI,EAAA,GAAK,OAAA,CAAQ,MAAA;EAnBT;;;;EA2BjC,SAAA,CAAU,WAAA,EAAa,EAAA,EAAI,EAAA,GAAK,EAAA,GAAK,OAAA;EAR7B;EAAA,QAaP,qBAAA;AAAA"}
|
|
@@ -86,9 +86,39 @@ var BaseNotificationsRepository = class extends RepositoryManager {
|
|
|
86
86
|
return row;
|
|
87
87
|
}
|
|
88
88
|
/**
|
|
89
|
+
* Runtime backstop for the compile-time `DatabasePayload` `Omit`: untyped
|
|
90
|
+
* callers (`notify.channel(name).send`, payloads assembled from request
|
|
91
|
+
* JSON) can smuggle server-owned keys past the type system, and spreading
|
|
92
|
+
* them into the row would let a payload redirect the notification into
|
|
93
|
+
* another recipient's inbox (recipient spoofing), cross tenants, pre-set
|
|
94
|
+
* read-state, or clobber the primary key. Drops BOTH the logical names and
|
|
95
|
+
* the model's resolved physical columns — `toRow` passes unmapped keys
|
|
96
|
+
* through verbatim, so a payload carrying `user_id` would otherwise reach
|
|
97
|
+
* the recipient column directly.
|
|
98
|
+
*/
|
|
99
|
+
stripServerOwnedKeys(input) {
|
|
100
|
+
const { recipient, tenant, readAt, isRead } = this.columns;
|
|
101
|
+
const serverOwned = new Set([
|
|
102
|
+
"id",
|
|
103
|
+
"recipientId",
|
|
104
|
+
"tenant",
|
|
105
|
+
"readAt",
|
|
106
|
+
"isRead",
|
|
107
|
+
recipient,
|
|
108
|
+
tenant,
|
|
109
|
+
readAt,
|
|
110
|
+
isRead
|
|
111
|
+
]);
|
|
112
|
+
const safe = {};
|
|
113
|
+
for (const [key, value] of Object.entries(input)) if (!serverOwned.has(key)) safe[key] = value;
|
|
114
|
+
return safe;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
89
117
|
* Create one row for the recipient. New rows start unread; the tenant column
|
|
90
118
|
* (when the model declares one) is written from `tenantId`, which the
|
|
91
|
-
* database channel reads off the recipient.
|
|
119
|
+
* database channel reads off the recipient. `recipientId` and the other
|
|
120
|
+
* server-owned keys always come from the trusted arguments — matching keys
|
|
121
|
+
* in `input` are stripped, never merged.
|
|
92
122
|
*
|
|
93
123
|
* Idempotency: when `input.idempotencyKey` is set, find-or-create — return
|
|
94
124
|
* the existing row instead of inserting a duplicate. A unique index on the
|
|
@@ -101,8 +131,8 @@ var BaseNotificationsRepository = class extends RepositoryManager {
|
|
|
101
131
|
if (existing) return existing;
|
|
102
132
|
}
|
|
103
133
|
const row = {
|
|
104
|
-
|
|
105
|
-
|
|
134
|
+
...this.stripServerOwnedKeys(input),
|
|
135
|
+
recipientId
|
|
106
136
|
};
|
|
107
137
|
if (this.columns.isRead) row.isRead = false;
|
|
108
138
|
if (this.columns.tenant && tenantId !== void 0) row.tenant = tenantId;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"base-notifications-repository.mjs","names":[],"sources":["../../../../../../../notifications/src/in-app/base-notifications-repository.ts"],"sourcesContent":["/**\n * Shipped behavior repository for the in-app store.\n *\n * Concrete (NOT abstract) so `inApp.configure({ model })` can\n * `new BaseNotificationsRepository(model)` without subclassing. The dev only\n * extends this when they need extra query methods.\n *\n * ## The single source of truth: the model's `columnMap`\n *\n * The model declares its physical columns ONCE via `static columnMap`. The\n * constructor resolves that map and derives BOTH sides from it:\n *\n * - the READ path — `filterBy` maps logical keys (`recipientId`, `unread`,\n * `type`, …) onto the model's columns; `RepositoryManager` applies it.\n * - the WRITE / UPDATE / DELETE path — `createFor` / `markRead` / … translate\n * logical keys through the same `filterBy` via `toRow`.\n *\n * Because both come from one `columnMap`, reads and writes can never target\n * different columns. `unread` is mode-agnostic: it filters `is_read = false`\n * when the model has that flag, otherwise `read_at IS NULL`.\n *\n * @example default (SQL snake_case — no subclass needed)\n * inApp.configure({ model: Notification })\n *\n * @example custom columns — declare them on the model, not here\n * class Notification extends DatabaseNotification {\n * public static columnMap = { recipient: \"audience_id\", isRead: \"seen\" };\n * }\n */\nimport { type FilterRules, RepositoryManager } from \"@warlock.js/core\";\nimport type { NotificationContract } from \"../contracts\";\nimport type { DatabasePayload, Id } from \"../types\";\nimport {\n type NotificationColumnMapHost,\n type ResolvedNotificationColumnMap,\n resolveColumnMap,\n} from \"./column-map\";\nimport type { DatabaseNotification } from \"./database-notification\";\n\n/**\n * Filter shape — logical keys the repo understands. `filterBy` maps each to a\n * physical column (or a predicate) derived from the model's `columnMap`.\n */\nexport type NotificationsFilter = {\n id?: Id;\n type?: string;\n /** Mode-agnostic unread filter — resolves to the model's read-state column. */\n unread?: boolean;\n recipientId?: Id;\n idempotencyKey?: string;\n};\n\n/** Class form of a `DatabaseNotification` subclass — what `configure()` accepts. */\nexport type NotificationModelClass = (new (...args: any[]) => DatabaseNotification) &\n NotificationColumnMapHost;\n\nexport class BaseNotificationsRepository<\n TModel extends NotificationContract = NotificationContract,\n> extends RepositoryManager<TModel, NotificationsFilter> {\n /** Resolved physical columns for the bound model — the single source. */\n private readonly columns: ResolvedNotificationColumnMap;\n\n public filterBy: FilterRules;\n\n public constructor(model?: NotificationModelClass) {\n super();\n\n if (model) {\n this.source = model;\n }\n\n this.columns = resolveColumnMap(model?.columnMap);\n this.filterBy = this.buildFilterBy();\n }\n\n /** The tenant column for this model, or `undefined` for single-tenant. */\n public get tenantColumn(): string | undefined {\n return this.columns.tenant;\n }\n\n /**\n * Build `filterBy` from the resolved column map. `unread` is the\n * mode-agnostic read filter; `isRead` / `readAt` / `tenant` are exposed for\n * direct filtering only when the model declares those columns.\n */\n private buildFilterBy(): FilterRules {\n const { recipient, tenant, readAt, isRead } = this.columns;\n\n const rules: FilterRules = {\n id: \"=\",\n type: \"=\",\n recipientId: [\"=\", recipient],\n idempotencyKey: [\"=\", \"idempotency_key\"],\n unread: (value, query) => {\n if (value !== true && value !== \"true\") {\n return;\n }\n\n if (isRead) {\n query.where(isRead, false);\n } else if (readAt) {\n query.whereNull(readAt);\n }\n },\n };\n\n if (isRead) {\n rules.isRead = [\"=\", isRead];\n }\n\n if (readAt) {\n rules.readAt = [\"=\", readAt];\n }\n\n if (tenant) {\n rules.tenant = [\"=\", tenant];\n }\n\n return rules;\n }\n\n /**\n * Translate an object keyed by LOGICAL names into one keyed by PHYSICAL\n * columns, using `filterBy` as the lookup. Keys with no `[op, column]` tuple\n * (e.g. `title`, `body`, `payload`, `type`, `id`) pass through unchanged —\n * they're already physical. Used for every write / update / delete payload +\n * filter (the paths `RepositoryManager` does NOT map).\n */\n protected toRow(obj: Record<string, unknown>): Record<string, unknown> {\n const rules = this.filterBy as Record<string, unknown>;\n const row: Record<string, unknown> = {};\n\n for (const [logical, value] of Object.entries(obj)) {\n const rule = rules[logical];\n const column = Array.isArray(rule) ? (rule[1] as string) : logical;\n row[column] = value;\n }\n\n return row;\n }\n\n /**\n * Create one row for the recipient. New rows start unread; the tenant column\n * (when the model declares one) is written from `tenantId`, which the\n * database channel reads off the recipient.\n *\n * Idempotency: when `input.idempotencyKey` is set, find-or-create — return\n * the existing row instead of inserting a duplicate. A unique index on the\n * key is the backstop for the rare insert race (we re-fetch on conflict).\n */\n public async createFor(\n recipientId: Id,\n input: DatabasePayload,\n tenantId?: Id,\n ): Promise<TModel> {\n const { idempotencyKey } = input;\n\n if (idempotencyKey) {\n const existing = await this.firstByIdempotencyKey(recipientId, idempotencyKey);\n if (existing) {\n return existing;\n }\n }\n\n const row: Record<string, unknown> = { recipientId, ...input };\n\n if (this.columns.isRead) {\n row.isRead = false;\n }\n\n if (this.columns.tenant && tenantId !== undefined) {\n row.tenant = tenantId;\n }\n\n try {\n return await this.create(this.toRow(row));\n } catch (error) {\n // Lost the insert race to a concurrent send with the same key → re-fetch.\n if (idempotencyKey) {\n const existing = await this.firstByIdempotencyKey(recipientId, idempotencyKey);\n if (existing) {\n return existing;\n }\n }\n\n throw error;\n }\n }\n\n /**\n * Bulk-create one row per recipient sharing the same input + tenant. Reserved\n * for the Phase-2 `Channel.sendMany` fan-out hook; not on the per-recipient\n * send path yet.\n */\n public createManyFor(\n recipientIds: Id[],\n input: DatabasePayload,\n tenantId?: Id,\n ): Promise<TModel[]> {\n return Promise.all(\n recipientIds.map((recipientId) => this.createFor(recipientId, input, tenantId)),\n );\n }\n\n /**\n * Mark rows read — recipient-scoped. `id` omitted → all unread rows for the\n * recipient; `id` given → that one row, still scoped to the recipient. Scoped\n * to unread so a re-mark never overwrites an earlier `read_at` timestamp.\n * Sets whichever read-state column(s) the model declares.\n */\n public markRead(recipientId: Id, id?: Id): Promise<number> {\n const { isRead, readAt } = this.columns;\n const unreadScope = isRead ? { isRead: false } : { readAt: null };\n\n const data: Record<string, unknown> = {};\n\n if (isRead) {\n data.isRead = true;\n }\n\n if (readAt) {\n data.readAt = new Date();\n }\n\n return this.updateMany(\n this.toRow({ recipientId, ...unreadScope, ...(id !== undefined ? { id } : {}) }),\n this.toRow(data),\n );\n }\n\n /**\n * Inverse of `markRead`, same recipient-scoping. No unread scope needed —\n * clearing the read-state of an already-unread row is a no-op.\n */\n public markUnread(recipientId: Id, id?: Id): Promise<number> {\n const { isRead, readAt } = this.columns;\n const data: Record<string, unknown> = {};\n\n if (isRead) {\n data.isRead = false;\n }\n\n if (readAt) {\n data.readAt = null;\n }\n\n return this.updateMany(\n this.toRow({ recipientId, ...(id !== undefined ? { id } : {}) }),\n this.toRow(data),\n );\n }\n\n /** Find one row for a recipient (read path — `filterBy` maps the keys). */\n public findFor(recipientId: Id, id: Id): Promise<TModel | null> {\n return this.first({ recipientId, id } as NotificationsFilter);\n }\n\n /**\n * Delete rows — recipient-scoped. `id` omitted → clear all for the\n * recipient; `id` given → that one row.\n */\n public deleteFor(recipientId: Id, id?: Id): Promise<number> {\n return this.deleteMany(this.toRow({ recipientId, ...(id !== undefined ? { id } : {}) }));\n }\n\n /** Read path — `filterBy` maps `recipientId`/`idempotencyKey` to columns. */\n private firstByIdempotencyKey(recipientId: Id, idempotencyKey: string): Promise<TModel | null> {\n return this.first({ recipientId, idempotencyKey } as NotificationsFilter);\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"base-notifications-repository.mjs","names":[],"sources":["../../../../../../../notifications/src/in-app/base-notifications-repository.ts"],"sourcesContent":["/**\n * Shipped behavior repository for the in-app store.\n *\n * Concrete (NOT abstract) so `inApp.configure({ model })` can\n * `new BaseNotificationsRepository(model)` without subclassing. The dev only\n * extends this when they need extra query methods.\n *\n * ## The single source of truth: the model's `columnMap`\n *\n * The model declares its physical columns ONCE via `static columnMap`. The\n * constructor resolves that map and derives BOTH sides from it:\n *\n * - the READ path — `filterBy` maps logical keys (`recipientId`, `unread`,\n * `type`, …) onto the model's columns; `RepositoryManager` applies it.\n * - the WRITE / UPDATE / DELETE path — `createFor` / `markRead` / … translate\n * logical keys through the same `filterBy` via `toRow`.\n *\n * Because both come from one `columnMap`, reads and writes can never target\n * different columns. `unread` is mode-agnostic: it filters `is_read = false`\n * when the model has that flag, otherwise `read_at IS NULL`.\n *\n * @example default (SQL snake_case — no subclass needed)\n * inApp.configure({ model: Notification })\n *\n * @example custom columns — declare them on the model, not here\n * class Notification extends DatabaseNotification {\n * public static columnMap = { recipient: \"audience_id\", isRead: \"seen\" };\n * }\n */\nimport { type FilterRules, RepositoryManager } from \"@warlock.js/core\";\nimport type { NotificationContract } from \"../contracts\";\nimport type { DatabasePayload, Id } from \"../types\";\nimport {\n type NotificationColumnMapHost,\n type ResolvedNotificationColumnMap,\n resolveColumnMap,\n} from \"./column-map\";\nimport type { DatabaseNotification } from \"./database-notification\";\n\n/**\n * Filter shape — logical keys the repo understands. `filterBy` maps each to a\n * physical column (or a predicate) derived from the model's `columnMap`.\n */\nexport type NotificationsFilter = {\n id?: Id;\n type?: string;\n /** Mode-agnostic unread filter — resolves to the model's read-state column. */\n unread?: boolean;\n recipientId?: Id;\n idempotencyKey?: string;\n};\n\n/** Class form of a `DatabaseNotification` subclass — what `configure()` accepts. */\nexport type NotificationModelClass = (new (...args: any[]) => DatabaseNotification) &\n NotificationColumnMapHost;\n\nexport class BaseNotificationsRepository<\n TModel extends NotificationContract = NotificationContract,\n> extends RepositoryManager<TModel, NotificationsFilter> {\n /** Resolved physical columns for the bound model — the single source. */\n private readonly columns: ResolvedNotificationColumnMap;\n\n public filterBy: FilterRules;\n\n public constructor(model?: NotificationModelClass) {\n super();\n\n if (model) {\n this.source = model;\n }\n\n this.columns = resolveColumnMap(model?.columnMap);\n this.filterBy = this.buildFilterBy();\n }\n\n /** The tenant column for this model, or `undefined` for single-tenant. */\n public get tenantColumn(): string | undefined {\n return this.columns.tenant;\n }\n\n /**\n * Build `filterBy` from the resolved column map. `unread` is the\n * mode-agnostic read filter; `isRead` / `readAt` / `tenant` are exposed for\n * direct filtering only when the model declares those columns.\n */\n private buildFilterBy(): FilterRules {\n const { recipient, tenant, readAt, isRead } = this.columns;\n\n const rules: FilterRules = {\n id: \"=\",\n type: \"=\",\n recipientId: [\"=\", recipient],\n idempotencyKey: [\"=\", \"idempotency_key\"],\n unread: (value, query) => {\n if (value !== true && value !== \"true\") {\n return;\n }\n\n if (isRead) {\n query.where(isRead, false);\n } else if (readAt) {\n query.whereNull(readAt);\n }\n },\n };\n\n if (isRead) {\n rules.isRead = [\"=\", isRead];\n }\n\n if (readAt) {\n rules.readAt = [\"=\", readAt];\n }\n\n if (tenant) {\n rules.tenant = [\"=\", tenant];\n }\n\n return rules;\n }\n\n /**\n * Translate an object keyed by LOGICAL names into one keyed by PHYSICAL\n * columns, using `filterBy` as the lookup. Keys with no `[op, column]` tuple\n * (e.g. `title`, `body`, `payload`, `type`, `id`) pass through unchanged —\n * they're already physical. Used for every write / update / delete payload +\n * filter (the paths `RepositoryManager` does NOT map).\n */\n protected toRow(obj: Record<string, unknown>): Record<string, unknown> {\n const rules = this.filterBy as Record<string, unknown>;\n const row: Record<string, unknown> = {};\n\n for (const [logical, value] of Object.entries(obj)) {\n const rule = rules[logical];\n const column = Array.isArray(rule) ? (rule[1] as string) : logical;\n row[column] = value;\n }\n\n return row;\n }\n\n /**\n * Runtime backstop for the compile-time `DatabasePayload` `Omit`: untyped\n * callers (`notify.channel(name).send`, payloads assembled from request\n * JSON) can smuggle server-owned keys past the type system, and spreading\n * them into the row would let a payload redirect the notification into\n * another recipient's inbox (recipient spoofing), cross tenants, pre-set\n * read-state, or clobber the primary key. Drops BOTH the logical names and\n * the model's resolved physical columns — `toRow` passes unmapped keys\n * through verbatim, so a payload carrying `user_id` would otherwise reach\n * the recipient column directly.\n */\n private stripServerOwnedKeys(input: DatabasePayload): Record<string, unknown> {\n const { recipient, tenant, readAt, isRead } = this.columns;\n const serverOwned = new Set<string | undefined>([\n \"id\",\n \"recipientId\",\n \"tenant\",\n \"readAt\",\n \"isRead\",\n recipient,\n tenant,\n readAt,\n isRead,\n ]);\n\n const safe: Record<string, unknown> = {};\n\n for (const [key, value] of Object.entries(input)) {\n if (!serverOwned.has(key)) {\n safe[key] = value;\n }\n }\n\n return safe;\n }\n\n /**\n * Create one row for the recipient. New rows start unread; the tenant column\n * (when the model declares one) is written from `tenantId`, which the\n * database channel reads off the recipient. `recipientId` and the other\n * server-owned keys always come from the trusted arguments — matching keys\n * in `input` are stripped, never merged.\n *\n * Idempotency: when `input.idempotencyKey` is set, find-or-create — return\n * the existing row instead of inserting a duplicate. A unique index on the\n * key is the backstop for the rare insert race (we re-fetch on conflict).\n */\n public async createFor(\n recipientId: Id,\n input: DatabasePayload,\n tenantId?: Id,\n ): Promise<TModel> {\n const { idempotencyKey } = input;\n\n if (idempotencyKey) {\n const existing = await this.firstByIdempotencyKey(recipientId, idempotencyKey);\n if (existing) {\n return existing;\n }\n }\n\n const row: Record<string, unknown> = { ...this.stripServerOwnedKeys(input), recipientId };\n\n if (this.columns.isRead) {\n row.isRead = false;\n }\n\n if (this.columns.tenant && tenantId !== undefined) {\n row.tenant = tenantId;\n }\n\n try {\n return await this.create(this.toRow(row));\n } catch (error) {\n // Lost the insert race to a concurrent send with the same key → re-fetch.\n if (idempotencyKey) {\n const existing = await this.firstByIdempotencyKey(recipientId, idempotencyKey);\n if (existing) {\n return existing;\n }\n }\n\n throw error;\n }\n }\n\n /**\n * Bulk-create one row per recipient sharing the same input + tenant. Reserved\n * for the Phase-2 `Channel.sendMany` fan-out hook; not on the per-recipient\n * send path yet.\n */\n public createManyFor(\n recipientIds: Id[],\n input: DatabasePayload,\n tenantId?: Id,\n ): Promise<TModel[]> {\n return Promise.all(\n recipientIds.map((recipientId) => this.createFor(recipientId, input, tenantId)),\n );\n }\n\n /**\n * Mark rows read — recipient-scoped. `id` omitted → all unread rows for the\n * recipient; `id` given → that one row, still scoped to the recipient. Scoped\n * to unread so a re-mark never overwrites an earlier `read_at` timestamp.\n * Sets whichever read-state column(s) the model declares.\n */\n public markRead(recipientId: Id, id?: Id): Promise<number> {\n const { isRead, readAt } = this.columns;\n const unreadScope = isRead ? { isRead: false } : { readAt: null };\n\n const data: Record<string, unknown> = {};\n\n if (isRead) {\n data.isRead = true;\n }\n\n if (readAt) {\n data.readAt = new Date();\n }\n\n return this.updateMany(\n this.toRow({ recipientId, ...unreadScope, ...(id !== undefined ? { id } : {}) }),\n this.toRow(data),\n );\n }\n\n /**\n * Inverse of `markRead`, same recipient-scoping. No unread scope needed —\n * clearing the read-state of an already-unread row is a no-op.\n */\n public markUnread(recipientId: Id, id?: Id): Promise<number> {\n const { isRead, readAt } = this.columns;\n const data: Record<string, unknown> = {};\n\n if (isRead) {\n data.isRead = false;\n }\n\n if (readAt) {\n data.readAt = null;\n }\n\n return this.updateMany(\n this.toRow({ recipientId, ...(id !== undefined ? { id } : {}) }),\n this.toRow(data),\n );\n }\n\n /** Find one row for a recipient (read path — `filterBy` maps the keys). */\n public findFor(recipientId: Id, id: Id): Promise<TModel | null> {\n return this.first({ recipientId, id } as NotificationsFilter);\n }\n\n /**\n * Delete rows — recipient-scoped. `id` omitted → clear all for the\n * recipient; `id` given → that one row.\n */\n public deleteFor(recipientId: Id, id?: Id): Promise<number> {\n return this.deleteMany(this.toRow({ recipientId, ...(id !== undefined ? { id } : {}) }));\n }\n\n /** Read path — `filterBy` maps `recipientId`/`idempotencyKey` to columns. */\n private firstByIdempotencyKey(recipientId: Id, idempotencyKey: string): Promise<TModel | null> {\n return this.first({ recipientId, idempotencyKey } as NotificationsFilter);\n }\n}\n"],"mappings":"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"}
|
package/llms-full.txt
CHANGED
|
@@ -697,7 +697,7 @@ const channelName = await chooseChannelForUser(user);
|
|
|
697
697
|
await notify.channel(channelName).send(user, payload);
|
|
698
698
|
```
|
|
699
699
|
|
|
700
|
-
This is the only path with `unknown`-typed payloads — prefer the literal `notify.<channel>` when you can.
|
|
700
|
+
This is the only path with `unknown`-typed payloads — prefer the literal `notify.<channel>` when you can. `to` is still what decides the recipient: even for the `database` channel, a `recipientId`-shaped key on `payload` (or the model's physical recipient column name) is stripped before the row is built, so an untyped payload assembled from request JSON can't redirect the write to another recipient — see [`use-in-app/SKILL.md`](../use-in-app/SKILL.md#createfor-write-side--no-idor-either-since-4150).
|
|
701
701
|
|
|
702
702
|
## Opt into preference/rate-limit gating
|
|
703
703
|
|
|
@@ -835,6 +835,10 @@ class BaseNotificationsRepository extends RepositoryManager {
|
|
|
835
835
|
}
|
|
836
836
|
```
|
|
837
837
|
|
|
838
|
+
### `createFor` write side — no IDOR either (since 4.15.0)
|
|
839
|
+
|
|
840
|
+
`recipientId` is always the trusted argument, applied **after** the payload — `createFor`/`createManyFor` strip server-owned keys (`id`, `recipientId`, `tenant`, `readAt`, `isRead`, and their resolved `columnMap` physical column names) off `input` before merging. Before this, a caller passing an untyped payload straight through (request JSON, a channel's `send` args) could smuggle its own `recipientId` — or the raw physical column name — and write the notification into someone else's inbox. `type` / `title` / `body` / `payload` / `idempotencyKey` are untouched; you don't need to sanitize `input` yourself.
|
|
841
|
+
|
|
838
842
|
### `columnMap` — map logical roles → your columns
|
|
839
843
|
|
|
840
844
|
```ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warlock.js/notifications",
|
|
3
3
|
"description": "Multi-channel notifications for Warlock.js — define once, fire anywhere; mail / in-app database / pluggable custom channels with preferences, rate limits, and idempotency.",
|
|
4
|
+
"dependencies": {
|
|
5
|
+
"@warlock.js/cache": "4.16.0",
|
|
6
|
+
"@warlock.js/cascade": "4.16.0",
|
|
7
|
+
"@warlock.js/core": "4.16.0",
|
|
8
|
+
"@warlock.js/logger": "4.16.0"
|
|
9
|
+
},
|
|
10
|
+
"optionalDependencies": {
|
|
11
|
+
"@warlock.js/herald": "*"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/warlockjs/notifications"
|
|
16
|
+
},
|
|
4
17
|
"keywords": [
|
|
5
18
|
"notifications",
|
|
6
19
|
"mail",
|
|
@@ -11,17 +24,7 @@
|
|
|
11
24
|
],
|
|
12
25
|
"author": "hassanzohdy",
|
|
13
26
|
"license": "MIT",
|
|
14
|
-
"
|
|
15
|
-
"type": "git",
|
|
16
|
-
"url": "https://github.com/warlockjs/notifications"
|
|
17
|
-
},
|
|
18
|
-
"dependencies": {
|
|
19
|
-
"@warlock.js/cache": "4.14.0",
|
|
20
|
-
"@warlock.js/cascade": "4.14.0",
|
|
21
|
-
"@warlock.js/core": "4.14.0",
|
|
22
|
-
"@warlock.js/logger": "4.14.0"
|
|
23
|
-
},
|
|
24
|
-
"version": "4.14.0",
|
|
27
|
+
"version": "4.16.0",
|
|
25
28
|
"main": "./cjs/index.cjs",
|
|
26
29
|
"module": "./esm/index.mjs",
|
|
27
30
|
"types": "./esm/index.d.mts",
|
|
@@ -46,7 +46,7 @@ const channelName = await chooseChannelForUser(user);
|
|
|
46
46
|
await notify.channel(channelName).send(user, payload);
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
This is the only path with `unknown`-typed payloads — prefer the literal `notify.<channel>` when you can.
|
|
49
|
+
This is the only path with `unknown`-typed payloads — prefer the literal `notify.<channel>` when you can. `to` is still what decides the recipient: even for the `database` channel, a `recipientId`-shaped key on `payload` (or the model's physical recipient column name) is stripped before the row is built, so an untyped payload assembled from request JSON can't redirect the write to another recipient — see [`use-in-app/SKILL.md`](../use-in-app/SKILL.md#createfor-write-side--no-idor-either-since-4150).
|
|
50
50
|
|
|
51
51
|
## Opt into preference/rate-limit gating
|
|
52
52
|
|
|
@@ -85,6 +85,10 @@ class BaseNotificationsRepository extends RepositoryManager {
|
|
|
85
85
|
}
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
+
### `createFor` write side — no IDOR either (since 4.15.0)
|
|
89
|
+
|
|
90
|
+
`recipientId` is always the trusted argument, applied **after** the payload — `createFor`/`createManyFor` strip server-owned keys (`id`, `recipientId`, `tenant`, `readAt`, `isRead`, and their resolved `columnMap` physical column names) off `input` before merging. Before this, a caller passing an untyped payload straight through (request JSON, a channel's `send` args) could smuggle its own `recipientId` — or the raw physical column name — and write the notification into someone else's inbox. `type` / `title` / `body` / `payload` / `idempotencyKey` are untouched; you don't need to sanitize `input` yourself.
|
|
91
|
+
|
|
88
92
|
### `columnMap` — map logical roles → your columns
|
|
89
93
|
|
|
90
94
|
```ts
|