@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
package/esm/index.d.mts CHANGED
@@ -1,11 +1,11 @@
1
- import { ChannelName, DatabasePayload, Id, MailPayload, Notifiable, NotificationChannels, NotificationEvents, NotificationInput, RenderContext, SendOptions } from "./types.mjs";
1
+ import { ChannelName, DatabasePayload, Id, MailPayload, Notifiable, NotificationChannels, NotificationEvents, NotificationInput, RecipientRef, RenderContext, SendOptions } from "./types.mjs";
2
2
  import { Channel } from "./contracts/channel.contract.mjs";
3
3
  import { NotificationContract } from "./contracts/notification.contract.mjs";
4
4
  import { PreferenceProvider } from "./contracts/preference-provider.contract.mjs";
5
5
  import { QueueDispatcher } from "./contracts/queue-dispatcher.contract.mjs";
6
6
  import { RateLimiter } from "./contracts/rate-limiter.contract.mjs";
7
7
  import "./contracts/index.mjs";
8
- import { ChannelNotFoundError, MissingRendererError, NoQueueDispatcherError, NotificationsNotConfiguredError, UnresolvableRouteError } from "./errors.mjs";
8
+ import { ChannelNotFoundError, MissingRendererError, NoQueueDispatcherError, NotificationsNotConfiguredError, RecipientTypeRequiredError, UnresolvableRouteError } from "./errors.mjs";
9
9
  import { ChannelMap, NotificationConfig, getNotificationConfig, resetNotificationConfig, setNotificationConfig } from "./config.mjs";
10
10
  import { defineChannel } from "./dispatch/define-channel.mjs";
11
11
  import { DefinedNotification, NotificationDef, defineNotification } from "./dispatch/define-notification.mjs";
@@ -14,6 +14,7 @@ import { notifications } from "./dispatch/notifications-event-bus.mjs";
14
14
  import "./dispatch/index.mjs";
15
15
  import { NotificationColumnMap, NotificationColumnMapHost, ResolvedNotificationColumnMap, resolveColumnMap } from "./in-app/column-map.mjs";
16
16
  import { DatabaseNotification } from "./in-app/database-notification.mjs";
17
+ import { RecipientInput, recipientTypeOf, toRecipientRef } from "./in-app/recipient.mjs";
17
18
  import { BaseNotificationsRepository, NotificationModelClass, NotificationsFilter } from "./in-app/base-notifications-repository.mjs";
18
19
  import { databaseChannel } from "./channels/database-channel.mjs";
19
20
  import { MailChannelConfig, mailChannel } from "./channels/mail-channel.mjs";
@@ -29,6 +30,6 @@ import { BullmqQueueOptions, NOTIFICATION_JOB_NAME, bullmqQueue } from "./queue/
29
30
  import { QueuePackageLoader, loadQueuePackage } from "./queue/load-queue.mjs";
30
31
  import { QueuePackageNotInstalledError } from "./queue/queue-package-not-installed.error.mjs";
31
32
  import "./queue/index.mjs";
32
- export { BaseNotificationsRepository, BullmqQueueOptions, Channel, ChannelMap, ChannelName, ChannelNotFoundError, ConfigureOptions, DEFAULT_QUEUE_CHANNEL, DatabaseNotification, DatabasePayload, DefinedNotification, HeraldQueueOptions, Id, MailChannelConfig, MailPayload, MissingRendererError, NOTIFICATION_JOB_NAME, NoQueueDispatcherError, Notifiable, NotificationChannels, NotificationColumnMap, NotificationColumnMapHost, NotificationColumnsOptions, NotificationConfig, NotificationContract, NotificationDef, NotificationEvents, NotificationIdType, NotificationInput, NotificationModelClass, NotificationsFilter, NotificationsListOptions, NotificationsNotConfiguredError, Notify, PreferenceProvider, QueueDispatcher, type QueuePackageLoader, QueuePackageNotInstalledError, RateLimiter, RenderContext, ResolvedNotificationColumnMap, SendOptions, UnresolvableRouteError, WorkerOptions, bullmqQueue, databaseChannel, defineChannel, defineNotification, getNotificationConfig, heraldQueue, inApp, loadQueuePackage, mailChannel, notificationColumns, notifications, notify, resetNotificationConfig, resolveColumnMap, setNotificationConfig, startNotificationsWorker };
33
+ export { BaseNotificationsRepository, BullmqQueueOptions, Channel, ChannelMap, ChannelName, ChannelNotFoundError, ConfigureOptions, DEFAULT_QUEUE_CHANNEL, DatabaseNotification, DatabasePayload, DefinedNotification, HeraldQueueOptions, Id, MailChannelConfig, MailPayload, MissingRendererError, NOTIFICATION_JOB_NAME, NoQueueDispatcherError, Notifiable, NotificationChannels, NotificationColumnMap, NotificationColumnMapHost, NotificationColumnsOptions, NotificationConfig, NotificationContract, NotificationDef, NotificationEvents, NotificationIdType, NotificationInput, NotificationModelClass, NotificationsFilter, NotificationsListOptions, NotificationsNotConfiguredError, Notify, PreferenceProvider, QueueDispatcher, type QueuePackageLoader, QueuePackageNotInstalledError, RateLimiter, RecipientInput, RecipientRef, RecipientTypeRequiredError, RenderContext, ResolvedNotificationColumnMap, SendOptions, UnresolvableRouteError, WorkerOptions, bullmqQueue, databaseChannel, defineChannel, defineNotification, getNotificationConfig, heraldQueue, inApp, loadQueuePackage, mailChannel, notificationColumns, notifications, notify, recipientTypeOf, resetNotificationConfig, resolveColumnMap, setNotificationConfig, startNotificationsWorker, toRecipientRef };
33
34
  import "./dispatch/define-channel.mjs";
34
35
  import "./types.mjs";
