@ttt-productions/notification-core 0.15.17 → 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 (87) hide show
  1. package/README.md +9 -10
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +0 -3
  4. package/dist/index.js.map +1 -1
  5. package/dist/react/components/NotificationEmptyState.d.ts +0 -3
  6. package/dist/react/components/NotificationEmptyState.d.ts.map +1 -1
  7. package/dist/react/components/NotificationEmptyState.js +0 -3
  8. package/dist/react/components/NotificationEmptyState.js.map +1 -1
  9. package/dist/react/components/NotificationHistoryList.d.ts +0 -6
  10. package/dist/react/components/NotificationHistoryList.d.ts.map +1 -1
  11. package/dist/react/components/NotificationHistoryList.js +0 -8
  12. package/dist/react/components/NotificationHistoryList.js.map +1 -1
  13. package/dist/react/components/NotificationList.d.ts +0 -3
  14. package/dist/react/components/NotificationList.d.ts.map +1 -1
  15. package/dist/react/components/NotificationList.js +2 -29
  16. package/dist/react/components/NotificationList.js.map +1 -1
  17. package/dist/react/components/NotificationUnreadBadge.d.ts +0 -5
  18. package/dist/react/components/NotificationUnreadBadge.d.ts.map +1 -1
  19. package/dist/react/components/NotificationUnreadBadge.js +0 -5
  20. package/dist/react/components/NotificationUnreadBadge.js.map +1 -1
  21. package/dist/react/components/relative-time.d.ts +0 -3
  22. package/dist/react/components/relative-time.d.ts.map +1 -1
  23. package/dist/react/components/relative-time.js +0 -3
  24. package/dist/react/components/relative-time.js.map +1 -1
  25. package/dist/react/hooks/useActiveNotifications.d.ts +0 -4
  26. package/dist/react/hooks/useActiveNotifications.d.ts.map +1 -1
  27. package/dist/react/hooks/useActiveNotifications.js +2 -6
  28. package/dist/react/hooks/useActiveNotifications.js.map +1 -1
  29. package/dist/react/hooks/useArchiveAllNotifications.d.ts +0 -30
  30. package/dist/react/hooks/useArchiveAllNotifications.d.ts.map +1 -1
  31. package/dist/react/hooks/useArchiveAllNotifications.js +1 -43
  32. package/dist/react/hooks/useArchiveAllNotifications.js.map +1 -1
  33. package/dist/react/hooks/useArchiveNotification.d.ts +0 -8
  34. package/dist/react/hooks/useArchiveNotification.d.ts.map +1 -1
  35. package/dist/react/hooks/useArchiveNotification.js +0 -8
  36. package/dist/react/hooks/useArchiveNotification.js.map +1 -1
  37. package/dist/react/hooks/useNotificationHistory.d.ts +0 -14
  38. package/dist/react/hooks/useNotificationHistory.d.ts.map +1 -1
  39. package/dist/react/hooks/useNotificationHistory.js +2 -18
  40. package/dist/react/hooks/useNotificationHistory.js.map +1 -1
  41. package/dist/react/hooks/useUnreadCount.d.ts +0 -22
  42. package/dist/react/hooks/useUnreadCount.d.ts.map +1 -1
  43. package/dist/react/hooks/useUnreadCount.js +1 -22
  44. package/dist/react/hooks/useUnreadCount.js.map +1 -1
  45. package/dist/react/index.d.ts.map +1 -1
  46. package/dist/react/index.js +0 -2
  47. package/dist/react/index.js.map +1 -1
  48. package/dist/react/types.d.ts +0 -42
  49. package/dist/react/types.d.ts.map +1 -1
  50. package/dist/react/types.js.map +1 -1
  51. package/dist/schemas.d.ts +0 -22
  52. package/dist/schemas.d.ts.map +1 -1
  53. package/dist/schemas.js +0 -25
  54. package/dist/schemas.js.map +1 -1
  55. package/dist/server/activeNotificationId.d.ts +1 -22
  56. package/dist/server/activeNotificationId.d.ts.map +1 -1
  57. package/dist/server/activeNotificationId.js +0 -16
  58. package/dist/server/activeNotificationId.js.map +1 -1
  59. package/dist/server/delivery-ledger.d.ts +1 -73
  60. package/dist/server/delivery-ledger.d.ts.map +1 -1
  61. package/dist/server/delivery-ledger.js +7 -59
  62. package/dist/server/delivery-ledger.js.map +1 -1
  63. package/dist/server/errors.d.ts +0 -9
  64. package/dist/server/errors.d.ts.map +1 -1
  65. package/dist/server/errors.js +1 -11
  66. package/dist/server/errors.js.map +1 -1
  67. package/dist/server/index.d.ts.map +1 -1
  68. package/dist/server/index.js +0 -3
  69. package/dist/server/index.js.map +1 -1
  70. package/dist/server/observed-generation.d.ts +1 -41
  71. package/dist/server/observed-generation.d.ts.map +1 -1
  72. package/dist/server/observed-generation.js +1 -31
  73. package/dist/server/observed-generation.js.map +1 -1
  74. package/dist/server/processBatchHelper.d.ts +0 -19
  75. package/dist/server/processBatchHelper.d.ts.map +1 -1
  76. package/dist/server/processBatchHelper.js +2 -46
  77. package/dist/server/processBatchHelper.js.map +1 -1
  78. package/dist/server/types.d.ts +0 -9
  79. package/dist/server/types.d.ts.map +1 -1
  80. package/dist/server/types.js +0 -4
  81. package/dist/server/types.js.map +1 -1
  82. package/dist/types.d.ts +2 -196
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js +0 -6
  85. package/dist/types.js.map +1 -1
  86. package/package.json +5 -5
  87. package/src/styles/notifications.css +0 -14
@@ -1,22 +1,3 @@
1
- /**
2
- * Observed-generation seen/archive protocol (notification redesign — P1).
3
- *
4
- * The SEEN/ARCHIVE precondition compares the card's current opaque
5
- * `activityGeneration` against the generation the client actually observed when
6
- * it rendered the row — so activity arriving after render (or a delete+recreate
7
- * that rotated the token) is never silently swallowed. Archive additionally
8
- * carries a retry-stable `requestId`; the deterministic history doc id makes a
9
- * replay return the stored result and touch nothing.
10
- *
11
- * GENERIC: the app constructs the deterministic ids (`historyRef`, `payloadHash`)
12
- * via its own canonical hash (in the consuming app's core package) and passes the
13
- * refs in; this module owns only the transactional state machine, not the id formulas.
14
- */
15
- /**
16
- * Set `seenAt`/`seenAtGeneration` ONLY if the card's current `activityGeneration`
17
- * still matches the observed one; otherwise no-op (the row stays unseen so newer
18
- * activity is never swallowed).
19
- */
20
1
  export async function markNotificationSeenWithGeneration(db, activeRef, params) {
21
2
  return db.runTransaction(async (tx) => {
22
3
  const snap = await tx.get(activeRef);
@@ -33,18 +14,10 @@ export async function markNotificationSeenWithGeneration(db, activeRef, params)
33
14
  return 'seen';
34
15
  });
35
16
  }
