@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/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to `@warlock.js/notifications` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
6
 
7
+ ## 5.46.0 - 2026-11-06
8
+
9
+ ### Upgrading
10
+
11
+ - **Security: different identity types could read each other's notifications.** Recipients were keyed by a bare id, so with several auth models (staff, customers, platform users) that each count ids from 1, staff `5` and customer `5` shared one inbox. Add a recipient type column and declare it: create a nullable `recipient_type` column, backfill existing rows with the type that owned them, then set `columnMap = { recipientType: "recipient_type", … }` on the notification model. Until you declare it, behavior is unchanged (ids are the only key), which is only safe for an app with a single identity type. See "Recipient types" in the in-app store guide.
12
+ - Once `recipientType` is declared, rows with no type match no read until backfilled, and a bare id passed to an `inApp` method or repository method throws `RecipientTypeRequiredError`; pass the model or `{ type, id }`.
13
+
14
+ ### Added
15
+
16
+ - `columnMap.recipientType`: stores the recipient's identity type next to its id. The `database` channel writes it from the recipient model (an auth model's `userType`, otherwise the table name), and `notificationColumns()` emits the column.
17
+ - `inApp` read, count, mark-read, mark-unread, find and dismiss methods (and the matching repository methods) accept `{ type, id }` (`RecipientRef`) as well as a model or bare id, and filter by type and id when the column is declared.
18
+ - `RecipientTypeRequiredError`, `recipientTypeOf()`, `RecipientRef`, `RecipientInput`.
19
+ - `notificationColumns(…, { recipientModel })` derives the id column type from the recipient model (`integer`, or `uuid` when it sets `autoGenerateId = false`). Without it the default stays UUID.
20
+
7
21
  ## 5.34.3 - 2026-10-20
8
22
 
9
23
  ### Changed
package/cjs/index.cjs CHANGED
@@ -56,6 +56,19 @@ var UnresolvableRouteError = class UnresolvableRouteError extends Error {
56
56
  captureStack$1(this, UnresolvableRouteError);
57
57
  }
58
58
  };
59
+ /**
60
+ * Thrown when the notification table keys recipients by type and a read or
61
+ * write names the recipient by a bare id. A bare id cannot say which identity
62
+ * type it belongs to, so serving it would let types sharing that id read each
63
+ * other's notifications. Pass the recipient model, or `{ type, id }`.
64
+ */
65
+ var RecipientTypeRequiredError = class RecipientTypeRequiredError extends Error {
66
+ constructor(recipientId) {
67
+ super(`The notifications table stores a recipient type, so recipient ${recipientId ?? "(unknown)"} must be given as the model or as \`{ type, id }\`, not as a bare id.`);
68
+ this.name = "RecipientTypeRequiredError";
69
+ captureStack$1(this, RecipientTypeRequiredError);
70
+ }
71
+ };
59
72
 
60
73
  //#endregion
61
74
  //#region ../../packages/notifications/src/config.ts
@@ -489,17 +502,49 @@ const notify = new Proxy({}, { get(_target, prop) {
489
502
  return (to, payload, options) => notifyImpl(prop, to, payload, options);
490
503
  } });
491
504
 
505
+ //#endregion
506
+ //#region ../../packages/notifications/src/in-app/recipient.ts
507
+ /**
508
+ * The identity type of a recipient model. Auth models name it with `userType`
509
+ * (the same value the auth config and token payloads use); any other model
510
+ * falls back to its table name.
511
+ */
512
+ function recipientTypeOf(notifiable) {
513
+ const userType = notifiable.userType;
514
+ if (typeof userType === "string" && userType) return userType;
515
+ return notifiable.constructor.table;
516
+ }
517
+ /** Normalize any accepted recipient form to `{ id, type? }` (`type` absent for bare ids). */
518
+ function toRecipientRef(recipient) {
519
+ if (typeof recipient !== "object") return { id: recipient };
520
+ if ("get" in recipient && typeof recipient.get === "function") return {
521
+ id: recipient.id,
522
+ type: recipientTypeOf(recipient)
523
+ };
524
+ return {
525
+ id: recipient.id,
526
+ type: recipient.type
527
+ };
528
+ }
529
+
492
530
  //#endregion
493
531
  //#region ../../packages/notifications/src/channels/database-channel.ts
