@ttt-productions/notification-core 0.15.9 → 0.15.10
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.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/react/components/NotificationHistoryList.d.ts +1 -1
- package/dist/react/components/NotificationHistoryList.d.ts.map +1 -1
- package/dist/react/components/NotificationHistoryList.js +6 -6
- package/dist/react/components/NotificationHistoryList.js.map +1 -1
- package/dist/react/components/NotificationList.d.ts +1 -1
- package/dist/react/components/NotificationList.d.ts.map +1 -1
- package/dist/react/components/NotificationList.js +65 -10
- package/dist/react/components/NotificationList.js.map +1 -1
- package/dist/react/components/NotificationUnreadBadge.d.ts +1 -1
- package/dist/react/components/NotificationUnreadBadge.d.ts.map +1 -1
- package/dist/react/components/NotificationUnreadBadge.js +2 -1
- package/dist/react/components/NotificationUnreadBadge.js.map +1 -1
- package/dist/react/components/notification-type-icon.d.ts +11 -0
- package/dist/react/components/notification-type-icon.d.ts.map +1 -0
- package/dist/react/components/notification-type-icon.js +12 -0
- package/dist/react/components/notification-type-icon.js.map +1 -0
- package/dist/react/hooks/useActiveNotifications.d.ts +1 -1
- package/dist/react/hooks/useActiveNotifications.d.ts.map +1 -1
- package/dist/react/hooks/useActiveNotifications.js +3 -2
- package/dist/react/hooks/useActiveNotifications.js.map +1 -1
- package/dist/react/hooks/useArchiveAllNotifications.d.ts +1 -1
- package/dist/react/hooks/useArchiveAllNotifications.d.ts.map +1 -1
- package/dist/react/hooks/useArchiveAllNotifications.js +3 -7
- package/dist/react/hooks/useArchiveAllNotifications.js.map +1 -1
- package/dist/react/hooks/useArchiveNotification.d.ts +1 -1
- package/dist/react/hooks/useArchiveNotification.d.ts.map +1 -1
- package/dist/react/hooks/useArchiveNotification.js +3 -7
- package/dist/react/hooks/useArchiveNotification.js.map +1 -1
- package/dist/react/hooks/useNotificationHistory.d.ts +1 -1
- package/dist/react/hooks/useNotificationHistory.d.ts.map +1 -1
- package/dist/react/hooks/useNotificationHistory.js +3 -2
- package/dist/react/hooks/useNotificationHistory.js.map +1 -1
- package/dist/react/hooks/useUnreadCount.d.ts +10 -2
- package/dist/react/hooks/useUnreadCount.d.ts.map +1 -1
- package/dist/react/hooks/useUnreadCount.js +16 -6
- package/dist/react/hooks/useUnreadCount.js.map +1 -1
- package/dist/react/query-keys.d.ts +5 -0
- package/dist/react/query-keys.d.ts.map +1 -0
- package/dist/react/query-keys.js +15 -0
- package/dist/react/query-keys.js.map +1 -0
- package/dist/server/delivery-ledger.d.ts +34 -5
- package/dist/server/delivery-ledger.d.ts.map +1 -1
- package/dist/server/delivery-ledger.js +24 -5
- package/dist/server/delivery-ledger.js.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/types.d.ts +65 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +5 -5
- package/src/styles/notifications.css +10 -33
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useArchiveAllNotifications.js","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveAllNotifications.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;
|
|
1
|
+
{"version":3,"file":"useArchiveAllNotifications.js","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveAllNotifications.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport type {\n ArchiveAllPollResult,\n UseArchiveAllNotificationsOptions,\n} from '../../types.js';\nimport { archiveRefreshKeys } from '../query-keys.js';\n\nconst DEFAULT_POLL_INTERVAL_MS = 1500;\nconst DEFAULT_MAX_POLLS = 120; // ~3 min at the default interval\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/**\n * Trigger + poll a SERVER-OWNED archive-all job for one notification category.\n *\n * This is a THIN STATUS POLLER, not a client loop. It:\n * 1. enqueues ONE server job via `enqueueArchiveAllFn(category)` (the category — the tab the tray\n * was invoked on — flows into the job, preserving per-tab scoping: the worker archives ONLY\n * that category's active cards, never all tabs);\n * 2. polls `getArchiveAllStatusFn(jobId)` until the job reaches a terminal state\n * (`complete` | `incomplete` | `failed`);\n * 3. resolves an {@link ArchiveAllPollResult} the caller gates side effects on, and invalidates\n * the read keys on a completed drain.\n *\n * All paging + ownership-checked per-card deletion happen server-side. The hook performs no client\n * Firestore writes and never chains archive calls. The generic package hardcodes no callable name —\n * the app injects both adapters.\n *\n * @example\n * ```tsx\n * const enqueue = httpsCallable(functions, 'enqueueArchiveAll');\n * const getStatus = httpsCallable(functions, 'getArchiveAllStatus');\n * const archiveAll = useArchiveAllNotifications({\n * userId: user.uid,\n * category: 'user',\n * enqueueArchiveAllFn: (category) => enqueue({ category }).then((r) => r.data),\n * getArchiveAllStatusFn: (jobId) => getStatus({ jobId }).then((r) => r.data),\n * });\n *\n * archiveAll.mutate();\n * ```\n */\nexport function useArchiveAllNotifications({\n userId,\n category,\n enqueueArchiveAllFn,\n getArchiveAllStatusFn,\n pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,\n maxPolls = DEFAULT_MAX_POLLS,\n queryKeys,\n invalidateKeys,\n}: UseArchiveAllNotificationsOptions) {\n const queryClient = useQueryClient();\n\n return useMutation({\n // Server-owned archive-all: enqueue ONE job scoped to `category`, then poll its status until it\n // reaches a terminal state. No browser loop, no chain of archive transactions. The mutation\n // resolves with an explicit terminal result (never throws for an incomplete drain) — `complete`\n // is the load-bearing flag callers gate post-clear side effects on.\n mutationFn: async (): Promise<ArchiveAllPollResult> => {\n const { jobId } = await enqueueArchiveAllFn(category);\n\n // Poll until terminal or until the guard bound is hit. A job that never terminates resolves\n // `incomplete` (mirrors the old loop's honest \"some remain\" result) rather than hanging. The\n // first poll happens immediately so a fast/already-complete job resolves without a delay.\n let lastArchived = 0;\n for (let poll = 0; poll < maxPolls; poll += 1) {\n const snap = await getArchiveAllStatusFn(jobId);\n lastArchived = snap.archived ?? 0;\n\n if (snap.state === 'complete') {\n return { status: 'complete', category, archived: lastArchived, complete: true };\n }\n if (snap.state === 'incomplete') {\n return { status: 'incomplete', category, archived: lastArchived, complete: false };\n }\n if (snap.state === 'failed') {\n return { status: 'failed', category, archived: lastArchived, complete: false, error: snap.error };\n }\n\n // Still in-progress — wait before the next poll unless this was the last allowed poll.\n if (poll < maxPolls - 1) await sleep(pollIntervalMs);\n }\n\n // Guard: the job never reached a terminal state within the poll budget — report incomplete.\n return { status: 'incomplete', category, archived: lastArchived, complete: false };\n },\n onSuccess: (result) => {\n // Only a completed drain changed the active set; still invalidate on an incomplete drain since\n // SOME cards were archived. A hard enqueue/poll failure rejects the mutation (onError), so this\n // never runs for `failed`.\n const keysToInvalidate = invalidateKeys ?? archiveRefreshKeys(queryKeys, category, userId);\n keysToInvalidate.forEach((key) => {\n queryClient.invalidateQueries({ queryKey: [...key], exact: false });\n });\n void result;\n },\n });\n}\n"]}
|
|
@@ -7,5 +7,5 @@ import type { NotificationArchiveResult, NotificationDoc, UseArchiveNotification
|
|
|
7
7
|
* row as rendered, resolves the adapter's `{ archived }` answer, and invalidates the
|
|
8
8
|
* read keys on success.
|
|
9
9
|
*/
|
|
10
|
-
export declare function useArchiveNotification({ userId, category, archiveFn, invalidateKeys, }: UseArchiveNotificationOptions): import("@tanstack/react-query").UseMutationResult<NotificationArchiveResult, Error, NotificationDoc, unknown>;
|
|
10
|
+
export declare function useArchiveNotification({ userId, category, archiveFn, queryKeys, invalidateKeys, }: UseArchiveNotificationOptions): import("@tanstack/react-query").UseMutationResult<NotificationArchiveResult, Error, NotificationDoc, unknown>;
|
|
11
11
|
//# sourceMappingURL=useArchiveNotification.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useArchiveNotification.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveNotification.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,yBAAyB,EACzB,eAAe,EACf,6BAA6B,EAC9B,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"useArchiveNotification.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveNotification.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,yBAAyB,EACzB,eAAe,EACf,6BAA6B,EAC9B,MAAM,gBAAgB,CAAC;AAGxB;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,EACrC,MAAM,EACN,QAAQ,EACR,SAAS,EACT,SAAS,EACT,cAAc,GACf,EAAE,6BAA6B,iHAY/B"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { archiveRefreshKeys } from '../query-keys.js';
|
|
3
4
|
/**
|
|
4
5
|
* Archive a single rendered notification through an app-supplied callable adapter.
|
|
5
6
|
*
|
|
@@ -8,17 +9,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
8
9
|
* row as rendered, resolves the adapter's `{ archived }` answer, and invalidates the
|
|
9
10
|
* read keys on success.
|
|
10
11
|
*/
|
|
11
|
-
export function useArchiveNotification({ userId, category, archiveFn, invalidateKeys, }) {
|
|
12
|
+
export function useArchiveNotification({ userId, category, archiveFn, queryKeys, invalidateKeys, }) {
|
|
12
13
|
const queryClient = useQueryClient();
|
|
13
|
-
const defaultInvalidateKeys = [
|
|
14
|
-
['notifications', 'active', category, userId],
|
|
15
|
-
['notifications', 'unread-count', category, userId],
|
|
16
|
-
['notifications', 'history', category, userId],
|
|
17
|
-
];
|
|
18
14
|
return useMutation({
|
|
19
15
|
mutationFn: (notification) => archiveFn(notification),
|
|
20
16
|
onSuccess: () => {
|
|
21
|
-
const keysToInvalidate = invalidateKeys ??
|
|
17
|
+
const keysToInvalidate = invalidateKeys ?? archiveRefreshKeys(queryKeys, category, userId);
|
|
22
18
|
keysToInvalidate.forEach((key) => {
|
|
23
19
|
queryClient.invalidateQueries({ queryKey: [...key], exact: false });
|
|
24
20
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useArchiveNotification.js","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveNotification.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;
|
|
1
|
+
{"version":3,"file":"useArchiveNotification.js","sourceRoot":"","sources":["../../../src/react/hooks/useArchiveNotification.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAMpE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAEtD;;;;;;;GAOG;AACH,MAAM,UAAU,sBAAsB,CAAC,EACrC,MAAM,EACN,QAAQ,EACR,SAAS,EACT,SAAS,EACT,cAAc,GACgB;IAC9B,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;IAErC,OAAO,WAAW,CAAoD;QACpE,UAAU,EAAE,CAAC,YAA6B,EAAE,EAAE,CAAC,SAAS,CAAC,YAAY,CAAC;QACtE,SAAS,EAAE,GAAG,EAAE;YACd,MAAM,gBAAgB,GAAG,cAAc,IAAI,kBAAkB,CAAC,SAAS,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;YAC3F,gBAAgB,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE;gBAC/B,WAAW,CAAC,iBAAiB,CAAC,EAAE,QAAQ,EAAE,CAAC,GAAG,GAAG,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;YACtE,CAAC,CAAC,CAAC;QACL,CAAC;KACF,CAAC,CAAC;AACL,CAAC","sourcesContent":["'use client';\n\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport type {\n NotificationArchiveResult,\n NotificationDoc,\n UseArchiveNotificationOptions,\n} from '../../types.js';\nimport { archiveRefreshKeys } from '../query-keys.js';\n\n/**\n * Archive a single rendered notification through an app-supplied callable adapter.\n *\n * The notification system is Cloud-Functions-only: clients never write notification\n * docs. This hook performs no Firestore writes — it delegates to `archiveFn` with the\n * row as rendered, resolves the adapter's `{ archived }` answer, and invalidates the\n * read keys on success.\n */\nexport function useArchiveNotification({\n userId,\n category,\n archiveFn,\n queryKeys,\n invalidateKeys,\n}: UseArchiveNotificationOptions) {\n const queryClient = useQueryClient();\n\n return useMutation<NotificationArchiveResult, Error, NotificationDoc>({\n mutationFn: (notification: NotificationDoc) => archiveFn(notification),\n onSuccess: () => {\n const keysToInvalidate = invalidateKeys ?? archiveRefreshKeys(queryKeys, category, userId);\n keysToInvalidate.forEach((key) => {\n queryClient.invalidateQueries({ queryKey: [...key], exact: false });\n });\n },\n });\n}\n"]}
|
|
@@ -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, }: UseNotificationHistoryOptions): import("@ttt-productions/query-core/react").UseFirestorePaginatedResult<NotificationHistoryItem>;
|
|
16
|
+
export declare function useNotificationHistory({ config, userId, category, enabled, pageSize, staleTime, queryKeys, }: 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;
|
|
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,6 +1,7 @@
|
|
|
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';
|
|
4
5
|
const DEFAULT_PAGE_SIZE = 20;
|
|
5
6
|
const DEFAULT_STALE_TIME = 60_000;
|
|
6
7
|
/**
|
|
@@ -17,7 +18,7 @@ const DEFAULT_STALE_TIME = 60_000;
|
|
|
17
18
|
* list. Owner-only (user) / admin-only (admin) reads are enforced by Firestore
|
|
18
19
|
* rules — this hook performs no writes.
|
|
19
20
|
*/
|
|
20
|
-
export function useNotificationHistory({ config, userId, category, enabled = true, pageSize = DEFAULT_PAGE_SIZE, staleTime = DEFAULT_STALE_TIME, }) {
|
|
21
|
+
export function useNotificationHistory({ config, userId, category, enabled = true, pageSize = DEFAULT_PAGE_SIZE, staleTime = DEFAULT_STALE_TIME, queryKeys, }) {
|
|
21
22
|
const categoryConfig = config.categories[category];
|
|
22
23
|
if (!categoryConfig) {
|
|
23
24
|
throw new Error(`[notification-core] Unknown category: ${category}`);
|
|
@@ -26,7 +27,7 @@ export function useNotificationHistory({ config, userId, category, enabled = tru
|
|
|
26
27
|
const constraints = [orderBy('archivedAt', 'desc')];
|
|
27
28
|
return useFirestorePaginated({
|
|
28
29
|
collectionPath,
|
|
29
|
-
queryKey: [
|
|
30
|
+
queryKey: [...resolveNotificationQueryKeys(queryKeys).history(category, userId), { pageSize }],
|
|
30
31
|
constraints,
|
|
31
32
|
pageSize,
|
|
32
33
|
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;
|
|
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"]}
|
|
@@ -6,7 +6,9 @@ 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
|
|
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
|
|
10
12
|
* existence-based — a count of all active items, with no `seenAt` predicate.
|
|
11
13
|
*
|
|
12
14
|
* The personal `seenAt == 0` predicate needs a composite index, captured as an
|
|
@@ -21,7 +23,7 @@ import type { UseUnreadCountOptions } from '../../types.js';
|
|
|
21
23
|
* });
|
|
22
24
|
* ```
|
|
23
25
|
*/
|
|
24
|
-
export declare function useUnreadCount({ config, userId, category, enabled, refetchInterval, countLimit, }: UseUnreadCountOptions): {
|
|
26
|
+
export declare function useUnreadCount({ config, userId, category, enabled, refetchInterval, countLimit, queryKeys, }: UseUnreadCountOptions): {
|
|
25
27
|
error: Error;
|
|
26
28
|
isError: true;
|
|
27
29
|
isPending: false;
|
|
@@ -48,6 +50,7 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
|
|
|
48
50
|
fetchStatus: import("@tanstack/query-core").FetchStatus;
|
|
49
51
|
promise: Promise<number>;
|
|
50
52
|
count: number;
|
|
53
|
+
hasAnswer: boolean;
|
|
51
54
|
hasMore: boolean;
|
|
52
55
|
} | {
|
|
53
56
|
error: null;
|
|
@@ -76,6 +79,7 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
|
|
|
76
79
|
fetchStatus: import("@tanstack/query-core").FetchStatus;
|
|
77
80
|
promise: Promise<number>;
|
|
78
81
|
count: number;
|
|
82
|
+
hasAnswer: boolean;
|
|
79
83
|
hasMore: boolean;
|
|
80
84
|
} | {
|
|
81
85
|
error: Error;
|
|
@@ -104,6 +108,7 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
|
|
|
104
108
|
fetchStatus: import("@tanstack/query-core").FetchStatus;
|
|
105
109
|
promise: Promise<number>;
|
|
106
110
|
count: number;
|
|
111
|
+
hasAnswer: boolean;
|
|
107
112
|
hasMore: boolean;
|
|
108
113
|
} | {
|
|
109
114
|
error: null;
|
|
@@ -132,6 +137,7 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
|
|
|
132
137
|
fetchStatus: import("@tanstack/query-core").FetchStatus;
|
|
133
138
|
promise: Promise<number>;
|
|
134
139
|
count: number;
|
|
140
|
+
hasAnswer: boolean;
|
|
135
141
|
hasMore: boolean;
|
|
136
142
|
} | {
|
|
137
143
|
error: null;
|
|
@@ -160,6 +166,7 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
|
|
|
160
166
|
fetchStatus: import("@tanstack/query-core").FetchStatus;
|
|
161
167
|
promise: Promise<number>;
|
|
162
168
|
count: number;
|
|
169
|
+
hasAnswer: boolean;
|
|
163
170
|
hasMore: boolean;
|
|
164
171
|
} | {
|
|
165
172
|
isError: false;
|
|
@@ -188,6 +195,7 @@ export declare function useUnreadCount({ config, userId, category, enabled, refe
|
|
|
188
195
|
fetchStatus: import("@tanstack/query-core").FetchStatus;
|
|
189
196
|
promise: Promise<number>;
|
|
190
197
|
count: number;
|
|
198
|
+
hasAnswer: boolean;
|
|
191
199
|
hasMore: boolean;
|
|
192
200
|
};
|
|
193
201
|
//# 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;
|
|
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,6 +1,7 @@
|
|
|
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';
|
|
4
5
|
const DEFAULT_REFETCH_INTERVAL = 30_000;
|
|
5
6
|
const DEFAULT_COUNT_LIMIT = 99;
|
|
6
7
|
/**
|
|
@@ -10,7 +11,9 @@ const DEFAULT_COUNT_LIMIT = 99;
|
|
|
10
11
|
*
|
|
11
12
|
* - **personal** categories count **unseen** active items (`seenAt == 0`),
|
|
12
13
|
* scoped to the caller (`targetUserId == uid`);
|
|
13
|
-
* - **shared** categories
|
|
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
|
|
14
17
|
* existence-based — a count of all active items, with no `seenAt` predicate.
|
|
15
18
|
*
|
|
16
19
|
* The personal `seenAt == 0` predicate needs a composite index, captured as an
|
|
@@ -25,27 +28,34 @@ const DEFAULT_COUNT_LIMIT = 99;
|
|
|
25
28
|
* });
|
|
26
29
|
* ```
|
|
27
30
|
*/
|
|
28
|
-
export function useUnreadCount({ config, userId, category, enabled = true, refetchInterval = DEFAULT_REFETCH_INTERVAL, countLimit = DEFAULT_COUNT_LIMIT, }) {
|
|
31
|
+
export function useUnreadCount({ config, userId, category, enabled = true, refetchInterval = DEFAULT_REFETCH_INTERVAL, countLimit = DEFAULT_COUNT_LIMIT, queryKeys, }) {
|
|
29
32
|
const categoryConfig = config.categories[category];
|
|
30
33
|
if (!categoryConfig) {
|
|
31
34
|
throw new Error(`[notification-core] Unknown category: ${category}`);
|
|
32
35
|
}
|
|
33
36
|
const collectionPath = categoryConfig.activePath;
|
|
34
37
|
const isPersonal = categoryConfig.audienceType === 'personal';
|
|
35
|
-
const constraints = isPersonal
|
|
36
|
-
?
|
|
37
|
-
|
|
38
|
+
const constraints = isPersonal
|
|
39
|
+
? userId
|
|
40
|
+
? [where('targetUserId', '==', userId), where('seenAt', '==', 0)]
|
|
41
|
+
: []
|
|
42
|
+
: categoryConfig.sharedSeenState
|
|
43
|
+
? [where('seenAt', '==', 0)]
|
|
44
|
+
: [];
|
|
38
45
|
const { data, ...rest } = useFirestoreCount({
|
|
39
46
|
collectionPath,
|
|
40
|
-
queryKey:
|
|
47
|
+
queryKey: resolveNotificationQueryKeys(queryKeys).unreadCount(category, userId),
|
|
41
48
|
constraints,
|
|
42
49
|
enabled: enabled && !!userId,
|
|
43
50
|
staleTime: refetchInterval,
|
|
44
51
|
refetchInterval,
|
|
45
52
|
});
|
|
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.
|
|
46
55
|
const count = data ?? 0;
|
|
47
56
|
return {
|
|
48
57
|
count,
|
|
58
|
+
hasAnswer: data !== undefined,
|
|
49
59
|
hasMore: count > countLimit,
|
|
50
60
|
...rest,
|
|
51
61
|
};
|
|
@@ -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;
|
|
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"]}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { NotificationQueryKeys } from '../types.js';
|
|
2
|
+
export declare function resolveNotificationQueryKeys(queryKeys: NotificationQueryKeys | undefined): NotificationQueryKeys;
|
|
3
|
+
/** The three keys an archive or an archive-all refreshes for one category and viewer. */
|
|
4
|
+
export declare function archiveRefreshKeys(queryKeys: NotificationQueryKeys | undefined, category: string, userId: string): readonly (readonly unknown[])[];
|
|
5
|
+
//# sourceMappingURL=query-keys.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query-keys.d.ts","sourceRoot":"","sources":["../../src/react/query-keys.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AASzD,wBAAgB,4BAA4B,CAAC,SAAS,EAAE,qBAAqB,GAAG,SAAS,GAAG,qBAAqB,CAEhH;AAED,yFAAyF;AACzF,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,qBAAqB,GAAG,SAAS,EAC5C,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,GACb,SAAS,CAAC,SAAS,OAAO,EAAE,CAAC,EAAE,CAGjC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The keys the hooks use when the app supplies none. */
|
|
2
|
+
const DEFAULT_NOTIFICATION_QUERY_KEYS = {
|
|
3
|
+
active: (category, userId) => ['notifications', 'active', category, userId],
|
|
4
|
+
history: (category, userId) => ['notifications', 'history', category, userId],
|
|
5
|
+
unreadCount: (category, userId) => ['notifications', 'unread-count', category, userId],
|
|
6
|
+
};
|
|
7
|
+
export function resolveNotificationQueryKeys(queryKeys) {
|
|
8
|
+
return queryKeys ?? DEFAULT_NOTIFICATION_QUERY_KEYS;
|
|
9
|
+
}
|
|
10
|
+
/** The three keys an archive or an archive-all refreshes for one category and viewer. */
|
|
11
|
+
export function archiveRefreshKeys(queryKeys, category, userId) {
|
|
12
|
+
const keys = resolveNotificationQueryKeys(queryKeys);
|
|
13
|
+
return [keys.active(category, userId), keys.unreadCount(category, userId), keys.history(category, userId)];
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=query-keys.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query-keys.js","sourceRoot":"","sources":["../../src/react/query-keys.ts"],"names":[],"mappings":"AAEA,yDAAyD;AACzD,MAAM,+BAA+B,GAA0B;IAC7D,MAAM,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC,eAAe,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,CAAC;IAC3E,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC,eAAe,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,CAAC;IAC7E,WAAW,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC,eAAe,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,CAAC;CACvF,CAAC;AAEF,MAAM,UAAU,4BAA4B,CAAC,SAA4C;IACvF,OAAO,SAAS,IAAI,+BAA+B,CAAC;AACtD,CAAC;AAED,yFAAyF;AACzF,MAAM,UAAU,kBAAkB,CAChC,SAA4C,EAC5C,QAAgB,EAChB,MAAc;IAEd,MAAM,IAAI,GAAG,4BAA4B,CAAC,SAAS,CAAC,CAAC;IACrD,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,EAAE,IAAI,CAAC,WAAW,CAAC,QAAQ,EAAE,MAAM,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC;AAC7G,CAAC","sourcesContent":["import type { NotificationQueryKeys } from '../types.js';\n\n/** The keys the hooks use when the app supplies none. */\nconst DEFAULT_NOTIFICATION_QUERY_KEYS: NotificationQueryKeys = {\n active: (category, userId) => ['notifications', 'active', category, userId],\n history: (category, userId) => ['notifications', 'history', category, userId],\n unreadCount: (category, userId) => ['notifications', 'unread-count', category, userId],\n};\n\nexport function resolveNotificationQueryKeys(queryKeys: NotificationQueryKeys | undefined): NotificationQueryKeys {\n return queryKeys ?? DEFAULT_NOTIFICATION_QUERY_KEYS;\n}\n\n/** The three keys an archive or an archive-all refreshes for one category and viewer. */\nexport function archiveRefreshKeys(\n queryKeys: NotificationQueryKeys | undefined,\n category: string,\n userId: string,\n): readonly (readonly unknown[])[] {\n const keys = resolveNotificationQueryKeys(queryKeys);\n return [keys.active(category, userId), keys.unreadCount(category, userId), keys.history(category, userId)];\n}\n"]}
|
|
@@ -87,16 +87,45 @@ export interface EnqueueResult {
|
|
|
87
87
|
allResolved: boolean;
|
|
88
88
|
}
|
|
89
89
|
export type MaterializeOutcome = 'materialized' | 'already-materialized' | 'skipped-ineligible-recipient' | 'missing' | 'skipped-non-queued';
|
|
90
|
+
export interface MaterializeManyOptions {
|
|
91
|
+
/** Rows materialized at once (default 10). */
|
|
92
|
+
concurrency?: number;
|
|
93
|
+
/**
|
|
94
|
+
* Called for a row whose materialize threw and whose failure could not then be recorded either —
|
|
95
|
+
* `error` is the recording's own failure and `details.cause` the materialize failure it was
|
|
96
|
+
* recording. The row stays as it was (queued, attempt count
|
|
97
|
+
* unchanged), so a later run selects it again; the rest of the batch proceeds. The app's capture
|
|
98
|
+
* goes here; without it the ledger writes the failure to `console.error`.
|
|
99
|
+
*/
|
|
100
|
+
onUnrecordedFailure?: (deliveryId: string, error: unknown, details: {
|
|
101
|
+
cause: unknown;
|
|
102
|
+
}) => void;
|
|
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
|
+
}
|
|
90
116
|
export interface DeliveryLedger {
|
|
91
117
|
enqueue(rows: DeliveryRowInput[]): Promise<EnqueueResult>;
|
|
92
118
|
materialize(deliveryId: string): Promise<MaterializeOutcome>;
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Materialize many rows with bounded concurrency. A row whose materialize throws has the failure
|
|
121
|
+
* recorded (a retry or a dead letter); each row is isolated, so one row's failure — even one
|
|
122
|
+
* whose recording also fails — never fails the batch or stops the rows behind it.
|
|
123
|
+
*/
|
|
124
|
+
materializeMany(deliveryIds: string[], options?: MaterializeManyOptions): Promise<Record<MaterializeOutcome, number>>;
|
|
97
125
|
recordTransientFailure(deliveryId: string, error: unknown): Promise<void>;
|
|
98
126
|
deadLetter(deliveryId: string, error: unknown): Promise<void>;
|
|
99
|
-
|
|
127
|
+
/** Resets a dead letter to `queued` with fresh attempts; any other row is left untouched. */
|
|
128
|
+
replay(deliveryId: string, options?: ReplayOptions): Promise<ReplayOutcome>;
|
|
100
129
|
}
|
|
101
130
|
/**
|
|
102
131
|
* 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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAGH,OAAO,KAAK,EAAE,wBAAwB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC7E,OAAO,KAAK,EAAE,eAAe,EAAgB,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAWnF;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,QAAQ,GAAG,cAAc,GAAG,SAAS,GAAG,YAAY,CAAC;AAEjF,+CAA+C;AAC/C,MAAM,MAAM,kBAAkB,GAAG,qBAAqB,CAAC;AAEvD;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC;;;;;;OAMG;IACH,mBAAmB,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,EAAE,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CACxF;AACD,MAAM,MAAM,mBAAmB,GAAG,WAAW,GAAG,eAAe,CAAC;AAChE,MAAM,MAAM,oBAAoB,GAC5B,cAAc,GACd,kBAAkB,GAClB,cAAc,GACd,OAAO,CAAC;AAEZ,iEAAiE;AACjE,MAAM,WAAW,eAAe;IAC9B,uFAAuF;IACvF,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,kFAAkF;AAClF,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,EAAE,MAAM,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,6FAA6F;IAC7F,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,OAAO,EAAE,eAAe,CAAC;IACzB,cAAc,EAAE,MAAM,CAAC;IACvB,oBAAoB,EAAE,oBAAoB,CAAC;CAC5C;AAED,MAAM,MAAM,gBAAgB,GACxB;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,SAAS,CAAA;CAAE,GAC1C;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,WAAW,CAAA;CAAE,GAC5C;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAAC;AAE9D,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,gBAAgB,EAAE,CAAC;IAC5B,4GAA4G;IAC5G,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,MAAM,kBAAkB,GAC1B,cAAc,GACd,sBAAsB,GACtB,8BAA8B,GAC9B,SAAS,GACT,oBAAoB,CAAC;AAEzB,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,IAAI,EAAE,gBAAgB,EAAE,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IAC1D,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAC7D
|
|
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) {
|
|
299
|
-
|
|
298
|
+
async function replay(deliveryId, replayOptions) {
|
|
299
|
+
return 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;
|
|
303
|
+
return 'missing';
|
|
304
304
|
const d = (dSnap.data() ?? {});
|
|
305
305
|
if (d.state !== 'deadLetter')
|
|
306
|
-
return;
|
|
306
|
+
return 'not-dead-lettered';
|
|
307
307
|
tx.update(dRef, {
|
|
308
308
|
state: 'queued',
|
|
309
309
|
attemptCount: 0,
|
|
@@ -312,8 +312,22 @@ 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';
|
|
315
317
|
});
|
|
316
318
|
}
|
|
319
|
+
function reportUnrecordedFailure(options, deliveryId, error, cause) {
|
|
320
|
+
try {
|
|
321
|
+
if (options?.onUnrecordedFailure) {
|
|
322
|
+
options.onUnrecordedFailure(deliveryId, error, { cause });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
catch (handlerError) {
|
|
327
|
+
console.error('[notification-core] onUnrecordedFailure threw for delivery', deliveryId, handlerError);
|
|
328
|
+
}
|
|
329
|
+
console.error('[notification-core] A delivery failure could not be recorded', deliveryId, error, { cause });
|
|
330
|
+
}
|
|
317
331
|
async function materializeMany(deliveryIds, options) {
|
|
318
332
|
const concurrency = Math.max(1, options?.concurrency ?? 10);
|
|
319
333
|
const tally = {
|
|
@@ -332,7 +346,12 @@ export function createDeliveryLedger(db, config, options) {
|
|
|
332
346
|
tally[outcome] += 1;
|
|
333
347
|
}
|
|
334
348
|
catch (error) {
|
|
335
|
-
|
|
349
|
+
try {
|
|
350
|
+
await recordTransientFailure(id, error);
|
|
351
|
+
}
|
|
352
|
+
catch (recordError) {
|
|
353
|
+
reportUnrecordedFailure(options, id, recordError, error);
|
|
354
|
+
}
|
|
336
355
|
}
|
|
337
356
|
}
|
|
338
357
|
}
|