@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,268 @@
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
+ */ import { checkQuota, ensureDeclaredCustomFieldTypes, resolveOrgEntitlements } from "@aglyn/aglyn/server";
18
+ import { dataStorageRefusal, firebaseAdmin, orgDataCollectionForHost } from "@aglyn/tenant-data-admin";
19
+ import { FieldValue } from "firebase-admin/firestore";
20
+ import { announceDatasetRecordChange } from "./dataset-live-pages.js";
21
+ import { resolveDatasetDoc } from "./resolve-dataset.js";
22
+ import { datasetIntegrityFields, datasetIntegrityUpdate, effectiveDatasetModel } from "../model/dataset-models.js";
23
+ import { datasetDisplayName, describeDatasetRecordErrors, prepareDatasetRecordWrite } from "../model/datasets.js";
24
+ /**
25
+ * Refresh the live pages showing the dataset this step just wrote to
26
+ * (AGL-3113).
27
+ *
28
+ * An automation that appends a row is the same change to a visitor as a form
29
+ * submission or a console edit: the pages repeating over the dataset go on
30
+ * serving the rows they were built from. Announced from the step rather than
31
+ * from the run, so a workflow whose steps write two different datasets
32
+ * refreshes both — the announce coalesces repeats of the SAME dataset itself,
33
+ * which is what a run hitting one dataset several times needs.
34
+ *
35
+ * Silent without an org: datasets are org-scoped, so a host with no resolvable
36
+ * org has no dataset to have written to. Best effort, and never thrown: the
37
+ * row is already stored.
38
+ */ async function announceDatasetStepWrite(orgId, datasetId) {
39
+ if (!orgId) return;
40
+ await announceDatasetRecordChange({
41
+ firestore: firebaseAdmin.app().firestore(),
42
+ orgId,
43
+ datasetId
44
+ });
45
+ }
46
+ /**
47
+ * Whether this dataset may take ANOTHER record, on the plan of the org that
48
+ * owns the site — the row band and the byte band, in that order. Null when
49
+ * the append may proceed; a reason string when it may not.
50
+ *
51
+ * ## Why an append needs a gate at all
52
+ *
53
+ * Every other door onto `datasets/{id}/records` already has one: the console
54
+ * route re-checks `recordsPerDataset` inside the creating transaction, the
55
+ * `/v1` record route checks it below its idempotency claim, and the public
56
+ * form-submission leg checks the rows and the bytes. An automation step wrote
57
+ * with no check of either — and it is the door a visitor drives hardest,
58
+ * because an action fires per event on a published site. A cap enforced at
59
+ * three of four doors is not a cap; it is the shape of the one that is left.
60
+ *
61
+ * ## What it does NOT do
62
+ *
63
+ * It refuses the WRITE, never the dataset. A dataset already holding more
64
+ * rows than the plan includes keeps every row it has and keeps being read —
65
+ * nothing here deletes, truncates, or hides anything, and nothing may be
66
+ * added that does. What is refused is the next row, which is the same
67
+ * boundary the other three doors draw, and the reason a plan change cannot
68
+ * cost a customer data they already have.
69
+ *
70
+ * The update leg of `updateDataset` is deliberately NOT gated: merging fields
71
+ * into a record that already exists adds no row, so refusing it would refuse
72
+ * the state of being over rather than the raise.
73
+ *
74
+ * ## What it costs
75
+ *
76
+ * Nothing on the plans that sell the data store. The row count is read only
77
+ * when `recordsPerDataset` is FINITE, so an uncapped plan pays nothing; and
78
+ * `dataStorageRefusal` answers null with no read at all whenever the plan
79
+ * carries an `extraDataGbMonthlyUsd` rate, which every metered plan does. The
80
+ * reads are paid on the shapes that can actually refuse. The org document is
81
+ * the one the run's gate already read, handed over with the step.
82
+ */ async function datasetAppendRefusal(request, datasetRef) {
83
+ const limit = resolveOrgEntitlements(request.org).recordsPerDataset;
84
+ if (Number.isFinite(limit)) {
85
+ const used = (await datasetRef.collection('records').count().get()).data().count;
86
+ if (!checkQuota(request.org, 'recordsPerDataset', used).allowed) {
87
+ return `dataset is full (${limit} records on this plan)`;
88
+ }
89
+ }
90
+ if (!request.orgId) return null;
91
+ const bytes = await dataStorageRefusal(request.org, firebaseAdmin.app().firestore().collection('orgs').doc(request.orgId));
92
+ if (!bytes) return null;
93
+ return `dataset storage is full (${bytes.includedMb} MB on this plan)`;
94
+ }
95
+ /** `datasetAppend`: one record from the event's matching fields. */ async function appendDatasetRecord(request, step) {
96
+ const { hostId, payload } = request;
97
+ // Id-first lookup (AGL-261/556); the name query is the legacy path.
98
+ const datasetsRef = await orgDataCollectionForHost(hostId, 'datasets');
99
+ const datasetDoc = await resolveDatasetDoc(datasetsRef, step, hostId);
100
+ if (!(datasetDoc == null ? void 0 : datasetDoc.exists) || datasetDoc.get('deletedAt')) {
101
+ return {
102
+ error: `unknown dataset "${step.datasetName || step.datasetId}"`
103
+ };
104
+ }
105
+ // Restrict to the model's field ids (AGL-556) — covers model-only
106
+ // datasets whose flat v1 `fields` mirror is absent.
107
+ const appendDataset = {
108
+ model: datasetDoc.get('model'),
109
+ fields: Array.isArray(datasetDoc.get('fields')) ? datasetDoc.get('fields') : []
110
+ };
111
+ const appendModel = effectiveDatasetModel(appendDataset);
112
+ // A plugin's field validator runs only once its plugin registered it.
113
+ await ensureDeclaredCustomFieldTypes(appendModel);
114
+ const write = prepareDatasetRecordWrite(appendDataset, payload);
115
+ const values = write.values;
116
+ // Same name precedence `findDatasetByName` resolves in.
117
+ const appendLabel = (datasetDisplayName({
118
+ displayName: datasetDoc.get('displayName'),
119
+ name: datasetDoc.get('name')
120
+ }) || step.datasetName || '').slice(0, 60);
121
+ // No event field matched a field of the dataset, so there is nothing
122
+ // to write. An error rather than a quiet success: a run history that
123
+ // says `saved to Leads` while nothing saves is how a mismatched field
124
+ // name goes unnoticed.
125
+ if (!write.matched.length) {
126
+ return {
127
+ error: `no event field matches a field in dataset "${appendLabel || step.datasetId}"`
128
+ };
129
+ }
130
+ // Held to the model like every other record write (AGL-2773): a value
131
+ // its field cannot hold refuses the whole record, and the run says
132
+ // which field and why instead of `saved to Leads`.
133
+ if (Object.keys(write.errors).length) {
134
+ return {
135
+ error: `record failed validation for dataset "${appendLabel || step.datasetId}": ${describeDatasetRecordErrors(write.errors)}`
136
+ };
137
+ }
138
+ if (!Object.keys(values).length) {
139
+ return {
140
+ error: `every event field matching dataset "${appendLabel || step.datasetId}" is empty`
141
+ };
142
+ }
143
+ const refusal = await datasetAppendRefusal(request, datasetDoc.ref);
144
+ if (refusal) return {
145
+ error: refusal
146
+ };
147
+ await datasetDoc.ref.collection('records').add(_extends({
148
+ values
149
+ }, datasetIntegrityFields(appendModel, values), {
150
+ createdAt: FieldValue.serverTimestamp()
151
+ }));
152
+ await announceDatasetStepWrite(request.orgId, datasetDoc.id);
153
+ // `saved to Leads` beats `saved to dataset` (AGL-2171).
154
+ return {
155
+ detail: appendLabel
156
+ };
157
+ }
158
+ /**
159
+ * `updateDataset`: update-or-append (AGL-257). Matches the record whose
160
+ * `email` field equals the payload's email; appends when nothing matches.
161
+ */ async function updateDatasetRecord(request, step) {
162
+ var _payload_email;
163
+ const { hostId, payload } = request;
164
+ const datasetsRef = await orgDataCollectionForHost(hostId, 'datasets');
165
+ const datasetDoc = await resolveDatasetDoc(datasetsRef, step, hostId);
166
+ if (!(datasetDoc == null ? void 0 : datasetDoc.exists) || datasetDoc.get('deletedAt')) {
167
+ return {
168
+ error: `unknown dataset "${step.datasetName || step.datasetId}"`
169
+ };
170
+ }
171
+ const updateDataset = {
172
+ model: datasetDoc.get('model'),
173
+ fields: Array.isArray(datasetDoc.get('fields')) ? datasetDoc.get('fields') : []
174
+ };
175
+ const updateModel = effectiveDatasetModel(updateDataset);
176
+ await ensureDeclaredCustomFieldTypes(updateModel);
177
+ const updateLabel = String(datasetDisplayName({
178
+ displayName: datasetDoc.get('displayName'),
179
+ name: datasetDoc.get('name')
180
+ }) || step.datasetName || step.datasetId || '').slice(0, 60);
181
+ // Checked before the lookup, as an append: the fields this write
182
+ // supplies are held to the model whichever leg runs below, and a
183
+ // refusal costs no read.
184
+ const incoming = prepareDatasetRecordWrite(updateDataset, payload);
185
+ // Nothing to merge or append — an error, for the reason the append
186
+ // step gives.
187
+ if (!incoming.matched.length) {
188
+ return {
189
+ error: `no event field matches a field in dataset "${updateLabel}"`
190
+ };
191
+ }
192
+ if (!Object.keys(incoming.values).length) {
193
+ return {
194
+ error: `every event field matching dataset "${updateLabel}" is empty`
195
+ };
196
+ }
197
+ const email = String((_payload_email = payload['email']) != null ? _payload_email : '').trim();
198
+ // `records.values` is exempt from indexing, so this lookup is served
199
+ // only by the `values.email` field override in
200
+ // cloud/firebase-firestore.indexes.json. Without that override
201
+ // production refuses the query and neither leg below runs.
202
+ const existing = email ? await datasetDoc.ref.collection('records').where('values.email', '==', email).limit(1).get() : null;
203
+ if (existing && !existing.empty) {
204
+ var _existing_docs__get;
205
+ // A merge holds only the fields this write sent to the model: a row
206
+ // stored as text before AGL-2773 is not refused for a legacy value
207
+ // this run never touched.
208
+ const write = prepareDatasetRecordWrite(updateDataset, payload, {
209
+ existing: (_existing_docs__get = existing.docs[0].get('values')) != null ? _existing_docs__get : {}
210
+ });
211
+ if (Object.keys(write.errors).length) {
212
+ return {
213
+ error: `record failed validation for dataset "${updateLabel}": ${describeDatasetRecordErrors(write.errors)}`
214
+ };
215
+ }
216
+ const merged = write.values;
217
+ await existing.docs[0].ref.set(_extends({
218
+ values: merged
219
+ }, datasetIntegrityUpdate(updateModel, merged, FieldValue.delete()), {
220
+ updatedAt: FieldValue.serverTimestamp()
221
+ }), // `mergeFields`, not `merge: true`: a merge would fold the new
222
+ // `filterValues` map into the stored one key by key, and a value
223
+ // cleared here would go on answering its old equality. Each named
224
+ // field is replaced whole; `merged` already holds every value.
225
+ {
226
+ mergeFields: [
227
+ 'values',
228
+ 'referencedIds',
229
+ 'filterKeys',
230
+ 'filterValues',
231
+ 'updatedAt'
232
+ ]
233
+ });
234
+ } else {
235
+ // The APPEND leg of update-or-append, and the only one of the two
236
+ // that adds a row — the merge above rewrites a record that already
237
+ // counts against the band. A new row is held to the whole model,
238
+ // required fields included, like any other append.
239
+ if (Object.keys(incoming.errors).length) {
240
+ return {
241
+ error: `record failed validation for dataset "${updateLabel}": ${describeDatasetRecordErrors(incoming.errors)}`
242
+ };
243
+ }
244
+ const refusal = await datasetAppendRefusal(request, datasetDoc.ref);
245
+ if (refusal) return {
246
+ error: refusal
247
+ };
248
+ await datasetDoc.ref.collection('records').add(_extends({
249
+ values: incoming.values
250
+ }, datasetIntegrityFields(updateModel, incoming.values), {
251
+ createdAt: FieldValue.serverTimestamp()
252
+ }));
253
+ }
254
+ // Both legs changed a row, so both make the same pages stale — an edited
255
+ // record reads no differently from a new one on a page that lists them.
256
+ await announceDatasetStepWrite(request.orgId, datasetDoc.id);
257
+ return {};
258
+ }
259
+ /** Runs one dataset step for the automation engine. */ export async function runDatasetStep(request) {
260
+ const step = request.step;
261
+ if (step.type === 'datasetAppend') return appendDatasetRecord(request, step);
262
+ if (step.type === 'updateDataset') return updateDatasetRecord(request, step);
263
+ return {
264
+ error: `"${String(step.type)}" is not a dataset step`
265
+ };
266
+ }
267
+
268
+ //# sourceMappingURL=dataset-steps.server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/server/dataset-steps.server.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\nimport {\n checkQuota,\n ensureDeclaredCustomFieldTypes,\n resolveOrgEntitlements,\n} from '@aglyn/aglyn/server'\nimport type {\n ServerStepAnswer,\n ServerStepRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-server-steps'\nimport {\n dataStorageRefusal,\n firebaseAdmin,\n orgDataCollectionForHost,\n} from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { DATASET_STEP_TYPES } from '../constants/bundle-common'\nimport { announceDatasetRecordChange } from './dataset-live-pages'\nimport { resolveDatasetDoc } from './resolve-dataset'\nimport { datasetIntegrityFields, datasetIntegrityUpdate, effectiveDatasetModel } from '../model/dataset-models'\nimport { datasetDisplayName, describeDatasetRecordErrors, prepareDatasetRecordWrite } from '../model/datasets'\n\n/**\n * THE TWO AUTOMATION STEPS THAT WRITE A DATASET RECORD (AGL-257, AGL-556,\n * AGL-2773), run for the automation engine through the platform's server-step\n * seam (`plugin-server-steps`, AGL-3080).\n *\n * `datasetAppend` adds a record; `updateDataset` merges into the record whose\n * `email` field equals the event's email and appends when none does. Both read\n * the event's fields that match a field of the dataset, held to its model.\n *\n * The engine runs the step's guard and keeps the run's history; this answers\n * what the step did. A refusal is ANSWERED as `error`, never thrown, and its\n * words are the line the run history carries — the same words the engine\n * wrote when these steps were its own.\n */\n\nexport type DatasetStepType = (typeof DATASET_STEP_TYPES)[number]\n\n/**\n * A dataset step as it is stored: the dataset by id (AGL-261, rename-safe),\n * with its name kept as a display hint — and the name alone on a step from\n * before ids, which the lookup resolves either way.\n */\nexport interface DatasetStep {\n type: DatasetStepType\n datasetId?: string\n datasetName?: string\n}\n\n/**\n * Refresh the live pages showing the dataset this step just wrote to\n * (AGL-3113).\n *\n * An automation that appends a row is the same change to a visitor as a form\n * submission or a console edit: the pages repeating over the dataset go on\n * serving the rows they were built from. Announced from the step rather than\n * from the run, so a workflow whose steps write two different datasets\n * refreshes both — the announce coalesces repeats of the SAME dataset itself,\n * which is what a run hitting one dataset several times needs.\n *\n * Silent without an org: datasets are org-scoped, so a host with no resolvable\n * org has no dataset to have written to. Best effort, and never thrown: the\n * row is already stored.\n */\nasync function announceDatasetStepWrite(\n orgId: string | null,\n datasetId: string,\n): Promise<void> {\n if (!orgId) return\n await announceDatasetRecordChange({\n firestore: firebaseAdmin.app().firestore(),\n orgId,\n datasetId,\n })\n}\n\n/**\n * Whether this dataset may take ANOTHER record, on the plan of the org that\n * owns the site — the row band and the byte band, in that order. Null when\n * the append may proceed; a reason string when it may not.\n *\n * ## Why an append needs a gate at all\n *\n * Every other door onto `datasets/{id}/records` already has one: the console\n * route re-checks `recordsPerDataset` inside the creating transaction, the\n * `/v1` record route checks it below its idempotency claim, and the public\n * form-submission leg checks the rows and the bytes. An automation step wrote\n * with no check of either — and it is the door a visitor drives hardest,\n * because an action fires per event on a published site. A cap enforced at\n * three of four doors is not a cap; it is the shape of the one that is left.\n *\n * ## What it does NOT do\n *\n * It refuses the WRITE, never the dataset. A dataset already holding more\n * rows than the plan includes keeps every row it has and keeps being read —\n * nothing here deletes, truncates, or hides anything, and nothing may be\n * added that does. What is refused is the next row, which is the same\n * boundary the other three doors draw, and the reason a plan change cannot\n * cost a customer data they already have.\n *\n * The update leg of `updateDataset` is deliberately NOT gated: merging fields\n * into a record that already exists adds no row, so refusing it would refuse\n * the state of being over rather than the raise.\n *\n * ## What it costs\n *\n * Nothing on the plans that sell the data store. The row count is read only\n * when `recordsPerDataset` is FINITE, so an uncapped plan pays nothing; and\n * `dataStorageRefusal` answers null with no read at all whenever the plan\n * carries an `extraDataGbMonthlyUsd` rate, which every metered plan does. The\n * reads are paid on the shapes that can actually refuse. The org document is\n * the one the run's gate already read, handed over with the step.\n */\nasync function datasetAppendRefusal(\n request: Pick<ServerStepRequest, 'org' | 'orgId'>,\n datasetRef: FirebaseFirestore.DocumentReference,\n): Promise<string | null> {\n const limit = resolveOrgEntitlements(request.org as never).recordsPerDataset\n if (Number.isFinite(limit)) {\n const used = (\n await datasetRef.collection('records').count().get()\n ).data().count\n if (!checkQuota(request.org as never, 'recordsPerDataset', used).allowed) {\n return `dataset is full (${limit} records on this plan)`\n }\n }\n if (!request.orgId) return null\n const bytes = await dataStorageRefusal(\n request.org as never,\n firebaseAdmin.app().firestore().collection('orgs').doc(request.orgId),\n )\n if (!bytes) return null\n return `dataset storage is full (${bytes.includedMb} MB on this plan)`\n}\n\n/** `datasetAppend`: one record from the event's matching fields. */\nasync function appendDatasetRecord(\n request: ServerStepRequest,\n step: DatasetStep,\n): Promise<ServerStepAnswer> {\n const { hostId, payload } = request\n // Id-first lookup (AGL-261/556); the name query is the legacy path.\n const datasetsRef = await orgDataCollectionForHost(hostId, 'datasets')\n const datasetDoc = await resolveDatasetDoc(datasetsRef, step, hostId)\n if (!datasetDoc?.exists || datasetDoc.get('deletedAt')) {\n return { error: `unknown dataset \"${step.datasetName || step.datasetId}\"` }\n }\n // Restrict to the model's field ids (AGL-556) — covers model-only\n // datasets whose flat v1 `fields` mirror is absent.\n const appendDataset = {\n model: datasetDoc.get('model'),\n fields: Array.isArray(datasetDoc.get('fields'))\n ? datasetDoc.get('fields')\n : [],\n }\n const appendModel = effectiveDatasetModel(appendDataset)\n // A plugin's field validator runs only once its plugin registered it.\n await ensureDeclaredCustomFieldTypes(appendModel)\n const write = prepareDatasetRecordWrite(appendDataset, payload)\n const values = write.values\n // Same name precedence `findDatasetByName` resolves in.\n const appendLabel = (\n datasetDisplayName({\n displayName: datasetDoc.get('displayName'),\n name: datasetDoc.get('name'),\n }) ||\n step.datasetName ||\n ''\n ).slice(0, 60)\n // No event field matched a field of the dataset, so there is nothing\n // to write. An error rather than a quiet success: a run history that\n // says `saved to Leads` while nothing saves is how a mismatched field\n // name goes unnoticed.\n if (!write.matched.length) {\n return {\n error: `no event field matches a field in dataset \"${appendLabel || step.datasetId}\"`,\n }\n }\n // Held to the model like every other record write (AGL-2773): a value\n // its field cannot hold refuses the whole record, and the run says\n // which field and why instead of `saved to Leads`.\n if (Object.keys(write.errors).length) {\n return {\n error: `record failed validation for dataset \"${appendLabel || step.datasetId}\": ${describeDatasetRecordErrors(write.errors)}`,\n }\n }\n if (!Object.keys(values).length) {\n return {\n error: `every event field matching dataset \"${appendLabel || step.datasetId}\" is empty`,\n }\n }\n const refusal = await datasetAppendRefusal(request, datasetDoc.ref)\n if (refusal) return { error: refusal }\n await datasetDoc.ref.collection('records').add({\n values,\n // The integrity index the console's delete check queries —\n // carried by every write that sets `values`, or the index\n // describes rows this one never held.\n ...datasetIntegrityFields(appendModel, values),\n createdAt: FieldValue.serverTimestamp(),\n })\n await announceDatasetStepWrite(request.orgId, datasetDoc.id)\n // `saved to Leads` beats `saved to dataset` (AGL-2171).\n return { detail: appendLabel }\n}\n\n/**\n * `updateDataset`: update-or-append (AGL-257). Matches the record whose\n * `email` field equals the payload's email; appends when nothing matches.\n */\nasync function updateDatasetRecord(\n request: ServerStepRequest,\n step: DatasetStep,\n): Promise<ServerStepAnswer> {\n const { hostId, payload } = request\n const datasetsRef = await orgDataCollectionForHost(hostId, 'datasets')\n const datasetDoc = await resolveDatasetDoc(datasetsRef, step, hostId)\n if (!datasetDoc?.exists || datasetDoc.get('deletedAt')) {\n return { error: `unknown dataset \"${step.datasetName || step.datasetId}\"` }\n }\n const updateDataset = {\n model: datasetDoc.get('model'),\n fields: Array.isArray(datasetDoc.get('fields'))\n ? datasetDoc.get('fields')\n : [],\n }\n const updateModel = effectiveDatasetModel(updateDataset)\n await ensureDeclaredCustomFieldTypes(updateModel)\n const updateLabel = String(\n datasetDisplayName({\n displayName: datasetDoc.get('displayName'),\n name: datasetDoc.get('name'),\n }) ||\n step.datasetName ||\n step.datasetId ||\n '',\n ).slice(0, 60)\n // Checked before the lookup, as an append: the fields this write\n // supplies are held to the model whichever leg runs below, and a\n // refusal costs no read.\n const incoming = prepareDatasetRecordWrite(updateDataset, payload)\n // Nothing to merge or append — an error, for the reason the append\n // step gives.\n if (!incoming.matched.length) {\n return { error: `no event field matches a field in dataset \"${updateLabel}\"` }\n }\n if (!Object.keys(incoming.values).length) {\n return { error: `every event field matching dataset \"${updateLabel}\" is empty` }\n }\n const email = String(payload['email'] ?? '').trim()\n // `records.values` is exempt from indexing, so this lookup is served\n // only by the `values.email` field override in\n // cloud/firebase-firestore.indexes.json. Without that override\n // production refuses the query and neither leg below runs.\n const existing = email\n ? await datasetDoc.ref\n .collection('records')\n .where('values.email', '==', email)\n .limit(1)\n .get()\n : null\n if (existing && !existing.empty) {\n // A merge holds only the fields this write sent to the model: a row\n // stored as text before AGL-2773 is not refused for a legacy value\n // this run never touched.\n const write = prepareDatasetRecordWrite(updateDataset, payload, {\n existing: existing.docs[0].get('values') ?? {},\n })\n if (Object.keys(write.errors).length) {\n return {\n error: `record failed validation for dataset \"${updateLabel}\": ${describeDatasetRecordErrors(write.errors)}`,\n }\n }\n const merged = write.values\n await existing.docs[0].ref.set(\n {\n values: merged,\n // The merging form: an update that clears the last reference\n // has to REMOVE the index rather than omit it, or a stale\n // array refuses a delete nothing is holding.\n ...datasetIntegrityUpdate(updateModel, merged, FieldValue.delete()),\n updatedAt: FieldValue.serverTimestamp(),\n },\n // `mergeFields`, not `merge: true`: a merge would fold the new\n // `filterValues` map into the stored one key by key, and a value\n // cleared here would go on answering its old equality. Each named\n // field is replaced whole; `merged` already holds every value.\n {\n mergeFields: ['values', 'referencedIds', 'filterKeys', 'filterValues', 'updatedAt'],\n },\n )\n } else {\n // The APPEND leg of update-or-append, and the only one of the two\n // that adds a row — the merge above rewrites a record that already\n // counts against the band. A new row is held to the whole model,\n // required fields included, like any other append.\n if (Object.keys(incoming.errors).length) {\n return {\n error: `record failed validation for dataset \"${updateLabel}\": ${describeDatasetRecordErrors(incoming.errors)}`,\n }\n }\n const refusal = await datasetAppendRefusal(request, datasetDoc.ref)\n if (refusal) return { error: refusal }\n await datasetDoc.ref.collection('records').add({\n values: incoming.values,\n ...datasetIntegrityFields(updateModel, incoming.values),\n createdAt: FieldValue.serverTimestamp(),\n })\n }\n // Both legs changed a row, so both make the same pages stale — an edited\n // record reads no differently from a new one on a page that lists them.\n await announceDatasetStepWrite(request.orgId, datasetDoc.id)\n return {}\n}\n\n/** Runs one dataset step for the automation engine. */\nexport async function runDatasetStep(request: ServerStepRequest): Promise<ServerStepAnswer> {\n const step = request.step as unknown as DatasetStep\n if (step.type === 'datasetAppend') return appendDatasetRecord(request, step)\n if (step.type === 'updateDataset') return updateDatasetRecord(request, step)\n return { error: `\"${String(step.type)}\" is not a dataset step` }\n}\n"],"names":["checkQuota","ensureDeclaredCustomFieldTypes","resolveOrgEntitlements","dataStorageRefusal","firebaseAdmin","orgDataCollectionForHost","FieldValue","announceDatasetRecordChange","resolveDatasetDoc","datasetIntegrityFields","datasetIntegrityUpdate","effectiveDatasetModel","datasetDisplayName","describeDatasetRecordErrors","prepareDatasetRecordWrite","announceDatasetStepWrite","orgId","datasetId","firestore","app","datasetAppendRefusal","request","datasetRef","limit","org","recordsPerDataset","Number","isFinite","used","collection","count","get","data","allowed","bytes","doc","includedMb","appendDatasetRecord","step","hostId","payload","datasetsRef","datasetDoc","exists","error","datasetName","appendDataset","model","fields","Array","isArray","appendModel","write","values","appendLabel","displayName","name","slice","matched","length","Object","keys","errors","refusal","ref","add","createdAt","serverTimestamp","id","detail","updateDatasetRecord","updateDataset","updateModel","updateLabel","String","incoming","email","trim","existing","where","empty","docs","merged","set","delete","updatedAt","mergeFields","runDatasetStep","type"],"mappings":"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"}
@@ -28,6 +28,8 @@ import { type PluginWebApiHandler } from '@aglyn/aglyn/server';
28
28
  * - `create-record`: `recordsPerDataset` quota; values are re-coerced
