@warlock.js/notifications 5.44.0 → 5.46.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/cjs/index.cjs +112 -32
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/channels/database-channel.d.mts.map +1 -1
  5. package/esm/channels/database-channel.mjs +10 -2
  6. package/esm/channels/database-channel.mjs.map +1 -1
  7. package/esm/contracts/channel.contract.d.mts +3 -1
  8. package/esm/contracts/channel.contract.d.mts.map +1 -1
  9. package/esm/errors.d.mts +9 -0
  10. package/esm/errors.d.mts.map +1 -1
  11. package/esm/errors.mjs +14 -1
  12. package/esm/errors.mjs.map +1 -1
  13. package/esm/in-app/base-notifications-repository.d.mts +21 -7
  14. package/esm/in-app/base-notifications-repository.d.mts.map +1 -1
  15. package/esm/in-app/base-notifications-repository.mjs +41 -19
  16. package/esm/in-app/base-notifications-repository.mjs.map +1 -1
  17. package/esm/in-app/column-map.d.mts +8 -0
  18. package/esm/in-app/column-map.d.mts.map +1 -1
  19. package/esm/in-app/column-map.mjs +3 -0
  20. package/esm/in-app/column-map.mjs.map +1 -1
  21. package/esm/in-app/in-app.d.mts +9 -8
  22. package/esm/in-app/in-app.d.mts.map +1 -1
  23. package/esm/in-app/in-app.mjs +7 -8
  24. package/esm/in-app/in-app.mjs.map +1 -1
  25. package/esm/in-app/index.d.mts +2 -1
  26. package/esm/in-app/index.mjs +2 -1
  27. package/esm/in-app/recipient.d.mts +17 -0
  28. package/esm/in-app/recipient.d.mts.map +1 -0
  29. package/esm/in-app/recipient.mjs +27 -0
  30. package/esm/in-app/recipient.mjs.map +1 -0
  31. package/esm/index.d.mts +4 -3
  32. package/esm/index.mjs +3 -2
  33. package/esm/migration/notification-columns.d.mts +12 -1
  34. package/esm/migration/notification-columns.d.mts.map +1 -1
  35. package/esm/migration/notification-columns.mjs +13 -3
  36. package/esm/migration/notification-columns.mjs.map +1 -1
  37. package/esm/types.d.mts +13 -1
  38. package/esm/types.d.mts.map +1 -1
  39. package/llms-full.txt +11 -4
  40. package/llms.txt +1 -1
  41. package/package.json +7 -7
  42. package/skills/use-in-app/SKILL.md +6 -1
  43. package/skills/write-notification-migration/SKILL.md +5 -3
@@ -1 +1 @@
1
- {"version":3,"file":"errors.mjs","names":[],"sources":["../../../../../../../packages/notifications/src/errors.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"],"mappings":";;;;;;;;;AASA,SAAS,aAAa,QAAgB,MAA6C;CAEjF,AAAC,MAA6E,oBAC5E,QACA,IACF;AACF;AAEA,IAAa,kCAAb,MAAa,wCAAwC,MAAM;CACzD,AAAO,cAAc;EACnB,MACE,oJAEF;EACA,KAAK,OAAO;EACZ,aAAa,MAAM,+BAA+B;CACpD;AACF;AAEA,IAAa,uBAAb,MAAa,6BAA6B,MAAM;CAC9C,AAAO,YAAY,aAAqB;EACtC,MACE,YAAY,YAAY,wFAE1B;EACA,KAAK,OAAO;EACZ,aAAa,MAAM,oBAAoB;CACzC;AACF;AAEA,IAAa,yBAAb,MAAa,+BAA+B,MAAM;CAChD,AAAO,YAAY,aAAqB;EACtC,MACE,4BAA4B,YAAY,qIAE1C;EACA,KAAK,OAAO;EACZ,aAAa,MAAM,sBAAsB;CAC3C;AACF;AAEA,IAAa,uBAAb,MAAa,6BAA6B,MAAM;CAC9C,AAAO,YAAY,kBAA0B,aAAqB;EAChE,MACE,iBAAiB,iBAAiB,uBAAuB,YAAY,iCACvC,YAAY,aAC5C;EACA,KAAK,OAAO;EACZ,aAAa,MAAM,oBAAoB;CACzC;AACF;;;;;;AAOA,IAAa,yBAAb,MAAa,+BAA+B,MAAM;CAChD,AAAO,YAAY,aAAqB,aAAuB;EAC7D,MACE,YAAY,YAAY,4CACnB,eAAe,YAAY,iHAElC;EACA,KAAK,OAAO;EACZ,aAAa,MAAM,sBAAsB;CAC3C;AACF"}
1
+ {"version":3,"file":"errors.mjs","names":[],"sources":["../../../../../../../packages/notifications/src/errors.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/**\n * Thrown when the notification table keys recipients by type and a read or\n * write names the recipient by a bare id. A bare id cannot say which identity\n * type it belongs to, so serving it would let types sharing that id read each\n * other's notifications. Pass the recipient model, or `{ type, id }`.\n */\nexport class RecipientTypeRequiredError extends Error {\n public constructor(recipientId?: unknown) {\n super(\n `The notifications table stores a recipient type, so recipient ${recipientId ?? \"(unknown)\"} ` +\n \"must be given as the model or as `{ type, id }`, not as a bare id.\",\n );\n this.name = \"RecipientTypeRequiredError\";\n captureStack(this, RecipientTypeRequiredError);\n }\n}\n"],"mappings":";;;;;;;;;AASA,SAAS,aAAa,QAAgB,MAA6C;CAEjF,AAAC,MAA6E,oBAC5E,QACA,IACF;AACF;AAEA,IAAa,kCAAb,MAAa,wCAAwC,MAAM;CACzD,AAAO,cAAc;EACnB,MACE,oJAEF;EACA,KAAK,OAAO;EACZ,aAAa,MAAM,+BAA+B;CACpD;AACF;AAEA,IAAa,uBAAb,MAAa,6BAA6B,MAAM;CAC9C,AAAO,YAAY,aAAqB;EACtC,MACE,YAAY,YAAY,wFAE1B;EACA,KAAK,OAAO;EACZ,aAAa,MAAM,oBAAoB;CACzC;AACF;AAEA,IAAa,yBAAb,MAAa,+BAA+B,MAAM;CAChD,AAAO,YAAY,aAAqB;EACtC,MACE,4BAA4B,YAAY,qIAE1C;EACA,KAAK,OAAO;EACZ,aAAa,MAAM,sBAAsB;CAC3C;AACF;AAEA,IAAa,uBAAb,MAAa,6BAA6B,MAAM;CAC9C,AAAO,YAAY,kBAA0B,aAAqB;EAChE,MACE,iBAAiB,iBAAiB,uBAAuB,YAAY,iCACvC,YAAY,aAC5C;EACA,KAAK,OAAO;EACZ,aAAa,MAAM,oBAAoB;CACzC;AACF;;;;;;AAOA,IAAa,yBAAb,MAAa,+BAA+B,MAAM;CAChD,AAAO,YAAY,aAAqB,aAAuB;EAC7D,MACE,YAAY,YAAY,4CACnB,eAAe,YAAY,iHAElC;EACA,KAAK,OAAO;EACZ,aAAa,MAAM,sBAAsB;CAC3C;AACF;;;;;;;AAQA,IAAa,6BAAb,MAAa,mCAAmC,MAAM;CACpD,AAAO,YAAY,aAAuB;EACxC,MACE,iEAAiE,eAAe,YAAY,sEAE9F;EACA,KAAK,OAAO;EACZ,aAAa,MAAM,0BAA0B;CAC/C;AACF"}
@@ -3,6 +3,7 @@ import { NotificationContract } from "../contracts/notification.contract.mjs";
3
3
  import "../contracts/index.mjs";