package/esm/index.mjs CHANGED
@@ -1,10 +1,11 @@
1
- import { ChannelNotFoundError, MissingRendererError, NoQueueDispatcherError, NotificationsNotConfiguredError, UnresolvableRouteError } from "./errors.mjs";
1
+ import { ChannelNotFoundError, MissingRendererError, NoQueueDispatcherError, NotificationsNotConfiguredError, RecipientTypeRequiredError, UnresolvableRouteError } from "./errors.mjs";
2
2
  import { getNotificationConfig, resetNotificationConfig, setNotificationConfig } from "./config.mjs";
3
3
  import { defineChannel } from "./dispatch/define-channel.mjs";
4
4
  import { notifications } from "./dispatch/notifications-event-bus.mjs";
5
5
  import { defineNotification } from "./dispatch/define-notification.mjs";
6
6
  import { notify } from "./dispatch/notify.mjs";
7
7
  import "./dispatch/index.mjs";
8
+ import { recipientTypeOf, toRecipientRef } from "./in-app/recipient.mjs";
8
9
  import { databaseChannel } from "./channels/database-channel.mjs";
9
10
  import { mailChannel } from "./channels/mail-channel.mjs";
10
11
  import "./channels/index.mjs";
@@ -23,4 +24,4 @@ import { loadQueuePackage } from "./queue/load-queue.mjs";
23
24
  import { NOTIFICATION_JOB_NAME, bullmqQueue } from "./queue/bullmq-queue.mjs";
24
25
  import "./queue/index.mjs";
25
26
 
26
- export { BaseNotificationsRepository, ChannelNotFoundError, DEFAULT_QUEUE_CHANNEL, DatabaseNotification, MissingRendererError, NOTIFICATION_JOB_NAME, NoQueueDispatcherError, NotificationsNotConfiguredError, QueuePackageNotInstalledError, UnresolvableRouteError, bullmqQueue, databaseChannel, defineChannel, defineNotification, getNotificationConfig, heraldQueue, inApp, loadQueuePackage, mailChannel, notificationColumns, notifications, notify, resetNotificationConfig, resolveColumnMap, setNotificationConfig, startNotificationsWorker };
27
+ export { BaseNotificationsRepository, ChannelNotFoundError, DEFAULT_QUEUE_CHANNEL, DatabaseNotification, MissingRendererError, NOTIFICATION_JOB_NAME, NoQueueDispatcherError, NotificationsNotConfiguredError, QueuePackageNotInstalledError, RecipientTypeRequiredError, UnresolvableRouteError, bullmqQueue, databaseChannel, defineChannel, defineNotification, getNotificationConfig, heraldQueue, inApp, loadQueuePackage, mailChannel, notificationColumns, notifications, notify, recipientTypeOf, resetNotificationConfig, resolveColumnMap, setNotificationConfig, startNotificationsWorker, toRecipientRef };
@@ -3,8 +3,19 @@ import { ColumnMap } from "@warlock.js/cascade";
3
3
  //#region ../../packages/notifications/src/migration/notification-columns.d.ts
4
4
  export type NotificationIdType = "uuid" | "bigint" | "integer" | "string";
5
5
  export type NotificationColumnsOptions = {
6
- /** The type used for recipient and tenant identifiers. Defaults to UUID. */
6
+ /**
7
+ * The type used for recipient and tenant identifiers. Defaults to the id
8
+ * type derived from `recipientModel` when given, otherwise UUID.
9
+ */
7
10
  idType?: NotificationIdType;
11
+ /**
12
+ * The recipient model (a `User`, `Customer`, …). Auto-generated ids are
13
+ * integers, so the id columns are `integer`; a model with
14
+ * `autoGenerateId = false` brings its own ids, so they are `uuid`.
15
+ */
16
+ recipientModel?: {
17
+ autoGenerateId?: boolean;
18
+ };
8
19
  };
9
20
  /**
10
21
  * Returns the column map for a notification table, named from the model's
@@ -1 +1 @@
1
- {"version":3,"file":"notification-columns.d.mts","names":[],"sources":["../../../../../../../../packages/notifications/src/migration/notification-columns.ts"],"mappings":";;;YAsCY;YAEA;;EAEV,SAAS;;;;;;;wBAQK,oBAAoB,UAAU,6BAA6B;wBAC3D,oBACd,QAAQ,2BACR,UAAU,6BACT"}
1
+ {"version":3,"file":"notification-columns.d.mts","names":[],"sources":["../../../../../../../../packages/notifications/src/migration/notification-columns.ts"],"mappings":";;;YA0CY;YAEA;;;;;EAKV,SAAS;;;;;;EAMT;IAAmB;;;;;;;;wBAQL,oBAAoB,UAAU,6BAA6B;wBAC3D,oBACd,QAAQ,2BACR,UAAU,6BACT"}
@@ -9,6 +9,10 @@ import { bigInteger, boolCol, integer, json, string, text, timestamp, uuid } fro
9
9
  * agree by construction. The stable lexical columns (`type`, `title`, `body`,
10
10
  * `payload`, `idempotency_key`) keep fixed names.
11
11
  *
12
+ * `columnMap.recipientType` adds the recipient-type string column (see
13
+ * `NotificationColumnMap`). For a table created earlier, add that column with a
14
+ * migration instead of re-creating the table.
15
+ *
12
16
  * Read-state follows the map's presence rules (see `NotificationColumnMap`):
13
17
  * `readAt` → a nullable timestamp, `isRead` → an indexed boolean, both → both.
14
18
  *
@@ -29,8 +33,9 @@ import { bigInteger, boolCol, integer, json, string, text, timestamp, uuid } fro
29
33
  */
