@aglyn/plugins-data 1.0.0-beta.218 → 1.0.0-beta.219
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -9
- package/src/lib/constants/bundle-common.d.ts +6 -0
- package/src/lib/constants/bundle-common.js +5 -0
- package/src/lib/constants/bundle-common.js.map +1 -1
- package/src/lib/declarations.server.d.ts +39 -0
- package/src/lib/declarations.server.js +63 -0
- package/src/lib/declarations.server.js.map +1 -0
- package/src/lib/form-target/dataset-form-record-target.server.d.ts +40 -0
- package/src/lib/form-target/dataset-form-record-target.server.js +160 -0
- package/src/lib/form-target/dataset-form-record-target.server.js.map +1 -0
- package/src/lib/form-target/form-dataset-binding-token.d.ts +35 -0
- package/src/lib/form-target/form-dataset-binding-token.js +107 -0
- package/src/lib/form-target/form-dataset-binding-token.js.map +1 -0
- package/src/lib/form-target/form-dataset-binding.d.ts +50 -0
- package/src/lib/form-target/form-dataset-binding.js +74 -0
- package/src/lib/form-target/form-dataset-binding.js.map +1 -0
- package/src/lib/form-target/stamp-form-dataset-bindings.d.ts +39 -0
- package/src/lib/form-target/stamp-form-dataset-bindings.js +65 -0
- package/src/lib/form-target/stamp-form-dataset-bindings.js.map +1 -0
- package/src/lib/repeat/dataset-repeat-rows.d.ts +2 -2
- package/src/lib/repeat/dataset-repeat-rows.js +2 -2
- package/src/lib/repeat/dataset-repeat-rows.js.map +1 -1
- package/src/lib/repeat/dataset-repeat-rows.server.d.ts +43 -0
- package/src/lib/repeat/dataset-repeat-rows.server.js +170 -0
- package/src/lib/repeat/dataset-repeat-rows.server.js.map +1 -0
- package/src/lib/repeat/dataset-repeat-source.js +3 -1
- package/src/lib/repeat/dataset-repeat-source.js.map +1 -1
- package/src/lib/site-bundle/datasets-site-bundle.server.d.ts +87 -0
- package/src/lib/site-bundle/datasets-site-bundle.server.js +197 -0
- package/src/lib/site-bundle/datasets-site-bundle.server.js.map +1 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { SiteBundleImportRequest, SiteBundleItem, SiteBundleReportRow, SiteBundleRequest, SiteBundleRestoreRequest } from '@aglyn/aglyn/plugin-manager/plugin-site-bundle';
|
|
18
|
+
/**
|
|
19
|
+
* A site's datasets in its whole-site backup (AGL-163), answered by the plugin
|
|
20
|
+
* that keeps them (AGL-3080).
|
|
21
|
+
*
|
|
22
|
+
* Datasets are ORG-owned (AGL-237) and shared with sites by scope, so a site's
|
|
23
|
+
* backup carries the datasets that site may see (AGL-1046) — each with its
|
|
24
|
+
* records — and a restore writes them back into the owning organization,
|
|
25
|
+
* scoped to the site it restores into.
|
|
26
|
+
*/
|
|
27
|
+
/** The records one dataset carries in a bundle, and the most a restore writes back. */
|
|
28
|
+
export declare const DATASET_BUNDLE_RECORD_LIMIT = 1000;
|
|
29
|
+
/**
|
|
30
|
+
* The keys a restore writes back on a dataset (AGL-1382): every key a live
|
|
31
|
+
* dataset carries, because the restore writes with `merge: false` and a key
|
|
32
|
+
* left off is ERASED. `visibleTo` is never on it — the restore assigns a fresh
|
|
33
|
+
* scope for the site it restores into, since a bundle is portable and an
|
|
34
|
+
* embedded `['org']` would publish one organization's data across another's
|
|
35
|
+
* whole client roster.
|
|
36
|
+
*/
|
|
37
|
+
export declare const DATASET_RESTORE_FIELDS: readonly string[];
|
|
38
|
+
/**
|
|
39
|
+
* The keys a restore writes back on a record. `order` is what
|
|
40
|
+
* `sortDatasetRecords` sorts by, so dropping it reorders every repeatable on
|
|
41
|
+
* the restored site. The integrity index is DERIVED on the way in, never
|
|
42
|
+
* carried (see {@link importSiteDatasets}).
|
|
43
|
+
*/
|
|
44
|
+
export declare const RECORD_RESTORE_FIELDS: readonly string[];
|
|
45
|
+
/**
|
|
46
|
+
* The site's share: the datasets the site may see, each with its records.
|
|
47
|
+
*
|
|
48
|
+
* A site with no organization genuinely has no datasets — a known-empty
|
|
49
|
+
* answer. Everything else THROWS: a catch-all would ship a silently empty,
|
|
50
|
+
* still-successful "backup" that nobody discovers until they restore it. An
|
|
51
|
+
* export that fails loudly is recoverable; one that lies is not.
|
|
52
|
+
*
|
|
53
|
+
* `visibleTo` is stripped on the way out. Its `host:` tokens name sites of
|
|
54
|
+
* THIS organization, so they are noise at best and a dangling reference once
|
|
55
|
+
* the bundle is restored elsewhere; the restore assigns a fresh scope.
|
|
56
|
+
*/
|
|
57
|
+
export declare function exportSiteDatasets({ hostId, orgId, limit, }: SiteBundleRequest): Promise<SiteBundleItem[]>;
|
|
58
|
+
/**
|
|
59
|
+
* Refuse a bundle that would put the workspace over the datasets it may hold
|
|
60
|
+
* (AGL-1403) — decided before the restore writes anything.
|
|
61
|
+
*
|
|
62
|
+
* Datasets are SOLD: the plan includes some and the workspace buys more as
|
|
63
|
+
* add-ons, so `checkDatasetQuota` decides, never the plan's included number —
|
|
64
|
+
* a workspace that paid for extra datasets is entitled to them, and refusing
|
|
65
|
+
* its own backup after taking its money for the room would be wrong.
|
|
66
|
+
*
|
|
67
|
+
* A restore is identified by id COLLISION, never by the bundle's own claim of
|
|
68
|
+
* where it came from (an unsigned string in a file the metered party
|
|
69
|
+
* uploads). The meter is per organization, so a collision here means the
|
|
70
|
+
* WORKSPACE already holds the dataset: restoring into a sibling site of the
|
|
71
|
+
* same workspace rewrites datasets it already pays for and raises nothing,
|
|
72
|
+
* while a copy into a different workspace collides on nothing and is refused
|
|
73
|
+
* when it lands that workspace over. What is refused is the RAISE, never the
|
|
74
|
+
* state of being over — a workspace already over its limit may still restore
|
|
75
|
+
* its own backup.
|
|
76
|
+
*/
|
|
77
|
+
export declare function siteDatasetsRefusal({ orgId, org, items, }: SiteBundleRestoreRequest): Promise<string | null>;
|
|
78
|
+
/**
|
|
79
|
+
* Writes the bundle's datasets into the owning organization, scoped to the
|
|
80
|
+
* site restored into (AGL-237, AGL-1046), each with its records.
|
|
81
|
+
*
|
|
82
|
+
* Non-conforming records are restored AND reported (AGL-182): data is never
|
|
83
|
+
* silently dropped, and the report tells the owner what to fix. A bundle from
|
|
84
|
+
* before typed models validates through the derived text model, as the live
|
|
85
|
+
* migration does, so everything in it passes.
|
|
86
|
+
*/
|
|
87
|
+
export declare function importSiteDatasets(request: SiteBundleImportRequest): Promise<SiteBundleReportRow[]>;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
|
|
3
|
+
/**
|
|
4
|
+
* @license
|
|
5
|
+
* Copyright 2026 Aglyn LLC
|
|
6
|
+
*
|
|
7
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
* you may not use this file except in compliance with the License.
|
|
9
|
+
* You may obtain a copy of the License at
|
|
10
|
+
*
|
|
11
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
*
|
|
13
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
* See the License for the specific language governing permissions and
|
|
17
|
+
* limitations under the License.
|
|
18
|
+
*/ import { datasetIntegrityFields, effectiveDatasetModel, validateDocument } from "@aglyn/aglyn/app-utils/dataset-models";
|
|
19
|
+
import { checkDatasetQuota } from "@aglyn/aglyn/app-utils/plan-entitlements";
|
|
20
|
+
import { hostScopeToken, newResourceScopeFields } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
21
|
+
import { firebaseAdmin, scopedToHost } from "@aglyn/tenant-data-admin";
|
|
22
|
+
/**
|
|
23
|
+
* A site's datasets in its whole-site backup (AGL-163), answered by the plugin
|
|
24
|
+
* that keeps them (AGL-3080).
|
|
25
|
+
*
|
|
26
|
+
* Datasets are ORG-owned (AGL-237) and shared with sites by scope, so a site's
|
|
27
|
+
* backup carries the datasets that site may see (AGL-1046) — each with its
|
|
28
|
+
* records — and a restore writes them back into the owning organization,
|
|
29
|
+
* scoped to the site it restores into.
|
|
30
|
+
*/ /** The records one dataset carries in a bundle, and the most a restore writes back. */ export const DATASET_BUNDLE_RECORD_LIMIT = 1000;
|
|
31
|
+
/**
|
|
32
|
+
* The keys a restore writes back on a dataset (AGL-1382): every key a live
|
|
33
|
+
* dataset carries, because the restore writes with `merge: false` and a key
|
|
34
|
+
* left off is ERASED. `visibleTo` is never on it — the restore assigns a fresh
|
|
35
|
+
* scope for the site it restores into, since a bundle is portable and an
|
|
36
|
+
* embedded `['org']` would publish one organization's data across another's
|
|
37
|
+
* whole client roster.
|
|
38
|
+
*/ export const DATASET_RESTORE_FIELDS = [
|
|
39
|
+
'displayName',
|
|
40
|
+
// Pre-AGL-536 human name, still read as a fallback by site search.
|
|
41
|
+
'name',
|
|
42
|
+
'fields',
|
|
43
|
+
// The typed DatasetModel, and exactly what `effectiveDatasetModel` reads
|
|
44
|
+
// below. Drop it and every restored dataset silently degrades to the
|
|
45
|
+
// derived all-text v1 model, losing types, `required`, `validation`,
|
|
46
|
+
// `default` and every `reference` link (AGL-180).
|
|
47
|
+
'model',
|
|
48
|
+
'names',
|
|
49
|
+
'description',
|
|
50
|
+
'source',
|
|
51
|
+
'installedFrom',
|
|
52
|
+
'detachedFrom'
|
|
53
|
+
];
|
|
54
|
+
/**
|
|
55
|
+
* The keys a restore writes back on a record. `order` is what
|
|
56
|
+
* `sortDatasetRecords` sorts by, so dropping it reorders every repeatable on
|
|
57
|
+
* the restored site. The integrity index is DERIVED on the way in, never
|
|
58
|
+
* carried (see {@link importSiteDatasets}).
|
|
59
|
+
*/ export const RECORD_RESTORE_FIELDS = [
|
|
60
|
+
'values',
|
|
61
|
+
'order'
|
|
62
|
+
];
|
|
63
|
+
/** One of a bundle's documents, reduced to the keys a restore may write. */ function restorable(fields, input) {
|
|
64
|
+
const allowed = new Set(fields);
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const [key, value] of Object.entries(input != null ? input : {})){
|
|
67
|
+
// Only `undefined` is absence — Firestore rejects it outright.
|
|
68
|
+
if (allowed.has(key) && value !== undefined) out[key] = value;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/** The ids a bundle would write — distinct, since two items sharing one are one document. */ function bundleIds(items) {
|
|
73
|
+
const ids = new Set();
|
|
74
|
+
for (const item of items)if (item == null ? void 0 : item.$id) ids.add(String(item.$id));
|
|
75
|
+
return ids;
|
|
76
|
+
}
|
|
77
|
+
const datasetsOf = (orgId)=>firebaseAdmin.app().firestore().collection('orgs').doc(orgId).collection('datasets');
|
|
78
|
+
/**
|
|
79
|
+
* The site's share: the datasets the site may see, each with its records.
|
|
80
|
+
*
|
|
81
|
+
* A site with no organization genuinely has no datasets — a known-empty
|
|
82
|
+
* answer. Everything else THROWS: a catch-all would ship a silently empty,
|
|
83
|
+
* still-successful "backup" that nobody discovers until they restore it. An
|
|
84
|
+
* export that fails loudly is recoverable; one that lies is not.
|
|
85
|
+
*
|
|
86
|
+
* `visibleTo` is stripped on the way out. Its `host:` tokens name sites of
|
|
87
|
+
* THIS organization, so they are noise at best and a dangling reference once
|
|
88
|
+
* the bundle is restored elsewhere; the restore assigns a fresh scope.
|
|
89
|
+
*/ export async function exportSiteDatasets({ hostId, orgId, limit }) {
|
|
90
|
+
if (!orgId) return [];
|
|
91
|
+
const snapshot = await scopedToHost(datasetsOf(orgId), hostId).limit(limit).get();
|
|
92
|
+
const live = snapshot.docs.filter((doc)=>!doc.get('deletedAt'));
|
|
93
|
+
return Promise.all(live.map(async (doc)=>{
|
|
94
|
+
const _doc_data = doc.data(), { visibleTo: _visibleTo } = _doc_data, data = _object_without_properties_loose(_doc_data, [
|
|
95
|
+
"visibleTo"
|
|
96
|
+
]);
|
|
97
|
+
const records = await doc.ref.collection('records').limit(DATASET_BUNDLE_RECORD_LIMIT).get();
|
|
98
|
+
return _extends({
|
|
99
|
+
$id: doc.id
|
|
100
|
+
}, data, {
|
|
101
|
+
records: records.docs.map((record)=>_extends({
|
|
102
|
+
$id: record.id
|
|
103
|
+
}, record.data()))
|
|
104
|
+
});
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Refuse a bundle that would put the workspace over the datasets it may hold
|
|
109
|
+
* (AGL-1403) — decided before the restore writes anything.
|
|
110
|
+
*
|
|
111
|
+
* Datasets are SOLD: the plan includes some and the workspace buys more as
|
|
112
|
+
* add-ons, so `checkDatasetQuota` decides, never the plan's included number —
|
|
113
|
+
* a workspace that paid for extra datasets is entitled to them, and refusing
|
|
114
|
+
* its own backup after taking its money for the room would be wrong.
|
|
115
|
+
*
|
|
116
|
+
* A restore is identified by id COLLISION, never by the bundle's own claim of
|
|
117
|
+
* where it came from (an unsigned string in a file the metered party
|
|
118
|
+
* uploads). The meter is per organization, so a collision here means the
|
|
119
|
+
* WORKSPACE already holds the dataset: restoring into a sibling site of the
|
|
120
|
+
* same workspace rewrites datasets it already pays for and raises nothing,
|
|
121
|
+
* while a copy into a different workspace collides on nothing and is refused
|
|
122
|
+
* when it lands that workspace over. What is refused is the RAISE, never the
|
|
123
|
+
* state of being over — a workspace already over its limit may still restore
|
|
124
|
+
* its own backup.
|
|
125
|
+
*/ export async function siteDatasetsRefusal({ orgId, org, items }) {
|
|
126
|
+
const bundled = bundleIds(items);
|
|
127
|
+
if (!orgId || !bundled.size) return null;
|
|
128
|
+
if (!Number.isFinite(checkDatasetQuota(org, 0).limit)) return null;
|
|
129
|
+
// A field mask with no fields projects to the document id alone.
|
|
130
|
+
const held = await datasetsOf(orgId).select().get();
|
|
131
|
+
const existing = new Set(held.docs.map((doc)=>doc.id));
|
|
132
|
+
const next = new Set([
|
|
133
|
+
...existing,
|
|
134
|
+
...bundled
|
|
135
|
+
]).size;
|
|
136
|
+
// `allowed` asks "may I add one more to N", so the post state N is checked
|
|
137
|
+
// as `N - 1` — the idiom the dataset routes' own bulk paths use.
|
|
138
|
+
const quota = checkDatasetQuota(org, next - 1);
|
|
139
|
+
if (next <= existing.size || quota.allowed) return null;
|
|
140
|
+
return `This backup holds ${bundled.size} datasets and this workspace ` + `has ${existing.size}, which would put it at ${next} of ${quota.limit} ` + 'datasets. Nothing was imported — ' + (quota.upgradeRequired ? 'upgrade in Billing.' : `add extra datasets for $${quota.addonPriceUsd}/mo each, or ` + 'upgrade in Billing.');
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Writes the bundle's datasets into the owning organization, scoped to the
|
|
144
|
+
* site restored into (AGL-237, AGL-1046), each with its records.
|
|
145
|
+
*
|
|
146
|
+
* Non-conforming records are restored AND reported (AGL-182): data is never
|
|
147
|
+
* silently dropped, and the report tells the owner what to fix. A bundle from
|
|
148
|
+
* before typed models validates through the derived text model, as the live
|
|
149
|
+
* migration does, so everything in it passes.
|
|
150
|
+
*/ export async function importSiteDatasets(request) {
|
|
151
|
+
const { hostId, orgId, items, write, stamps } = request;
|
|
152
|
+
if (!orgId) return [];
|
|
153
|
+
// Through the scoped-create gate (AGL-1478, AGL-1484): a restore CREATES
|
|
154
|
+
// datasets, and a dataset is born scoped to the site it lands in.
|
|
155
|
+
const scope = newResourceScopeFields([
|
|
156
|
+
hostScopeToken(hostId)
|
|
157
|
+
]);
|
|
158
|
+
const report = [];
|
|
159
|
+
for (const item of items){
|
|
160
|
+
if (!(item == null ? void 0 : item.$id)) continue;
|
|
161
|
+
const path = `orgs/${orgId}/datasets/${String(item.$id)}`;
|
|
162
|
+
await write(path, _extends({}, restorable(DATASET_RESTORE_FIELDS, item), stamps(), scope));
|
|
163
|
+
const model = effectiveDatasetModel(item);
|
|
164
|
+
await ensureCustomFieldTypes(model, request);
|
|
165
|
+
const records = Array.isArray(item['records']) ? item['records'] : [];
|
|
166
|
+
for (const record of records.slice(0, DATASET_BUNDLE_RECORD_LIMIT)){
|
|
167
|
+
var _record_values, _record_values1;
|
|
168
|
+
if (!(record == null ? void 0 : record.$id)) continue;
|
|
169
|
+
const errors = validateDocument(model, (_record_values = record['values']) != null ? _record_values : {});
|
|
170
|
+
if (Object.keys(errors).length) {
|
|
171
|
+
report.push({
|
|
172
|
+
datasetId: String(item.$id),
|
|
173
|
+
recordId: String(record.$id),
|
|
174
|
+
errors
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
await write(`${path}/records/${String(record.$id)}`, _extends({}, restorable(RECORD_RESTORE_FIELDS, record), stamps(), datasetIntegrityFields(model, (_record_values1 = record['values']) != null ? _record_values1 : {})));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return report;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Loads the plugins that register custom field types before a record is
|
|
184
|
+
* validated against `model` (AGL-434). A type is registered when its plugin's
|
|
185
|
+
* server surface loads, and `validateDocument` answers "no error" for a type
|
|
186
|
+
* nobody registered — right for a plugin that is absent, wrong for one not yet
|
|
187
|
+
* loaded. Only a model that names a custom type pays for the load.
|
|
188
|
+
*/ async function ensureCustomFieldTypes(model, request) {
|
|
189
|
+
var _ref;
|
|
190
|
+
const usesCustomType = ((_ref = model == null ? void 0 : model.order) != null ? _ref : []).some((fieldId)=>{
|
|
191
|
+
var _model_fields_fieldId, _model_fields;
|
|
192
|
+
return Boolean(model == null ? void 0 : (_model_fields = model.fields) == null ? void 0 : (_model_fields_fieldId = _model_fields[fieldId]) == null ? void 0 : _model_fields_fieldId.customType);
|
|
193
|
+
});
|
|
194
|
+
if (usesCustomType) await request.loadPluginSurfaces();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
//# sourceMappingURL=datasets-site-bundle.server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/site-bundle/datasets-site-bundle.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 datasetIntegrityFields,\n effectiveDatasetModel,\n validateDocument,\n} from '@aglyn/aglyn/app-utils/dataset-models'\nimport { checkDatasetQuota } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { hostScopeToken, newResourceScopeFields } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport type {\n SiteBundleImportRequest,\n SiteBundleItem,\n SiteBundleReportRow,\n SiteBundleRequest,\n SiteBundleRestoreRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-site-bundle'\nimport { firebaseAdmin, scopedToHost } from '@aglyn/tenant-data-admin'\n\n/**\n * A site's datasets in its whole-site backup (AGL-163), answered by the plugin\n * that keeps them (AGL-3080).\n *\n * Datasets are ORG-owned (AGL-237) and shared with sites by scope, so a site's\n * backup carries the datasets that site may see (AGL-1046) — each with its\n * records — and a restore writes them back into the owning organization,\n * scoped to the site it restores into.\n */\n\n/** The records one dataset carries in a bundle, and the most a restore writes back. */\nexport const DATASET_BUNDLE_RECORD_LIMIT = 1000\n\n/**\n * The keys a restore writes back on a dataset (AGL-1382): every key a live\n * dataset carries, because the restore writes with `merge: false` and a key\n * left off is ERASED. `visibleTo` is never on it — the restore assigns a fresh\n * scope for the site it restores into, since a bundle is portable and an\n * embedded `['org']` would publish one organization's data across another's\n * whole client roster.\n */\nexport const DATASET_RESTORE_FIELDS: readonly string[] = [\n 'displayName',\n // Pre-AGL-536 human name, still read as a fallback by site search.\n 'name',\n 'fields',\n // The typed DatasetModel, and exactly what `effectiveDatasetModel` reads\n // below. Drop it and every restored dataset silently degrades to the\n // derived all-text v1 model, losing types, `required`, `validation`,\n // `default` and every `reference` link (AGL-180).\n 'model',\n 'names',\n 'description',\n 'source',\n 'installedFrom',\n 'detachedFrom',\n]\n\n/**\n * The keys a restore writes back on a record. `order` is what\n * `sortDatasetRecords` sorts by, so dropping it reorders every repeatable on\n * the restored site. The integrity index is DERIVED on the way in, never\n * carried (see {@link importSiteDatasets}).\n */\nexport const RECORD_RESTORE_FIELDS: readonly string[] = ['values', 'order']\n\n/** One of a bundle's documents, reduced to the keys a restore may write. */\nfunction restorable(fields: readonly string[], input: SiteBundleItem): Record<string, unknown> {\n const allowed = new Set(fields)\n const out: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(input ?? {})) {\n // Only `undefined` is absence — Firestore rejects it outright.\n if (allowed.has(key) && value !== undefined) out[key] = value\n }\n return out\n}\n\n/** The ids a bundle would write — distinct, since two items sharing one are one document. */\nfunction bundleIds(items: readonly SiteBundleItem[]): Set<string> {\n const ids = new Set<string>()\n for (const item of items) if (item?.$id) ids.add(String(item.$id))\n return ids\n}\n\nconst datasetsOf = (orgId: string) =>\n firebaseAdmin.app().firestore().collection('orgs').doc(orgId).collection('datasets')\n\n/**\n * The site's share: the datasets the site may see, each with its records.\n *\n * A site with no organization genuinely has no datasets — a known-empty\n * answer. Everything else THROWS: a catch-all would ship a silently empty,\n * still-successful \"backup\" that nobody discovers until they restore it. An\n * export that fails loudly is recoverable; one that lies is not.\n *\n * `visibleTo` is stripped on the way out. Its `host:` tokens name sites of\n * THIS organization, so they are noise at best and a dangling reference once\n * the bundle is restored elsewhere; the restore assigns a fresh scope.\n */\nexport async function exportSiteDatasets({\n hostId,\n orgId,\n limit,\n}: SiteBundleRequest): Promise<SiteBundleItem[]> {\n if (!orgId) return []\n const snapshot = await scopedToHost(datasetsOf(orgId), hostId).limit(limit).get()\n const live = snapshot.docs.filter((doc) => !doc.get('deletedAt'))\n return Promise.all(\n live.map(async (doc) => {\n const { visibleTo: _visibleTo, ...data } = doc.data()\n const records = await doc.ref.collection('records').limit(DATASET_BUNDLE_RECORD_LIMIT).get()\n return {\n $id: doc.id,\n ...data,\n records: records.docs.map((record) => ({ $id: record.id, ...record.data() })),\n }\n }),\n )\n}\n\n/**\n * Refuse a bundle that would put the workspace over the datasets it may hold\n * (AGL-1403) — decided before the restore writes anything.\n *\n * Datasets are SOLD: the plan includes some and the workspace buys more as\n * add-ons, so `checkDatasetQuota` decides, never the plan's included number —\n * a workspace that paid for extra datasets is entitled to them, and refusing\n * its own backup after taking its money for the room would be wrong.\n *\n * A restore is identified by id COLLISION, never by the bundle's own claim of\n * where it came from (an unsigned string in a file the metered party\n * uploads). The meter is per organization, so a collision here means the\n * WORKSPACE already holds the dataset: restoring into a sibling site of the\n * same workspace rewrites datasets it already pays for and raises nothing,\n * while a copy into a different workspace collides on nothing and is refused\n * when it lands that workspace over. What is refused is the RAISE, never the\n * state of being over — a workspace already over its limit may still restore\n * its own backup.\n */\nexport async function siteDatasetsRefusal({\n orgId,\n org,\n items,\n}: SiteBundleRestoreRequest): Promise<string | null> {\n const bundled = bundleIds(items)\n if (!orgId || !bundled.size) return null\n if (!Number.isFinite(checkDatasetQuota(org as any, 0).limit)) return null\n\n // A field mask with no fields projects to the document id alone.\n const held = await datasetsOf(orgId).select().get()\n const existing = new Set(held.docs.map((doc) => doc.id))\n const next = new Set([...existing, ...bundled]).size\n // `allowed` asks \"may I add one more to N\", so the post state N is checked\n // as `N - 1` — the idiom the dataset routes' own bulk paths use.\n const quota = checkDatasetQuota(org as any, next - 1)\n if (next <= existing.size || quota.allowed) return null\n\n return (\n `This backup holds ${bundled.size} datasets and this workspace ` +\n `has ${existing.size}, which would put it at ${next} of ${quota.limit} ` +\n 'datasets. Nothing was imported — ' +\n (quota.upgradeRequired\n ? 'upgrade in Billing.'\n : `add extra datasets for $${quota.addonPriceUsd}/mo each, or ` +\n 'upgrade in Billing.')\n )\n}\n\n/**\n * Writes the bundle's datasets into the owning organization, scoped to the\n * site restored into (AGL-237, AGL-1046), each with its records.\n *\n * Non-conforming records are restored AND reported (AGL-182): data is never\n * silently dropped, and the report tells the owner what to fix. A bundle from\n * before typed models validates through the derived text model, as the live\n * migration does, so everything in it passes.\n */\nexport async function importSiteDatasets(\n request: SiteBundleImportRequest,\n): Promise<SiteBundleReportRow[]> {\n const { hostId, orgId, items, write, stamps } = request\n if (!orgId) return []\n // Through the scoped-create gate (AGL-1478, AGL-1484): a restore CREATES\n // datasets, and a dataset is born scoped to the site it lands in.\n const scope = newResourceScopeFields([hostScopeToken(hostId)])\n const report: SiteBundleReportRow[] = []\n for (const item of items) {\n if (!item?.$id) continue\n const path = `orgs/${orgId}/datasets/${String(item.$id)}`\n await write(path, { ...restorable(DATASET_RESTORE_FIELDS, item), ...stamps(), ...scope })\n const model = effectiveDatasetModel(item)\n await ensureCustomFieldTypes(model, request)\n const records: SiteBundleItem[] = Array.isArray(item['records']) ? item['records'] : []\n for (const record of records.slice(0, DATASET_BUNDLE_RECORD_LIMIT)) {\n if (!record?.$id) continue\n const errors = validateDocument(model, record['values'] ?? {})\n if (Object.keys(errors).length) {\n report.push({ datasetId: String(item.$id), recordId: String(record.$id), errors })\n }\n await write(`${path}/records/${String(record.$id)}`, {\n ...restorable(RECORD_RESTORE_FIELDS, record),\n ...stamps(),\n // DERIVED here rather than carried in the bundle. The integrity\n // index the delete check queries must describe the values just\n // restored, and a bundle written before the field existed carries\n // none — accepting the bundle's copy would restore records the check\n // cannot reach. Records keep their original ids, so the ids it holds\n // still resolve.\n ...datasetIntegrityFields(model, record['values'] ?? {}),\n })\n }\n }\n return report\n}\n\n/**\n * Loads the plugins that register custom field types before a record is\n * validated against `model` (AGL-434). A type is registered when its plugin's\n * server surface loads, and `validateDocument` answers \"no error\" for a type\n * nobody registered — right for a plugin that is absent, wrong for one not yet\n * loaded. Only a model that names a custom type pays for the load.\n */\nasync function ensureCustomFieldTypes(\n model: ReturnType<typeof effectiveDatasetModel>,\n request: Pick<SiteBundleImportRequest, 'loadPluginSurfaces'>,\n): Promise<void> {\n const usesCustomType = (model?.order ?? []).some((fieldId) =>\n Boolean(model?.fields?.[fieldId]?.customType),\n )\n if (usesCustomType) await request.loadPluginSurfaces()\n}\n"],"names":["datasetIntegrityFields","effectiveDatasetModel","validateDocument","checkDatasetQuota","hostScopeToken","newResourceScopeFields","firebaseAdmin","scopedToHost","DATASET_BUNDLE_RECORD_LIMIT","DATASET_RESTORE_FIELDS","RECORD_RESTORE_FIELDS","restorable","fields","input","allowed","Set","out","key","value","Object","entries","has","undefined","bundleIds","items","ids","item","$id","add","String","datasetsOf","orgId","app","firestore","collection","doc","exportSiteDatasets","hostId","limit","snapshot","get","live","docs","filter","Promise","all","map","data","visibleTo","_visibleTo","records","ref","id","record","siteDatasetsRefusal","org","bundled","size","Number","isFinite","held","select","existing","next","quota","upgradeRequired","addonPriceUsd","importSiteDatasets","request","write","stamps","scope","report","path","model","ensureCustomFieldTypes","Array","isArray","slice","errors","keys","length","push","datasetId","recordId","usesCustomType","order","some","fieldId","Boolean","customType","loadPluginSurfaces"],"mappings":"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"}
|