@aglyn/plugins-data 1.0.0-beta.220 → 1.0.0-beta.222

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 (135) hide show
  1. package/README.md +9 -6
  2. package/package.json +10 -10
  3. package/src/index.d.ts +1 -1
  4. package/src/index.js +1 -1
  5. package/src/index.js.map +1 -1
  6. package/src/lib/artifact/dataset-schema-artifact.server.d.ts +57 -0
  7. package/src/lib/artifact/dataset-schema-artifact.server.js +308 -0
  8. package/src/lib/artifact/dataset-schema-artifact.server.js.map +1 -0
  9. package/src/lib/artifact/dataset-schema.d.ts +136 -0
  10. package/src/lib/artifact/dataset-schema.js +232 -0
  11. package/src/lib/artifact/dataset-schema.js.map +1 -0
  12. package/src/lib/components/dataset-record-dialog.component.d.ts +1 -1
  13. package/src/lib/components/dataset-record-dialog.component.js +1 -1
  14. package/src/lib/components/dataset-record-dialog.component.js.map +1 -1
  15. package/src/lib/components/dataset-record-filter.d.ts +1 -1
  16. package/src/lib/components/dataset-record-filter.js +2 -2
  17. package/src/lib/components/dataset-record-filter.js.map +1 -1
  18. package/src/lib/components/dataset-schema-dialog.component.d.ts +1 -1
  19. package/src/lib/components/dataset-schema-dialog.component.js +55 -23
  20. package/src/lib/components/dataset-schema-dialog.component.js.map +1 -1
  21. package/src/lib/components/host-datasets-card.component.js +91 -403
  22. package/src/lib/components/host-datasets-card.component.js.map +1 -1
  23. package/src/lib/constants/bundle-common.d.ts +12 -0
  24. package/src/lib/constants/bundle-common.js +13 -0
  25. package/src/lib/constants/bundle-common.js.map +1 -1
  26. package/src/lib/declarations.console-server.d.ts +16 -1
  27. package/src/lib/declarations.console-server.js +34 -2
  28. package/src/lib/declarations.console-server.js.map +1 -1
  29. package/src/lib/declarations.server.d.ts +13 -0
  30. package/src/lib/declarations.server.js +48 -2
  31. package/src/lib/declarations.server.js.map +1 -1
  32. package/src/lib/form-target/dataset-form-record-target.server.js +5 -3
  33. package/src/lib/form-target/dataset-form-record-target.server.js.map +1 -1
  34. package/src/lib/model/dataset-models.d.ts +392 -0
  35. package/src/lib/model/dataset-models.js +806 -0
  36. package/src/lib/model/dataset-models.js.map +1 -0
  37. package/src/lib/model/dataset-record-view.d.ts +1 -1
  38. package/src/lib/model/dataset-record-view.js +1 -2
  39. package/src/lib/model/dataset-record-view.js.map +1 -1
  40. package/src/lib/model/dataset-record.js +2 -2
  41. package/src/lib/model/dataset-record.js.map +1 -1
  42. package/src/lib/model/datasets.d.ts +162 -0
  43. package/src/lib/model/datasets.js +227 -0
  44. package/src/lib/model/datasets.js.map +1 -0
  45. package/src/lib/plugin.d.ts +4 -0
  46. package/src/lib/plugin.js +40 -0
  47. package/src/lib/plugin.js.map +1 -1
  48. package/src/lib/record-pages/record-page-live-paths.server.d.ts +55 -0
  49. package/src/lib/record-pages/record-page-live-paths.server.js +83 -0
  50. package/src/lib/record-pages/record-page-live-paths.server.js.map +1 -0
  51. package/src/lib/record-pages/record-page-read.server.d.ts +70 -0
  52. package/src/lib/record-pages/record-page-read.server.js +175 -0
  53. package/src/lib/record-pages/record-page-read.server.js.map +1 -0
  54. package/src/lib/record-pages/record-page-resolver.server.d.ts +37 -0
  55. package/src/lib/record-pages/record-page-resolver.server.js +131 -0
  56. package/src/lib/record-pages/record-page-resolver.server.js.map +1 -0
  57. package/src/lib/record-pages/record-page-sitemap.server.d.ts +32 -0
  58. package/src/lib/record-pages/record-page-sitemap.server.js +102 -0
  59. package/src/lib/record-pages/record-page-sitemap.server.js.map +1 -0
  60. package/src/lib/record-pages/record-page-source.d.ts +42 -0
  61. package/src/lib/record-pages/record-page-source.js +176 -0
  62. package/src/lib/record-pages/record-page-source.js.map +1 -0
  63. package/src/lib/record-pages/record-pages-route.d.ts +42 -0
  64. package/src/lib/record-pages/record-pages-route.js +348 -0
  65. package/src/lib/record-pages/record-pages-route.js.map +1 -0
  66. package/src/lib/record-pages/record-pages.d.ts +218 -0
  67. package/src/lib/record-pages/record-pages.js +351 -0
  68. package/src/lib/record-pages/record-pages.js.map +1 -0
  69. package/src/lib/record-pages/record-template-row-chip.component.d.ts +28 -0
  70. package/src/lib/record-pages/record-template-row-chip.component.js +87 -0
  71. package/src/lib/record-pages/record-template-row-chip.component.js.map +1 -0
  72. package/src/lib/record-pages/record-template-section.component.d.ts +30 -0
  73. package/src/lib/record-pages/record-template-section.component.js +491 -0
  74. package/src/lib/record-pages/record-template-section.component.js.map +1 -0
  75. package/src/lib/repeat/dataset-repeat-rows.d.ts +2 -1
  76. package/src/lib/repeat/dataset-repeat-rows.js +30 -12
  77. package/src/lib/repeat/dataset-repeat-rows.js.map +1 -1
  78. package/src/lib/repeat/dataset-repeat-rows.server.d.ts +1 -1
  79. package/src/lib/repeat/dataset-repeat-rows.server.js +23 -12
  80. package/src/lib/repeat/dataset-repeat-rows.server.js.map +1 -1
  81. package/src/lib/repeat/repeat-record-pages.d.ts +48 -0
  82. package/src/lib/repeat/repeat-record-pages.js +64 -0
  83. package/src/lib/repeat/repeat-record-pages.js.map +1 -0
  84. package/src/lib/server/announce-dataset-records.d.ts +1 -1
  85. package/src/lib/server/announce-dataset-records.js +1 -1
  86. package/src/lib/server/announce-dataset-records.js.map +1 -1
  87. package/src/lib/server/api-v1/datasets.js +5 -3
  88. package/src/lib/server/api-v1/datasets.js.map +1 -1
  89. package/src/lib/server/dataset-figures.js +2 -2
  90. package/src/lib/server/dataset-figures.js.map +1 -1
  91. package/src/lib/server/dataset-live-pages.d.ts +147 -0
  92. package/src/lib/server/dataset-live-pages.js +436 -0
  93. package/src/lib/server/dataset-live-pages.js.map +1 -0
  94. package/src/lib/server/dataset-steps.server.d.ts +45 -0
  95. package/src/lib/server/dataset-steps.server.js +268 -0
  96. package/src/lib/server/dataset-steps.server.js.map +1 -0
  97. package/src/lib/server/datasets-route.d.ts +2 -0
  98. package/src/lib/server/datasets-route.js +102 -6
  99. package/src/lib/server/datasets-route.js.map +1 -1
  100. package/src/lib/server/resolve-dataset.d.ts +40 -0
  101. package/src/lib/server/resolve-dataset.js +64 -0
  102. package/src/lib/server/resolve-dataset.js.map +1 -0
  103. package/src/lib/server.d.ts +3 -2
  104. package/src/lib/server.js +8 -9
  105. package/src/lib/server.js.map +1 -1
  106. package/src/lib/site-bundle/datasets-package.d.ts +42 -0
  107. package/src/lib/site-bundle/datasets-package.js +95 -0
  108. package/src/lib/site-bundle/datasets-package.js.map +1 -0
  109. package/src/lib/site-bundle/datasets-site-bundle.server.js +2 -2
  110. package/src/lib/site-bundle/datasets-site-bundle.server.js.map +1 -1
  111. package/src/lib/{model/index.js → tenant-api.d.ts} +12 -7
  112. package/src/lib/tenant-api.js +30 -0
  113. package/src/lib/tenant-api.js.map +1 -0
  114. package/src/lib/transfer/dataset-transfer-key.d.ts +30 -0
  115. package/src/lib/transfer/dataset-transfer-key.js +31 -0
  116. package/src/lib/transfer/dataset-transfer-key.js.map +1 -0
  117. package/src/lib/transfer/dataset-transfer-model.d.ts +146 -0
  118. package/src/lib/transfer/dataset-transfer-model.js +536 -0
  119. package/src/lib/transfer/dataset-transfer-model.js.map +1 -0
  120. package/src/lib/{model/index.d.ts → transfer/dataset-transfer-resource.d.ts} +2 -6
  121. package/src/lib/transfer/dataset-transfer-resource.js +41 -0
  122. package/src/lib/transfer/dataset-transfer-resource.js.map +1 -0
  123. package/src/lib/transfer/dataset-transfer.server.d.ts +69 -0
  124. package/src/lib/transfer/dataset-transfer.server.js +930 -0
  125. package/src/lib/transfer/dataset-transfer.server.js.map +1 -0
  126. package/src/lib/model/dataset-csv.d.ts +0 -36
  127. package/src/lib/model/dataset-csv.js +0 -85
  128. package/src/lib/model/dataset-csv.js.map +0 -1
  129. package/src/lib/model/dataset-io.d.ts +0 -44
  130. package/src/lib/model/dataset-io.js +0 -85
  131. package/src/lib/model/dataset-io.js.map +0 -1
  132. package/src/lib/model/index.js.map +0 -1
  133. package/src/lib/server/datasets-export-route.d.ts +0 -57
  134. package/src/lib/server/datasets-export-route.js +0 -229
  135. package/src/lib/server/datasets-export-route.js.map +0 -1