4
4
  import { NotificationColumnMapHost } from "./column-map.mjs";
5
5
  import { DatabaseNotification } from "./database-notification.mjs";
6
+ import { RecipientInput } from "./recipient.mjs";
6
7
  import { FilterRules, RepositoryManager } from "@warlock.js/core";
7
8
  //#region ../../packages/notifications/src/in-app/base-notifications-repository.d.ts
8
9
  /**
@@ -15,6 +16,8 @@ export type NotificationsFilter = {
15
16
  /** Mode-agnostic unread filter — resolves to the model's read-state column. */
16
17
  unread?: boolean;
17
18
  recipientId?: Id;
19
+ /** Recipient identity type — only filterable when the model declares `columnMap.recipientType`. */
20
+ recipientType?: string;
18
21
  idempotencyKey?: string;
19
22
  };
20
23
  /** Class form of a `DatabaseNotification` subclass — what `configure()` accepts. */
@@ -32,6 +35,17 @@ export declare class BaseNotificationsRepository<TModel extends NotificationCont
32
35
  * direct filtering only when the model declares those columns.
33
36
  */
34
37
  private buildFilterBy;
38
+ /**
39
+ * Resolve a recipient into the logical filter keys that scope a query to it.
40
+ * When the model stores a recipient type the scope is `(recipientType,
41
+ * recipientId)`; a bare id then throws, since an id alone can't say which
42
+ * identity type it belongs to. Without a type column the scope is the id
43
+ * alone and any type on the recipient is ignored.
44
+ */
45
+ scopeOf(recipient: RecipientInput): {
46
+ recipientId: Id;
47
+ recipientType?: string;
48
+ };
35
49
  /**
36
50
  * Translate an object keyed by LOGICAL names into one keyed by PHYSICAL
37
51
  * columns, using `filterBy` as the lookup. Keys with no `[op, column]` tuple
@@ -63,33 +77,33 @@ export declare class BaseNotificationsRepository<TModel extends NotificationCont
63
77
  * the existing row instead of inserting a duplicate. A unique index on the
64
78
  * key is the backstop for the rare insert race (we re-fetch on conflict).
65
79
  */
66
- createFor(recipientId: Id, input: DatabasePayload, tenantId?: Id): Promise<TModel>;
80
+ createFor(recipient: RecipientInput, input: DatabasePayload, tenantId?: Id): Promise<TModel>;
67
81
  /**
68
82
  * Bulk-create one row per recipient sharing the same input + tenant. Reserved
69
83
  * for the Phase-2 `Channel.sendMany` fan-out hook; not on the per-recipient
70
84
  * send path yet.
71
85
  */
72
- createManyFor(recipientIds: Id[], input: DatabasePayload, tenantId?: Id): Promise<TModel[]>;
86
+ createManyFor(recipients: RecipientInput[], input: DatabasePayload, tenantId?: Id): Promise<TModel[]>;
73
87
  /**
74
88
  * Mark rows read — recipient-scoped. `id` omitted → all unread rows for the
75
89
  * recipient; `id` given → that one row, still scoped to the recipient. Scoped
76
90
  * to unread so a re-mark never overwrites an earlier `read_at` timestamp.
77
91
  * Sets whichever read-state column(s) the model declares.
78
92
  */
79
- markRead(recipientId: Id, id?: Id): Promise<number>;
93
+ markRead(recipient: RecipientInput, id?: Id): Promise<number>;
80
94
  /**
81
95
  * Inverse of `markRead`, same recipient-scoping. No unread scope needed —
82
96
  * clearing the read-state of an already-unread row is a no-op.
83
97
  */
84
- markUnread(recipientId: Id, id?: Id): Promise<number>;
98
+ markUnread(recipient: RecipientInput, id?: Id): Promise<number>;
85
99
  /** Find one row for a recipient (read path — `filterBy` maps the keys). */
86
- findFor(recipientId: Id, id: Id): Promise<TModel | null>;
100
+ findFor(recipient: RecipientInput, id: Id): Promise<TModel | null>;
87
101
  /**
88
102
  * Delete rows — recipient-scoped. `id` omitted → clear all for the
89
103
  * recipient; `id` given → that one row.
90
104
  */
91
- deleteFor(recipientId: Id, id?: Id): Promise<number>;
92
- /** Read path — `filterBy` maps `recipientId`/`idempotencyKey` to columns. */
105
+ deleteFor(recipient: RecipientInput, id?: Id): Promise<number>;
106
+ /** Read path — `filterBy` maps `recipientId`/`recipientType`/`idempotencyKey` to columns. */
93
107
  private firstByIdempotencyKey;
94
108
  }
