@ttt-productions/notification-core 0.15.10 → 0.15.11

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 (55) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/react/components/NotificationHistoryList.d.ts +1 -1
  5. package/dist/react/components/NotificationHistoryList.d.ts.map +1 -1
  6. package/dist/react/components/NotificationHistoryList.js +6 -6
  7. package/dist/react/components/NotificationHistoryList.js.map +1 -1
  8. package/dist/react/components/NotificationList.d.ts +1 -1
  9. package/dist/react/components/NotificationList.d.ts.map +1 -1
  10. package/dist/react/components/NotificationList.js +8 -22
  11. package/dist/react/components/NotificationList.js.map +1 -1
  12. package/dist/react/components/NotificationUnreadBadge.d.ts +1 -1
  13. package/dist/react/components/NotificationUnreadBadge.d.ts.map +1 -1
  14. package/dist/react/components/NotificationUnreadBadge.js +1 -2
  15. package/dist/react/components/NotificationUnreadBadge.js.map +1 -1
  16. package/dist/react/hooks/useActiveNotifications.d.ts +1 -1
  17. package/dist/react/hooks/useActiveNotifications.d.ts.map +1 -1
  18. package/dist/react/hooks/useActiveNotifications.js +2 -3
  19. package/dist/react/hooks/useActiveNotifications.js.map +1 -1
  20. package/dist/react/hooks/useArchiveAllNotifications.d.ts +1 -1
  21. package/dist/react/hooks/useArchiveAllNotifications.d.ts.map +1 -1
  22. package/dist/react/hooks/useArchiveAllNotifications.js +7 -3
  23. package/dist/react/hooks/useArchiveAllNotifications.js.map +1 -1
  24. package/dist/react/hooks/useArchiveNotification.d.ts +1 -1
  25. package/dist/react/hooks/useArchiveNotification.d.ts.map +1 -1
  26. package/dist/react/hooks/useArchiveNotification.js +7 -3
  27. package/dist/react/hooks/useArchiveNotification.js.map +1 -1
  28. package/dist/react/hooks/useNotificationHistory.d.ts +1 -1
  29. package/dist/react/hooks/useNotificationHistory.d.ts.map +1 -1
  30. package/dist/react/hooks/useNotificationHistory.js +2 -3
  31. package/dist/react/hooks/useNotificationHistory.js.map +1 -1
  32. package/dist/react/hooks/useUnreadCount.d.ts +2 -10
  33. package/dist/react/hooks/useUnreadCount.d.ts.map +1 -1
  34. package/dist/react/hooks/useUnreadCount.js +6 -16
  35. package/dist/react/hooks/useUnreadCount.js.map +1 -1
  36. package/dist/server/delivery-ledger.d.ts +1 -14
  37. package/dist/server/delivery-ledger.d.ts.map +1 -1
  38. package/dist/server/delivery-ledger.js +4 -6
  39. package/dist/server/delivery-ledger.js.map +1 -1
  40. package/dist/server/index.d.ts +1 -1
  41. package/dist/server/index.d.ts.map +1 -1
  42. package/dist/server/index.js.map +1 -1
  43. package/dist/types.d.ts +5 -65
  44. package/dist/types.d.ts.map +1 -1
  45. package/dist/types.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/styles/notifications.css +4 -6
  48. package/dist/react/components/notification-type-icon.d.ts +0 -11
  49. package/dist/react/components/notification-type-icon.d.ts.map +0 -1
  50. package/dist/react/components/notification-type-icon.js +0 -12
  51. package/dist/react/components/notification-type-icon.js.map +0 -1
  52. package/dist/react/query-keys.d.ts +0 -5
  53. package/dist/react/query-keys.d.ts.map +0 -1
  54. package/dist/react/query-keys.js +0 -15
  55. package/dist/react/query-keys.js.map +0 -1
@@ -13,5 +13,5 @@ import type { NotificationHistoryItem, UseNotificationHistoryOptions } from '../
13
13
  * list. Owner-only (user) / admin-only (admin) reads are enforced by Firestore
14
14
  * rules — this hook performs no writes.
15
15
  */
16
- export declare function useNotificationHistory({ config, userId, category, enabled, pageSize, staleTime, queryKeys, }: UseNotificationHistoryOptions): import("@ttt-productions/query-core/react").UseFirestorePaginatedResult<NotificationHistoryItem>;
16
+ export declare function useNotificationHistory({ config, userId, category, enabled, pageSize, staleTime, }: UseNotificationHistoryOptions): import("@ttt-productions/query-core/react").UseFirestorePaginatedResult<NotificationHistoryItem>;
17
17
  //# sourceMappingURL=useNotificationHistory.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useNotificationHistory.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useNotificationHistory.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAEV,uBAAuB,EACvB,6BAA6B,EAC9B,MAAM,gBAAgB,CAAC;AAMxB;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CAAC,EACrC,MAAM,EACN,MAAM,EACN,QAAQ,EACR,OAAc,EACd,QAA4B,EAC5B,SAA8B,EAC9B,SAAS,GACV,EAAE,6BAA6B,oGA4B/B"}
1
+ {"version":3,"file":"useNotificationHistory.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useNotificationHistory.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAEV,uBAAuB,EACvB,6BAA6B,EAC9B,MAAM,gBAAgB,CAAC;AAKxB;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CAAC,EACrC,MAAM,EACN,MAAM,EACN,QAAQ,EACR,OAAc,EACd,QAA4B,EAC5B,SAA8B,GAC/B,EAAE,6BAA6B,oGA4B/B"}
@@ -1,7 +1,6 @@
1
1
  'use client';
2
2
  import { useFirestorePaginated } from '@ttt-productions/query-core/react';
