@aglyn/tenant-data-admin 1.0.0-beta.195 → 1.0.0-beta.197

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/consent-group-carry.d.ts +50 -0
  3. package/src/lib/server/consent-group-carry.js +319 -0
  4. package/src/lib/server/consent-group-carry.js.map +1 -0
  5. package/src/lib/server/consent-group-change.d.ts +246 -0
  6. package/src/lib/server/consent-group-change.js +1096 -0
  7. package/src/lib/server/consent-group-change.js.map +1 -0
  8. package/src/lib/server/dynamic-list-materialize.js +31 -5
  9. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  10. package/src/lib/server/email-suppression.d.ts +32 -0
  11. package/src/lib/server/email-suppression.js +40 -2
  12. package/src/lib/server/email-suppression.js.map +1 -1
  13. package/src/lib/server/erase.d.ts +21 -0
  14. package/src/lib/server/erase.js +32 -1
  15. package/src/lib/server/erase.js.map +1 -1
  16. package/src/lib/server/host-visitor-records.d.ts +7 -0
  17. package/src/lib/server/host-visitor-records.js +9 -3
  18. package/src/lib/server/host-visitor-records.js.map +1 -1
  19. package/src/lib/server/list-members.d.ts +23 -0
  20. package/src/lib/server/list-members.js +49 -7
  21. package/src/lib/server/list-members.js.map +1 -1
  22. package/src/lib/server/notifications.js +4 -1
  23. package/src/lib/server/notifications.js.map +1 -1
  24. package/src/lib/server/test-firestore-queries.d.ts +42 -0
  25. package/src/lib/server/test-firestore-queries.js +640 -0
  26. package/src/lib/server/test-firestore-queries.js.map +1 -0
  27. package/src/lib/server/upsert-contact.d.ts +15 -1
  28. package/src/lib/server/upsert-contact.js +8 -2
  29. package/src/lib/server/upsert-contact.js.map +1 -1
  30. package/src/tenancy.d.ts +2 -0
  31. package/src/tenancy.js +4 -0
  32. package/src/tenancy.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-data-admin",
3
- "version": "1.0.0-beta.195",
3
+ "version": "1.0.0-beta.197",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,12 +25,12 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.195",
29
- "@aglyn/shared-util-email": "1.0.0-beta.195",
30
- "@aglyn/shared-util-fbserver": "1.0.0-beta.195",
31
- "@aglyn/shared-util-first-touch": "1.0.0-beta.195",
32
- "@aglyn/shared-util-http": "1.0.0-beta.195",
33
- "@aglyn/shared-util-tools": "1.0.0-beta.195",
28
+ "@aglyn/aglyn": "1.0.0-beta.197",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.197",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.197",
31
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.197",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.197",
33
+ "@aglyn/shared-util-tools": "1.0.0-beta.197",
34
34
  "@msgpack/msgpack": "^3.1.3",
35
35
  "@swc/helpers": "0.5.23",