30
34
  function notificationColumns(modelOrOptions, options) {
31
35
  const model = isNotificationColumnsOptions(modelOrOptions) ? void 0 : modelOrOptions;
32
- const idType = (isNotificationColumnsOptions(modelOrOptions) ? modelOrOptions : options)?.idType ?? "uuid";
33
- const { recipient, tenant, readAt, isRead } = resolveColumnMap(model?.columnMap);
36
+ const resolvedOptions = isNotificationColumnsOptions(modelOrOptions) ? modelOrOptions : options;
37
+ const idType = resolvedOptions?.idType ?? deriveIdType(resolvedOptions?.recipientModel);
38
+ const { recipient, recipientType, tenant, readAt, isRead } = resolveColumnMap(model?.columnMap);
34
39
  const idColumn = getIdColumn(idType);
35
40
  const columns = {
36
41
  [recipient]: idColumn().index().notNullable(),
@@ -39,6 +44,7 @@ function notificationColumns(modelOrOptions, options) {
39
44
  body: text().nullable(),
40
45
  payload: json().nullable()
41
46
  };
47
+ if (recipientType) columns[recipientType] = string().index().notNullable();
42
48
  if (readAt) columns[readAt] = timestamp().nullable();
43
49
  if (isRead) columns[isRead] = boolCol().default(false).index();
44
50
  if (tenant) columns[tenant] = idColumn().index().notNullable();
@@ -46,7 +52,7 @@ function notificationColumns(modelOrOptions, options) {
46
52
  return columns;
47
53
  }
48
54
  function isNotificationColumnsOptions(value) {
49
- return "idType" in (value ?? {});
55
+ return "idType" in (value ?? {}) || "recipientModel" in (value ?? {});
50
56
  }
51
57
  function getIdColumn(idType) {
52
58
  switch (idType) {
@@ -56,6 +62,10 @@ function getIdColumn(idType) {
56
62
  default: return uuid;
57
63
  }
58
64
  }
65
+ function deriveIdType(recipientModel) {
66
+ if (!recipientModel || recipientModel.autoGenerateId === false) return "uuid";
67
+ return "integer";
68
+ }
59
69
 
60
70
  //#endregion
61
71
  export { notificationColumns };
@@ -1 +1 @@
1
- {"version":3,"file":"notification-columns.mjs","names":[],"sources":["../../../../../../../../packages/notifications/src/migration/notification-columns.ts"],"sourcesContent":["/**\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 {\n bigInteger,\n boolCol,\n type ColumnMap,\n integer,\n json,\n string,\n text,\n timestamp,\n uuid,\n} from \"@warlock.js/cascade\";\nimport { type NotificationColumnMapHost, resolveColumnMap } from \"../in-app/column-map\";\n\nexport type NotificationIdType = \"uuid\" | \"bigint\" | \"integer\" | \"string\";\n\nexport type NotificationColumnsOptions = {\n /** The type used for recipient and tenant identifiers. Defaults to UUID. */\n idType?: NotificationIdType;\n};\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(options?: NotificationColumnsOptions): ColumnMap;\nexport function notificationColumns(\n model?: NotificationColumnMapHost,\n options?: NotificationColumnsOptions,\n): ColumnMap;\nexport function notificationColumns(\n modelOrOptions?: NotificationColumnMapHost | NotificationColumnsOptions,\n options?: NotificationColumnsOptions,\n): ColumnMap {\n const model = isNotificationColumnsOptions(modelOrOptions) ? undefined : modelOrOptions;\n const idType =\n (isNotificationColumnsOptions(modelOrOptions) ? modelOrOptions : options)?.idType ?? \"uuid\";\n const { recipient, tenant, readAt, isRead } = resolveColumnMap(model?.columnMap);\n const idColumn = getIdColumn(idType);\n\n const columns: ColumnMap = {\n [recipient]: idColumn().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] = idColumn().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\nfunction isNotificationColumnsOptions(\n value: NotificationColumnMapHost | NotificationColumnsOptions | undefined,\n): value is NotificationColumnsOptions {\n return \"idType\" in (value ?? {});\n}\n\nfunction getIdColumn(idType: NotificationIdType) {\n switch (idType) {\n case \"bigint\":\n return bigInteger;\n case \"integer\":\n return integer;\n case \"string\":\n return string;\n default:\n return uuid;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuDA,SAAgB,oBACd,gBACA,SACW;CACX,MAAM,QAAQ,6BAA6B,cAAc,IAAI,SAAY;CACzE,MAAM,UACH,6BAA6B,cAAc,IAAI,iBAAiB,QAAO,EAAG,UAAU;CACvF,MAAM,EAAE,WAAW,QAAQ,QAAQ,WAAW,iBAAiB,OAAO,SAAS;CAC/E,MAAM,WAAW,YAAY,MAAM;CAEnC,MAAM,UAAqB;GACxB,YAAY,SAAS,CAAC,CAAC,MAAM,CAAC,CAAC,YAAY;EAC5C,MAAM,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,YAAY;EACnC,OAAO,OAAO,CAAC,CAAC,YAAY;EAC5B,MAAM,KAAK,CAAC,CAAC,SAAS;EACtB,SAAS,KAAK,CAAC,CAAC,SAAS;CAC3B;CAEA,IAAI,QACF,QAAQ,UAAU,UAAU,CAAC,CAAC,SAAS;CAGzC,IAAI,QACF,QAAQ,UAAU,QAAQ,CAAC,CAAC,QAAQ,KAAK,CAAC,CAAC,MAAM;CAGnD,IAAI,QACF,QAAQ,UAAU,SAAS,CAAC,CAAC,MAAM,CAAC,CAAC,YAAY;CAMnD,QAAQ,kBAAkB,OAAO,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO;CAErD,OAAO;AACT;AAEA,SAAS,6BACP,OACqC;CACrC,OAAO,aAAa,SAAS,CAAC;AAChC;AAEA,SAAS,YAAY,QAA4B;CAC/C,QAAQ,QAAR;EACE,KAAK,UACH,OAAO;EACT,KAAK,WACH,OAAO;EACT,KAAK,UACH,OAAO;EACT,SACE,OAAO;CACX;AACF"}
1
+ {"version":3,"file":"notification-columns.mjs","names":[],"sources":["../../../../../../../../packages/notifications/src/migration/notification-columns.ts"],"sourcesContent":["/**\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 * `columnMap.recipientType` adds the recipient-type string column (see\n * `NotificationColumnMap`). For a table created earlier, add that column with a\n * migration instead of re-creating the table.\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 {\n bigInteger,\n boolCol,\n type ColumnMap,\n integer,\n json,\n string,\n text,\n timestamp,\n uuid,\n} from \"@warlock.js/cascade\";\nimport { type NotificationColumnMapHost, resolveColumnMap } from \"../in-app/column-map\";\n\nexport type NotificationIdType = \"uuid\" | \"bigint\" | \"integer\" | \"string\";\n\nexport type NotificationColumnsOptions = {\n /**\n * The type used for recipient and tenant identifiers. Defaults to the id\n * type derived from `recipientModel` when given, otherwise UUID.\n */\n idType?: NotificationIdType;\n /**\n * The recipient model (a `User`, `Customer`, …). Auto-generated ids are\n * integers, so the id columns are `integer`; a model with\n * `autoGenerateId = false` brings its own ids, so they are `uuid`.\n */\n recipientModel?: { autoGenerateId?: boolean };\n};\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(options?: NotificationColumnsOptions): ColumnMap;\nexport function notificationColumns(\n model?: NotificationColumnMapHost,\n options?: NotificationColumnsOptions,\n): ColumnMap;\nexport function notificationColumns(\n modelOrOptions?: NotificationColumnMapHost | NotificationColumnsOptions,\n options?: NotificationColumnsOptions,\n): ColumnMap {\n const model = isNotificationColumnsOptions(modelOrOptions) ? undefined : modelOrOptions;\n const resolvedOptions = isNotificationColumnsOptions(modelOrOptions) ? modelOrOptions : options;\n const idType = resolvedOptions?.idType ?? deriveIdType(resolvedOptions?.recipientModel);\n const { recipient, recipientType, tenant, readAt, isRead } = resolveColumnMap(model?.columnMap);\n const idColumn = getIdColumn(idType);\n\n const columns: ColumnMap = {\n [recipient]: idColumn().index().notNullable(),\n type: string().index().notNullable(),\n title: string().notNullable(),\n body: text().nullable(),\n payload: json().nullable(),\n };\n\n if (recipientType) {\n // Indexed together with the recipient id by the app's own composite index when needed.\n columns[recipientType] = string().index().notNullable();\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] = idColumn().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\nfunction isNotificationColumnsOptions(\n value: NotificationColumnMapHost | NotificationColumnsOptions | undefined,\n): value is NotificationColumnsOptions {\n return \"idType\" in (value ?? {}) || \"recipientModel\" in (value ?? {});\n}\n\nfunction getIdColumn(idType: NotificationIdType) {\n switch (idType) {\n case \"bigint\":\n return bigInteger;\n case \"integer\":\n return integer;\n case \"string\":\n return string;\n default:\n return uuid;\n }\n}\n\nfunction deriveIdType(recipientModel?: { autoGenerateId?: boolean }): NotificationIdType {\n if (!recipientModel || recipientModel.autoGenerateId === false) {\n return \"uuid\";\n }\n\n return \"integer\";\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoEA,SAAgB,oBACd,gBACA,SACW;CACX,MAAM,QAAQ,6BAA6B,cAAc,IAAI,SAAY;CACzE,MAAM,kBAAkB,6BAA6B,cAAc,IAAI,iBAAiB;CACxF,MAAM,SAAS,iBAAiB,UAAU,aAAa,iBAAiB,cAAc;CACtF,MAAM,EAAE,WAAW,eAAe,QAAQ,QAAQ,WAAW,iBAAiB,OAAO,SAAS;CAC9F,MAAM,WAAW,YAAY,MAAM;CAEnC,MAAM,UAAqB;GACxB,YAAY,SAAS,CAAC,CAAC,MAAM,CAAC,CAAC,YAAY;EAC5C,MAAM,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,YAAY;EACnC,OAAO,OAAO,CAAC,CAAC,YAAY;EAC5B,MAAM,KAAK,CAAC,CAAC,SAAS;EACtB,SAAS,KAAK,CAAC,CAAC,SAAS;CAC3B;CAEA,IAAI,eAEF,QAAQ,iBAAiB,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,YAAY;CAGxD,IAAI,QACF,QAAQ,UAAU,UAAU,CAAC,CAAC,SAAS;CAGzC,IAAI,QACF,QAAQ,UAAU,QAAQ,CAAC,CAAC,QAAQ,KAAK,CAAC,CAAC,MAAM;CAGnD,IAAI,QACF,QAAQ,UAAU,SAAS,CAAC,CAAC,MAAM,CAAC,CAAC,YAAY;CAMnD,QAAQ,kBAAkB,OAAO,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO;CAErD,OAAO;AACT;AAEA,SAAS,6BACP,OACqC;CACrC,OAAO,aAAa,SAAS,CAAC,MAAM,qBAAqB,SAAS,CAAC;AACrE;AAEA,SAAS,YAAY,QAA4B;CAC/C,QAAQ,QAAR;EACE,KAAK,UACH,OAAO;EACT,KAAK,WACH,OAAO;EACT,KAAK,UACH,OAAO;EACT,SACE,OAAO;CACX;AACF;AAEA,SAAS,aAAa,gBAAmE;CACvF,IAAI,CAAC,kBAAkB,eAAe,mBAAmB,OACvD,OAAO;CAGT,OAAO;AACT"}
package/esm/types.d.mts CHANGED
@@ -10,6 +10,16 @@ import { Model } from "@warlock.js/cascade";
10
10
  export type Notifiable = Model;
11
11
  /** An identifier — string (uuid / objectId) or number (auto-increment). */
12
12
  export type Id = string | number;
13
+ /**
14
+ * A recipient named without its model: the identity `type` (an auth model's
15
+ * `userType`, otherwise its table name) plus its `id`. Use it where only the
16
+ * id is at hand but the notifications table keys recipients by type. A ref
17
+ * without `type` is treated like a bare id.
18
+ */
19
+ export type RecipientRef = {
20
+ type?: string;
21
+ id: Id;
22
+ };
13
23
  /** What the `mail` channel renders to and `core.sendMail` consumes. */
14
24
  export type MailPayload = {
15
25
  subject: string;
@@ -20,6 +30,8 @@ export type MailPayload = {
20
30
  /** Normalized in-app record shape written through the database channel. */
21
31
  export type NotificationInput = {
22
32
  recipientId: Id;
33
+ /** Identity type of the recipient — set by the channel from the recipient model. */
34
+ recipientType?: string;
23
35
  type: string;
24
36
  title: string;
25
37
  body?: string;
@@ -31,7 +43,7 @@ export type NotificationInput = {
31
43
  * Database payload as seen by RENDERERS — `recipientId` is set by the channel
32
44
  * from the resolved route, not by the render closure.
33
45
  */
34
- export type DatabasePayload = Omit<NotificationInput, "recipientId">;
46
+ export type DatabasePayload = Omit<NotificationInput, "recipientId" | "recipientType">;
35
47
  /**
36
48
  * Channel REGISTRY — the single declaration-merge target mapping channel NAME
37
49
  * → payload type. Drives `notify.<channel>` proxy typing and
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.mts","names":[],"sources":["../../../../../../../packages/notifications/src/types.ts"],"mappings":";;;;;;;;;YAkBY,aAAa;;YAGb;;YAKA;EACV;EACA;EACA;EACA;;;YAIU;EACV,aAAa;EACb;EACA;EACA;EACA,UAAU;;EAEV;;;;;;YAOU,kBAAkB,KAAK;;;;;;;;;;;;;;iBAiBlB;EACf,MAAM;EACN,UAAU;;YAGA,oBAAoB;;;;;;;;;;;;;YAgBpB;;;;;;;;EAQV;;EAEA;;EAEA,OAAO;;EAEP;;EAEA;;EAEA;;;YAIU,gBAAgB,KAAK;;;;;;;;;;;;;;YAiBrB;EACV;IACE;IACA;IACA,aAAa;IACb;IACA,SAAS;;EAEX;IACE;IACA;IACA,aAAa;IACb;IACA,SAAS;IACT;;EAEF;IACE;IACA;IACA,aAAa;IACb;IACA,OAAO;IACP,SAAS;IACT;;EAEF;IACE;IACA;IACA,aAAa;IACb;IACA,SAAS"}
1
+ {"version":3,"file":"types.d.mts","names":[],"sources":["../../../../../../../packages/notifications/src/types.ts"],"mappings":";;;;;;;;;YAkBY,aAAa;;YAGb;;;;;;;YAQA;EAAiB;EAAe,IAAI;;;YAKpC;EACV;EACA;EACA;EACA;;;YAIU;EACV,aAAa;;EAEb;EACA;EACA;EACA;EACA,UAAU;;EAEV;;;;;;YAOU,kBAAkB,KAAK;;;;;;;;;;;;;;iBAiBlB;EACf,MAAM;EACN,UAAU;;YAGA,oBAAoB;;;;;;;;;;;;;YAgBpB;;;;;;;;EAQV;;EAEA;;EAEA,OAAO;;EAEP;;EAEA;;EAEA;;;YAIU,gBAAgB,KAAK;;;;;;;;;;;;;;YAiBrB;EACV;IACE;IACA;IACA,aAAa;IACb;IACA,SAAS;;EAEX;IACE;IACA;IACA,aAAa;IACb;IACA,SAAS;IACT;;EAEF;IACE;IACA;IACA,aAAa;IACb;IACA,OAAO;IACP,SAAS;IACT;;EAEF;IACE;IACA;IACA,aAAa;IACb;IACA,SAAS"}
package/llms-full.txt CHANGED
@@ -903,13 +903,18 @@ class BaseNotificationsRepository extends RepositoryManager {
903
903
 
904
904
  ### `createFor` write side — no IDOR either (since 4.15.0)
905
905
 
906
- `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.
906
+ `recipientId` (and `recipientType`) are always the trusted arguments, applied **after** the payload — `createFor`/`createManyFor` strip server-owned keys (`id`, `recipientId`, `recipientType`, `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.
907
+
908
+ ### Recipient types — identities that share an id
909
+
910
+ Recipients are keyed by `(type, id)`. Declare `recipientType: "recipient_type"` in `columnMap` and every `inApp` read / count / mark / dismiss filters on both, so staff `5` and customer `5` never see each other's rows. The type is the recipient model's `userType` (auth models) or its table name; the `database` channel stores it. Also accepted: `{ type, id }` (`RecipientRef`). `recipientTypeOf(model)` returns the type a model is stored under, and `toRecipientRef(recipient)` turns a model or ref into `{ type, id }`. With the column declared a **bare id throws `RecipientTypeRequiredError`**; without it ids are the only key (single-identity apps, tables created before 5.46.0). Upgrading: add a nullable `recipient_type` column, backfill it (unbackfilled rows match no typed read), then declare it in `columnMap`. Full steps in the docs page "The in-app store".
907
911
 
908
912
  ### `columnMap` — map logical roles → your columns
909
913
 
910
914
  ```ts
911
915
  export type NotificationColumnMap = {
912
916
  recipient?: string; // recipient FK. Default "user_id"
917
+ recipientType?: string; // recipient identity type column. Omit → ids are the only key
913
918
  tenant?: string; // multi-tenant scope, written from the recipient. Omit → single-tenant
914
919
  readAt?: string; // read-timestamp column
915
920
  isRead?: string; // read-flag column (indexed)
@@ -992,7 +997,7 @@ database: inApp.configure({ repository: notificationsRepository }),
992
997
 
993
998
  ---
994
999
  name: write-notification-migration
995
- description: 'Create the notifications table with the `notificationColumns(Notification)` factory + cascade `Migration.create`. Column NAMES come from the model''s `columnMap` (recipient / tenant / readAt / isRead); the fixed columns are `type` / `title` / `body` / `payload` / `idempotency_key`. Set `{ idType: "bigint" }` for integer-key apps; UUID remains the default. Read-state follows columnMap presence: `readAt` → nullable timestamp, `isRead` → indexed boolean, both → both. Defaults to `user_id` + `read_at` when no columnMap. Spread + extend for extras (FK references, composite indexes). Triggers: `notificationColumns`, `Migration.create` for notifications, "create the notifications table", "notification migration", "add organization_id to notifications", `is_read` column, `read_at` column, `idempotency_key` column; typical import `import { Migration } from "@warlock.js/cascade"; import { notificationColumns } from "@warlock.js/notifications"`. Skip: generic cascade migration writing — the `write-migration` topic of the `warlock-js-cascade` skill; the model itself — the `use-in-app` topic.'
1000
+ description: 'Create the notifications table with the `notificationColumns(Notification)` factory + cascade `Migration.create`. Column NAMES come from the model''s `columnMap` (recipient / tenant / readAt / isRead); the fixed columns are `type` / `title` / `body` / `payload` / `idempotency_key`. Id columns default to UUID; pass `{ recipientModel: User }` to derive them (integer, or UUID when the model has `autoGenerateId = false`) or `{ idType }` to set them. `columnMap.recipientType` adds a `recipient_type` string column. Read-state follows columnMap presence: `readAt` → nullable timestamp, `isRead` → indexed boolean, both → both. Defaults to `user_id` + `read_at` when no columnMap. Spread + extend for extras (FK references, composite indexes). Triggers: `notificationColumns`, `Migration.create` for notifications, "create the notifications table", "notification migration", "add organization_id to notifications", `is_read` column, `read_at` column, `idempotency_key` column; typical import `import { Migration } from "@warlock.js/cascade"; import { notificationColumns } from "@warlock.js/notifications"`. Skip: generic cascade migration writing — the `write-migration` topic of the `warlock-js-cascade` skill; the model itself — the `use-in-app` topic.'
996
1001
  ---
997
1002
 
998
1003
  # Write the notifications migration
@@ -1018,6 +1023,7 @@ matching columns:
1018
1023
  | -------------------------------------------------- | --------------------------------------------------------------------------- |
1019
1024
  | _(none / default)_ | `user_id`, `type`, `title`, `body`, `payload`, `read_at`, `idempotency_key` |
1020
1025
  | `{ tenant: "organization_id", readAt: "read_at" }` | adds `organization_id`; read_at-only |
1026
+ | `{ recipientType: "recipient_type" }` | adds `recipient_type` (the identity type that owns the row) |
1021
1027
  | `{ isRead: "is_read" }` | swaps `read_at` for `is_read` (indexed boolean) |
1022
1028
  | `{ isRead: "is_read", readAt: "read_at" }` | both read-state columns |
1023
1029
 
@@ -1025,10 +1031,11 @@ The recipient / tenant / read-state column NAMES come from `columnMap`; `type` /
1025
1031
  `title` / `body` / `payload` / `idempotency_key` are fixed. `id` / `createdAt` /
1026
1032
  `updatedAt` are added automatically by cascade.
1027
1033
 
1034
+ Declare `recipientType` so different identity types sharing an id cannot read each other's notifications (see the `use-in-app` topic). For a table that already exists, add the column with `Migration.alter` and backfill it.
1035
+
1028
1036
  ## Match your app's identifier type
1029
1037
 
1030
- Recipient and tenant columns default to UUID. For applications whose primary keys
1031
- are `bigint`, `integer`, or strings, pass `idType`; it is applied to every
1038
+ Recipient and tenant columns default to UUID. For integer-id apps pass `recipientModel: User` (auto-generated ids give `integer`; `autoGenerateId = false` keeps UUID) or set `idType` (`"uuid"`, `"bigint"`, `"integer"`, `"string"`) explicitly; it is applied to every
1032
1039
  identifier column the factory creates:
1033
1040
 
1034
1041
  ```ts
package/llms.txt CHANGED
@@ -15,4 +15,4 @@
15
15
  - [queue-notifications](@warlock.js/notifications/skills/queue-notifications/SKILL.md): Send notifications asynchronously through the herald backend or the BullMQ driver. Configure a QueueDispatcher in config/notifications.ts, then use notif.queue(to, data, options?) to enqueue rendered jobs. Herald uses heraldQueue() and startNotificationsWorker(); bullmqQueue({ attempts, backoff }) from @warlock.js/notifications uses BullMQ (lazy-loading the optional @warlock.js/queue peer), supports retries, and honours SendOptions.delay. Without a queue configured, .queue() rejects NoQueueDispatcherError. Triggers: heraldQueue, bullmqQueue, startNotificationsWorker, QueueDispatcher, .queue(, async notifications, queue notifications, notification worker, background notifications, notifications.dispatch. Skip: synchronous sends - the `define-notification` topic; herald itself - @warlock.js/herald/*.
16
16
  - [send-ad-hoc](@warlock.js/notifications/skills/send-ad-hoc/SKILL.md): Ad-hoc per-channel send via the `notify.<channel>(to, payload, options?)` Proxy facade. Works for any channel registered in `NotificationChannels` (built-in or custom). `to` accepts a `Notifiable` model OR a raw route string (`"x@y.com"`); `payload` is typed per channel via the registry; `options` is the standard `SendOptions`. `notify.channel(name).send(...)` is the runtime escape when the channel name is not a compile-time literal. Type-based preference gating applies when a type is known — explicit via `options.type` OR auto-detected from a database payload's `type` field. For multi-channel sends use `defineNotification` instead — there is no inline `notify(to, { via, ... })` form on purpose. Triggers: `notify.<channel>`, `notify.channel(name)`, `notify.mail`, `notify.database`, "send a one-off notification", "ad-hoc notification", "send an email without defining a notification", "raw email target", "dynamic channel name"; typical import `import { notify } from "@warlock.js/notifications"`. Skip: reusable multi-channel notifications — the `define-notification` topic; configuring channels — the `configure-notifications` topic.
17
17
  - [use-in-app](@warlock.js/notifications/skills/use-in-app/SKILL.md): Use the `inApp` facade for the in-app/database channel's READ side: `inApp.configure({ model: Notification })` or `{ repository: myRepo }` binds the store AND returns the channel; `inApp.list / listUnread / countUnread / markAsRead / markAsUnread` are RECIPIENT-SCOPED by construction (no IDOR — `markAsRead(user, id)` cannot flip another user's row even with a foreign id). `countUnread` uses `RepositoryManager.countCached` for the badge fast-path. Triggers: `inApp.configure`, `inApp.list`, `inApp.listUnread`, `inApp.countUnread`, `inApp.markAsRead`, `inApp.markAsUnread`, `BaseNotificationsRepository`, `DatabaseNotification`, `NotificationsFilter`, "in-app notifications", "mark notification as read", "unread count", "notification badge", "IDOR-safe markAsRead"; typical import `import { inApp } from "@warlock.js/notifications"`. Skip: writing notifications (use `defineNotification` or `notify.database`) — the `define-notification` topic; the migration columns — the `write-notification-migration` topic; wiring it into config — the `configure-notifications` topic.
18
- - [write-notification-migration](@warlock.js/notifications/skills/write-notification-migration/SKILL.md): Create the notifications table with the `notificationColumns(Notification)` factory + cascade `Migration.create`. Column NAMES come from the model's `columnMap` (recipient / tenant / readAt / isRead); the fixed columns are `type` / `title` / `body` / `payload` / `idempotency_key`. Set `{ idType: "bigint" }` for integer-key apps; UUID remains the default. Read-state follows columnMap presence: `readAt` → nullable timestamp, `isRead` → indexed boolean, both → both. Defaults to `user_id` + `read_at` when no columnMap. Spread + extend for extras (FK references, composite indexes). Triggers: `notificationColumns`, `Migration.create` for notifications, "create the notifications table", "notification migration", "add organization_id to notifications", `is_read` column, `read_at` column, `idempotency_key` column; typical import `import { Migration } from "@warlock.js/cascade"; import { notificationColumns } from "@warlock.js/notifications"`. Skip: generic cascade migration writing — the `write-migration` topic of the `warlock-js-cascade` skill; the model itself — the `use-in-app` topic.
18
+ - [write-notification-migration](@warlock.js/notifications/skills/write-notification-migration/SKILL.md): Create the notifications table with the `notificationColumns(Notification)` factory + cascade `Migration.create`. Column NAMES come from the model's `columnMap` (recipient / tenant / readAt / isRead); the fixed columns are `type` / `title` / `body` / `payload` / `idempotency_key`. Id columns default to UUID; pass `{ recipientModel: User }` to derive them (integer, or UUID when the model has `autoGenerateId = false`) or `{ idType }` to set them. `columnMap.recipientType` adds a `recipient_type` string column. Read-state follows columnMap presence: `readAt` → nullable timestamp, `isRead` → indexed boolean, both → both. Defaults to `user_id` + `read_at` when no columnMap. Spread + extend for extras (FK references, composite indexes). Triggers: `notificationColumns`, `Migration.create` for notifications, "create the notifications table", "notification migration", "add organization_id to notifications", `is_read` column, `read_at` column, `idempotency_key` column; typical import `import { Migration } from "@warlock.js/cascade"; import { notificationColumns } from "@warlock.js/notifications"`. Skip: generic cascade migration writing — the `write-migration` topic of the `warlock-js-cascade` skill; the model itself — the `use-in-app` topic.
package/package.json CHANGED
@@ -5,14 +5,14 @@
5
5
  "environment": "server"
6
6
  },
7
7
  "dependencies": {
8
- "@warlock.js/cache": "5.44.0",
9
- "@warlock.js/cascade": "5.44.0",
10
- "@warlock.js/core": "5.44.0",
11
- "@warlock.js/logger": "5.44.0"
8
+ "@warlock.js/cache": "5.46.0",
9
+ "@warlock.js/cascade": "5.46.0",
10
+ "@warlock.js/core": "5.46.0",
11
+ "@warlock.js/logger": "5.46.0"
12
12
  },
13
13
  "peerDependencies": {
14
- "@warlock.js/herald": "5.44.0",
15
- "@warlock.js/queue": "5.44.0"
14
+ "@warlock.js/herald": "5.46.0",
15
+ "@warlock.js/queue": "5.46.0"
16
16
  },
17
17
  "peerDependenciesMeta": {
18
18
  "@warlock.js/herald": {
@@ -37,7 +37,7 @@
37
37
  ],
38
38
  "author": "hassanzohdy",
39
39
  "license": "MIT",
40
- "version": "5.44.0",
40
+ "version": "5.46.0",
41
41
  "main": "./cjs/index.cjs",
42
42
  "module": "./esm/index.mjs",
43
43
  "types": "./esm/index.d.mts",
@@ -89,13 +89,18 @@ class BaseNotificationsRepository extends RepositoryManager {
89
89
 
90
90
  ### `createFor` write side — no IDOR either (since 4.15.0)
91
91
 
92
- `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.
92
+ `recipientId` (and `recipientType`) are always the trusted arguments, applied **after** the payload — `createFor`/`createManyFor` strip server-owned keys (`id`, `recipientId`, `recipientType`, `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.
93
+
94
+ ### Recipient types — identities that share an id
95
+
96
+ Recipients are keyed by `(type, id)`. Declare `recipientType: "recipient_type"` in `columnMap` and every `inApp` read / count / mark / dismiss filters on both, so staff `5` and customer `5` never see each other's rows. The type is the recipient model's `userType` (auth models) or its table name; the `database` channel stores it. Also accepted: `{ type, id }` (`RecipientRef`). `recipientTypeOf(model)` returns the type a model is stored under, and `toRecipientRef(recipient)` turns a model or ref into `{ type, id }`. With the column declared a **bare id throws `RecipientTypeRequiredError`**; without it ids are the only key (single-identity apps, tables created before 5.46.0). Upgrading: add a nullable `recipient_type` column, backfill it (unbackfilled rows match no typed read), then declare it in `columnMap`. Full steps in the docs page "The in-app store".
93
97
 
94
98
  ### `columnMap` — map logical roles → your columns
95
99
 
96
100
  ```ts
97
101
  export type NotificationColumnMap = {
98
102
  recipient?: string; // recipient FK. Default "user_id"
103
+ recipientType?: string; // recipient identity type column. Omit → ids are the only key
99
104
  tenant?: string; // multi-tenant scope, written from the recipient. Omit → single-tenant
100
105
  readAt?: string; // read-timestamp column
101
106
  isRead?: string; // read-flag column (indexed)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: write-notification-migration
3
- description: 'Create the notifications table with the `notificationColumns(Notification)` factory + cascade `Migration.create`. Column NAMES come from the model''s `columnMap` (recipient / tenant / readAt / isRead); the fixed columns are `type` / `title` / `body` / `payload` / `idempotency_key`. Set `{ idType: "bigint" }` for integer-key apps; UUID remains the default. Read-state follows columnMap presence: `readAt` → nullable timestamp, `isRead` → indexed boolean, both → both. Defaults to `user_id` + `read_at` when no columnMap. Spread + extend for extras (FK references, composite indexes). Triggers: `notificationColumns`, `Migration.create` for notifications, "create the notifications table", "notification migration", "add organization_id to notifications", `is_read` column, `read_at` column, `idempotency_key` column; typical import `import { Migration } from "@warlock.js/cascade"; import { notificationColumns } from "@warlock.js/notifications"`. Skip: generic cascade migration writing — the `write-migration` topic of the `warlock-js-cascade` skill; the model itself — the `use-in-app` topic.'
3
+ description: 'Create the notifications table with the `notificationColumns(Notification)` factory + cascade `Migration.create`. Column NAMES come from the model''s `columnMap` (recipient / tenant / readAt / isRead); the fixed columns are `type` / `title` / `body` / `payload` / `idempotency_key`. Id columns default to UUID; pass `{ recipientModel: User }` to derive them (integer, or UUID when the model has `autoGenerateId = false`) or `{ idType }` to set them. `columnMap.recipientType` adds a `recipient_type` string column. Read-state follows columnMap presence: `readAt` → nullable timestamp, `isRead` → indexed boolean, both → both. Defaults to `user_id` + `read_at` when no columnMap. Spread + extend for extras (FK references, composite indexes). Triggers: `notificationColumns`, `Migration.create` for notifications, "create the notifications table", "notification migration", "add organization_id to notifications", `is_read` column, `read_at` column, `idempotency_key` column; typical import `import { Migration } from "@warlock.js/cascade"; import { notificationColumns } from "@warlock.js/notifications"`. Skip: generic cascade migration writing — the `write-migration` topic of the `warlock-js-cascade` skill; the model itself — the `use-in-app` topic.'
4
4
  ---
5
5
 
6
6
  # Write the notifications migration
@@ -26,6 +26,7 @@ matching columns:
26
26
  | -------------------------------------------------- | --------------------------------------------------------------------------- |
27
27
  | _(none / default)_ | `user_id`, `type`, `title`, `body`, `payload`, `read_at`, `idempotency_key` |
28
28
  | `{ tenant: "organization_id", readAt: "read_at" }` | adds `organization_id`; read_at-only |
29
+ | `{ recipientType: "recipient_type" }` | adds `recipient_type` (the identity type that owns the row) |
29
30
  | `{ isRead: "is_read" }` | swaps `read_at` for `is_read` (indexed boolean) |
30
31
  | `{ isRead: "is_read", readAt: "read_at" }` | both read-state columns |
31
32
 
@@ -33,10 +34,11 @@ The recipient / tenant / read-state column NAMES come from `columnMap`; `type` /
33
34
  `title` / `body` / `payload` / `idempotency_key` are fixed. `id` / `createdAt` /
34
35
  `updatedAt` are added automatically by cascade.
35
36
 
37
+ Declare `recipientType` so different identity types sharing an id cannot read each other's notifications (see the `use-in-app` topic). For a table that already exists, add the column with `Migration.alter` and backfill it.
38
+
36
39
  ## Match your app's identifier type
37
40
 
38
- Recipient and tenant columns default to UUID. For applications whose primary keys
39
- are `bigint`, `integer`, or strings, pass `idType`; it is applied to every
41
+ Recipient and tenant columns default to UUID. For integer-id apps pass `recipientModel: User` (auto-generated ids give `integer`; `autoGenerateId = false` keeps UUID) or set `idType` (`"uuid"`, `"bigint"`, `"integer"`, `"string"`) explicitly; it is applied to every
40
42
  identifier column the factory creates:
41
43
 
42
44
  ```ts