3
3
  import { orderBy } from 'firebase/firestore';
4
- import { resolveNotificationQueryKeys } from '../query-keys.js';
5
4
  const DEFAULT_PAGE_SIZE = 20;
6
5
  const DEFAULT_STALE_TIME = 60_000;
7
6
  /**
@@ -18,7 +17,7 @@ const DEFAULT_STALE_TIME = 60_000;
18
17
  * list. Owner-only (user) / admin-only (admin) reads are enforced by Firestore
19
18
  * rules — this hook performs no writes.
20
19
  */
21
- export function useNotificationHistory({ config, userId, category, enabled = true, pageSize = DEFAULT_PAGE_SIZE, staleTime = DEFAULT_STALE_TIME, queryKeys, }) {
20
+ export function useNotificationHistory({ config, userId, category, enabled = true, pageSize = DEFAULT_PAGE_SIZE, staleTime = DEFAULT_STALE_TIME, }) {
22
21
  const categoryConfig = config.categories[category];
23
22
  if (!categoryConfig) {
24
23
  throw new Error(`[notification-core] Unknown category: ${category}`);
@@ -27,7 +26,7 @@ export function useNotificationHistory({ config, userId, category, enabled = tru
27
26
  const constraints = [orderBy('archivedAt', 'desc')];
28
27
  return useFirestorePaginated({
29
28
  collectionPath,
30
- queryKey: [...resolveNotificationQueryKeys(queryKeys).history(category, userId), { pageSize }],
29
+ queryKey: ['notifications', 'history', category, userId, { pageSize }],
31
30
  constraints,
32
31
  pageSize,
33
32
  enabled: enabled && !!userId,
@@ -1 +1 @@
1
- {"version":3,"file":"useNotificationHistory.js","sourceRoot":"","sources":["../../../src/react/hooks/useNotificationHistory.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,qBAAqB,EAAE,MAAM,mCAAmC,CAAC;AAC1E,OAAO,EAAE,OAAO,EAAwB,MAAM,oBAAoB,CAAC;AAMnE,OAAO,EAAE,4BAA4B,EAAE,MAAM,kBAAkB,CAAC;AAEhE,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAC7B,MAAM,kBAAkB,GAAG,MAAM,CAAC;AAElC;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,sBAAsB,CAAC,EACrC,MAAM,EACN,MAAM,EACN,QAAQ,EACR,OAAO,GAAG,IAAI,EACd,QAAQ,GAAG,iBAAiB,EAC5B,SAAS,GAAG,kBAAkB,EAC9B,SAAS,GACqB;IAC9B,MAAM,cAAc,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;IACnD,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,MAAM,IAAI,KAAK,CAAC,yCAAyC,QAAQ,EAAE,CAAC,CAAC;IACvE,CAAC;IAED,MAAM,cAAc,GAAG,cAAc,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;IAE1D,MAAM,WAAW,GAAsB,CAAC,OAAO,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC,CAAC;IAEvE,OAAO,qBAAqB,CAA0B;QACpD,cAAc;QACd,QAAQ,EAAE,CAAC,GAAG,4BAA4B,CAAC,SAAS,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,MAAM,CAAC,EAAE,EAAE,QAAQ,EAAE,CAAC;QAC9F,WAAW;QACX,QAAQ;QACR,OAAO,EAAE,OAAO,IAAI,CAAC,CAAC,MAAM;QAC5B,SAAS;QACT,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE;YACf,MAAM,QAAQ,GAAG,CAAC,IAAI,CAAC,gBAAgB,IAAI,EAAE,CAAoB,CAAC;YAClE,OAAO;gBACL,GAAG,QAAQ;gBACX,kEAAkE;gBAClE,mCAAmC;gBACnC,mBAAmB,EAAE,IAAI,CAAC,EAAE;gBAC5B,UAAU,EAAG,IAAI,CAAC,UAAiC,IAAI,QAAQ,CAAC,SAAS,IAAI,CAAC;aAC/E,CAAC;QACJ,CAAC;KACF,CAAC,CAAC;AACL,CAAC","sourcesContent":["'use client';\n\nimport { useFirestorePaginated } from '@ttt-productions/query-core/react';\nimport { orderBy, type QueryConstraint } from 'firebase/firestore';\nimport type {\n NotificationDoc,\n NotificationHistoryItem,\n UseNotificationHistoryOptions,\n} from '../../types.js';\nimport { resolveNotificationQueryKeys } from '../query-keys.js';\n\nconst DEFAULT_PAGE_SIZE = 20;\nconst DEFAULT_STALE_TIME = 60_000;\n\n/**\n * Paginated read of ARCHIVED notification history (the two-tier system's history\n * tier). Personal history lives under a per-user path\n * (`userProfiles/{uid}/notificationHistory`) and shared/admin history under a\n * top-level collection (`adminNotificationHistory`) — both resolved from the\n * category's `historyPath`, so no `where` clause is needed either way. Rows are\n * immutable and ordered newest-first by `archivedAt`.\n *\n * Each history doc is a wrapper (`archivedSnapshot` + archive metadata + a\n * native-TTL `expireAt`); the `select` mapper flattens it into a\n * {@link NotificationHistoryItem} so the read surface renders like the active\n * list. Owner-only (user) / admin-only (admin) reads are enforced by Firestore\n * rules — this hook performs no writes.\n */\nexport function useNotificationHistory({\n config,\n userId,\n category,\n enabled = true,\n pageSize = DEFAULT_PAGE_SIZE,\n staleTime = DEFAULT_STALE_TIME,\n queryKeys,\n}: UseNotificationHistoryOptions) {\n const categoryConfig = config.categories[category];\n if (!categoryConfig) {\n throw new Error(`[notification-core] Unknown category: ${category}`);\n }\n\n const collectionPath = categoryConfig.historyPath(userId);\n\n const constraints: QueryConstraint[] = [orderBy('archivedAt', 'desc')];\n\n return useFirestorePaginated<NotificationHistoryItem>({\n collectionPath,\n queryKey: [...resolveNotificationQueryKeys(queryKeys).history(category, userId), { pageSize }],\n constraints,\n pageSize,\n enabled: enabled && !!userId,\n staleTime,\n select: (data) => {\n const snapshot = (data.archivedSnapshot ?? {}) as NotificationDoc;\n return {\n ...snapshot,\n // The archive occurrence id is the stable history-doc key; expose\n // archivedAt for ordering/display.\n archiveOccurrenceId: data.id,\n archivedAt: (data.archivedAt as number | undefined) ?? snapshot.updatedAt ?? 0,\n };\n },\n });\n}\n"]}
1
+ {"version":3,"file":"useNotificationHistory.js","sourceRoot":"","sources":["../../../src/react/hooks/useNotificationHistory.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,qBAAqB,EAAE,MAAM,mCAAmC,CAAC;AAC1E,OAAO,EAAE,OAAO,EAAwB,MAAM,oBAAoB,CAAC;AAOnE,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAC7B,MAAM,kBAAkB,GAAG,MAAM,CAAC;AAElC;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,sBAAsB,CAAC,EACrC,MAAM,EACN,MAAM,EACN,QAAQ,EACR,OAAO,GAAG,IAAI,EACd,QAAQ,GAAG,iBAAiB,EAC5B,SAAS,GAAG,kBAAkB,GACA;IAC9B,MAAM,cAAc,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;IACnD,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,MAAM,IAAI,KAAK,CAAC,yCAAyC,QAAQ,EAAE,CAAC,CAAC;IACvE,CAAC;IAED,MAAM,cAAc,GAAG,cAAc,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;IAE1D,MAAM,WAAW,GAAsB,CAAC,OAAO,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC,CAAC;IAEvE,OAAO,qBAAqB,CAA0B;QACpD,cAAc;QACd,QAAQ,EAAE,CAAC,eAAe,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,CAAC;QACtE,WAAW;QACX,QAAQ;QACR,OAAO,EAAE,OAAO,IAAI,CAAC,CAAC,MAAM;QAC5B,SAAS;QACT,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE;YACf,MAAM,QAAQ,GAAG,CAAC,IAAI,CAAC,gBAAgB,IAAI,EAAE,CAAoB,CAAC;YAClE,OAAO;gBACL,GAAG,QAAQ;gBACX,kEAAkE;gBAClE,mCAAmC;gBACnC,mBAAmB,EAAE,IAAI,CAAC,EAAE;gBAC5B,UAAU,EAAG,IAAI,CAAC,UAAiC,IAAI,QAAQ,CAAC,SAAS,IAAI,CAAC;aAC/E,CAAC;QACJ,CAAC;KACF,CAAC,CAAC;AACL,CAAC","sourcesContent":["'use client';\n\nimport { useFirestorePaginated } from '@ttt-productions/query-core/react';\nimport { orderBy, type QueryConstraint } from 'firebase/firestore';\nimport type {\n NotificationDoc,\n NotificationHistoryItem,\n UseNotificationHistoryOptions,\n} from '../../types.js';\n\nconst DEFAULT_PAGE_SIZE = 20;\nconst DEFAULT_STALE_TIME = 60_000;\n\n/**\n * Paginated read of ARCHIVED notification history (the two-tier system's history\n * tier). Personal history lives under a per-user path\n * (`userProfiles/{uid}/notificationHistory`) and shared/admin history under a\n * top-level collection (`adminNotificationHistory`) — both resolved from the\n * category's `historyPath`, so no `where` clause is needed either way. Rows are\n * immutable and ordered newest-first by `archivedAt`.\n *\n * Each history doc is a wrapper (`archivedSnapshot` + archive metadata + a\n * native-TTL `expireAt`); the `select` mapper flattens it into a\n * {@link NotificationHistoryItem} so the read surface renders like the active\n * list. Owner-only (user) / admin-only (admin) reads are enforced by Firestore\n * rules — this hook performs no writes.\n */\nexport function useNotificationHistory({\n config,\n userId,\n category,\n enabled = true,\n pageSize = DEFAULT_PAGE_SIZE,\n staleTime = DEFAULT_STALE_TIME,\n}: UseNotificationHistoryOptions) {\n const categoryConfig = config.categories[category];\n if (!categoryConfig) {\n throw new Error(`[notification-core] Unknown category: ${category}`);\n }\n\n const collectionPath = categoryConfig.historyPath(userId);\n\n const constraints: QueryConstraint[] = [orderBy('archivedAt', 'desc')];\n\n return useFirestorePaginated<NotificationHistoryItem>({\n collectionPath,\n queryKey: ['notifications', 'history', category, userId, { pageSize }],\n constraints,\n pageSize,\n enabled: enabled && !!userId,\n staleTime,\n select: (data) => {\n const snapshot = (data.archivedSnapshot ?? {}) as NotificationDoc;\n return {\n ...snapshot,\n // The archive occurrence id is the stable history-doc key; expose\n // archivedAt for ordering/display.\n archiveOccurrenceId: data.id,\n archivedAt: (data.archivedAt as number | undefined) ?? snapshot.updatedAt ?? 0,\n };\n },\n });\n}\n"]}
@@ -6,9 +6,7 @@ import type { UseUnreadCountOptions } from '../../types.js';
6
6
  *
7
7
  * - **personal** categories count **unseen** active items (`seenAt == 0`),
8
8
  * scoped to the caller (`targetUserId == uid`);
9
- * - **shared** categories that declare `sharedSeenState` count the items no
10
- * member has seen (`seenAt == 0`) — the members share one seen state;
11
- * - other **shared** categories have no seen state, so the indicator is
9
+ * - **shared** categories have no per-admin seen state, so the indicator is
12
10
  * existence-based — a count of all active items, with no `seenAt` predicate.
13
11
  *
14
12
  * The personal `seenAt == 0` predicate needs a composite index, captured as an
@@ -23,7 +21,7 @@ import type { UseUnreadCountOptions } from '../../types.js';
23
21
  * });
24
22
  * ```
25
23
  */
26
- export declare function useUnreadCount({ config, userId, category, enabled, refetchInterval, countLimit, queryKeys, }: UseUnreadCountOptions): {
24
+ export declare function useUnreadCount({ config, userId, category, enabled, refetchInterval, countLimit, }: UseUnreadCountOptions): {
27
25
  error: Error;
28
26
  isError: true;
29
27
  isPending: false;
@@ -50,7 +48,6 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
50
48
  fetchStatus: import("@tanstack/query-core").FetchStatus;
51
49
  promise: Promise<number>;
52
50
  count: number;
53
- hasAnswer: boolean;
54
51
  hasMore: boolean;
55
52
  } | {
56
53
  error: null;
@@ -79,7 +76,6 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
79
76
  fetchStatus: import("@tanstack/query-core").FetchStatus;
80
77
  promise: Promise<number>;
81
78
  count: number;
82
- hasAnswer: boolean;
83
79
  hasMore: boolean;
84
80
  } | {
85
81
  error: Error;
@@ -108,7 +104,6 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
108
104
  fetchStatus: import("@tanstack/query-core").FetchStatus;
109
105
  promise: Promise<number>;
110
106
  count: number;
111
- hasAnswer: boolean;
112
107
  hasMore: boolean;
113
108
  } | {
114
109
  error: null;
@@ -137,7 +132,6 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
137
132
  fetchStatus: import("@tanstack/query-core").FetchStatus;
138
133
  promise: Promise<number>;
139
134
  count: number;
140
- hasAnswer: boolean;
141
135
  hasMore: boolean;
142
136
  } | {
143
137
  error: null;
@@ -166,7 +160,6 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
166
160
  fetchStatus: import("@tanstack/query-core").FetchStatus;
167
161
  promise: Promise<number>;
168
162
  count: number;
169
- hasAnswer: boolean;
170
163
  hasMore: boolean;
171
164
  } | {
172
165
  isError: false;
@@ -195,7 +188,6 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
195
188
  fetchStatus: import("@tanstack/query-core").FetchStatus;
196
189
  promise: Promise<number>;
197
190
  count: number;
198
- hasAnswer: boolean;
199
191
  hasMore: boolean;
200
192
  };
201
193
  //# sourceMappingURL=useUnreadCount.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useUnreadCount.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useUnreadCount.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAM5D;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,cAAc,CAAC,EAC7B,MAAM,EACN,MAAM,EACN,QAAQ,EACR,OAAc,EACd,eAA0C,EAC1C,UAAgC,EAChC,SAAS,GACV,EAAE,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAoCvB"}
1
+ {"version":3,"file":"useUnreadCount.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useUnreadCount.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAK5D;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,cAAc,CAAC,EAC7B,MAAM,EACN,MAAM,EACN,QAAQ,EACR,OAAc,EACd,eAA0C,EAC1C,UAAgC,GACjC,EAAE,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA8BvB"}
@@ -1,7 +1,6 @@
1
1
  'use client';
2
2
  import { where } from 'firebase/firestore';
3
3
  import { useFirestoreCount } from '@ttt-productions/query-core/react';
4
- import { resolveNotificationQueryKeys } from '../query-keys.js';
5
4
  const DEFAULT_REFETCH_INTERVAL = 30_000;
6
5
  const DEFAULT_COUNT_LIMIT = 99;
7
6
  /**
@@ -11,9 +10,7 @@ const DEFAULT_COUNT_LIMIT = 99;
11
10
  *
12
11
  * - **personal** categories count **unseen** active items (`seenAt == 0`),
13
12
  * scoped to the caller (`targetUserId == uid`);
14
- * - **shared** categories that declare `sharedSeenState` count the items no
15
- * member has seen (`seenAt == 0`) — the members share one seen state;
16
- * - other **shared** categories have no seen state, so the indicator is
13
+ * - **shared** categories have no per-admin seen state, so the indicator is
17
14
  * existence-based — a count of all active items, with no `seenAt` predicate.
18
15
  *
19
16
  * The personal `seenAt == 0` predicate needs a composite index, captured as an
@@ -28,34 +25,27 @@ const DEFAULT_COUNT_LIMIT = 99;
28
25
  * });
29
26
  * ```
30
27
  */
31
- export function useUnreadCount({ config, userId, category, enabled = true, refetchInterval = DEFAULT_REFETCH_INTERVAL, countLimit = DEFAULT_COUNT_LIMIT, queryKeys, }) {
28
+ export function useUnreadCount({ config, userId, category, enabled = true, refetchInterval = DEFAULT_REFETCH_INTERVAL, countLimit = DEFAULT_COUNT_LIMIT, }) {
32
29
  const categoryConfig = config.categories[category];
33
30
  if (!categoryConfig) {
34
31
  throw new Error(`[notification-core] Unknown category: ${category}`);
35
32
  }
36
33
  const collectionPath = categoryConfig.activePath;
37
34
  const isPersonal = categoryConfig.audienceType === 'personal';
38
- const constraints = isPersonal
39
- ? userId
40
- ? [where('targetUserId', '==', userId), where('seenAt', '==', 0)]
41
- : []
42
- : categoryConfig.sharedSeenState
43
- ? [where('seenAt', '==', 0)]
44
- : [];
35
+ const constraints = isPersonal && userId
36
+ ? [where('targetUserId', '==', userId), where('seenAt', '==', 0)]
37
+ : [];
45
38
  const { data, ...rest } = useFirestoreCount({
46
39
  collectionPath,
47
- queryKey: resolveNotificationQueryKeys(queryKeys).unreadCount(category, userId),
40
+ queryKey: ['notifications', 'unread-count', category, userId],
48
41
  constraints,
49
42
  enabled: enabled && !!userId,
50
43
  staleTime: refetchInterval,
51
44
  refetchInterval,
52
45
  });
53
- // `count` reads 0 until the count has answered; `hasAnswer` says whether it has, so a consumer
54
- // never shows that 0 as the answer (FRONTEND-206). A failed refresh keeps the last answer.
55
46
  const count = data ?? 0;
56
47
  return {
57
48
  count,
58
- hasAnswer: data !== undefined,
59
49
  hasMore: count > countLimit,
60
50
  ...rest,
61
51
  };
@@ -1 +1 @@
1
- {"version":3,"file":"useUnreadCount.js","sourceRoot":"","sources":["../../../src/react/hooks/useUnreadCount.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,KAAK,EAAwB,MAAM,oBAAoB,CAAC;AACjE,OAAO,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AAEtE,OAAO,EAAE,4BAA4B,EAAE,MAAM,kBAAkB,CAAC;AAEhE,MAAM,wBAAwB,GAAG,MAAM,CAAC;AACxC,MAAM,mBAAmB,GAAG,EAAE,CAAC;AAE/B;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,UAAU,cAAc,CAAC,EAC7B,MAAM,EACN,MAAM,EACN,QAAQ,EACR,OAAO,GAAG,IAAI,EACd,eAAe,GAAG,wBAAwB,EAC1C,UAAU,GAAG,mBAAmB,EAChC,SAAS,GACa;IACtB,MAAM,cAAc,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;IACnD,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,MAAM,IAAI,KAAK,CAAC,yCAAyC,QAAQ,EAAE,CAAC,CAAC;IACvE,CAAC;IAED,MAAM,cAAc,GAAG,cAAc,CAAC,UAAU,CAAC;IACjD,MAAM,UAAU,GAAG,cAAc,CAAC,YAAY,KAAK,UAAU,CAAC;IAE9D,MAAM,WAAW,GAAsB,UAAU;QAC/C,CAAC,CAAC,MAAM;YACN,CAAC,CAAC,CAAC,KAAK,CAAC,cAAc,EAAE,IAAI,EAAE,MAAM,CAAC,EAAE,KAAK,CAAC,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;YACjE,CAAC,CAAC,EAAE;QACN,CAAC,CAAC,cAAc,CAAC,eAAe;YAC9B,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;YAC5B,CAAC,CAAC,EAAE,CAAC;IAET,MAAM,EAAE,IAAI,EAAE,GAAG,IAAI,EAAE,GAAG,iBAAiB,CAAC;QAC1C,cAAc;QACd,QAAQ,EAAE,4BAA4B,CAAC,SAAS,CAAC,CAAC,WAAW,CAAC,QAAQ,EAAE,MAAM,CAAC;QAC/E,WAAW;QACX,OAAO,EAAE,OAAO,IAAI,CAAC,CAAC,MAAM;QAC5B,SAAS,EAAE,eAAe;QAC1B,eAAe;KAChB,CAAC,CAAC;IAEH,+FAA+F;IAC/F,2FAA2F;IAC3F,MAAM,KAAK,GAAG,IAAI,IAAI,CAAC,CAAC;IAExB,OAAO;QACL,KAAK;QACL,SAAS,EAAE,IAAI,KAAK,SAAS;QAC7B,OAAO,EAAE,KAAK,GAAG,UAAU;QAC3B,GAAG,IAAI;KACR,CAAC;AACJ,CAAC","sourcesContent":["'use client';\n\nimport { where, type QueryConstraint } from 'firebase/firestore';\nimport { useFirestoreCount } from '@ttt-productions/query-core/react';\nimport type { UseUnreadCountOptions } from '../../types.js';\nimport { resolveNotificationQueryKeys } from '../query-keys.js';\n\nconst DEFAULT_REFETCH_INTERVAL = 30_000;\nconst DEFAULT_COUNT_LIMIT = 99;\n\n/**\n * Unread-count badge backed by a Firestore `count()` aggregation (a server-side\n * count, not a doc fetch), via query-core's `useFirestoreCount`. Polls for cost\n * control; displays capped (`hasMore` → `99+`).\n *\n * - **personal** categories count **unseen** active items (`seenAt == 0`),\n * scoped to the caller (`targetUserId == uid`);\n * - **shared** categories that declare `sharedSeenState` count the items no\n * member has seen (`seenAt == 0`) — the members share one seen state;\n * - other **shared** categories have no seen state, so the indicator is\n * existence-based — a count of all active items, with no `seenAt` predicate.\n *\n * The personal `seenAt == 0` predicate needs a composite index, captured as an\n * app-side index step.\n *\n * @example\n * ```tsx\n * const { count, isLoading } = useUnreadCount({\n * config: TTT_NOTIFICATION_CONFIG,\n * userId: user.uid,\n * category: 'user',\n * });\n * ```\n */\nexport function useUnreadCount({\n config,\n userId,\n category,\n enabled = true,\n refetchInterval = DEFAULT_REFETCH_INTERVAL,\n countLimit = DEFAULT_COUNT_LIMIT,\n queryKeys,\n}: UseUnreadCountOptions) {\n const categoryConfig = config.categories[category];\n if (!categoryConfig) {\n throw new Error(`[notification-core] Unknown category: ${category}`);\n }\n\n const collectionPath = categoryConfig.activePath;\n const isPersonal = categoryConfig.audienceType === 'personal';\n\n const constraints: QueryConstraint[] = isPersonal\n ? userId\n ? [where('targetUserId', '==', userId), where('seenAt', '==', 0)]\n : []\n : categoryConfig.sharedSeenState\n ? [where('seenAt', '==', 0)]\n : [];\n\n const { data, ...rest } = useFirestoreCount({\n collectionPath,\n queryKey: resolveNotificationQueryKeys(queryKeys).unreadCount(category, userId),\n constraints,\n enabled: enabled && !!userId,\n staleTime: refetchInterval,\n refetchInterval,\n });\n\n // `count` reads 0 until the count has answered; `hasAnswer` says whether it has, so a consumer\n // never shows that 0 as the answer (FRONTEND-206). A failed refresh keeps the last answer.\n const count = data ?? 0;\n\n return {\n count,\n hasAnswer: data !== undefined,\n hasMore: count > countLimit,\n ...rest,\n };\n}\n"]}
1
+ {"version":3,"file":"useUnreadCount.js","sourceRoot":"","sources":["../../../src/react/hooks/useUnreadCount.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,KAAK,EAAwB,MAAM,oBAAoB,CAAC;AACjE,OAAO,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AAGtE,MAAM,wBAAwB,GAAG,MAAM,CAAC;AACxC,MAAM,mBAAmB,GAAG,EAAE,CAAC;AAE/B;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,cAAc,CAAC,EAC7B,MAAM,EACN,MAAM,EACN,QAAQ,EACR,OAAO,GAAG,IAAI,EACd,eAAe,GAAG,wBAAwB,EAC1C,UAAU,GAAG,mBAAmB,GACV;IACtB,MAAM,cAAc,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;IACnD,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,MAAM,IAAI,KAAK,CAAC,yCAAyC,QAAQ,EAAE,CAAC,CAAC;IACvE,CAAC;IAED,MAAM,cAAc,GAAG,cAAc,CAAC,UAAU,CAAC;IACjD,MAAM,UAAU,GAAG,cAAc,CAAC,YAAY,KAAK,UAAU,CAAC;IAE9D,MAAM,WAAW,GACf,UAAU,IAAI,MAAM;QAClB,CAAC,CAAC,CAAC,KAAK,CAAC,cAAc,EAAE,IAAI,EAAE,MAAM,CAAC,EAAE,KAAK,CAAC,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;QACjE,CAAC,CAAC,EAAE,CAAC;IAET,MAAM,EAAE,IAAI,EAAE,GAAG,IAAI,EAAE,GAAG,iBAAiB,CAAC;QAC1C,cAAc;QACd,QAAQ,EAAE,CAAC,eAAe,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,CAAC;QAC7D,WAAW;QACX,OAAO,EAAE,OAAO,IAAI,CAAC,CAAC,MAAM;QAC5B,SAAS,EAAE,eAAe;QAC1B,eAAe;KAChB,CAAC,CAAC;IAEH,MAAM,KAAK,GAAG,IAAI,IAAI,CAAC,CAAC;IAExB,OAAO;QACL,KAAK;QACL,OAAO,EAAE,KAAK,GAAG,UAAU;QAC3B,GAAG,IAAI;KACR,CAAC;AACJ,CAAC","sourcesContent":["'use client';\n\nimport { where, type QueryConstraint } from 'firebase/firestore';\nimport { useFirestoreCount } from '@ttt-productions/query-core/react';\nimport type { UseUnreadCountOptions } from '../../types.js';\n\nconst DEFAULT_REFETCH_INTERVAL = 30_000;\nconst DEFAULT_COUNT_LIMIT = 99;\n\n/**\n * Unread-count badge backed by a Firestore `count()` aggregation (a server-side\n * count, not a doc fetch), via query-core's `useFirestoreCount`. Polls for cost\n * control; displays capped (`hasMore` → `99+`).\n *\n * - **personal** categories count **unseen** active items (`seenAt == 0`),\n * scoped to the caller (`targetUserId == uid`);\n * - **shared** categories have no per-admin seen state, so the indicator is\n * existence-based — a count of all active items, with no `seenAt` predicate.\n *\n * The personal `seenAt == 0` predicate needs a composite index, captured as an\n * app-side index step.\n *\n * @example\n * ```tsx\n * const { count, isLoading } = useUnreadCount({\n * config: TTT_NOTIFICATION_CONFIG,\n * userId: user.uid,\n * category: 'user',\n * });\n * ```\n */\nexport function useUnreadCount({\n config,\n userId,\n category,\n enabled = true,\n refetchInterval = DEFAULT_REFETCH_INTERVAL,\n countLimit = DEFAULT_COUNT_LIMIT,\n}: UseUnreadCountOptions) {\n const categoryConfig = config.categories[category];\n if (!categoryConfig) {\n throw new Error(`[notification-core] Unknown category: ${category}`);\n }\n\n const collectionPath = categoryConfig.activePath;\n const isPersonal = categoryConfig.audienceType === 'personal';\n\n const constraints: QueryConstraint[] =\n isPersonal && userId\n ? [where('targetUserId', '==', userId), where('seenAt', '==', 0)]\n : [];\n\n const { data, ...rest } = useFirestoreCount({\n collectionPath,\n queryKey: ['notifications', 'unread-count', category, userId],\n constraints,\n enabled: enabled && !!userId,\n staleTime: refetchInterval,\n refetchInterval,\n });\n\n const count = data ?? 0;\n\n return {\n count,\n hasMore: count > countLimit,\n ...rest,\n };\n}\n"]}
@@ -101,18 +101,6 @@ export interface MaterializeManyOptions {
101
101
  cause: unknown;
102
102
  }) => void;
103
103
  }
104
- /** What `replay` did: reset a dead letter to `queued`, or found nothing to replay. */
105
- export type ReplayOutcome = 'replayed' | 'missing' | 'not-dead-lettered';
106
- export interface ReplayOptions {
107
- /**
108
- * Composes the caller's own writes — its audit event — into the replay's transaction, so they
109
- * commit with the reset or not at all (BACKEND-202). Called only when the row is reset, after
110
- * every read and after the reset write, with the row as the transaction read it (before the
111
- * reset). It writes through `transaction` only and reads nothing. Awaited, so an async audit
112
- * writer composes; a throw or rejection aborts the transaction and nothing commits.
113
- */
114
- auditWrite?: (transaction: ServerTransaction, row: Readonly<Record<string, unknown>>) => void | Promise<void>;
115
- }
116
104
  export interface DeliveryLedger {
117
105
  enqueue(rows: DeliveryRowInput[]): Promise<EnqueueResult>;
118
106
  materialize(deliveryId: string): Promise<MaterializeOutcome>;
@@ -124,8 +112,7 @@ export interface DeliveryLedger {
124
112
  materializeMany(deliveryIds: string[], options?: MaterializeManyOptions): Promise<Record<MaterializeOutcome, number>>;
125
113
  recordTransientFailure(deliveryId: string, error: unknown): Promise<void>;
126
114
  deadLetter(deliveryId: string, error: unknown): Promise<void>;
127
- /** Resets a dead letter to `queued` with fresh attempts; any other row is left untouched. */
128
- replay(deliveryId: string, options?: ReplayOptions): Promise<ReplayOutcome>;
115
+ replay(deliveryId: string): Promise<void>;
129
116
  }
130
117
  /**
131
118
  * Apply the aggregation strategy to produce the active-doc field delta.
@@ -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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"}
@@ -295,15 +295,15 @@ export function createDeliveryLedger(db, config, options) {
295
295
  });
296
296
  });
297
297
  }
298
- async function replay(deliveryId, replayOptions) {
299
- return db.runTransaction(async (tx) => {
298
+ async function replay(deliveryId) {
299
+ await db.runTransaction(async (tx) => {
300
300
  const dRef = deliveryRef(deliveryId);
301
301
  const dSnap = await tx.get(dRef);
302
302
  if (!dSnap.exists)
303
- return 'missing';
303
+ return;
304
304
  const d = (dSnap.data() ?? {});
305
305
  if (d.state !== 'deadLetter')
306
- return 'not-dead-lettered';
306
+ return;
307
307
  tx.update(dRef, {
308
308
  state: 'queued',
309
309
  attemptCount: 0,
@@ -312,8 +312,6 @@ export function createDeliveryLedger(db, config, options) {
312
312
  deadLetteredAt: null,
313
313
  expireAt: null, // clear any TTL set on a prior terminal
314
314
  });
315
- await replayOptions?.auditWrite?.(tx, d);
316
- return 'replayed';
317
315
  });
318
316
  }
319
317
  function reportUnrecordedFailure(options, deliveryId, error, cause) {
@@ -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 `replay` did: reset a dead letter to `queued`, or found nothing to replay. */\nexport type ReplayOutcome = 'replayed' | 'missing' | 'not-dead-lettered';\n\nexport interface ReplayOptions {\n /**\n * Composes the caller's own writes — its audit event — into the replay's transaction, so they\n * commit with the reset or not at all (BACKEND-202). Called only when the row is reset, after\n * every read and after the reset write, with the row as the transaction read it (before the\n * reset). It writes through `transaction` only and reads nothing. Awaited, so an async audit\n * writer composes; a throw or rejection aborts the transaction and nothing commits.\n */\n auditWrite?: (transaction: ServerTransaction, row: Readonly<Record<string, unknown>>) => 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 /** Resets a dead letter to `queued` with fresh attempts; any other row is left untouched. */\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, replayOptions?: ReplayOptions): Promise<ReplayOutcome> {\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 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 replayOptions?.auditWrite?.(tx, d);\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":["/**\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\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 replay(deliveryId: string): Promise<void>;\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): 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) !== 'deadLetter') return;\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 });\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"]}
@@ -2,7 +2,7 @@ export { processBatchHelper } from './processBatchHelper.js';
2
2
  export { NotificationPermissionError } from './errors.js';
3
3
  export { buildActiveNotificationDocId } from './activeNotificationId.js';
4
4
  export { createDeliveryLedger, applyAggregation } from './delivery-ledger.js';
5
- export type { DeliveryLedger, DeliveryRowInput, DeliveryPayload, DeliveryState, DeliverySkipReason, DeliveryLedgerOptions, AggregationStrategy, MaterializationClass, EnqueueResult, EnqueueRowResult, MaterializeOutcome, MaterializeManyOptions, ReplayOutcome, ReplayOptions, } from './delivery-ledger.js';
5
+ export type { DeliveryLedger, DeliveryRowInput, DeliveryPayload, DeliveryState, DeliverySkipReason, DeliveryLedgerOptions, AggregationStrategy, MaterializationClass, EnqueueResult, EnqueueRowResult, MaterializeOutcome, MaterializeManyOptions, } from './delivery-ledger.js';
6
6
  export { markNotificationSeenWithGeneration, archiveNotificationWithGeneration, } from './observed-generation.js';
7
7
  export type { MarkSeenOutcome, ArchiveOutcome } from './observed-generation.js';
8
8
  export type { ServerFirestore, ServerCollectionRef, ServerQuery, ServerQuerySnapshot, ServerDocSnapshot, ServerDocRef, ServerWriteBatch, ServerTransaction, } from './types.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,4BAA4B,EAAE,MAAM,2BAA2B,CAAC;AAGzE,OAAO,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAC9E,YAAY,EACV,cAAc,EACd,gBAAgB,EAChB,eAAe,EACf,aAAa,EACb,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,EACnB,oBAAoB,EACpB,aAAa,EACb,gBAAgB,EAChB,kBAAkB,EAClB,sBAAsB,EACtB,aAAa,EACb,aAAa,GACd,MAAM,sBAAsB,CAAC;AAG9B,OAAO,EACL,kCAAkC,EAClC,iCAAiC,GAClC,MAAM,0BAA0B,CAAC;AAClC,YAAY,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAGhF,YAAY,EACV,eAAe,EACf,mBAAmB,EACnB,WAAW,EACX,mBAAmB,EACnB,iBAAiB,EACjB,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,4BAA4B,EAAE,MAAM,2BAA2B,CAAC;AAGzE,OAAO,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAC9E,YAAY,EACV,cAAc,EACd,gBAAgB,EAChB,eAAe,EACf,aAAa,EACb,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,EACnB,oBAAoB,EACpB,aAAa,EACb,gBAAgB,EAChB,kBAAkB,EAClB,sBAAsB,GACvB,MAAM,sBAAsB,CAAC;AAG9B,OAAO,EACL,kCAAkC,EAClC,iCAAiC,GAClC,MAAM,0BAA0B,CAAC;AAClC,YAAY,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAGhF,YAAY,EACV,eAAe,EACf,mBAAmB,EACnB,WAAW,EACX,mBAAmB,EACnB,iBAAiB,EACjB,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,YAAY,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,4CAA4C;AAC5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,4BAA4B,EAAE,MAAM,2BAA2B,CAAC;AAEzE,+CAA+C;AAC/C,OAAO,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAkB9E,yEAAyE;AACzE,OAAO,EACL,kCAAkC,EAClC,iCAAiC,GAClC,MAAM,0BAA0B,CAAC","sourcesContent":["// Server-side helpers (for Cloud Functions)\nexport { processBatchHelper } from './processBatchHelper.js';\nexport { NotificationPermissionError } from './errors.js';\nexport { buildActiveNotificationDocId } from './activeNotificationId.js';\n\n// Delivery ledger (notification redesign — P1)\nexport { createDeliveryLedger, applyAggregation } from './delivery-ledger.js';\nexport type {\n DeliveryLedger,\n DeliveryRowInput,\n DeliveryPayload,\n DeliveryState,\n DeliverySkipReason,\n DeliveryLedgerOptions,\n AggregationStrategy,\n MaterializationClass,\n EnqueueResult,\n EnqueueRowResult,\n MaterializeOutcome,\n MaterializeManyOptions,\n ReplayOutcome,\n ReplayOptions,\n} from './delivery-ledger.js';\n\n// Observed-generation seen/archive protocol (notification redesign — P1)\nexport {\n markNotificationSeenWithGeneration,\n archiveNotificationWithGeneration,\n} from './observed-generation.js';\nexport type { MarkSeenOutcome, ArchiveOutcome } from './observed-generation.js';\n\n// Server types\nexport type {\n ServerFirestore,\n ServerCollectionRef,\n ServerQuery,\n ServerQuerySnapshot,\n ServerDocSnapshot,\n ServerDocRef,\n ServerWriteBatch,\n ServerTransaction,\n} from './types.js';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,4CAA4C;AAC5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,4BAA4B,EAAE,MAAM,2BAA2B,CAAC;AAEzE,+CAA+C;AAC/C,OAAO,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAgB9E,yEAAyE;AACzE,OAAO,EACL,kCAAkC,EAClC,iCAAiC,GAClC,MAAM,0BAA0B,CAAC","sourcesContent":["// Server-side helpers (for Cloud Functions)\nexport { processBatchHelper } from './processBatchHelper.js';\nexport { NotificationPermissionError } from './errors.js';\nexport { buildActiveNotificationDocId } from './activeNotificationId.js';\n\n// Delivery ledger (notification redesign — P1)\nexport { createDeliveryLedger, applyAggregation } from './delivery-ledger.js';\nexport type {\n DeliveryLedger,\n DeliveryRowInput,\n DeliveryPayload,\n DeliveryState,\n DeliverySkipReason,\n DeliveryLedgerOptions,\n AggregationStrategy,\n MaterializationClass,\n EnqueueResult,\n EnqueueRowResult,\n MaterializeOutcome,\n MaterializeManyOptions,\n} from './delivery-ledger.js';\n\n// Observed-generation seen/archive protocol (notification redesign — P1)\nexport {\n markNotificationSeenWithGeneration,\n archiveNotificationWithGeneration,\n} from './observed-generation.js';\nexport type { MarkSeenOutcome, ArchiveOutcome } from './observed-generation.js';\n\n// Server types\nexport type {\n ServerFirestore,\n ServerCollectionRef,\n ServerQuery,\n ServerQuerySnapshot,\n ServerDocSnapshot,\n ServerDocRef,\n ServerWriteBatch,\n ServerTransaction,\n} from './types.js';\n"]}