@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.
- 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,930 @@
|
|
|
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 { createHash } from "node:crypto";
|
|
18
|
+
import { TRANSFER_ID_FIELD, buildTransferPlan, matchLookupKey, planTransferUndo, rankTransferLookupSuggestions, transferLookupKey, transferLookupNewName, transferResourceInstanceOf } from "@aglyn/aglyn/data-transfer";
|
|
19
|
+
import { checkQuota, ensureDeclaredCustomFieldTypes, memberCanSee } from "@aglyn/aglyn/server";
|
|
20
|
+
import { dataStorageRefusal, firebaseAdmin, isServerReleaseFlagOnForOrg, resolveOrgMembership } from "@aglyn/tenant-data-admin";
|
|
21
|
+
import { applyListQuery } from "@aglyn/tenant-data-admin/server/list-query";
|
|
22
|
+
import { TransferEngineError } from "@aglyn/tenant-data-admin/server/transfer-jobs";
|
|
23
|
+
import { FieldPath, FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
24
|
+
import { datasetRecordFilter, planRecordQuery } from "../components/dataset-record-filter.js";
|
|
25
|
+
import { datasetFilterToken, datasetFilterValuePath, datasetFilterWords, datasetIntegrityFields, datasetIntegrityUpdate, effectiveDatasetModel } from "../model/dataset-models.js";
|
|
26
|
+
import { datasetDisplayName, describeDatasetRecordErrors } from "../model/datasets.js";
|
|
27
|
+
import { fillRecordAddresses } from "../record-pages/record-pages.js";
|
|
28
|
+
import { announceDatasetRecords } from "../server/announce-dataset-records.js";
|
|
29
|
+
import { datasetTransferResourceKey } from "./dataset-transfer-key.js";
|
|
30
|
+
import { datasetFieldIdOf, datasetFieldOfPicklist, datasetHoldsKey, datasetMatchKeyOffer, datasetMatchKeyQuery, datasetOptionsPicklist, datasetRecordTransferValues, datasetStorageValues, datasetTransferCatalog, datasetTransferFieldId, datasetWriteErrors, isOptionsField, limitDatasetCreates, refuseInvalidDatasetRows } from "./dataset-transfer-model.js";
|
|
31
|
+
const productionDeps = ()=>({
|
|
32
|
+
firestore: firebaseAdmin.app().firestore()
|
|
33
|
+
});
|
|
34
|
+
/** The page an export reads when the engine names none. */ const PAGE_ROWS = 500;
|
|
35
|
+
/** Firestore's cap on the values of one `in` query. */ const IN_MAX = 30;
|
|
36
|
+
/** The most documents one `getAll` reads. */ const GET_ALL_MAX = 300;
|
|
37
|
+
/** What a merging record write names, so each field is replaced whole. */ const RECORD_MERGE_FIELDS = [
|
|
38
|
+
'values',
|
|
39
|
+
'referencedIds',
|
|
40
|
+
'filterKeys',
|
|
41
|
+
'filterValues',
|
|
42
|
+
'updatedAt'
|
|
43
|
+
];
|
|
44
|
+
const chunked = (items, size)=>{
|
|
45
|
+
const out = [];
|
|
46
|
+
for(let at = 0; at < items.length; at += size)out.push(items.slice(at, at + size));
|
|
47
|
+
return out;
|
|
48
|
+
};
|
|
49
|
+
const notFound = ()=>new TransferEngineError('notFound', 404, 'No such dataset.');
|
|
50
|
+
/** The nanoid alphabet `createResourceUid` names documents from. */ const ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-';
|
|
51
|
+
/**
|
|
52
|
+
* The id a record created by an import's row is given: ten characters of the
|
|
53
|
+
* resource-id alphabet, derived from the job and the row, so a retried chunk
|
|
54
|
+
* names the record it already wrote rather than writing a second one.
|
|
55
|
+
*/ export function transferRecordId(jobId, row) {
|
|
56
|
+
const digest = createHash('sha256').update(`${jobId}:${row}`).digest();
|
|
57
|
+
let id = '';
|
|
58
|
+
for(let at = 0; at < 10; at += 1)id += ID_ALPHABET[digest[at] % ID_ALPHABET.length];
|
|
59
|
+
return id;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The dataset per context object: the engine hands one context to every
|
|
63
|
+
* hook a step runs, so an export's pages and a plan's lookups read the
|
|
64
|
+
* dataset and the member once.
|
|
65
|
+
*/ const loaded = new WeakMap();
|
|
66
|
+
async function readDataset(deps, ctx) {
|
|
67
|
+
const datasetId = transferResourceInstanceOf(ctx);
|
|
68
|
+
if (!datasetId) throw notFound();
|
|
69
|
+
const orgRef = deps.firestore.collection('orgs').doc(ctx.orgId);
|
|
70
|
+
const ref = orgRef.collection('datasets').doc(datasetId);
|
|
71
|
+
const snapshot = await ref.get();
|
|
72
|
+
const data = snapshot.exists ? snapshot.data() : null;
|
|
73
|
+
if (!data || data['deletedAt'] != null) throw notFound();
|
|
74
|
+
const visibleTo = Array.isArray(data['visibleTo']) ? data['visibleTo'] : [];
|
|
75
|
+
/*
|
|
76
|
+
* The member has to SEE the dataset, as on every other door onto its
|
|
77
|
+
* records (`datasets-route.ts`): the gate asked about the workspace, and a
|
|
78
|
+
* collaborator scoped to one site passes it. The Admin SDK reads past the
|
|
79
|
+
* rules, so this IS the enforcement, answered with the same 404 as a
|
|
80
|
+
* dataset that does not exist. No membership is staff (the gate admitted
|
|
81
|
+
* them) or the sweep resuming a job; the data store's release flag holds
|
|
82
|
+
* for everyone else.
|
|
83
|
+
*/ if (ctx.actorUid) {
|
|
84
|
+
var _this;
|
|
85
|
+
const member = (_this = await resolveOrgMembership(ctx.actorUid, ctx.orgId)) == null ? void 0 : _this.member;
|
|
86
|
+
if (member) {
|
|
87
|
+
if (!memberCanSee(member, visibleTo)) throw notFound();
|
|
88
|
+
if (!await isServerReleaseFlagOnForOrg('release_data_store', ctx.orgId)) {
|
|
89
|
+
throw new TransferEngineError('notFound', 404, 'Datasets are not available on this workspace.');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
id: datasetId,
|
|
95
|
+
label: datasetDisplayName(data) || 'Fields',
|
|
96
|
+
model: effectiveDatasetModel(data),
|
|
97
|
+
visibleTo,
|
|
98
|
+
orgRef,
|
|
99
|
+
ref,
|
|
100
|
+
records: ref.collection('records')
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function datasetFor(deps, ctx) {
|
|
104
|
+
let pending = loaded.get(ctx);
|
|
105
|
+
if (!pending) {
|
|
106
|
+
pending = readDataset(deps, ctx);
|
|
107
|
+
loaded.set(ctx, pending);
|
|
108
|
+
pending.catch(()=>loaded.delete(ctx));
|
|
109
|
+
}
|
|
110
|
+
return pending;
|
|
111
|
+
}
|
|
112
|
+
/** The context a referenced dataset is asked in: the same job, actor and site, as that dataset. */ function targetContext(ctx, datasetId) {
|
|
113
|
+
return _extends({}, ctx, {
|
|
114
|
+
resource: datasetTransferResourceKey(datasetId)
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Each dataset the model references, as the person may use it: whether they
|
|
119
|
+
* can read it (the same check as opening it), and its model when they can.
|
|
120
|
+
*/ async function datasetReferenceTargets(deps, ctx, model) {
|
|
121
|
+
var _model_fields;
|
|
122
|
+
const out = {};
|
|
123
|
+
for (const field of Object.values((_model_fields = model.fields) != null ? _model_fields : {})){
|
|
124
|
+
var _field_reference;
|
|
125
|
+
const targetId = (field == null ? void 0 : field.type) === 'reference' ? (_field_reference = field.reference) == null ? void 0 : _field_reference.datasetId : undefined;
|
|
126
|
+
if (!targetId || out[targetId]) continue;
|
|
127
|
+
try {
|
|
128
|
+
out[targetId] = {
|
|
129
|
+
readable: true,
|
|
130
|
+
model: (await datasetFor(deps, targetContext(ctx, targetId))).model
|
|
131
|
+
};
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (!(error instanceof TransferEngineError && error.code === 'notFound')) throw error;
|
|
134
|
+
out[targetId] = {
|
|
135
|
+
readable: false
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
/** A record document as transfer values. */ function viewOf(model, doc) {
|
|
142
|
+
var _doc_data, _data_values;
|
|
143
|
+
const data = (_doc_data = doc.data()) != null ? _doc_data : {};
|
|
144
|
+
return datasetRecordTransferValues(model, {
|
|
145
|
+
id: doc.id,
|
|
146
|
+
values: (_data_values = data['values']) != null ? _data_values : {},
|
|
147
|
+
createdAt: data['createdAt'],
|
|
148
|
+
updatedAt: data['updatedAt']
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
async function getAll(firestore, refs) {
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const slice of chunked(refs, GET_ALL_MAX))if (slice.length) out.push(...await firestore.getAll(...slice));
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
function readFilter(filter) {
|
|
157
|
+
const clauses = Array.isArray(filter['clauses']) ? filter['clauses'] : [];
|
|
158
|
+
const search = Array.isArray(filter['search']) ? filter['search'] : [];
|
|
159
|
+
const clause = (entry)=>{
|
|
160
|
+
const one = entry;
|
|
161
|
+
return Boolean(one) && typeof (one == null ? void 0 : one['field']) === 'string' && typeof (one == null ? void 0 : one['op']) === 'string' && typeof (one == null ? void 0 : one['value']) === 'string';
|
|
162
|
+
};
|
|
163
|
+
if (!clauses.every(clause) || !search.every((word)=>typeof word === 'string')) {
|
|
164
|
+
throw new TransferEngineError('invalid', 400, 'The filter could not be read.');
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
clauses: clauses,
|
|
168
|
+
search: search
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Every record, or the ones the records table's filter matches — the same
|
|
173
|
+
* one query the table pages (`planRecordQuery`), ordered by document id.
|
|
174
|
+
* What the table could not put on its query it did not apply either, so
|
|
175
|
+
* neither does the export.
|
|
176
|
+
*/ function recordsQuery(dataset, options) {
|
|
177
|
+
if (!(options == null ? void 0 : options.filter)) return dataset.records.orderBy(FieldPath.documentId());
|
|
178
|
+
const filter = readFilter(options.filter);
|
|
179
|
+
const { plan } = planRecordQuery(dataset.model, datasetRecordFilter(dataset.model), filter.clauses, filter.search);
|
|
180
|
+
return applyListQuery(dataset.records, plan);
|
|
181
|
+
}
|
|
182
|
+
/** A collaborator's scope tokens must reach the dataset; records carry no scope of their own. */ function assertScope(dataset, options) {
|
|
183
|
+
if ((options == null ? void 0 : options.scopeTokens) && !options.scopeTokens.some((token)=>dataset.visibleTo.includes(token))) throw notFound();
|
|
184
|
+
}
|
|
185
|
+
function pick(view, fieldIds) {
|
|
186
|
+
return Object.fromEntries(fieldIds.filter((fieldId)=>view[fieldId] !== undefined).map((fieldId)=>[
|
|
187
|
+
fieldId,
|
|
188
|
+
view[fieldId]
|
|
189
|
+
]));
|
|
190
|
+
}
|
|
191
|
+
export async function countDatasetRecords(deps, ctx, options) {
|
|
192
|
+
var _data_count;
|
|
193
|
+
const dataset = await datasetFor(deps, ctx);
|
|
194
|
+
assertScope(dataset, options);
|
|
195
|
+
if (options.ids) {
|
|
196
|
+
let total = 0;
|
|
197
|
+
for (const slice of chunked(options.ids, IN_MAX)){
|
|
198
|
+
var _data_count1;
|
|
199
|
+
total += Number((_data_count1 = (await dataset.records.where(FieldPath.documentId(), 'in', slice).count().get()).data().count) != null ? _data_count1 : 0);
|
|
200
|
+
}
|
|
201
|
+
return total;
|
|
202
|
+
}
|
|
203
|
+
return Number((_data_count = (await recordsQuery(dataset, options).count().get()).data().count) != null ? _data_count : 0);
|
|
204
|
+
}
|
|
205
|
+
export async function readDatasetPage(deps, ctx, cursor, fieldIds, options) {
|
|
206
|
+
var _ref;
|
|
207
|
+
const dataset = await datasetFor(deps, ctx);
|
|
208
|
+
assertScope(dataset, options);
|
|
209
|
+
const size = Math.max(1, Math.min((_ref = options == null ? void 0 : options.pageSize) != null ? _ref : PAGE_ROWS, PAGE_ROWS));
|
|
210
|
+
if (options == null ? void 0 : options.ids) {
|
|
211
|
+
// The selection, in the order it was made; the cursor is how far through it.
|
|
212
|
+
const offset = Math.max(0, Number(cursor != null ? cursor : 0) || 0);
|
|
213
|
+
const slice = options.ids.slice(offset, offset + size);
|
|
214
|
+
const docs = await getAll(deps.firestore, slice.map((id)=>dataset.records.doc(id)));
|
|
215
|
+
return {
|
|
216
|
+
rows: docs.filter((doc)=>doc.exists).map((doc)=>pick(viewOf(dataset.model, doc), fieldIds)),
|
|
217
|
+
next: offset + size < options.ids.length ? String(offset + size) : null
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
let query = recordsQuery(dataset, options);
|
|
221
|
+
if (cursor) query = query.startAfter(cursor);
|
|
222
|
+
const snapshot = await query.limit(size).get();
|
|
223
|
+
const docs = snapshot.docs;
|
|
224
|
+
return {
|
|
225
|
+
rows: docs.map((doc)=>pick(viewOf(dataset.model, doc), fieldIds)),
|
|
226
|
+
next: docs.length === size ? docs[docs.length - 1].id : null
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/*------------------------------------------
|
|
230
|
+
* Matching
|
|
231
|
+
*-----------------------------------------*/ export async function lookupDatasetRecords(deps, ctx, requests) {
|
|
232
|
+
const dataset = await datasetFor(deps, ctx);
|
|
233
|
+
const lookup = new Map();
|
|
234
|
+
const records = new Map();
|
|
235
|
+
const file = (fieldId, normalized, id)=>{
|
|
236
|
+
var _lookup_get;
|
|
237
|
+
const at = matchLookupKey(fieldId, normalized);
|
|
238
|
+
const ids = (_lookup_get = lookup.get(at)) != null ? _lookup_get : [];
|
|
239
|
+
if (!ids.includes(id)) ids.push(id);
|
|
240
|
+
lookup.set(at, ids);
|
|
241
|
+
};
|
|
242
|
+
for (const request of requests){
|
|
243
|
+
var _datasetFieldIdOf;
|
|
244
|
+
if (!request.values.length) continue;
|
|
245
|
+
if (request.fieldId === TRANSFER_ID_FIELD) {
|
|
246
|
+
const docs = await getAll(deps.firestore, request.values.map((id)=>dataset.records.doc(id)));
|
|
247
|
+
for (const doc of docs){
|
|
248
|
+
if (!doc.exists) continue;
|
|
249
|
+
records.set(doc.id, viewOf(dataset.model, doc));
|
|
250
|
+
file(request.fieldId, doc.id, doc.id);
|
|
251
|
+
}
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
// Asked of `filterValues`, the index the records table's own filters
|
|
255
|
+
// read; a value clipped there is held to the whole value below.
|
|
256
|
+
const asked = new Map();
|
|
257
|
+
for (const normalized of request.values){
|
|
258
|
+
var _asked_get;
|
|
259
|
+
const query = datasetMatchKeyQuery(dataset.model, request, normalized);
|
|
260
|
+
if (!query) continue;
|
|
261
|
+
asked.set(query.value, [
|
|
262
|
+
...(_asked_get = asked.get(query.value)) != null ? _asked_get : [],
|
|
263
|
+
normalized
|
|
264
|
+
]);
|
|
265
|
+
}
|
|
266
|
+
const path = datasetFilterValuePath((_datasetFieldIdOf = datasetFieldIdOf(request.fieldId)) != null ? _datasetFieldIdOf : '');
|
|
267
|
+
if (!path) continue;
|
|
268
|
+
for (const slice of chunked([
|
|
269
|
+
...asked.keys()
|
|
270
|
+
], IN_MAX)){
|
|
271
|
+
const snapshot = await dataset.records.where(path, 'in', slice).get();
|
|
272
|
+
for (const doc of snapshot.docs){
|
|
273
|
+
const view = viewOf(dataset.model, doc);
|
|
274
|
+
let held = false;
|
|
275
|
+
for (const normalized of slice.flatMap((value)=>{
|
|
276
|
+
var _asked_get;
|
|
277
|
+
return (_asked_get = asked.get(value)) != null ? _asked_get : [];
|
|
278
|
+
})){
|
|
279
|
+
if (!datasetHoldsKey(view, request, normalized)) continue;
|
|
280
|
+
file(request.fieldId, normalized, doc.id);
|
|
281
|
+
held = true;
|
|
282
|
+
}
|
|
283
|
+
if (held) records.set(doc.id, view);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
lookup,
|
|
289
|
+
records
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/** The most distinct word queries one suggestion request runs. */ const SUGGEST_QUERIES_MAX = 200;
|
|
293
|
+
/** The records one word query reads. */ const SUGGEST_READ = 20;
|
|
294
|
+
/** The words of a value its candidates are asked by. */ const SUGGEST_WORDS = 2;
|
|
295
|
+
/** The leading characters a word is also asked by, so a later misspelling still finds it. */ const SUGGEST_STEM = 3;
|
|
296
|
+
/**
|
|
297
|
+
* Records named LIKE each value a reference could not find, for the person
|
|
298
|
+
* to pick from: each plain text `by` field asked for records with a word that
|
|
299
|
+
* starts with one of the value's first words, or with its first few letters
|
|
300
|
+
* (the `filterKeys` prefix tokens the records table's `contains` reads), then
|
|
301
|
+
* ranked by how close the whole name is. Values past the query budget get none.
|
|
302
|
+
*/ export async function suggestDatasetRecords(deps, ctx, request) {
|
|
303
|
+
const dataset = await datasetFor(deps, ctx);
|
|
304
|
+
const fields = request.by.map((transferId)=>datasetFieldIdOf(transferId)).filter((fieldId)=>{
|
|
305
|
+
var _dataset_model_fields;
|
|
306
|
+
const field = fieldId ? (_dataset_model_fields = dataset.model.fields) == null ? void 0 : _dataset_model_fields[fieldId] : undefined;
|
|
307
|
+
return (field == null ? void 0 : field.type) === 'text' && !isOptionsField(field);
|
|
308
|
+
});
|
|
309
|
+
const asked = new Map();
|
|
310
|
+
const ask = (fieldId, token)=>{
|
|
311
|
+
const at = `${fieldId}\u0000${token}`;
|
|
312
|
+
let pending = asked.get(at);
|
|
313
|
+
if (!pending) {
|
|
314
|
+
pending = dataset.records.where('filterKeys', 'array-contains', token).limit(SUGGEST_READ).get().then((snapshot)=>snapshot.docs.map((doc)=>{
|
|
315
|
+
var _fieldId, _doc_get;
|
|
316
|
+
return {
|
|
317
|
+
recordId: doc.id,
|
|
318
|
+
label: String((_fieldId = ((_doc_get = doc.get('values')) != null ? _doc_get : {})[fieldId]) != null ? _fieldId : '').trim()
|
|
319
|
+
};
|
|
320
|
+
}));
|
|
321
|
+
asked.set(at, pending);
|
|
322
|
+
}
|
|
323
|
+
return pending;
|
|
324
|
+
};
|
|
325
|
+
const answer = {};
|
|
326
|
+
for (const value of request.values){
|
|
327
|
+
const candidates = [];
|
|
328
|
+
for (const fieldId of fields){
|
|
329
|
+
for (const word of datasetFilterWords(value).slice(0, SUGGEST_WORDS)){
|
|
330
|
+
const stem = Array.from(word).slice(0, SUGGEST_STEM).join('');
|
|
331
|
+
for (const asWord of new Set([
|
|
332
|
+
word,
|
|
333
|
+
stem
|
|
334
|
+
])){
|
|
335
|
+
const token = datasetFilterToken(dataset.model, {
|
|
336
|
+
field: fieldId,
|
|
337
|
+
op: 'contains',
|
|
338
|
+
value: asWord
|
|
339
|
+
});
|
|
340
|
+
if (!token || asked.size >= SUGGEST_QUERIES_MAX && !asked.has(`${fieldId}\u0000${token}`)) continue;
|
|
341
|
+
candidates.push(...(await ask(fieldId, token)).filter((candidate)=>candidate.label));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
answer[value] = rankTransferLookupSuggestions(value, candidates, {
|
|
346
|
+
limit: 5
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
return answer;
|
|
350
|
+
}
|
|
351
|
+
/*------------------------------------------
|
|
352
|
+
* Options fields as picklists
|
|
353
|
+
*-----------------------------------------*/ export async function datasetPicklists(deps, ctx, picklistIds) {
|
|
354
|
+
const { model } = await datasetFor(deps, ctx);
|
|
355
|
+
const out = {};
|
|
356
|
+
for (const picklistId of picklistIds){
|
|
357
|
+
var _datasetFieldOfPicklist;
|
|
358
|
+
var _model_fields;
|
|
359
|
+
const field = (_model_fields = model.fields) == null ? void 0 : _model_fields[(_datasetFieldOfPicklist = datasetFieldOfPicklist(picklistId)) != null ? _datasetFieldOfPicklist : ''];
|
|
360
|
+
if (field) out[picklistId] = datasetOptionsPicklist(field);
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* The values the person chose to add to an options field, added to the
|
|
366
|
+
* field's options in the dataset's model — the schema the Schema dialog
|
|
367
|
+
* edits — before the import's first write. A value already there is left.
|
|
368
|
+
*/ export async function addDatasetOptions(deps, ctx, picklistId, values) {
|
|
369
|
+
const dataset = await datasetFor(deps, ctx);
|
|
370
|
+
const fieldId = datasetFieldOfPicklist(picklistId);
|
|
371
|
+
if (!fieldId) return;
|
|
372
|
+
await deps.firestore.runTransaction(async (tx)=>{
|
|
373
|
+
var _snapshot_data, _ref;
|
|
374
|
+
var _model_fields, _field_validation;
|
|
375
|
+
const snapshot = await tx.get(dataset.ref);
|
|
376
|
+
const model = effectiveDatasetModel((_snapshot_data = snapshot.data()) != null ? _snapshot_data : {});
|
|
377
|
+
const field = (_model_fields = model.fields) == null ? void 0 : _model_fields[fieldId];
|
|
378
|
+
if (!field) return;
|
|
379
|
+
const options = [
|
|
380
|
+
...(_ref = (_field_validation = field.validation) == null ? void 0 : _field_validation.options) != null ? _ref : []
|
|
381
|
+
];
|
|
382
|
+
const added = values.map((value)=>value.label).filter((label)=>label && !options.includes(label));
|
|
383
|
+
if (!added.length) return;
|
|
384
|
+
tx.update(dataset.ref, {
|
|
385
|
+
model: _extends({}, model, {
|
|
386
|
+
fields: _extends({}, model.fields, {
|
|
387
|
+
[fieldId]: _extends({}, field, {
|
|
388
|
+
validation: _extends({}, field.validation, {
|
|
389
|
+
options: [
|
|
390
|
+
...options,
|
|
391
|
+
...added
|
|
392
|
+
]
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
})
|
|
396
|
+
}),
|
|
397
|
+
updatedAt: Timestamp.now()
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
// The writes that follow validate against the options just added.
|
|
401
|
+
loaded.delete(ctx);
|
|
402
|
+
}
|
|
403
|
+
/*------------------------------------------
|
|
404
|
+
* The dry run
|
|
405
|
+
*-----------------------------------------*/ /**
|
|
406
|
+
* The records this import may create, from the plan's `recordsPerDataset`
|
|
407
|
+
* against the dataset's real size — and none while the data storage band is
|
|
408
|
+
* full — so the dry run fails what Apply would refuse.
|
|
409
|
+
*/ async function createLimits(dataset) {
|
|
410
|
+
var _data_count;
|
|
411
|
+
const org = (await dataset.orgRef.get()).data();
|
|
412
|
+
if (await dataStorageRefusal(org, dataset.orgRef)) return {
|
|
413
|
+
maxCreates: 0
|
|
414
|
+
};
|
|
415
|
+
const live = Number((_data_count = (await dataset.records.count().get()).data().count) != null ? _data_count : 0);
|
|
416
|
+
const { remaining } = checkQuota(org, 'recordsPerDataset', live);
|
|
417
|
+
return Number.isFinite(remaining) ? {
|
|
418
|
+
maxCreates: remaining
|
|
419
|
+
} : {};
|
|
420
|
+
}
|
|
421
|
+
const failed = (row, reason, message)=>({
|
|
422
|
+
result: _extends({
|
|
423
|
+
row: row.index,
|
|
424
|
+
outcome: 'failed'
|
|
425
|
+
}, row.recordId ? {
|
|
426
|
+
recordId: row.recordId
|
|
427
|
+
} : {}, {
|
|
428
|
+
reason
|
|
429
|
+
}, message ? {
|
|
430
|
+
message
|
|
431
|
+
} : {})
|
|
432
|
+
});
|
|
433
|
+
function only(view, fieldIds) {
|
|
434
|
+
return Object.fromEntries(fieldIds.map((fieldId)=>{
|
|
435
|
+
var _view_fieldId;
|
|
436
|
+
return [
|
|
437
|
+
fieldId,
|
|
438
|
+
(_view_fieldId = view[fieldId]) != null ? _view_fieldId : null
|
|
439
|
+
];
|
|
440
|
+
}));
|
|
441
|
+
}
|
|
442
|
+
/** Updates: each record's values with the row's changes over them, in one batch. */ async function applyUpdates(deps, dataset, rows) {
|
|
443
|
+
if (!rows.length) return [];
|
|
444
|
+
const { model, records } = dataset;
|
|
445
|
+
const docs = await getAll(deps.firestore, rows.map((row)=>records.doc(row.recordId)));
|
|
446
|
+
const batch = deps.firestore.batch();
|
|
447
|
+
const landed = [];
|
|
448
|
+
let writes = 0;
|
|
449
|
+
rows.forEach((row, at)=>{
|
|
450
|
+
var _doc_get;
|
|
451
|
+
const doc = docs[at];
|
|
452
|
+
if (!doc.exists) return landed.push(failed(row, 'matchedRecordMissing'));
|
|
453
|
+
const stored = _extends({}, (_doc_get = doc.get('values')) != null ? _doc_get : {});
|
|
454
|
+
const before = viewOf(model, doc);
|
|
455
|
+
const { values: changes, cleared } = datasetStorageValues(model, Object.fromEntries(row.diff.map((change)=>[
|
|
456
|
+
change.fieldId,
|
|
457
|
+
change.after
|
|
458
|
+
])));
|
|
459
|
+
const merged = _extends({}, stored, changes);
|
|
460
|
+
for (const fieldId of cleared)delete merged[fieldId];
|
|
461
|
+
const coerced = fillRecordAddresses(model, merged);
|
|
462
|
+
const errors = datasetWriteErrors(model, coerced, new Set([
|
|
463
|
+
...Object.keys(changes),
|
|
464
|
+
...cleared
|
|
465
|
+
]));
|
|
466
|
+
if (Object.keys(errors).length) return landed.push(failed(row, 'refusedValue', describeDatasetRecordErrors(errors)));
|
|
467
|
+
batch.set(doc.ref, _extends({
|
|
468
|
+
values: coerced
|
|
469
|
+
}, datasetIntegrityUpdate(model, coerced, FieldValue.delete()), {
|
|
470
|
+
updatedAt: Timestamp.now()
|
|
471
|
+
}), // Each named field replaced whole: `merge: true` would fold the new
|
|
472
|
+
// `filterValues` into the stored map and keep a cleared value.
|
|
473
|
+
{
|
|
474
|
+
mergeFields: RECORD_MERGE_FIELDS
|
|
475
|
+
});
|
|
476
|
+
writes += 1;
|
|
477
|
+
const touched = row.diff.map((change)=>change.fieldId);
|
|
478
|
+
const after = datasetRecordTransferValues(model, {
|
|
479
|
+
values: coerced
|
|
480
|
+
});
|
|
481
|
+
landed.push({
|
|
482
|
+
result: {
|
|
483
|
+
row: row.index,
|
|
484
|
+
outcome: 'updated',
|
|
485
|
+
recordId: doc.id
|
|
486
|
+
},
|
|
487
|
+
undo: {
|
|
488
|
+
row: row.index,
|
|
489
|
+
recordId: doc.id,
|
|
490
|
+
action: 'updated',
|
|
491
|
+
previous: only(before, touched),
|
|
492
|
+
written: only(after, touched),
|
|
493
|
+
modes: Object.fromEntries(row.diff.map((change)=>[
|
|
494
|
+
change.fieldId,
|
|
495
|
+
change.mode
|
|
496
|
+
]))
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
return undefined;
|
|
500
|
+
});
|
|
501
|
+
if (writes) await batch.commit();
|
|
502
|
+
return landed;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* New records written as `/api/orgs/datasets` creates them: the data storage
|
|
506
|
+
* band refusing first, then counted, decided and written in ONE transaction
|
|
507
|
+
* against `recordsPerDataset`, each with the integrity index of its values.
|
|
508
|
+
* A record already there is left as it is. `values` are validated already.
|
|
509
|
+
*/ async function writeNewRecords(deps, dataset, entries) {
|
|
510
|
+
const { model, records } = dataset;
|
|
511
|
+
const org = (await dataset.orgRef.get()).data();
|
|
512
|
+
// Bytes, not rows (AGL-2163), before the write as on every other create.
|
|
513
|
+
const storage = await dataStorageRefusal(org, dataset.orgRef);
|
|
514
|
+
if (storage) return {
|
|
515
|
+
message: `Dataset storage is full (${storage.includedMb} MB on this plan) — upgrade in Billing`
|
|
516
|
+
};
|
|
517
|
+
const outcomes = await deps.firestore.runTransaction(async (tx)=>{
|
|
518
|
+
var _data_count;
|
|
519
|
+
const refs = entries.map((entry)=>records.doc(entry.id));
|
|
520
|
+
const existing = await tx.getAll(...refs);
|
|
521
|
+
const live = Number((_data_count = (await tx.get(records.count())).data().count) != null ? _data_count : 0);
|
|
522
|
+
let room = checkQuota(org, 'recordsPerDataset', live).remaining;
|
|
523
|
+
let order = live;
|
|
524
|
+
return entries.map((entry, at)=>{
|
|
525
|
+
if (existing[at].exists) return 'existed';
|
|
526
|
+
if (room <= 0) return 'refused';
|
|
527
|
+
room -= 1;
|
|
528
|
+
tx.create(refs[at], _extends({
|
|
529
|
+
values: entry.values
|
|
530
|
+
}, datasetIntegrityFields(model, entry.values), {
|
|
531
|
+
order: order++,
|
|
532
|
+
createdAt: Timestamp.now(),
|
|
533
|
+
updatedAt: Timestamp.now()
|
|
534
|
+
}));
|
|
535
|
+
return 'created';
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
const limit = checkQuota(org, 'recordsPerDataset', 0).limit;
|
|
539
|
+
return {
|
|
540
|
+
outcomes,
|
|
541
|
+
limitMessage: `Record limit reached (${limit}) — upgrade in Billing`
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Creates, through {@link writeNewRecords}: validated first, so a row the
|
|
546
|
+
* model refuses fails naming why. A row the plan's room has run out for
|
|
547
|
+
* fails as past the plan; a record this job already created (a retried
|
|
548
|
+
* chunk) is reported, not written again.
|
|
549
|
+
*/ async function applyCreates(deps, ctx, dataset, rows) {
|
|
550
|
+
if (!rows.length) return [];
|
|
551
|
+
const { model } = dataset;
|
|
552
|
+
const landed = [];
|
|
553
|
+
const prepared = [];
|
|
554
|
+
for (const row of rows){
|
|
555
|
+
var _ctx_jobId;
|
|
556
|
+
const { values } = datasetStorageValues(model, Object.fromEntries(row.diff.map((change)=>[
|
|
557
|
+
change.fieldId,
|
|
558
|
+
change.after
|
|
559
|
+
])));
|
|
560
|
+
const coerced = fillRecordAddresses(model, values);
|
|
561
|
+
const errors = datasetWriteErrors(model, coerced);
|
|
562
|
+
if (Object.keys(errors).length) landed.push(failed(row, 'refusedValue', describeDatasetRecordErrors(errors)));
|
|
563
|
+
else prepared.push({
|
|
564
|
+
row,
|
|
565
|
+
id: transferRecordId((_ctx_jobId = ctx.jobId) != null ? _ctx_jobId : ctx.resource, row.index),
|
|
566
|
+
values: coerced
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
if (!prepared.length) return landed;
|
|
570
|
+
const written = await writeNewRecords(deps, dataset, prepared);
|
|
571
|
+
if ('message' in written) return [
|
|
572
|
+
...landed,
|
|
573
|
+
...prepared.map((entry)=>failed(entry.row, 'planLimit', written.message))
|
|
574
|
+
];
|
|
575
|
+
prepared.forEach((entry, at)=>{
|
|
576
|
+
if (written.outcomes[at] === 'refused') {
|
|
577
|
+
landed.push(failed(entry.row, 'planLimit', written.limitMessage));
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const values = datasetRecordTransferValues(model, {
|
|
581
|
+
values: entry.values
|
|
582
|
+
});
|
|
583
|
+
delete values[TRANSFER_ID_FIELD];
|
|
584
|
+
landed.push({
|
|
585
|
+
result: {
|
|
586
|
+
row: entry.row.index,
|
|
587
|
+
outcome: 'created',
|
|
588
|
+
recordId: entry.id
|
|
589
|
+
},
|
|
590
|
+
undo: {
|
|
591
|
+
row: entry.row.index,
|
|
592
|
+
recordId: entry.id,
|
|
593
|
+
action: 'created',
|
|
594
|
+
written: values
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
return landed;
|
|
599
|
+
}
|
|
600
|
+
/*------------------------------------------
|
|
601
|
+
* References the person chose to create
|
|
602
|
+
*-----------------------------------------*/ /**
|
|
603
|
+
* The id a record created for a reference's name is given: ten characters
|
|
604
|
+
* of the resource-id alphabet, from the job, the target, the field it is
|
|
605
|
+
* named in and the name as the engine keys it — so every row naming it, in
|
|
606
|
+
* any chunk, and a retried chunk all land on the one record.
|
|
607
|
+
*/ export function transferReferenceRecordId(jobId, datasetId, displayFieldId, name) {
|
|
608
|
+
return transferRecordId(`${jobId}:${datasetId}:${displayFieldId}:${transferLookupKey(name)}`, 0);
|
|
609
|
+
}
|
|
610
|
+
/** The names a reference value asks to create, by the engine's marker. */ function namesToCreate(value) {
|
|
611
|
+
const names = [];
|
|
612
|
+
for (const item of Array.isArray(value) ? value : [
|
|
613
|
+
value
|
|
614
|
+
]){
|
|
615
|
+
const name = transferLookupNewName(item);
|
|
616
|
+
if (name) names.push(name);
|
|
617
|
+
}
|
|
618
|
+
return names;
|
|
619
|
+
}
|
|
620
|
+
function namedReferences(model) {
|
|
621
|
+
var _model_order;
|
|
622
|
+
const out = [];
|
|
623
|
+
for (const fieldId of (_model_order = model.order) != null ? _model_order : []){
|
|
624
|
+
var _model_fields, _field_reference, _field_reference1;
|
|
625
|
+
const field = (_model_fields = model.fields) == null ? void 0 : _model_fields[fieldId];
|
|
626
|
+
const targetId = (field == null ? void 0 : field.type) === 'reference' ? (_field_reference = field.reference) == null ? void 0 : _field_reference.datasetId : undefined;
|
|
627
|
+
const displayFieldId = field == null ? void 0 : (_field_reference1 = field.reference) == null ? void 0 : _field_reference1.displayFieldId;
|
|
628
|
+
if (targetId && displayFieldId) out.push({
|
|
629
|
+
transferId: datasetTransferFieldId(fieldId),
|
|
630
|
+
targetId,
|
|
631
|
+
displayFieldId
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
return out;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* The records the person chose to create from a reference's names, created
|
|
638
|
+
* in the referenced dataset before the rows that name them are written: each
|
|
639
|
+
* name once, holding the name in the field the reference displays, held to
|
|
640
|
+
* the target's model and its plan, through {@link writeNewRecords}. The rows
|
|
641
|
+
* come back naming the records' ids; a row naming one that could not be
|
|
642
|
+
* created fails, saying why.
|
|
643
|
+
*/ async function createNamedReferences(deps, ctx, dataset, rows) {
|
|
644
|
+
var _ctx_jobId;
|
|
645
|
+
const references = namedReferences(dataset.model);
|
|
646
|
+
// Target and display field → name key → the name as the file spelled it.
|
|
647
|
+
const wanted = new Map();
|
|
648
|
+
for (const row of rows){
|
|
649
|
+
for (const change of row.diff){
|
|
650
|
+
var _wanted_get;
|
|
651
|
+
const reference = references.find((one)=>one.transferId === change.fieldId);
|
|
652
|
+
const names = reference ? namesToCreate(change.after) : [];
|
|
653
|
+
if (!reference || !names.length) continue;
|
|
654
|
+
const at = `${reference.targetId}\u0000${reference.displayFieldId}`;
|
|
655
|
+
const group = (_wanted_get = wanted.get(at)) != null ? _wanted_get : {
|
|
656
|
+
reference,
|
|
657
|
+
names: new Map()
|
|
658
|
+
};
|
|
659
|
+
for (const name of names)if (!group.names.has(transferLookupKey(name))) group.names.set(transferLookupKey(name), name);
|
|
660
|
+
wanted.set(at, group);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (!wanted.size) return {
|
|
664
|
+
rows: [
|
|
665
|
+
...rows
|
|
666
|
+
],
|
|
667
|
+
failed: []
|
|
668
|
+
};
|
|
669
|
+
// Name key → the record's id, or why it could not be created; per target and display field.
|
|
670
|
+
const outcome = new Map();
|
|
671
|
+
const jobId = (_ctx_jobId = ctx.jobId) != null ? _ctx_jobId : ctx.resource;
|
|
672
|
+
for (const [at, { reference, names }] of wanted){
|
|
673
|
+
const decided = new Map();
|
|
674
|
+
outcome.set(at, decided);
|
|
675
|
+
const target = await datasetFor(deps, targetContext(ctx, reference.targetId));
|
|
676
|
+
await ensureDeclaredCustomFieldTypes(target.model);
|
|
677
|
+
const entries = [];
|
|
678
|
+
for (const [key, name] of names){
|
|
679
|
+
const values = fillRecordAddresses(target.model, datasetStorageValues(target.model, {
|
|
680
|
+
[datasetTransferFieldId(reference.displayFieldId)]: name
|
|
681
|
+
}).values);
|
|
682
|
+
const errors = datasetWriteErrors(target.model, values);
|
|
683
|
+
if (Object.keys(errors).length) {
|
|
684
|
+
decided.set(key, {
|
|
685
|
+
message: `“${name}” could not be created in ${target.label}: ${describeDatasetRecordErrors(errors)}`
|
|
686
|
+
});
|
|
687
|
+
} else {
|
|
688
|
+
entries.push({
|
|
689
|
+
key,
|
|
690
|
+
id: transferReferenceRecordId(jobId, target.id, reference.displayFieldId, name),
|
|
691
|
+
values
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (!entries.length) continue;
|
|
696
|
+
const written = await writeNewRecords(deps, target, entries);
|
|
697
|
+
entries.forEach((entry, index)=>{
|
|
698
|
+
const name = names.get(entry.key);
|
|
699
|
+
if ('message' in written) decided.set(entry.key, {
|
|
700
|
+
message: `“${name}” could not be created in ${target.label}: ${written.message}`
|
|
701
|
+
});
|
|
702
|
+
else if (written.outcomes[index] === 'refused') {
|
|
703
|
+
decided.set(entry.key, {
|
|
704
|
+
message: `“${name}” could not be created in ${target.label}: ${written.limitMessage}`
|
|
705
|
+
});
|
|
706
|
+
} else decided.set(entry.key, {
|
|
707
|
+
id: entry.id
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
if (!('message' in written) && written.outcomes.includes('created')) {
|
|
711
|
+
await announceDatasetRecords({
|
|
712
|
+
firestore: deps.firestore,
|
|
713
|
+
orgId: ctx.orgId,
|
|
714
|
+
datasetId: target.id
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
const out = [];
|
|
719
|
+
const refused = [];
|
|
720
|
+
for (const row of rows){
|
|
721
|
+
const problems = [];
|
|
722
|
+
const diff = row.diff.map((change)=>{
|
|
723
|
+
const reference = references.find((one)=>one.transferId === change.fieldId);
|
|
724
|
+
if (!reference || !namesToCreate(change.after).length) return change;
|
|
725
|
+
const decided = outcome.get(`${reference.targetId}\u0000${reference.displayFieldId}`);
|
|
726
|
+
const idOf = (item)=>{
|
|
727
|
+
const name = transferLookupNewName(item);
|
|
728
|
+
if (!name) return item;
|
|
729
|
+
const one = decided == null ? void 0 : decided.get(transferLookupKey(name));
|
|
730
|
+
if (one && 'id' in one) return one.id;
|
|
731
|
+
problems.push(one && 'message' in one ? one.message : `“${name}” could not be created.`);
|
|
732
|
+
return item;
|
|
733
|
+
};
|
|
734
|
+
return _extends({}, change, {
|
|
735
|
+
after: Array.isArray(change.after) ? [
|
|
736
|
+
...new Set(change.after.map(idOf))
|
|
737
|
+
] : idOf(change.after)
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
if (problems.length) refused.push(failed(row, 'refusedValue', [
|
|
741
|
+
...new Set(problems)
|
|
742
|
+
].join(' ')));
|
|
743
|
+
else out.push(_extends({}, row, {
|
|
744
|
+
diff
|
|
745
|
+
}));
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
rows: out,
|
|
749
|
+
failed: refused
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
export async function applyDatasetChunk(deps, ctx, chunk, writer) {
|
|
753
|
+
const dataset = await datasetFor(deps, ctx);
|
|
754
|
+
// A plugin's field validator runs only once its plugin registered it.
|
|
755
|
+
await ensureDeclaredCustomFieldTypes(dataset.model);
|
|
756
|
+
const results = [];
|
|
757
|
+
const undo = [];
|
|
758
|
+
const pending = [];
|
|
759
|
+
for (const row of chunk.rows){
|
|
760
|
+
const done = await writer.alreadyApplied(row.index);
|
|
761
|
+
if (done) results.push(done);
|
|
762
|
+
else pending.push(row);
|
|
763
|
+
}
|
|
764
|
+
// The records the rows' references name for creation, before the rows.
|
|
765
|
+
const named = await createNamedReferences(deps, ctx, dataset, pending);
|
|
766
|
+
const landed = [
|
|
767
|
+
...named.failed,
|
|
768
|
+
...await applyUpdates(deps, dataset, named.rows.filter((row)=>row.verdict === 'update' && row.recordId)),
|
|
769
|
+
...await applyCreates(deps, ctx, dataset, named.rows.filter((row)=>row.verdict === 'create'))
|
|
770
|
+
];
|
|
771
|
+
for (const entry of landed){
|
|
772
|
+
await writer.markApplied(entry.result, entry.undo);
|
|
773
|
+
results.push(entry.result);
|
|
774
|
+
if (entry.undo) undo.push(entry.undo);
|
|
775
|
+
}
|
|
776
|
+
// The pages repeating over the dataset, told once for the chunk (AGL-3113).
|
|
777
|
+
if (undo.length) {
|
|
778
|
+
await announceDatasetRecords({
|
|
779
|
+
firestore: deps.firestore,
|
|
780
|
+
orgId: ctx.orgId,
|
|
781
|
+
datasetId: dataset.id
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
return {
|
|
785
|
+
results,
|
|
786
|
+
undo
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
/*------------------------------------------
|
|
790
|
+
* Undo
|
|
791
|
+
*-----------------------------------------*/ /**
|
|
792
|
+
* Deletes a record the import created, keeping every reference to it whole
|
|
793
|
+
* (see the block header). `false` when a `restrict` reference holds it.
|
|
794
|
+
*/ async function deleteWithIntegrity(deps, dataset, recordId) {
|
|
795
|
+
const others = await dataset.orgRef.collection('datasets').get();
|
|
796
|
+
const strips = [];
|
|
797
|
+
const batch = deps.firestore.batch();
|
|
798
|
+
for (const other of others.docs){
|
|
799
|
+
var _model_fields;
|
|
800
|
+
const model = effectiveDatasetModel(other.data());
|
|
801
|
+
const referencing = Object.keys((_model_fields = model.fields) != null ? _model_fields : {}).filter((fieldId)=>{
|
|
802
|
+
var _model_fields_fieldId, _model_fields_fieldId_reference, _model_fields_fieldId1;
|
|
803
|
+
return ((_model_fields_fieldId = model.fields[fieldId]) == null ? void 0 : _model_fields_fieldId.type) === 'reference' && ((_model_fields_fieldId1 = model.fields[fieldId]) == null ? void 0 : (_model_fields_fieldId_reference = _model_fields_fieldId1.reference) == null ? void 0 : _model_fields_fieldId_reference.datasetId) === dataset.id;
|
|
804
|
+
});
|
|
805
|
+
if (!referencing.length) continue;
|
|
806
|
+
const holders = (await other.ref.collection('records').where('referencedIds', 'array-contains', recordId).get()).docs.filter((doc)=>referencing.some((fieldId)=>{
|
|
807
|
+
var _doc_get;
|
|
808
|
+
const stored = ((_doc_get = doc.get('values')) != null ? _doc_get : {})[fieldId];
|
|
809
|
+
return Array.isArray(stored) ? stored.includes(recordId) : stored === recordId;
|
|
810
|
+
}));
|
|
811
|
+
if (!holders.length) continue;
|
|
812
|
+
if (referencing.some((fieldId)=>{
|
|
813
|
+
var _model_fields_fieldId_reference, _model_fields_fieldId;
|
|
814
|
+
return ((_model_fields_fieldId = model.fields[fieldId]) == null ? void 0 : (_model_fields_fieldId_reference = _model_fields_fieldId.reference) == null ? void 0 : _model_fields_fieldId_reference.onDelete) === 'restrict';
|
|
815
|
+
})) return false;
|
|
816
|
+
for (const holder of holders){
|
|
817
|
+
strips.push(()=>{
|
|
818
|
+
var _holder_get;
|
|
819
|
+
const values = _extends({}, (_holder_get = holder.get('values')) != null ? _holder_get : {});
|
|
820
|
+
for (const fieldId of referencing){
|
|
821
|
+
const stored = values[fieldId];
|
|
822
|
+
if (Array.isArray(stored)) values[fieldId] = stored.filter((id)=>id !== recordId);
|
|
823
|
+
else if (stored === recordId) delete values[fieldId];
|
|
824
|
+
}
|
|
825
|
+
batch.update(holder.ref, _extends({
|
|
826
|
+
values
|
|
827
|
+
}, datasetIntegrityUpdate(model, values, FieldValue.delete()), {
|
|
828
|
+
updatedAt: Timestamp.now()
|
|
829
|
+
}));
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
for (const strip of strips)strip();
|
|
834
|
+
batch.delete(dataset.records.doc(recordId));
|
|
835
|
+
await batch.commit();
|
|
836
|
+
return true;
|
|
837
|
+
}
|
|
838
|
+
export async function revertDatasetChunk(deps, ctx, snapshot, decisions = {}) {
|
|
839
|
+
const dataset = await datasetFor(deps, ctx);
|
|
840
|
+
const { model, records } = dataset;
|
|
841
|
+
const done = [];
|
|
842
|
+
const conflicts = [];
|
|
843
|
+
if (!snapshot.entries.length) return {
|
|
844
|
+
done,
|
|
845
|
+
conflicts
|
|
846
|
+
};
|
|
847
|
+
const docs = await getAll(deps.firestore, snapshot.entries.map((entry)=>records.doc(entry.recordId)));
|
|
848
|
+
const restores = deps.firestore.batch();
|
|
849
|
+
let restoring = 0;
|
|
850
|
+
for (const [at, entry] of snapshot.entries.entries()){
|
|
851
|
+
var _doc_get;
|
|
852
|
+
const doc = docs[at];
|
|
853
|
+
const step = planTransferUndo(entry, doc.exists ? viewOf(model, doc) : null);
|
|
854
|
+
const revert = step.action !== 'conflict' || decisions[entry.recordId] === 'revert';
|
|
855
|
+
if (!revert) {
|
|
856
|
+
conflicts.push(step);
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
if (step.action === 'nothing') {
|
|
860
|
+
done.push(step);
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
if (step.action === 'delete' || step.action === 'conflict' && entry.action === 'created') {
|
|
864
|
+
if (await deleteWithIntegrity(deps, dataset, entry.recordId)) done.push({
|
|
865
|
+
action: 'delete',
|
|
866
|
+
recordId: entry.recordId
|
|
867
|
+
});
|
|
868
|
+
else conflicts.push(step);
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
// Restore (or a conflict the person chose to revert): the fields the
|
|
872
|
+
// import wrote, back to what they held before it.
|
|
873
|
+
const stored = _extends({}, (_doc_get = doc.get('values')) != null ? _doc_get : {});
|
|
874
|
+
const { values, cleared } = datasetStorageValues(model, step.values);
|
|
875
|
+
const merged = _extends({}, stored, values);
|
|
876
|
+
for (const fieldId of cleared)delete merged[fieldId];
|
|
877
|
+
restores.set(doc.ref, _extends({
|
|
878
|
+
values: merged
|
|
879
|
+
}, datasetIntegrityUpdate(model, merged, FieldValue.delete()), {
|
|
880
|
+
updatedAt: Timestamp.now()
|
|
881
|
+
}), {
|
|
882
|
+
mergeFields: RECORD_MERGE_FIELDS
|
|
883
|
+
});
|
|
884
|
+
restoring += 1;
|
|
885
|
+
done.push({
|
|
886
|
+
action: 'restore',
|
|
887
|
+
recordId: entry.recordId,
|
|
888
|
+
values: step.values
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
if (restoring) await restores.commit();
|
|
892
|
+
if (done.some((step)=>step.action !== 'nothing')) {
|
|
893
|
+
await announceDatasetRecords({
|
|
894
|
+
firestore: deps.firestore,
|
|
895
|
+
orgId: ctx.orgId,
|
|
896
|
+
datasetId: dataset.id
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
return {
|
|
900
|
+
done,
|
|
901
|
+
conflicts
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
/*------------------------------------------
|
|
905
|
+
* The hooks
|
|
906
|
+
*-----------------------------------------*/ /** The records hooks for `data.dataset`, over `deps` (the Admin SDK's Firestore by default). */ export function datasetTransferHooks(deps = productionDeps) {
|
|
907
|
+
return {
|
|
908
|
+
fields: async (ctx)=>{
|
|
909
|
+
const dataset = await datasetFor(deps(), ctx);
|
|
910
|
+
return datasetTransferCatalog(dataset.model, dataset.label, await datasetReferenceTargets(deps(), ctx, dataset.model));
|
|
911
|
+
},
|
|
912
|
+
matchKeys: async (ctx)=>datasetMatchKeyOffer((await datasetFor(deps(), ctx)).model),
|
|
913
|
+
count: (ctx, options)=>countDatasetRecords(deps(), ctx, options),
|
|
914
|
+
readPage: (ctx, cursor, fieldIds, options)=>readDatasetPage(deps(), ctx, cursor, fieldIds, options),
|
|
915
|
+
lookup: (ctx, requests)=>lookupDatasetRecords(deps(), ctx, requests),
|
|
916
|
+
suggest: (ctx, request)=>suggestDatasetRecords(deps(), ctx, request),
|
|
917
|
+
picklists: (ctx, picklistIds)=>datasetPicklists(deps(), ctx, picklistIds),
|
|
918
|
+
addPicklistValues: (ctx, picklistId, values)=>addDatasetOptions(deps(), ctx, picklistId, values),
|
|
919
|
+
plan: async (ctx, input)=>{
|
|
920
|
+
const dataset = await datasetFor(deps(), ctx);
|
|
921
|
+
await ensureDeclaredCustomFieldTypes(dataset.model);
|
|
922
|
+
const plan = refuseInvalidDatasetRows(dataset.model, buildTransferPlan(input), input.existing);
|
|
923
|
+
return limitDatasetCreates(plan, (await createLimits(dataset)).maxCreates);
|
|
924
|
+
},
|
|
925
|
+
apply: (ctx, chunk, writer)=>applyDatasetChunk(deps(), ctx, chunk, writer),
|
|
926
|
+
revert: (ctx, snapshot, decisions)=>revertDatasetChunk(deps(), ctx, snapshot, decisions)
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
//# sourceMappingURL=dataset-transfer.server.js.map
|