29
29
  * and re-validated against the dataset's model server-side.
30
30
  * - `import-records`: batch create with the whole batch fitting the cap.
31
+ * - `add-address-field`: a "Page address" field that fills in from a text
32
+ * field, and a unique address on every record that has none (AGL-3475).
31
33
  *
32
34
  * Since AGL-2163 both record actions ALSO enforce `checkDataStorageQuota`.
33
35
  * See {@link refuseIfDataStorageBlocked}: that check's `allowed` field had no
@@ -15,11 +15,14 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { defaultScopeForNewResource, newResourceScopeFields, pluginRequestFromWeb } from "@aglyn/aglyn/server";
18
- import { checkDatasetQuota, checkEntitlement, checkQuota, coerceDocumentValues, createResourceUid, datasetIntegrityFields, effectiveDatasetModel, ensureDeclaredCustomFieldTypes, memberCanSee, validateDocument } from "@aglyn/aglyn/server";
18
+ import { checkDatasetQuota, checkEntitlement, checkQuota, createResourceUid, ensureDeclaredCustomFieldTypes, memberCanSee } from "@aglyn/aglyn/server";
19
19
  import { emailUnverifiedResponse, firebaseAdmin, dataStorageRefusal, isImpersonationSession, isServerReleaseFlagOnForOrg, lockdownRefusal, memberHasOrgPermission, resolveOrgIdForHost, resolveOrgMembership } from "@aglyn/tenant-data-admin";