@@ -0,0 +1,436 @@
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
+ * And the dataset's own record pages (AGL-3475): every page a record template
42
+ * on the site draws from it, by each record's address.
43
+ *
44
+ * Three corpora, because the composer reads repeat keys after the layout chain
45
+ * and the reusable-component graft: a repeat in a layout's chrome or inside a
46
+ * component reaches every screen that renders it.
47
+ *
48
+ * ## Which sites
49
+ *
50
+ * Datasets are ORG-scoped and shared with sites through `visibleTo` — every
51
+ * site in the org, or up to `MAX_SCOPE_HOSTS` named ones. Each site caches its
52
+ * own copy of the rows under its own `tenant-data:{hostId}` tag and its own
53
+ * page keys, so one record write is a fan-out. A missing `visibleTo` is
54
+ * visible to NOBODY and announces nothing; defaulting it to org-wide here
55
+ * would re-make AGL-1466 in the cache layer.
56
+ *
57
+ * ## Best effort, always
58
+ *
59
+ * The record is already written by the time any of this runs. A refused or
60
+ * failed drop is reported to the caller and logged, never thrown: a record
61
+ * must not be lost because a cache refused, and the TTL is still underneath as
62
+ * the backstop it was always meant to be.
63
+ *
64
+ * ## Which pages is the data plugin's; how a page is dropped is the runtime's
65
+ *
66
+ * Datasets are this plugin's, so the walk from a dataset to the pages that
67
+ * repeat over it is too. The drop itself is the platform's
68
+ * (`@aglyn/tenant-data-admin/server/live-page-drops`): the deployment that
69
+ * owns the caches registers its dropper there, and a caller holding its own —
70
+ * the console's — passes it here.
71
+ */ import { ORG_SCOPE_TOKEN, hostIdsFromScope, repeatKeys, screenRoutePathToUrl } from "@aglyn/aglyn/server";
72
+ import { registeredLivePageDropper } from "@aglyn/tenant-data-admin/server/live-page-drops";
73
+ import { readUsageSources, screenIdsUsingComponentDeep, screenIdsUsingLayoutDeep, isLiveUsageCandidate } from "@aglyn/tenant-data-admin/server/live-page-usage";
74
+ /**
75
+ * How many documents per collection the per-site scan reads.
76
+ *
77
+ * The console's publish scan uses the same number for the same reason
78
+ * (AGL-1161): this decides which caches get dropped, so a prefix scan would
79
+ * report a refreshed site and leave real pages serving the old rows. Bounded
80
+ * all the same, and when the bound bites it is SAID rather than absorbed.
81
+ */ export const DATASET_SCAN_LIMIT = 2000;
82
+ /**
83
+ * How many sites one record write fans out to.
84
+ *
85
+ * `MAX_SCOPE_HOSTS` is the ceiling on a NAMED selection, so only an org-wide
86
+ * dataset can reach this — an org with more sites than this pays a scan per
87
+ * site on every write otherwise, which is a cost the person writing one row
88
+ * did not ask for. Sites past the cap keep the TTL they had before this
89
+ * existed, and the shortfall is returned and logged rather than swallowed.
90
+ */ export const MAX_DATASET_FANOUT_HOSTS = 30;
91
+ /**
92
+ * How long one site-and-dataset pair waits before announcing again.
93
+ *
94
+ * A 1,000-row import is one announce because the importer announces after its
95
+ * loop, not per row. This covers the other burst — many small writes arriving
96
+ * separately, a form under load or an automation fanning out — where each one
97
+ * would otherwise pay a full scan per site and hand the tenant the same path
98
+ * list again.
99
+ *
100
+ * Leading edge, so the FIRST write of a burst refreshes immediately; what is
101
+ * skipped is a repeat inside the window, and the next write after it announces
102
+ * again. The cost of the skip is bounded by the window: a row written during
103
+ * one is visible no later than the next announce.
104
+ */ export const DATASET_ANNOUNCE_WINDOW_MS = 2000;
105
+ /** Last announce per `orgId/datasetId/hostId`, for the window above. */ const lastAnnouncedAt = new Map();
106
+ /**
107
+ * Bounded so a long-lived server cannot grow this map without limit. Far above
108
+ * the number of datasets any one instance writes in a window; when it bites,
109
+ * the oldest entries simply lose their throttle and announce again.
110
+ */ const MAX_THROTTLE_ENTRIES = 5000;
111
+ function throttleAllows(key, now) {
112
+ const previous = lastAnnouncedAt.get(key);
113
+ if (previous !== undefined && now - previous < DATASET_ANNOUNCE_WINDOW_MS) {
114
+ return false;
115
+ }
116
+ if (lastAnnouncedAt.size >= MAX_THROTTLE_ENTRIES) lastAnnouncedAt.clear();
117
+ lastAnnouncedAt.set(key, now);
118
+ return true;
119
+ }
120
+ /** Test seam: the window is process-wide state, so a spec has to reset it. */ export function resetDatasetAnnounceThrottle() {
121
+ lastAnnouncedAt.clear();
122
+ }
123
+ /**
124
+ * Whether a node tree repeats over any of `keys`.
125
+ *
126
+ * Asks `repeatKeys` — literally the function the tenant composer calls
127
+ * to decide which datasets to load for a page — so this cannot come to
128
+ * disagree with what actually renders. A predicate even slightly narrower than
129
+ * the expansion's is a page that renders rows and never refreshes them.
130
+ */ function nodesRepeatDataset(nodes, keys) {
131
+ return repeatKeys(nodes).some((key)=>keys.has(key));
132
+ }
133
+ /**
134
+ * Every live screen whose rendered output repeats over the dataset, however
135
+ * indirectly.
136
+ *
137
+ * The same three-bucket closure a placed form's pages are found by, for the
138
+ * same reason: a binding is found by SEARCHING node trees rather than by
139
+ * matching a pointer, so it can sit on the screen, in the chrome of the layout
140
+ * the screen renders inside, or inside a reusable component a third component
141
+ * nests. After the first level the fan-out is identical, so it is delegated
142
+ * rather than restated.
143
+ *
144
+ * Pure, and separate from the Firestore read, so the closure is testable
145
+ * without a database.
146
+ */ export function screenIdsRepeatingDataset(keys, sources) {
147
+ if (!keys.size) return [];
148
+ const screenIds = new Set();
149
+ for (const candidate of sources.screens){
150
+ if (!isLiveUsageCandidate(candidate)) continue;
151
+ if (nodesRepeatDataset(candidate.nodes, keys)) screenIds.add(candidate.id);
152
+ }
153
+ for (const candidate of sources.layouts){
154
+ if (!isLiveUsageCandidate(candidate)) continue;
155
+ if (!nodesRepeatDataset(candidate.nodes, keys)) continue;
156
+ // The layout itself renders no URL; the screens beneath it do.
157
+ for (const screenId of screenIdsUsingLayoutDeep(candidate.id, sources.screens, sources.layouts)){
158
+ screenIds.add(screenId);
159
+ }
160
+ }
161
+ for (const candidate of sources.components){
162
+ if (!isLiveUsageCandidate(candidate)) continue;
163
+ if (!nodesRepeatDataset(candidate.nodes, keys)) continue;
164
+ // And a component renders wherever it is placed, however deeply nested.
165
+ for (const screenId of screenIdsUsingComponentDeep(candidate.id, sources)){
166
+ screenIds.add(screenId);
167
+ }
168
+ }
169
+ return [
170
+ ...screenIds
171
+ ];
172
+ }
173
+ /**
174
+ * The keys a page can be bound to this dataset by: its id, and the display
175
+ * name editors type into the Repeat attribute.
176
+ *
177
+ * Both, because the expansion looks a key up either way (the data plugin's
178
+ * repeat rows resolve an id first and then a display name). Matching only the id would
179
+ * miss every binding written by name, which is the older and still-supported
180
+ * half of them.
181
+ */ function datasetKeysOf(snapshot) {
182
+ var _snapshot_get;
183
+ const keys = new Set([
184
+ snapshot.id
185
+ ]);
186
+ const displayName = String((_snapshot_get = snapshot.get('displayName')) != null ? _snapshot_get : '').trim();
187
+ if (displayName) keys.add(displayName);
188
+ return keys;
189
+ }
190
+ /**
191
+ * The sites a dataset is shared with, as host ids.
192
+ *
193
+ * Fails CLOSED on a missing or empty `visibleTo`, matching `visibleToHost` and
194
+ * both enforcement layers underneath it: a document nobody scoped is shared
195
+ * with nobody, and reading it as org-wide here would announce one org's
196
+ * dataset change to sites that cannot see the dataset at all.
197
+ */ async function hostIdsSharingDataset(firestore, orgId, visibleTo) {
198
+ if (!Array.isArray(visibleTo) || !visibleTo.length) {
199
+ return {
200
+ hostIds: [],
201
+ hostsDropped: 0
202
+ };
203
+ }
204
+ if (!visibleTo.includes(ORG_SCOPE_TOKEN)) {
205
+ const named = hostIdsFromScope(visibleTo);
206
+ return {
207
+ hostIds: named,
208
+ hostsDropped: 0
209
+ };
210
+ }
211
+ // One over the cap, so a shortfall is detected rather than assumed away.
212
+ const hosts = await firestore.collection('hosts').where('orgId', '==', orgId).limit(MAX_DATASET_FANOUT_HOSTS + 1).get();
213
+ const all = hosts.docs.map((doc)=>doc.id);
214
+ return {
215
+ hostIds: all.slice(0, MAX_DATASET_FANOUT_HOSTS),
216
+ hostsDropped: Math.max(0, all.length - MAX_DATASET_FANOUT_HOSTS)
217
+ };
218
+ }
219
+ /** One site's paths, or `null` when the site has nothing to refresh. */ async function targetForHost(firestore, hostId, keys, datasetId) {
220
+ var _hostSnapshot_get, _hostSnapshot_get1, _hostSnapshot_get2;
221
+ const hostRef = firestore.collection('hosts').doc(hostId);
222
+ const hostSnapshot = await hostRef.get();
223
+ if (!hostSnapshot.exists) return null;
224
+ const subdomain = String((_hostSnapshot_get = hostSnapshot.get('subdomain')) != null ? _hostSnapshot_get : '');
225
+ // Without a subdomain there is no tenant deployment holding cached pages for
226
+ // this site — the ordinary state of one not yet given a name, not a failure.
227
+ if (!subdomain) return null;
228
+ const [sources, recordPages] = await Promise.all([
229
+ readUsageSources(hostRef, DATASET_SCAN_LIMIT),
230
+ // The dataset's own record pages (AGL-3475), which no routing-map entry
231
+ // names: a template draws them all from one screen.
232
+ // Loaded on first use, like every other read here: this module is on
233
+ // every record write's path, and the record pages read reaches the whole
234
+ // admin barrel.
235
+ import("../record-pages/record-page-live-paths.server.js").then((module)=>module.recordPagePathsForDataset(hostId, datasetId)).catch((error)=>{
236
+ console.error('[dataset-live-pages] record pages scan failed', hostId, error);
237
+ return {
238
+ paths: [],
239
+ truncated: false
240
+ };
241
+ })
242
+ ]);
243
+ const screens = (_hostSnapshot_get1 = hostSnapshot.get('screens')) != null ? _hostSnapshot_get1 : {};
244
+ const paths = [
245
+ ...screenIdsRepeatingDataset(keys, sources.candidates).map((screenId)=>screens[screenId]).filter((path)=>Boolean(path)).map((path)=>screenRoutePathToUrl(path)),
246
+ ...recordPages.paths
247
+ ].filter((path, index, all)=>all.indexOf(path) === index);
248
+ if (!paths.length) return null;
249
+ const cname = String((_hostSnapshot_get2 = hostSnapshot.get('cname')) != null ? _hostSnapshot_get2 : '');
250
+ return _extends({
251
+ hostId,
252
+ subdomain
253
+ }, cname ? {
254
+ cname
255
+ } : {}, {
256
+ paths,
257
+ truncated: sources.truncated || recordPages.truncated
258
+ });
259
+ }
260
+ /**
261
+ * Every live page a change to this dataset's records makes stale, per site.
262
+ *
263
+ * Reads only; announcing is the caller's, because the two runtimes announce
264
+ * differently — the console posts to the tenant over the shared secret, and
265
+ * the tenant drops its own caches in process. Splitting it here is what lets
266
+ * both use one answer to the question of WHICH pages.
267
+ *
268
+ * Never throws. A scope that could not be computed is a scope of nothing, and
269
+ * the TTL underneath is unaffected.
270
+ */ export async function datasetLivePageScope(options) {
271
+ const { firestore, orgId, datasetId } = options;
272
+ if (!orgId || !datasetId) {
273
+ return {
274
+ targets: [],
275
+ hostsDropped: 0,
276
+ reason: 'no-dataset'
277
+ };
278
+ }
279
+ try {
280
+ const snapshot = await firestore.collection('orgs').doc(orgId).collection('datasets').doc(datasetId).get();
281
+ if (!snapshot.exists) {
282
+ return {
283
+ targets: [],
284
+ hostsDropped: 0,
285
+ reason: 'no-dataset'
286
+ };
287
+ }
288
+ const { hostIds, hostsDropped } = await hostIdsSharingDataset(firestore, orgId, snapshot.get('visibleTo'));
289
+ if (!hostIds.length) {
290
+ return {
291
+ targets: [],
292
+ hostsDropped,
293
+ reason: 'no-sites'
294
+ };
295
+ }
296
+ const keys = datasetKeysOf(snapshot);
297
+ const resolved = await Promise.all(hostIds.map(async (hostId)=>{
298
+ try {
299
+ return await targetForHost(firestore, hostId, keys, snapshot.id);
300
+ } catch (error) {
301
+ // One unreadable site must not cost the others their refresh.
302
+ console.error('[dataset-live-pages] site scan failed', hostId, error);
303
+ return null;
304
+ }
305
+ }));
306
+ const targets = resolved.filter((target)=>target !== null);
307
+ return {
308
+ targets,
309
+ hostsDropped,
310
+ reason: targets.length ? 'ok' : 'not-rendered'
311
+ };
312
+ } catch (error) {
313
+ console.error('[dataset-live-pages] scope failed', datasetId, error);
314
+ return {
315
+ targets: [],
316
+ hostsDropped: 0,
317
+ reason: 'error'
318
+ };
319
+ }
320
+ }
321
+ const EMPTY_RESULT = {
322
+ sitesRefreshed: 0,
323
+ pathsRefreshed: 0,
324
+ sitesRefused: 0,
325
+ hostsDropped: 0,
326
+ scanTruncated: false
327
+ };
328
+ /**
329
+ * Refresh every live page that shows this dataset's records.
330
+ *
331
+ * The one call a write path makes. BEST EFFORT, ALWAYS: it never throws and
332
+ * never rejects, so a caller can `await` it inside the try that already guards
333
+ * the write without any chance of turning a stored record into a failed one.
334
+ *
335
+ * `drop` overrides the registered dropper for callers that hold their own —
336
+ * the console, whose announce is an authenticated request rather than a local
337
+ * cache call.
338
+ */ export async function announceDatasetRecordChange(options) {
339
+ var _options_drop;
340
+ const { firestore, orgId, datasetId } = options;
341
+ const drop = (_options_drop = options.drop) != null ? _options_drop : registeredLivePageDropper();
342
+ if (!drop) {
343
+ // Said out loud: a process that writes records and registered no dropper
344
+ // is one where this feature is silently absent, which reads exactly like
345
+ // a working one until somebody watches a page fail to change.
346
+ console.warn(JSON.stringify({
347
+ tag: 'AGL-3113:no-live-page-dropper',
348
+ orgId,
349
+ datasetId
350
+ }));
351
+ return _extends({}, EMPTY_RESULT, {
352
+ reason: 'no-dropper'
353
+ });
354
+ }
355
+ const throttleKey = `${orgId}/${datasetId}`;
356
+ if (!throttleAllows(throttleKey, Date.now())) {
357
+ return _extends({}, EMPTY_RESULT, {
358
+ reason: 'throttled'
359
+ });
360
+ }
361
+ const scope = await datasetLivePageScope({
362
+ firestore,
363
+ orgId,
364
+ datasetId
365
+ });
366
+ if (!scope.targets.length) {
367
+ return _extends({}, EMPTY_RESULT, {
368
+ hostsDropped: scope.hostsDropped,
369
+ reason: scope.reason
370
+ });
371
+ }
372
+ let sitesRefreshed = 0;
373
+ let sitesRefused = 0;
374
+ let pathsRefreshed = 0;
375
+ for (const target of scope.targets){
376
+ let landed = false;
377
+ try {
378
+ landed = await drop(target);
379
+ } catch (error) {
380
+ // A dropper that throws is a dropper that failed; it is not a write
381
+ // that failed.
382
+ console.error('[dataset-live-pages] drop failed', target.hostId, error);
383
+ }
384
+ if (landed) {
385
+ sitesRefreshed += 1;
386
+ pathsRefreshed += target.paths.length;
387
+ } else {
388
+ sitesRefused += 1;
389
+ }
390
+ }
391
+ const scanTruncated = scope.targets.some((target)=>target.truncated);
392
+ const result = {
393
+ sitesRefreshed,
394
+ pathsRefreshed,
395
+ sitesRefused,
396
+ hostsDropped: scope.hostsDropped,
397
+ scanTruncated,
398
+ reason: scope.reason
399
+ };
400
+ // ONE line per announce, on success as well as failure, for the reason
401
+ // `postTenantRevalidate` gives: a record that only logs its failures cannot
402
+ // tell "every write refreshed" apart from "the call never happened", and
403
+ // that ambiguity is how an eleven-day propagation outage went unnoticed.
404
+ console.log(JSON.stringify(_extends({
405
+ tag: 'AGL-3113:dataset-announce',
406
+ orgId,
407
+ datasetId
408
+ }, result)));
409
+ return result;
410
+ }
411
+ /**
412
+ * One sentence for a refresh that did not cover every page, or `null` when it
413
+ * did.
414
+ *
415
+ * Beside the call rather than at each surface, so the console cannot word this
416
+ * differently in two places — the same reason `describeRevalidateShortfall`
417
+ * lives beside `revalidateLivePages`. `lead` names the act that succeeded,
418
+ * because only that word differs between surfaces: a record is SAVED, and
419
+ * telling someone who fixed a typo that they published it is a worse error
420
+ * than the staleness the sentence is about.
421
+ */ export function describeDatasetAnnounceShortfall(result, lead = 'Saved.') {
422
+ if (!result) return null;
423
+ // `not-rendered`, `no-sites` and `throttled` are deliberately silent: a
424
+ // dataset no page shows has no stale page, and a throttled drop means one
425
+ // already went out a moment ago. Saying anything would be alarming and wrong.
426
+ if (result.reason === 'error' || result.sitesRefused) {
427
+ return `${lead} The live pages could not be refreshed just now, so they ` + 'may show the previous records for up to an hour.';
428
+ }
429
+ if (result.hostsDropped || result.scanTruncated) {
430
+ return `${lead} Some pages that use this dataset are too many to refresh at ` + 'once — they update on their own within an hour.';
431
+ }
432
+ return null;
433
+ }
434
+ export default announceDatasetRecordChange;
435
+
436
+ //# sourceMappingURL=dataset-live-pages.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/data/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 * And the dataset's own record pages (AGL-3475): every page a record template\n * on the site draws from it, by each record's address.\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 * ## Which pages is the data plugin's; how a page is dropped is the runtime's\n *\n * Datasets are this plugin's, so the walk from a dataset to the pages that\n * repeat over it is too. The drop itself is the platform's\n * (`@aglyn/tenant-data-admin/server/live-page-drops`): the deployment that\n * owns the caches registers its dropper there, and a caller holding its own —\n * the console's — passes it here.\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 type LivePageDropper,\n type LivePageTarget,\n registeredLivePageDropper,\n} from '@aglyn/tenant-data-admin/server/live-page-drops'\nimport {\n readUsageSources,\n screenIdsUsingComponentDeep,\n screenIdsUsingLayoutDeep,\n isLiveUsageCandidate,\n type UsageCandidate,\n type UsageSources,\n} from '@aglyn/tenant-data-admin/server/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\nexport interface DatasetLivePageScope {\n targets: LivePageTarget[]\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 datasetId: string,\n): Promise<LivePageTarget | 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, recordPages] = await Promise.all([\n readUsageSources(hostRef, DATASET_SCAN_LIMIT),\n // The dataset's own record pages (AGL-3475), which no routing-map entry\n // names: a template draws them all from one screen.\n // Loaded on first use, like every other read here: this module is on\n // every record write's path, and the record pages read reaches the whole\n // admin barrel.\n import('../record-pages/record-page-live-paths.server')\n .then((module) => module.recordPagePathsForDataset(hostId, datasetId))\n .catch((error: unknown) => {\n console.error('[dataset-live-pages] record pages scan failed', hostId, error)\n return { paths: [] as string[], truncated: false }\n }),\n ])\n const screens = (hostSnapshot.get('screens') ?? {}) as Record<string, string>\n const paths = [\n ...screenIdsRepeatingDataset(keys, sources.candidates)\n .map((screenId) => screens[screenId])\n .filter((path): path is string => Boolean(path))\n .map((path) => screenRoutePathToUrl(path)),\n ...recordPages.paths,\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 || recordPages.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, snapshot.id)\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 LivePageTarget => 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\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 ?? registeredLivePageDropper()\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","registeredLivePageDropper","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","datasetId","hostSnapshot","hostRef","exists","subdomain","recordPages","Promise","then","module","recordPagePathsForDataset","catch","error","console","paths","truncated","candidates","filter","path","Boolean","index","indexOf","cname","datasetLivePageScope","options","targets","reason","resolved","target","EMPTY_RESULT","sitesRefreshed","pathsRefreshed","sitesRefused","scanTruncated","announceDatasetRecordChange","drop","warn","JSON","stringify","tag","throttleKey","Date","scope","landed","result","log","describeDatasetAnnounceShortfall","lead"],"mappings":"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"}
@@ -0,0 +1,45 @@
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 { ServerStepAnswer, ServerStepRequest } from '@aglyn/aglyn/plugin-manager/plugin-server-steps';
18
+ import type { DATASET_STEP_TYPES } from '../constants/bundle-common';
19
+ /**
20
+ * THE TWO AUTOMATION STEPS THAT WRITE A DATASET RECORD (AGL-257, AGL-556,
21
+ * AGL-2773), run for the automation engine through the platform's server-step
22
+ * seam (`plugin-server-steps`, AGL-3080).
23
+ *
24
+ * `datasetAppend` adds a record; `updateDataset` merges into the record whose
25
+ * `email` field equals the event's email and appends when none does. Both read
26
+ * the event's fields that match a field of the dataset, held to its model.
27
+ *
28
+ * The engine runs the step's guard and keeps the run's history; this answers
29
+ * what the step did. A refusal is ANSWERED as `error`, never thrown, and its
30
+ * words are the line the run history carries — the same words the engine
31
+ * wrote when these steps were its own.
32
+ */
33
+ export type DatasetStepType = (typeof DATASET_STEP_TYPES)[number];
34
+ /**
35
+ * A dataset step as it is stored: the dataset by id (AGL-261, rename-safe),
36
+ * with its name kept as a display hint — and the name alone on a step from
37
+ * before ids, which the lookup resolves either way.
38
+ */
39
+ export interface DatasetStep {
40
+ type: DatasetStepType;
41
+ datasetId?: string;
42
+ datasetName?: string;
43
+ }
44
+ /** Runs one dataset step for the automation engine. */
45
+ export declare function runDatasetStep(request: ServerStepRequest): Promise<ServerStepAnswer>;