36
36
  "sharp": "^0.35.3",
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { ConsentGroupCarry } from '@aglyn/aglyn/app-utils/consent-group-change';
18
+ /** The three stores a carry copies, as the job counts them. */
19
+ export type ConsentGroupCarryStore = 'siteSuppressions' | 'topicOptOuts' | 'paces';
20
+ export declare const CONSENT_GROUP_CARRY_STORES: readonly ConsentGroupCarryStore[];
21
+ /** Each store's subcollection under `hosts/{hostId}`. */
22
+ export declare const CONSENT_GROUP_CARRY_SUBCOLLECTIONS: Readonly<Record<ConsentGroupCarryStore, string>>;
23
+ /** Documents one page reads from the source site. */
24
+ export declare const CONSENT_GROUP_CARRY_PAGE = 300;
25
+ export interface ConsentGroupCarryPage {
26
+ /** The source site has nothing after the cursor. */
27
+ done: boolean;
28
+ /** Where the next page starts, or `null` once `done`. */
29
+ cursor: string | null;
30
+ /** Source documents read. */
31
+ read: number;
32
+ /** Documents written on the receiving site (counted, not written, on a dry run). */
33
+ written: number;
34
+ }
35
+ export interface CarryConsentGroupRefusalsOptions {
36
+ firestore: FirebaseFirestore.Firestore;
37
+ store: ConsentGroupCarryStore;
38
+ carry: ConsentGroupCarry;
39
+ changeId: string;
40
+ cursor: string | null;
41
+ /** Only documents changed at or after this instant — the catch-up. */
42
+ sinceMs?: number | null;
43
+ pageSize?: number;
44
+ dryRun?: boolean;
45
+ }
46
+ /**
47
+ * One page of one store of one carry. See the module note for what each
48
+ * store copies and why a re-run writes nothing.
49
+ */
50
+ export declare function carryConsentGroupRefusals(options: CarryConsentGroupRefusalsOptions): Promise<ConsentGroupCarryPage>;
@@ -0,0 +1,319 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /**
18
+ * CARRYING A SITE'S REFUSALS ONTO A SITE THAT IS ABOUT TO STOP READING THEM
19
+ * (AGL-3320).
20
+ *
21
+ * Three per-site stores are read across the CURRENT consent group by every
22
+ * send path (`email-suppression.ts`, `email-marketing-gate.ts`), so a site
23
+ * leaving a group stops seeing its former siblings' — and they stop seeing
24
+ * its. Before the declaration flips, each carry `(to ← from)` of the plan
25
+ * copies `from`'s refusals onto `to`:
26
+ *
27
+ * - **C1, the suppression list** (`hosts/{h}/suppressions/{key}`). A row
28
+ * `to` does not have is CREATED with `from`'s fields and provenance; a row
29
+ * `to` has is never touched. `reason` is kept, so only an unsubscribe stays
30
+ * liftable by the preference page's resubscribe, and a group-wide
31
+ * resubscribe lifts a carried row like an original.
32
+ * - **C2, the topic opt-outs** (`hosts/{h}/topicOptOuts/{key}`). Every
33
+ * stream `from` has LEFT, and `to` has not, becomes an opt-out on `to`
34
+ * dated when the person left `from`. `to`'s other entries — its own
35
+ * confirmations, its own opt-outs — are kept as they are.
36
+ * - **C3, the pace** (`hosts/{h}/emailFrequency/{key}`). The choice made
37
+ * most recently wins, as `groupPace` reads it within a group, and a site
38
+ * keeping a slower pace learns when `from` last mailed the person, so the
39
+ * separation cannot loosen the pace they asked for. The send counters are
40
+ * never touched.
41
+ *
42
+ * ## One page per call, and every page idempotent
43
+ *
44
+ * The executor calls this until it answers `done`, handing back the cursor.
45
+ * A page reads `from`'s documents in id order and `to`'s matching documents
46
+ * in one `getAll`, and writes only what `to` lacks — so a page re-run after a
47
+ * crash, a catch-up pass and the sweep all write nothing that was already
48
+ * carried. A write that raced another writer (a `lastUpdateTime` precondition
49
+ * or a create that found the document) is re-read and decided again once;
50
+ * failing twice throws, and the executor retries the page.
51
+ *
52
+ * `sinceMs` narrows a page to what changed since then — the catch-up, which
53
+ * reads by `suppressedAt`, `updatedAt` and `cadenceSetAtMs`. A row written
54
+ * without one of those is left to the sweep's full pass.
55
+ */ import { readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
56
+ import { FieldPath, FieldValue, Timestamp } from "firebase-admin/firestore";
57
+ import { EMAIL_FREQUENCY_SUBCOLLECTION } from "./email-marketing-gate.js";
58
+ import { HOST_SUPPRESSIONS_SUBCOLLECTION } from "./email-suppression.js";
59
+ export const CONSENT_GROUP_CARRY_STORES = [
60
+ 'siteSuppressions',
61
+ 'topicOptOuts',
62
+ 'paces'
63
+ ];
64
+ /** Each store's subcollection under `hosts/{hostId}`. */ export const CONSENT_GROUP_CARRY_SUBCOLLECTIONS = {
65
+ siteSuppressions: HOST_SUPPRESSIONS_SUBCOLLECTION,
66
+ topicOptOuts: TOPIC_OPT_OUTS_SUBCOLLECTION,
67
+ paces: EMAIL_FREQUENCY_SUBCOLLECTION
68
+ };
69
+ /** The field a catch-up reads each store by, and whether it is a timestamp. */ const CATCH_UP_FIELDS = {
70
+ siteSuppressions: {
71
+ field: 'suppressedAt',
72
+ timestamp: true
73
+ },
74
+ topicOptOuts: {
75
+ field: 'updatedAt',
76
+ timestamp: true
77
+ },
78
+ paces: {
79
+ field: 'cadenceSetAtMs',
80
+ timestamp: false
81
+ }
82
+ };
83
+ /**
84
+ * The suppression row's fields a carry copies. Everything else a row could
85
+ * carry is either the carry's own provenance or not a fact about the refusal.
86
+ */ const SUPPRESSION_ROW_FIELDS = [
87
+ 'reason',
88
+ 'email',
89
+ 'createdAt',
90
+ 'suppressedAt',
91
+ 'note',
92
+ 'suppressedByUid',
93
+ 'campaignId',
94
+ 'topicId'
95
+ ];
96
+ /** Documents one page reads from the source site. */ export const CONSENT_GROUP_CARRY_PAGE = 300;
97
+ function readCursor(raw) {
98
+ if (!raw) return null;
99
+ try {
100
+ const parsed = JSON.parse(raw);
101
+ return typeof (parsed == null ? void 0 : parsed.id) === 'string' ? parsed : null;
102
+ } catch (unused) {
103
+ return null;
104
+ }
105
+ }
106
+ /** The error codes that mean "somebody wrote this document since I read it". */ const RACED = new Set([
107
+ 5,
108
+ 6,
109
+ 9
110
+ ]);
111
+ const codeOf = (error)=>error == null ? void 0 : error.code;
112
+ /** Milliseconds from a stored number or timestamp, or `null`. */ function storedMs(value) {
113
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
114
+ const toMillis = value == null ? void 0 : value.toMillis;
115
+ return typeof toMillis === 'function' ? toMillis.call(value) : null;
116
+ }
117
+ /**
118
+ * When the site last mailed the person, as the gate reads it: the stored
119
+ * instant, or the newest entry of a window written before it existed.
120
+ */ function lastSentMs(data) {
121
+ if (!data) return null;
122
+ const stored = Number(data['lastSentAtMs']);
123
+ if (Number.isFinite(stored) && stored > 0) return stored;
124
+ const window = Array.isArray(data['sentAtMs']) ? data['sentAtMs'].map(Number).filter(Number.isFinite) : [];
125
+ return window.length ? Math.max(...window) : null;
126
+ }
127
+ function decideSuppression(source, target, carry, changeId) {
128
+ var _source_data;
129
+ if (target.exists) return null;
130
+ const row = (_source_data = source.data()) != null ? _source_data : {};
131
+ const data = {};
132
+ for (const field of SUPPRESSION_ROW_FIELDS){
133
+ if (field in row) data[field] = row[field];
134
+ }
135
+ data['carriedFromHostId'] = carry.fromHostId;
136
+ data['carriedByChangeId'] = changeId;
137
+ data['carriedAt'] = FieldValue.serverTimestamp();
138
+ return {
139
+ create: true,
140
+ data
141
+ };
142
+ }
143
+ function decideTopics(source, target, carry) {
144
+ var _source_get, _ref, _ref1, _ref2;
145
+ const sourceTopics = (_source_get = source.get('topics')) != null ? _source_get : {};
146
+ const targetTopics = (_ref = target.exists ? target.get('topics') : null) != null ? _ref : {};
147
+ const next = _extends({}, targetTopics);
148
+ let changed = false;
149
+ for (const [topicId, entry] of Object.entries(sourceTopics)){
150
+ var _targetTopics_topicId;
151
+ if (readTopicSubscriptionState(entry) !== 'opted-out') continue;
152
+ if (readTopicSubscriptionState(targetTopics[topicId]) === 'opted-out') continue;
153
+ next[topicId] = _extends({}, (_targetTopics_topicId = targetTopics[topicId]) != null ? _targetTopics_topicId : {}, {
154
+ optedOutAt: entry.optedOutAt,
155
+ resubscribedAt: null,
156
+ carriedFromHostId: carry.fromHostId
157
+ });
158
+ changed = true;
159
+ }
160
+ if (!changed) return null;
161
+ const email = (_ref1 = (_ref2 = target.exists ? target.get('email') : null) != null ? _ref2 : source.get('email')) != null ? _ref1 : null;
162
+ return {
163
+ create: !target.exists,
164
+ data: _extends({
165
+ email,
166
+ topics: next,
167
+ updatedAt: FieldValue.serverTimestamp()
168
+ }, target.exists ? {} : {
169
+ createdAt: FieldValue.serverTimestamp()
170
+ })
171
+ };
172
+ }
173
+ function decidePace(source, target, carry) {
174
+ var _source_data, _target_data, _from_email;
175
+ const from = (_source_data = source.data()) != null ? _source_data : {};
176
+ const to = target.exists ? (_target_data = target.data()) != null ? _target_data : {} : undefined;
177
+ const patch = {};
178
+ const sourceChoice = from['cadence'];
179
+ const targetChoice = to == null ? void 0 : to['cadence'];
180
+ const sourceSetAt = Number(from['cadenceSetAtMs']);
181
+ const targetSetAt = Number(to == null ? void 0 : to['cadenceSetAtMs']);
182
+ const carried = sourceChoice != null && (targetChoice == null || (Number.isFinite(targetSetAt) ? targetSetAt : 0) < (Number.isFinite(sourceSetAt) ? sourceSetAt : 0));
183
+ if (carried) {
184
+ patch['cadence'] = sourceChoice;
185
+ if (Number.isFinite(sourceSetAt)) patch['cadenceSetAtMs'] = sourceSetAt;
186
+ patch['cadenceCarriedFromHostId'] = carry.fromHostId;
187
+ }
188
+ // The pace in force afterwards decides whether the last send matters: `all`
189
+ // sends as they come and never asks when the last one went.
190
+ const choice = carried ? sourceChoice : targetChoice;
191
+ if (choice != null && choice !== 'all') {
192
+ const sourceLast = lastSentMs(from);
193
+ const targetLast = lastSentMs(to);
194
+ if (sourceLast !== null && (targetLast === null || sourceLast > targetLast)) {
195
+ patch['lastSentAtMs'] = sourceLast;
196
+ }
197
+ }
198
+ if (!Object.keys(patch).length) return null;
199
+ return {
200
+ create: !target.exists,
201
+ data: target.exists ? patch : _extends({
202
+ email: (_from_email = from['email']) != null ? _from_email : null
203
+ }, patch)
204
+ };
205
+ }
206
+ function decide(store, source, target, carry, changeId) {
207
+ if (store === 'siteSuppressions') return decideSuppression(source, target, carry, changeId);
208
+ if (store === 'topicOptOuts') return decideTopics(source, target, carry);
209
+ return decidePace(source, target, carry);
210
+ }
211
+ /**
212
+ * One page of one store of one carry. See the module note for what each
213
+ * store copies and why a re-run writes nothing.
214
+ */ export async function carryConsentGroupRefusals(options) {
215
+ var _options_pageSize, _options_sinceMs;
216
+ const { firestore, store, carry, changeId } = options;
217
+ const subcollection = CONSENT_GROUP_CARRY_SUBCOLLECTIONS[store];
218
+ const sourceList = firestore.collection('hosts').doc(carry.fromHostId).collection(subcollection);
219
+ const targetList = firestore.collection('hosts').doc(carry.toHostId).collection(subcollection);
220
+ const limit = (_options_pageSize = options.pageSize) != null ? _options_pageSize : CONSENT_GROUP_CARRY_PAGE;
221
+ const cursor = readCursor(options.cursor);
222
+ const catchUp = CATCH_UP_FIELDS[store];
223
+ const since = (_options_sinceMs = options.sinceMs) != null ? _options_sinceMs : null;
224
+ let query = sourceList;
225
+ if (since !== null) {
226
+ query = query.where(catchUp.field, '>=', catchUp.timestamp ? Timestamp.fromMillis(since) : since).orderBy(catchUp.field).orderBy(FieldPath.documentId());
227
+ if (cursor && cursor.at !== undefined) {
228
+ const at = typeof cursor.at === 'number' ? cursor.at : new Timestamp(cursor.at.seconds, cursor.at.nanoseconds);
229
+ query = query.startAfter(at, cursor.id);
230
+ }
231
+ } else {
232
+ query = query.orderBy(FieldPath.documentId());
233
+ if (cursor) query = query.startAfter(cursor.id);
234
+ }
235
+ const page = await query.limit(limit).get();
236
+ if (page.empty) return {
237
+ done: true,
238
+ cursor: null,
239
+ read: 0,
240
+ written: 0
241
+ };
242
+ const targets = await firestore.getAll(...page.docs.map((doc)=>targetList.doc(doc.id)));
243
+ const decisions = page.docs.map((source, index)=>({
244
+ source,
245
+ target: targets[index],
246
+ decision: decide(store, source, targets[index], carry, changeId)
247
+ }));
248
+ const due = decisions.filter((entry)=>entry.decision !== null);
249
+ const written = options.dryRun ? due.length : await writeDecisions(firestore, targetList, store, carry, changeId, due);
250
+ const last = page.docs[page.docs.length - 1];
251
+ const next = {
252
+ id: last.id
253
+ };
254
+ if (since !== null) {
255
+ var _storedMs;
256
+ const value = last.get(catchUp.field);
257
+ next.at = value instanceof Timestamp ? {
258
+ seconds: value.seconds,
259
+ nanoseconds: value.nanoseconds
260
+ } : (_storedMs = storedMs(value)) != null ? _storedMs : 0;
261
+ }
262
+ const done = page.size < limit;
263
+ return {
264
+ done,
265
+ cursor: done ? null : JSON.stringify(next),
266
+ read: page.size,
267
+ written
268
+ };
269
+ }
270
+ /**
271
+ * Writes one page's decisions through a `BulkWriter`, each guarded against
272
+ * the document having moved since it was read, and decides the ones that
273
+ * raced once more from a fresh read.
274
+ */ async function writeDecisions(firestore, targetList, store, carry, changeId, due) {
275
+ let written = 0;
276
+ for(let round = 0; round < 2 && due.length; round += 1){
277
+ const writer = firestore.bulkWriter();
278
+ const outcomes = due.map(({ source, target, decision })=>{
279
+ const ref = targetList.doc(source.id);
280
+ const write = decision.create ? writer.create(ref, decision.data) : writer.update(ref, decision.data, {
281
+ lastUpdateTime: target.updateTime
282
+ });
283
+ return write.then(()=>({
284
+ source,
285
+ error: null
286
+ }), (error)=>({
287
+ source,
288
+ error: error != null ? error : new Error('The write failed')
289
+ }));
290
+ });
291
+ await writer.close();
292
+ const settled = await Promise.all(outcomes);
293
+ const retry = [];
294
+ for (const { source, error } of settled){
295
+ if (error === null) {
296
+ written += 1;
297
+ continue;
298
+ }
299
+ const code = codeOf(error);
300
+ if (code === undefined || !RACED.has(code)) throw error;
301
+ // The row a suppression carry would create exists now: `to` has its own.
302
+ if (store === 'siteSuppressions' && code === 6) continue;
303
+ retry.push(source);
304
+ }
305
+ if (!retry.length) return written;
306
+ const fresh = await firestore.getAll(...retry.map((source)=>targetList.doc(source.id)));
307
+ due = retry.map((source, index)=>({
308
+ source,
309
+ target: fresh[index],
310
+ decision: decide(store, source, fresh[index], carry, changeId)
311
+ })).filter((entry)=>entry.decision !== null);
312
+ }
313
+ if (due.length) {
314
+ throw new Error(`[consent-group-carry] ${due.length} document(s) on ${carry.toHostId} kept changing under the carry`);
315
+ }
316
+ return written;
317
+ }
318
+
319
+ //# sourceMappingURL=consent-group-carry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/consent-group-carry.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * CARRYING A SITE'S REFUSALS ONTO A SITE THAT IS ABOUT TO STOP READING THEM\n * (AGL-3320).\n *\n * Three per-site stores are read across the CURRENT consent group by every\n * send path (`email-suppression.ts`, `email-marketing-gate.ts`), so a site\n * leaving a group stops seeing its former siblings' — and they stop seeing\n * its. Before the declaration flips, each carry `(to ← from)` of the plan\n * copies `from`'s refusals onto `to`:\n *\n * - **C1, the suppression list** (`hosts/{h}/suppressions/{key}`). A row\n * `to` does not have is CREATED with `from`'s fields and provenance; a row\n * `to` has is never touched. `reason` is kept, so only an unsubscribe stays\n * liftable by the preference page's resubscribe, and a group-wide\n * resubscribe lifts a carried row like an original.\n * - **C2, the topic opt-outs** (`hosts/{h}/topicOptOuts/{key}`). Every\n * stream `from` has LEFT, and `to` has not, becomes an opt-out on `to`\n * dated when the person left `from`. `to`'s other entries — its own\n * confirmations, its own opt-outs — are kept as they are.\n * - **C3, the pace** (`hosts/{h}/emailFrequency/{key}`). The choice made\n * most recently wins, as `groupPace` reads it within a group, and a site\n * keeping a slower pace learns when `from` last mailed the person, so the\n * separation cannot loosen the pace they asked for. The send counters are\n * never touched.\n *\n * ## One page per call, and every page idempotent\n *\n * The executor calls this until it answers `done`, handing back the cursor.\n * A page reads `from`'s documents in id order and `to`'s matching documents\n * in one `getAll`, and writes only what `to` lacks — so a page re-run after a\n * crash, a catch-up pass and the sweep all write nothing that was already\n * carried. A write that raced another writer (a `lastUpdateTime` precondition\n * or a create that found the document) is re-read and decided again once;\n * failing twice throws, and the executor retries the page.\n *\n * `sinceMs` narrows a page to what changed since then — the catch-up, which\n * reads by `suppressedAt`, `updatedAt` and `cadenceSetAtMs`. A row written\n * without one of those is left to the sweep's full pass.\n */\n\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport type { ConsentGroupCarry } from '@aglyn/aglyn/app-utils/consent-group-change'\nimport { FieldPath, FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { EMAIL_FREQUENCY_SUBCOLLECTION } from './email-marketing-gate'\nimport { HOST_SUPPRESSIONS_SUBCOLLECTION } from './email-suppression'\n\n/** The three stores a carry copies, as the job counts them. */\nexport type ConsentGroupCarryStore = 'siteSuppressions' | 'topicOptOuts' | 'paces'\n\nexport const CONSENT_GROUP_CARRY_STORES: readonly ConsentGroupCarryStore[] = [\n 'siteSuppressions',\n 'topicOptOuts',\n 'paces',\n]\n\n/** Each store's subcollection under `hosts/{hostId}`. */\nexport const CONSENT_GROUP_CARRY_SUBCOLLECTIONS: Readonly<Record<ConsentGroupCarryStore, string>> = {\n siteSuppressions: HOST_SUPPRESSIONS_SUBCOLLECTION,\n topicOptOuts: TOPIC_OPT_OUTS_SUBCOLLECTION,\n paces: EMAIL_FREQUENCY_SUBCOLLECTION,\n}\n\n/** The field a catch-up reads each store by, and whether it is a timestamp. */\nconst CATCH_UP_FIELDS: Readonly<Record<ConsentGroupCarryStore, { field: string; timestamp: boolean }>> = {\n siteSuppressions: { field: 'suppressedAt', timestamp: true },\n topicOptOuts: { field: 'updatedAt', timestamp: true },\n paces: { field: 'cadenceSetAtMs', timestamp: false },\n}\n\n/**\n * The suppression row's fields a carry copies. Everything else a row could\n * carry is either the carry's own provenance or not a fact about the refusal.\n */\nconst SUPPRESSION_ROW_FIELDS = [\n 'reason',\n 'email',\n 'createdAt',\n 'suppressedAt',\n 'note',\n 'suppressedByUid',\n 'campaignId',\n 'topicId',\n] as const\n\n/** Documents one page reads from the source site. */\nexport const CONSENT_GROUP_CARRY_PAGE = 300\n\nexport interface ConsentGroupCarryPage {\n /** The source site has nothing after the cursor. */\n done: boolean\n /** Where the next page starts, or `null` once `done`. */\n cursor: string | null\n /** Source documents read. */\n read: number\n /** Documents written on the receiving site (counted, not written, on a dry run). */\n written: number\n}\n\nexport interface CarryConsentGroupRefusalsOptions {\n firestore: FirebaseFirestore.Firestore\n store: ConsentGroupCarryStore\n carry: ConsentGroupCarry\n changeId: string\n cursor: string | null\n /** Only documents changed at or after this instant — the catch-up. */\n sinceMs?: number | null\n pageSize?: number\n dryRun?: boolean\n}\n\ninterface Cursor {\n id: string\n /** The catch-up field's value on the last document read. */\n at?: { seconds: number; nanoseconds: number } | number\n}\n\nfunction readCursor(raw: string | null): Cursor | null {\n if (!raw) return null\n try {\n const parsed = JSON.parse(raw) as Cursor\n return typeof parsed?.id === 'string' ? parsed : null\n } catch {\n return null\n }\n}\n\n/** The error codes that mean \"somebody wrote this document since I read it\". */\nconst RACED = new Set([5, 6, 9])\nconst codeOf = (error: unknown): number | undefined => (error as { code?: number } | null)?.code\n\n/** Milliseconds from a stored number or timestamp, or `null`. */\nfunction storedMs(value: unknown): number | null {\n if (typeof value === 'number') return Number.isFinite(value) ? value : null\n const toMillis = (value as { toMillis?: () => number } | null)?.toMillis\n return typeof toMillis === 'function' ? toMillis.call(value) : null\n}\n\n/**\n * When the site last mailed the person, as the gate reads it: the stored\n * instant, or the newest entry of a window written before it existed.\n */\nfunction lastSentMs(data: Record<string, unknown> | undefined): number | null {\n if (!data) return null\n const stored = Number(data['lastSentAtMs'])\n if (Number.isFinite(stored) && stored > 0) return stored\n const window = Array.isArray(data['sentAtMs'])\n ? (data['sentAtMs'] as unknown[]).map(Number).filter(Number.isFinite)\n : []\n return window.length ? Math.max(...window) : null\n}\n\n/**\n * What `to` must become for one source document, or `null` when it already\n * honors everything `from` does. `create` says the document is new.\n */\ntype Decision = { create: boolean; data: Record<string, unknown> } | null\n\nfunction decideSuppression(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (target.exists) return null\n const row = source.data() ?? {}\n const data: Record<string, unknown> = {}\n for (const field of SUPPRESSION_ROW_FIELDS) {\n if (field in row) data[field] = row[field]\n }\n data['carriedFromHostId'] = carry.fromHostId\n data['carriedByChangeId'] = changeId\n data['carriedAt'] = FieldValue.serverTimestamp()\n return { create: true, data }\n}\n\nfunction decideTopics(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const sourceTopics = (source.get('topics') ?? {}) as Record<string, TopicSubscriptionEntry>\n const targetTopics = ((target.exists ? target.get('topics') : null) ?? {}) as Record<\n string,\n TopicSubscriptionEntry & Record<string, unknown>\n >\n const next: Record<string, unknown> = { ...targetTopics }\n let changed = false\n for (const [topicId, entry] of Object.entries(sourceTopics)) {\n if (readTopicSubscriptionState(entry) !== 'opted-out') continue\n if (readTopicSubscriptionState(targetTopics[topicId]) === 'opted-out') continue\n next[topicId] = {\n ...(targetTopics[topicId] ?? {}),\n optedOutAt: entry.optedOutAt,\n resubscribedAt: null,\n carriedFromHostId: carry.fromHostId,\n }\n changed = true\n }\n if (!changed) return null\n const email = (target.exists ? target.get('email') : null) ?? source.get('email') ?? null\n return {\n create: !target.exists,\n data: {\n email,\n topics: next,\n updatedAt: FieldValue.serverTimestamp(),\n ...(target.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n }\n}\n\nfunction decidePace(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const from = source.data() ?? {}\n const to = target.exists ? (target.data() ?? {}) : undefined\n const patch: Record<string, unknown> = {}\n const sourceChoice = from['cadence']\n const targetChoice = to?.['cadence']\n const sourceSetAt = Number(from['cadenceSetAtMs'])\n const targetSetAt = Number(to?.['cadenceSetAtMs'])\n const carried =\n sourceChoice != null &&\n (targetChoice == null ||\n (Number.isFinite(targetSetAt) ? targetSetAt : 0) <\n (Number.isFinite(sourceSetAt) ? sourceSetAt : 0))\n if (carried) {\n patch['cadence'] = sourceChoice\n if (Number.isFinite(sourceSetAt)) patch['cadenceSetAtMs'] = sourceSetAt\n patch['cadenceCarriedFromHostId'] = carry.fromHostId\n }\n // The pace in force afterwards decides whether the last send matters: `all`\n // sends as they come and never asks when the last one went.\n const choice = carried ? sourceChoice : targetChoice\n if (choice != null && choice !== 'all') {\n const sourceLast = lastSentMs(from)\n const targetLast = lastSentMs(to)\n if (sourceLast !== null && (targetLast === null || sourceLast > targetLast)) {\n patch['lastSentAtMs'] = sourceLast\n }\n }\n if (!Object.keys(patch).length) return null\n return {\n create: !target.exists,\n data: target.exists ? patch : { email: from['email'] ?? null, ...patch },\n }\n}\n\nfunction decide(\n store: ConsentGroupCarryStore,\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (store === 'siteSuppressions') return decideSuppression(source, target, carry, changeId)\n if (store === 'topicOptOuts') return decideTopics(source, target, carry)\n return decidePace(source, target, carry)\n}\n\n/**\n * One page of one store of one carry. See the module note for what each\n * store copies and why a re-run writes nothing.\n */\nexport async function carryConsentGroupRefusals(\n options: CarryConsentGroupRefusalsOptions,\n): Promise<ConsentGroupCarryPage> {\n const { firestore, store, carry, changeId } = options\n const subcollection = CONSENT_GROUP_CARRY_SUBCOLLECTIONS[store]\n const sourceList = firestore.collection('hosts').doc(carry.fromHostId).collection(subcollection)\n const targetList = firestore.collection('hosts').doc(carry.toHostId).collection(subcollection)\n const limit = options.pageSize ?? CONSENT_GROUP_CARRY_PAGE\n const cursor = readCursor(options.cursor)\n const catchUp = CATCH_UP_FIELDS[store]\n const since = options.sinceMs ?? null\n\n let query: FirebaseFirestore.Query = sourceList\n if (since !== null) {\n query = query\n .where(catchUp.field, '>=', catchUp.timestamp ? Timestamp.fromMillis(since) : since)\n .orderBy(catchUp.field)\n .orderBy(FieldPath.documentId())\n if (cursor && cursor.at !== undefined) {\n const at =\n typeof cursor.at === 'number' ? cursor.at : new Timestamp(cursor.at.seconds, cursor.at.nanoseconds)\n query = query.startAfter(at, cursor.id)\n }\n } else {\n query = query.orderBy(FieldPath.documentId())\n if (cursor) query = query.startAfter(cursor.id)\n }\n const page = await query.limit(limit).get()\n if (page.empty) return { done: true, cursor: null, read: 0, written: 0 }\n\n const targets = await firestore.getAll(...page.docs.map((doc) => targetList.doc(doc.id)))\n const decisions = page.docs.map((source, index) => ({\n source,\n target: targets[index],\n decision: decide(store, source, targets[index], carry, changeId),\n }))\n const due = decisions.filter((entry) => entry.decision !== null)\n const written = options.dryRun\n ? due.length\n : await writeDecisions(firestore, targetList, store, carry, changeId, due)\n\n const last = page.docs[page.docs.length - 1]\n const next: Cursor = { id: last.id }\n if (since !== null) {\n const value = last.get(catchUp.field)\n next.at =\n value instanceof Timestamp\n ? { seconds: value.seconds, nanoseconds: value.nanoseconds }\n : (storedMs(value) ?? 0)\n }\n const done = page.size < limit\n return { done, cursor: done ? null : JSON.stringify(next), read: page.size, written }\n}\n\n/**\n * Writes one page's decisions through a `BulkWriter`, each guarded against\n * the document having moved since it was read, and decides the ones that\n * raced once more from a fresh read.\n */\nasync function writeDecisions(\n firestore: FirebaseFirestore.Firestore,\n targetList: FirebaseFirestore.CollectionReference,\n store: ConsentGroupCarryStore,\n carry: ConsentGroupCarry,\n changeId: string,\n due: Array<{\n source: FirebaseFirestore.DocumentSnapshot\n target: FirebaseFirestore.DocumentSnapshot\n decision: Decision\n }>,\n): Promise<number> {\n let written = 0\n for (let round = 0; round < 2 && due.length; round += 1) {\n const writer = firestore.bulkWriter()\n const outcomes = due.map(({ source, target, decision }) => {\n const ref = targetList.doc(source.id)\n const write = (decision as NonNullable<Decision>).create\n ? writer.create(ref, (decision as NonNullable<Decision>).data)\n : writer.update(ref, (decision as NonNullable<Decision>).data, {\n lastUpdateTime: target.updateTime as FirebaseFirestore.Timestamp,\n })\n return write.then(\n () => ({ source, error: null as unknown }),\n (error: unknown) => ({ source, error: error ?? new Error('The write failed') }),\n )\n })\n await writer.close()\n const settled = await Promise.all(outcomes)\n const retry: FirebaseFirestore.DocumentSnapshot[] = []\n for (const { source, error } of settled) {\n if (error === null) {\n written += 1\n continue\n }\n const code = codeOf(error)\n if (code === undefined || !RACED.has(code)) throw error\n // The row a suppression carry would create exists now: `to` has its own.\n if (store === 'siteSuppressions' && code === 6) continue\n retry.push(source)\n }\n if (!retry.length) return written\n const fresh = await firestore.getAll(...retry.map((source) => targetList.doc(source.id)))\n due = retry\n .map((source, index) => ({\n source,\n target: fresh[index],\n decision: decide(store, source, fresh[index], carry, changeId),\n }))\n .filter((entry) => entry.decision !== null)\n }\n if (due.length) {\n throw new Error(\n `[consent-group-carry] ${due.length} document(s) on ${carry.toHostId} kept changing under the carry`,\n )\n }\n return written\n}\n"],"names":["readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","FieldPath","FieldValue","Timestamp","EMAIL_FREQUENCY_SUBCOLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","CONSENT_GROUP_CARRY_STORES","CONSENT_GROUP_CARRY_SUBCOLLECTIONS","siteSuppressions","topicOptOuts","paces","CATCH_UP_FIELDS","field","timestamp","SUPPRESSION_ROW_FIELDS","CONSENT_GROUP_CARRY_PAGE","readCursor","raw","parsed","JSON","parse","id","RACED","Set","codeOf","error","code","storedMs","value","Number","isFinite","toMillis","call","lastSentMs","data","stored","window","Array","isArray","map","filter","length","Math","max","decideSuppression","source","target","carry","changeId","exists","row","fromHostId","serverTimestamp","create","decideTopics","sourceTopics","get","targetTopics","next","changed","topicId","entry","Object","entries","optedOutAt","resubscribedAt","carriedFromHostId","email","topics","updatedAt","createdAt","decidePace","from","to","undefined","patch","sourceChoice","targetChoice","sourceSetAt","targetSetAt","carried","choice","sourceLast","targetLast","keys","decide","store","carryConsentGroupRefusals","options","firestore","subcollection","sourceList","collection","doc","targetList","toHostId","limit","pageSize","cursor","catchUp","since","sinceMs","query","where","fromMillis","orderBy","documentId","at","seconds","nanoseconds","startAfter","page","empty","done","read","written","targets","getAll","docs","decisions","index","decision","due","dryRun","writeDecisions","last","size","stringify","round","writer","bulkWriter","outcomes","ref","write","update","lastUpdateTime","updateTime","then","Error","close","settled","Promise","all","retry","has","push","fresh"],"mappings":"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"}