20
20
  import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
21
- import { Timestamp } from "firebase-admin/firestore";
21
+ import { FieldPath, FieldValue, Timestamp } from "firebase-admin/firestore";
22
22
  import { announceDatasetRecords as announceDatasetChange } from "./announce-dataset-records.js";
23
+ import { coerceDocumentValues, datasetIntegrityFields, datasetIntegrityUpdate, effectiveDatasetModel, validateDocument } from "../model/dataset-models.js";
24
+ import { defaultDatasetFieldId } from "../model/datasets.js";
25
+ import { RECORD_PAGE_ADDRESS_FIELD_TYPE, fillRecordAddresses, isRecordAddressField, recordAddressFieldIds, recordAddressOf, uniqueRecordAddresses } from "../record-pages/record-pages.js";
23
26
  /**
24
27
  * `dataStorageMbPerOrg` for this route, rendered as the console's 403.
25
28
  *
@@ -48,6 +51,11 @@ import { announceDatasetRecords as announceDatasetChange } from "./announce-data
48
51
  * chunk size the route already used for its batches, so nothing about the
49
52
  * shape of an import changed when the batches became transactions (AGL-2371).
50
53
  */ const IMPORT_CHUNK = 400;
54
+ /**
55
+ * How many records one "give it page addresses" call fills (AGL-3475). Far
56
+ * past a services or locations dataset; a larger one reports `truncated`, and
57
+ * the next call fills the next records, since a filled record is skipped.
58
+ */ const ADDRESS_FILL_MAX = 5000;
51
59
  /**
52
60
  * Dataset/record creation API (AGL-473): creates moved out of the client
53
61
  * SDK so quotas and entitlements are enforced server-side — Firestore
@@ -61,6 +69,8 @@ import { announceDatasetRecords as announceDatasetChange } from "./announce-data
61
69
  * - `create-record`: `recordsPerDataset` quota; values are re-coerced
62
70
  * and re-validated against the dataset's model server-side.
63
71
  * - `import-records`: batch create with the whole batch fitting the cap.
72
+ * - `add-address-field`: a "Page address" field that fills in from a text
73
+ * field, and a unique address on every record that has none (AGL-3475).
64
74
  *
65
75
  * Since AGL-2163 both record actions ALSO enforce `checkDataStorageQuota`.
66
76
  * See {@link refuseIfDataStorageBlocked}: that check's `allowed` field had no
@@ -297,7 +307,7 @@ import { announceDatasetRecords as announceDatasetChange } from "./announce-data
297
307
  status: 200
298
308
  });
299
309
  }
300
- if (action === 'create-record' || action === 'import-records' || action === 'announce-records') {
310
+ if (action === 'create-record' || action === 'import-records' || action === 'announce-records' || action === 'add-address-field') {
301
311
  var _ref4;
302
312
  var _datasetSnapshot_data;
303
313
  const datasetId = String((_ref4 = body == null ? void 0 : body.datasetId) != null ? _ref4 : '');
@@ -366,6 +376,92 @@ import { announceDatasetRecords as announceDatasetChange } from "./announce-data
366
376
  });
367
377
  }
368
378
  const model = effectiveDatasetModel(datasetSnapshot.data());
379
+ /**
380
+ * Gives the dataset a page address (AGL-3475): a "Page address" field
381
+ * that fills in from `sourceField`, and an address on every record that
382
+ * has none yet, unique within the dataset (`roofing`, `roofing-2`).
383
+ *
384
+ * Server-side because it writes every record, and each write has to
385
+ * carry the filter fields the record template's lookup reads — the
386
+ * address is found through `filterValues`, so a record written without
387
+ * them would have a page nobody can reach. A dataset that already has
388
+ * an address field keeps it: the call fills that field's empty
389
+ * addresses instead of adding a second one.
390
+ */ if (action === 'add-address-field') {
391
+ var _ref5;
392
+ const sourceField = String((_ref5 = body == null ? void 0 : body.sourceField) != null ? _ref5 : '').trim();
393
+ const source = model.fields[sourceField];
394
+ if (!source || source.type !== 'text' || isRecordAddressField(source)) {
395
+ return Response.json({
396
+ error: 'Pick a text field to fill page addresses from'
397
+ }, {
398
+ status: 400
399
+ });
400
+ }
401
+ const held = recordAddressFieldIds(model)[0];
402
+ const fieldId = held != null ? held : defaultDatasetFieldId('slug', new Set(model.order));
403
+ const nextModel = held ? model : {
404
+ fields: _extends({}, model.fields, {
405
+ [fieldId]: {
406
+ name: 'Page address',
407
+ type: 'text',
408
+ customType: RECORD_PAGE_ADDRESS_FIELD_TYPE,
409
+ slugFrom: sourceField
410
+ }
411
+ }),
412
+ order: [
413
+ ...model.order,
414
+ fieldId
415
+ ]
416
+ };
417
+ if (!held) {
418
+ await datasetRef.update({
419
+ model: nextModel,
420
+ updatedAt: Timestamp.now()
421
+ });
422
+ }
423
+ const snapshot = await datasetRef.collection('records').orderBy(FieldPath.documentId()).limit(ADDRESS_FILL_MAX + 1).get();
424
+ const docs = snapshot.docs.slice(0, ADDRESS_FILL_MAX);
425
+ const valuesOf = (doc)=>{
426
+ var _doc_get;
427
+ return (_doc_get = doc.get('values')) != null ? _doc_get : {};
428
+ };
429
+ const taken = docs.map((doc)=>recordAddressOf(valuesOf(doc), fieldId)).filter(Boolean);
430
+ const assigned = uniqueRecordAddresses(docs.filter((doc)=>!recordAddressOf(valuesOf(doc), fieldId)).map((doc)=>({
431
+ id: doc.id,
432
+ source: valuesOf(doc)[sourceField]
433
+ })), taken);
434
+ const writes = docs.filter((doc)=>assigned.has(doc.id));
435
+ for(let start = 0; start < writes.length; start += IMPORT_CHUNK){
436
+ const batch = firestore.batch();
437
+ for (const doc of writes.slice(start, start + IMPORT_CHUNK)){
438
+ const values = _extends({}, valuesOf(doc), {
439
+ [fieldId]: assigned.get(doc.id)
440
+ });
441
+ batch.update(doc.ref, _extends({
442
+ values
443
+ }, datasetIntegrityUpdate(nextModel, values, FieldValue.delete()), {
444
+ updatedAt: Timestamp.now()
445
+ }));
446
+ }
447
+ await batch.commit();
448
+ }
449
+ if (writes.length || !held) {
450
+ await announceDatasetChange({
451
+ firestore,
452
+ orgId,
453
+ datasetId
454
+ });
455
+ }
456
+ return Response.json({
457
+ ok: true,
458
+ fieldId,
459
+ filled: writes.length,
460
+ truncated: snapshot.docs.length > ADDRESS_FILL_MAX
461
+ }, {
462
+ status: 200
463
+ });
464
+ }
369
465
  // Before either validation below: a plugin's field validator only runs
