@aglyn/plugins-data 1.0.0-beta.221 → 1.0.0-beta.223
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -6
- package/package.json +10 -10
- package/src/index.d.ts +1 -1
- package/src/index.js +1 -1
- package/src/index.js.map +1 -1
- package/src/lib/artifact/dataset-schema-artifact.server.d.ts +57 -0
- package/src/lib/artifact/dataset-schema-artifact.server.js +308 -0
- package/src/lib/artifact/dataset-schema-artifact.server.js.map +1 -0
- package/src/lib/artifact/dataset-schema.d.ts +136 -0
- package/src/lib/artifact/dataset-schema.js +232 -0
- package/src/lib/artifact/dataset-schema.js.map +1 -0
- package/src/lib/components/dataset-record-dialog.component.d.ts +1 -1
- package/src/lib/components/dataset-record-dialog.component.js +1 -1
- package/src/lib/components/dataset-record-dialog.component.js.map +1 -1
- package/src/lib/components/dataset-record-filter.d.ts +1 -1
- package/src/lib/components/dataset-record-filter.js +2 -2
- package/src/lib/components/dataset-record-filter.js.map +1 -1
- package/src/lib/components/dataset-schema-dialog.component.d.ts +1 -1
- package/src/lib/components/dataset-schema-dialog.component.js +55 -23
- package/src/lib/components/dataset-schema-dialog.component.js.map +1 -1
- package/src/lib/components/host-datasets-card.component.js +91 -403
- package/src/lib/components/host-datasets-card.component.js.map +1 -1
- package/src/lib/constants/bundle-common.d.ts +12 -0
- package/src/lib/constants/bundle-common.js +13 -0
- package/src/lib/constants/bundle-common.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +16 -1
- package/src/lib/declarations.console-server.js +34 -2
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/declarations.server.d.ts +13 -0
- package/src/lib/declarations.server.js +48 -2
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/form-target/dataset-form-record-target.server.js +5 -3
- package/src/lib/form-target/dataset-form-record-target.server.js.map +1 -1
- package/src/lib/model/dataset-models.d.ts +392 -0
- package/src/lib/model/dataset-models.js +806 -0
- package/src/lib/model/dataset-models.js.map +1 -0
- package/src/lib/model/dataset-record-view.d.ts +1 -1
- package/src/lib/model/dataset-record-view.js +1 -2
- package/src/lib/model/dataset-record-view.js.map +1 -1
- package/src/lib/model/dataset-record.js +2 -2
- package/src/lib/model/dataset-record.js.map +1 -1
- package/src/lib/model/datasets.d.ts +162 -0
- package/src/lib/model/datasets.js +227 -0
- package/src/lib/model/datasets.js.map +1 -0
- package/src/lib/plugin.d.ts +4 -0
- package/src/lib/plugin.js +40 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/record-pages/record-page-live-paths.server.d.ts +55 -0
- package/src/lib/record-pages/record-page-live-paths.server.js +83 -0
- package/src/lib/record-pages/record-page-live-paths.server.js.map +1 -0
- package/src/lib/record-pages/record-page-read.server.d.ts +70 -0
- package/src/lib/record-pages/record-page-read.server.js +175 -0
- package/src/lib/record-pages/record-page-read.server.js.map +1 -0
- package/src/lib/record-pages/record-page-resolver.server.d.ts +37 -0
- package/src/lib/record-pages/record-page-resolver.server.js +131 -0
- package/src/lib/record-pages/record-page-resolver.server.js.map +1 -0
- package/src/lib/record-pages/record-page-sitemap.server.d.ts +32 -0
- package/src/lib/record-pages/record-page-sitemap.server.js +102 -0
- package/src/lib/record-pages/record-page-sitemap.server.js.map +1 -0
- package/src/lib/record-pages/record-page-source.d.ts +42 -0
- package/src/lib/record-pages/record-page-source.js +176 -0
- package/src/lib/record-pages/record-page-source.js.map +1 -0
- package/src/lib/record-pages/record-pages-route.d.ts +42 -0
- package/src/lib/record-pages/record-pages-route.js +348 -0
- package/src/lib/record-pages/record-pages-route.js.map +1 -0
- package/src/lib/record-pages/record-pages.d.ts +218 -0
- package/src/lib/record-pages/record-pages.js +351 -0
- package/src/lib/record-pages/record-pages.js.map +1 -0
- package/src/lib/record-pages/record-template-row-chip.component.d.ts +28 -0
- package/src/lib/record-pages/record-template-row-chip.component.js +87 -0
- package/src/lib/record-pages/record-template-row-chip.component.js.map +1 -0
- package/src/lib/record-pages/record-template-section.component.d.ts +30 -0
- package/src/lib/record-pages/record-template-section.component.js +491 -0
- package/src/lib/record-pages/record-template-section.component.js.map +1 -0
- package/src/lib/repeat/dataset-repeat-rows.d.ts +2 -1
- package/src/lib/repeat/dataset-repeat-rows.js +30 -12
- package/src/lib/repeat/dataset-repeat-rows.js.map +1 -1
- package/src/lib/repeat/dataset-repeat-rows.server.d.ts +1 -1
- package/src/lib/repeat/dataset-repeat-rows.server.js +23 -12
- package/src/lib/repeat/dataset-repeat-rows.server.js.map +1 -1
- package/src/lib/repeat/repeat-record-pages.d.ts +48 -0
- package/src/lib/repeat/repeat-record-pages.js +64 -0
- package/src/lib/repeat/repeat-record-pages.js.map +1 -0
- package/src/lib/server/announce-dataset-records.d.ts +1 -1
- package/src/lib/server/announce-dataset-records.js +1 -1
- package/src/lib/server/announce-dataset-records.js.map +1 -1
- package/src/lib/server/api-v1/datasets.js +5 -3
- package/src/lib/server/api-v1/datasets.js.map +1 -1
- package/src/lib/server/dataset-figures.js +2 -2
- package/src/lib/server/dataset-figures.js.map +1 -1
- package/src/lib/server/dataset-live-pages.d.ts +147 -0
- package/src/lib/server/dataset-live-pages.js +436 -0
- package/src/lib/server/dataset-live-pages.js.map +1 -0
- package/src/lib/server/dataset-steps.server.d.ts +45 -0
- package/src/lib/server/dataset-steps.server.js +268 -0
- package/src/lib/server/dataset-steps.server.js.map +1 -0
- package/src/lib/server/datasets-route.d.ts +2 -0
- package/src/lib/server/datasets-route.js +102 -6
- package/src/lib/server/datasets-route.js.map +1 -1
- package/src/lib/server/resolve-dataset.d.ts +40 -0
- package/src/lib/server/resolve-dataset.js +64 -0
- package/src/lib/server/resolve-dataset.js.map +1 -0
- package/src/lib/server.d.ts +3 -2
- package/src/lib/server.js +8 -9
- package/src/lib/server.js.map +1 -1
- package/src/lib/site-bundle/datasets-package.d.ts +42 -0
- package/src/lib/site-bundle/datasets-package.js +95 -0
- package/src/lib/site-bundle/datasets-package.js.map +1 -0
- package/src/lib/site-bundle/datasets-site-bundle.server.js +2 -2
- package/src/lib/site-bundle/datasets-site-bundle.server.js.map +1 -1
- package/src/lib/{model/index.js → tenant-api.d.ts} +12 -7
- package/src/lib/tenant-api.js +30 -0
- package/src/lib/tenant-api.js.map +1 -0
- package/src/lib/transfer/dataset-transfer-key.d.ts +30 -0
- package/src/lib/transfer/dataset-transfer-key.js +31 -0
- package/src/lib/transfer/dataset-transfer-key.js.map +1 -0
- package/src/lib/transfer/dataset-transfer-model.d.ts +146 -0
- package/src/lib/transfer/dataset-transfer-model.js +536 -0
- package/src/lib/transfer/dataset-transfer-model.js.map +1 -0
- package/src/lib/{model/index.d.ts → transfer/dataset-transfer-resource.d.ts} +2 -6
- package/src/lib/transfer/dataset-transfer-resource.js +41 -0
- package/src/lib/transfer/dataset-transfer-resource.js.map +1 -0
- package/src/lib/transfer/dataset-transfer.server.d.ts +69 -0
- package/src/lib/transfer/dataset-transfer.server.js +930 -0
- package/src/lib/transfer/dataset-transfer.server.js.map +1 -0
- package/src/lib/model/dataset-csv.d.ts +0 -36
- package/src/lib/model/dataset-csv.js +0 -85
- package/src/lib/model/dataset-csv.js.map +0 -1
- package/src/lib/model/dataset-io.d.ts +0 -44
- package/src/lib/model/dataset-io.js +0 -85
- package/src/lib/model/dataset-io.js.map +0 -1
- package/src/lib/model/index.js.map +0 -1
- package/src/lib/server/datasets-export-route.d.ts +0 -57
- package/src/lib/server/datasets-export-route.js +0 -229
- 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,
|
|
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
|
|
377
|
-
const coerced = coerceDocumentValues(model, (
|
|
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,
|