@ttt-productions/notification-core 0.15.16 → 0.15.18

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 (98) hide show
  1. package/README.md +9 -10
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +0 -3
  5. package/dist/index.js.map +1 -1
  6. package/dist/react/components/NotificationEmptyState.d.ts +0 -3
  7. package/dist/react/components/NotificationEmptyState.d.ts.map +1 -1
  8. package/dist/react/components/NotificationEmptyState.js +0 -3
  9. package/dist/react/components/NotificationEmptyState.js.map +1 -1
  10. package/dist/react/components/NotificationHistoryList.d.ts +1 -7
  11. package/dist/react/components/NotificationHistoryList.d.ts.map +1 -1
  12. package/dist/react/components/NotificationHistoryList.js +4 -9
  13. package/dist/react/components/NotificationHistoryList.js.map +1 -1
  14. package/dist/react/components/NotificationList.d.ts +1 -4
  15. package/dist/react/components/NotificationList.d.ts.map +1 -1
  16. package/dist/react/components/NotificationList.js +26 -38
  17. package/dist/react/components/NotificationList.js.map +1 -1
  18. package/dist/react/components/NotificationUnreadBadge.d.ts +0 -5
  19. package/dist/react/components/NotificationUnreadBadge.d.ts.map +1 -1
  20. package/dist/react/components/NotificationUnreadBadge.js +1 -6
  21. package/dist/react/components/NotificationUnreadBadge.js.map +1 -1
  22. package/dist/react/components/relative-time.d.ts +0 -3
  23. package/dist/react/components/relative-time.d.ts.map +1 -1
  24. package/dist/react/components/relative-time.js +0 -3
  25. package/dist/react/components/relative-time.js.map +1 -1
  26. package/dist/react/hooks/useActiveNotifications.d.ts +3 -7
  27. package/dist/react/hooks/useActiveNotifications.d.ts.map +1 -1
  28. package/dist/react/hooks/useActiveNotifications.js +3 -8
  29. package/dist/react/hooks/useActiveNotifications.js.map +1 -1
  30. package/dist/react/hooks/useArchiveAllNotifications.d.ts +0 -30
  31. package/dist/react/hooks/useArchiveAllNotifications.d.ts.map +1 -1
  32. package/dist/react/hooks/useArchiveAllNotifications.js +15 -45
  33. package/dist/react/hooks/useArchiveAllNotifications.js.map +1 -1
  34. package/dist/react/hooks/useArchiveNotification.d.ts +0 -8
  35. package/dist/react/hooks/useArchiveNotification.d.ts.map +1 -1
  36. package/dist/react/hooks/useArchiveNotification.js +0 -8
  37. package/dist/react/hooks/useArchiveNotification.js.map +1 -1
  38. package/dist/react/hooks/useNotificationHistory.d.ts +3 -17
  39. package/dist/react/hooks/useNotificationHistory.d.ts.map +1 -1
  40. package/dist/react/hooks/useNotificationHistory.js +3 -20
  41. package/dist/react/hooks/useNotificationHistory.js.map +1 -1
  42. package/dist/react/hooks/useUnreadCount.d.ts +0 -22
  43. package/dist/react/hooks/useUnreadCount.d.ts.map +1 -1
  44. package/dist/react/hooks/useUnreadCount.js +2 -25
  45. package/dist/react/hooks/useUnreadCount.js.map +1 -1
  46. package/dist/react/index.d.ts +10 -2
  47. package/dist/react/index.d.ts.map +1 -1
  48. package/dist/react/index.js +10 -5
  49. package/dist/react/index.js.map +1 -1
  50. package/dist/react/types.d.ts +30 -0
  51. package/dist/react/types.d.ts.map +1 -0
  52. package/dist/react/types.js +2 -0
  53. package/dist/react/types.js.map +1 -0
  54. package/dist/schemas.d.ts +0 -22
  55. package/dist/schemas.d.ts.map +1 -1
  56. package/dist/schemas.js +0 -25
  57. package/dist/schemas.js.map +1 -1
  58. package/dist/server/activeNotificationId.d.ts +1 -22
  59. package/dist/server/activeNotificationId.d.ts.map +1 -1
  60. package/dist/server/activeNotificationId.js +0 -16
  61. package/dist/server/activeNotificationId.js.map +1 -1
  62. package/dist/server/delivery-ledger.d.ts +1 -73
  63. package/dist/server/delivery-ledger.d.ts.map +1 -1
  64. package/dist/server/delivery-ledger.js +13 -63
  65. package/dist/server/delivery-ledger.js.map +1 -1
  66. package/dist/server/errors.d.ts +0 -9
  67. package/dist/server/errors.d.ts.map +1 -1
  68. package/dist/server/errors.js +1 -11
  69. package/dist/server/errors.js.map +1 -1
  70. package/dist/server/index.d.ts.map +1 -1
  71. package/dist/server/index.js +0 -3
  72. package/dist/server/index.js.map +1 -1
  73. package/dist/server/observed-generation.d.ts +1 -41
  74. package/dist/server/observed-generation.d.ts.map +1 -1
  75. package/dist/server/observed-generation.js +1 -31
  76. package/dist/server/observed-generation.js.map +1 -1
  77. package/dist/server/processBatchHelper.d.ts +0 -19
  78. package/dist/server/processBatchHelper.d.ts.map +1 -1
  79. package/dist/server/processBatchHelper.js +5 -51
  80. package/dist/server/processBatchHelper.js.map +1 -1
  81. package/dist/server/types.d.ts +0 -9
  82. package/dist/server/types.d.ts.map +1 -1
  83. package/dist/server/types.js +0 -4
  84. package/dist/server/types.js.map +1 -1
  85. package/dist/types.d.ts +2 -266
  86. package/dist/types.d.ts.map +1 -1
  87. package/dist/types.js +0 -6
  88. package/dist/types.js.map +1 -1
  89. package/package.json +5 -5
  90. package/src/styles/notifications.css +0 -14
  91. package/dist/react/components/index.d.ts +0 -5
  92. package/dist/react/components/index.d.ts.map +0 -1
  93. package/dist/react/components/index.js +0 -5
  94. package/dist/react/components/index.js.map +0 -1
  95. package/dist/react/hooks/index.d.ts +0 -6
  96. package/dist/react/hooks/index.d.ts.map +0 -1
  97. package/dist/react/hooks/index.js +0 -6
  98. package/dist/react/hooks/index.js.map +0 -1
package/dist/schemas.js CHANGED
@@ -1,23 +1,4 @@
1
- /**
2
- * @ttt-productions/notification-core — Zod schemas
3
- *
4
- * Runtime validation schemas for the generic notification document shapes whose
5
- * TypeScript types live in `./types.ts`. notification-core OWNS these shapes, so
6
- * it owns their schemas too — the consuming app's data package composes these rather than hand-mirroring them, so the schema can never drift
7
- * from the type.
8
- *
9
- * The interfaces in `./types.ts` remain the documented public type API. Each
10
- * schema is compile-time-checked against its interface via
11
- * `satisfies z.ZodType<Shape>` — a shape drift between the interface and the
12
- * schema fails the build here.
13
- *
14
- * zod-only, no React/firebase: safe for both server (Cloud Functions) and UI.
15
- */
16
1
  import { z } from 'zod';