370
466
  // once its plugin's server entry has registered it.
371
467
  await ensureDeclaredCustomFieldTypes(model);
@@ -373,8 +469,8 @@ import { announceDatasetRecords as announceDatasetChange } from "./announce-data
373
469
  const recordCount = (await recordsRef.count().get()).data().count;
374
470
  const overRecordQuota = (limit)=>`Record limit reached (${limit}) — upgrade in Billing`;
375
471
  if (action === 'create-record') {
376
- var _ref5;
377
- const coerced = coerceDocumentValues(model, (_ref5 = body == null ? void 0 : body.values) != null ? _ref5 : {});
472
+ var _ref6;
473
+ const coerced = fillRecordAddresses(model, coerceDocumentValues(model, (_ref6 = body == null ? void 0 : body.values) != null ? _ref6 : {}));
378
474
  const errors = validateDocument(model, coerced);
379
475
  if (Object.keys(errors).length) {
380
476
  return Response.json({
@@ -486,7 +582,7 @@ import { announceDatasetRecords as announceDatasetChange } from "./announce-data
486
582
  if (importStorageRefusal) return importStorageRefusal;
487
583
  const prepared = rows.map((row, index)=>{
488
584
  var _ref;
489
- const coerced = coerceDocumentValues(model, (_ref = row == null ? void 0 : row.values) != null ? _ref : {});
585
+ const coerced = fillRecordAddresses(model, coerceDocumentValues(model, (_ref = row == null ? void 0 : row.values) != null ? _ref : {}));
490
586
  const errors = validateDocument(model, coerced);
491
587
  return {
492
588
  index,