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