17
- /**
18
- * Active notification document (`NotificationDoc`). Same shape for both the user
19
- * and admin active collections.
20
- */
21
2
  export const NotificationDocSchema = z.object({
22
3
  id: z.string(),
23
4
  type: z.string(),
@@ -28,20 +9,14 @@ export const NotificationDocSchema = z.object({
28
9
  message: z.string(),
29
10
  count: z.number(),
30
11
  latestActorIds: z.array(z.string()),
31
- // Absent on linkless types (no defaultTargetPath in the type config).
32
12
  targetPath: z.string().optional(),
33
13
  metadata: z.record(z.string(), z.unknown()),
34
14
  seenAt: z.number(),
35
- // Opaque per-generation token rotated on create / material re-light. Optional on
36
- // the legacy active doc shape; the ledger materializer always sets it.
37
15
  activityGeneration: z.string().optional(),
38
16
  seenAtGeneration: z.string().optional(),
39
17
  createdAt: z.number(),
40
18
  updatedAt: z.number(),
41
19
  });
42
- /**
43
- * Pending notification (`PendingNotification`) — queue item for the batch processor.
44
- */
45
20
  export const PendingNotificationSchema = z.object({
46
21
  id: z.string(),
47
22
  type: z.string(),
@@ -1 +1 @@
1
- {"version":3,"file":"schemas.js","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB;;;GAGG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC5C,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE;IACd,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE;IACpB,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE;IACpB,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACnC,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE;IACjB,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE;IACnB,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE;IACjB,cAAc,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IACnC,sEAAsE;IACtE,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACjC,QAAQ,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC;IAC3C,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE;IAClB,iFAAiF;IACjF,uEAAuE;IACvE,kBAAkB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACzC,gBAAgB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACvC,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;IACrB,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;CACtB,CAAsC,CAAC;AAExC;;GAEG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,CAAC,MAAM,CAAC;IAChD,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE;IACd,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE;IACpB,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACnC,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE;IACnB,QAAQ,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC;IAC3C,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;CACtB,CAA0C,CAAC","sourcesContent":["/**\n * @ttt-productions/notification-core — Zod schemas\n *\n * Runtime validation schemas for the generic notification document shapes whose\n * TypeScript types live in `./types.ts`. notification-core OWNS these shapes, so\n * it owns their schemas too — the consuming app's data package composes these rather than hand-mirroring them, so the schema can never drift\n * from the type.\n *\n * The interfaces in `./types.ts` remain the documented public type API. Each\n * schema is compile-time-checked against its interface via\n * `satisfies z.ZodType<Shape>` — a shape drift between the interface and the\n * schema fails the build here.\n *\n * zod-only, no React/firebase: safe for both server (Cloud Functions) and UI.\n */\n\nimport { z } from 'zod';\nimport type { NotificationDoc, PendingNotification } from './types.js';\n\n/**\n * Active notification document (`NotificationDoc`). Same shape for both the user\n * and admin active collections.\n */\nexport const NotificationDocSchema = z.object({\n id: z.string(),\n type: z.string(),\n dedupKey: z.string(),\n category: z.string(),\n targetUserId: z.string().nullable(),\n title: z.string(),\n message: z.string(),\n count: z.number(),\n latestActorIds: z.array(z.string()),\n // Absent on linkless types (no defaultTargetPath in the type config).\n targetPath: z.string().optional(),\n metadata: z.record(z.string(), z.unknown()),\n seenAt: z.number(),\n // Opaque per-generation token rotated on create / material re-light. Optional on\n // the legacy active doc shape; the ledger materializer always sets it.\n activityGeneration: z.string().optional(),\n seenAtGeneration: z.string().optional(),\n createdAt: z.number(),\n updatedAt: z.number(),\n}) satisfies z.ZodType<NotificationDoc>;\n\n/**\n * Pending notification (`PendingNotification`) — queue item for the batch processor.\n */\nexport const PendingNotificationSchema = z.object({\n id: z.string(),\n type: z.string(),\n category: z.string(),\n targetUserId: z.string().nullable(),\n actorId: z.string(),\n metadata: z.record(z.string(), z.unknown()),\n createdAt: z.number(),\n}) satisfies z.ZodType<PendingNotification>;\n"]}
1
+ {"version":3,"file":"schemas.js","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC5C,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE;IACd,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE;IACpB,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE;IACpB,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACnC,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE;IACjB,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE;IACnB,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE;IACjB,cAAc,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IACnC,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACjC,QAAQ,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC;IAC3C,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE;IAClB,kBAAkB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACzC,gBAAgB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACvC,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;IACrB,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;CACtB,CAAsC,CAAC;AAExC,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,CAAC,MAAM,CAAC;IAChD,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE;IACd,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE;IACpB,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACnC,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE;IACnB,QAAQ,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC;IAC3C,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;CACtB,CAA0C,CAAC","sourcesContent":["import { z } from 'zod';\nimport type { NotificationDoc, PendingNotification } from './types.js';\n\nexport const NotificationDocSchema = z.object({\n id: z.string(),\n type: z.string(),\n dedupKey: z.string(),\n category: z.string(),\n targetUserId: z.string().nullable(),\n title: z.string(),\n message: z.string(),\n count: z.number(),\n latestActorIds: z.array(z.string()),\n targetPath: z.string().optional(),\n metadata: z.record(z.string(), z.unknown()),\n seenAt: z.number(),\n activityGeneration: z.string().optional(),\n seenAtGeneration: z.string().optional(),\n createdAt: z.number(),\n updatedAt: z.number(),\n}) satisfies z.ZodType<NotificationDoc>;\n\nexport const PendingNotificationSchema = z.object({\n id: z.string(),\n type: z.string(),\n category: z.string(),\n targetUserId: z.string().nullable(),\n actorId: z.string(),\n metadata: z.record(z.string(), z.unknown()),\n createdAt: z.number(),\n}) satisfies z.ZodType<PendingNotification>;\n"]}
@@ -1,30 +1,9 @@
1
- /**
2
- * Deterministic active-notification document IDs.
3
- *
4
- * The active doc for a given dedup scope always lives at the same ID, so
5
- * concurrent writers (real-time sends racing each other or the batch
6
- * materializer) converge on one document inside a transaction instead of
7
- * racing a query-then-write. The ID hashes the same fields the dedup lookup
8
- * used to filter on: category, audience scope, and dedupKey.
9
- */
10
- /**
11
- * Build the deterministic doc ID for an active notification.
12
- *
13
- * Personal notifications scope per recipient (a fan-out to many users must
14
- * never collapse into one recipient's doc); shared notifications are
15
- * intentionally a single doc for the whole audience.
16
- */
17
1
  export declare function buildActiveNotificationDocId(params: {
18
2
  category: string;
19
3
  audienceType: 'personal' | 'shared';
20
4
  targetUserId: string | null | undefined;
21
5
  dedupKey: string;
22
- /**
23
- * When provided, the id becomes **type-scoped** so two notification types that
24
- * share an aggregation key never collapse onto one active doc (the delivery
25
- * ledger materializer always passes it). Omitted => the legacy non-type-scoped
26
- * id, kept byte-identical for the pre-cutover composer path.
27
- */
6
+ /** Omit for processBatchHelper's id, which must stay byte-identical: existing active docs live at it. */
28
7
  notificationType?: string;
29
8
  }): string;
30
9
  //# sourceMappingURL=activeNotificationId.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"activeNotificationId.d.ts","sourceRoot":"","sources":["../../src/server/activeNotificationId.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAAC,MAAM,EAAE;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,UAAU,GAAG,QAAQ,CAAC;IACpC,YAAY,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,GAAG,MAAM,CAQT"}
1
+ {"version":3,"file":"activeNotificationId.d.ts","sourceRoot":"","sources":["../../src/server/activeNotificationId.ts"],"names":[],"mappings":"AAEA,wBAAgB,4BAA4B,CAAC,MAAM,EAAE;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,UAAU,GAAG,QAAQ,CAAC;IACpC,YAAY,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB,yGAAyG;IACzG,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,GAAG,MAAM,CAQT"}
@@ -1,20 +1,4 @@
1
- /**
2
- * Deterministic active-notification document IDs.
3
- *
4
- * The active doc for a given dedup scope always lives at the same ID, so
5
- * concurrent writers (real-time sends racing each other or the batch
6
- * materializer) converge on one document inside a transaction instead of
7
- * racing a query-then-write. The ID hashes the same fields the dedup lookup
8
- * used to filter on: category, audience scope, and dedupKey.
9
- */
10
1
  import { createHash } from 'node:crypto';
11
- /**
12
- * Build the deterministic doc ID for an active notification.
13
- *
14
- * Personal notifications scope per recipient (a fan-out to many users must
15
- * never collapse into one recipient's doc); shared notifications are
16
- * intentionally a single doc for the whole audience.
17
- */
18
2
  export function buildActiveNotificationDocId(params) {
19
3
  const scope = params.audienceType === 'personal' ? `user:${params.targetUserId ?? 'null'}` : 'shared';
20
4
  const input = params.notificationType !== undefined
@@ -1 +1 @@
1
- {"version":3,"file":"activeNotificationId.js","sourceRoot":"","sources":["../../src/server/activeNotificationId.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC;;;;;;GAMG;AACH,MAAM,UAAU,4BAA4B,CAAC,MAY5C;IACC,MAAM,KAAK,GACT,MAAM,CAAC,YAAY,KAAK,UAAU,CAAC,CAAC,CAAC,QAAQ,MAAM,CAAC,YAAY,IAAI,MAAM,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC;IAC1F,MAAM,KAAK,GACT,MAAM,CAAC,gBAAgB,KAAK,SAAS;QACnC,CAAC,CAAC,gBAAgB,MAAM,CAAC,gBAAgB,IAAI,KAAK,IAAI,MAAM,CAAC,QAAQ,EAAE;QACvE,CAAC,CAAC,GAAG,MAAM,CAAC,QAAQ,IAAI,KAAK,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;IACvD,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AACvE,CAAC","sourcesContent":["/**\n * Deterministic active-notification document IDs.\n *\n * The active doc for a given dedup scope always lives at the same ID, so\n * concurrent writers (real-time sends racing each other or the batch\n * materializer) converge on one document inside a transaction instead of\n * racing a query-then-write. The ID hashes the same fields the dedup lookup\n * used to filter on: category, audience scope, and dedupKey.\n */\n\nimport { createHash } from 'node:crypto';\n\n/**\n * Build the deterministic doc ID for an active notification.\n *\n * Personal notifications scope per recipient (a fan-out to many users must\n * never collapse into one recipient's doc); shared notifications are\n * intentionally a single doc for the whole audience.\n */\nexport function buildActiveNotificationDocId(params: {\n category: string;\n audienceType: 'personal' | 'shared';\n targetUserId: string | null | undefined;\n dedupKey: string;\n /**\n * When provided, the id becomes **type-scoped** so two notification types that\n * share an aggregation key never collapse onto one active doc (the delivery\n * ledger materializer always passes it). Omitted => the legacy non-type-scoped\n * id, kept byte-identical for the pre-cutover composer path.\n */\n notificationType?: string;\n}): string {\n const scope =\n params.audienceType === 'personal' ? `user:${params.targetUserId ?? 'null'}` : 'shared';\n const input =\n params.notificationType !== undefined\n ? `notif-active ${params.notificationType} ${scope} ${params.dedupKey}`\n : `${params.category} ${scope} ${params.dedupKey}`;\n return createHash('sha256').update(input).digest('hex').slice(0, 40);\n}\n"]}
1
+ {"version":3,"file":"activeNotificationId.js","sourceRoot":"","sources":["../../src/server/activeNotificationId.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,MAAM,UAAU,4BAA4B,CAAC,MAO5C;IACC,MAAM,KAAK,GACT,MAAM,CAAC,YAAY,KAAK,UAAU,CAAC,CAAC,CAAC,QAAQ,MAAM,CAAC,YAAY,IAAI,MAAM,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC;IAC1F,MAAM,KAAK,GACT,MAAM,CAAC,gBAAgB,KAAK,SAAS;QACnC,CAAC,CAAC,gBAAgB,MAAM,CAAC,gBAAgB,IAAI,KAAK,IAAI,MAAM,CAAC,QAAQ,EAAE;QACvE,CAAC,CAAC,GAAG,MAAM,CAAC,QAAQ,IAAI,KAAK,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;IACvD,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AACvE,CAAC","sourcesContent":["import { createHash } from 'node:crypto';\n\nexport function buildActiveNotificationDocId(params: {\n category: string;\n audienceType: 'personal' | 'shared';\n targetUserId: string | null | undefined;\n dedupKey: string;\n /** Omit for processBatchHelper's id, which must stay byte-identical: existing active docs live at it. */\n notificationType?: string;\n}): string {\n const scope =\n params.audienceType === 'personal' ? `user:${params.targetUserId ?? 'null'}` : 'shared';\n const input =\n params.notificationType !== undefined\n ? `notif-active ${params.notificationType} ${scope} ${params.dedupKey}`\n : `${params.category} ${scope} ${params.dedupKey}`;\n return createHash('sha256').update(input).digest('hex').slice(0, 40);\n}\n"]}
@@ -1,68 +1,22 @@
1
- /**
2
- * Generic notification delivery ledger (notification redesign — P1).
3
- *
4
- * One Firestore doc per (recipient|shared, type, occurrence) is BOTH the queue
5
- * row AND the idempotency ledger. This package owns the GENERIC mechanism;
6
- * the consuming app owns the concrete collection name, the deterministic
7
- * `deliveryId`/`eventId`/`aggregationKey` construction, and the worker that
8
- * selects rows per `materializationClass`. The app passes fully-formed
9
- * `DeliveryRowInput`s; this module never invents domain ids.
10
- *
11
- * Key invariants (FROZEN — this module is their single source; the consuming app's
12
- * notification design doc explains the product rationale):
13
- * - Enqueue = create-if-absent. `ALREADY_EXISTS` is a per-row duplicate no-op,
14
- * never a failure — there is no marker-without-payload state, so the
15
- * "marker created, payload lost" crash window cannot exist.
16
- * - Materialize = ONE transaction that reads the delivery row + active card,
17
- * applies the aggregation exactly once (per strategy + caps), rotates the
18
- * opaque `activityGeneration`, resets `seenAt`, and flips the row to
19
- * `materialized` — so the "applied but not marked" double-apply window is gone.
20
- * Retrying a `materialized` row is a successful no-op.
21
- * - The same transaction asks the app whether the recipient may still receive
22
- * (`options.isRecipientEligible`); an ineligible recipient gets no card and the
23
- * row turns terminal `skipped`, so a row queued before (or retried after) the
24
- * recipient became ineligible can never recreate a card. Re-running it is a no-op.
25
- * - TTL (`expireAt`, a real Firestore Timestamp via `config.timestampFromMillis`)
26
- * is set ONLY at `materialized` / `skipped`; a `queued` or `deadLetter` row is NEVER TTL'd
27
- * so an unresolved delivery can't be silently deleted before replay.
28
- */
29
1
  import type { NotificationSystemConfig, NotificationDoc } from '../types.js';
30
2
  import type { ServerFirestore, ServerTransaction } from './types.js';
31
- /**
32
- * `skipped` is terminal: the recipient was not eligible when the row was materialized, so no card
33
- * was written. It is TTL'd like `materialized` and never retried.
34
- */
35
3
  export type DeliveryState = 'queued' | 'materialized' | 'skipped' | 'deadLetter';
36
- /** Why a row was terminalized as `skipped`. */
37
4
  export type DeliverySkipReason = 'recipientIneligible';
38
- /**
39
- * Ledger options the consuming app must decide.
40
- */
41
5
  export interface DeliveryLedgerOptions {
42
- /**
43
- * Whether `recipientUid` may still receive a notification. Called INSIDE the materialize
44
- * transaction, after the delivery row is read and before anything is written, so the answer
45
- * and the card write commit together (BACKEND-116): read through `tx` only. A recipient that
46
- * answers false gets no card, and the row becomes `skipped`. Not called for a shared
47
- * (recipient-less) row. A consumer with no eligibility rule answers true.
48
- */
6
+ /** Runs inside the materialize transaction before any write: read through `tx` only (RULE-STD-BACKEND-116). */
49
7
  isRecipientEligible: (recipientUid: string, tx: ServerTransaction) => Promise<boolean>;
50
8
  }
51
9
  export type AggregationStrategy = 'increment' | 'staticRelight';
52
10
  export type MaterializationClass = 'directQueued' | 'realtimeFallback' | 'fanoutOrphan' | 'retry';
53
- /** The materialization payload carried on every delivery row. */
54
11
  export interface DeliveryPayload {
55
- /** The acting user's uid, or `null`/`''` for a system / no-actor occurrence (N-M2). */
56
12
  actorId: string | null;
57
13
  metadata: Record<string, unknown>;
58
14
  occurrenceAt: number;
59
15
  }
60
- /** App-supplied, fully-formed delivery row (the app owns all id construction). */
61
16
  export interface DeliveryRowInput {
62
17
  deliveryId: string;
63
18
  notificationType: string;
64
19
  eventId: string;
65
- /** `null` for shared-admin occurrences (the deliveryId hash uses the literal `'shared'`). */
66
20
  recipientUid: string | null;
67
21
  aggregationKey: string;
68
22
  strategy: AggregationStrategy;
@@ -83,53 +37,27 @@ export type EnqueueRowResult = {
83
37
  };
84
38
  export interface EnqueueResult {
85
39
  results: EnqueueRowResult[];
86
- /** True iff EVERY row is now represented by a created-or-pre-existing doc (page-cursor invariant input). */
87
40
  allResolved: boolean;
88
41
  }
89
42
  export type MaterializeOutcome = 'materialized' | 'already-materialized' | 'skipped-ineligible-recipient' | 'missing' | 'skipped-non-queued';
90
43
  export interface MaterializeManyOptions {
91
- /** Rows materialized at once (default 10). */
92
44
  concurrency?: number;
93
- /**
94
- * Called for a row whose materialize threw and whose failure could not then be recorded either —
95
- * `error` is the recording's own failure and `details.cause` the materialize failure it was
96
- * recording. The row stays as it was (queued, attempt count
97
- * unchanged), so a later run selects it again; the rest of the batch proceeds. The app's capture
98
- * goes here; without it the ledger writes the failure to `console.error`.
99
- */
100
45
  onUnrecordedFailure?: (deliveryId: string, error: unknown, details: {
101
46
  cause: unknown;
102
47
  }) => void;
103
48
  }
104
- /** What a `replay` did: re-queued the row, found no row, or found a row that is not dead-lettered. */
105
49
  export type ReplayOutcome = 'replayed' | 'missing' | 'not-dead-lettered';
106
50
  export interface ReplayOptions {
107
- /**
108
- * The caller's own write (its audit event), composed into the replay transaction and awaited
109
- * there on the `replayed` path only. A hook that throws or rejects fails the replay, so the
110
- * reset never commits without it.
111
- */
112
51
  auditWrite?: (txn: ServerTransaction) => void | Promise<void>;
113
52
  }
114
53
  export interface DeliveryLedger {
115
54
  enqueue(rows: DeliveryRowInput[]): Promise<EnqueueResult>;
116
55
  materialize(deliveryId: string): Promise<MaterializeOutcome>;
117
- /**
118
- * Materialize many rows with bounded concurrency. A row whose materialize throws has the failure
119
- * recorded (a retry or a dead letter); each row is isolated, so one row's failure — even one
120
- * whose recording also fails — never fails the batch or stops the rows behind it.
121
- */
122
56
  materializeMany(deliveryIds: string[], options?: MaterializeManyOptions): Promise<Record<MaterializeOutcome, number>>;
123
57
  recordTransientFailure(deliveryId: string, error: unknown): Promise<void>;
124
58
  deadLetter(deliveryId: string, error: unknown): Promise<void>;
125
- /** Re-queue a dead-lettered row. A missing or non-dead-lettered row is answered, never written. */
126
59
  replay(deliveryId: string, options?: ReplayOptions): Promise<ReplayOutcome>;
127
60
  }
128
- /**
129
- * Apply the aggregation strategy to produce the active-doc field delta.
130
- * PURE — exported for direct unit testing. `existing` is the current active doc
131
- * (or null to create). Always rotates `activityGeneration` and resets `seenAt`.
132
- */
133
61
  export declare function applyAggregation(params: {
134
62
  strategy: AggregationStrategy;
135
63
  existing: Pick<NotificationDoc, 'count' | 'latestActorIds'> | null;
@@ -1 +1 @@
1
- {"version":3,"file":"delivery-ledger.d.ts","sourceRoot":"","sources":["../../src/server/delivery-ledger.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"delivery-ledger.d.ts","sourceRoot":"","sources":["../../src/server/delivery-ledger.ts"],"names":[],"mappings":"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"}
@@ -1,67 +1,27 @@
1
- /**
2
- * Generic notification delivery ledger (notification redesign — P1).
3
- *
4
- * One Firestore doc per (recipient|shared, type, occurrence) is BOTH the queue
5
- * row AND the idempotency ledger. This package owns the GENERIC mechanism;
6
- * the consuming app owns the concrete collection name, the deterministic
7
- * `deliveryId`/`eventId`/`aggregationKey` construction, and the worker that
8
- * selects rows per `materializationClass`. The app passes fully-formed
9
- * `DeliveryRowInput`s; this module never invents domain ids.
10
- *
11
- * Key invariants (FROZEN — this module is their single source; the consuming app's
12
- * notification design doc explains the product rationale):
13
- * - Enqueue = create-if-absent. `ALREADY_EXISTS` is a per-row duplicate no-op,
14
- * never a failure — there is no marker-without-payload state, so the
15
- * "marker created, payload lost" crash window cannot exist.
16
- * - Materialize = ONE transaction that reads the delivery row + active card,
17
- * applies the aggregation exactly once (per strategy + caps), rotates the
18
- * opaque `activityGeneration`, resets `seenAt`, and flips the row to
19
- * `materialized` — so the "applied but not marked" double-apply window is gone.
20
- * Retrying a `materialized` row is a successful no-op.
21
- * - The same transaction asks the app whether the recipient may still receive
22
- * (`options.isRecipientEligible`); an ineligible recipient gets no card and the
23
- * row turns terminal `skipped`, so a row queued before (or retried after) the
24
- * recipient became ineligible can never recreate a card. Re-running it is a no-op.
25
- * - TTL (`expireAt`, a real Firestore Timestamp via `config.timestampFromMillis`)
26
- * is set ONLY at `materialized` / `skipped`; a `queued` or `deadLetter` row is NEVER TTL'd
27
- * so an unresolved delivery can't be silently deleted before replay.
28
- */
29
1
  import { randomUUID } from 'node:crypto';
30
2
  import { buildActiveNotificationDocId } from './activeNotificationId.js';
31
3
  const DEFAULT_DELIVERIES_PATH = 'notificationDeliveries';
32
- const DEFAULT_DELIVERY_TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
4
+ const DEFAULT_DELIVERY_TTL_MS = 90 * 24 * 60 * 60 * 1000;
33
5
  const DEFAULT_MAX_ATTEMPTS = 8;
34
6
  const DEFAULT_COUNT_CAP = 5000;
35
7
  const DEFAULT_ACTOR_CAP = 5;
36
- const BACKOFF_BASE_MS = 60 * 1000; // 1 min
37
- const BACKOFF_MAX_MS = 60 * 60 * 1000; // 1 hour
38
- /** gRPC ALREADY_EXISTS (6) — the create-if-absent duplicate signal. */
8
+ const BACKOFF_BASE_MS = 60 * 1000;
9
+ const BACKOFF_MAX_MS = 60 * 60 * 1000;
39
10
  function isAlreadyExists(error) {
40
11
  const e = error;
41
12
  return e?.code === 6 || /already.?exists/i.test(e?.message ?? '');
42
13
  }
43
14
  function backoffMs(attemptCount) {
44
15
  const exp = Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, attemptCount - 1), BACKOFF_MAX_MS);
45
- // Full jitter — spread retries so a burst of failures doesn't synchronize.
46
16
  return Math.floor(exp / 2 + Math.random() * (exp / 2));
47
17
  }
48
- /**
49
- * Apply the aggregation strategy to produce the active-doc field delta.
50
- * PURE — exported for direct unit testing. `existing` is the current active doc
51
- * (or null to create). Always rotates `activityGeneration` and resets `seenAt`.
52
- */
53
18
  export function applyAggregation(params) {
54
19
  const { strategy, existing, actorId, countCap, actorCap, buildMessage, now, generation } = params;
55
20
  const prevActors = existing?.latestActorIds ?? [];
56
- // N-M2: a system / no-actor occurrence (null or '') is never injected into the actor
57
- // list — it would render as a blank avatar / unresolvable uid in `latestActorIds`.
58
21
  const latestActorIds = actorId
59
22
  ? [actorId, ...prevActors.filter((id) => id !== actorId)].slice(0, actorCap)
60
23
  : prevActors.slice(0, actorCap);
61
- // staticRelight never shows a count (stays 1); increment counts up to the cap.
62
- const count = strategy === 'staticRelight'
63
- ? 1
64
- : Math.min((existing?.count ?? 0) + 1, countCap);
24
+ const count = strategy === 'staticRelight' ? 1 : Math.min((existing?.count ?? 0) + 1, countCap);
65
25
  return {
66
26
  count,
67
27
  latestActorIds,
@@ -80,8 +40,7 @@ export function createDeliveryLedger(db, config, options) {
80
40
  if (!configTimestampFromMillis) {
81
41
  throw new Error('[notification-core] createDeliveryLedger requires config.timestampFromMillis (Firestore TTL needs a real Timestamp for expireAt).');
82
42
  }
83
- // Bind to an explicitly non-optional type so the nested transaction closures
84
- // (called later) see it as defined, not `... | undefined`.
43
+ // An explicitly non-optional binding: TS does not carry the narrowing into closures called later.
85
44
  const toTimestamp = configTimestampFromMillis;
86
45
  const deliveries = db.collection(deliveriesPath);
87
46
  function deliveryRef(deliveryId) {
@@ -105,7 +64,6 @@ export function createDeliveryLedger(db, config, options) {
105
64
  createdAt: now,
106
65
  materializedAt: null,
107
66
  deadLetteredAt: null,
108
- // No expireAt while queued.
109
67
  };
110
68
  }
111
69
  function getTypeConfig(type) {
@@ -151,8 +109,6 @@ export function createDeliveryLedger(db, config, options) {
151
109
  const notificationType = d.notificationType;
152
110
  const aggregationKey = d.aggregationKey;
153
111
  const recipientUid = d.recipientUid ?? null;
154
- // The recipient is checked in this transaction, so a recipient that became ineligible
155
- // (e.g. an erased account) never gets a card from a row queued before, or retried after.
156
112
  if (recipientUid !== null && !(await isRecipientEligible(recipientUid, tx))) {
157
113
  const skippedAt = Date.now();
158
114
  tx.update(dRef, {
@@ -164,7 +120,11 @@ export function createDeliveryLedger(db, config, options) {
164
120
  return 'skipped-ineligible-recipient';
165
121
  }
166
122
  const strategy = d.strategy ?? 'increment';
167
- const payload = d.payload ?? { actorId: '', metadata: {}, occurrenceAt: Date.now() };
123
+ const payload = d.payload ?? {
124
+ actorId: '',
125
+ metadata: {},
126
+ occurrenceAt: Date.now(),
127
+ };
168
128
  const { typeConfig, categoryConfig } = getTypeConfig(notificationType);
169
129
  const activeRef = db.collection(categoryConfig.activePath).doc(buildActiveNotificationDocId({
170
130
  category: typeConfig.category,
@@ -173,19 +133,14 @@ export function createDeliveryLedger(db, config, options) {
173
133
  dedupKey: aggregationKey,
174
134
  notificationType,
175
135
  }));
176
- const activeSnap = await tx.get(activeRef); // all reads before writes
136
+ const activeSnap = await tx.get(activeRef); // Firestore transactions take every read before any write.
177
137
  const now = Date.now();
178
138
  const generation = randomUUID();
179
139
  const countCap = typeConfig.countCap ?? DEFAULT_COUNT_CAP;
180
140
  const actorCap = typeConfig.actorCap ?? DEFAULT_ACTOR_CAP;
181
141
  const buildMessage = (count) => typeConfig.messagePattern(payload.metadata, count);
182
- // Latest-occurrence-derived fields — recomputed every materialize so an EXISTING
183
- // aggregate card reflects the newest activity (title/targetPath/metadata), not the
184
- // first occurrence's stale values (N-I4).
185
142
  const title = typeConfig.titlePattern(payload.metadata);
186
- // A type with no defaultTargetPath is linkless: the doc must OMIT the field
187
- // entirely (Firestore rejects `undefined` values). A pre-existing doc written
188
- // before a type went linkless keeps its stale targetPath until archived.
143
+ // Firestore rejects `undefined` values, so a linkless type omits the field.
189
144
  const targetPath = typeConfig.defaultTargetPath === undefined
190
145
  ? undefined
191
146
  : typeof typeConfig.defaultTargetPath === 'function'
@@ -264,11 +219,6 @@ export function createDeliveryLedger(db, config, options) {
264
219
  });
265
220
  }
266
221
  else {
267
- // n1: re-stamp the row into the reserved `retry` materialization lane on backoff. Without
268
- // this the row keeps its original class and competes for that lane's capacity while backed
269
- // off; the worker's reserved `retry` slice (separate CLASS_CAPS capacity, drained in
270
- // DRAIN_ORDER) would always select 0 rows. Re-stamping moves a backed-off row into the
271
- // anti-starvation lane so it drains there instead of starving in its original lane.
272
222
  tx.update(dRef, {
273
223
  attemptCount,
274
224
  nextAttemptAt: now + backoffMs(attemptCount),
@@ -310,7 +260,7 @@ export function createDeliveryLedger(db, config, options) {
310
260
  nextAttemptAt: Date.now(),
311
261
  lastError: null,
312
262
  deadLetteredAt: null,
313
- expireAt: null, // clear any TTL set on a prior terminal
263
+ expireAt: null,
314
264
  });
315
265
  await options?.auditWrite?.(tx);
316
266
  return 'replayed';
@@ -1 +1 @@
1
- {"version":3,"file":"delivery-ledger.js","sourceRoot":"","sources":["../../src/server/delivery-ledger.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Generic notification delivery ledger (notification redesign — P1).\n *\n * One Firestore doc per (recipient|shared, type, occurrence) is BOTH the queue\n * row AND the idempotency ledger. This package owns the GENERIC mechanism;\n * the consuming app owns the concrete collection name, the deterministic\n * `deliveryId`/`eventId`/`aggregationKey` construction, and the worker that\n * selects rows per `materializationClass`. The app passes fully-formed\n * `DeliveryRowInput`s; this module never invents domain ids.\n *\n * Key invariants (FROZEN — this module is their single source; the consuming app's\n * notification design doc explains the product rationale):\n * - Enqueue = create-if-absent. `ALREADY_EXISTS` is a per-row duplicate no-op,\n * never a failure — there is no marker-without-payload state, so the\n * \"marker created, payload lost\" crash window cannot exist.\n * - Materialize = ONE transaction that reads the delivery row + active card,\n * applies the aggregation exactly once (per strategy + caps), rotates the\n * opaque `activityGeneration`, resets `seenAt`, and flips the row to\n * `materialized` — so the \"applied but not marked\" double-apply window is gone.\n * Retrying a `materialized` row is a successful no-op.\n * - The same transaction asks the app whether the recipient may still receive\n * (`options.isRecipientEligible`); an ineligible recipient gets no card and the\n * row turns terminal `skipped`, so a row queued before (or retried after) the\n * recipient became ineligible can never recreate a card. Re-running it is a no-op.\n * - TTL (`expireAt`, a real Firestore Timestamp via `config.timestampFromMillis`)\n * is set ONLY at `materialized` / `skipped`; a `queued` or `deadLetter` row is NEVER TTL'd\n * so an unresolved delivery can't be silently deleted before replay.\n */\n\nimport { randomUUID } from 'node:crypto';\nimport type { NotificationSystemConfig, NotificationDoc } from '../types.js';\nimport type { ServerFirestore, ServerDocRef, ServerTransaction } from './types.js';\nimport { buildActiveNotificationDocId } from './activeNotificationId.js';\n\nconst DEFAULT_DELIVERIES_PATH = 'notificationDeliveries';\nconst DEFAULT_DELIVERY_TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 days\nconst DEFAULT_MAX_ATTEMPTS = 8;\nconst DEFAULT_COUNT_CAP = 5000;\nconst DEFAULT_ACTOR_CAP = 5;\nconst BACKOFF_BASE_MS = 60 * 1000; // 1 min\nconst BACKOFF_MAX_MS = 60 * 60 * 1000; // 1 hour\n\n/**\n * `skipped` is terminal: the recipient was not eligible when the row was materialized, so no card\n * was written. It is TTL'd like `materialized` and never retried.\n */\nexport type DeliveryState = 'queued' | 'materialized' | 'skipped' | 'deadLetter';\n\n/** Why a row was terminalized as `skipped`. */\nexport type DeliverySkipReason = 'recipientIneligible';\n\n/**\n * Ledger options the consuming app must decide.\n */\nexport interface DeliveryLedgerOptions {\n /**\n * Whether `recipientUid` may still receive a notification. Called INSIDE the materialize\n * transaction, after the delivery row is read and before anything is written, so the answer\n * and the card write commit together (BACKEND-116): read through `tx` only. A recipient that\n * answers false gets no card, and the row becomes `skipped`. Not called for a shared\n * (recipient-less) row. A consumer with no eligibility rule answers true.\n */\n isRecipientEligible: (recipientUid: string, tx: ServerTransaction) => Promise<boolean>;\n}\nexport type AggregationStrategy = 'increment' | 'staticRelight';\nexport type MaterializationClass =\n | 'directQueued'\n | 'realtimeFallback'\n | 'fanoutOrphan'\n | 'retry';\n\n/** The materialization payload carried on every delivery row. */\nexport interface DeliveryPayload {\n /** The acting user's uid, or `null`/`''` for a system / no-actor occurrence (N-M2). */\n actorId: string | null;\n metadata: Record<string, unknown>;\n occurrenceAt: number;\n}\n\n/** App-supplied, fully-formed delivery row (the app owns all id construction). */\nexport interface DeliveryRowInput {\n deliveryId: string;\n notificationType: string;\n eventId: string;\n /** `null` for shared-admin occurrences (the deliveryId hash uses the literal `'shared'`). */\n recipientUid: string | null;\n aggregationKey: string;\n strategy: AggregationStrategy;\n payload: DeliveryPayload;\n payloadVersion: number;\n materializationClass: MaterializationClass;\n}\n\nexport type EnqueueRowResult =\n | { deliveryId: string; outcome: 'created' }\n | { deliveryId: string; outcome: 'duplicate' }\n | { deliveryId: string; outcome: 'failed'; error: unknown };\n\nexport interface EnqueueResult {\n results: EnqueueRowResult[];\n /** True iff EVERY row is now represented by a created-or-pre-existing doc (page-cursor invariant input). */\n allResolved: boolean;\n}\n\nexport type MaterializeOutcome =\n | 'materialized'\n | 'already-materialized'\n | 'skipped-ineligible-recipient'\n | 'missing'\n | 'skipped-non-queued';\n\nexport interface MaterializeManyOptions {\n /** Rows materialized at once (default 10). */\n concurrency?: number;\n /**\n * Called for a row whose materialize threw and whose failure could not then be recorded either —\n * `error` is the recording's own failure and `details.cause` the materialize failure it was\n * recording. The row stays as it was (queued, attempt count\n * unchanged), so a later run selects it again; the rest of the batch proceeds. The app's capture\n * goes here; without it the ledger writes the failure to `console.error`.\n */\n onUnrecordedFailure?: (deliveryId: string, error: unknown, details: { cause: unknown }) => void;\n}\n\n/** What a `replay` did: re-queued the row, found no row, or found a row that is not dead-lettered. */\nexport type ReplayOutcome = 'replayed' | 'missing' | 'not-dead-lettered';\n\nexport interface ReplayOptions {\n /**\n * The caller's own write (its audit event), composed into the replay transaction and awaited\n * there on the `replayed` path only. A hook that throws or rejects fails the replay, so the\n * reset never commits without it.\n */\n auditWrite?: (txn: ServerTransaction) => void | Promise<void>;\n}\n\nexport interface DeliveryLedger {\n enqueue(rows: DeliveryRowInput[]): Promise<EnqueueResult>;\n materialize(deliveryId: string): Promise<MaterializeOutcome>;\n /**\n * Materialize many rows with bounded concurrency. A row whose materialize throws has the failure\n * recorded (a retry or a dead letter); each row is isolated, so one row's failure — even one\n * whose recording also fails — never fails the batch or stops the rows behind it.\n */\n materializeMany(deliveryIds: string[], options?: MaterializeManyOptions): Promise<Record<MaterializeOutcome, number>>;\n recordTransientFailure(deliveryId: string, error: unknown): Promise<void>;\n deadLetter(deliveryId: string, error: unknown): Promise<void>;\n /** Re-queue a dead-lettered row. A missing or non-dead-lettered row is answered, never written. */\n replay(deliveryId: string, options?: ReplayOptions): Promise<ReplayOutcome>;\n}\n\n/** gRPC ALREADY_EXISTS (6) — the create-if-absent duplicate signal. */\nfunction isAlreadyExists(error: unknown): boolean {\n const e = error as { code?: number; message?: string } | undefined;\n return e?.code === 6 || /already.?exists/i.test(e?.message ?? '');\n}\n\nfunction backoffMs(attemptCount: number): number {\n const exp = Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, attemptCount - 1), BACKOFF_MAX_MS);\n // Full jitter — spread retries so a burst of failures doesn't synchronize.\n return Math.floor(exp / 2 + Math.random() * (exp / 2));\n}\n\n/**\n * Apply the aggregation strategy to produce the active-doc field delta.\n * PURE — exported for direct unit testing. `existing` is the current active doc\n * (or null to create). Always rotates `activityGeneration` and resets `seenAt`.\n */\nexport function applyAggregation(params: {\n strategy: AggregationStrategy;\n existing: Pick<NotificationDoc, 'count' | 'latestActorIds'> | null;\n actorId: string | null;\n countCap: number;\n actorCap: number;\n buildMessage: (count: number) => string;\n now: number;\n generation: string;\n}): { count: number; latestActorIds: string[]; message: string; activityGeneration: string; seenAt: number; updatedAt: number } {\n const { strategy, existing, actorId, countCap, actorCap, buildMessage, now, generation } = params;\n const prevActors = existing?.latestActorIds ?? [];\n // N-M2: a system / no-actor occurrence (null or '') is never injected into the actor\n // list — it would render as a blank avatar / unresolvable uid in `latestActorIds`.\n const latestActorIds = actorId\n ? [actorId, ...prevActors.filter((id) => id !== actorId)].slice(0, actorCap)\n : prevActors.slice(0, actorCap);\n // staticRelight never shows a count (stays 1); increment counts up to the cap.\n const count =\n strategy === 'staticRelight'\n ? 1\n : Math.min((existing?.count ?? 0) + 1, countCap);\n return {\n count,\n latestActorIds,\n message: buildMessage(count),\n activityGeneration: generation,\n seenAt: 0,\n updatedAt: now,\n };\n}\n\nexport function createDeliveryLedger(\n db: ServerFirestore,\n config: NotificationSystemConfig,\n options: DeliveryLedgerOptions,\n): DeliveryLedger {\n const { isRecipientEligible } = options;\n const deliveriesPath = config.deliveriesCollectionPath ?? DEFAULT_DELIVERIES_PATH;\n const ttlMs = config.deliveryTtlMs ?? DEFAULT_DELIVERY_TTL_MS;\n const maxAttempts = config.maxDeliveryAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const configTimestampFromMillis = config.timestampFromMillis;\n if (!configTimestampFromMillis) {\n throw new Error(\n '[notification-core] createDeliveryLedger requires config.timestampFromMillis (Firestore TTL needs a real Timestamp for expireAt).',\n );\n }\n // Bind to an explicitly non-optional type so the nested transaction closures\n // (called later) see it as defined, not `... | undefined`.\n const toTimestamp: (ms: number) => unknown = configTimestampFromMillis;\n\n const deliveries = db.collection(deliveriesPath);\n\n function deliveryRef(deliveryId: string): ServerDocRef {\n return deliveries.doc(deliveryId);\n }\n\n function buildDeliveryDoc(row: DeliveryRowInput, now: number): Record<string, unknown> {\n return {\n deliveryId: row.deliveryId,\n state: 'queued' as DeliveryState,\n notificationType: row.notificationType,\n eventId: row.eventId,\n recipientUid: row.recipientUid,\n aggregationKey: row.aggregationKey,\n strategy: row.strategy,\n payload: row.payload,\n payloadVersion: row.payloadVersion,\n materializationClass: row.materializationClass,\n attemptCount: 0,\n nextAttemptAt: now,\n lastError: null,\n createdAt: now,\n materializedAt: null,\n deadLetteredAt: null,\n // No expireAt while queued.\n };\n }\n\n function getTypeConfig(type: string) {\n const typeConfig = config.types[type];\n if (!typeConfig) throw new Error(`[notification-core] Unknown notification type: ${type}`);\n const categoryConfig = config.categories[typeConfig.category];\n if (!categoryConfig) throw new Error(`[notification-core] Unknown category: ${typeConfig.category}`);\n return { typeConfig, categoryConfig };\n }\n\n async function enqueue(rows: DeliveryRowInput[]): Promise<EnqueueResult> {\n if (rows.length === 0) return { results: [], allResolved: true };\n const now = Date.now();\n const settled = await Promise.allSettled(\n rows.map((row) =>\n deliveryRef(row.deliveryId)\n .create(buildDeliveryDoc(row, now))\n .then(() => ({ deliveryId: row.deliveryId, outcome: 'created' as const })),\n ),\n );\n const results: EnqueueRowResult[] = settled.map((s, i) => {\n const deliveryId = rows[i].deliveryId;\n if (s.status === 'fulfilled') return s.value;\n if (isAlreadyExists(s.reason)) return { deliveryId, outcome: 'duplicate' };\n return { deliveryId, outcome: 'failed', error: s.reason };\n });\n return { results, allResolved: results.every((r) => r.outcome !== 'failed') };\n }\n\n async function materialize(deliveryId: string): Promise<MaterializeOutcome> {\n return db.runTransaction(async (tx) => {\n const dRef = deliveryRef(deliveryId);\n const dSnap = await tx.get(dRef);\n if (!dSnap.exists) return 'missing';\n const d = (dSnap.data() ?? {}) as Record<string, unknown>;\n const state = d.state as DeliveryState;\n if (state === 'materialized') return 'already-materialized';\n if (state === 'skipped') return 'skipped-ineligible-recipient';\n if (state !== 'queued') return 'skipped-non-queued';\n\n const notificationType = d.notificationType as string;\n const aggregationKey = d.aggregationKey as string;\n const recipientUid = (d.recipientUid as string | null) ?? null;\n\n // The recipient is checked in this transaction, so a recipient that became ineligible\n // (e.g. an erased account) never gets a card from a row queued before, or retried after.\n if (recipientUid !== null && !(await isRecipientEligible(recipientUid, tx))) {\n const skippedAt = Date.now();\n tx.update(dRef, {\n state: 'skipped' as DeliveryState,\n skipReason: 'recipientIneligible' as DeliverySkipReason,\n skippedAt,\n expireAt: toTimestamp(skippedAt + ttlMs),\n });\n return 'skipped-ineligible-recipient';\n }\n const strategy = (d.strategy as AggregationStrategy) ?? 'increment';\n const payload = (d.payload as DeliveryPayload) ?? { actorId: '', metadata: {}, occurrenceAt: Date.now() };\n const { typeConfig, categoryConfig } = getTypeConfig(notificationType);\n\n const activeRef = db.collection(categoryConfig.activePath).doc(\n buildActiveNotificationDocId({\n category: typeConfig.category,\n audienceType: categoryConfig.audienceType,\n targetUserId: recipientUid,\n dedupKey: aggregationKey,\n notificationType,\n }),\n );\n const activeSnap = await tx.get(activeRef); // all reads before writes\n\n const now = Date.now();\n const generation = randomUUID();\n const countCap = typeConfig.countCap ?? DEFAULT_COUNT_CAP;\n const actorCap = typeConfig.actorCap ?? DEFAULT_ACTOR_CAP;\n const buildMessage = (count: number) => typeConfig.messagePattern(payload.metadata, count);\n // Latest-occurrence-derived fields — recomputed every materialize so an EXISTING\n // aggregate card reflects the newest activity (title/targetPath/metadata), not the\n // first occurrence's stale values (N-I4).\n const title = typeConfig.titlePattern(payload.metadata);\n // A type with no defaultTargetPath is linkless: the doc must OMIT the field\n // entirely (Firestore rejects `undefined` values). A pre-existing doc written\n // before a type went linkless keeps its stale targetPath until archived.\n const targetPath =\n typeConfig.defaultTargetPath === undefined\n ? undefined\n : typeof typeConfig.defaultTargetPath === 'function'\n ? typeConfig.defaultTargetPath(payload.metadata)\n : typeConfig.defaultTargetPath;\n const targetPathField = targetPath === undefined ? {} : { targetPath };\n\n if (activeSnap.exists) {\n const existing = (activeSnap.data() ?? {}) as Pick<NotificationDoc, 'count' | 'latestActorIds'>;\n const delta = applyAggregation({\n strategy,\n existing,\n actorId: payload.actorId,\n countCap,\n actorCap,\n buildMessage,\n now,\n generation,\n });\n tx.update(activeRef, { ...delta, title, ...targetPathField, metadata: payload.metadata });\n } else {\n const delta = applyAggregation({\n strategy,\n existing: null,\n actorId: payload.actorId,\n countCap,\n actorCap,\n buildMessage,\n now,\n generation,\n });\n const newDoc: Omit<NotificationDoc, 'id'> = {\n type: notificationType,\n dedupKey: aggregationKey,\n category: typeConfig.category,\n targetUserId: recipientUid,\n title,\n message: delta.message,\n count: delta.count,\n latestActorIds: delta.latestActorIds,\n ...targetPathField,\n metadata: payload.metadata,\n seenAt: 0,\n activityGeneration: generation,\n createdAt: now,\n updatedAt: now,\n };\n tx.set(activeRef, newDoc as Record<string, unknown>);\n }\n\n tx.update(dRef, {\n state: 'materialized' as DeliveryState,\n materializedAt: now,\n expireAt: toTimestamp(now + ttlMs),\n });\n return 'materialized';\n });\n }\n\n async function recordTransientFailure(deliveryId: string, error: unknown): Promise<void> {\n await db.runTransaction(async (tx) => {\n const dRef = deliveryRef(deliveryId);\n const dSnap = await tx.get(dRef);\n if (!dSnap.exists) return;\n const d = (dSnap.data() ?? {}) as Record<string, unknown>;\n if ((d.state as DeliveryState) !== 'queued') return;\n const attemptCount = ((d.attemptCount as number) ?? 0) + 1;\n const now = Date.now();\n const message = error instanceof Error ? error.message : String(error);\n if (attemptCount >= maxAttempts) {\n tx.update(dRef, {\n state: 'deadLetter' as DeliveryState,\n attemptCount,\n lastError: message,\n deadLetteredAt: now,\n });\n } else {\n // n1: re-stamp the row into the reserved `retry` materialization lane on backoff. Without\n // this the row keeps its original class and competes for that lane's capacity while backed\n // off; the worker's reserved `retry` slice (separate CLASS_CAPS capacity, drained in\n // DRAIN_ORDER) would always select 0 rows. Re-stamping moves a backed-off row into the\n // anti-starvation lane so it drains there instead of starving in its original lane.\n tx.update(dRef, {\n attemptCount,\n nextAttemptAt: now + backoffMs(attemptCount),\n lastError: message,\n materializationClass: 'retry' as MaterializationClass,\n });\n }\n });\n }\n\n async function deadLetter(deliveryId: string, error: unknown): Promise<void> {\n await db.runTransaction(async (tx) => {\n const dRef = deliveryRef(deliveryId);\n const dSnap = await tx.get(dRef);\n if (!dSnap.exists) return;\n const d = (dSnap.data() ?? {}) as Record<string, unknown>;\n if ((d.state as DeliveryState) !== 'queued') return;\n tx.update(dRef, {\n state: 'deadLetter' as DeliveryState,\n attemptCount: ((d.attemptCount as number) ?? 0) + 1,\n lastError: error instanceof Error ? error.message : String(error),\n deadLetteredAt: Date.now(),\n });\n });\n }\n\n async function replay(deliveryId: string, options?: ReplayOptions): Promise<ReplayOutcome> {\n return db.runTransaction(async (tx): Promise<ReplayOutcome> => {\n const dRef = deliveryRef(deliveryId);\n const dSnap = await tx.get(dRef);\n if (!dSnap.exists) return 'missing';\n const d = (dSnap.data() ?? {}) as Record<string, unknown>;\n if ((d.state as DeliveryState) !== 'deadLetter') return 'not-dead-lettered';\n tx.update(dRef, {\n state: 'queued' as DeliveryState,\n attemptCount: 0,\n nextAttemptAt: Date.now(),\n lastError: null,\n deadLetteredAt: null,\n expireAt: null, // clear any TTL set on a prior terminal\n });\n await options?.auditWrite?.(tx);\n return 'replayed';\n });\n }\n\n function reportUnrecordedFailure(\n options: MaterializeManyOptions | undefined,\n deliveryId: string,\n error: unknown,\n cause: unknown,\n ): void {\n try {\n if (options?.onUnrecordedFailure) {\n options.onUnrecordedFailure(deliveryId, error, { cause });\n return;\n }\n } catch (handlerError) {\n console.error('[notification-core] onUnrecordedFailure threw for delivery', deliveryId, handlerError);\n }\n console.error('[notification-core] A delivery failure could not be recorded', deliveryId, error, { cause });\n }\n\n async function materializeMany(\n deliveryIds: string[],\n options?: MaterializeManyOptions,\n ): Promise<Record<MaterializeOutcome, number>> {\n const concurrency = Math.max(1, options?.concurrency ?? 10);\n const tally: Record<MaterializeOutcome, number> = {\n materialized: 0,\n 'already-materialized': 0,\n 'skipped-ineligible-recipient': 0,\n missing: 0,\n 'skipped-non-queued': 0,\n };\n let cursor = 0;\n async function worker(): Promise<void> {\n while (cursor < deliveryIds.length) {\n const id = deliveryIds[cursor++];\n try {\n const outcome = await materialize(id);\n tally[outcome] += 1;\n } catch (error) {\n try {\n await recordTransientFailure(id, error);\n } catch (recordError) {\n reportUnrecordedFailure(options, id, recordError, error);\n }\n }\n }\n }\n await Promise.all(Array.from({ length: Math.min(concurrency, deliveryIds.length) }, worker));\n return tally;\n }\n\n return { enqueue, materialize, materializeMany, recordTransientFailure, deadLetter, replay };\n}\n"]}
1
+ {"version":3,"file":"delivery-ledger.js","sourceRoot":"","sources":["../../src/server/delivery-ledger.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomUUID } from 'node:crypto';\nimport type { NotificationSystemConfig, NotificationDoc } from '../types.js';\nimport type { ServerFirestore, ServerDocRef, ServerTransaction } from './types.js';\nimport { buildActiveNotificationDocId } from './activeNotificationId.js';\n\nconst DEFAULT_DELIVERIES_PATH = 'notificationDeliveries';\nconst DEFAULT_DELIVERY_TTL_MS = 90 * 24 * 60 * 60 * 1000;\nconst DEFAULT_MAX_ATTEMPTS = 8;\nconst DEFAULT_COUNT_CAP = 5000;\nconst DEFAULT_ACTOR_CAP = 5;\nconst BACKOFF_BASE_MS = 60 * 1000;\nconst BACKOFF_MAX_MS = 60 * 60 * 1000;\n\nexport type DeliveryState = 'queued' | 'materialized' | 'skipped' | 'deadLetter';\n\nexport type DeliverySkipReason = 'recipientIneligible';\n\nexport interface DeliveryLedgerOptions {\n /** Runs inside the materialize transaction before any write: read through `tx` only (RULE-STD-BACKEND-116). */\n isRecipientEligible: (recipientUid: string, tx: ServerTransaction) => Promise<boolean>;\n}\nexport type AggregationStrategy = 'increment' | 'staticRelight';\nexport type MaterializationClass = 'directQueued' | 'realtimeFallback' | 'fanoutOrphan' | 'retry';\n\nexport interface DeliveryPayload {\n actorId: string | null;\n metadata: Record<string, unknown>;\n occurrenceAt: number;\n}\n\nexport interface DeliveryRowInput {\n deliveryId: string;\n notificationType: string;\n eventId: string;\n recipientUid: string | null;\n aggregationKey: string;\n strategy: AggregationStrategy;\n payload: DeliveryPayload;\n payloadVersion: number;\n materializationClass: MaterializationClass;\n}\n\nexport type EnqueueRowResult =\n | { deliveryId: string; outcome: 'created' }\n | { deliveryId: string; outcome: 'duplicate' }\n | { deliveryId: string; outcome: 'failed'; error: unknown };\n\nexport interface EnqueueResult {\n results: EnqueueRowResult[];\n allResolved: boolean;\n}\n\nexport type MaterializeOutcome =\n | 'materialized'\n | 'already-materialized'\n | 'skipped-ineligible-recipient'\n | 'missing'\n | 'skipped-non-queued';\n\nexport interface MaterializeManyOptions {\n concurrency?: number;\n onUnrecordedFailure?: (deliveryId: string, error: unknown, details: { cause: unknown }) => void;\n}\n\nexport type ReplayOutcome = 'replayed' | 'missing' | 'not-dead-lettered';\n\nexport interface ReplayOptions {\n auditWrite?: (txn: ServerTransaction) => void | Promise<void>;\n}\n\nexport interface DeliveryLedger {\n enqueue(rows: DeliveryRowInput[]): Promise<EnqueueResult>;\n materialize(deliveryId: string): Promise<MaterializeOutcome>;\n materializeMany(\n deliveryIds: string[],\n options?: MaterializeManyOptions,\n ): Promise<Record<MaterializeOutcome, number>>;\n recordTransientFailure(deliveryId: string, error: unknown): Promise<void>;\n deadLetter(deliveryId: string, error: unknown): Promise<void>;\n replay(deliveryId: string, options?: ReplayOptions): Promise<ReplayOutcome>;\n}\n\nfunction isAlreadyExists(error: unknown): boolean {\n const e = error as { code?: number; message?: string } | undefined;\n return e?.code === 6 || /already.?exists/i.test(e?.message ?? '');\n}\n\nfunction backoffMs(attemptCount: number): number {\n const exp = Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, attemptCount - 1), BACKOFF_MAX_MS);\n return Math.floor(exp / 2 + Math.random() * (exp / 2));\n}\n\nexport function applyAggregation(params: {\n strategy: AggregationStrategy;\n existing: Pick<NotificationDoc, 'count' | 'latestActorIds'> | null;\n actorId: string | null;\n countCap: number;\n actorCap: number;\n buildMessage: (count: number) => string;\n now: number;\n generation: string;\n}): {\n count: number;\n latestActorIds: string[];\n message: string;\n activityGeneration: string;\n seenAt: number;\n updatedAt: number;\n} {\n const { strategy, existing, actorId, countCap, actorCap, buildMessage, now, generation } = params;\n const prevActors = existing?.latestActorIds ?? [];\n const latestActorIds = actorId\n ? [actorId, ...prevActors.filter((id) => id !== actorId)].slice(0, actorCap)\n : prevActors.slice(0, actorCap);\n const count = strategy === 'staticRelight' ? 1 : Math.min((existing?.count ?? 0) + 1, countCap);\n return {\n count,\n latestActorIds,\n message: buildMessage(count),\n activityGeneration: generation,\n seenAt: 0,\n updatedAt: now,\n };\n}\n\nexport function createDeliveryLedger(\n db: ServerFirestore,\n config: NotificationSystemConfig,\n options: DeliveryLedgerOptions,\n): DeliveryLedger {\n const { isRecipientEligible } = options;\n const deliveriesPath = config.deliveriesCollectionPath ?? DEFAULT_DELIVERIES_PATH;\n const ttlMs = config.deliveryTtlMs ?? DEFAULT_DELIVERY_TTL_MS;\n const maxAttempts = config.maxDeliveryAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const configTimestampFromMillis = config.timestampFromMillis;\n if (!configTimestampFromMillis) {\n throw new Error(\n '[notification-core] createDeliveryLedger requires config.timestampFromMillis (Firestore TTL needs a real Timestamp for expireAt).',\n );\n }\n // An explicitly non-optional binding: TS does not carry the narrowing into closures called later.\n const toTimestamp: (ms: number) => unknown = configTimestampFromMillis;\n\n const deliveries = db.collection(deliveriesPath);\n\n function deliveryRef(deliveryId: string): ServerDocRef {\n return deliveries.doc(deliveryId);\n }\n\n function buildDeliveryDoc(row: DeliveryRowInput, now: number): Record<string, unknown> {\n return {\n deliveryId: row.deliveryId,\n state: 'queued' as DeliveryState,\n notificationType: row.notificationType,\n eventId: row.eventId,\n recipientUid: row.recipientUid,\n aggregationKey: row.aggregationKey,\n strategy: row.strategy,\n payload: row.payload,\n payloadVersion: row.payloadVersion,\n materializationClass: row.materializationClass,\n attemptCount: 0,\n nextAttemptAt: now,\n lastError: null,\n createdAt: now,\n materializedAt: null,\n deadLetteredAt: null,\n };\n }\n\n function getTypeConfig(type: string) {\n const typeConfig = config.types[type];\n if (!typeConfig) throw new Error(`[notification-core] Unknown notification type: ${type}`);\n const categoryConfig = config.categories[typeConfig.category];\n if (!categoryConfig)\n throw new Error(`[notification-core] Unknown category: ${typeConfig.category}`);\n return { typeConfig, categoryConfig };\n }\n\n async function enqueue(rows: DeliveryRowInput[]): Promise<EnqueueResult> {\n if (rows.length === 0) return { results: [], allResolved: true };\n const now = Date.now();\n const settled = await Promise.allSettled(\n rows.map((row) =>\n deliveryRef(row.deliveryId)\n .create(buildDeliveryDoc(row, now))\n .then(() => ({ deliveryId: row.deliveryId, outcome: 'created' as const })),\n ),\n );\n const results: EnqueueRowResult[] = settled.map((s, i) => {\n const deliveryId = rows[i].deliveryId;\n if (s.status === 'fulfilled') return s.value;\n if (isAlreadyExists(s.reason)) return { deliveryId, outcome: 'duplicate' };\n return { deliveryId, outcome: 'failed', error: s.reason };\n });\n return { results, allResolved: results.every((r) => r.outcome !== 'failed') };\n }\n\n async function materialize(deliveryId: string): Promise<MaterializeOutcome> {\n return db.runTransaction(async (tx) => {\n const dRef = deliveryRef(deliveryId);\n const dSnap = await tx.get(dRef);\n if (!dSnap.exists) return 'missing';\n const d = (dSnap.data() ?? {}) as Record<string, unknown>;\n const state = d.state as DeliveryState;\n if (state === 'materialized') return 'already-materialized';\n if (state === 'skipped') return 'skipped-ineligible-recipient';\n if (state !== 'queued') return 'skipped-non-queued';\n\n const notificationType = d.notificationType as string;\n const aggregationKey = d.aggregationKey as string;\n const recipientUid = (d.recipientUid as string | null) ?? null;\n\n if (recipientUid !== null && !(await isRecipientEligible(recipientUid, tx))) {\n const skippedAt = Date.now();\n tx.update(dRef, {\n state: 'skipped' as DeliveryState,\n skipReason: 'recipientIneligible' as DeliverySkipReason,\n skippedAt,\n expireAt: toTimestamp(skippedAt + ttlMs),\n });\n return 'skipped-ineligible-recipient';\n }\n const strategy = (d.strategy as AggregationStrategy) ?? 'increment';\n const payload = (d.payload as DeliveryPayload) ?? {\n actorId: '',\n metadata: {},\n occurrenceAt: Date.now(),\n };\n const { typeConfig, categoryConfig } = getTypeConfig(notificationType);\n\n const activeRef = db.collection(categoryConfig.activePath).doc(\n buildActiveNotificationDocId({\n category: typeConfig.category,\n audienceType: categoryConfig.audienceType,\n targetUserId: recipientUid,\n dedupKey: aggregationKey,\n notificationType,\n }),\n );\n const activeSnap = await tx.get(activeRef); // Firestore transactions take every read before any write.\n\n const now = Date.now();\n const generation = randomUUID();\n const countCap = typeConfig.countCap ?? DEFAULT_COUNT_CAP;\n const actorCap = typeConfig.actorCap ?? DEFAULT_ACTOR_CAP;\n const buildMessage = (count: number) => typeConfig.messagePattern(payload.metadata, count);\n const title = typeConfig.titlePattern(payload.metadata);\n // Firestore rejects `undefined` values, so a linkless type omits the field.\n const targetPath =\n typeConfig.defaultTargetPath === undefined\n ? undefined\n : typeof typeConfig.defaultTargetPath === 'function'\n ? typeConfig.defaultTargetPath(payload.metadata)\n : typeConfig.defaultTargetPath;\n const targetPathField = targetPath === undefined ? {} : { targetPath };\n\n if (activeSnap.exists) {\n const existing = (activeSnap.data() ?? {}) as Pick<\n NotificationDoc,\n 'count' | 'latestActorIds'\n >;\n const delta = applyAggregation({\n strategy,\n existing,\n actorId: payload.actorId,\n countCap,\n actorCap,\n buildMessage,\n now,\n generation,\n });\n tx.update(activeRef, { ...delta, title, ...targetPathField, metadata: payload.metadata });\n } else {\n const delta = applyAggregation({\n strategy,\n existing: null,\n actorId: payload.actorId,\n countCap,\n actorCap,\n buildMessage,\n now,\n generation,\n });\n const newDoc: Omit<NotificationDoc, 'id'> = {\n type: notificationType,\n dedupKey: aggregationKey,\n category: typeConfig.category,\n targetUserId: recipientUid,\n title,\n message: delta.message,\n count: delta.count,\n latestActorIds: delta.latestActorIds,\n ...targetPathField,\n metadata: payload.metadata,\n seenAt: 0,\n activityGeneration: generation,\n createdAt: now,\n updatedAt: now,\n };\n tx.set(activeRef, newDoc as Record<string, unknown>);\n }\n\n tx.update(dRef, {\n state: 'materialized' as DeliveryState,\n materializedAt: now,\n expireAt: toTimestamp(now + ttlMs),\n });\n return 'materialized';\n });\n }\n\n async function recordTransientFailure(deliveryId: string, error: unknown): Promise<void> {\n await db.runTransaction(async (tx) => {\n const dRef = deliveryRef(deliveryId);\n const dSnap = await tx.get(dRef);\n if (!dSnap.exists) return;\n const d = (dSnap.data() ?? {}) as Record<string, unknown>;\n if ((d.state as DeliveryState) !== 'queued') return;\n const attemptCount = ((d.attemptCount as number) ?? 0) + 1;\n const now = Date.now();\n const message = error instanceof Error ? error.message : String(error);\n if (attemptCount >= maxAttempts) {\n tx.update(dRef, {\n state: 'deadLetter' as DeliveryState,\n attemptCount,\n lastError: message,\n deadLetteredAt: now,\n });\n } else {\n tx.update(dRef, {\n attemptCount,\n nextAttemptAt: now + backoffMs(attemptCount),\n lastError: message,\n materializationClass: 'retry' as MaterializationClass,\n });\n }\n });\n }\n\n async function deadLetter(deliveryId: string, error: unknown): Promise<void> {\n await db.runTransaction(async (tx) => {\n const dRef = deliveryRef(deliveryId);\n const dSnap = await tx.get(dRef);\n if (!dSnap.exists) return;\n const d = (dSnap.data() ?? {}) as Record<string, unknown>;\n if ((d.state as DeliveryState) !== 'queued') return;\n tx.update(dRef, {\n state: 'deadLetter' as DeliveryState,\n attemptCount: ((d.attemptCount as number) ?? 0) + 1,\n lastError: error instanceof Error ? error.message : String(error),\n deadLetteredAt: Date.now(),\n });\n });\n }\n\n async function replay(deliveryId: string, options?: ReplayOptions): Promise<ReplayOutcome> {\n return db.runTransaction(async (tx): Promise<ReplayOutcome> => {\n const dRef = deliveryRef(deliveryId);\n const dSnap = await tx.get(dRef);\n if (!dSnap.exists) return 'missing';\n const d = (dSnap.data() ?? {}) as Record<string, unknown>;\n if ((d.state as DeliveryState) !== 'deadLetter') return 'not-dead-lettered';\n tx.update(dRef, {\n state: 'queued' as DeliveryState,\n attemptCount: 0,\n nextAttemptAt: Date.now(),\n lastError: null,\n deadLetteredAt: null,\n expireAt: null,\n });\n await options?.auditWrite?.(tx);\n return 'replayed';\n });\n }\n\n function reportUnrecordedFailure(\n options: MaterializeManyOptions | undefined,\n deliveryId: string,\n error: unknown,\n cause: unknown,\n ): void {\n try {\n if (options?.onUnrecordedFailure) {\n options.onUnrecordedFailure(deliveryId, error, { cause });\n return;\n }\n } catch (handlerError) {\n console.error(\n '[notification-core] onUnrecordedFailure threw for delivery',\n deliveryId,\n handlerError,\n );\n }\n console.error(\n '[notification-core] A delivery failure could not be recorded',\n deliveryId,\n error,\n { cause },\n );\n }\n\n async function materializeMany(\n deliveryIds: string[],\n options?: MaterializeManyOptions,\n ): Promise<Record<MaterializeOutcome, number>> {\n const concurrency = Math.max(1, options?.concurrency ?? 10);\n const tally: Record<MaterializeOutcome, number> = {\n materialized: 0,\n 'already-materialized': 0,\n 'skipped-ineligible-recipient': 0,\n missing: 0,\n 'skipped-non-queued': 0,\n };\n let cursor = 0;\n async function worker(): Promise<void> {\n while (cursor < deliveryIds.length) {\n const id = deliveryIds[cursor++];\n try {\n const outcome = await materialize(id);\n tally[outcome] += 1;\n } catch (error) {\n try {\n await recordTransientFailure(id, error);\n } catch (recordError) {\n reportUnrecordedFailure(options, id, recordError, error);\n }\n }\n }\n }\n await Promise.all(Array.from({ length: Math.min(concurrency, deliveryIds.length) }, worker));\n return tally;\n }\n\n return { enqueue, materialize, materializeMany, recordTransientFailure, deadLetter, replay };\n}\n"]}
@@ -1,12 +1,3 @@
1
- /**
2
- * Typed errors thrown by notification-core server helpers.
3
- */
4
- /**
5
- * Thrown when a caller is not allowed to perform a notification action — e.g.
6
- * archiving a personal notification they do not own, or a non-admin archiving a
7
- * shared notification. Consuming callables should catch this and map it to a
8
- * `permission-denied` error code rather than surfacing a generic `internal`.
9
- */
10
1
  export declare class NotificationPermissionError extends Error {
11
2
  constructor(message: string);
12
3
  }
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/server/errors.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;;;;GAKG;AACH,qBAAa,2BAA4B,SAAQ,KAAK;gBACxC,OAAO,EAAE,MAAM;CAO5B"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/server/errors.ts"],"names":[],"mappings":"AAAA,qBAAa,2BAA4B,SAAQ,KAAK;gBACxC,OAAO,EAAE,MAAM;CAM5B"}
@@ -1,18 +1,8 @@
1
- /**
2
- * Typed errors thrown by notification-core server helpers.
3
- */
4
- /**
5
- * Thrown when a caller is not allowed to perform a notification action — e.g.
6
- * archiving a personal notification they do not own, or a non-admin archiving a
7
- * shared notification. Consuming callables should catch this and map it to a
8
- * `permission-denied` error code rather than surfacing a generic `internal`.
9
- */
10
1
  export class NotificationPermissionError extends Error {
11
2
  constructor(message) {
12
3
  super(message);
13
4
  this.name = 'NotificationPermissionError';
14
- // Restore the prototype chain so `instanceof` works across the transpile
15
- // target (extending built-ins can break it otherwise).
5
+ // Restores the prototype chain, which extending a built-in can break under the transpile target.
16
6
  Object.setPrototypeOf(this, NotificationPermissionError.prototype);
17
7
  }
18
8
  }