@aglyn/tenant-data-admin 1.0.0-beta.221 → 1.0.0-beta.223
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/package.json +7 -7
- package/src/lib/server/api-keys.js +3 -3
- package/src/lib/server/api-keys.js.map +1 -1
- package/src/lib/server/api-v1-resources.d.ts +44 -3
- package/src/lib/server/api-v1-resources.js +82 -5
- package/src/lib/server/api-v1-resources.js.map +1 -1
- package/src/lib/server/collection-live-pages.d.ts +2 -2
- package/src/lib/server/collection-live-pages.js +1 -1
- package/src/lib/server/collection-live-pages.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +17 -73
- package/src/lib/server/email-delivery-log.js +44 -222
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +27 -38
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/erase-person.d.ts +34 -54
- package/src/lib/server/erase-person.js +44 -239
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/ga4-measurement-protocol.js +41 -28
- package/src/lib/server/ga4-measurement-protocol.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +25 -4
- package/src/lib/server/host-visitor-records.js +39 -11
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.js +52 -36
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/link-reputation-review.d.ts +6 -2
- package/src/lib/server/link-reputation-review.js +61 -20
- package/src/lib/server/link-reputation-review.js.map +1 -1
- package/src/lib/server/live-page-drops.d.ts +67 -0
- package/src/lib/server/live-page-drops.js +57 -0
- package/src/lib/server/live-page-drops.js.map +1 -0
- package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
- package/src/lib/server/media-bandwidth-cap.js +180 -0
- package/src/lib/server/media-bandwidth-cap.js.map +1 -0
- package/src/lib/server/media-cdn-icon.d.ts +77 -0
- package/src/lib/server/media-cdn-icon.js +140 -0
- package/src/lib/server/media-cdn-icon.js.map +1 -0
- package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
- package/src/lib/server/media-delivery-regeneration.js +216 -0
- package/src/lib/server/media-delivery-regeneration.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +2 -0
- package/src/lib/server/media-delivery.js +2 -1
- package/src/lib/server/media-delivery.js.map +1 -1
- package/src/lib/server/media-serve-count.d.ts +50 -0
- package/src/lib/server/media-serve-count.js +62 -0
- package/src/lib/server/media-serve-count.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +13 -2
- package/src/lib/server/media-signing.js +12 -8
- package/src/lib/server/media-signing.js.map +1 -1
- package/src/lib/server/media-tombstone.js +7 -5
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/media-variants.d.ts +151 -20
- package/src/lib/server/media-variants.js +204 -27
- package/src/lib/server/media-variants.js.map +1 -1
- package/src/lib/server/organizations.d.ts +102 -2
- package/src/lib/server/organizations.js +388 -58
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/outbound-send-review.js +11 -7
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/paged-sweeps.d.ts +51 -0
- package/src/lib/server/paged-sweeps.js +79 -0
- package/src/lib/server/paged-sweeps.js.map +1 -0
- package/src/lib/server/payment-fraud-signal.d.ts +49 -9
- package/src/lib/server/payment-fraud-signal.js +192 -48
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/platform-marketing-consent.d.ts +0 -49
- package/src/lib/server/platform-marketing-consent.js +27 -11
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/render-monitor.d.ts +166 -0
- package/src/lib/server/render-monitor.js +412 -0
- package/src/lib/server/render-monitor.js.map +1 -0
- package/src/lib/server/risk-notice.d.ts +7 -0
- package/src/lib/server/risk-notice.js +6 -0
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/serve-media-cdn.d.ts +69 -1
- package/src/lib/server/serve-media-cdn.js +424 -70
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/site-icon-render.d.ts +68 -0
- package/src/lib/server/site-icon-render.js +157 -0
- package/src/lib/server/site-icon-render.js.map +1 -0
- package/src/lib/server/staff-seat.d.ts +50 -0
- package/src/lib/server/staff-seat.js +118 -0
- package/src/lib/server/staff-seat.js.map +1 -0
- package/src/lib/server/transfer-export.d.ts +46 -0
- package/src/lib/server/transfer-export.js +221 -0
- package/src/lib/server/transfer-export.js.map +1 -0
- package/src/lib/server/transfer-jobs.d.ts +385 -0
- package/src/lib/server/transfer-jobs.js +2368 -0
- package/src/lib/server/transfer-jobs.js.map +1 -0
- package/src/lib/server/transfer-packages.d.ts +127 -0
- package/src/lib/server/transfer-packages.js +1036 -0
- package/src/lib/server/transfer-packages.js.map +1 -0
- package/src/lib/server/upgrade-proposal.d.ts +97 -0
- package/src/lib/server/upgrade-proposal.js +312 -0
- package/src/lib/server/upgrade-proposal.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +17 -8
- package/src/lib/server/upsert-contact.js +82 -16
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/lib/server/web-risk.d.ts +50 -9
- package/src/lib/server/web-risk.js +290 -155
- package/src/lib/server/web-risk.js.map +1 -1
- package/src/tenancy.d.ts +3 -3
- package/src/tenancy.js +5 -3
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/campaign-attribution-store.d.ts +0 -55
- package/src/lib/server/campaign-attribution-store.js +0 -95
- package/src/lib/server/campaign-attribution-store.js.map +0 -1
- package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
- package/src/lib/server/campaign-conversion-attribution.js +0 -450
- package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
- package/src/lib/server/crm-email-activity.d.ts +0 -118
- package/src/lib/server/crm-email-activity.js +0 -148
- package/src/lib/server/crm-email-activity.js.map +0 -1
- package/src/lib/server/crm-next-activity.d.ts +0 -74
- package/src/lib/server/crm-next-activity.js +0 -194
- package/src/lib/server/crm-next-activity.js.map +0 -1
- package/src/lib/server/dataset-live-pages.d.ts +0 -179
- package/src/lib/server/dataset-live-pages.js +0 -421
- package/src/lib/server/dataset-live-pages.js.map +0 -1
- package/src/lib/server/email-revenue-attribution.d.ts +0 -185
- package/src/lib/server/email-revenue-attribution.js +0 -321
- package/src/lib/server/email-revenue-attribution.js.map +0 -1
|
@@ -1,421 +0,0 @@
|
|
|
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
|
-
* A dataset record write refreshes the live pages that show it (AGL-3113).
|
|
19
|
-
*
|
|
20
|
-
* ## What was missing
|
|
21
|
-
*
|
|
22
|
-
* Every other change a visitor can see announces itself the moment it lands: a
|
|
23
|
-
* publish, a layout, a component, a content entry, a redirect rule, the
|
|
24
|
-
* maintenance toggle. A dataset RECORD did not. It was written — by a form, by
|
|
25
|
-
* an automation, over `/v1`, from the console — and the pages repeating over
|
|
26
|
-
* it went on serving the rows they were built from until the hour-long window
|
|
27
|
-
* lapsed. `PUBLISHED_SITE_DATA_TTL_SECONDS` was the propagation mechanism,
|
|
28
|
-
* which is the same mistake AGL-1302 and AGL-1150 each had to undo elsewhere:
|
|
29
|
-
* a backstop doing a job nothing else was doing.
|
|
30
|
-
*
|
|
31
|
-
* ## Scope, not the site
|
|
32
|
-
*
|
|
33
|
-
* A record changed one dataset, so this drops the pages that repeat over that
|
|
34
|
-
* dataset and nothing else. The set is derived from the SAME binding the
|
|
35
|
-
* published page expands — `repeatKeys`, the generic
|
|
36
|
-
* `props.repeatDataset` any node can now carry (AGL-3111) — rather than from a
|
|
37
|
-
* list of components that may repeat. A component allowlist was already wrong
|
|
38
|
-
* the day repeat stopped being the Stack's: a heading bound to a dataset
|
|
39
|
-
* renders rows exactly as a Stack does, and would have been missed.
|
|
40
|
-
*
|
|
41
|
-
* Three corpora, because the composer reads repeat keys after the layout chain
|
|
42
|
-
* and the reusable-component graft: a repeat in a layout's chrome or inside a
|
|
43
|
-
* component reaches every screen that renders it.
|
|
44
|
-
*
|
|
45
|
-
* ## Which sites
|
|
46
|
-
*
|
|
47
|
-
* Datasets are ORG-scoped and shared with sites through `visibleTo` — every
|
|
48
|
-
* site in the org, or up to `MAX_SCOPE_HOSTS` named ones. Each site caches its
|
|
49
|
-
* own copy of the rows under its own `tenant-data:{hostId}` tag and its own
|
|
50
|
-
* page keys, so one record write is a fan-out. A missing `visibleTo` is
|
|
51
|
-
* visible to NOBODY and announces nothing; defaulting it to org-wide here
|
|
52
|
-
* would re-make AGL-1466 in the cache layer.
|
|
53
|
-
*
|
|
54
|
-
* ## Best effort, always
|
|
55
|
-
*
|
|
56
|
-
* The record is already written by the time any of this runs. A refused or
|
|
57
|
-
* failed drop is reported to the caller and logged, never thrown: a record
|
|
58
|
-
* must not be lost because a cache refused, and the TTL is still underneath as
|
|
59
|
-
* the backstop it was always meant to be.
|
|
60
|
-
*/ import { ORG_SCOPE_TOKEN, hostIdsFromScope, repeatKeys, screenRoutePathToUrl } from "@aglyn/aglyn/server";
|
|
61
|
-
import { readUsageSources, screenIdsUsingComponentDeep, screenIdsUsingLayoutDeep, isLiveUsageCandidate } from "./live-page-usage.js";
|
|
62
|
-
/**
|
|
63
|
-
* How many documents per collection the per-site scan reads.
|
|
64
|
-
*
|
|
65
|
-
* The console's publish scan uses the same number for the same reason
|
|
66
|
-
* (AGL-1161): this decides which caches get dropped, so a prefix scan would
|
|
67
|
-
* report a refreshed site and leave real pages serving the old rows. Bounded
|
|
68
|
-
* all the same, and when the bound bites it is SAID rather than absorbed.
|
|
69
|
-
*/ export const DATASET_SCAN_LIMIT = 2000;
|
|
70
|
-
/**
|
|
71
|
-
* How many sites one record write fans out to.
|
|
72
|
-
*
|
|
73
|
-
* `MAX_SCOPE_HOSTS` is the ceiling on a NAMED selection, so only an org-wide
|
|
74
|
-
* dataset can reach this — an org with more sites than this pays a scan per
|
|
75
|
-
* site on every write otherwise, which is a cost the person writing one row
|
|
76
|
-
* did not ask for. Sites past the cap keep the TTL they had before this
|
|
77
|
-
* existed, and the shortfall is returned and logged rather than swallowed.
|
|
78
|
-
*/ export const MAX_DATASET_FANOUT_HOSTS = 30;
|
|
79
|
-
/**
|
|
80
|
-
* How long one site-and-dataset pair waits before announcing again.
|
|
81
|
-
*
|
|
82
|
-
* A 1,000-row import is one announce because the importer announces after its
|
|
83
|
-
* loop, not per row. This covers the other burst — many small writes arriving
|
|
84
|
-
* separately, a form under load or an automation fanning out — where each one
|
|
85
|
-
* would otherwise pay a full scan per site and hand the tenant the same path
|
|
86
|
-
* list again.
|
|
87
|
-
*
|
|
88
|
-
* Leading edge, so the FIRST write of a burst refreshes immediately; what is
|
|
89
|
-
* skipped is a repeat inside the window, and the next write after it announces
|
|
90
|
-
* again. The cost of the skip is bounded by the window: a row written during
|
|
91
|
-
* one is visible no later than the next announce.
|
|
92
|
-
*/ export const DATASET_ANNOUNCE_WINDOW_MS = 2000;
|
|
93
|
-
/** Last announce per `orgId/datasetId/hostId`, for the window above. */ const lastAnnouncedAt = new Map();
|
|
94
|
-
/**
|
|
95
|
-
* Bounded so a long-lived server cannot grow this map without limit. Far above
|
|
96
|
-
* the number of datasets any one instance writes in a window; when it bites,
|
|
97
|
-
* the oldest entries simply lose their throttle and announce again.
|
|
98
|
-
*/ const MAX_THROTTLE_ENTRIES = 5000;
|
|
99
|
-
function throttleAllows(key, now) {
|
|
100
|
-
const previous = lastAnnouncedAt.get(key);
|
|
101
|
-
if (previous !== undefined && now - previous < DATASET_ANNOUNCE_WINDOW_MS) {
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
if (lastAnnouncedAt.size >= MAX_THROTTLE_ENTRIES) lastAnnouncedAt.clear();
|
|
105
|
-
lastAnnouncedAt.set(key, now);
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
/** Test seam: the window is process-wide state, so a spec has to reset it. */ export function resetDatasetAnnounceThrottle() {
|
|
109
|
-
lastAnnouncedAt.clear();
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
|
-
* Whether a node tree repeats over any of `keys`.
|
|
113
|
-
*
|
|
114
|
-
* Asks `repeatKeys` — literally the function the tenant composer calls
|
|
115
|
-
* to decide which datasets to load for a page — so this cannot come to
|
|
116
|
-
* disagree with what actually renders. A predicate even slightly narrower than
|
|
117
|
-
* the expansion's is a page that renders rows and never refreshes them.
|
|
118
|
-
*/ function nodesRepeatDataset(nodes, keys) {
|
|
119
|
-
return repeatKeys(nodes).some((key)=>keys.has(key));
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* Every live screen whose rendered output repeats over the dataset, however
|
|
123
|
-
* indirectly.
|
|
124
|
-
*
|
|
125
|
-
* The same three-bucket closure a placed form's pages are found by, for the
|
|
126
|
-
* same reason: a binding is found by SEARCHING node trees rather than by
|
|
127
|
-
* matching a pointer, so it can sit on the screen, in the chrome of the layout
|
|
128
|
-
* the screen renders inside, or inside a reusable component a third component
|
|
129
|
-
* nests. After the first level the fan-out is identical, so it is delegated
|
|
130
|
-
* rather than restated.
|
|
131
|
-
*
|
|
132
|
-
* Pure, and separate from the Firestore read, so the closure is testable
|
|
133
|
-
* without a database.
|
|
134
|
-
*/ export function screenIdsRepeatingDataset(keys, sources) {
|
|
135
|
-
if (!keys.size) return [];
|
|
136
|
-
const screenIds = new Set();
|
|
137
|
-
for (const candidate of sources.screens){
|
|
138
|
-
if (!isLiveUsageCandidate(candidate)) continue;
|
|
139
|
-
if (nodesRepeatDataset(candidate.nodes, keys)) screenIds.add(candidate.id);
|
|
140
|
-
}
|
|
141
|
-
for (const candidate of sources.layouts){
|
|
142
|
-
if (!isLiveUsageCandidate(candidate)) continue;
|
|
143
|
-
if (!nodesRepeatDataset(candidate.nodes, keys)) continue;
|
|
144
|
-
// The layout itself renders no URL; the screens beneath it do.
|
|
145
|
-
for (const screenId of screenIdsUsingLayoutDeep(candidate.id, sources.screens, sources.layouts)){
|
|
146
|
-
screenIds.add(screenId);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
for (const candidate of sources.components){
|
|
150
|
-
if (!isLiveUsageCandidate(candidate)) continue;
|
|
151
|
-
if (!nodesRepeatDataset(candidate.nodes, keys)) continue;
|
|
152
|
-
// And a component renders wherever it is placed, however deeply nested.
|
|
153
|
-
for (const screenId of screenIdsUsingComponentDeep(candidate.id, sources)){
|
|
154
|
-
screenIds.add(screenId);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
return [
|
|
158
|
-
...screenIds
|
|
159
|
-
];
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* The keys a page can be bound to this dataset by: its id, and the display
|
|
163
|
-
* name editors type into the Repeat attribute.
|
|
164
|
-
*
|
|
165
|
-
* Both, because the expansion looks a key up either way (the data plugin's
|
|
166
|
-
* repeat rows resolve an id first and then a display name). Matching only the id would
|
|
167
|
-
* miss every binding written by name, which is the older and still-supported
|
|
168
|
-
* half of them.
|
|
169
|
-
*/ function datasetKeysOf(snapshot) {
|
|
170
|
-
var _snapshot_get;
|
|
171
|
-
const keys = new Set([
|
|
172
|
-
snapshot.id
|
|
173
|
-
]);
|
|
174
|
-
const displayName = String((_snapshot_get = snapshot.get('displayName')) != null ? _snapshot_get : '').trim();
|
|
175
|
-
if (displayName) keys.add(displayName);
|
|
176
|
-
return keys;
|
|
177
|
-
}
|
|
178
|
-
/**
|
|
179
|
-
* The sites a dataset is shared with, as host ids.
|
|
180
|
-
*
|
|
181
|
-
* Fails CLOSED on a missing or empty `visibleTo`, matching `visibleToHost` and
|
|
182
|
-
* both enforcement layers underneath it: a document nobody scoped is shared
|
|
183
|
-
* with nobody, and reading it as org-wide here would announce one org's
|
|
184
|
-
* dataset change to sites that cannot see the dataset at all.
|
|
185
|
-
*/ async function hostIdsSharingDataset(firestore, orgId, visibleTo) {
|
|
186
|
-
if (!Array.isArray(visibleTo) || !visibleTo.length) {
|
|
187
|
-
return {
|
|
188
|
-
hostIds: [],
|
|
189
|
-
hostsDropped: 0
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
if (!visibleTo.includes(ORG_SCOPE_TOKEN)) {
|
|
193
|
-
const named = hostIdsFromScope(visibleTo);
|
|
194
|
-
return {
|
|
195
|
-
hostIds: named,
|
|
196
|
-
hostsDropped: 0
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
// One over the cap, so a shortfall is detected rather than assumed away.
|
|
200
|
-
const hosts = await firestore.collection('hosts').where('orgId', '==', orgId).limit(MAX_DATASET_FANOUT_HOSTS + 1).get();
|
|
201
|
-
const all = hosts.docs.map((doc)=>doc.id);
|
|
202
|
-
return {
|
|
203
|
-
hostIds: all.slice(0, MAX_DATASET_FANOUT_HOSTS),
|
|
204
|
-
hostsDropped: Math.max(0, all.length - MAX_DATASET_FANOUT_HOSTS)
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
/** One site's paths, or `null` when the site has nothing to refresh. */ async function targetForHost(firestore, hostId, keys) {
|
|
208
|
-
var _hostSnapshot_get, _hostSnapshot_get1, _hostSnapshot_get2;
|
|
209
|
-
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
210
|
-
const hostSnapshot = await hostRef.get();
|
|
211
|
-
if (!hostSnapshot.exists) return null;
|
|
212
|
-
const subdomain = String((_hostSnapshot_get = hostSnapshot.get('subdomain')) != null ? _hostSnapshot_get : '');
|
|
213
|
-
// Without a subdomain there is no tenant deployment holding cached pages for
|
|
214
|
-
// this site — the ordinary state of one not yet given a name, not a failure.
|
|
215
|
-
if (!subdomain) return null;
|
|
216
|
-
const sources = await readUsageSources(hostRef, DATASET_SCAN_LIMIT);
|
|
217
|
-
const screens = (_hostSnapshot_get1 = hostSnapshot.get('screens')) != null ? _hostSnapshot_get1 : {};
|
|
218
|
-
const paths = screenIdsRepeatingDataset(keys, sources.candidates).map((screenId)=>screens[screenId]).filter((path)=>Boolean(path)).map((path)=>screenRoutePathToUrl(path)).filter((path, index, all)=>all.indexOf(path) === index);
|
|
219
|
-
if (!paths.length) return null;
|
|
220
|
-
const cname = String((_hostSnapshot_get2 = hostSnapshot.get('cname')) != null ? _hostSnapshot_get2 : '');
|
|
221
|
-
return _extends({
|
|
222
|
-
hostId,
|
|
223
|
-
subdomain
|
|
224
|
-
}, cname ? {
|
|
225
|
-
cname
|
|
226
|
-
} : {}, {
|
|
227
|
-
paths,
|
|
228
|
-
truncated: sources.truncated
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
/**
|
|
232
|
-
* Every live page a change to this dataset's records makes stale, per site.
|
|
233
|
-
*
|
|
234
|
-
* Reads only; announcing is the caller's, because the two runtimes announce
|
|
235
|
-
* differently — the console posts to the tenant over the shared secret, and
|
|
236
|
-
* the tenant drops its own caches in process. Splitting it here is what lets
|
|
237
|
-
* both use one answer to the question of WHICH pages.
|
|
238
|
-
*
|
|
239
|
-
* Never throws. A scope that could not be computed is a scope of nothing, and
|
|
240
|
-
* the TTL underneath is unaffected.
|
|
241
|
-
*/ export async function datasetLivePageScope(options) {
|
|
242
|
-
const { firestore, orgId, datasetId } = options;
|
|
243
|
-
if (!orgId || !datasetId) {
|
|
244
|
-
return {
|
|
245
|
-
targets: [],
|
|
246
|
-
hostsDropped: 0,
|
|
247
|
-
reason: 'no-dataset'
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
try {
|
|
251
|
-
const snapshot = await firestore.collection('orgs').doc(orgId).collection('datasets').doc(datasetId).get();
|
|
252
|
-
if (!snapshot.exists) {
|
|
253
|
-
return {
|
|
254
|
-
targets: [],
|
|
255
|
-
hostsDropped: 0,
|
|
256
|
-
reason: 'no-dataset'
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
const { hostIds, hostsDropped } = await hostIdsSharingDataset(firestore, orgId, snapshot.get('visibleTo'));
|
|
260
|
-
if (!hostIds.length) {
|
|
261
|
-
return {
|
|
262
|
-
targets: [],
|
|
263
|
-
hostsDropped,
|
|
264
|
-
reason: 'no-sites'
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
const keys = datasetKeysOf(snapshot);
|
|
268
|
-
const resolved = await Promise.all(hostIds.map(async (hostId)=>{
|
|
269
|
-
try {
|
|
270
|
-
return await targetForHost(firestore, hostId, keys);
|
|
271
|
-
} catch (error) {
|
|
272
|
-
// One unreadable site must not cost the others their refresh.
|
|
273
|
-
console.error('[dataset-live-pages] site scan failed', hostId, error);
|
|
274
|
-
return null;
|
|
275
|
-
}
|
|
276
|
-
}));
|
|
277
|
-
const targets = resolved.filter((target)=>target !== null);
|
|
278
|
-
return {
|
|
279
|
-
targets,
|
|
280
|
-
hostsDropped,
|
|
281
|
-
reason: targets.length ? 'ok' : 'not-rendered'
|
|
282
|
-
};
|
|
283
|
-
} catch (error) {
|
|
284
|
-
console.error('[dataset-live-pages] scope failed', datasetId, error);
|
|
285
|
-
return {
|
|
286
|
-
targets: [],
|
|
287
|
-
hostsDropped: 0,
|
|
288
|
-
reason: 'error'
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
let registeredDropper;
|
|
293
|
-
/**
|
|
294
|
-
* Registers the dropper for this process, replacing any previous one. Returns
|
|
295
|
-
* the unregister, which removes it only if it is still the one registered.
|
|
296
|
-
*
|
|
297
|
-
* Called from a function the platform calls BY NAME at boot, never from a
|
|
298
|
-
* module's top level: a bundler deletes a module imported only for its side
|
|
299
|
-
* effect when its package says it has none (AGL-3025).
|
|
300
|
-
*/ export function registerLivePageDropper(drop) {
|
|
301
|
-
registeredDropper = drop;
|
|
302
|
-
return ()=>{
|
|
303
|
-
if (registeredDropper === drop) registeredDropper = undefined;
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
const EMPTY_RESULT = {
|
|
307
|
-
sitesRefreshed: 0,
|
|
308
|
-
pathsRefreshed: 0,
|
|
309
|
-
sitesRefused: 0,
|
|
310
|
-
hostsDropped: 0,
|
|
311
|
-
scanTruncated: false
|
|
312
|
-
};
|
|
313
|
-
/**
|
|
314
|
-
* Refresh every live page that shows this dataset's records.
|
|
315
|
-
*
|
|
316
|
-
* The one call a write path makes. BEST EFFORT, ALWAYS: it never throws and
|
|
317
|
-
* never rejects, so a caller can `await` it inside the try that already guards
|
|
318
|
-
* the write without any chance of turning a stored record into a failed one.
|
|
319
|
-
*
|
|
320
|
-
* `drop` overrides the registered dropper for callers that hold their own —
|
|
321
|
-
* the console, whose announce is an authenticated request rather than a local
|
|
322
|
-
* cache call.
|
|
323
|
-
*/ export async function announceDatasetRecordChange(options) {
|
|
324
|
-
var _options_drop;
|
|
325
|
-
const { firestore, orgId, datasetId } = options;
|
|
326
|
-
const drop = (_options_drop = options.drop) != null ? _options_drop : registeredDropper;
|
|
327
|
-
if (!drop) {
|
|
328
|
-
// Said out loud: a process that writes records and registered no dropper
|
|
329
|
-
// is one where this feature is silently absent, which reads exactly like
|
|
330
|
-
// a working one until somebody watches a page fail to change.
|
|
331
|
-
console.warn(JSON.stringify({
|
|
332
|
-
tag: 'AGL-3113:no-live-page-dropper',
|
|
333
|
-
orgId,
|
|
334
|
-
datasetId
|
|
335
|
-
}));
|
|
336
|
-
return _extends({}, EMPTY_RESULT, {
|
|
337
|
-
reason: 'no-dropper'
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
const throttleKey = `${orgId}/${datasetId}`;
|
|
341
|
-
if (!throttleAllows(throttleKey, Date.now())) {
|
|
342
|
-
return _extends({}, EMPTY_RESULT, {
|
|
343
|
-
reason: 'throttled'
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
const scope = await datasetLivePageScope({
|
|
347
|
-
firestore,
|
|
348
|
-
orgId,
|
|
349
|
-
datasetId
|
|
350
|
-
});
|
|
351
|
-
if (!scope.targets.length) {
|
|
352
|
-
return _extends({}, EMPTY_RESULT, {
|
|
353
|
-
hostsDropped: scope.hostsDropped,
|
|
354
|
-
reason: scope.reason
|
|
355
|
-
});
|
|
356
|
-
}
|
|
357
|
-
let sitesRefreshed = 0;
|
|
358
|
-
let sitesRefused = 0;
|
|
359
|
-
let pathsRefreshed = 0;
|
|
360
|
-
for (const target of scope.targets){
|
|
361
|
-
let landed = false;
|
|
362
|
-
try {
|
|
363
|
-
landed = await drop(target);
|
|
364
|
-
} catch (error) {
|
|
365
|
-
// A dropper that throws is a dropper that failed; it is not a write
|
|
366
|
-
// that failed.
|
|
367
|
-
console.error('[dataset-live-pages] drop failed', target.hostId, error);
|
|
368
|
-
}
|
|
369
|
-
if (landed) {
|
|
370
|
-
sitesRefreshed += 1;
|
|
371
|
-
pathsRefreshed += target.paths.length;
|
|
372
|
-
} else {
|
|
373
|
-
sitesRefused += 1;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
const scanTruncated = scope.targets.some((target)=>target.truncated);
|
|
377
|
-
const result = {
|
|
378
|
-
sitesRefreshed,
|
|
379
|
-
pathsRefreshed,
|
|
380
|
-
sitesRefused,
|
|
381
|
-
hostsDropped: scope.hostsDropped,
|
|
382
|
-
scanTruncated,
|
|
383
|
-
reason: scope.reason
|
|
384
|
-
};
|
|
385
|
-
// ONE line per announce, on success as well as failure, for the reason
|
|
386
|
-
// `postTenantRevalidate` gives: a record that only logs its failures cannot
|
|
387
|
-
// tell "every write refreshed" apart from "the call never happened", and
|
|
388
|
-
// that ambiguity is how an eleven-day propagation outage went unnoticed.
|
|
389
|
-
console.log(JSON.stringify(_extends({
|
|
390
|
-
tag: 'AGL-3113:dataset-announce',
|
|
391
|
-
orgId,
|
|
392
|
-
datasetId
|
|
393
|
-
}, result)));
|
|
394
|
-
return result;
|
|
395
|
-
}
|
|
396
|
-
/**
|
|
397
|
-
* One sentence for a refresh that did not cover every page, or `null` when it
|
|
398
|
-
* did.
|
|
399
|
-
*
|
|
400
|
-
* Beside the call rather than at each surface, so the console cannot word this
|
|
401
|
-
* differently in two places — the same reason `describeRevalidateShortfall`
|
|
402
|
-
* lives beside `revalidateLivePages`. `lead` names the act that succeeded,
|
|
403
|
-
* because only that word differs between surfaces: a record is SAVED, and
|
|
404
|
-
* telling someone who fixed a typo that they published it is a worse error
|
|
405
|
-
* than the staleness the sentence is about.
|
|
406
|
-
*/ export function describeDatasetAnnounceShortfall(result, lead = 'Saved.') {
|
|
407
|
-
if (!result) return null;
|
|
408
|
-
// `not-rendered`, `no-sites` and `throttled` are deliberately silent: a
|
|
409
|
-
// dataset no page shows has no stale page, and a throttled drop means one
|
|
410
|
-
// already went out a moment ago. Saying anything would be alarming and wrong.
|
|
411
|
-
if (result.reason === 'error' || result.sitesRefused) {
|
|
412
|
-
return `${lead} The live pages could not be refreshed just now, so they ` + 'may show the previous records for up to an hour.';
|
|
413
|
-
}
|
|
414
|
-
if (result.hostsDropped || result.scanTruncated) {
|
|
415
|
-
return `${lead} Some pages that use this dataset are too many to refresh at ` + 'once — they update on their own within an hour.';
|
|
416
|
-
}
|
|
417
|
-
return null;
|
|
418
|
-
}
|
|
419
|
-
export default announceDatasetRecordChange;
|
|
420
|
-
|
|
421
|
-
//# sourceMappingURL=dataset-live-pages.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/dataset-live-pages.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 * A dataset record write refreshes the live pages that show it (AGL-3113).\n *\n * ## What was missing\n *\n * Every other change a visitor can see announces itself the moment it lands: a\n * publish, a layout, a component, a content entry, a redirect rule, the\n * maintenance toggle. A dataset RECORD did not. It was written — by a form, by\n * an automation, over `/v1`, from the console — and the pages repeating over\n * it went on serving the rows they were built from until the hour-long window\n * lapsed. `PUBLISHED_SITE_DATA_TTL_SECONDS` was the propagation mechanism,\n * which is the same mistake AGL-1302 and AGL-1150 each had to undo elsewhere:\n * a backstop doing a job nothing else was doing.\n *\n * ## Scope, not the site\n *\n * A record changed one dataset, so this drops the pages that repeat over that\n * dataset and nothing else. The set is derived from the SAME binding the\n * published page expands — `repeatKeys`, the generic\n * `props.repeatDataset` any node can now carry (AGL-3111) — rather than from a\n * list of components that may repeat. A component allowlist was already wrong\n * the day repeat stopped being the Stack's: a heading bound to a dataset\n * renders rows exactly as a Stack does, and would have been missed.\n *\n * Three corpora, because the composer reads repeat keys after the layout chain\n * and the reusable-component graft: a repeat in a layout's chrome or inside a\n * component reaches every screen that renders it.\n *\n * ## Which sites\n *\n * Datasets are ORG-scoped and shared with sites through `visibleTo` — every\n * site in the org, or up to `MAX_SCOPE_HOSTS` named ones. Each site caches its\n * own copy of the rows under its own `tenant-data:{hostId}` tag and its own\n * page keys, so one record write is a fan-out. A missing `visibleTo` is\n * visible to NOBODY and announces nothing; defaulting it to org-wide here\n * would re-make AGL-1466 in the cache layer.\n *\n * ## Best effort, always\n *\n * The record is already written by the time any of this runs. A refused or\n * failed drop is reported to the caller and logged, never thrown: a record\n * must not be lost because a cache refused, and the TTL is still underneath as\n * the backstop it was always meant to be.\n */\n\nimport {\n ORG_SCOPE_TOKEN,\n hostIdsFromScope,\n repeatKeys,\n screenRoutePathToUrl,\n} from '@aglyn/aglyn/server'\nimport type { Firestore } from 'firebase-admin/firestore'\nimport {\n readUsageSources,\n screenIdsUsingComponentDeep,\n screenIdsUsingLayoutDeep,\n isLiveUsageCandidate,\n type UsageCandidate,\n type UsageSources,\n} from './live-page-usage'\n\n/**\n * How many documents per collection the per-site scan reads.\n *\n * The console's publish scan uses the same number for the same reason\n * (AGL-1161): this decides which caches get dropped, so a prefix scan would\n * report a refreshed site and leave real pages serving the old rows. Bounded\n * all the same, and when the bound bites it is SAID rather than absorbed.\n */\nexport const DATASET_SCAN_LIMIT = 2000\n\n/**\n * How many sites one record write fans out to.\n *\n * `MAX_SCOPE_HOSTS` is the ceiling on a NAMED selection, so only an org-wide\n * dataset can reach this — an org with more sites than this pays a scan per\n * site on every write otherwise, which is a cost the person writing one row\n * did not ask for. Sites past the cap keep the TTL they had before this\n * existed, and the shortfall is returned and logged rather than swallowed.\n */\nexport const MAX_DATASET_FANOUT_HOSTS = 30\n\n/**\n * How long one site-and-dataset pair waits before announcing again.\n *\n * A 1,000-row import is one announce because the importer announces after its\n * loop, not per row. This covers the other burst — many small writes arriving\n * separately, a form under load or an automation fanning out — where each one\n * would otherwise pay a full scan per site and hand the tenant the same path\n * list again.\n *\n * Leading edge, so the FIRST write of a burst refreshes immediately; what is\n * skipped is a repeat inside the window, and the next write after it announces\n * again. The cost of the skip is bounded by the window: a row written during\n * one is visible no later than the next announce.\n */\nexport const DATASET_ANNOUNCE_WINDOW_MS = 2000\n\n/** Last announce per `orgId/datasetId/hostId`, for the window above. */\nconst lastAnnouncedAt = new Map<string, number>()\n\n/**\n * Bounded so a long-lived server cannot grow this map without limit. Far above\n * the number of datasets any one instance writes in a window; when it bites,\n * the oldest entries simply lose their throttle and announce again.\n */\nconst MAX_THROTTLE_ENTRIES = 5000\n\nfunction throttleAllows(key: string, now: number): boolean {\n const previous = lastAnnouncedAt.get(key)\n if (previous !== undefined && now - previous < DATASET_ANNOUNCE_WINDOW_MS) {\n return false\n }\n if (lastAnnouncedAt.size >= MAX_THROTTLE_ENTRIES) lastAnnouncedAt.clear()\n lastAnnouncedAt.set(key, now)\n return true\n}\n\n/** Test seam: the window is process-wide state, so a spec has to reset it. */\nexport function resetDatasetAnnounceThrottle(): void {\n lastAnnouncedAt.clear()\n}\n\n/** One site's share of a dataset change. */\nexport interface DatasetLivePageTarget {\n hostId: string\n /** The tenant keys its cache on this, never on `hostId`. */\n subdomain: string\n /** The attached custom domain, whose pages live under a second cache key. */\n cname?: string\n /** Site-absolute addresses (`/`, `/team`) that repeat over the dataset. */\n paths: string[]\n /** The site held more documents than the scan read, so `paths` is partial. */\n truncated: boolean\n}\n\nexport interface DatasetLivePageScope {\n targets: DatasetLivePageTarget[]\n /**\n * Sites the fan-out cap left out. Zero in the ordinary case, and reported\n * rather than logged because a caller that says \"refreshed\" while some sites\n * were skipped is the failure this whole arc exists to remove.\n */\n hostsDropped: number\n /**\n * Why there is nothing to announce, or `'ok'`.\n *\n * `'not-rendered'` is a success: a dataset no page repeats over has no live\n * page to refresh. It is kept apart from `'error'` for the same reason the\n * revalidate route keeps `not-routed` apart from a refusal — one means there\n * was nothing to do, the other means something was left stale.\n */\n reason: 'ok' | 'no-dataset' | 'no-sites' | 'not-rendered' | 'error'\n}\n\n/**\n * Whether a node tree repeats over any of `keys`.\n *\n * Asks `repeatKeys` — literally the function the tenant composer calls\n * to decide which datasets to load for a page — so this cannot come to\n * disagree with what actually renders. A predicate even slightly narrower than\n * the expansion's is a page that renders rows and never refreshes them.\n */\nfunction nodesRepeatDataset(\n nodes: UsageCandidate['nodes'],\n keys: ReadonlySet<string>,\n): boolean {\n return repeatKeys(nodes).some((key) => keys.has(key))\n}\n\n/**\n * Every live screen whose rendered output repeats over the dataset, however\n * indirectly.\n *\n * The same three-bucket closure a placed form's pages are found by, for the\n * same reason: a binding is found by SEARCHING node trees rather than by\n * matching a pointer, so it can sit on the screen, in the chrome of the layout\n * the screen renders inside, or inside a reusable component a third component\n * nests. After the first level the fan-out is identical, so it is delegated\n * rather than restated.\n *\n * Pure, and separate from the Firestore read, so the closure is testable\n * without a database.\n */\nexport function screenIdsRepeatingDataset(\n keys: ReadonlySet<string>,\n sources: UsageSources,\n): string[] {\n if (!keys.size) return []\n const screenIds = new Set<string>()\n\n for (const candidate of sources.screens) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (nodesRepeatDataset(candidate.nodes, keys)) screenIds.add(candidate.id)\n }\n for (const candidate of sources.layouts) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (!nodesRepeatDataset(candidate.nodes, keys)) continue\n // The layout itself renders no URL; the screens beneath it do.\n for (const screenId of screenIdsUsingLayoutDeep(\n candidate.id,\n sources.screens,\n sources.layouts,\n )) {\n screenIds.add(screenId)\n }\n }\n for (const candidate of sources.components) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (!nodesRepeatDataset(candidate.nodes, keys)) continue\n // And a component renders wherever it is placed, however deeply nested.\n for (const screenId of screenIdsUsingComponentDeep(candidate.id, sources)) {\n screenIds.add(screenId)\n }\n }\n\n return [...screenIds]\n}\n\n/**\n * The keys a page can be bound to this dataset by: its id, and the display\n * name editors type into the Repeat attribute.\n *\n * Both, because the expansion looks a key up either way (the data plugin's\n * repeat rows resolve an id first and then a display name). Matching only the id would\n * miss every binding written by name, which is the older and still-supported\n * half of them.\n */\nfunction datasetKeysOf(\n snapshot: FirebaseFirestore.DocumentSnapshot,\n): Set<string> {\n const keys = new Set<string>([snapshot.id])\n const displayName = String(snapshot.get('displayName') ?? '').trim()\n if (displayName) keys.add(displayName)\n return keys\n}\n\n/**\n * The sites a dataset is shared with, as host ids.\n *\n * Fails CLOSED on a missing or empty `visibleTo`, matching `visibleToHost` and\n * both enforcement layers underneath it: a document nobody scoped is shared\n * with nobody, and reading it as org-wide here would announce one org's\n * dataset change to sites that cannot see the dataset at all.\n */\nasync function hostIdsSharingDataset(\n firestore: Firestore,\n orgId: string,\n visibleTo: readonly string[] | undefined,\n): Promise<{ hostIds: string[]; hostsDropped: number }> {\n if (!Array.isArray(visibleTo) || !visibleTo.length) {\n return { hostIds: [], hostsDropped: 0 }\n }\n if (!visibleTo.includes(ORG_SCOPE_TOKEN)) {\n const named = hostIdsFromScope(visibleTo)\n return { hostIds: named, hostsDropped: 0 }\n }\n // One over the cap, so a shortfall is detected rather than assumed away.\n const hosts = await firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .limit(MAX_DATASET_FANOUT_HOSTS + 1)\n .get()\n const all = hosts.docs.map((doc) => doc.id)\n return {\n hostIds: all.slice(0, MAX_DATASET_FANOUT_HOSTS),\n hostsDropped: Math.max(0, all.length - MAX_DATASET_FANOUT_HOSTS),\n }\n}\n\n/** One site's paths, or `null` when the site has nothing to refresh. */\nasync function targetForHost(\n firestore: Firestore,\n hostId: string,\n keys: ReadonlySet<string>,\n): Promise<DatasetLivePageTarget | null> {\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) return null\n const subdomain = String(hostSnapshot.get('subdomain') ?? '')\n // Without a subdomain there is no tenant deployment holding cached pages for\n // this site — the ordinary state of one not yet given a name, not a failure.\n if (!subdomain) return null\n\n const sources = await readUsageSources(hostRef, DATASET_SCAN_LIMIT)\n const screens = (hostSnapshot.get('screens') ?? {}) as Record<string, string>\n const paths = screenIdsRepeatingDataset(keys, sources.candidates)\n .map((screenId) => screens[screenId])\n .filter((path): path is string => Boolean(path))\n .map((path) => screenRoutePathToUrl(path))\n .filter((path, index, all) => all.indexOf(path) === index)\n if (!paths.length) return null\n\n const cname = String(hostSnapshot.get('cname') ?? '')\n return {\n hostId,\n subdomain,\n ...(cname ? { cname } : {}),\n paths,\n truncated: sources.truncated,\n }\n}\n\n/**\n * Every live page a change to this dataset's records makes stale, per site.\n *\n * Reads only; announcing is the caller's, because the two runtimes announce\n * differently — the console posts to the tenant over the shared secret, and\n * the tenant drops its own caches in process. Splitting it here is what lets\n * both use one answer to the question of WHICH pages.\n *\n * Never throws. A scope that could not be computed is a scope of nothing, and\n * the TTL underneath is unaffected.\n */\nexport async function datasetLivePageScope(options: {\n firestore: Firestore\n orgId: string\n datasetId: string\n}): Promise<DatasetLivePageScope> {\n const { firestore, orgId, datasetId } = options\n if (!orgId || !datasetId) {\n return { targets: [], hostsDropped: 0, reason: 'no-dataset' }\n }\n try {\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('datasets')\n .doc(datasetId)\n .get()\n if (!snapshot.exists) {\n return { targets: [], hostsDropped: 0, reason: 'no-dataset' }\n }\n const { hostIds, hostsDropped } = await hostIdsSharingDataset(\n firestore,\n orgId,\n snapshot.get('visibleTo') as readonly string[] | undefined,\n )\n if (!hostIds.length) {\n return { targets: [], hostsDropped, reason: 'no-sites' }\n }\n const keys = datasetKeysOf(snapshot)\n const resolved = await Promise.all(\n hostIds.map(async (hostId) => {\n try {\n return await targetForHost(firestore, hostId, keys)\n } catch (error) {\n // One unreadable site must not cost the others their refresh.\n console.error('[dataset-live-pages] site scan failed', hostId, error)\n return null\n }\n }),\n )\n const targets = resolved.filter(\n (target): target is DatasetLivePageTarget => target !== null,\n )\n return {\n targets,\n hostsDropped,\n reason: targets.length ? 'ok' : 'not-rendered',\n }\n } catch (error) {\n console.error('[dataset-live-pages] scope failed', datasetId, error)\n return { targets: [], hostsDropped: 0, reason: 'error' }\n }\n}\n\n/**\n * How one runtime drops a site's cached pages.\n *\n * The generic seam, registered by the deployment that owns the caches. The\n * tenant registers an in-process drop at boot; the console announces over the\n * shared secret and passes its own dropper at the call site. Core names\n * neither, exactly as it names no repeat source.\n *\n * Returns whether the drop landed. It must never throw: everything above it\n * has already been written.\n */\nexport type LivePageDropper = (\n target: DatasetLivePageTarget,\n) => Promise<boolean>\n\nlet registeredDropper: LivePageDropper | undefined\n\n/**\n * Registers the dropper for this process, replacing any previous one. Returns\n * the unregister, which removes it only if it is still the one registered.\n *\n * Called from a function the platform calls BY NAME at boot, never from a\n * module's top level: a bundler deletes a module imported only for its side\n * effect when its package says it has none (AGL-3025).\n */\nexport function registerLivePageDropper(drop: LivePageDropper): () => void {\n registeredDropper = drop\n return () => {\n if (registeredDropper === drop) registeredDropper = undefined\n }\n}\n\nexport interface DatasetAnnounceResult {\n /** Sites whose cached pages were dropped. */\n sitesRefreshed: number\n /** Pages named across those sites. */\n pathsRefreshed: number\n /** Sites the drop was attempted on and refused. */\n sitesRefused: number\n /** Sites past the fan-out cap, and sites whose scan was truncated. */\n hostsDropped: number\n scanTruncated: boolean\n /** `'throttled'` when a drop for this pair already went out just now. */\n reason: DatasetLivePageScope['reason'] | 'throttled' | 'no-dropper'\n}\n\nconst EMPTY_RESULT: Omit<DatasetAnnounceResult, 'reason'> = {\n sitesRefreshed: 0,\n pathsRefreshed: 0,\n sitesRefused: 0,\n hostsDropped: 0,\n scanTruncated: false,\n}\n\n/**\n * Refresh every live page that shows this dataset's records.\n *\n * The one call a write path makes. BEST EFFORT, ALWAYS: it never throws and\n * never rejects, so a caller can `await` it inside the try that already guards\n * the write without any chance of turning a stored record into a failed one.\n *\n * `drop` overrides the registered dropper for callers that hold their own —\n * the console, whose announce is an authenticated request rather than a local\n * cache call.\n */\nexport async function announceDatasetRecordChange(options: {\n firestore: Firestore\n orgId: string\n datasetId: string\n drop?: LivePageDropper\n}): Promise<DatasetAnnounceResult> {\n const { firestore, orgId, datasetId } = options\n const drop = options.drop ?? registeredDropper\n if (!drop) {\n // Said out loud: a process that writes records and registered no dropper\n // is one where this feature is silently absent, which reads exactly like\n // a working one until somebody watches a page fail to change.\n console.warn(\n JSON.stringify({\n tag: 'AGL-3113:no-live-page-dropper',\n orgId,\n datasetId,\n }),\n )\n return { ...EMPTY_RESULT, reason: 'no-dropper' }\n }\n const throttleKey = `${orgId}/${datasetId}`\n if (!throttleAllows(throttleKey, Date.now())) {\n return { ...EMPTY_RESULT, reason: 'throttled' }\n }\n const scope = await datasetLivePageScope({ firestore, orgId, datasetId })\n if (!scope.targets.length) {\n return {\n ...EMPTY_RESULT,\n hostsDropped: scope.hostsDropped,\n reason: scope.reason,\n }\n }\n\n let sitesRefreshed = 0\n let sitesRefused = 0\n let pathsRefreshed = 0\n for (const target of scope.targets) {\n let landed = false\n try {\n landed = await drop(target)\n } catch (error) {\n // A dropper that throws is a dropper that failed; it is not a write\n // that failed.\n console.error('[dataset-live-pages] drop failed', target.hostId, error)\n }\n if (landed) {\n sitesRefreshed += 1\n pathsRefreshed += target.paths.length\n } else {\n sitesRefused += 1\n }\n }\n\n const scanTruncated = scope.targets.some((target) => target.truncated)\n const result: DatasetAnnounceResult = {\n sitesRefreshed,\n pathsRefreshed,\n sitesRefused,\n hostsDropped: scope.hostsDropped,\n scanTruncated,\n reason: scope.reason,\n }\n // ONE line per announce, on success as well as failure, for the reason\n // `postTenantRevalidate` gives: a record that only logs its failures cannot\n // tell \"every write refreshed\" apart from \"the call never happened\", and\n // that ambiguity is how an eleven-day propagation outage went unnoticed.\n console.log(\n JSON.stringify({\n tag: 'AGL-3113:dataset-announce',\n orgId,\n datasetId,\n ...result,\n }),\n )\n return result\n}\n\n/**\n * One sentence for a refresh that did not cover every page, or `null` when it\n * did.\n *\n * Beside the call rather than at each surface, so the console cannot word this\n * differently in two places — the same reason `describeRevalidateShortfall`\n * lives beside `revalidateLivePages`. `lead` names the act that succeeded,\n * because only that word differs between surfaces: a record is SAVED, and\n * telling someone who fixed a typo that they published it is a worse error\n * than the staleness the sentence is about.\n */\nexport function describeDatasetAnnounceShortfall(\n result: DatasetAnnounceResult | null,\n lead = 'Saved.',\n): string | null {\n if (!result) return null\n // `not-rendered`, `no-sites` and `throttled` are deliberately silent: a\n // dataset no page shows has no stale page, and a throttled drop means one\n // already went out a moment ago. Saying anything would be alarming and wrong.\n if (result.reason === 'error' || result.sitesRefused) {\n return (\n `${lead} The live pages could not be refreshed just now, so they ` +\n 'may show the previous records for up to an hour.'\n )\n }\n if (result.hostsDropped || result.scanTruncated) {\n return (\n `${lead} Some pages that use this dataset are too many to refresh at ` +\n 'once — they update on their own within an hour.'\n )\n }\n return null\n}\n\nexport default announceDatasetRecordChange\n"],"names":["ORG_SCOPE_TOKEN","hostIdsFromScope","repeatKeys","screenRoutePathToUrl","readUsageSources","screenIdsUsingComponentDeep","screenIdsUsingLayoutDeep","isLiveUsageCandidate","DATASET_SCAN_LIMIT","MAX_DATASET_FANOUT_HOSTS","DATASET_ANNOUNCE_WINDOW_MS","lastAnnouncedAt","Map","MAX_THROTTLE_ENTRIES","throttleAllows","key","now","previous","get","undefined","size","clear","set","resetDatasetAnnounceThrottle","nodesRepeatDataset","nodes","keys","some","has","screenIdsRepeatingDataset","sources","screenIds","Set","candidate","screens","add","id","layouts","screenId","components","datasetKeysOf","snapshot","displayName","String","trim","hostIdsSharingDataset","firestore","orgId","visibleTo","Array","isArray","length","hostIds","hostsDropped","includes","named","hosts","collection","where","limit","all","docs","map","doc","slice","Math","max","targetForHost","hostId","hostSnapshot","hostRef","exists","subdomain","paths","candidates","filter","path","Boolean","index","indexOf","cname","truncated","datasetLivePageScope","options","datasetId","targets","reason","resolved","Promise","error","console","target","registeredDropper","registerLivePageDropper","drop","EMPTY_RESULT","sitesRefreshed","pathsRefreshed","sitesRefused","scanTruncated","announceDatasetRecordChange","warn","JSON","stringify","tag","throttleKey","Date","scope","landed","result","log","describeDatasetAnnounceShortfall","lead"],"mappings":"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"}
|
|
@@ -1,185 +0,0 @@
|
|
|
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 { EMAIL_ATTRIBUTION_WINDOW_MS } from '@aglyn/shared-util-email';
|
|
18
|
-
/**
|
|
19
|
-
* THE COMMERCE↔EMAIL JOIN — an order, credited to the campaign that led to it.
|
|
20
|
-
*
|
|
21
|
-
* ## Why this is a join
|
|
22
|
-
*
|
|
23
|
-
* Every compared ESP attributes revenue through an integration into a store
|
|
24
|
-
* it does not own: a catalog sync, an on-site tracking snippet, an identity
|
|
25
|
-
* graph, and a window wide enough to absorb what the reconciliation misses.
|
|
26
|
-
* Commerce here is first-party and runs on the merchant's own Stripe Connect
|
|
27
|
-
* account, so the click and the order are two rows in one database keyed the
|
|
28
|
-
* same way. The window is not a fudge factor here — it is the model, and
|
|
29
|
-
* nothing else about the number is estimated.
|
|
30
|
-
*
|
|
31
|
-
* ## Three writes, and what each one is for
|
|
32
|
-
*
|
|
33
|
-
* - **The touch**, `emailDeliveries/{personKey}.campaignTouches[hostId]`,
|
|
34
|
-
* written by the delivery webhook on a click. Owned by
|
|
35
|
-
* `email-delivery-log.ts`, because it lives on the person's document and
|
|
36
|
-
* the erasure path has to be able to remove it.
|
|
37
|
-
* - **The attribution record**, `hosts/{hostId}/emailAttributions/{orderId}`,
|
|
38
|
-
* written here when an order is credited. It is the audit trail — which
|
|
39
|
-
* campaign, which click, which model, which window — and it is what the
|
|
40
|
-
* refund path reads to find out which campaign to take the money back off.
|
|
41
|
-
* - **The rollup**, `campaigns/{campaignId}/reports/revenue`, incremented
|
|
42
|
-
* here. One document per campaign, so the report reads it whole.
|
|
43
|
-
*
|
|
44
|
-
* ## Why the record is a document and not a field on the order
|
|
45
|
-
*
|
|
46
|
-
* Three reasons, and the first is decisive. `create()` fails when the
|
|
47
|
-
* document already exists, which is exact idempotency for free: webhook
|
|
48
|
-
* delivery is at-least-once, and a redelivered purchase must not be able to
|
|
49
|
-
* credit a campaign twice. Second, not every purchase door writes an order —
|
|
50
|
-
* a booking is a sale with no order document — and a field would have had to
|
|
51
|
-
* either skip those or conjure the document, and conjuring is the
|
|
52
|
-
* phantom-document shape this codebase spent a sweep removing. Third, the
|
|
53
|
-
* order document is read by the console list, the fulfilment path, the
|
|
54
|
-
* supplier outbox and the CSV export, and none of them wants a field about
|
|
55
|
-
* email.
|
|
56
|
-
*
|
|
57
|
-
* ## Never throws
|
|
58
|
-
*
|
|
59
|
-
* Same contract as `upsertHostContact`, which calls it, and for the same
|
|
60
|
-
* reason: the money has already moved and the order already records it, so
|
|
61
|
-
* nothing here may fail a sale. A lost attribution understates a campaign; a
|
|
62
|
-
* thrown one loses a checkout.
|
|
63
|
-
*/
|
|
64
|
-
/** The per-host collection of attribution records. */
|
|
65
|
-
export declare const EMAIL_ATTRIBUTIONS_COLLECTION = "emailAttributions";
|
|
66
|
-
/** The single rollup document under a campaign. */
|
|
67
|
-
export declare const CAMPAIGN_REVENUE_REPORT_DOC = "revenue";
|
|
68
|
-
/**
|
|
69
|
-
* The currency an amount is recorded under when the caller does not say.
|
|
70
|
-
*
|
|
71
|
-
* Every checkout door in this repo — cart, buy-now, POS, draft orders,
|
|
72
|
-
* reservations, subscriptions — writes `currency: 'usd'` onto the Stripe line
|
|
73
|
-
* items, and no order document carries a currency field to read back. So this
|
|
74
|
-
* is a statement about what the code charges rather than a guess about the
|
|
75
|
-
* money. The parameter exists so a door that ever charges in something else
|
|
76
|
-
* says so and lands in its own bucket, because the one thing the report may
|
|
77
|
-
* never do is add two currencies together.
|
|
78
|
-
*/
|
|
79
|
-
export declare const DEFAULT_ATTRIBUTION_CURRENCY = "usd";
|
|
80
|
-
/** What one attribution record holds. */
|
|
81
|
-
export interface EmailAttributionRecord {
|
|
82
|
-
campaignId: string;
|
|
83
|
-
/** When the credited click happened. */
|
|
84
|
-
clickedAtMs: number;
|
|
85
|
-
/** When the order was placed. */
|
|
86
|
-
orderedAtMs: number;
|
|
87
|
-
/** Minor units credited, gross — the amount the buyer was charged. */
|
|
88
|
-
amountCents: number;
|
|
89
|
-
/** Lowercase currency code the amount is in. */
|
|
90
|
-
currency: string;
|
|
91
|
-
/** The model this credit was decided under. */
|
|
92
|
-
model: string;
|
|
93
|
-
/** The window, in days, it was decided inside. */
|
|
94
|
-
windowDays: number;
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Credits one order to the campaign whose link the buyer last clicked.
|
|
98
|
-
*
|
|
99
|
-
* ## What happens when there is nobody to credit
|
|
100
|
-
*
|
|
101
|
-
* Each case answers `null` and writes NOTHING, which is the whole design:
|
|
102
|
-
*
|
|
103
|
-
* - **No email on the order.** A guest checkout that never identified its
|
|
104
|
-
* buyer cannot be joined to anybody's clicks. There is no fallback and
|
|
105
|
-
* there deliberately is not one — the alternatives are an IP or a device
|
|
106
|
-
* guess, which is the probabilistic attribution owning the checkout exists
|
|
107
|
-
* to avoid.
|
|
108
|
-
* - **A guest with an email but no contact record.** This is ATTRIBUTED
|
|
109
|
-
* normally. The join keys on the address hash, exactly as the touch and
|
|
110
|
-
* the suppression list do, so it never asks whether a contact document
|
|
111
|
-
* exists — which matters, because contact creation is audience-band gated
|
|
112
|
-
* and a Free org's dropped contact would otherwise silently drop the
|
|
113
|
-
* revenue with it.
|
|
114
|
-
* - **No touch, or a touch on another site.** Nobody clicked, so nobody is
|
|
115
|
-
* credited. The touch map is keyed by host and the send path refuses
|
|
116
|
-
* cross-site reach; the revenue join agrees with it.
|
|
117
|
-
* - **A touch outside the window**, in either direction. See
|
|
118
|
-
* {@link emailTouchIsInWindow} — a click AFTER the order is the receipt,
|
|
119
|
-
* not the cause.
|
|
120
|
-
*
|
|
121
|
-
* None of these is counted anywhere. A miss costs no write, which is what
|
|
122
|
-
* keeps the ordinary order — placed by somebody who is not on the mailing
|
|
123
|
-
* list at all — at exactly one document read. What it means for the report is
|
|
124
|
-
* stated on the report: the figure counts orders it could join, and is a
|
|
125
|
-
* floor.
|
|
126
|
-
*
|
|
127
|
-
* @returns the record written, or `null` when nothing was credited.
|
|
128
|
-
*/
|
|
129
|
-
export declare function attributeOrderToEmail(options: {
|
|
130
|
-
hostId: string;
|
|
131
|
-
/** The order, booking or invoice the money came in on. */
|
|
132
|
-
orderId: string;
|
|
133
|
-
/** The buyer as the sale recorded them, raw — normalized downstream. */
|
|
134
|
-
email: unknown;
|
|
135
|
-
/** Gross minor units the buyer was charged. */
|
|
136
|
-
amountCents: number;
|
|
137
|
-
/** Lowercase currency code, when the door knows one. */
|
|
138
|
-
currency?: string;
|
|
139
|
-
/** When the order was placed. Defaults to now. */
|
|
140
|
-
orderedAtMs?: number;
|
|
141
|
-
}, firestore?: any): Promise<EmailAttributionRecord | null>;
|
|
142
|
-
/**
|
|
143
|
-
* Takes back revenue a campaign was credited with, when it is refunded.
|
|
144
|
-
*
|
|
145
|
-
* ## Recorded beside the gross, never subtracted from it
|
|
146
|
-
*
|
|
147
|
-
* `grossCents` is left exactly as it is and the reversal lands in
|
|
148
|
-
* `refundedCents` next to it. This is the shape `contact-refund.ts` chose for
|
|
149
|
-
* `ltvCents`/`refundedCents` and the orders CSV chose for
|
|
150
|
-
* `amountUsd`/`refundedUsd`, and it is chosen a third time here so all three
|
|
151
|
-
* answer "what did this earn, net" identically. Decrementing would make a
|
|
152
|
-
* stored number mean one thing for rollups written before a refund and
|
|
153
|
-
* another after, with nothing on the document to tell them apart — and would
|
|
154
|
-
* put the question "can it go negative" onto storage, where the answer cannot
|
|
155
|
-
* be clamped without destroying evidence. Both stored figures are monotonic
|
|
156
|
-
* counters of money that really moved in one direction; the derived net is
|
|
157
|
-
* clamped at the point of display and nowhere else.
|
|
158
|
-
*
|
|
159
|
-
* ## The currency comes from the RECORD, not from the caller
|
|
160
|
-
*
|
|
161
|
-
* A refund reverses a specific sale, and the bucket it comes out of has to be
|
|
162
|
-
* the bucket it went into. Reading the currency back off the attribution
|
|
163
|
-
* record is what guarantees that, and it is the second thing the record is
|
|
164
|
-
* for.
|
|
165
|
-
*
|
|
166
|
-
* ## Chargebacks come through here too
|
|
167
|
-
*
|
|
168
|
-
* Money reversed is money reversed, whichever door it left by, so a lost
|
|
169
|
-
* dispute reverses the credit the same way a refund does. `kind` exists for
|
|
170
|
-
* the caller's clarity and changes nothing about the arithmetic — the same
|
|
171
|
-
* choice `recordContactRefund` made one field along.
|
|
172
|
-
*
|
|
173
|
-
* @returns whether a reversal was recorded.
|
|
174
|
-
*/
|
|
175
|
-
export declare function reverseEmailAttributedRevenue(options: {
|
|
176
|
-
hostId: string;
|
|
177
|
-
orderId: string;
|
|
178
|
-
/** Minor units reversed by THIS attempt, never the order total. */
|
|
179
|
-
amountCents: number;
|
|
180
|
-
/** True only for the write that moved the order into `refunded`. */
|
|
181
|
-
closedTheOrder: boolean;
|
|
182
|
-
kind?: 'refund' | 'chargeback';
|
|
183
|
-
}, firestore?: any): Promise<boolean>;
|
|
184
|
-
/** The window, re-exported so a caller needs one import for the whole join. */
|
|
185
|
-
export { EMAIL_ATTRIBUTION_WINDOW_MS };
|