494
532
  function databaseChannel(repo) {
495
533
  return defineChannel({
496
534
  name: "database",
497
- route: (notifiable) => ({ id: notifiable.id }),
535
+ route: (notifiable) => ({
536
+ id: notifiable.id,
537
+ type: recipientTypeOf(notifiable)
538
+ }),
498
539
  async send({ payload, route, notifiable }) {
499
540
  const id = typeof route === "object" ? route.id : route;
541
+ const type = typeof route === "object" ? route.type : void 0;
500
542
  const { tenantColumn } = repo;
501
543
  const tenantId = tenantColumn && notifiable ? notifiable.get(tenantColumn) : void 0;
502
- await repo.createFor(id, payload, tenantId);
544
+ await repo.createFor({
545
+ id,
546
+ type
547
+ }, payload, tenantId);
503
548
  }
504
549
  });
505
550
  }
@@ -555,13 +600,16 @@ const DEFAULT_READ_AT_COLUMN = "read_at";
555
600
  function resolveColumnMap(map) {
556
601
  const recipient = map?.recipient ?? DEFAULT_RECIPIENT_COLUMN;
557
602
  const tenant = map?.tenant;
603
+ const recipientType = map?.recipientType;
558
604
  if (!map?.readAt && !map?.isRead) return {
559
605
  recipient,
606
+ recipientType,
560
607
  tenant,
561
608
  readAt: DEFAULT_READ_AT_COLUMN
562
609
  };
563
610
  return {
564
611
  recipient,
612
+ recipientType,
565
613
  tenant,
566
614
  readAt: map.readAt,
567
615
  isRead: map.isRead
@@ -619,7 +667,7 @@ var BaseNotificationsRepository = class extends _warlock_js_core.RepositoryManag
619
667
  * direct filtering only when the model declares those columns.
620
668
  */
621
669
  buildFilterBy() {
622
- const { recipient, tenant, readAt, isRead } = this.columns;
670
+ const { recipient, recipientType, tenant, readAt, isRead } = this.columns;
623
671
  const rules = {
624
672
  id: "=",
625
673
  type: "=",
@@ -631,12 +679,29 @@ var BaseNotificationsRepository = class extends _warlock_js_core.RepositoryManag
631
679
  else if (readAt) query.whereNull(readAt);
632
680
  }
633
681
  };
682
+ if (recipientType) rules.recipientType = ["=", recipientType];
634
683
  if (isRead) rules.isRead = ["=", isRead];
635
684
  if (readAt) rules.readAt = ["=", readAt];
636
685
  if (tenant) rules.tenant = ["=", tenant];
637
686
  return rules;
638
687
  }
639
688
  /**
689
+ * Resolve a recipient into the logical filter keys that scope a query to it.
690
+ * When the model stores a recipient type the scope is `(recipientType,
691
+ * recipientId)`; a bare id then throws, since an id alone can't say which
692
+ * identity type it belongs to. Without a type column the scope is the id
693
+ * alone and any type on the recipient is ignored.
694
+ */
695
+ scopeOf(recipient) {
696
+ const { id, type } = toRecipientRef(recipient);
697
+ if (!this.columns.recipientType) return { recipientId: id };
698
+ if (!type) throw new RecipientTypeRequiredError(id);
699
+ return {
700
+ recipientId: id,
701
+ recipientType: type
702
+ };
703
+ }
704
+ /**
640
705
  * Translate an object keyed by LOGICAL names into one keyed by PHYSICAL
641
706
  * columns, using `filterBy` as the lookup. Keys with no `[op, column]` tuple
642
707
  * (e.g. `title`, `body`, `payload`, `type`, `id`) pass through unchanged —
@@ -665,10 +730,12 @@ var BaseNotificationsRepository = class extends _warlock_js_core.RepositoryManag
665
730
  * the recipient column directly.
666
731
  */
667
732
  stripServerOwnedKeys(input) {
668
- const { recipient, tenant, readAt, isRead } = this.columns;
733
+ const { recipient, recipientType, tenant, readAt, isRead } = this.columns;
669
734
  const serverOwned = /* @__PURE__ */ new Set([
670
735
  "id",
671
736
  "recipientId",
737
+ "recipientType",
738
+ recipientType,
672
739
  "tenant",
673
740
  "readAt",
674
741
  "isRead",
@@ -692,15 +759,16 @@ var BaseNotificationsRepository = class extends _warlock_js_core.RepositoryManag
692
759
  * the existing row instead of inserting a duplicate. A unique index on the
693
760
  * key is the backstop for the rare insert race (we re-fetch on conflict).
694
761
  */
695
- async createFor(recipientId, input, tenantId) {
762
+ async createFor(recipient, input, tenantId) {
696
763
  const { idempotencyKey } = input;
764
+ const scope = this.scopeOf(recipient);
697
765
  if (idempotencyKey) {
698
- const existing = await this.firstByIdempotencyKey(recipientId, idempotencyKey);
766
+ const existing = await this.firstByIdempotencyKey(scope, idempotencyKey);
699
767
  if (existing) return existing;
700
768
  }
701
769
  const row = {
702
770
  ...this.stripServerOwnedKeys(input),
703
- recipientId
771
+ ...scope
704
772
  };
705
773
  if (this.columns.isRead) row.isRead = false;
706
774
  if (this.columns.tenant && tenantId !== void 0) row.tenant = tenantId;
@@ -708,7 +776,7 @@ var BaseNotificationsRepository = class extends _warlock_js_core.RepositoryManag
708
776
  return await this.create(this.toRow(row));
709
777
  } catch (error) {
710
778
  if (idempotencyKey) {
711
- const existing = await this.firstByIdempotencyKey(recipientId, idempotencyKey);
779
+ const existing = await this.firstByIdempotencyKey(scope, idempotencyKey);
712
780
  if (existing) return existing;
713
781
  }
714
782
  throw error;
@@ -719,8 +787,8 @@ var BaseNotificationsRepository = class extends _warlock_js_core.RepositoryManag
719
787
  * for the Phase-2 `Channel.sendMany` fan-out hook; not on the per-recipient
720
788
  * send path yet.
721
789
  */
722
- createManyFor(recipientIds, input, tenantId) {
723
- return Promise.all(recipientIds.map((recipientId) => this.createFor(recipientId, input, tenantId)));
790
+ createManyFor(recipients, input, tenantId) {
791
+ return Promise.all(recipients.map((recipient) => this.createFor(recipient, input, tenantId)));
724
792
  }
725
793
  /**
726
794
  * Mark rows read — recipient-scoped. `id` omitted → all unread rows for the
@@ -728,14 +796,14 @@ var BaseNotificationsRepository = class extends _warlock_js_core.RepositoryManag
728
796
  * to unread so a re-mark never overwrites an earlier `read_at` timestamp.
729
797
  * Sets whichever read-state column(s) the model declares.
730
798
  */
731
- markRead(recipientId, id) {
799
+ markRead(recipient, id) {
732
800
  const { isRead, readAt } = this.columns;
733
801
  const unreadScope = isRead ? { isRead: false } : { readAt: null };
734
802
  const data = {};
735
803
  if (isRead) data.isRead = true;
736
804
  if (readAt) data.readAt = /* @__PURE__ */ new Date();
737
805
  return this.updateMany(this.toRow({
738
- recipientId,
806
+ ...this.scopeOf(recipient),
739
807
  ...unreadScope,
740
808
  ...id !== void 0 ? { id } : {}
741
809
  }), this.toRow(data));
@@ -744,20 +812,20 @@ var BaseNotificationsRepository = class extends _warlock_js_core.RepositoryManag
744
812
  * Inverse of `markRead`, same recipient-scoping. No unread scope needed —
745
813
  * clearing the read-state of an already-unread row is a no-op.
746
814
  */
747
- markUnread(recipientId, id) {
815
+ markUnread(recipient, id) {
748
816
  const { isRead, readAt } = this.columns;
749
817
  const data = {};
750
818
  if (isRead) data.isRead = false;
751
819
  if (readAt) data.readAt = null;
752
820
  return this.updateMany(this.toRow({
753
- recipientId,
821
+ ...this.scopeOf(recipient),
754
822
  ...id !== void 0 ? { id } : {}
755
823
  }), this.toRow(data));
756
824
  }
757
825
  /** Find one row for a recipient (read path — `filterBy` maps the keys). */
758
- findFor(recipientId, id) {
826
+ findFor(recipient, id) {
759
827
  return this.first({
760
- recipientId,
828
+ ...this.scopeOf(recipient),
761
829
  id
762
830
  });
763
831
  }
@@ -765,16 +833,16 @@ var BaseNotificationsRepository = class extends _warlock_js_core.RepositoryManag
765
833
  * Delete rows — recipient-scoped. `id` omitted → clear all for the
766
834
  * recipient; `id` given → that one row.
767
835
  */
768
- deleteFor(recipientId, id) {
836
+ deleteFor(recipient, id) {
769
837
  return this.deleteMany(this.toRow({
770
- recipientId,
838
+ ...this.scopeOf(recipient),
771
839
  ...id !== void 0 ? { id } : {}
772
840
  }));
773
841
  }
774
- /** Read path — `filterBy` maps `recipientId`/`idempotencyKey` to columns. */
775
- firstByIdempotencyKey(recipientId, idempotencyKey) {
842
+ /** Read path — `filterBy` maps `recipientId`/`recipientType`/`idempotencyKey` to columns. */
843
+ firstByIdempotencyKey(scope, idempotencyKey) {
776
844
  return this.first({
777
- recipientId,
845
+ ...scope,
778
846
  idempotencyKey
779
847
  });
780
848
  }
@@ -847,7 +915,6 @@ var DatabaseNotification = class extends _warlock_js_cascade.Model {
847
915
 
848
916
  //#endregion
849
917
  //#region ../../packages/notifications/src/in-app/in-app.ts
850
- const idOf = (recipient) => typeof recipient === "object" ? recipient.id : recipient;
851
918
  var InApp = class {
852
919
  repo;
853
920
  /**
@@ -866,14 +933,14 @@ var InApp = class {
866
933
  list(recipient, options) {
867
934
  return this.repository.list({
868
935
  ...options,
869
- recipientId: idOf(recipient)
936
+ ...this.repository.scopeOf(recipient)
870
937
  });
871
938
  }
872
939
  /** Unread-only list — common case for badges + dashboards. */
873
940
  listUnread(recipient, options) {
874
941
  return this.repository.list({
875
942
  ...options,
876
- recipientId: idOf(recipient),
943
+ ...this.repository.scopeOf(recipient),
877
944
  unread: true
878
945
  });
879
946
  }
@@ -883,25 +950,25 @@ var InApp = class {
883
950
  */
884
951
  countUnread(recipient) {
885
952
  return this.repository.countCached({
886
- recipientId: idOf(recipient),
953
+ ...this.repository.scopeOf(recipient),
887
954
  unread: true
888
955
  });
889
956
  }
890
957
  /** Find one notification for a recipient — for a detail view. */
891
958
  find(recipient, id) {
892
- return this.repository.findFor(idOf(recipient), id);
959
+ return this.repository.findFor(recipient, id);
893
960
  }
894
961
  /** Mark read — `id` omitted = all unread for this recipient. */
895
962
  markAsRead(recipient, id) {
896
- return this.repository.markRead(idOf(recipient), id);
963
+ return this.repository.markRead(recipient, id);
897
964
  }
898
965
  /** Mark unread — same recipient-scoping. */
899
966
  markAsUnread(recipient, id) {
900
- return this.repository.markUnread(idOf(recipient), id);
967
+ return this.repository.markUnread(recipient, id);
901
968
  }
902
969
  /** Delete/dismiss — `id` omitted = clear all for this recipient. */
903
970
  dismiss(recipient, id) {
904
- return this.repository.deleteFor(idOf(recipient), id);
971
+ return this.repository.deleteFor(recipient, id);
905
972
  }
906
973
  };
907
974
  /** Single in-app facade — bound at boot via `inApp.configure(...)`. */
@@ -916,6 +983,10 @@ const inApp = new InApp();
916
983
  * agree by construction. The stable lexical columns (`type`, `title`, `body`,
917
984
  * `payload`, `idempotency_key`) keep fixed names.
918
985
  *
986
+ * `columnMap.recipientType` adds the recipient-type string column (see
987
+ * `NotificationColumnMap`). For a table created earlier, add that column with a
988
+ * migration instead of re-creating the table.
989
+ *
919
990
  * Read-state follows the map's presence rules (see `NotificationColumnMap`):
920
991
  * `readAt` → a nullable timestamp, `isRead` → an indexed boolean, both → both.
921
992
  *
@@ -936,8 +1007,9 @@ const inApp = new InApp();
936
1007
  */
937
1008
  function notificationColumns(modelOrOptions, options) {
938
1009
  const model = isNotificationColumnsOptions(modelOrOptions) ? void 0 : modelOrOptions;
939
- const idType = (isNotificationColumnsOptions(modelOrOptions) ? modelOrOptions : options)?.idType ?? "uuid";
940
- const { recipient, tenant, readAt, isRead } = resolveColumnMap(model?.columnMap);
1010
+ const resolvedOptions = isNotificationColumnsOptions(modelOrOptions) ? modelOrOptions : options;
1011
+ const idType = resolvedOptions?.idType ?? deriveIdType(resolvedOptions?.recipientModel);
1012
+ const { recipient, recipientType, tenant, readAt, isRead } = resolveColumnMap(model?.columnMap);
941
1013
  const idColumn = getIdColumn(idType);
942
1014
  const columns = {
943
1015
  [recipient]: idColumn().index().notNullable(),
@@ -946,6 +1018,7 @@ function notificationColumns(modelOrOptions, options) {
946
1018
  body: (0, _warlock_js_cascade.text)().nullable(),
947
1019
  payload: (0, _warlock_js_cascade.json)().nullable()
948
1020
  };
1021
+ if (recipientType) columns[recipientType] = (0, _warlock_js_cascade.string)().index().notNullable();
949
1022
  if (readAt) columns[readAt] = (0, _warlock_js_cascade.timestamp)().nullable();
950
1023
  if (isRead) columns[isRead] = (0, _warlock_js_cascade.boolCol)().default(false).index();
951
1024
  if (tenant) columns[tenant] = idColumn().index().notNullable();
@@ -953,7 +1026,7 @@ function notificationColumns(modelOrOptions, options) {
953
1026
  return columns;
954
1027
  }
955
1028
  function isNotificationColumnsOptions(value) {
956
- return "idType" in (value ?? {});
1029
+ return "idType" in (value ?? {}) || "recipientModel" in (value ?? {});
957
1030
  }
958
1031
  function getIdColumn(idType) {
959
1032
  switch (idType) {
@@ -963,6 +1036,10 @@ function getIdColumn(idType) {
963
1036
  default: return _warlock_js_cascade.uuid;
964
1037
  }
965
1038
  }
1039
+ function deriveIdType(recipientModel) {
1040
+ if (!recipientModel || recipientModel.autoGenerateId === false) return "uuid";
1041
+ return "integer";
1042
+ }
966
1043
 
967
1044
  //#endregion
968
1045
  //#region ../../packages/notifications/src/queue/load-herald.ts
@@ -1164,6 +1241,7 @@ exports.NOTIFICATION_JOB_NAME = NOTIFICATION_JOB_NAME;
1164
1241
  exports.NoQueueDispatcherError = NoQueueDispatcherError;
1165
1242
  exports.NotificationsNotConfiguredError = NotificationsNotConfiguredError;
1166
1243
  exports.QueuePackageNotInstalledError = QueuePackageNotInstalledError;
1244
+ exports.RecipientTypeRequiredError = RecipientTypeRequiredError;
1167
1245
  exports.UnresolvableRouteError = UnresolvableRouteError;
1168
1246
  exports.bullmqQueue = bullmqQueue;
1169
1247
  exports.databaseChannel = databaseChannel;
@@ -1177,8 +1255,10 @@ exports.mailChannel = mailChannel;
1177
1255
  exports.notificationColumns = notificationColumns;
1178
1256
  exports.notifications = notifications;
1179
1257
  exports.notify = notify;
1258
+ exports.recipientTypeOf = recipientTypeOf;
1180
1259
  exports.resetNotificationConfig = resetNotificationConfig;
1181
1260
  exports.resolveColumnMap = resolveColumnMap;
1182
1261
  exports.setNotificationConfig = setNotificationConfig;
1183
1262
  exports.startNotificationsWorker = startNotificationsWorker;
1263
+ exports.toRecipientRef = toRecipientRef;
1184
1264
  //# sourceMappingURL=index.cjs.map