36
- /**
37
- * Archive one active card into history, idempotently and replay-safely.
38
- *
39
- * - history doc already exists + same `payloadHash` ⇒ `replayed` (touch NOTHING).
40
- * - history doc already exists + DIFFERENT `payloadHash` ⇒ `conflict` (caller alerts).
41
- * - first-seen: archive only if the active card's `activityGeneration` still
42
- * matches the observed generation; else `generation-mismatch` (leave it active).
43
- */
44
17
  export async function archiveNotificationWithGeneration(db, params) {
45
18
  const { activeRef, historyRef, requestId, observedActivityGeneration, payloadHash, category, audienceScope, expireAtMs, timestampFromMillis, handledBy, auditWrite, } = params;
46
19
  return db.runTransaction(async (tx) => {
47
- // Reads first (Firestore reads-before-writes).
20
+ // Firestore transactions take every read before any write.
48
21
  const historySnap = await tx.get(historyRef);
49
22
  if (historySnap.exists) {
50
23
  const existing = (historySnap.data() ?? {});
@@ -74,9 +47,6 @@ export async function archiveNotificationWithGeneration(db, params) {
74
47
  historyDoc.handledBy = handledBy;
75
48
  tx.set(historyRef, historyDoc);
76
49
  tx.delete(activeRef);
77
- // Compose any caller-supplied write (e.g. an audit event) into THIS
78
- // transaction so it commits atomically with the archive. Only reached on the
79
- // successful path, after all reads, so reads-before-writes is preserved.
80
50
  await auditWrite?.(tx);
81
51
  return 'archived';
82
52
  });
@@ -1 +1 @@
1
- {"version":3,"file":"observed-generation.js","sourceRoot":"","sources":["../../src/server/observed-generation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAMH;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,kCAAkC,CACtD,EAAmB,EACnB,SAAuB,EACvB,MAA4D;IAE5D,OAAO,EAAE,CAAC,cAAc,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;QACpC,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QACrC,IAAI,CAAC,IAAI,CAAC,MAAM;YAAE,OAAO,SAAS,CAAC;QACnC,MAAM,IAAI,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,CAA4B,CAAC;QAC5D,IAAK,IAAI,CAAC,kBAAyC,KAAK,MAAM,CAAC,0BAA0B,EAAE,CAAC;YAC1F,OAAO,qBAAqB,CAAC;QAC/B,CAAC;QACD,EAAE,CAAC,MAAM,CAAC,SAAS,EAAE;YACnB,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE;YAChC,gBAAgB,EAAE,MAAM,CAAC,0BAA0B;SACpD,CAAC,CAAC;QACH,OAAO,MAAM,CAAC;IAChB,CAAC,CAAC,CAAC;AACL,CAAC;AAKD;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,iCAAiC,CACrD,EAAmB,EACnB,MA0BC;IAED,MAAM,EACJ,SAAS,EACT,UAAU,EACV,SAAS,EACT,0BAA0B,EAC1B,WAAW,EACX,QAAQ,EACR,aAAa,EACb,UAAU,EACV,mBAAmB,EACnB,SAAS,EACT,UAAU,GACX,GAAG,MAAM,CAAC;IAEX,OAAO,EAAE,CAAC,cAAc,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;QACpC,+CAA+C;QAC/C,MAAM,WAAW,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;QAC7C,IAAI,WAAW,CAAC,MAAM,EAAE,CAAC;YACvB,MAAM,QAAQ,GAAG,CAAC,WAAW,CAAC,IAAI,EAAE,IAAI,EAAE,CAA4B,CAAC;YACvE,OAAQ,QAAQ,CAAC,WAAkC,KAAK,WAAW,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,UAAU,CAAC;QAChG,CAAC;QACD,MAAM,UAAU,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QAC3C,IAAI,CAAC,UAAU,CAAC,MAAM;YAAE,OAAO,SAAS,CAAC;QACzC,MAAM,UAAU,GAAG,CAAC,UAAU,CAAC,IAAI,EAAE,IAAI,EAAE,CAA4B,CAAC;QACxE,IAAK,UAAU,CAAC,kBAAyC,KAAK,0BAA0B,EAAE,CAAC;YACzF,OAAO,qBAAqB,CAAC;QAC/B,CAAC;QAED,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,MAAM,UAAU,GAA4B;YAC1C,mBAAmB,EAAE,UAAU,CAAC,EAAE;YAClC,SAAS;YACT,WAAW;YACX,QAAQ,EAAE,SAAS,CAAC,EAAE;YACtB,0BAA0B;YAC1B,QAAQ;YACR,aAAa;YACb,gBAAgB,EAAE,UAAU;YAC5B,UAAU,EAAE,GAAG;YACf,QAAQ,EAAE,mBAAmB,CAAC,UAAU,CAAC;SAC1C,CAAC;QACF,IAAI,SAAS,KAAK,SAAS;YAAE,UAAU,CAAC,SAAS,GAAG,SAAS,CAAC;QAE9D,EAAE,CAAC,GAAG,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC;QAC/B,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QACrB,oEAAoE;QACpE,6EAA6E;QAC7E,yEAAyE;QACzE,MAAM,UAAU,EAAE,CAAC,EAAE,CAAC,CAAC;QACvB,OAAO,UAAU,CAAC;IACpB,CAAC,CAAC,CAAC;AACL,CAAC","sourcesContent":["/**\n * Observed-generation seen/archive protocol (notification redesign — P1).\n *\n * The SEEN/ARCHIVE precondition compares the card's current opaque\n * `activityGeneration` against the generation the client actually observed when\n * it rendered the row — so activity arriving after render (or a delete+recreate\n * that rotated the token) is never silently swallowed. Archive additionally\n * carries a retry-stable `requestId`; the deterministic history doc id makes a\n * replay return the stored result and touch nothing.\n *\n * GENERIC: the app constructs the deterministic ids (`historyRef`, `payloadHash`)\n * via its own canonical hash (in the consuming app's core package) and passes the\n * refs in; this module owns only the transactional state machine, not the id formulas.\n */\n\nimport type { ServerFirestore, ServerDocRef, ServerTransaction } from './types.js';\n\nexport type MarkSeenOutcome = 'seen' | 'generation-mismatch' | 'missing';\n\n/**\n * Set `seenAt`/`seenAtGeneration` ONLY if the card's current `activityGeneration`\n * still matches the observed one; otherwise no-op (the row stays unseen so newer\n * activity is never swallowed).\n */\nexport async function markNotificationSeenWithGeneration(\n db: ServerFirestore,\n activeRef: ServerDocRef,\n params: { observedActivityGeneration: string; now?: number },\n): Promise<MarkSeenOutcome> {\n return db.runTransaction(async (tx) => {\n const snap = await tx.get(activeRef);\n if (!snap.exists) return 'missing';\n const data = (snap.data() ?? {}) as Record<string, unknown>;\n if ((data.activityGeneration as string | undefined) !== params.observedActivityGeneration) {\n return 'generation-mismatch';\n }\n tx.update(activeRef, {\n seenAt: params.now ?? Date.now(),\n seenAtGeneration: params.observedActivityGeneration,\n });\n return 'seen';\n });\n}\n\nexport type ArchiveOutcome =\n 'archived' | 'replayed' | 'conflict' | 'generation-mismatch' | 'missing';\n\n/**\n * Archive one active card into history, idempotently and replay-safely.\n *\n * - history doc already exists + same `payloadHash` ⇒ `replayed` (touch NOTHING).\n * - history doc already exists + DIFFERENT `payloadHash` ⇒ `conflict` (caller alerts).\n * - first-seen: archive only if the active card's `activityGeneration` still\n * matches the observed generation; else `generation-mismatch` (leave it active).\n */\nexport async function archiveNotificationWithGeneration(\n db: ServerFirestore,\n params: {\n activeRef: ServerDocRef;\n /** Deterministic history doc id = hash('notification-archive', category, audienceScope, requestId) (app-built). */\n historyRef: ServerDocRef;\n requestId: string;\n observedActivityGeneration: string;\n /** Deterministic payload hash (app-built) — the replay-vs-conflict discriminator. */\n payloadHash: string;\n category: string;\n audienceScope: string;\n /** Native-TTL expiry for the history doc; converted to a Timestamp via the factory. */\n expireAtMs: number;\n timestampFromMillis: (ms: number) => unknown;\n /** Admin history quick-access field (admin category only). */\n handledBy?: string;\n /**\n * Optional hook to compose additional writes (e.g. an audit event) INTO the\n * same archive transaction, so they commit atomically with the active→history\n * move. Invoked in the write phase ONLY on the successful 'archived' path —\n * never on replay/conflict/generation-mismatch/missing — so the audit is\n * written iff the archive actually happens. Honor reads-before-writes: do not\n * read inside the hook. The hook is awaited inside the transaction body, so a\n * throw or a rejected promise fails the archive instead of committing it\n * without its write.\n */\n auditWrite?: (txn: ServerTransaction) => void | Promise<void>;\n },\n): Promise<ArchiveOutcome> {\n const {\n activeRef,\n historyRef,\n requestId,\n observedActivityGeneration,\n payloadHash,\n category,\n audienceScope,\n expireAtMs,\n timestampFromMillis,\n handledBy,\n auditWrite,\n } = params;\n\n return db.runTransaction(async (tx) => {\n // Reads first (Firestore reads-before-writes).\n const historySnap = await tx.get(historyRef);\n if (historySnap.exists) {\n const existing = (historySnap.data() ?? {}) as Record<string, unknown>;\n return (existing.payloadHash as string | undefined) === payloadHash ? 'replayed' : 'conflict';\n }\n const activeSnap = await tx.get(activeRef);\n if (!activeSnap.exists) return 'missing';\n const activeData = (activeSnap.data() ?? {}) as Record<string, unknown>;\n if ((activeData.activityGeneration as string | undefined) !== observedActivityGeneration) {\n return 'generation-mismatch';\n }\n\n const now = Date.now();\n const historyDoc: Record<string, unknown> = {\n archiveOccurrenceId: historyRef.id,\n requestId,\n payloadHash,\n activeId: activeRef.id,\n observedActivityGeneration,\n category,\n audienceScope,\n archivedSnapshot: activeData,\n archivedAt: now,\n expireAt: timestampFromMillis(expireAtMs),\n };\n if (handledBy !== undefined) historyDoc.handledBy = handledBy;\n\n tx.set(historyRef, historyDoc);\n tx.delete(activeRef);\n // Compose any caller-supplied write (e.g. an audit event) into THIS\n // transaction so it commits atomically with the archive. Only reached on the\n // successful path, after all reads, so reads-before-writes is preserved.\n await auditWrite?.(tx);\n return 'archived';\n });\n}\n"]}
1
+ {"version":3,"file":"observed-generation.js","sourceRoot":"","sources":["../../src/server/observed-generation.ts"],"names":[],"mappings":"AAIA,MAAM,CAAC,KAAK,UAAU,kCAAkC,CACtD,EAAmB,EACnB,SAAuB,EACvB,MAA4D;IAE5D,OAAO,EAAE,CAAC,cAAc,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;QACpC,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QACrC,IAAI,CAAC,IAAI,CAAC,MAAM;YAAE,OAAO,SAAS,CAAC;QACnC,MAAM,IAAI,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,CAA4B,CAAC;QAC5D,IAAK,IAAI,CAAC,kBAAyC,KAAK,MAAM,CAAC,0BAA0B,EAAE,CAAC;YAC1F,OAAO,qBAAqB,CAAC;QAC/B,CAAC;QACD,EAAE,CAAC,MAAM,CAAC,SAAS,EAAE;YACnB,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE;YAChC,gBAAgB,EAAE,MAAM,CAAC,0BAA0B;SACpD,CAAC,CAAC;QACH,OAAO,MAAM,CAAC;IAChB,CAAC,CAAC,CAAC;AACL,CAAC;AAKD,MAAM,CAAC,KAAK,UAAU,iCAAiC,CACrD,EAAmB,EACnB,MAaC;IAED,MAAM,EACJ,SAAS,EACT,UAAU,EACV,SAAS,EACT,0BAA0B,EAC1B,WAAW,EACX,QAAQ,EACR,aAAa,EACb,UAAU,EACV,mBAAmB,EACnB,SAAS,EACT,UAAU,GACX,GAAG,MAAM,CAAC;IAEX,OAAO,EAAE,CAAC,cAAc,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;QACpC,2DAA2D;QAC3D,MAAM,WAAW,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;QAC7C,IAAI,WAAW,CAAC,MAAM,EAAE,CAAC;YACvB,MAAM,QAAQ,GAAG,CAAC,WAAW,CAAC,IAAI,EAAE,IAAI,EAAE,CAA4B,CAAC;YACvE,OAAQ,QAAQ,CAAC,WAAkC,KAAK,WAAW,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,UAAU,CAAC;QAChG,CAAC;QACD,MAAM,UAAU,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QAC3C,IAAI,CAAC,UAAU,CAAC,MAAM;YAAE,OAAO,SAAS,CAAC;QACzC,MAAM,UAAU,GAAG,CAAC,UAAU,CAAC,IAAI,EAAE,IAAI,EAAE,CAA4B,CAAC;QACxE,IAAK,UAAU,CAAC,kBAAyC,KAAK,0BAA0B,EAAE,CAAC;YACzF,OAAO,qBAAqB,CAAC;QAC/B,CAAC;QAED,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,MAAM,UAAU,GAA4B;YAC1C,mBAAmB,EAAE,UAAU,CAAC,EAAE;YAClC,SAAS;YACT,WAAW;YACX,QAAQ,EAAE,SAAS,CAAC,EAAE;YACtB,0BAA0B;YAC1B,QAAQ;YACR,aAAa;YACb,gBAAgB,EAAE,UAAU;YAC5B,UAAU,EAAE,GAAG;YACf,QAAQ,EAAE,mBAAmB,CAAC,UAAU,CAAC;SAC1C,CAAC;QACF,IAAI,SAAS,KAAK,SAAS;YAAE,UAAU,CAAC,SAAS,GAAG,SAAS,CAAC;QAE9D,EAAE,CAAC,GAAG,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC;QAC/B,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QACrB,MAAM,UAAU,EAAE,CAAC,EAAE,CAAC,CAAC;QACvB,OAAO,UAAU,CAAC;IACpB,CAAC,CAAC,CAAC;AACL,CAAC","sourcesContent":["import type { ServerFirestore, ServerDocRef, ServerTransaction } from './types.js';\n\nexport type MarkSeenOutcome = 'seen' | 'generation-mismatch' | 'missing';\n\nexport async function markNotificationSeenWithGeneration(\n db: ServerFirestore,\n activeRef: ServerDocRef,\n params: { observedActivityGeneration: string; now?: number },\n): Promise<MarkSeenOutcome> {\n return db.runTransaction(async (tx) => {\n const snap = await tx.get(activeRef);\n if (!snap.exists) return 'missing';\n const data = (snap.data() ?? {}) as Record<string, unknown>;\n if ((data.activityGeneration as string | undefined) !== params.observedActivityGeneration) {\n return 'generation-mismatch';\n }\n tx.update(activeRef, {\n seenAt: params.now ?? Date.now(),\n seenAtGeneration: params.observedActivityGeneration,\n });\n return 'seen';\n });\n}\n\nexport type ArchiveOutcome =\n 'archived' | 'replayed' | 'conflict' | 'generation-mismatch' | 'missing';\n\nexport async function archiveNotificationWithGeneration(\n db: ServerFirestore,\n params: {\n activeRef: ServerDocRef;\n historyRef: ServerDocRef;\n requestId: string;\n observedActivityGeneration: string;\n payloadHash: string;\n category: string;\n audienceScope: string;\n expireAtMs: number;\n timestampFromMillis: (ms: number) => unknown;\n handledBy?: string;\n /** Runs in the write phase, so it must not read (Firestore reads-before-writes). */\n auditWrite?: (txn: ServerTransaction) => void | Promise<void>;\n },\n): Promise<ArchiveOutcome> {\n const {\n activeRef,\n historyRef,\n requestId,\n observedActivityGeneration,\n payloadHash,\n category,\n audienceScope,\n expireAtMs,\n timestampFromMillis,\n handledBy,\n auditWrite,\n } = params;\n\n return db.runTransaction(async (tx) => {\n // Firestore transactions take every read before any write.\n const historySnap = await tx.get(historyRef);\n if (historySnap.exists) {\n const existing = (historySnap.data() ?? {}) as Record<string, unknown>;\n return (existing.payloadHash as string | undefined) === payloadHash ? 'replayed' : 'conflict';\n }\n const activeSnap = await tx.get(activeRef);\n if (!activeSnap.exists) return 'missing';\n const activeData = (activeSnap.data() ?? {}) as Record<string, unknown>;\n if ((activeData.activityGeneration as string | undefined) !== observedActivityGeneration) {\n return 'generation-mismatch';\n }\n\n const now = Date.now();\n const historyDoc: Record<string, unknown> = {\n archiveOccurrenceId: historyRef.id,\n requestId,\n payloadHash,\n activeId: activeRef.id,\n observedActivityGeneration,\n category,\n audienceScope,\n archivedSnapshot: activeData,\n archivedAt: now,\n expireAt: timestampFromMillis(expireAtMs),\n };\n if (handledBy !== undefined) historyDoc.handledBy = handledBy;\n\n tx.set(historyRef, historyDoc);\n tx.delete(activeRef);\n await auditWrite?.(tx);\n return 'archived';\n });\n}\n"]}
@@ -1,7 +1,3 @@
1
- /**
2
- * Batch processor helper — called by the app's scheduled Cloud Function.
3
- * Reads pending notifications, groups by dedupKey, creates/increments active docs.
4
- */
5
1
  import type { NotificationSystemConfig } from '../types.js';
6
2
  import type { ServerFirestore } from './types.js';
7
3
  interface BatchProcessResult {
@@ -9,21 +5,6 @@ interface BatchProcessResult {
9
5
  notificationsCreated: number;
10
6
  notificationsUpdated: number;
11
7
  }
12
- /**
13
- * Process the pending notifications queue.
14
- *
15
- * @example
16
- * ```ts
17
- * // In your scheduled Cloud Function:
18
- * export const processNotificationBatch = onSchedule(
19
- * { schedule: 'every 10 minutes' },
20
- * async () => {
21
- * const result = await processBatchHelper(db as any, TTT_NOTIFICATION_CONFIG);
22
- * logger.info('Batch processed', result);
23
- * }
24
- * );
25
- * ```
26
- */
27
8
  export declare function processBatchHelper(db: ServerFirestore, config: NotificationSystemConfig): Promise<BatchProcessResult>;
28
9
  export {};
29
10
  //# sourceMappingURL=processBatchHelper.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"processBatchHelper.d.ts","sourceRoot":"","sources":["../../src/server/processBatchHelper.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,wBAAwB,EAAuB,MAAM,aAAa,CAAC;AACjF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAQlD,UAAU,kBAAkB;IAC1B,cAAc,EAAE,MAAM,CAAC;IACvB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,oBAAoB,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,eAAe,EACnB,MAAM,EAAE,wBAAwB,GAC/B,OAAO,CAAC,kBAAkB,CAAC,CAkM7B"}
1
+ {"version":3,"file":"processBatchHelper.d.ts","sourceRoot":"","sources":["../../src/server/processBatchHelper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAuB,MAAM,aAAa,CAAC;AACjF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAQlD,UAAU,kBAAkB;IAC1B,cAAc,EAAE,MAAM,CAAC;IACvB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,oBAAoB,EAAE,MAAM,CAAC;CAC9B;AAED,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,eAAe,EACnB,MAAM,EAAE,wBAAwB,GAC/B,OAAO,CAAC,kBAAkB,CAAC,CAyK7B"}
@@ -1,30 +1,11 @@
1
- /**
2
- * Batch processor helper — called by the app's scheduled Cloud Function.
3
- * Reads pending notifications, groups by dedupKey, creates/increments active docs.
4
- */
5
1
  import { buildActiveNotificationDocId } from './activeNotificationId.js';
6
2
  const DEFAULT_COUNT_CAP = 5000;
7
3
  const DEFAULT_ACTOR_CAP = 5;
8
4
  const PROCESSING_BATCH_SIZE = 500;
9
5
  const MAX_ITERATIONS = 20;
10
- /**
11
- * Process the pending notifications queue.
12
- *
13
- * @example
14
- * ```ts
15
- * // In your scheduled Cloud Function:
16
- * export const processNotificationBatch = onSchedule(
17
- * { schedule: 'every 10 minutes' },
18
- * async () => {
19
- * const result = await processBatchHelper(db as any, TTT_NOTIFICATION_CONFIG);
20
- * logger.info('Batch processed', result);
21
- * }
22
- * );
23
- * ```
24
- */
25
6
  export async function processBatchHelper(db, config) {
26
7
  const pendingPath = config.pendingCollectionPath ?? 'pendingNotifications';
27
- const cutoff = Date.now() - 30_000; // Process items older than 30 seconds
8
+ const cutoff = Date.now() - 30_000;
28
9
  let totalProcessed = 0;
29
10
  let notificationsCreated = 0;
30
11
  let notificationsUpdated = 0;
@@ -41,7 +22,6 @@ export async function processBatchHelper(db, config) {
41
22
  if (snapshot.empty) {
42
23
  break;
43
24
  }
44
- // Group by dedupKey
45
25
  const groups = new Map();
46
26
  for (const docSnap of snapshot.docs) {
47
27
  const data = docSnap.data();
@@ -52,17 +32,12 @@ export async function processBatchHelper(db, config) {
52
32
  if (!categoryConfig)
53
33
  continue;
54
34
  const dedupKey = typeConfig.dedupKeyPattern(data.metadata);
55
- // Personal notifications dedup per-recipient: a multi-recipient fan-out
56
- // (same type/metadata, different targetUserId) must never collapse into
57
- // one recipient's active doc. Shared notifications are intentionally a
58
- // single doc for all admins (targetUserId is null).
59
35
  const groupKey = categoryConfig.audienceType === 'personal'
60
36
  ? `${data.category}::${data.targetUserId}::${dedupKey}`
61
37
  : `${data.category}::${dedupKey}`;
62
38
  const existing = groups.get(groupKey);
63
39
  if (existing) {
64
40
  existing.count++;
65
- // Add actor id if not already present
66
41
  if (!existing.actorIds.includes(data.actorId)) {
67
42
  existing.actorIds.push(data.actorId);
68
43
  }
@@ -80,16 +55,6 @@ export async function processBatchHelper(db, config) {
80
55
  });
81
56
  }
82
57
  }
83
- // Process each group: create or increment the group's active doc.
84
- // Each group's groupKey uniquely determines a single active doc — the doc ID
85
- // is a deterministic hash of exactly the dedupKey + category (+ targetUserId
86
- // for personal) that compose the groupKey — so two distinct groups can never
87
- // resolve to the same active doc and there is no cross-group contention. The
88
- // work per group is independent I/O, so Promise.all turns N sequential
89
- // round-trips into one parallel wave, removing the timeout risk under high
90
- // dedup-group counts. WITHIN a dedup scope, the transactional read-modify-write
91
- // on the deterministic ID makes concurrent writers (a racing real-time send,
92
- // or an overlapping batch run) converge on one doc with correct counts.
93
58
  const groupResults = await Promise.all(Array.from(groups.values()).map(async (group) => {
94
59
  const typeConfig = config.types[group.type];
95
60
  if (!typeConfig)
@@ -101,9 +66,6 @@ export async function processBatchHelper(db, config) {
101
66
  const dedupKey = typeConfig.dedupKeyPattern(group.metadata);
102
67
  const countCap = typeConfig.countCap ?? DEFAULT_COUNT_CAP;
103
68
  const actorCap = typeConfig.actorCap ?? DEFAULT_ACTOR_CAP;
104
- // Personal notifications are scoped to the recipient so different
105
- // recipients never share an active doc; shared notifications omit
106
- // targetUserId by design.
107
69
  const activeDocRef = db.collection(activePath).doc(buildActiveNotificationDocId({
108
70
  category: group.category,
109
71
  audienceType: categoryConfig.audienceType,
@@ -113,11 +75,9 @@ export async function processBatchHelper(db, config) {
113
75
  return db.runTransaction(async (tx) => {
114
76
  const existingSnap = await tx.get(activeDocRef);
115
77
  if (existingSnap.exists) {
116
- // Increment existing
117
78
  const existingData = (existingSnap.data() ?? {});
118
79
  const currentCount = existingData.count || 1;
119
80
  const currentActorIds = existingData.latestActorIds || [];
120
- // Merge actor ids (new first, deduped, capped)
121
81
  const newActorIds = group.actorIds.filter((id) => !currentActorIds.includes(id));
122
82
  const mergedActorIds = [...newActorIds, ...currentActorIds].slice(0, actorCap);
123
83
  const newCount = Math.min(currentCount + group.count, countCap);
@@ -125,15 +85,13 @@ export async function processBatchHelper(db, config) {
125
85
  count: newCount,
126
86
  latestActorIds: mergedActorIds,
127
87
  message: typeConfig.messagePattern(group.metadata, newCount),
128
- // New activity on an existing notification re-lights the unread badge.
129
88
  seenAt: 0,
130
89
  updatedAt: Date.now(),
131
90
  });
132
91
  return 'updated';
133
92
  }
134
93
  else {
135
- // Create new notification. A type with no defaultTargetPath is
136
- // linkless — OMIT the field (Firestore rejects `undefined` values).
94
+ // Firestore rejects `undefined` values, so a linkless type omits the field.
137
95
  const targetPath = typeConfig.defaultTargetPath === undefined
138
96
  ? undefined
139
97
  : typeof typeConfig.defaultTargetPath === 'function'
@@ -151,7 +109,6 @@ export async function processBatchHelper(db, config) {
151
109
  latestActorIds: group.actorIds.slice(0, actorCap),
152
110
  ...(targetPath === undefined ? {} : { targetPath }),
153
111
  metadata: group.metadata,
154
- // Active docs are created unseen so the unread count() predicate matches.
155
112
  seenAt: 0,
156
113
  createdAt: now,
157
114
  updatedAt: now,
@@ -167,7 +124,6 @@ export async function processBatchHelper(db, config) {
167
124
  else if (r === 'updated')
168
125
  notificationsUpdated++;
169
126
  }
170
- // Delete processed pending docs in batches of 500
171
127
  const allDocs = snapshot.docs;
172
128
  for (let i = 0; i < allDocs.length; i += 500) {
173
129
  const chunk = allDocs.slice(i, i + 500);
@@ -1 +1 @@
1
- {"version":3,"file":"processBatchHelper.js","sourceRoot":"","sources":["../../src/server/processBatchHelper.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Batch processor helper — called by the app's scheduled Cloud Function.\n * Reads pending notifications, groups by dedupKey, creates/increments active docs.\n */\n\nimport type { NotificationSystemConfig, PendingNotification } from '../types.js';\nimport type { ServerFirestore } from './types.js';\nimport { buildActiveNotificationDocId } from './activeNotificationId.js';\n\nconst DEFAULT_COUNT_CAP = 5000;\nconst DEFAULT_ACTOR_CAP = 5;\nconst PROCESSING_BATCH_SIZE = 500;\nconst MAX_ITERATIONS = 20;\n\ninterface BatchProcessResult {\n totalProcessed: number;\n notificationsCreated: number;\n notificationsUpdated: number;\n}\n\n/**\n * Process the pending notifications queue.\n *\n * @example\n * ```ts\n * // In your scheduled Cloud Function:\n * export const processNotificationBatch = onSchedule(\n * { schedule: 'every 10 minutes' },\n * async () => {\n * const result = await processBatchHelper(db as any, TTT_NOTIFICATION_CONFIG);\n * logger.info('Batch processed', result);\n * }\n * );\n * ```\n */\nexport async function processBatchHelper(\n db: ServerFirestore,\n config: NotificationSystemConfig,\n): Promise<BatchProcessResult> {\n const pendingPath = config.pendingCollectionPath ?? 'pendingNotifications';\n const cutoff = Date.now() - 30_000; // Process items older than 30 seconds\n\n let totalProcessed = 0;\n let notificationsCreated = 0;\n let notificationsUpdated = 0;\n let iterations = 0;\n let hasMore = true;\n\n while (hasMore && iterations < MAX_ITERATIONS) {\n iterations++;\n\n const pendingQuery = db\n .collection(pendingPath)\n .where('createdAt', '<=', cutoff)\n .orderBy('createdAt', 'asc')\n .limit(PROCESSING_BATCH_SIZE);\n\n const snapshot = await pendingQuery.get();\n\n if (snapshot.empty) {\n break;\n }\n\n // Group by dedupKey\n const groups = new Map<\n string,\n {\n type: string;\n category: string;\n targetUserId: string | null;\n metadata: Record<string, unknown>;\n actorIds: string[];\n count: number;\n docs: typeof snapshot.docs;\n }\n >();\n\n for (const docSnap of snapshot.docs) {\n const data = docSnap.data() as Omit<PendingNotification, 'id'>;\n const typeConfig = config.types[data.type];\n if (!typeConfig) continue;\n const categoryConfig = config.categories[data.category];\n if (!categoryConfig) continue;\n\n const dedupKey = typeConfig.dedupKeyPattern(data.metadata);\n // Personal notifications dedup per-recipient: a multi-recipient fan-out\n // (same type/metadata, different targetUserId) must never collapse into\n // one recipient's active doc. Shared notifications are intentionally a\n // single doc for all admins (targetUserId is null).\n const groupKey =\n categoryConfig.audienceType === 'personal'\n ? `${data.category}::${data.targetUserId}::${dedupKey}`\n : `${data.category}::${dedupKey}`;\n\n const existing = groups.get(groupKey);\n if (existing) {\n existing.count++;\n // Add actor id if not already present\n if (!existing.actorIds.includes(data.actorId)) {\n existing.actorIds.push(data.actorId);\n }\n existing.docs.push(docSnap);\n } else {\n groups.set(groupKey, {\n type: data.type,\n category: data.category,\n targetUserId: data.targetUserId,\n metadata: data.metadata,\n actorIds: [data.actorId],\n count: 1,\n docs: [docSnap],\n });\n }\n }\n\n // Process each group: create or increment the group's active doc.\n // Each group's groupKey uniquely determines a single active doc — the doc ID\n // is a deterministic hash of exactly the dedupKey + category (+ targetUserId\n // for personal) that compose the groupKey — so two distinct groups can never\n // resolve to the same active doc and there is no cross-group contention. The\n // work per group is independent I/O, so Promise.all turns N sequential\n // round-trips into one parallel wave, removing the timeout risk under high\n // dedup-group counts. WITHIN a dedup scope, the transactional read-modify-write\n // on the deterministic ID makes concurrent writers (a racing real-time send,\n // or an overlapping batch run) converge on one doc with correct counts.\n const groupResults = await Promise.all(\n Array.from(groups.values()).map(async (group): Promise<'created' | 'updated' | 'skipped'> => {\n const typeConfig = config.types[group.type];\n if (!typeConfig) return 'skipped';\n\n const categoryConfig = config.categories[group.category];\n if (!categoryConfig) return 'skipped';\n\n const activePath = categoryConfig.activePath;\n const dedupKey = typeConfig.dedupKeyPattern(group.metadata);\n const countCap = typeConfig.countCap ?? DEFAULT_COUNT_CAP;\n const actorCap = typeConfig.actorCap ?? DEFAULT_ACTOR_CAP;\n\n // Personal notifications are scoped to the recipient so different\n // recipients never share an active doc; shared notifications omit\n // targetUserId by design.\n const activeDocRef = db.collection(activePath).doc(\n buildActiveNotificationDocId({\n category: group.category,\n audienceType: categoryConfig.audienceType,\n targetUserId: group.targetUserId,\n dedupKey,\n }),\n );\n\n return db.runTransaction(async (tx): Promise<'created' | 'updated'> => {\n const existingSnap = await tx.get(activeDocRef);\n\n if (existingSnap.exists) {\n // Increment existing\n const existingData = (existingSnap.data() ?? {}) as Record<string, unknown>;\n const currentCount = (existingData.count as number) || 1;\n const currentActorIds = (existingData.latestActorIds as string[]) || [];\n\n // Merge actor ids (new first, deduped, capped)\n const newActorIds = group.actorIds.filter((id) => !currentActorIds.includes(id));\n const mergedActorIds = [...newActorIds, ...currentActorIds].slice(0, actorCap);\n\n const newCount = Math.min(currentCount + group.count, countCap);\n\n tx.update(activeDocRef, {\n count: newCount,\n latestActorIds: mergedActorIds,\n message: typeConfig.messagePattern(group.metadata, newCount),\n // New activity on an existing notification re-lights the unread badge.\n seenAt: 0,\n updatedAt: Date.now(),\n });\n\n return 'updated';\n } else {\n // Create new notification. A type with no defaultTargetPath is\n // linkless — OMIT the field (Firestore rejects `undefined` values).\n const targetPath =\n typeConfig.defaultTargetPath === undefined\n ? undefined\n : typeof typeConfig.defaultTargetPath === 'function'\n ? typeConfig.defaultTargetPath(group.metadata)\n : typeConfig.defaultTargetPath;\n const now = Date.now();\n\n const newDoc = {\n type: group.type,\n dedupKey,\n category: group.category,\n targetUserId: group.targetUserId,\n title: typeConfig.titlePattern(group.metadata),\n message: typeConfig.messagePattern(group.metadata, group.count),\n count: group.count,\n latestActorIds: group.actorIds.slice(0, actorCap),\n ...(targetPath === undefined ? {} : { targetPath }),\n metadata: group.metadata,\n // Active docs are created unseen so the unread count() predicate matches.\n seenAt: 0,\n createdAt: now,\n updatedAt: now,\n };\n\n tx.set(activeDocRef, newDoc);\n return 'created';\n }\n });\n }),\n );\n\n for (const r of groupResults) {\n if (r === 'created') notificationsCreated++;\n else if (r === 'updated') notificationsUpdated++;\n }\n\n // Delete processed pending docs in batches of 500\n const allDocs = snapshot.docs;\n for (let i = 0; i < allDocs.length; i += 500) {\n const chunk = allDocs.slice(i, i + 500);\n const batch = db.batch();\n chunk.forEach((docSnap) => batch.delete(docSnap.ref));\n await batch.commit();\n }\n\n totalProcessed += snapshot.size;\n\n if (snapshot.size < PROCESSING_BATCH_SIZE) {\n hasMore = false;\n }\n }\n\n return { totalProcessed, notificationsCreated, notificationsUpdated };\n}\n"]}
1
+ {"version":3,"file":"processBatchHelper.js","sourceRoot":"","sources":["../../src/server/processBatchHelper.ts"],"names":[],"mappings":"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","sourcesContent":["import type { NotificationSystemConfig, PendingNotification } from '../types.js';\nimport type { ServerFirestore } from './types.js';\nimport { buildActiveNotificationDocId } from './activeNotificationId.js';\n\nconst DEFAULT_COUNT_CAP = 5000;\nconst DEFAULT_ACTOR_CAP = 5;\nconst PROCESSING_BATCH_SIZE = 500;\nconst MAX_ITERATIONS = 20;\n\ninterface BatchProcessResult {\n totalProcessed: number;\n notificationsCreated: number;\n notificationsUpdated: number;\n}\n\nexport async function processBatchHelper(\n db: ServerFirestore,\n config: NotificationSystemConfig,\n): Promise<BatchProcessResult> {\n const pendingPath = config.pendingCollectionPath ?? 'pendingNotifications';\n const cutoff = Date.now() - 30_000;\n\n let totalProcessed = 0;\n let notificationsCreated = 0;\n let notificationsUpdated = 0;\n let iterations = 0;\n let hasMore = true;\n\n while (hasMore && iterations < MAX_ITERATIONS) {\n iterations++;\n\n const pendingQuery = db\n .collection(pendingPath)\n .where('createdAt', '<=', cutoff)\n .orderBy('createdAt', 'asc')\n .limit(PROCESSING_BATCH_SIZE);\n\n const snapshot = await pendingQuery.get();\n\n if (snapshot.empty) {\n break;\n }\n\n const groups = new Map<\n string,\n {\n type: string;\n category: string;\n targetUserId: string | null;\n metadata: Record<string, unknown>;\n actorIds: string[];\n count: number;\n docs: typeof snapshot.docs;\n }\n >();\n\n for (const docSnap of snapshot.docs) {\n const data = docSnap.data() as Omit<PendingNotification, 'id'>;\n const typeConfig = config.types[data.type];\n if (!typeConfig) continue;\n const categoryConfig = config.categories[data.category];\n if (!categoryConfig) continue;\n\n const dedupKey = typeConfig.dedupKeyPattern(data.metadata);\n const groupKey =\n categoryConfig.audienceType === 'personal'\n ? `${data.category}::${data.targetUserId}::${dedupKey}`\n : `${data.category}::${dedupKey}`;\n\n const existing = groups.get(groupKey);\n if (existing) {\n existing.count++;\n if (!existing.actorIds.includes(data.actorId)) {\n existing.actorIds.push(data.actorId);\n }\n existing.docs.push(docSnap);\n } else {\n groups.set(groupKey, {\n type: data.type,\n category: data.category,\n targetUserId: data.targetUserId,\n metadata: data.metadata,\n actorIds: [data.actorId],\n count: 1,\n docs: [docSnap],\n });\n }\n }\n\n const groupResults = await Promise.all(\n Array.from(groups.values()).map(async (group): Promise<'created' | 'updated' | 'skipped'> => {\n const typeConfig = config.types[group.type];\n if (!typeConfig) return 'skipped';\n\n const categoryConfig = config.categories[group.category];\n if (!categoryConfig) return 'skipped';\n\n const activePath = categoryConfig.activePath;\n const dedupKey = typeConfig.dedupKeyPattern(group.metadata);\n const countCap = typeConfig.countCap ?? DEFAULT_COUNT_CAP;\n const actorCap = typeConfig.actorCap ?? DEFAULT_ACTOR_CAP;\n\n const activeDocRef = db.collection(activePath).doc(\n buildActiveNotificationDocId({\n category: group.category,\n audienceType: categoryConfig.audienceType,\n targetUserId: group.targetUserId,\n dedupKey,\n }),\n );\n\n return db.runTransaction(async (tx): Promise<'created' | 'updated'> => {\n const existingSnap = await tx.get(activeDocRef);\n\n if (existingSnap.exists) {\n const existingData = (existingSnap.data() ?? {}) as Record<string, unknown>;\n const currentCount = (existingData.count as number) || 1;\n const currentActorIds = (existingData.latestActorIds as string[]) || [];\n\n const newActorIds = group.actorIds.filter((id) => !currentActorIds.includes(id));\n const mergedActorIds = [...newActorIds, ...currentActorIds].slice(0, actorCap);\n\n const newCount = Math.min(currentCount + group.count, countCap);\n\n tx.update(activeDocRef, {\n count: newCount,\n latestActorIds: mergedActorIds,\n message: typeConfig.messagePattern(group.metadata, newCount),\n seenAt: 0,\n updatedAt: Date.now(),\n });\n\n return 'updated';\n } else {\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(group.metadata)\n : typeConfig.defaultTargetPath;\n const now = Date.now();\n\n const newDoc = {\n type: group.type,\n dedupKey,\n category: group.category,\n targetUserId: group.targetUserId,\n title: typeConfig.titlePattern(group.metadata),\n message: typeConfig.messagePattern(group.metadata, group.count),\n count: group.count,\n latestActorIds: group.actorIds.slice(0, actorCap),\n ...(targetPath === undefined ? {} : { targetPath }),\n metadata: group.metadata,\n seenAt: 0,\n createdAt: now,\n updatedAt: now,\n };\n\n tx.set(activeDocRef, newDoc);\n return 'created';\n }\n });\n }),\n );\n\n for (const r of groupResults) {\n if (r === 'created') notificationsCreated++;\n else if (r === 'updated') notificationsUpdated++;\n }\n\n const allDocs = snapshot.docs;\n for (let i = 0; i < allDocs.length; i += 500) {\n const chunk = allDocs.slice(i, i + 500);\n const batch = db.batch();\n chunk.forEach((docSnap) => batch.delete(docSnap.ref));\n await batch.commit();\n }\n\n totalProcessed += snapshot.size;\n\n if (snapshot.size < PROCESSING_BATCH_SIZE) {\n hasMore = false;\n }\n }\n\n return { totalProcessed, notificationsCreated, notificationsUpdated };\n}\n"]}
@@ -1,11 +1,3 @@
1
- /**
2
- * Server-side types for notification-core backend helpers.
3
- * These use firebase-admin Firestore types passed in by the calling app.
4
- */
5
- /**
6
- * Firestore interface — matches firebase-admin's Firestore type.
7
- * We accept this instead of importing firebase-admin directly.
8
- */
9
1
  export interface ServerFirestore {
10
2
  collection(path: string): ServerCollectionRef;
11
3
  doc(path: string): ServerDocRef;
@@ -50,7 +42,6 @@ export interface ServerDocRef {
50
42
  merge?: boolean;
51
43
  }): Promise<unknown>;
52
44
  update(data: Record<string, unknown>): Promise<unknown>;
53
- /** Create-if-absent. Rejects with an `already-exists` error (gRPC code 6) if the doc exists. */
54
45
  create(data: Record<string, unknown>): Promise<unknown>;
55
46
  delete(): Promise<unknown>;
56
47
  get(): Promise<ServerDocSnapshot>;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/server/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/server/types.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,2 @@
1
- /**
2
- * Server-side types for notification-core backend helpers.
3
- * These use firebase-admin Firestore types passed in by the calling app.
4
- */
5
1
  export {};
6
2
  //# sourceMappingURL=types.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/server/types.ts"],"names":[],"mappings":"AAAA;;;GAGG","sourcesContent":["/**\n * Server-side types for notification-core backend helpers.\n * These use firebase-admin Firestore types passed in by the calling app.\n */\n\n/**\n * Firestore interface — matches firebase-admin's Firestore type.\n * We accept this instead of importing firebase-admin directly.\n */\nexport interface ServerFirestore {\n collection(path: string): ServerCollectionRef;\n doc(path: string): ServerDocRef;\n batch(): ServerWriteBatch;\n runTransaction<T>(updateFunction: (transaction: ServerTransaction) => Promise<T>): Promise<T>;\n}\n\nexport interface ServerTransaction {\n get(ref: ServerDocRef): Promise<ServerDocSnapshot>;\n set(\n ref: ServerDocRef,\n data: Record<string, unknown>,\n options?: { merge?: boolean },\n ): ServerTransaction;\n update(ref: ServerDocRef, data: Record<string, unknown>): ServerTransaction;\n delete(ref: ServerDocRef): ServerTransaction;\n}\n\nexport interface ServerCollectionRef {\n doc(id?: string): ServerDocRef;\n where(field: string, op: string, value: unknown): ServerQuery;\n orderBy(field: string, direction?: 'asc' | 'desc'): ServerQuery;\n limit(n: number): ServerQuery;\n add(data: Record<string, unknown>): Promise<ServerDocRef>;\n}\n\nexport interface ServerQuery {\n where(field: string, op: string, value: unknown): ServerQuery;\n orderBy(field: string, direction?: 'asc' | 'desc'): ServerQuery;\n limit(n: number): ServerQuery;\n get(): Promise<ServerQuerySnapshot>;\n}\n\nexport interface ServerQuerySnapshot {\n empty: boolean;\n size: number;\n docs: ServerDocSnapshot[];\n}\n\nexport interface ServerDocSnapshot {\n id: string;\n exists: boolean;\n data(): Record<string, unknown> | undefined;\n ref: ServerDocRef;\n}\n\nexport interface ServerDocRef {\n id: string;\n set(data: Record<string, unknown>, options?: { merge?: boolean }): Promise<unknown>;\n update(data: Record<string, unknown>): Promise<unknown>;\n /** Create-if-absent. Rejects with an `already-exists` error (gRPC code 6) if the doc exists. */\n create(data: Record<string, unknown>): Promise<unknown>;\n delete(): Promise<unknown>;\n get(): Promise<ServerDocSnapshot>;\n}\n\nexport interface ServerWriteBatch {\n set(\n ref: ServerDocRef,\n data: Record<string, unknown>,\n options?: { merge?: boolean },\n ): ServerWriteBatch;\n update(ref: ServerDocRef, data: Record<string, unknown>): ServerWriteBatch;\n delete(ref: ServerDocRef): ServerWriteBatch;\n commit(): Promise<unknown>;\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/server/types.ts"],"names":[],"mappings":"","sourcesContent":["export interface ServerFirestore {\n collection(path: string): ServerCollectionRef;\n doc(path: string): ServerDocRef;\n batch(): ServerWriteBatch;\n runTransaction<T>(updateFunction: (transaction: ServerTransaction) => Promise<T>): Promise<T>;\n}\n\nexport interface ServerTransaction {\n get(ref: ServerDocRef): Promise<ServerDocSnapshot>;\n set(\n ref: ServerDocRef,\n data: Record<string, unknown>,\n options?: { merge?: boolean },\n ): ServerTransaction;\n update(ref: ServerDocRef, data: Record<string, unknown>): ServerTransaction;\n delete(ref: ServerDocRef): ServerTransaction;\n}\n\nexport interface ServerCollectionRef {\n doc(id?: string): ServerDocRef;\n where(field: string, op: string, value: unknown): ServerQuery;\n orderBy(field: string, direction?: 'asc' | 'desc'): ServerQuery;\n limit(n: number): ServerQuery;\n add(data: Record<string, unknown>): Promise<ServerDocRef>;\n}\n\nexport interface ServerQuery {\n where(field: string, op: string, value: unknown): ServerQuery;\n orderBy(field: string, direction?: 'asc' | 'desc'): ServerQuery;\n limit(n: number): ServerQuery;\n get(): Promise<ServerQuerySnapshot>;\n}\n\nexport interface ServerQuerySnapshot {\n empty: boolean;\n size: number;\n docs: ServerDocSnapshot[];\n}\n\nexport interface ServerDocSnapshot {\n id: string;\n exists: boolean;\n data(): Record<string, unknown> | undefined;\n ref: ServerDocRef;\n}\n\nexport interface ServerDocRef {\n id: string;\n set(data: Record<string, unknown>, options?: { merge?: boolean }): Promise<unknown>;\n update(data: Record<string, unknown>): Promise<unknown>;\n create(data: Record<string, unknown>): Promise<unknown>;\n delete(): Promise<unknown>;\n get(): Promise<ServerDocSnapshot>;\n}\n\nexport interface ServerWriteBatch {\n set(\n ref: ServerDocRef,\n data: Record<string, unknown>,\n options?: { merge?: boolean },\n ): ServerWriteBatch;\n update(ref: ServerDocRef, data: Record<string, unknown>): ServerWriteBatch;\n delete(ref: ServerDocRef): ServerWriteBatch;\n commit(): Promise<unknown>;\n}\n"]}