95
109
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"base-notifications-repository.d.mts","names":[],"sources":["../../../../../../../../packages/notifications/src/in-app/base-notifications-repository.ts"],"mappings":";;;;;;;;;;;YA2CY;EACV,KAAK;EACL;;EAEA;EACA,cAAc;EACd;;;YAIU,kCAAkC,gBAAgB,wBAC5D;qBAEW,4BACX,eAAe,uBAAuB,8BAC9B,kBAAkB,QAAQ;;mBAEjB;EAEV,UAAU;EAEjB,YAAmB,QAAQ;;MAYhB;;;;;;UASH;;;;;;;;YA2CE,MAAM,KAAK,0BAA0B;;;;;;;;;;;;UAwBvC;;;;;;;;;;;;EAoCK,UAAU,aAAa,IAAI,OAAO,iBAAiB,WAAW,KAAK,QAAQ;;;;;;EAwCjF,cACL,cAAc,MACd,OAAO,iBACP,WAAW,KACV,QAAQ;;;;;;;EAYJ,SAAS,aAAa,IAAI,KAAK,KAAK;;;;;EAwBpC,WAAW,aAAa,IAAI,KAAK,KAAK;;EAmBtC,QAAQ,aAAa,IAAI,IAAI,KAAK,QAAQ;;;;;EAQ1C,UAAU,aAAa,IAAI,KAAK,KAAK;;UAKpC"}
1
+ {"version":3,"file":"base-notifications-repository.d.mts","names":[],"sources":["../../../../../../../../packages/notifications/src/in-app/base-notifications-repository.ts"],"mappings":";;;;;;;;;;;;YA6CY;EACV,KAAK;EACL;;EAEA;EACA,cAAc;;EAEd;EACA;;;YAIU,kCAAkC,gBAAgB,wBAC5D;qBAEW,4BACX,eAAe,uBAAuB,8BAC9B,kBAAkB,QAAQ;;mBAEjB;EAEV,UAAU;EAEjB,YAAmB,QAAQ;;MAYhB;;;;;;UASH;;;;;;;;EA+CD,QAAQ,WAAW;IAAmB,aAAa;IAAI;;;;;;;;;YAqBpD,MAAM,KAAK,0BAA0B;;;;;;;;;;;;UAwBvC;;;;;;;;;;;;EAsCK,UACX,WAAW,gBACX,OAAO,iBACP,WAAW,KACV,QAAQ;;;;;;EAyCJ,cACL,YAAY,kBACZ,OAAO,iBACP,WAAW,KACV,QAAQ;;;;;;;EAUJ,SAAS,WAAW,gBAAgB,KAAK,KAAK;;;;;EA4B9C,WAAW,WAAW,gBAAgB,KAAK,KAAK;;EAmBhD,QAAQ,WAAW,gBAAgB,IAAI,KAAK,QAAQ;;;;;EAQpD,UAAU,WAAW,gBAAgB,KAAK,KAAK;;UAO9C"}
@@ -1,3 +1,5 @@
1
+ import { RecipientTypeRequiredError } from "../errors.mjs";
2
+ import { toRecipientRef } from "./recipient.mjs";
1
3
  import { resolveColumnMap } from "./column-map.mjs";
2
4
  import { RepositoryManager } from "@warlock.js/core";
3
5
 
@@ -51,7 +53,7 @@ var BaseNotificationsRepository = class extends RepositoryManager {
51
53
  * direct filtering only when the model declares those columns.
52
54
  */
53
55
  buildFilterBy() {
54
- const { recipient, tenant, readAt, isRead } = this.columns;
56
+ const { recipient, recipientType, tenant, readAt, isRead } = this.columns;
55
57
  const rules = {
56
58
  id: "=",
57
59
  type: "=",
@@ -63,12 +65,29 @@ var BaseNotificationsRepository = class extends RepositoryManager {
63
65
  else if (readAt) query.whereNull(readAt);
64
66
  }
65
67
  };
68
+ if (recipientType) rules.recipientType = ["=", recipientType];
66
69
  if (isRead) rules.isRead = ["=", isRead];
67
70
  if (readAt) rules.readAt = ["=", readAt];
68
71
  if (tenant) rules.tenant = ["=", tenant];
69
72
  return rules;
70
73
  }
71
74
  /**
75
+ * Resolve a recipient into the logical filter keys that scope a query to it.
76
+ * When the model stores a recipient type the scope is `(recipientType,
77
+ * recipientId)`; a bare id then throws, since an id alone can't say which
78
+ * identity type it belongs to. Without a type column the scope is the id
79
+ * alone and any type on the recipient is ignored.
80
+ */
81
+ scopeOf(recipient) {
82
+ const { id, type } = toRecipientRef(recipient);
83
+ if (!this.columns.recipientType) return { recipientId: id };
84
+ if (!type) throw new RecipientTypeRequiredError(id);
85
+ return {
86
+ recipientId: id,
87
+ recipientType: type
88
+ };
89
+ }
90
+ /**
72
91
  * Translate an object keyed by LOGICAL names into one keyed by PHYSICAL
73
92
  * columns, using `filterBy` as the lookup. Keys with no `[op, column]` tuple
74
93
  * (e.g. `title`, `body`, `payload`, `type`, `id`) pass through unchanged —
@@ -97,10 +116,12 @@ var BaseNotificationsRepository = class extends RepositoryManager {
97
116
  * the recipient column directly.
98
117
  */
99
118
  stripServerOwnedKeys(input) {
100
- const { recipient, tenant, readAt, isRead } = this.columns;
119
+ const { recipient, recipientType, tenant, readAt, isRead } = this.columns;
101
120
  const serverOwned = /* @__PURE__ */ new Set([
102
121
  "id",
103
122
  "recipientId",
123
+ "recipientType",
124
+ recipientType,
104
125
  "tenant",
105
126
  "readAt",
106
127
  "isRead",
@@ -124,15 +145,16 @@ var BaseNotificationsRepository = class extends RepositoryManager {
124
145
  * the existing row instead of inserting a duplicate. A unique index on the
125
146
  * key is the backstop for the rare insert race (we re-fetch on conflict).
126
147
  */
127
- async createFor(recipientId, input, tenantId) {
148
+ async createFor(recipient, input, tenantId) {
128
149
  const { idempotencyKey } = input;
150
+ const scope = this.scopeOf(recipient);
129
151
  if (idempotencyKey) {
130
- const existing = await this.firstByIdempotencyKey(recipientId, idempotencyKey);
152
+ const existing = await this.firstByIdempotencyKey(scope, idempotencyKey);
131
153
  if (existing) return existing;
132
154
  }
133
155
  const row = {
134
156
  ...this.stripServerOwnedKeys(input),
135
- recipientId
157
+ ...scope
136
158
  };
137
159
  if (this.columns.isRead) row.isRead = false;
138
160
  if (this.columns.tenant && tenantId !== void 0) row.tenant = tenantId;
@@ -140,7 +162,7 @@ var BaseNotificationsRepository = class extends RepositoryManager {
140
162
  return await this.create(this.toRow(row));
141
163
  } catch (error) {
142
164
  if (idempotencyKey) {
143
- const existing = await this.firstByIdempotencyKey(recipientId, idempotencyKey);
165
+ const existing = await this.firstByIdempotencyKey(scope, idempotencyKey);
144
166
  if (existing) return existing;
145
167
  }
146
168
  throw error;
@@ -151,8 +173,8 @@ var BaseNotificationsRepository = class extends RepositoryManager {
151
173
  * for the Phase-2 `Channel.sendMany` fan-out hook; not on the per-recipient
152
174
  * send path yet.
153
175
  */
154
- createManyFor(recipientIds, input, tenantId) {
155
- return Promise.all(recipientIds.map((recipientId) => this.createFor(recipientId, input, tenantId)));
176
+ createManyFor(recipients, input, tenantId) {
177
+ return Promise.all(recipients.map((recipient) => this.createFor(recipient, input, tenantId)));
156
178
  }
157
179
  /**
158
180
  * Mark rows read — recipient-scoped. `id` omitted → all unread rows for the
@@ -160,14 +182,14 @@ var BaseNotificationsRepository = class extends RepositoryManager {
160
182
  * to unread so a re-mark never overwrites an earlier `read_at` timestamp.
161
183
  * Sets whichever read-state column(s) the model declares.
162
184
  */
163
- markRead(recipientId, id) {
185
+ markRead(recipient, id) {
164
186
  const { isRead, readAt } = this.columns;
165
187
  const unreadScope = isRead ? { isRead: false } : { readAt: null };
166
188
  const data = {};
167
189
  if (isRead) data.isRead = true;
168
190
  if (readAt) data.readAt = /* @__PURE__ */ new Date();
169
191
  return this.updateMany(this.toRow({
170
- recipientId,
192
+ ...this.scopeOf(recipient),
171
193
  ...unreadScope,
172
194
  ...id !== void 0 ? { id } : {}
173
195
  }), this.toRow(data));
@@ -176,20 +198,20 @@ var BaseNotificationsRepository = class extends RepositoryManager {
176
198
  * Inverse of `markRead`, same recipient-scoping. No unread scope needed —
177
199
  * clearing the read-state of an already-unread row is a no-op.
178
200
  */
179
- markUnread(recipientId, id) {
201
+ markUnread(recipient, id) {
180
202
  const { isRead, readAt } = this.columns;
181
203
  const data = {};
182
204
  if (isRead) data.isRead = false;
183
205
  if (readAt) data.readAt = null;
184
206
  return this.updateMany(this.toRow({
185
- recipientId,
207
+ ...this.scopeOf(recipient),
186
208
  ...id !== void 0 ? { id } : {}
187
209
  }), this.toRow(data));
188
210
  }
189
211
  /** Find one row for a recipient (read path — `filterBy` maps the keys). */
190
- findFor(recipientId, id) {
212
+ findFor(recipient, id) {
191
213
  return this.first({
192
- recipientId,
214
+ ...this.scopeOf(recipient),
193
215
  id
194
216
  });
195
217
  }
@@ -197,16 +219,16 @@ var BaseNotificationsRepository = class extends RepositoryManager {
197
219
  * Delete rows — recipient-scoped. `id` omitted → clear all for the
198
220
  * recipient; `id` given → that one row.
199
221
  */
200
- deleteFor(recipientId, id) {
222
+ deleteFor(recipient, id) {
201
223
  return this.deleteMany(this.toRow({
202
- recipientId,
224
+ ...this.scopeOf(recipient),
203
225
  ...id !== void 0 ? { id } : {}
204
226
  }));
205
227
  }
206
- /** Read path — `filterBy` maps `recipientId`/`idempotencyKey` to columns. */
207
- firstByIdempotencyKey(recipientId, idempotencyKey) {
228
+ /** Read path — `filterBy` maps `recipientId`/`recipientType`/`idempotencyKey` to columns. */
229
+ firstByIdempotencyKey(scope, idempotencyKey) {
208
230
  return this.first({
209
- recipientId,
231
+ ...scope,
210
232
  idempotencyKey
211
233
  });
212
234
  }
@@ -1 +1 @@
1
- {"version":3,"file":"base-notifications-repository.mjs","names":[],"sources":["../../../../../../../../packages/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(recipientId: Id, input: DatabasePayload, tenantId?: Id): 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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"}
1
+ {"version":3,"file":"base-notifications-repository.mjs","names":[],"sources":["../../../../../../../../packages/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 { RecipientTypeRequiredError } from \"../errors\";\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\";\nimport { type RecipientInput, toRecipientRef } from \"./recipient\";\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 /** Recipient identity type — only filterable when the model declares `columnMap.recipientType`. */\n recipientType?: string;\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, recipientType, 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 (recipientType) {\n rules.recipientType = [\"=\", recipientType];\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 * Resolve a recipient into the logical filter keys that scope a query to it.\n * When the model stores a recipient type the scope is `(recipientType,\n * recipientId)`; a bare id then throws, since an id alone can't say which\n * identity type it belongs to. Without a type column the scope is the id\n * alone and any type on the recipient is ignored.\n */\n public scopeOf(recipient: RecipientInput): { recipientId: Id; recipientType?: string } {\n const { id, type } = toRecipientRef(recipient);\n\n if (!this.columns.recipientType) {\n return { recipientId: id };\n }\n\n if (!type) {\n throw new RecipientTypeRequiredError(id);\n }\n\n return { recipientId: id, recipientType: type };\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, recipientType, tenant, readAt, isRead } = this.columns;\n const serverOwned = new Set<string | undefined>([\n \"id\",\n \"recipientId\",\n \"recipientType\",\n recipientType,\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 recipient: RecipientInput,\n input: DatabasePayload,\n tenantId?: Id,\n ): Promise<TModel> {\n const { idempotencyKey } = input;\n const scope = this.scopeOf(recipient);\n\n if (idempotencyKey) {\n const existing = await this.firstByIdempotencyKey(scope, idempotencyKey);\n if (existing) {\n return existing;\n }\n }\n\n const row: Record<string, unknown> = { ...this.stripServerOwnedKeys(input), ...scope };\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(scope, 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 recipients: RecipientInput[],\n input: DatabasePayload,\n tenantId?: Id,\n ): Promise<TModel[]> {\n return Promise.all(recipients.map((recipient) => this.createFor(recipient, input, tenantId)));\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(recipient: RecipientInput, 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({\n ...this.scopeOf(recipient),\n ...unreadScope,\n ...(id !== undefined ? { id } : {}),\n }),\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(recipient: RecipientInput, 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({ ...this.scopeOf(recipient), ...(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(recipient: RecipientInput, id: Id): Promise<TModel | null> {\n return this.first({ ...this.scopeOf(recipient), 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(recipient: RecipientInput, id?: Id): Promise<number> {\n return this.deleteMany(\n this.toRow({ ...this.scopeOf(recipient), ...(id !== undefined ? { id } : {}) }),\n );\n }\n\n /** Read path — `filterBy` maps `recipientId`/`recipientType`/`idempotencyKey` to columns. */\n private firstByIdempotencyKey(\n scope: { recipientId: Id; recipientType?: string },\n idempotencyKey: string,\n ): Promise<TModel | null> {\n return this.first({ ...scope, idempotencyKey } as NotificationsFilter);\n }\n}\n"],"mappings":"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"}
@@ -35,6 +35,13 @@
35
35
  export type NotificationColumnMap = {
36
36
  /** Recipient FK column. Default: `"user_id"`. */
37
37
  recipient?: string;
38
+ /**
39
+ * Recipient TYPE column (e.g. `"recipient_type"`). Declare it to key every
40
+ * notification by `(type, id)` so identities of different types that share an
41
+ * id (staff 5 vs customer 5) never read each other's rows. Omit → ids are
42
+ * the only key (single-identity apps, tables created before the column).
43
+ */
44
+ recipientType?: string;
38
45
  /** Multi-tenant scope column, written from the recipient. Omit → single-tenant. */
39
46
  tenant?: string;
40
47
  /** Read-timestamp column. Present → read-state records "when". */
@@ -49,6 +56,7 @@ export type NotificationColumnMap = {
49
56
  */
50
57
  export type ResolvedNotificationColumnMap = {
51
58
  recipient: string;
59
+ recipientType?: string;
52
60
  tenant?: string;
53
61
  readAt?: string;
54
62
  isRead?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"column-map.d.mts","names":[],"sources":["../../../../../../../../packages/notifications/src/in-app/column-map.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;YAkCY;;EAEV;;EAEA;;EAEA;;EAEA;;;;;;;YAQU;EACV;EACA;EACA;EACA;;;YAIU;EACV,YAAY;;;;;;;;wBAYE,iBACd,KAAK,oCACJ"}
1
+ {"version":3,"file":"column-map.d.mts","names":[],"sources":["../../../../../../../../packages/notifications/src/in-app/column-map.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;YAkCY;;EAEV;;;;;;;EAOA;;EAEA;;EAEA;;EAEA;;;;;;;YAQU;EACV;EACA;EACA;EACA;EACA;;;YAIU;EACV,YAAY;;;;;;;;wBAYE,iBACd,KAAK,oCACJ"}
@@ -10,13 +10,16 @@ const DEFAULT_READ_AT_COLUMN = "read_at";
10
10
  function resolveColumnMap(map) {
11
11
  const recipient = map?.recipient ?? DEFAULT_RECIPIENT_COLUMN;
12
12
  const tenant = map?.tenant;
13
+ const recipientType = map?.recipientType;
13
14
  if (!map?.readAt && !map?.isRead) return {
14
15
  recipient,
16
+ recipientType,
15
17
  tenant,
16
18
  readAt: DEFAULT_READ_AT_COLUMN
17
19
  };
18
20
  return {
19
21
  recipient,
22
+ recipientType,
20
23
  tenant,
21
24
  readAt: map.readAt,
22
25
  isRead: map.isRead
@@ -1 +1 @@
1
- {"version":3,"file":"column-map.mjs","names":[],"sources":["../../../../../../../../packages/notifications/src/in-app/column-map.ts"],"sourcesContent":["/**\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"],"mappings":";AA8DA,MAAM,2BAA2B;AACjC,MAAM,yBAAyB;;;;;;;AAQ/B,SAAgB,iBACd,KAC+B;CAC/B,MAAM,YAAY,KAAK,aAAa;CACpC,MAAM,SAAS,KAAK;CAEpB,IAAI,CAAC,KAAK,UAAU,CAAC,KAAK,QACxB,OAAO;EAAE;EAAW;EAAQ,QAAQ;CAAuB;CAG7D,OAAO;EACL;EACA;EACA,QAAQ,IAAI;EACZ,QAAQ,IAAI;CACd;AACF"}
1
+ {"version":3,"file":"column-map.mjs","names":[],"sources":["../../../../../../../../packages/notifications/src/in-app/column-map.ts"],"sourcesContent":["/**\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 /**\n * Recipient TYPE column (e.g. `\"recipient_type\"`). Declare it to key every\n * notification by `(type, id)` so identities of different types that share an\n * id (staff 5 vs customer 5) never read each other's rows. Omit → ids are\n * the only key (single-identity apps, tables created before the column).\n */\n recipientType?: 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 recipientType?: 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 const recipientType = map?.recipientType;\n\n if (!map?.readAt && !map?.isRead) {\n return { recipient, recipientType, tenant, readAt: DEFAULT_READ_AT_COLUMN };\n }\n\n return {\n recipient,\n recipientType,\n tenant,\n readAt: map.readAt,\n isRead: map.isRead,\n };\n}\n"],"mappings":";AAsEA,MAAM,2BAA2B;AACjC,MAAM,yBAAyB;;;;;;;AAQ/B,SAAgB,iBACd,KAC+B;CAC/B,MAAM,YAAY,KAAK,aAAa;CACpC,MAAM,SAAS,KAAK;CACpB,MAAM,gBAAgB,KAAK;CAE3B,IAAI,CAAC,KAAK,UAAU,CAAC,KAAK,QACxB,OAAO;EAAE;EAAW;EAAe;EAAQ,QAAQ;CAAuB;CAG5E,OAAO;EACL;EACA;EACA;EACA,QAAQ,IAAI;EACZ,QAAQ,IAAI;CACd;AACF"}
@@ -1,7 +1,8 @@
1
- import { DatabasePayload, Id, Notifiable } from "../types.mjs";
1
+ import { DatabasePayload, Id } from "../types.mjs";
2
2
  import { Channel } from "../contracts/channel.contract.mjs";
3
3
  import { NotificationContract } from "../contracts/notification.contract.mjs";
4
4
  import "../contracts/index.mjs";
5
+ import { RecipientInput } from "./recipient.mjs";
5
6
  import { BaseNotificationsRepository, NotificationModelClass, NotificationsFilter } from "./base-notifications-repository.mjs";
6
7
  import "../index.mjs";
7
8
  import { TypedRepositoryOptionsWithPages } from "@warlock.js/core";
@@ -33,22 +34,22 @@ declare class InApp {
33
34
  configure(options: ConfigureOptions): Channel<DatabasePayload>;
34
35
  private get repository();
35
36
  /** General list — scoped to recipient; pass `options` for paging + filters. */
36
- list(recipient: Notifiable | Id, options?: NotificationsListOptions): Promise<import("@warlock.js/core").PaginationResult<NotificationContract>>;
37
+ list(recipient: RecipientInput, options?: NotificationsListOptions): Promise<import("@warlock.js/core").PaginationResult<NotificationContract>>;
37
38
  /** Unread-only list — common case for badges + dashboards. */
38
- listUnread(recipient: Notifiable | Id, options?: NotificationsListOptions): Promise<import("@warlock.js/core").PaginationResult<NotificationContract>>;
39
+ listUnread(recipient: RecipientInput, options?: NotificationsListOptions): Promise<import("@warlock.js/core").PaginationResult<NotificationContract>>;
39
40
  /**
40
41
  * Cached unread count — backs the badge. Auto-invalidated by the repo's
41
42
  * model create/update events (`RepositoryManager.registerEvents`).
42
43
  */
43
- countUnread(recipient: Notifiable | Id): Promise<number>;
44
+ countUnread(recipient: RecipientInput): Promise<number>;
44
45
  /** Find one notification for a recipient — for a detail view. */
45
- find(recipient: Notifiable | Id, id: Id): Promise<NotificationContract | null>;
46
+ find(recipient: RecipientInput, id: Id): Promise<NotificationContract | null>;
46
47
  /** Mark read — `id` omitted = all unread for this recipient. */
47
- markAsRead(recipient: Notifiable | Id, id?: Id): Promise<number>;
48
+ markAsRead(recipient: RecipientInput, id?: Id): Promise<number>;
48
49
  /** Mark unread — same recipient-scoping. */
49
- markAsUnread(recipient: Notifiable | Id, id?: Id): Promise<number>;
50
+ markAsUnread(recipient: RecipientInput, id?: Id): Promise<number>;
50
51
  /** Delete/dismiss — `id` omitted = clear all for this recipient. */
51
- dismiss(recipient: Notifiable | Id, id?: Id): Promise<number>;
52
+ dismiss(recipient: RecipientInput, id?: Id): Promise<number>;
52
53
  }
53
54
  /** Single in-app facade — bound at boot via `inApp.configure(...)`. */
54
55
  export declare const inApp: InApp;
@@ -1 +1 @@
1
- {"version":3,"file":"in-app.d.mts","names":["_$__0.NotificationContract"],"sources":["../../../../../../../../packages/notifications/src/in-app/in-app.ts"],"mappings":";;;;;;;;;;;;;YA4CY,2BAA2B,gCAAgC;;;;;;YAO3D;EACN,OAAO;EAAwB;;EAC/B,YAAY;EAA6B;;cAKzC;UACI;;;;;EAMD,UAAU,SAAS,mBAAmB,QAAQ;cAQzC;;EAWL,KAAK,WAAW,aAAa,IAAI,UAAU,2BAAwB,mCAAA,iBAAxBA;;EAK3C,WAAW,WAAW,aAAa,IAAI,UAAU,2BAAwB,mCAAA,iBAAxBA;;;;;EAQjD,YAAY,WAAW,aAAa,KAAE;;EAKtC,KAAK,WAAW,aAAa,IAAI,IAAI,KAAE,QAAFA;;EAKrC,WAAW,WAAW,aAAa,IAAI,KAAK,KAAE;;EAK9C,aAAa,WAAW,aAAa,IAAI,KAAK,KAAE;;EAKhD,QAAQ,WAAW,aAAa,IAAI,KAAK,KAAE;;;qBAMvC,OAAK"}
1
+ {"version":3,"file":"in-app.d.mts","names":["_$__0.NotificationContract"],"sources":["../../../../../../../../packages/notifications/src/in-app/in-app.ts"],"mappings":";;;;;;;;;;;;;;YA6CY,2BAA2B,gCAAgC;;;;;;YAO3D;EACN,OAAO;EAAwB;;EAC/B,YAAY;EAA6B;;cAEzC;UACI;;;;;EAMD,UAAU,SAAS,mBAAmB,QAAQ;cAQzC;;EAWL,KAAK,WAAW,gBAAgB,UAAU,2BAAwB,mCAAA,iBAAxBA;;EAK1C,WAAW,WAAW,gBAAgB,UAAU,2BAAwB,mCAAA,iBAAxBA;;;;;EAYhD,YAAY,WAAW,iBAAc;;EAKrC,KAAK,WAAW,gBAAgB,IAAI,KAAE,QAAFA;;EAKpC,WAAW,WAAW,gBAAgB,KAAK,KAAE;;EAK7C,aAAa,WAAW,gBAAgB,KAAK,KAAE;;EAK/C,QAAQ,WAAW,gBAAgB,KAAK,KAAE;;;qBAMtC,OAAK"}
@@ -2,7 +2,6 @@ import { databaseChannel } from "../channels/database-channel.mjs";
2
2
  import { BaseNotificationsRepository } from "./base-notifications-repository.mjs";
3
3
 
4
4
  //#region ../../packages/notifications/src/in-app/in-app.ts
5
- const idOf = (recipient) => typeof recipient === "object" ? recipient.id : recipient;
6
5
  var InApp = class {
7
6
  repo;
8
7
  /**
@@ -21,14 +20,14 @@ var InApp = class {
21
20
  list(recipient, options) {
22
21
  return this.repository.list({
23
22
  ...options,
24
- recipientId: idOf(recipient)
23
+ ...this.repository.scopeOf(recipient)
25
24
  });
26
25
  }
27
26
  /** Unread-only list — common case for badges + dashboards. */
28
27
  listUnread(recipient, options) {
29
28
  return this.repository.list({
30
29
  ...options,
31
- recipientId: idOf(recipient),
30
+ ...this.repository.scopeOf(recipient),
32
31
  unread: true
33
32
  });
34
33
  }
@@ -38,25 +37,25 @@ var InApp = class {
38
37
  */
39
38
  countUnread(recipient) {
40
39
  return this.repository.countCached({
41
- recipientId: idOf(recipient),
40
+ ...this.repository.scopeOf(recipient),
42
41
  unread: true
43
42
  });
44
43
  }
45
44
  /** Find one notification for a recipient — for a detail view. */
46
45
  find(recipient, id) {
47
- return this.repository.findFor(idOf(recipient), id);
46
+ return this.repository.findFor(recipient, id);
48
47
  }
49
48
  /** Mark read — `id` omitted = all unread for this recipient. */
50
49
  markAsRead(recipient, id) {
51
- return this.repository.markRead(idOf(recipient), id);
50
+ return this.repository.markRead(recipient, id);
52
51
  }
53
52
  /** Mark unread — same recipient-scoping. */
54
53
  markAsUnread(recipient, id) {
55
- return this.repository.markUnread(idOf(recipient), id);
54
+ return this.repository.markUnread(recipient, id);
56
55
  }
57
56
  /** Delete/dismiss — `id` omitted = clear all for this recipient. */
58
57
  dismiss(recipient, id) {
59
- return this.repository.deleteFor(idOf(recipient), id);
58
+ return this.repository.deleteFor(recipient, id);
60
59
  }
61
60
  };
62
61
  /** Single in-app facade — bound at boot via `inApp.configure(...)`. */
@@ -1 +1 @@
1
- {"version":3,"file":"in-app.mjs","names":[],"sources":["../../../../../../../../packages/notifications/src/in-app/in-app.ts"],"sourcesContent":["/**\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"],"mappings":";;;;AAuDA,MAAM,QAAQ,cACZ,OAAO,cAAc,WAAW,UAAU,KAAK;AAEjD,IAAM,QAAN,MAAY;CACV,AAAQ;;;;;CAMR,AAAO,UAAU,SAAqD;EACpE,KAAK,OACH,gBAAgB,WAAW,QAAQ,aAC/B,QAAQ,aACR,IAAI,4BAA4B,QAAQ,KAAK;EACnD,OAAO,gBAAgB,KAAK,IAAI;CAClC;CAEA,IAAY,aAA0C;EACpD,IAAI,CAAC,KAAK,MACR,MAAM,IAAI,MACR,2HAEF;EAEF,OAAO,KAAK;CACd;;CAGA,AAAO,KAAK,WAA4B,SAAoC;EAC1E,OAAO,KAAK,WAAW,KAAK;GAAE,GAAG;GAAS,aAAa,KAAK,SAAS;EAAE,CAAC;CAC1E;;CAGA,AAAO,WAAW,WAA4B,SAAoC;EAChF,OAAO,KAAK,WAAW,KAAK;GAAE,GAAG;GAAS,aAAa,KAAK,SAAS;GAAG,QAAQ;EAAK,CAAC;CACxF;;;;;CAMA,AAAO,YAAY,WAA4B;EAC7C,OAAO,KAAK,WAAW,YAAY;GAAE,aAAa,KAAK,SAAS;GAAG,QAAQ;EAAK,CAAC;CACnF;;CAGA,AAAO,KAAK,WAA4B,IAAQ;EAC9C,OAAO,KAAK,WAAW,QAAQ,KAAK,SAAS,GAAG,EAAE;CACpD;;CAGA,AAAO,WAAW,WAA4B,IAAS;EACrD,OAAO,KAAK,WAAW,SAAS,KAAK,SAAS,GAAG,EAAE;CACrD;;CAGA,AAAO,aAAa,WAA4B,IAAS;EACvD,OAAO,KAAK,WAAW,WAAW,KAAK,SAAS,GAAG,EAAE;CACvD;;CAGA,AAAO,QAAQ,WAA4B,IAAS;EAClD,OAAO,KAAK,WAAW,UAAU,KAAK,SAAS,GAAG,EAAE;CACtD;AACF;;AAGA,MAAa,QAAQ,IAAI,MAAM"}
1
+ {"version":3,"file":"in-app.mjs","names":[],"sources":["../../../../../../../../packages/notifications/src/in-app/in-app.ts"],"sourcesContent":["/**\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 } from \"../types\";\nimport {\n BaseNotificationsRepository,\n type NotificationModelClass,\n type NotificationsFilter,\n} from \"./base-notifications-repository\";\nimport type { RecipientInput } from \"./recipient\";\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\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: RecipientInput, options?: NotificationsListOptions) {\n return this.repository.list({ ...options, ...this.repository.scopeOf(recipient) });\n }\n\n /** Unread-only list — common case for badges + dashboards. */\n public listUnread(recipient: RecipientInput, options?: NotificationsListOptions) {\n return this.repository.list({\n ...options,\n ...this.repository.scopeOf(recipient),\n unread: true,\n });\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: RecipientInput) {\n return this.repository.countCached({ ...this.repository.scopeOf(recipient), unread: true });\n }\n\n /** Find one notification for a recipient — for a detail view. */\n public find(recipient: RecipientInput, id: Id) {\n return this.repository.findFor(recipient, id);\n }\n\n /** Mark read — `id` omitted = all unread for this recipient. */\n public markAsRead(recipient: RecipientInput, id?: Id) {\n return this.repository.markRead(recipient, id);\n }\n\n /** Mark unread — same recipient-scoping. */\n public markAsUnread(recipient: RecipientInput, id?: Id) {\n return this.repository.markUnread(recipient, id);\n }\n\n /** Delete/dismiss — `id` omitted = clear all for this recipient. */\n public dismiss(recipient: RecipientInput, id?: Id) {\n return this.repository.deleteFor(recipient, id);\n }\n}\n\n/** Single in-app facade — bound at boot via `inApp.configure(...)`. */\nexport const inApp = new InApp();\n"],"mappings":";;;;AAwDA,IAAM,QAAN,MAAY;CACV,AAAQ;;;;;CAMR,AAAO,UAAU,SAAqD;EACpE,KAAK,OACH,gBAAgB,WAAW,QAAQ,aAC/B,QAAQ,aACR,IAAI,4BAA4B,QAAQ,KAAK;EACnD,OAAO,gBAAgB,KAAK,IAAI;CAClC;CAEA,IAAY,aAA0C;EACpD,IAAI,CAAC,KAAK,MACR,MAAM,IAAI,MACR,2HAEF;EAEF,OAAO,KAAK;CACd;;CAGA,AAAO,KAAK,WAA2B,SAAoC;EACzE,OAAO,KAAK,WAAW,KAAK;GAAE,GAAG;GAAS,GAAG,KAAK,WAAW,QAAQ,SAAS;EAAE,CAAC;CACnF;;CAGA,AAAO,WAAW,WAA2B,SAAoC;EAC/E,OAAO,KAAK,WAAW,KAAK;GAC1B,GAAG;GACH,GAAG,KAAK,WAAW,QAAQ,SAAS;GACpC,QAAQ;EACV,CAAC;CACH;;;;;CAMA,AAAO,YAAY,WAA2B;EAC5C,OAAO,KAAK,WAAW,YAAY;GAAE,GAAG,KAAK,WAAW,QAAQ,SAAS;GAAG,QAAQ;EAAK,CAAC;CAC5F;;CAGA,AAAO,KAAK,WAA2B,IAAQ;EAC7C,OAAO,KAAK,WAAW,QAAQ,WAAW,EAAE;CAC9C;;CAGA,AAAO,WAAW,WAA2B,IAAS;EACpD,OAAO,KAAK,WAAW,SAAS,WAAW,EAAE;CAC/C;;CAGA,AAAO,aAAa,WAA2B,IAAS;EACtD,OAAO,KAAK,WAAW,WAAW,WAAW,EAAE;CACjD;;CAGA,AAAO,QAAQ,WAA2B,IAAS;EACjD,OAAO,KAAK,WAAW,UAAU,WAAW,EAAE;CAChD;AACF;;AAGA,MAAa,QAAQ,IAAI,MAAM"}
@@ -1,5 +1,6 @@
1
1
  import { NotificationColumnMap, NotificationColumnMapHost, ResolvedNotificationColumnMap, resolveColumnMap } from "./column-map.mjs";
2
2
  import { DatabaseNotification } from "./database-notification.mjs";
3
+ import { RecipientInput, recipientTypeOf, toRecipientRef } from "./recipient.mjs";
3
4
  import { BaseNotificationsRepository, NotificationModelClass, NotificationsFilter } from "./base-notifications-repository.mjs";
4
5
  import { ConfigureOptions, NotificationsListOptions, inApp } from "./in-app.mjs";
5
- export { BaseNotificationsRepository, ConfigureOptions, DatabaseNotification, NotificationColumnMap, NotificationColumnMapHost, NotificationModelClass, NotificationsFilter, NotificationsListOptions, ResolvedNotificationColumnMap, inApp, resolveColumnMap };
6
+ export { BaseNotificationsRepository, ConfigureOptions, DatabaseNotification, NotificationColumnMap, NotificationColumnMapHost, NotificationModelClass, NotificationsFilter, NotificationsListOptions, RecipientInput, ResolvedNotificationColumnMap, inApp, recipientTypeOf, resolveColumnMap, toRecipientRef };
@@ -1,6 +1,7 @@
1
+ import { recipientTypeOf, toRecipientRef } from "./recipient.mjs";
1
2
  import { resolveColumnMap } from "./column-map.mjs";
2
3
  import { BaseNotificationsRepository } from "./base-notifications-repository.mjs";
3
4
  import { DatabaseNotification } from "./database-notification.mjs";
4
5
  import { inApp } from "./in-app.mjs";
5
6
 
6
- export { BaseNotificationsRepository, DatabaseNotification, inApp, resolveColumnMap };
7
+ export { BaseNotificationsRepository, DatabaseNotification, inApp, recipientTypeOf, resolveColumnMap, toRecipientRef };
@@ -0,0 +1,17 @@
1
+ import { Id, Notifiable, RecipientRef } from "../types.mjs";
2
+ //#region ../../packages/notifications/src/in-app/recipient.d.ts
3
+ /** What the read and mutation methods accept for "who": a model, a typed ref, or a bare id. */
4
+ export type RecipientInput = Notifiable | RecipientRef | Id;
5
+ /**
6
+ * The identity type of a recipient model. Auth models name it with `userType`
7
+ * (the same value the auth config and token payloads use); any other model
8
+ * falls back to its table name.
9
+ */
10
+ export declare function recipientTypeOf(notifiable: Notifiable): string;
11
+ /** Normalize any accepted recipient form to `{ id, type? }` (`type` absent for bare ids). */
12
+ export declare function toRecipientRef(recipient: RecipientInput): {
13
+ id: Id;
14
+ type?: string;
15
+ };
16
+ //#endregion
17
+ //# sourceMappingURL=recipient.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recipient.d.mts","names":[],"sources":["../../../../../../../../packages/notifications/src/in-app/recipient.ts"],"mappings":";;;YAOY,iBAAiB,aAAa,eAAe;;;;;;wBAOzC,gBAAgB,YAAY;;wBAW5B,eAAe,WAAW;EAAmB,IAAI;EAAI"}
@@ -0,0 +1,27 @@
1
+ //#region ../../packages/notifications/src/in-app/recipient.ts
2
+ /**
3
+ * The identity type of a recipient model. Auth models name it with `userType`
4
+ * (the same value the auth config and token payloads use); any other model
5
+ * falls back to its table name.
6
+ */
7
+ function recipientTypeOf(notifiable) {
8
+ const userType = notifiable.userType;
9
+ if (typeof userType === "string" && userType) return userType;
10
+ return notifiable.constructor.table;
11
+ }
12
+ /** Normalize any accepted recipient form to `{ id, type? }` (`type` absent for bare ids). */
13
+ function toRecipientRef(recipient) {
14
+ if (typeof recipient !== "object") return { id: recipient };
15
+ if ("get" in recipient && typeof recipient.get === "function") return {
16
+ id: recipient.id,
17
+ type: recipientTypeOf(recipient)
18
+ };
19
+ return {
20
+ id: recipient.id,
21
+ type: recipient.type
22
+ };
23
+ }
24
+
25
+ //#endregion
26
+ export { recipientTypeOf, toRecipientRef };
27
+ //# sourceMappingURL=recipient.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recipient.mjs","names":[],"sources":["../../../../../../../../packages/notifications/src/in-app/recipient.ts"],"sourcesContent":["/**\n * Recipient identity for the in-app store. A recipient is keyed by\n * `(type, id)`: the same id under two identity types is two recipients.\n */\nimport type { Id, Notifiable, RecipientRef } from \"../types\";\n\n/** What the read and mutation methods accept for \"who\": a model, a typed ref, or a bare id. */\nexport type RecipientInput = Notifiable | RecipientRef | Id;\n\n/**\n * The identity type of a recipient model. Auth models name it with `userType`\n * (the same value the auth config and token payloads use); any other model\n * falls back to its table name.\n */\nexport function recipientTypeOf(notifiable: Notifiable): string {\n const userType = (notifiable as { userType?: unknown }).userType;\n\n if (typeof userType === \"string\" && userType) {\n return userType;\n }\n\n return (notifiable.constructor as unknown as { table: string }).table;\n}\n\n/** Normalize any accepted recipient form to `{ id, type? }` (`type` absent for bare ids). */\nexport function toRecipientRef(recipient: RecipientInput): { id: Id; type?: string } {\n if (typeof recipient !== \"object\") {\n return { id: recipient };\n }\n\n if (\"get\" in recipient && typeof recipient.get === \"function\") {\n return { id: (recipient as Notifiable).id, type: recipientTypeOf(recipient as Notifiable) };\n }\n\n return { id: (recipient as RecipientRef).id, type: (recipient as RecipientRef).type };\n}\n"],"mappings":";;;;;;AAcA,SAAgB,gBAAgB,YAAgC;CAC9D,MAAM,WAAY,WAAsC;CAExD,IAAI,OAAO,aAAa,YAAY,UAClC,OAAO;CAGT,OAAQ,WAAW,YAA6C;AAClE;;AAGA,SAAgB,eAAe,WAAsD;CACnF,IAAI,OAAO,cAAc,UACvB,OAAO,EAAE,IAAI,UAAU;CAGzB,IAAI,SAAS,aAAa,OAAO,UAAU,QAAQ,YACjD,OAAO;EAAE,IAAK,UAAyB;EAAI,MAAM,gBAAgB,SAAuB;CAAE;CAG5F,OAAO;EAAE,IAAK,UAA2B;EAAI,MAAO,UAA2B;CAAK;AACtF"}