@aglyn/tenant-data-admin 1.0.0-beta.221 → 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/package.json +7 -7
- package/src/lib/server/api-keys.js +3 -3
- package/src/lib/server/api-keys.js.map +1 -1
- package/src/lib/server/api-v1-resources.d.ts +44 -3
- package/src/lib/server/api-v1-resources.js +82 -5
- package/src/lib/server/api-v1-resources.js.map +1 -1
- package/src/lib/server/collection-live-pages.d.ts +2 -2
- package/src/lib/server/collection-live-pages.js +1 -1
- package/src/lib/server/collection-live-pages.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +17 -73
- package/src/lib/server/email-delivery-log.js +44 -222
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +27 -38
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/erase-person.d.ts +34 -54
- package/src/lib/server/erase-person.js +44 -239
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +12 -4
- package/src/lib/server/host-visitor-records.js +20 -10
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.js +46 -33
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/link-reputation-review.d.ts +6 -2
- package/src/lib/server/link-reputation-review.js +61 -20
- package/src/lib/server/link-reputation-review.js.map +1 -1
- package/src/lib/server/live-page-drops.d.ts +67 -0
- package/src/lib/server/live-page-drops.js +57 -0
- package/src/lib/server/live-page-drops.js.map +1 -0
- package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
- package/src/lib/server/media-bandwidth-cap.js +180 -0
- package/src/lib/server/media-bandwidth-cap.js.map +1 -0
- package/src/lib/server/media-cdn-icon.d.ts +77 -0
- package/src/lib/server/media-cdn-icon.js +140 -0
- package/src/lib/server/media-cdn-icon.js.map +1 -0
- package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
- package/src/lib/server/media-delivery-regeneration.js +216 -0
- package/src/lib/server/media-delivery-regeneration.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +2 -0
- package/src/lib/server/media-delivery.js +2 -1
- package/src/lib/server/media-delivery.js.map +1 -1
- package/src/lib/server/media-serve-count.d.ts +50 -0
- package/src/lib/server/media-serve-count.js +62 -0
- package/src/lib/server/media-serve-count.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +13 -2
- package/src/lib/server/media-signing.js +12 -8
- package/src/lib/server/media-signing.js.map +1 -1
- package/src/lib/server/media-tombstone.js +7 -5
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/media-variants.d.ts +151 -20
- package/src/lib/server/media-variants.js +204 -27
- package/src/lib/server/media-variants.js.map +1 -1
- package/src/lib/server/organizations.d.ts +102 -2
- package/src/lib/server/organizations.js +388 -58
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/outbound-send-review.js +5 -4
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/paged-sweeps.d.ts +51 -0
- package/src/lib/server/paged-sweeps.js +79 -0
- package/src/lib/server/paged-sweeps.js.map +1 -0
- package/src/lib/server/platform-marketing-consent.d.ts +0 -49
- package/src/lib/server/platform-marketing-consent.js +27 -11
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/serve-media-cdn.d.ts +69 -1
- package/src/lib/server/serve-media-cdn.js +424 -70
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/site-icon-render.d.ts +68 -0
- package/src/lib/server/site-icon-render.js +157 -0
- package/src/lib/server/site-icon-render.js.map +1 -0
- package/src/lib/server/staff-seat.d.ts +50 -0
- package/src/lib/server/staff-seat.js +118 -0
- package/src/lib/server/staff-seat.js.map +1 -0
- package/src/lib/server/transfer-export.d.ts +46 -0
- package/src/lib/server/transfer-export.js +221 -0
- package/src/lib/server/transfer-export.js.map +1 -0
- package/src/lib/server/transfer-jobs.d.ts +385 -0
- package/src/lib/server/transfer-jobs.js +2368 -0
- package/src/lib/server/transfer-jobs.js.map +1 -0
- package/src/lib/server/transfer-packages.d.ts +127 -0
- package/src/lib/server/transfer-packages.js +1036 -0
- package/src/lib/server/transfer-packages.js.map +1 -0
- package/src/lib/server/upgrade-proposal.d.ts +97 -0
- package/src/lib/server/upgrade-proposal.js +312 -0
- package/src/lib/server/upgrade-proposal.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +17 -8
- package/src/lib/server/upsert-contact.js +82 -16
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/lib/server/web-risk.d.ts +48 -9
- package/src/lib/server/web-risk.js +281 -143
- package/src/lib/server/web-risk.js.map +1 -1
- package/src/tenancy.d.ts +3 -1
- package/src/tenancy.js +5 -1
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/campaign-attribution-store.d.ts +0 -55
- package/src/lib/server/campaign-attribution-store.js +0 -95
- package/src/lib/server/campaign-attribution-store.js.map +0 -1
- package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
- package/src/lib/server/campaign-conversion-attribution.js +0 -450
- package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
- package/src/lib/server/dataset-live-pages.d.ts +0 -179
- package/src/lib/server/dataset-live-pages.js +0 -421
- package/src/lib/server/dataset-live-pages.js.map +0 -1
- package/src/lib/server/email-revenue-attribution.d.ts +0 -185
- package/src/lib/server/email-revenue-attribution.js +0 -321
- package/src/lib/server/email-revenue-attribution.js.map +0 -1
|
@@ -0,0 +1,2368 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /*==========================================
|
|
18
|
+
* THE TRANSFER JOB ENGINE (AGL-3524) — an import as a durable job.
|
|
19
|
+
*
|
|
20
|
+
* The core (`@aglyn/aglyn/data-transfer`) says what a file means and what
|
|
21
|
+
* each row would do; the resource's plugin (`plugin-transfer-resources.ts`)
|
|
22
|
+
* reads and writes its own records. This module is everything between: the
|
|
23
|
+
* stored file, the job document and its subcollections, the dry run over
|
|
24
|
+
* every row, the chunked and idempotent apply, the result file and the
|
|
25
|
+
* seven-day undo. The console's `/api/transfer/*` routes are wiring over it,
|
|
26
|
+
* and the cron sweep resumes what a closed tab left behind.
|
|
27
|
+
*
|
|
28
|
+
* ## Where things are
|
|
29
|
+
*
|
|
30
|
+
* orgs/{orgId}/transferJobs/{jobId} the job (`TransferJobRecord`)
|
|
31
|
+
* chunks/{n} the dry run, 200 rows a chunk
|
|
32
|
+
* ledger/{jobId}:{row} a row's write, recorded the moment it lands
|
|
33
|
+
* results/{n} a written chunk's per-row results
|
|
34
|
+
* undo/{n} a written chunk's undo entries
|
|
35
|
+
* Storage orgs/{orgId}/transfers/{jobId}/source the file, inspected before it is stored
|
|
36
|
+
*
|
|
37
|
+
* The engine never writes a resource's record itself. A row reaches its
|
|
38
|
+
* store only through the plugin's `apply` and `revert`, on the plugin's
|
|
39
|
+
* own write paths, so what a record write must derive is derived there:
|
|
40
|
+
* a dataset record's `datasetIntegrityFields` / `datasetIntegrityUpdate`
|
|
41
|
+
* (referenced ids and filter fields) by the data plugin, as on every other
|
|
42
|
+
* path that writes `records`. The `'records'` and `values` in this file are
|
|
43
|
+
* the transfer kind and planned rows in the job's own subcollections.
|
|
44
|
+
*
|
|
45
|
+
* Every document is written with the Admin SDK only; the rules let members
|
|
46
|
+
* holding `data.manage` read the job and nobody write anything.
|
|
47
|
+
*
|
|
48
|
+
* ## Applying never writes a row twice
|
|
49
|
+
*
|
|
50
|
+
* The plugin's `apply` is handed a writer whose `markApplied` creates the
|
|
51
|
+
* row's ledger document the moment its write lands, and whose
|
|
52
|
+
* `alreadyApplied` answers from that ledger — so a chunk retried after a
|
|
53
|
+
* crash, a timeout or a second driver skips every row it already wrote. A
|
|
54
|
+
* chunk is complete when every planned write has a ledger entry; then its
|
|
55
|
+
* results, its undo entries and the job's cursor commit in ONE batch, and
|
|
56
|
+
* only after that are its ledger entries cleared.
|
|
57
|
+
*
|
|
58
|
+
* One driver at a time: a request takes a lease on the job for its time
|
|
59
|
+
* budget, and a second request (a second tab, the sweep) is refused while it
|
|
60
|
+
* holds. One running import per resource per workspace: starting one while
|
|
61
|
+
* another of the same resource is `applying` is refused.
|
|
62
|
+
*
|
|
63
|
+
* ## Undo asks before it overwrites a later edit
|
|
64
|
+
*
|
|
65
|
+
* Undo is two calls. `planTransferJobUndo` reads every record the import
|
|
66
|
+
* touched and runs the core's `planTransferUndo` — restore, delete, nothing,
|
|
67
|
+
* or a conflict for a record edited since — and writes nothing.
|
|
68
|
+
* `applyTransferJobUndo` hands each chunk's snapshot to the plugin's
|
|
69
|
+
* `revert` with the person's decision for every record, chunk by chunk.
|
|
70
|
+
*=========================================*/ import { TRANSFER_DEFAULT_MAX_ROWS, TRANSFER_DRAFT_RETENTION_MS, TRANSFER_ID_FIELD, TRANSFER_PLAN_CONFLICTS_MAX, TRANSFER_PLAN_PAGE_MAX, TRANSFER_RESULT_COLUMNS, TRANSFER_UNDO_PAGE_MAX, TRANSFER_UPLOAD_MAX_BYTES, TRANSFER_UPLOAD_MAX_PARTS, TRANSFER_UPLOAD_PART_MAX_BYTES, TRANSFER_WRITE_CONCURRENCY, canApplyTransferPlan, collectPicklistValues, createTransferPolicy, withTransferPolicyDefaults, deriveTransferCell, deriveTransferRow, mapTransferRow, mappingIsUsable, mappingProblems, matchHeaders, matchLookupRequests, matchPicklistValues, missingAcknowledgements, picklistChoiceProblems, planTransferUndo, proposePicklistChoice, readTransferSource, resolveMultiPicklistCell, resolvePicklistCell, resolvePicklistChoices, sniffTransferFormat, summarizeMatches, summarizeTransferDerivations, summarizeTransferResults, transferAmbiguities, transferCellText, transferChunkRanges, transferContentType, transferDateOrderOptions, transferFormatFromFileName, transferLedgerKey, transferMatchReview, transferMatchedRecordIds, transferPlanConflicts, transferPlanFeature, transferPlanSample, transferPolicyProblems, transferRowLabel, transferUndoAvailable, transferUndoExpiresAt, transitionTransferJob, transferLookupKey, transferResourceImports, transferLookupNewValue, mayBeTransferRecordId, normalizeMatchValue, matchLookupKey, isBlankTransferValue, isTransferLookupField, TRANSFER_UNDO_WINDOW_MS, TRANSFER_UNFINISHED_IMPORTS_MAX, TRANSFER_JOBS_PAGE_MAX, parseTransferResourceKey } from "@aglyn/aglyn/data-transfer";
|
|
71
|
+
import { normalizePicklistLabel, picklistLabelKey } from "@aglyn/aglyn/app-utils/picklists";
|
|
72
|
+
import { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
|
|
73
|
+
import { escapeCsvCell } from "@aglyn/aglyn/app-utils/csv";
|
|
74
|
+
import { inspectUploadBytes } from "@aglyn/aglyn/app-utils/upload-inspection";
|
|
75
|
+
import { matchTransferResourceRows, planTransferResourceRows, transferResourcePolicyDefaults, resolveTransferResource, transferInvariantFailures, transferRecordsHooks, transferResourceCatalog, transferResourceLockedRules, transferResourceMatchKeys, TransferResourceUnavailableError, transferPlanRefusal } from "@aglyn/aglyn/plugin-manager/plugin-transfer-resources";
|
|
76
|
+
/** A refusal a route answers as `{ error, code, details }` with `status`. */ export class TransferEngineError extends Error {
|
|
77
|
+
constructor(code, status, message, details){
|
|
78
|
+
super(message), this.code = code, this.status = status, this.details = details;
|
|
79
|
+
this.name = 'TransferEngineError';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A refusal for the workspace's plan (AGL-3555): a route answers its
|
|
84
|
+
* `refusal` as it stands — the owning plugin's flat 403 body, with
|
|
85
|
+
* `reason: 'plan_required'` and the plan feature as `code` — rather than
|
|
86
|
+
* as `{ error, code, details }`. Its own `code` is `planRequired`, what a
|
|
87
|
+
* client reads that body as.
|
|
88
|
+
*/ export class TransferPlanRefusedError extends TransferEngineError {
|
|
89
|
+
constructor(refusal){
|
|
90
|
+
super('planRequired', refusal.status, refusal.body.error, refusal.body), this.refusal = refusal;
|
|
91
|
+
this.name = 'TransferPlanRefusedError';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** The collection an organization's jobs live in. */ export const TRANSFER_JOBS_COLLECTION = 'transferJobs';
|
|
95
|
+
/** How long a driver's lease outlives its budget, so a crashed request's job is resumed soon after. */ export const TRANSFER_LEASE_GRACE_MS = 30000;
|
|
96
|
+
/** How long an `applying` job sits untouched before the sweep resumes it. */ export const TRANSFER_STALE_MS = 2 * 60 * 1000;
|
|
97
|
+
/** Below this much budget, a request stops rather than starting another chunk. */ export const TRANSFER_MIN_CHUNK_BUDGET_MS = 2000;
|
|
98
|
+
/** The most ids one undo lookup asks for. */ const UNDO_LOOKUP_SLICE = 500;
|
|
99
|
+
/** The most values one match lookup request carries. */ const MATCH_LOOKUP_SLICE = 500;
|
|
100
|
+
/** The most characters of JSON one document holds before it is split into pieces. */ const JSON_PIECE_CHARS = 900000;
|
|
101
|
+
/** Where an organization's jobs are. */ export function transferJobsCollection(firestore, orgId) {
|
|
102
|
+
return firestore.collection('orgs').doc(orgId).collection(TRANSFER_JOBS_COLLECTION);
|
|
103
|
+
}
|
|
104
|
+
/** Where a job's file is stored. */ export function transferSourcePath(orgId, jobId) {
|
|
105
|
+
return `orgs/${orgId}/transfers/${jobId}/source`;
|
|
106
|
+
}
|
|
107
|
+
/** Where one part of a file sent in parts waits until the rest arrives. */ export function transferPartPath(orgId, jobId, part) {
|
|
108
|
+
return `orgs/${orgId}/transfers/${jobId}/parts/${part}`;
|
|
109
|
+
}
|
|
110
|
+
export const clock = (deps)=>{
|
|
111
|
+
var _deps_now;
|
|
112
|
+
return ((_deps_now = deps.now) != null ? _deps_now : Date.now)();
|
|
113
|
+
};
|
|
114
|
+
/** A value as Firestore can store it: no `undefined`, no class instances. */ export function stored(value) {
|
|
115
|
+
return JSON.parse(JSON.stringify(value));
|
|
116
|
+
}
|
|
117
|
+
export function emptyResults() {
|
|
118
|
+
return {
|
|
119
|
+
created: 0,
|
|
120
|
+
updated: 0,
|
|
121
|
+
unchanged: 0,
|
|
122
|
+
skipped: 0,
|
|
123
|
+
failed: 0,
|
|
124
|
+
total: 0
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export function emptyUndoCounts() {
|
|
128
|
+
return {
|
|
129
|
+
restore: 0,
|
|
130
|
+
delete: 0,
|
|
131
|
+
conflict: 0,
|
|
132
|
+
nothing: 0
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** `fn` over `items`, at most `limit` at once. */ export async function eachLimited(items, limit, fn) {
|
|
136
|
+
let next = 0;
|
|
137
|
+
const workers = Array.from({
|
|
138
|
+
length: Math.min(limit, items.length)
|
|
139
|
+
}, async ()=>{
|
|
140
|
+
while(next < items.length){
|
|
141
|
+
const item = items[next];
|
|
142
|
+
next += 1;
|
|
143
|
+
await fn(item);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
await Promise.all(workers);
|
|
147
|
+
}
|
|
148
|
+
export function isAlreadyExists(error) {
|
|
149
|
+
var _ref;
|
|
150
|
+
const failure = error;
|
|
151
|
+
return (failure == null ? void 0 : failure.code) === 6 || /ALREADY_EXISTS/i.test(String((_ref = failure == null ? void 0 : failure.message) != null ? _ref : ''));
|
|
152
|
+
}
|
|
153
|
+
/*------------------------------------------
|
|
154
|
+
* JSON documents that may outgrow one document
|
|
155
|
+
*-----------------------------------------*/ /**
|
|
156
|
+
* Writes `value` as JSON on `ref` beside `fields`. JSON, because a plan row
|
|
157
|
+
* holds whatever the file held — a nested list from a JSON file is a value
|
|
158
|
+
* Firestore refuses — and past {@link JSON_PIECE_CHARS} the text goes into
|
|
159
|
+
* `pieces/{k}` documents, so a chunk of long notes is never refused for size.
|
|
160
|
+
*/ export async function writeJsonDoc(ref, fields, value) {
|
|
161
|
+
const json = JSON.stringify(value);
|
|
162
|
+
if (json.length <= JSON_PIECE_CHARS) {
|
|
163
|
+
await ref.set(stored(_extends({}, fields, {
|
|
164
|
+
json
|
|
165
|
+
})));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const pieces = [];
|
|
169
|
+
for(let at = 0; at < json.length; at += JSON_PIECE_CHARS)pieces.push(json.slice(at, at + JSON_PIECE_CHARS));
|
|
170
|
+
await eachLimited(pieces.map((json, index)=>({
|
|
171
|
+
json,
|
|
172
|
+
index
|
|
173
|
+
})), TRANSFER_WRITE_CONCURRENCY, async (piece)=>{
|
|
174
|
+
await ref.collection('pieces').doc(String(piece.index)).set({
|
|
175
|
+
json: piece.json
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
await ref.set(stored(_extends({}, fields, {
|
|
179
|
+
pieces: pieces.length
|
|
180
|
+
})));
|
|
181
|
+
}
|
|
182
|
+
/** The value {@link writeJsonDoc} wrote, or `null` when the document is absent. */ export async function readJsonDoc(ref) {
|
|
183
|
+
var _data_pieces;
|
|
184
|
+
const snapshot = await ref.get();
|
|
185
|
+
if (!snapshot.exists) return null;
|
|
186
|
+
const data = snapshot.data();
|
|
187
|
+
if (typeof data.json === 'string') return JSON.parse(data.json);
|
|
188
|
+
const count = Number((_data_pieces = data.pieces) != null ? _data_pieces : 0);
|
|
189
|
+
const parts = [];
|
|
190
|
+
for(let index = 0; index < count; index += 1){
|
|
191
|
+
var _ref;
|
|
192
|
+
var _piece_data;
|
|
193
|
+
const piece = await ref.collection('pieces').doc(String(index)).get();
|
|
194
|
+
parts.push(String((_ref = (_piece_data = piece.data()) == null ? void 0 : _piece_data.json) != null ? _ref : ''));
|
|
195
|
+
}
|
|
196
|
+
return JSON.parse(parts.join(''));
|
|
197
|
+
}
|
|
198
|
+
/** Deletes every document of a collection (and a JSON document's pieces), eight at a time. */ export async function clearCollection(collection) {
|
|
199
|
+
const snapshot = await collection.get();
|
|
200
|
+
await eachLimited(snapshot.docs, TRANSFER_WRITE_CONCURRENCY, async (doc)=>{
|
|
201
|
+
const pieces = await doc.ref.collection('pieces').get();
|
|
202
|
+
for (const piece of pieces.docs)await piece.ref.delete();
|
|
203
|
+
await doc.ref.delete();
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/*==========================================
|
|
207
|
+
* JOBS
|
|
208
|
+
*=========================================*/ export async function readJob(deps, orgId, jobId) {
|
|
209
|
+
if (!jobId || jobId.includes('/')) throw new TransferEngineError('invalid', 400, 'Name the import.');
|
|
210
|
+
const snapshot = await transferJobsCollection(deps.firestore, orgId).doc(jobId).get();
|
|
211
|
+
if (!snapshot.exists) throw new TransferEngineError('notFound', 404, 'No such import.');
|
|
212
|
+
return _extends({}, snapshot.data(), {
|
|
213
|
+
id: jobId
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* When the sweep may clean a job up, and how (see the sweep below): never
|
|
218
|
+
* while it runs or undoes, nor once trimmed; a job that never wrote expires
|
|
219
|
+
* {@link TRANSFER_DRAFT_RETENTION_MS} after it was last touched; one that
|
|
220
|
+
* wrote is trimmed once its undo window has closed.
|
|
221
|
+
*/ export function transferJobRetention(job) {
|
|
222
|
+
var _job_appliedAt;
|
|
223
|
+
var _job_undo;
|
|
224
|
+
if (job.trimmedAt !== undefined || job.status === 'applying' || ((_job_undo = job.undo) == null ? void 0 : _job_undo.status) === 'running') return null;
|
|
225
|
+
const wrote = job.applyStartedAt !== undefined || job.status === 'applied' || job.status === 'undone';
|
|
226
|
+
if (!wrote) return {
|
|
227
|
+
retention: 'expire',
|
|
228
|
+
retainUntil: job.updatedAt + TRANSFER_DRAFT_RETENTION_MS
|
|
229
|
+
};
|
|
230
|
+
return {
|
|
231
|
+
retention: 'trim',
|
|
232
|
+
retainUntil: ((_job_appliedAt = job.appliedAt) != null ? _job_appliedAt : job.updatedAt) + TRANSFER_UNDO_WINDOW_MS
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/** A job as it is stored: Firestore-safe, with its retention stamped for the sweep's query. */ function storedJob(job) {
|
|
236
|
+
var _transferJobRetention;
|
|
237
|
+
const rest = _extends({}, job);
|
|
238
|
+
delete rest.retention;
|
|
239
|
+
delete rest.retainUntil;
|
|
240
|
+
return stored(_extends({}, rest, (_transferJobRetention = transferJobRetention(job)) != null ? _transferJobRetention : {}));
|
|
241
|
+
}
|
|
242
|
+
export async function saveJob(deps, job) {
|
|
243
|
+
await transferJobsCollection(deps.firestore, job.orgId).doc(job.id).set(storedJob(job));
|
|
244
|
+
}
|
|
245
|
+
/** The job as a route returns it. */ export async function readTransferJob(deps, input) {
|
|
246
|
+
return readJob(deps, input.orgId, input.jobId);
|
|
247
|
+
}
|
|
248
|
+
/** A `records` resource, joined to its server half, or the refusal a route answers. */ export async function resolveTransferRecordsResource(deps, key) {
|
|
249
|
+
return resolveResource(deps, key);
|
|
250
|
+
}
|
|
251
|
+
async function resolveResource(deps, key) {
|
|
252
|
+
try {
|
|
253
|
+
var _deps_resolveResource;
|
|
254
|
+
const resource = await ((_deps_resolveResource = deps.resolveResource) != null ? _deps_resolveResource : resolveTransferResource)(key);
|
|
255
|
+
if (!resource.kinds.includes('records')) {
|
|
256
|
+
throw new TransferEngineError('invalid', 400, `${resource.label} is moved as a package, not imported as rows.`);
|
|
257
|
+
}
|
|
258
|
+
return resource;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error instanceof TransferResourceUnavailableError) {
|
|
261
|
+
throw error.reason === 'undeclared' ? new TransferEngineError('notFound', 404, `Nothing called "${key}" can be imported.`) : new TransferEngineError('unavailable', 500, 'Importing this is unavailable right now. Try again in a minute.');
|
|
262
|
+
}
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The site a transfer concerns: required for a `host` resource; for an
|
|
268
|
+
* `org` resource the site the person named, or `null` — a workspace's
|
|
269
|
+
* records can be read through one site's view of them and imported as
|
|
270
|
+
* that site's captures (the CRM's, AGL-3541), and the gate has already
|
|
271
|
+
* checked `data.manage` on that site.
|
|
272
|
+
*/ export function transferHostIdFor(resource, hostId) {
|
|
273
|
+
const site = typeof hostId === 'string' && hostId.trim() && !hostId.includes('/') ? hostId.trim() : null;
|
|
274
|
+
if (resource.scope === 'host' && !site) {
|
|
275
|
+
throw new TransferEngineError('invalid', 400, `${resource.label} belong to a site; name the site.`);
|
|
276
|
+
}
|
|
277
|
+
return site;
|
|
278
|
+
}
|
|
279
|
+
export function contextFor(job, actorUid) {
|
|
280
|
+
var _job_hostId;
|
|
281
|
+
return _extends({
|
|
282
|
+
resource: job.resource,
|
|
283
|
+
orgId: job.orgId,
|
|
284
|
+
hostId: (_job_hostId = job.hostId) != null ? _job_hostId : null,
|
|
285
|
+
actorUid,
|
|
286
|
+
jobId: job.id
|
|
287
|
+
}, job.extras ? {
|
|
288
|
+
extras: job.extras
|
|
289
|
+
} : {}, job.headers ? {
|
|
290
|
+
headers: job.headers
|
|
291
|
+
} : {});
|
|
292
|
+
}
|
|
293
|
+
export function moveJob(job, to, now, patch = {}) {
|
|
294
|
+
try {
|
|
295
|
+
return transitionTransferJob(job, to, now, patch);
|
|
296
|
+
} catch (unused) {
|
|
297
|
+
throw new TransferEngineError('state', 409, `This import is ${job.status} and cannot be ${to} now.`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/** Where a job stands, for a progress bar. */ export function transferJobProgress(job) {
|
|
301
|
+
var _ref, _job_chunkCount, _ref1, _job_rowCount, _job_results;
|
|
302
|
+
var _job_cursor, _job_cursor1;
|
|
303
|
+
return {
|
|
304
|
+
status: job.status,
|
|
305
|
+
chunk: (_ref = (_job_cursor = job.cursor) == null ? void 0 : _job_cursor.chunk) != null ? _ref : 0,
|
|
306
|
+
chunkCount: (_job_chunkCount = job.chunkCount) != null ? _job_chunkCount : 0,
|
|
307
|
+
rowsDone: (_ref1 = (_job_cursor1 = job.cursor) == null ? void 0 : _job_cursor1.rowsDone) != null ? _ref1 : 0,
|
|
308
|
+
rowCount: (_job_rowCount = job.rowCount) != null ? _job_rowCount : 0,
|
|
309
|
+
results: (_job_results = job.results) != null ? _job_results : emptyResults()
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/*==========================================
|
|
313
|
+
* FIELDS — what a resource offers
|
|
314
|
+
*=========================================*/ /** The resource's starting policy as a `TransferResourceInfo` entry: absent when it starts from the core's. */ async function policyDefaultsEntry(resource, ctx, fields) {
|
|
315
|
+
const defaultPolicy = await transferResourcePolicyDefaults(resource, ctx, fields);
|
|
316
|
+
return Object.keys(defaultPolicy).length ? {
|
|
317
|
+
defaultPolicy
|
|
318
|
+
} : {};
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* What a resource offers the wizard and the export dialog: its descriptor,
|
|
322
|
+
* every field and group, its match keys (and the ones a person starts with,
|
|
323
|
+
* which the Re-importable preset leads with), the presets' hints and its own
|
|
324
|
+
* presets, its locked rules and aliases. `prefs` is the person's own
|
|
325
|
+
* and is filled by the caller; a custom field cannot be created from here.
|
|
326
|
+
*/ export async function readTransferResourceInfo(deps, input) {
|
|
327
|
+
var _input_resource;
|
|
328
|
+
var _hooks_presets, _hooks_aliases;
|
|
329
|
+
const resource = await resolveResource(deps, String((_input_resource = input.resource) != null ? _input_resource : '').trim());
|
|
330
|
+
const filter = input.filter;
|
|
331
|
+
const ctx = _extends({
|
|
332
|
+
resource: resource.key,
|
|
333
|
+
orgId: input.orgId,
|
|
334
|
+
hostId: transferHostIdFor(resource, input.hostId),
|
|
335
|
+
actorUid: input.actorUid
|
|
336
|
+
}, filter && typeof filter === 'object' && !Array.isArray(filter) ? {
|
|
337
|
+
filter: filter
|
|
338
|
+
} : {});
|
|
339
|
+
const hooks = transferRecordsHooks(resource);
|
|
340
|
+
const catalog = await transferResourceCatalog(resource, ctx);
|
|
341
|
+
const offer = await transferResourceMatchKeys(resource, ctx);
|
|
342
|
+
const matchKeys = offer.keys;
|
|
343
|
+
return _extends({
|
|
344
|
+
resource: _extends({
|
|
345
|
+
key: resource.key,
|
|
346
|
+
label: resource.label
|
|
347
|
+
}, resource.singularLabel ? {
|
|
348
|
+
singularLabel: resource.singularLabel
|
|
349
|
+
} : {}, {
|
|
350
|
+
scope: resource.scope,
|
|
351
|
+
kinds: [
|
|
352
|
+
...resource.kinds
|
|
353
|
+
],
|
|
354
|
+
formats: [
|
|
355
|
+
...resource.formats
|
|
356
|
+
]
|
|
357
|
+
}, resource.limits ? {
|
|
358
|
+
limits: resource.limits
|
|
359
|
+
} : {}, resource.description ? {
|
|
360
|
+
description: resource.description
|
|
361
|
+
} : {}, resource.exportOnly ? {
|
|
362
|
+
exportOnly: true
|
|
363
|
+
} : {}),
|
|
364
|
+
fields: catalog.fields,
|
|
365
|
+
groups: catalog.groups,
|
|
366
|
+
matchKeys,
|
|
367
|
+
defaultMatchKeys: offer.defaults,
|
|
368
|
+
presetHints: {
|
|
369
|
+
matchKeyFieldIds: offer.defaults
|
|
370
|
+
}
|
|
371
|
+
}, ((_hooks_presets = hooks.presets) == null ? void 0 : _hooks_presets.length) ? {
|
|
372
|
+
resourcePresets: hooks.presets.map((preset)=>_extends({}, preset, {
|
|
373
|
+
// Only fields the catalog has: a preset never names a column the export would refuse.
|
|
374
|
+
fieldIds: preset.fieldIds.filter((fieldId)=>catalog.byId.has(fieldId))
|
|
375
|
+
}))
|
|
376
|
+
} : {}, {
|
|
377
|
+
locked: [
|
|
378
|
+
...await transferResourceLockedRules(resource, ctx)
|
|
379
|
+
]
|
|
380
|
+
}, await policyDefaultsEntry(resource, ctx, catalog.fields), ((_hooks_aliases = hooks.aliases) == null ? void 0 : _hooks_aliases.length) ? {
|
|
381
|
+
dictionaries: [
|
|
382
|
+
...hooks.aliases
|
|
383
|
+
]
|
|
384
|
+
} : {}, {
|
|
385
|
+
canCreateCustomField: false
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
const CSV_DELIMITERS = [
|
|
389
|
+
',',
|
|
390
|
+
';',
|
|
391
|
+
'\t',
|
|
392
|
+
'|'
|
|
393
|
+
];
|
|
394
|
+
/** How the person said the CSV reads, or nothing for detection to decide. */ function readOptionsOf(input) {
|
|
395
|
+
const options = {};
|
|
396
|
+
if (input.delimiter !== undefined) {
|
|
397
|
+
if (!CSV_DELIMITERS.includes(input.delimiter)) throw new TransferEngineError('invalid', 400, 'That separator is not one a CSV can use.');
|
|
398
|
+
options.delimiter = input.delimiter;
|
|
399
|
+
}
|
|
400
|
+
if (input.headerRow === false) options.headerRow = false;
|
|
401
|
+
return Object.keys(options).length ? options : undefined;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* The structure check every stored upload passes (`inspectUploadBytes`):
|
|
405
|
+
* an executable or a test signature named as a CSV is refused before a byte
|
|
406
|
+
* of it is stored. It is a structural check, not an antivirus scan.
|
|
407
|
+
*/ function inspectTransferBytes(bytes, format, fileName) {
|
|
408
|
+
const refusal = inspectUploadBytes({
|
|
409
|
+
bytes,
|
|
410
|
+
contentType: transferContentType(format),
|
|
411
|
+
fileName
|
|
412
|
+
});
|
|
413
|
+
if (refusal) throw new TransferEngineError('rejectedFile', 422, refusal.message, {
|
|
414
|
+
code: refusal.code
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
function textOf(bytes) {
|
|
418
|
+
return bytes.toString('utf8');
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Stores a file, or one part of it, and makes the job on the first part.
|
|
422
|
+
* Each part is inspected before it is stored; the whole file is inspected
|
|
423
|
+
* again, read, counted against the resource's limits and stored as the
|
|
424
|
+
* source once the last part lands. A part sent twice is accepted once.
|
|
425
|
+
*/ export async function uploadTransferSource(deps, input) {
|
|
426
|
+
var _input_resource, _input_fileName, _ref, _input_format, _input_parts, _input_part, _ref1, _ref2, _job_read, _ref3;
|
|
427
|
+
var _resource_limits, _job_upload, _resource_limits1;
|
|
428
|
+
const resource = await resolveResource(deps, String((_input_resource = input.resource) != null ? _input_resource : '').trim());
|
|
429
|
+
if (!transferResourceImports(resource)) {
|
|
430
|
+
throw new TransferEngineError('invalid', 400, `${resource.label} are exported, not imported.`);
|
|
431
|
+
}
|
|
432
|
+
const hostId = transferHostIdFor(resource, input.hostId);
|
|
433
|
+
const fileName = String((_input_fileName = input.fileName) != null ? _input_fileName : '').trim().slice(0, 240) || 'import';
|
|
434
|
+
const content = typeof input.content === 'string' ? input.content : '';
|
|
435
|
+
const format = (_ref = (_input_format = input.format) != null ? _input_format : transferFormatFromFileName(fileName)) != null ? _ref : sniffTransferFormat(content);
|
|
436
|
+
if (!resource.formats.includes(format)) {
|
|
437
|
+
throw new TransferEngineError('unsupportedFormat', 415, `${resource.label} can be imported from ${resource.formats.join(', ').toUpperCase()}, not ${format.toUpperCase()}.`);
|
|
438
|
+
}
|
|
439
|
+
const parts = (_input_parts = input.parts) != null ? _input_parts : 1;
|
|
440
|
+
const part = (_input_part = input.part) != null ? _input_part : 0;
|
|
441
|
+
if (!Number.isInteger(parts) || parts < 1 || parts > TRANSFER_UPLOAD_MAX_PARTS) {
|
|
442
|
+
throw new TransferEngineError('invalid', 400, `A file is sent in 1 to ${TRANSFER_UPLOAD_MAX_PARTS} parts.`);
|
|
443
|
+
}
|
|
444
|
+
if (!Number.isInteger(part) || part < 0 || part >= parts) {
|
|
445
|
+
throw new TransferEngineError('invalid', 400, `Part ${part} is not one of ${parts}.`);
|
|
446
|
+
}
|
|
447
|
+
const bytes = Buffer.from(content, 'utf8');
|
|
448
|
+
if (bytes.length > TRANSFER_UPLOAD_PART_MAX_BYTES) {
|
|
449
|
+
throw new TransferEngineError('tooLarge', 413, 'Send the file in parts of at most 3 MB.');
|
|
450
|
+
}
|
|
451
|
+
const maxBytes = Math.min((_ref1 = (_resource_limits = resource.limits) == null ? void 0 : _resource_limits.maxBytes) != null ? _ref1 : TRANSFER_UPLOAD_MAX_BYTES, TRANSFER_UPLOAD_MAX_BYTES);
|
|
452
|
+
inspectTransferBytes(bytes, format, fileName);
|
|
453
|
+
const now = clock(deps);
|
|
454
|
+
const jobs = transferJobsCollection(deps.firestore, input.orgId);
|
|
455
|
+
let job;
|
|
456
|
+
if (input.jobId) {
|
|
457
|
+
job = await readJob(deps, input.orgId, input.jobId);
|
|
458
|
+
if (job.status !== 'draft' || job.resource !== resource.key || !job.upload || job.upload.complete) {
|
|
459
|
+
throw new TransferEngineError('state', 409, 'This import is not waiting for a file.');
|
|
460
|
+
}
|
|
461
|
+
if (job.upload.parts !== parts) throw new TransferEngineError('invalid', 400, 'The part count changed.');
|
|
462
|
+
if (job.upload.received.includes(part)) return {
|
|
463
|
+
job,
|
|
464
|
+
complete: false
|
|
465
|
+
};
|
|
466
|
+
} else {
|
|
467
|
+
var _ref4;
|
|
468
|
+
if (part !== 0) throw new TransferEngineError('invalid', 400, 'Send the first part first.');
|
|
469
|
+
const id = (_ref4 = deps.newJobId == null ? void 0 : deps.newJobId.call(deps)) != null ? _ref4 : createResourceUid();
|
|
470
|
+
job = _extends({
|
|
471
|
+
id,
|
|
472
|
+
resource: resource.key,
|
|
473
|
+
kind: 'records',
|
|
474
|
+
direction: 'import',
|
|
475
|
+
format,
|
|
476
|
+
status: 'draft',
|
|
477
|
+
orgId: input.orgId
|
|
478
|
+
}, hostId ? {
|
|
479
|
+
hostId
|
|
480
|
+
} : {}, {
|
|
481
|
+
createdBy: input.actorUid,
|
|
482
|
+
createdAt: now,
|
|
483
|
+
updatedAt: now,
|
|
484
|
+
fileName
|
|
485
|
+
}, format === 'csv' && readOptionsOf(input) ? {
|
|
486
|
+
read: readOptionsOf(input)
|
|
487
|
+
} : {}, {
|
|
488
|
+
upload: {
|
|
489
|
+
parts,
|
|
490
|
+
received: [],
|
|
491
|
+
bytes: 0,
|
|
492
|
+
complete: false
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
if (((_ref2 = (_job_upload = job.upload) == null ? void 0 : _job_upload.bytes) != null ? _ref2 : 0) + bytes.length > maxBytes) {
|
|
497
|
+
throw new TransferEngineError('tooLarge', 413, `A file may be at most ${Math.round(maxBytes / 1000000)} MB.`);
|
|
498
|
+
}
|
|
499
|
+
let whole;
|
|
500
|
+
if (parts === 1) {
|
|
501
|
+
whole = bytes;
|
|
502
|
+
} else {
|
|
503
|
+
var _ref5;
|
|
504
|
+
var _job_upload1;
|
|
505
|
+
await deps.bucket.file(transferPartPath(input.orgId, job.id, part)).save(bytes, {
|
|
506
|
+
contentType: 'application/octet-stream',
|
|
507
|
+
resumable: false
|
|
508
|
+
});
|
|
509
|
+
const received = await deps.firestore.runTransaction(async (transaction)=>{
|
|
510
|
+
var _current_upload;
|
|
511
|
+
const ref = jobs.doc(job.id);
|
|
512
|
+
const snapshot = await transaction.get(ref);
|
|
513
|
+
const current = snapshot.exists ? _extends({}, snapshot.data(), {
|
|
514
|
+
id: job.id
|
|
515
|
+
}) : job;
|
|
516
|
+
const upload = (_current_upload = current.upload) != null ? _current_upload : {
|
|
517
|
+
parts,
|
|
518
|
+
received: [],
|
|
519
|
+
bytes: 0,
|
|
520
|
+
complete: false
|
|
521
|
+
};
|
|
522
|
+
const next = _extends({}, current, {
|
|
523
|
+
updatedAt: now,
|
|
524
|
+
upload: _extends({}, upload, {
|
|
525
|
+
received: [
|
|
526
|
+
...new Set([
|
|
527
|
+
...upload.received,
|
|
528
|
+
part
|
|
529
|
+
])
|
|
530
|
+
].sort((a, b)=>a - b),
|
|
531
|
+
bytes: upload.received.includes(part) ? upload.bytes : upload.bytes + bytes.length
|
|
532
|
+
})
|
|
533
|
+
});
|
|
534
|
+
transaction.set(ref, storedJob(next));
|
|
535
|
+
return next;
|
|
536
|
+
});
|
|
537
|
+
job = received;
|
|
538
|
+
if (((_ref5 = (_job_upload1 = job.upload) == null ? void 0 : _job_upload1.received.length) != null ? _ref5 : 0) < parts) return {
|
|
539
|
+
job,
|
|
540
|
+
complete: false
|
|
541
|
+
};
|
|
542
|
+
const pieces = [];
|
|
543
|
+
for(let index = 0; index < parts; index += 1){
|
|
544
|
+
const [piece] = await deps.bucket.file(transferPartPath(input.orgId, job.id, index)).download();
|
|
545
|
+
pieces.push(piece);
|
|
546
|
+
}
|
|
547
|
+
whole = Buffer.concat(pieces);
|
|
548
|
+
if (whole.length > maxBytes) {
|
|
549
|
+
throw new TransferEngineError('tooLarge', 413, `A file may be at most ${Math.round(maxBytes / 1000000)} MB.`);
|
|
550
|
+
}
|
|
551
|
+
inspectTransferBytes(whole, format, fileName);
|
|
552
|
+
}
|
|
553
|
+
const read = readTransferSource(textOf(whole), format, (_job_read = job.read) != null ? _job_read : {});
|
|
554
|
+
if ('problem' in read) {
|
|
555
|
+
await saveJob(deps, moveJob(job, 'failed', now, {
|
|
556
|
+
error: {
|
|
557
|
+
code: read.problem.code,
|
|
558
|
+
message: read.problem.message
|
|
559
|
+
}
|
|
560
|
+
}));
|
|
561
|
+
throw new TransferEngineError('invalid', 422, read.problem.message, read.problem);
|
|
562
|
+
}
|
|
563
|
+
const maxRows = (_ref3 = (_resource_limits1 = resource.limits) == null ? void 0 : _resource_limits1.maxRows) != null ? _ref3 : TRANSFER_DEFAULT_MAX_ROWS;
|
|
564
|
+
const rowCount = read.table.rows.length;
|
|
565
|
+
if (rowCount > maxRows) {
|
|
566
|
+
const message = `${resource.label} are imported at most ${maxRows.toLocaleString('en-US')} rows at a time; this file has ${rowCount.toLocaleString('en-US')}. Split it and import each part.`;
|
|
567
|
+
await saveJob(deps, moveJob(job, 'failed', now, {
|
|
568
|
+
error: {
|
|
569
|
+
code: 'tooManyRows',
|
|
570
|
+
message
|
|
571
|
+
}
|
|
572
|
+
}));
|
|
573
|
+
throw new TransferEngineError('tooLarge', 413, message);
|
|
574
|
+
}
|
|
575
|
+
if (!rowCount) throw new TransferEngineError('invalid', 422, 'The file has a header and no rows.');
|
|
576
|
+
const sourcePath = transferSourcePath(input.orgId, job.id);
|
|
577
|
+
await deps.bucket.file(sourcePath).save(whole, {
|
|
578
|
+
contentType: transferContentType(format),
|
|
579
|
+
resumable: false
|
|
580
|
+
});
|
|
581
|
+
if (parts > 1) {
|
|
582
|
+
await eachLimited(Array.from({
|
|
583
|
+
length: parts
|
|
584
|
+
}, (_unused, index)=>index), TRANSFER_WRITE_CONCURRENCY, async (index)=>{
|
|
585
|
+
await deps.bucket.file(transferPartPath(input.orgId, job.id, index)).delete({
|
|
586
|
+
ignoreNotFound: true
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
const done = _extends({}, job, {
|
|
591
|
+
updatedAt: now,
|
|
592
|
+
sourcePath,
|
|
593
|
+
rowCount,
|
|
594
|
+
chunkCount: transferChunkRanges(rowCount).length,
|
|
595
|
+
headers: read.table.headers,
|
|
596
|
+
upload: {
|
|
597
|
+
parts,
|
|
598
|
+
received: Array.from({
|
|
599
|
+
length: parts
|
|
600
|
+
}, (_unused, index)=>index),
|
|
601
|
+
bytes: whole.length,
|
|
602
|
+
complete: true
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
await saveJob(deps, done);
|
|
606
|
+
return {
|
|
607
|
+
job: done,
|
|
608
|
+
complete: true
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
async function loadTable(deps, job) {
|
|
612
|
+
var _job_read;
|
|
613
|
+
var _job_upload;
|
|
614
|
+
if (!job.sourcePath || !((_job_upload = job.upload) == null ? void 0 : _job_upload.complete)) {
|
|
615
|
+
throw new TransferEngineError('state', 409, 'The file has not finished uploading.');
|
|
616
|
+
}
|
|
617
|
+
const [bytes] = await deps.bucket.file(job.sourcePath).download();
|
|
618
|
+
const read = readTransferSource(textOf(bytes), job.format, (_job_read = job.read) != null ? _job_read : {});
|
|
619
|
+
if ('problem' in read) throw new TransferEngineError('invalid', 422, read.problem.message, read.problem);
|
|
620
|
+
return read.table;
|
|
621
|
+
}
|
|
622
|
+
/*==========================================
|
|
623
|
+
* ANALYZE
|
|
624
|
+
*=========================================*/ function assertBeforeWrites(job, what) {
|
|
625
|
+
if (job.applyStartedAt !== undefined || job.status === 'applying' || job.status === 'applied' || job.status === 'undone') {
|
|
626
|
+
throw new TransferEngineError('state', 409, `This import has started writing; it cannot be ${what} again.`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function usableMapping(mapping) {
|
|
630
|
+
const out = {};
|
|
631
|
+
for (const [column, fieldId] of Object.entries(mapping != null ? mapping : {})){
|
|
632
|
+
if (typeof fieldId === 'string' && fieldId && /^\d+$/.test(column)) out[Number(column)] = fieldId;
|
|
633
|
+
}
|
|
634
|
+
return out;
|
|
635
|
+
}
|
|
636
|
+
function isPicklistField(field) {
|
|
637
|
+
return Boolean(field && (field.type === 'picklist' || field.type === 'multiPicklist') && field.picklistId);
|
|
638
|
+
}
|
|
639
|
+
async function picklistLists(hooks, ctx, fields) {
|
|
640
|
+
const ids = [
|
|
641
|
+
...new Set(fields.filter(isPicklistField).map((field)=>field.picklistId))
|
|
642
|
+
];
|
|
643
|
+
if (!ids.length || !hooks.picklists) return {};
|
|
644
|
+
return hooks.picklists(ctx, ids);
|
|
645
|
+
}
|
|
646
|
+
/** The picklist values each mapped picklist column carries, matched to the organization's list. */ function analyzePicklistColumns(table, mapping, byId, lists) {
|
|
647
|
+
const analyses = [];
|
|
648
|
+
for (const [column, fieldId] of Object.entries(mapping)){
|
|
649
|
+
const field = byId.get(fieldId);
|
|
650
|
+
if (!isPicklistField(field)) continue;
|
|
651
|
+
const list = lists[field.picklistId];
|
|
652
|
+
if (!list) continue;
|
|
653
|
+
const incoming = collectPicklistValues(table.rows.map((cells, row)=>{
|
|
654
|
+
const derived = deriveTransferCell(field, cells[Number(column)]);
|
|
655
|
+
return {
|
|
656
|
+
row,
|
|
657
|
+
value: derived.ok ? derived.value : null
|
|
658
|
+
};
|
|
659
|
+
}));
|
|
660
|
+
const result = matchPicklistValues(list.set, incoming);
|
|
661
|
+
analyses.push({
|
|
662
|
+
fieldId,
|
|
663
|
+
picklistId: field.picklistId,
|
|
664
|
+
spec: list.spec,
|
|
665
|
+
set: list.set,
|
|
666
|
+
matched: result.matched,
|
|
667
|
+
unmatched: result.unmatched,
|
|
668
|
+
proposals: Object.fromEntries(result.unmatched.map((value)=>[
|
|
669
|
+
value.key,
|
|
670
|
+
proposePicklistChoice(list.spec, value)
|
|
671
|
+
]))
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
return analyses;
|
|
675
|
+
}
|
|
676
|
+
/** Record id → a name for it, for the records `ids` names that `records` holds. */ function recordLabelsFor(records, ids) {
|
|
677
|
+
const labels = {};
|
|
678
|
+
for (const id of ids){
|
|
679
|
+
const label = transferRowLabel(records.get(id));
|
|
680
|
+
if (label) labels[id] = label;
|
|
681
|
+
}
|
|
682
|
+
return labels;
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Reads the file and proposes a mapping: every column against the
|
|
686
|
+
* resource's catalog and aliases, with the first rows as samples, and each
|
|
687
|
+
* mapped picklist column's values against the organization's list. With
|
|
688
|
+
* `mapping`, the values are read under that mapping instead of the proposal,
|
|
689
|
+
* and the answer adds what reading every mapped field did over the whole
|
|
690
|
+
* file and how the rows match existing records under `matchKeys`.
|
|
691
|
+
*/ export async function analyzeTransferJob(deps, input) {
|
|
692
|
+
var _hooks_aliases;
|
|
693
|
+
const job = await readJob(deps, input.orgId, input.jobId);
|
|
694
|
+
assertBeforeWrites(job, 'analyzed');
|
|
695
|
+
const resource = await resolveResource(deps, job.resource);
|
|
696
|
+
const hooks = transferRecordsHooks(resource);
|
|
697
|
+
const ctx = contextFor(job, input.actorUid);
|
|
698
|
+
const table = await loadTable(deps, job);
|
|
699
|
+
const catalog = await transferResourceCatalog(resource, ctx);
|
|
700
|
+
const samples = table.rows.slice(0, 20);
|
|
701
|
+
const match = matchHeaders(table.headers, catalog.fields, {
|
|
702
|
+
dictionaries: (_hooks_aliases = hooks.aliases) != null ? _hooks_aliases : [],
|
|
703
|
+
samples
|
|
704
|
+
});
|
|
705
|
+
const mapping = input.mapping ? usableMapping(input.mapping) : match.mapping;
|
|
706
|
+
const byId = new Map(catalog.fields.map((field)=>[
|
|
707
|
+
field.id,
|
|
708
|
+
field
|
|
709
|
+
]));
|
|
710
|
+
const lists = await picklistLists(hooks, ctx, catalog.fields);
|
|
711
|
+
const picklists = analyzePicklistColumns(table, mapping, byId, lists);
|
|
712
|
+
const lockedRules = [
|
|
713
|
+
...await transferResourceLockedRules(resource, ctx)
|
|
714
|
+
];
|
|
715
|
+
const offer = await transferResourceMatchKeys(resource, ctx);
|
|
716
|
+
let review = {};
|
|
717
|
+
if (input.mapping) {
|
|
718
|
+
var _input_matchKeys;
|
|
719
|
+
const fieldOptions = transferDateOrderOptions(input.dateOrders);
|
|
720
|
+
const read = table.rows.map((cells, index)=>{
|
|
721
|
+
const mapped = mapTransferRow(cells, mapping);
|
|
722
|
+
return _extends({
|
|
723
|
+
index,
|
|
724
|
+
cells: mapped
|
|
725
|
+
}, deriveTransferRow(byId, mapped, {}, fieldOptions));
|
|
726
|
+
});
|
|
727
|
+
const mappedFields = [
|
|
728
|
+
...new Set(Object.values(mapping))
|
|
729
|
+
].map((fieldId)=>byId.get(fieldId)).filter((field)=>Boolean(field));
|
|
730
|
+
const keys = chosenMatchKeys(offer.keys, (_input_matchKeys = input.matchKeys) != null ? _input_matchKeys : offer.defaults);
|
|
731
|
+
const values = read.map((row)=>row.values);
|
|
732
|
+
const found = keys.length ? await lookupAll(hooks, ctx, matchLookupRequests(values, keys)) : {
|
|
733
|
+
lookup: new Map(),
|
|
734
|
+
records: new Map()
|
|
735
|
+
};
|
|
736
|
+
// The resource's own matching, when it has one: the Matching step shows
|
|
737
|
+
// the outcome the dry run will act on.
|
|
738
|
+
const matched = await matchTransferResourceRows(resource, ctx, {
|
|
739
|
+
rows: values,
|
|
740
|
+
keys,
|
|
741
|
+
lookup: found.lookup,
|
|
742
|
+
records: found.records
|
|
743
|
+
});
|
|
744
|
+
const outcomes = matched.outcomes;
|
|
745
|
+
const lookups = await lookupReviews(await resolveLookupColumns(deps, hooks, ctx, mapping, byId, read));
|
|
746
|
+
review = _extends({
|
|
747
|
+
derivations: summarizeTransferDerivations(mappedFields, read)
|
|
748
|
+
}, lookups.length ? {
|
|
749
|
+
lookups
|
|
750
|
+
} : {}, {
|
|
751
|
+
matches: transferMatchReview(keys, outcomes, values),
|
|
752
|
+
recordLabels: recordLabelsFor(matched.records, transferMatchedRecordIds(outcomes))
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
const now = clock(deps);
|
|
756
|
+
const next = moveJob(job, 'analyzed', now, {
|
|
757
|
+
headers: table.headers,
|
|
758
|
+
rowCount: table.rows.length,
|
|
759
|
+
chunkCount: transferChunkRanges(table.rows.length).length,
|
|
760
|
+
mapping
|
|
761
|
+
});
|
|
762
|
+
await saveJob(deps, next);
|
|
763
|
+
return _extends({
|
|
764
|
+
job: next,
|
|
765
|
+
headers: table.headers,
|
|
766
|
+
rowCount: table.rows.length,
|
|
767
|
+
samples: samples.map((cells)=>cells.map(transferCellText)),
|
|
768
|
+
catalog,
|
|
769
|
+
match,
|
|
770
|
+
mappingProblems: mappingProblems(mapping, catalog.fields),
|
|
771
|
+
matchKeys: offer.keys,
|
|
772
|
+
lockedRules,
|
|
773
|
+
picklists
|
|
774
|
+
}, review);
|
|
775
|
+
}
|
|
776
|
+
/*==========================================
|
|
777
|
+
* LOOKUPS — cells that name another record (AGL-3541)
|
|
778
|
+
*=========================================*/ /** The most suggestions one unresolved value carries. */ const LOOKUP_SUGGESTIONS_MAX = 5;
|
|
779
|
+
/** The most rows one unresolved value lists. */ const LOOKUP_ROWS_MAX = 20;
|
|
780
|
+
/**
|
|
781
|
+
* The names a lookup cell holds, trimmed, blanks dropped: one for a `lookup`
|
|
782
|
+
* field, each item of a `list` field that names a target. `null` for a cell
|
|
783
|
+
* that is neither text nor a list — a value no record could be named by.
|
|
784
|
+
*/ function lookupCellNames(raw) {
|
|
785
|
+
if (isBlankTransferValue(raw)) return [];
|
|
786
|
+
const items = Array.isArray(raw) ? raw : [
|
|
787
|
+
raw
|
|
788
|
+
];
|
|
789
|
+
if (items.some((item)=>item !== null && item !== undefined && typeof item === 'object')) return null;
|
|
790
|
+
return items.map((item)=>String(item != null ? item : '').trim()).filter(Boolean);
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* The target of a lookup field: one the resource answers itself
|
|
794
|
+
* (`lookupTargets`), or the declared resource it names, asked as that
|
|
795
|
+
* resource in the same workspace and site.
|
|
796
|
+
*/ async function lookupTargetFor(deps, hooks, ctx, key) {
|
|
797
|
+
var _hooks_lookupTargets;
|
|
798
|
+
const own = (_hooks_lookupTargets = hooks.lookupTargets) == null ? void 0 : _hooks_lookupTargets[key];
|
|
799
|
+
if (own) return {
|
|
800
|
+
hooks: own,
|
|
801
|
+
ctx
|
|
802
|
+
};
|
|
803
|
+
const target = await resolveResource(deps, key);
|
|
804
|
+
const targetCtx = _extends({}, ctx, {
|
|
805
|
+
resource: target.key
|
|
806
|
+
});
|
|
807
|
+
// Linking rows to another resource's records is part of the import, so
|
|
808
|
+
// it asks that resource's plan as an import does (AGL-3555): a workspace
|
|
809
|
+
// without the feature cannot reach those records through a lookup either.
|
|
810
|
+
if (transferPlanFeature('import', target)) {
|
|
811
|
+
var _org_data;
|
|
812
|
+
const org = await deps.firestore.collection('orgs').doc(ctx.orgId).get();
|
|
813
|
+
const refusal = await transferPlanRefusal({
|
|
814
|
+
resource: target.key,
|
|
815
|
+
orgId: ctx.orgId,
|
|
816
|
+
hostId: ctx.hostId,
|
|
817
|
+
org: (_org_data = org.data()) != null ? _org_data : {}
|
|
818
|
+
}, 'import');
|
|
819
|
+
if (refusal) throw new TransferPlanRefusedError(refusal);
|
|
820
|
+
}
|
|
821
|
+
// The target's keys as they stand for it — read for the context when
|
|
822
|
+
// they depend on it (one dataset's own fields).
|
|
823
|
+
const { keys } = await transferResourceMatchKeys(target, targetCtx);
|
|
824
|
+
return {
|
|
825
|
+
hooks: _extends({}, transferRecordsHooks(target), {
|
|
826
|
+
matchKeys: keys
|
|
827
|
+
}),
|
|
828
|
+
ctx: targetCtx
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* A record's name for the person: the first text it holds — the name a
|
|
833
|
+
* resource lists first, as every record label the wizard shows is — else
|
|
834
|
+
* its first `by` value.
|
|
835
|
+
*/ function lookupRecordLabel(record, by) {
|
|
836
|
+
if (!record) return undefined;
|
|
837
|
+
const label = transferRowLabel(record);
|
|
838
|
+
if (label) return label;
|
|
839
|
+
for (const fieldId of by){
|
|
840
|
+
const value = record[fieldId];
|
|
841
|
+
if (value !== null && value !== undefined && String(value).trim()) return String(value).trim();
|
|
842
|
+
}
|
|
843
|
+
return undefined;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Every mapped lookup column's values resolved against its target: each
|
|
847
|
+
* distinct value asked by id (when it could be one) and then by each `by`
|
|
848
|
+
* field in order; the first key that names exactly one record decides. A
|
|
849
|
+
* value that names several records, or none, is unresolved.
|
|
850
|
+
*/ async function resolveLookupColumns(deps, hooks, ctx, mapping, byId, rows) {
|
|
851
|
+
const columns = [];
|
|
852
|
+
for (const fieldId of new Set(Object.values(mapping))){
|
|
853
|
+
const field = byId.get(fieldId);
|
|
854
|
+
if (!isTransferLookupField(field)) continue;
|
|
855
|
+
const values = new Map();
|
|
856
|
+
for (const row of rows){
|
|
857
|
+
var _lookupCellNames;
|
|
858
|
+
// A value is counted once per row, however often a list repeats it.
|
|
859
|
+
const inRow = new Set();
|
|
860
|
+
for (const text of (_lookupCellNames = lookupCellNames(row.values[field.id])) != null ? _lookupCellNames : []){
|
|
861
|
+
const key = transferLookupKey(text);
|
|
862
|
+
if (inRow.has(key)) continue;
|
|
863
|
+
inRow.add(key);
|
|
864
|
+
const seen = values.get(key);
|
|
865
|
+
if (seen) {
|
|
866
|
+
seen.count += 1;
|
|
867
|
+
if (seen.rows.length < LOOKUP_ROWS_MAX) seen.rows.push(row.index);
|
|
868
|
+
} else {
|
|
869
|
+
values.set(key, {
|
|
870
|
+
value: text,
|
|
871
|
+
key,
|
|
872
|
+
count: 1,
|
|
873
|
+
rows: [
|
|
874
|
+
row.index
|
|
875
|
+
]
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
const target = await lookupTargetFor(deps, hooks, ctx, field.lookup.resource);
|
|
881
|
+
const column = {
|
|
882
|
+
field,
|
|
883
|
+
target,
|
|
884
|
+
resolved: new Map(),
|
|
885
|
+
unresolved: new Map(),
|
|
886
|
+
records: new Map()
|
|
887
|
+
};
|
|
888
|
+
columns.push(column);
|
|
889
|
+
if (!values.size) continue;
|
|
890
|
+
const keys = [
|
|
891
|
+
{
|
|
892
|
+
fieldId: TRANSFER_ID_FIELD,
|
|
893
|
+
normalizer: 'aglynId'
|
|
894
|
+
},
|
|
895
|
+
...field.lookup.by.map((by)=>{
|
|
896
|
+
var _ref;
|
|
897
|
+
var _target_hooks_matchKeys;
|
|
898
|
+
return (_ref = (_target_hooks_matchKeys = target.hooks.matchKeys) == null ? void 0 : _target_hooks_matchKeys.find((key)=>key.fieldId === by)) != null ? _ref : {
|
|
899
|
+
fieldId: by,
|
|
900
|
+
normalizer: 'caseless'
|
|
901
|
+
};
|
|
902
|
+
})
|
|
903
|
+
];
|
|
904
|
+
const requests = keys.map((key)=>{
|
|
905
|
+
const wanted = new Set();
|
|
906
|
+
for (const entry of values.values()){
|
|
907
|
+
if (key.normalizer === 'aglynId' && !mayBeTransferRecordId(entry.value)) continue;
|
|
908
|
+
const normalized = normalizeMatchValue(key.normalizer, entry.value);
|
|
909
|
+
if (normalized) wanted.add(normalized);
|
|
910
|
+
}
|
|
911
|
+
return _extends({}, key, {
|
|
912
|
+
values: [
|
|
913
|
+
...wanted
|
|
914
|
+
]
|
|
915
|
+
});
|
|
916
|
+
}).filter((request)=>request.values.length);
|
|
917
|
+
const found = await lookupAll(target.hooks, target.ctx, requests);
|
|
918
|
+
for (const [id, record] of found.records)column.records.set(id, record);
|
|
919
|
+
for (const entry of values.values()){
|
|
920
|
+
let candidates = [];
|
|
921
|
+
for (const key of keys){
|
|
922
|
+
var _found_lookup_get;
|
|
923
|
+
if (key.normalizer === 'aglynId' && !mayBeTransferRecordId(entry.value)) continue;
|
|
924
|
+
const normalized = normalizeMatchValue(key.normalizer, entry.value);
|
|
925
|
+
const ids = normalized ? [
|
|
926
|
+
...new Set((_found_lookup_get = found.lookup.get(matchLookupKey(key.fieldId, normalized))) != null ? _found_lookup_get : [])
|
|
927
|
+
] : [];
|
|
928
|
+
if (ids.length === 1) {
|
|
929
|
+
column.resolved.set(entry.key, ids[0]);
|
|
930
|
+
candidates = [];
|
|
931
|
+
break;
|
|
932
|
+
}
|
|
933
|
+
if (ids.length > 1 && !candidates.length) candidates = ids;
|
|
934
|
+
}
|
|
935
|
+
if (!column.resolved.has(entry.key)) column.unresolved.set(entry.key, _extends({}, entry, {
|
|
936
|
+
candidates
|
|
937
|
+
}));
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return columns;
|
|
941
|
+
}
|
|
942
|
+
/** The review the values step shows: every unresolved value, with records it may mean. */ async function lookupReviews(columns) {
|
|
943
|
+
const reviews = [];
|
|
944
|
+
for (const column of columns){
|
|
945
|
+
const unresolved = [
|
|
946
|
+
...column.unresolved.values()
|
|
947
|
+
];
|
|
948
|
+
const asked = unresolved.length && column.target.hooks.suggest ? await column.target.hooks.suggest(column.target.ctx, {
|
|
949
|
+
by: column.field.lookup.by,
|
|
950
|
+
values: unresolved.map((entry)=>entry.value)
|
|
951
|
+
}) : {};
|
|
952
|
+
reviews.push({
|
|
953
|
+
fieldId: column.field.id,
|
|
954
|
+
resolved: column.resolved.size,
|
|
955
|
+
unresolved: unresolved.map((entry)=>{
|
|
956
|
+
var _asked_entry_value;
|
|
957
|
+
const several = entry.candidates.map((recordId)=>{
|
|
958
|
+
var _lookupRecordLabel;
|
|
959
|
+
return {
|
|
960
|
+
recordId,
|
|
961
|
+
label: (_lookupRecordLabel = lookupRecordLabel(column.records.get(recordId), column.field.lookup.by)) != null ? _lookupRecordLabel : recordId
|
|
962
|
+
};
|
|
963
|
+
});
|
|
964
|
+
const seen = new Set();
|
|
965
|
+
const suggestions = [
|
|
966
|
+
...several,
|
|
967
|
+
...(_asked_entry_value = asked[entry.value]) != null ? _asked_entry_value : []
|
|
968
|
+
].filter((suggestion)=>{
|
|
969
|
+
if (!(suggestion == null ? void 0 : suggestion.recordId) || seen.has(suggestion.recordId)) return false;
|
|
970
|
+
seen.add(suggestion.recordId);
|
|
971
|
+
return true;
|
|
972
|
+
}).slice(0, LOOKUP_SUGGESTIONS_MAX).map((suggestion)=>({
|
|
973
|
+
recordId: suggestion.recordId,
|
|
974
|
+
label: String(suggestion.label || suggestion.recordId)
|
|
975
|
+
}));
|
|
976
|
+
return {
|
|
977
|
+
value: entry.value,
|
|
978
|
+
key: entry.key,
|
|
979
|
+
count: entry.count,
|
|
980
|
+
rows: entry.rows,
|
|
981
|
+
suggestions
|
|
982
|
+
};
|
|
983
|
+
})
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
return reviews;
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* What the person must still decide about the lookup columns, by field: a
|
|
990
|
+
* value with no choice, a "create" the target does not allow, and a record
|
|
991
|
+
* chosen that does not exist. The records chosen are looked up by id.
|
|
992
|
+
*/ async function lookupChoiceProblems(columns, choices) {
|
|
993
|
+
const problems = {};
|
|
994
|
+
for (const column of columns){
|
|
995
|
+
var _ref;
|
|
996
|
+
const fieldChoices = (_ref = choices == null ? void 0 : choices[column.field.id]) != null ? _ref : {};
|
|
997
|
+
const found = [];
|
|
998
|
+
const mapped = new Set();
|
|
999
|
+
for (const entry of column.unresolved.values()){
|
|
1000
|
+
const choice = fieldChoices[entry.key];
|
|
1001
|
+
if (!choice) found.push(`Choose what to do with “${entry.value}”.`);
|
|
1002
|
+
else if (choice.action === 'create' && !column.field.lookup.creatable) {
|
|
1003
|
+
found.push(`“${entry.value}” cannot be created by this import; use a record, leave it blank or refuse the rows.`);
|
|
1004
|
+
} else if (choice.action === 'mapTo') {
|
|
1005
|
+
if (typeof choice.recordId !== 'string' || !choice.recordId) found.push(`Choose the record “${entry.value}” means.`);
|
|
1006
|
+
else if (!column.records.has(choice.recordId)) mapped.add(choice.recordId);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
if (mapped.size) {
|
|
1010
|
+
const known = await lookupAll(column.target.hooks, column.target.ctx, [
|
|
1011
|
+
{
|
|
1012
|
+
fieldId: TRANSFER_ID_FIELD,
|
|
1013
|
+
normalizer: 'aglynId',
|
|
1014
|
+
values: [
|
|
1015
|
+
...mapped
|
|
1016
|
+
]
|
|
1017
|
+
}
|
|
1018
|
+
]);
|
|
1019
|
+
for (const id of mapped){
|
|
1020
|
+
if (known.records.has(id)) column.records.set(id, known.records.get(id));
|
|
1021
|
+
else found.push(`The record chosen for a value no longer exists (${id}); choose again.`);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
if (found.length) problems[column.field.id] = found;
|
|
1025
|
+
}
|
|
1026
|
+
return problems;
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* The person's lookup choices applied to the rows: a resolved value becomes
|
|
1030
|
+
* its record's id; an unresolved one its choice — the chosen record's id, a
|
|
1031
|
+
* record to create, nothing written, or the row refused — each noted on the
|
|
1032
|
+
* row as `unresolvedLookup`. A value left blank by choice is not written at
|
|
1033
|
+
* all: it must never read as "clear the field". In a list, each item is
|
|
1034
|
+
* decided on its own and an item left blank is dropped from the list; a
|
|
1035
|
+
* list whose every item was left blank is not written.
|
|
1036
|
+
*/ function applyLookupChoices(rows, columns, choices) {
|
|
1037
|
+
for (const row of rows){
|
|
1038
|
+
var _row_notes;
|
|
1039
|
+
const values = row.values;
|
|
1040
|
+
const notes = [
|
|
1041
|
+
...(_row_notes = row.notes) != null ? _row_notes : []
|
|
1042
|
+
];
|
|
1043
|
+
for (const column of columns){
|
|
1044
|
+
const raw = values[column.field.id];
|
|
1045
|
+
const names = lookupCellNames(raw);
|
|
1046
|
+
if (!(names == null ? void 0 : names.length)) continue;
|
|
1047
|
+
const ids = [];
|
|
1048
|
+
let refused = false;
|
|
1049
|
+
for (const text of names){
|
|
1050
|
+
var _choices_column_field_id;
|
|
1051
|
+
const key = transferLookupKey(text);
|
|
1052
|
+
const id = column.resolved.get(key);
|
|
1053
|
+
if (id) {
|
|
1054
|
+
ids.push(id);
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
const choice = choices == null ? void 0 : (_choices_column_field_id = choices[column.field.id]) == null ? void 0 : _choices_column_field_id[key];
|
|
1058
|
+
const note = {
|
|
1059
|
+
class: 'unresolvedLookup',
|
|
1060
|
+
fieldId: column.field.id,
|
|
1061
|
+
value: text
|
|
1062
|
+
};
|
|
1063
|
+
if ((choice == null ? void 0 : choice.action) === 'mapTo') {
|
|
1064
|
+
ids.push(choice.recordId);
|
|
1065
|
+
const label = lookupRecordLabel(column.records.get(choice.recordId), column.field.lookup.by);
|
|
1066
|
+
notes.push(_extends({}, note, {
|
|
1067
|
+
detail: label ? `Uses “${label}”` : 'Uses a chosen record'
|
|
1068
|
+
}));
|
|
1069
|
+
} else if ((choice == null ? void 0 : choice.action) === 'create') {
|
|
1070
|
+
ids.push(transferLookupNewValue(text));
|
|
1071
|
+
notes.push(_extends({}, note, {
|
|
1072
|
+
detail: 'Creates it'
|
|
1073
|
+
}));
|
|
1074
|
+
} else if ((choice == null ? void 0 : choice.action) === 'refuseRow') {
|
|
1075
|
+
refused = true;
|
|
1076
|
+
notes.push(_extends({}, note, {
|
|
1077
|
+
refuse: true,
|
|
1078
|
+
detail: 'Refuses the row'
|
|
1079
|
+
}));
|
|
1080
|
+
} else {
|
|
1081
|
+
notes.push(_extends({}, note, {
|
|
1082
|
+
detail: 'Left blank'
|
|
1083
|
+
}));
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (refused || !ids.length) delete values[column.field.id];
|
|
1087
|
+
else values[column.field.id] = Array.isArray(raw) ? [
|
|
1088
|
+
...new Set(ids)
|
|
1089
|
+
] : ids[0];
|
|
1090
|
+
}
|
|
1091
|
+
if (notes.length) row.notes = notes;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
/** The rows read, picklists resolved: what `buildTransferPlan` takes. */ function readPlanRows(table, mapping, byId, columns, choices) {
|
|
1095
|
+
return table.rows.map((cells, index)=>{
|
|
1096
|
+
var _choices_derive;
|
|
1097
|
+
const derived = deriveTransferRow(byId, mapTransferRow(cells, mapping), (_choices_derive = choices.derive) != null ? _choices_derive : {}, transferDateOrderOptions(choices.dateOrders));
|
|
1098
|
+
const notes = [];
|
|
1099
|
+
for (const column of columns){
|
|
1100
|
+
const fieldId = column.field.id;
|
|
1101
|
+
const value = derived.values[fieldId];
|
|
1102
|
+
if (value === null || value === undefined) continue;
|
|
1103
|
+
const noteFor = (raw)=>{
|
|
1104
|
+
const label = normalizePicklistLabel(raw);
|
|
1105
|
+
if (!label) return;
|
|
1106
|
+
const key = picklistLabelKey(label);
|
|
1107
|
+
if (column.added.has(key)) notes.push({
|
|
1108
|
+
class: 'newPicklistValue',
|
|
1109
|
+
fieldId,
|
|
1110
|
+
value: String(raw)
|
|
1111
|
+
});
|
|
1112
|
+
else if (column.blank.has(key)) notes.push({
|
|
1113
|
+
class: 'unmatchedPicklist',
|
|
1114
|
+
fieldId,
|
|
1115
|
+
value: String(raw),
|
|
1116
|
+
detail: 'Left blank'
|
|
1117
|
+
});
|
|
1118
|
+
};
|
|
1119
|
+
if (column.field.type === 'multiPicklist' && Array.isArray(value)) {
|
|
1120
|
+
const outcome = resolveMultiPicklistCell(column.resolution, value);
|
|
1121
|
+
if (outcome.kind === 'refuse') {
|
|
1122
|
+
notes.push({
|
|
1123
|
+
class: 'unmatchedPicklist',
|
|
1124
|
+
fieldId,
|
|
1125
|
+
value: value.join(', '),
|
|
1126
|
+
refuse: true,
|
|
1127
|
+
detail: 'Refuses the row'
|
|
1128
|
+
});
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
for (const raw of value)noteFor(raw);
|
|
1132
|
+
if (outcome.labels.length) derived.values[fieldId] = outcome.labels;
|
|
1133
|
+
else delete derived.values[fieldId];
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
const outcome = resolvePicklistCell(column.resolution, value);
|
|
1137
|
+
if (outcome.kind === 'refuse') {
|
|
1138
|
+
notes.push({
|
|
1139
|
+
class: 'unmatchedPicklist',
|
|
1140
|
+
fieldId,
|
|
1141
|
+
value: String(value),
|
|
1142
|
+
refuse: true,
|
|
1143
|
+
detail: 'Refuses the row'
|
|
1144
|
+
});
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
noteFor(value);
|
|
1148
|
+
// A value left blank by choice is not written at all: a blank here
|
|
1149
|
+
// must never read as "clear the field".
|
|
1150
|
+
if (outcome.kind === 'value') derived.values[fieldId] = outcome.label;
|
|
1151
|
+
else delete derived.values[fieldId];
|
|
1152
|
+
}
|
|
1153
|
+
return _extends({
|
|
1154
|
+
index,
|
|
1155
|
+
values: derived.values,
|
|
1156
|
+
derivations: derived.derivations,
|
|
1157
|
+
problems: derived.problems
|
|
1158
|
+
}, notes.length ? {
|
|
1159
|
+
notes
|
|
1160
|
+
} : {});
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
/** The match keys the person chose, in their order, from the resource's own. */ function chosenMatchKeys(keys, chosen) {
|
|
1164
|
+
return chosen.map((fieldId)=>{
|
|
1165
|
+
const key = keys.find((entry)=>entry.fieldId === fieldId);
|
|
1166
|
+
if (!key) throw new TransferEngineError('invalid', 400, `"${fieldId}" is not a key records can be matched by.`);
|
|
1167
|
+
return key;
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
/** Every lookup request, cut so no single request carries more than {@link MATCH_LOOKUP_SLICE} values. */ function sliceLookupRequests(requests) {
|
|
1171
|
+
const slices = [];
|
|
1172
|
+
for (const request of requests){
|
|
1173
|
+
for(let at = 0; at < request.values.length; at += MATCH_LOOKUP_SLICE){
|
|
1174
|
+
slices.push([
|
|
1175
|
+
_extends({}, request, {
|
|
1176
|
+
values: request.values.slice(at, at + MATCH_LOOKUP_SLICE)
|
|
1177
|
+
})
|
|
1178
|
+
]);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return slices;
|
|
1182
|
+
}
|
|
1183
|
+
async function lookupAll(hooks, ctx, requests) {
|
|
1184
|
+
const lookup = new Map();
|
|
1185
|
+
const records = new Map();
|
|
1186
|
+
for (const slice of sliceLookupRequests(requests)){
|
|
1187
|
+
var _lookup_get;
|
|
1188
|
+
const found = await hooks.lookup(ctx, slice);
|
|
1189
|
+
for (const [key, ids] of found.lookup)lookup.set(key, [
|
|
1190
|
+
...new Set([
|
|
1191
|
+
...(_lookup_get = lookup.get(key)) != null ? _lookup_get : [],
|
|
1192
|
+
...ids
|
|
1193
|
+
])
|
|
1194
|
+
]);
|
|
1195
|
+
for (const [id, values] of found.records)records.set(id, values);
|
|
1196
|
+
}
|
|
1197
|
+
return {
|
|
1198
|
+
lookup,
|
|
1199
|
+
records
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* The dry run: every row read, its picklist values resolved, matched to the
|
|
1204
|
+
* records that exist and planned under the policy — without writing a
|
|
1205
|
+
* record. The planned rows are stored chunk by chunk for Apply; the summary,
|
|
1206
|
+
* the warnings and the first page answer the wizard.
|
|
1207
|
+
*/ export async function planTransferJob(deps, input) {
|
|
1208
|
+
var _choices_policy, _choices_matchKeys, _choices_picklistChoices, _choices_lookupChoices;
|
|
1209
|
+
const job = await readJob(deps, input.orgId, input.jobId);
|
|
1210
|
+
assertBeforeWrites(job, 'planned');
|
|
1211
|
+
if (job.status === 'draft') throw new TransferEngineError('state', 409, 'Analyze the file before planning it.');
|
|
1212
|
+
const resource = await resolveResource(deps, job.resource);
|
|
1213
|
+
const hooks = transferRecordsHooks(resource);
|
|
1214
|
+
const choices = input.choices;
|
|
1215
|
+
// The plugin steps' answers as sent with this dry run, not the last one's.
|
|
1216
|
+
const ctx = _extends({}, contextFor(job, input.actorUid));
|
|
1217
|
+
delete ctx.extras;
|
|
1218
|
+
if (choices.extras) ctx.extras = choices.extras;
|
|
1219
|
+
const table = await loadTable(deps, job);
|
|
1220
|
+
const catalog = await transferResourceCatalog(resource, ctx);
|
|
1221
|
+
const byId = new Map(catalog.fields.map((field)=>[
|
|
1222
|
+
field.id,
|
|
1223
|
+
field
|
|
1224
|
+
]));
|
|
1225
|
+
const mapping = usableMapping(choices.mapping);
|
|
1226
|
+
const problems = mappingProblems(mapping, catalog.fields);
|
|
1227
|
+
if (!mappingIsUsable(problems)) {
|
|
1228
|
+
throw new TransferEngineError('invalid', 422, 'Resolve the mapping before the dry run.', problems);
|
|
1229
|
+
}
|
|
1230
|
+
const lockedRules = await transferResourceLockedRules(resource, ctx);
|
|
1231
|
+
// The person's choices over the resource's starting point (AGL-3548).
|
|
1232
|
+
const defaults = await transferResourcePolicyDefaults(resource, ctx, catalog.fields);
|
|
1233
|
+
const policy = createTransferPolicy(_extends({}, withTransferPolicyDefaults(defaults, (_choices_policy = choices.policy) != null ? _choices_policy : {}), {
|
|
1234
|
+
locked: lockedRules
|
|
1235
|
+
}));
|
|
1236
|
+
const policyProblems = transferPolicyProblems(policy, catalog.fields);
|
|
1237
|
+
if (policyProblems.length) throw new TransferEngineError('invalid', 422, policyProblems[0], policyProblems);
|
|
1238
|
+
const offer = await transferResourceMatchKeys(resource, ctx);
|
|
1239
|
+
const keys = chosenMatchKeys(offer.keys, (_choices_matchKeys = choices.matchKeys) != null ? _choices_matchKeys : offer.defaults);
|
|
1240
|
+
// Picklists: every unmatched value needs the person's choice.
|
|
1241
|
+
const lists = await picklistLists(hooks, ctx, catalog.fields);
|
|
1242
|
+
const analyses = analyzePicklistColumns(table, mapping, byId, lists);
|
|
1243
|
+
const columns = [];
|
|
1244
|
+
const choiceProblems = {};
|
|
1245
|
+
const additions = {};
|
|
1246
|
+
for (const analysis of analyses){
|
|
1247
|
+
var _ref, _additions, _analysis_picklistId, _;
|
|
1248
|
+
var _choices_picklistChoices1;
|
|
1249
|
+
const fieldChoices = (_ref = (_choices_picklistChoices1 = choices.picklistChoices) == null ? void 0 : _choices_picklistChoices1[analysis.fieldId]) != null ? _ref : {};
|
|
1250
|
+
const found = picklistChoiceProblems(analysis.spec, analysis.set, analysis.unmatched, fieldChoices);
|
|
1251
|
+
if (found.length) {
|
|
1252
|
+
choiceProblems[analysis.fieldId] = found;
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
const resolution = resolvePicklistChoices(analysis.spec, analysis.set, {
|
|
1256
|
+
matched: analysis.matched,
|
|
1257
|
+
unmatched: analysis.unmatched
|
|
1258
|
+
}, fieldChoices);
|
|
1259
|
+
const added = new Set();
|
|
1260
|
+
const blank = new Set();
|
|
1261
|
+
for (const value of analysis.unmatched){
|
|
1262
|
+
const choice = fieldChoices[value.key];
|
|
1263
|
+
if ((choice == null ? void 0 : choice.action) === 'addValue') added.add(value.key);
|
|
1264
|
+
else if ((choice == null ? void 0 : choice.action) !== 'mapTo' && (choice == null ? void 0 : choice.action) !== 'refuseRow') blank.add(value.key);
|
|
1265
|
+
}
|
|
1266
|
+
const list = (_ = (_additions = additions)[_analysis_picklistId = analysis.picklistId]) != null ? _ : _additions[_analysis_picklistId] = [];
|
|
1267
|
+
for (const value of resolution.added){
|
|
1268
|
+
if (!list.some((entry)=>entry.id === value.id)) list.push(value);
|
|
1269
|
+
}
|
|
1270
|
+
columns.push({
|
|
1271
|
+
field: byId.get(analysis.fieldId),
|
|
1272
|
+
resolution,
|
|
1273
|
+
added,
|
|
1274
|
+
blank
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
for (const [picklistId, values] of Object.entries(additions))if (!values.length) delete additions[picklistId];
|
|
1278
|
+
const rows = readPlanRows(table, mapping, byId, columns, choices);
|
|
1279
|
+
// Lookups: every value that names no record needs the person's choice too.
|
|
1280
|
+
const lookupColumns = await resolveLookupColumns(deps, hooks, ctx, mapping, byId, rows);
|
|
1281
|
+
const lookupProblems = await lookupChoiceProblems(lookupColumns, choices.lookupChoices);
|
|
1282
|
+
for (const [fieldId, found] of Object.entries(lookupProblems)){
|
|
1283
|
+
var _choiceProblems_fieldId;
|
|
1284
|
+
choiceProblems[fieldId] = [
|
|
1285
|
+
...(_choiceProblems_fieldId = choiceProblems[fieldId]) != null ? _choiceProblems_fieldId : [],
|
|
1286
|
+
...found
|
|
1287
|
+
];
|
|
1288
|
+
}
|
|
1289
|
+
if (Object.keys(choiceProblems).length) {
|
|
1290
|
+
throw new TransferEngineError('choicesNeeded', 422, Object.keys(lookupProblems).length ? 'Choose what to do with every value the list does not hold and every record the file names that was not found.' : 'Choose what to do with every value the list does not hold.', choiceProblems);
|
|
1291
|
+
}
|
|
1292
|
+
applyLookupChoices(rows, lookupColumns, choices.lookupChoices);
|
|
1293
|
+
const values = rows.map((row)=>row.values);
|
|
1294
|
+
const looked = keys.length ? await lookupAll(hooks, ctx, matchLookupRequests(values, keys)) : {
|
|
1295
|
+
lookup: new Map(),
|
|
1296
|
+
records: new Map()
|
|
1297
|
+
};
|
|
1298
|
+
const { outcomes: matches, records } = await matchTransferResourceRows(resource, ctx, {
|
|
1299
|
+
rows: values,
|
|
1300
|
+
keys,
|
|
1301
|
+
lookup: looked.lookup,
|
|
1302
|
+
records: looked.records
|
|
1303
|
+
});
|
|
1304
|
+
const found = {
|
|
1305
|
+
lookup: looked.lookup,
|
|
1306
|
+
records
|
|
1307
|
+
};
|
|
1308
|
+
const plan = await planTransferResourceRows(resource, ctx, {
|
|
1309
|
+
fields: catalog.fields,
|
|
1310
|
+
rows,
|
|
1311
|
+
matches,
|
|
1312
|
+
existing: found.records,
|
|
1313
|
+
policy
|
|
1314
|
+
});
|
|
1315
|
+
const failures = transferInvariantFailures(hooks.invariants, plan.rows, found.records);
|
|
1316
|
+
if (failures.length) {
|
|
1317
|
+
const failing = new Set(failures.map((failure)=>failure.row));
|
|
1318
|
+
plan.rows = plan.rows.map((row)=>failing.has(row.index) ? _extends({}, row, {
|
|
1319
|
+
verdict: 'fail',
|
|
1320
|
+
reason: 'refusedValue',
|
|
1321
|
+
diff: []
|
|
1322
|
+
}) : row);
|
|
1323
|
+
plan.summary = {
|
|
1324
|
+
create: 0,
|
|
1325
|
+
update: 0,
|
|
1326
|
+
unchanged: 0,
|
|
1327
|
+
skip: 0,
|
|
1328
|
+
fail: 0,
|
|
1329
|
+
total: plan.rows.length
|
|
1330
|
+
};
|
|
1331
|
+
for (const row of plan.rows)plan.summary[row.verdict] += 1;
|
|
1332
|
+
}
|
|
1333
|
+
// The stored dry run, replaced whole.
|
|
1334
|
+
const jobRef = transferJobsCollection(deps.firestore, job.orgId).doc(job.id);
|
|
1335
|
+
await clearCollection(jobRef.collection('chunks'));
|
|
1336
|
+
const ranges = transferChunkRanges(plan.rows.length);
|
|
1337
|
+
await eachLimited(ranges, TRANSFER_WRITE_CONCURRENCY, async (range)=>{
|
|
1338
|
+
await writeJsonDoc(jobRef.collection('chunks').doc(String(range.index)), {
|
|
1339
|
+
jobId: job.id,
|
|
1340
|
+
index: range.index,
|
|
1341
|
+
start: range.start,
|
|
1342
|
+
end: range.end
|
|
1343
|
+
}, plan.rows.slice(range.start, range.end));
|
|
1344
|
+
});
|
|
1345
|
+
const now = clock(deps);
|
|
1346
|
+
const matchSummary = summarizeMatches(matches);
|
|
1347
|
+
const allConflicts = transferPlanConflicts(_extends({
|
|
1348
|
+
fields: byId,
|
|
1349
|
+
rows,
|
|
1350
|
+
matches,
|
|
1351
|
+
existing: found.records,
|
|
1352
|
+
policy
|
|
1353
|
+
}, hooks.valuesEqual ? {
|
|
1354
|
+
valuesEqual: hooks.valuesEqual
|
|
1355
|
+
} : {}));
|
|
1356
|
+
const conflicts = allConflicts.slice(0, TRANSFER_PLAN_CONFLICTS_MAX);
|
|
1357
|
+
const ambiguous = transferAmbiguities(matches);
|
|
1358
|
+
const sample = transferPlanSample(plan.rows);
|
|
1359
|
+
const labelled = new Set();
|
|
1360
|
+
for (const row of sample)if (row.recordId) labelled.add(row.recordId);
|
|
1361
|
+
for (const conflict of conflicts)labelled.add(conflict.recordId);
|
|
1362
|
+
for (const row of ambiguous)for (const id of row.recordIds)labelled.add(id);
|
|
1363
|
+
const next = moveJob(job, 'planned', now, _extends({
|
|
1364
|
+
mapping,
|
|
1365
|
+
matchKeys: keys.map((key)=>key.fieldId),
|
|
1366
|
+
policy,
|
|
1367
|
+
picklistChoices: (_choices_picklistChoices = choices.picklistChoices) != null ? _choices_picklistChoices : {},
|
|
1368
|
+
lookupChoices: (_choices_lookupChoices = choices.lookupChoices) != null ? _choices_lookupChoices : {}
|
|
1369
|
+
}, choices.derive ? {
|
|
1370
|
+
derive: choices.derive
|
|
1371
|
+
} : {}, choices.dateOrders ? {
|
|
1372
|
+
dateOrders: choices.dateOrders
|
|
1373
|
+
} : {}, choices.extras ? {
|
|
1374
|
+
extras: choices.extras
|
|
1375
|
+
} : {}, {
|
|
1376
|
+
picklistAdditions: additions,
|
|
1377
|
+
summary: plan.summary,
|
|
1378
|
+
warnings: plan.warnings,
|
|
1379
|
+
acknowledgementsRequired: plan.acknowledgementsRequired,
|
|
1380
|
+
acknowledged: [],
|
|
1381
|
+
matchSummary,
|
|
1382
|
+
invariantFailures: failures.slice(0, 50),
|
|
1383
|
+
invariantFailureCount: failures.length,
|
|
1384
|
+
rowCount: plan.rows.length,
|
|
1385
|
+
chunkCount: ranges.length,
|
|
1386
|
+
plannedAt: now
|
|
1387
|
+
}));
|
|
1388
|
+
await saveJob(deps, next);
|
|
1389
|
+
const first = plan.rows.slice(0, 50);
|
|
1390
|
+
return {
|
|
1391
|
+
job: next,
|
|
1392
|
+
summary: plan.summary,
|
|
1393
|
+
warnings: plan.warnings,
|
|
1394
|
+
acknowledgementsRequired: plan.acknowledgementsRequired,
|
|
1395
|
+
matchSummary,
|
|
1396
|
+
invariantFailures: failures.slice(0, 50),
|
|
1397
|
+
invariantFailureCount: failures.length,
|
|
1398
|
+
picklistAdditions: additions,
|
|
1399
|
+
rows: {
|
|
1400
|
+
rows: first,
|
|
1401
|
+
offset: 0,
|
|
1402
|
+
next: plan.rows.length > first.length ? first.length : null
|
|
1403
|
+
},
|
|
1404
|
+
sample,
|
|
1405
|
+
conflicts,
|
|
1406
|
+
conflictCount: allConflicts.length,
|
|
1407
|
+
ambiguous,
|
|
1408
|
+
recordLabels: recordLabelsFor(found.records, labelled)
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
async function readChunkRows(jobRef, index) {
|
|
1412
|
+
var _ref;
|
|
1413
|
+
const ref = jobRef.collection('chunks').doc(String(index));
|
|
1414
|
+
const snapshot = await ref.get();
|
|
1415
|
+
if (!snapshot.exists) return null;
|
|
1416
|
+
const data = snapshot.data();
|
|
1417
|
+
const rows = (_ref = await readJsonDoc(ref)) != null ? _ref : [];
|
|
1418
|
+
return {
|
|
1419
|
+
range: {
|
|
1420
|
+
index: data.index,
|
|
1421
|
+
start: data.start,
|
|
1422
|
+
end: data.end
|
|
1423
|
+
},
|
|
1424
|
+
rows
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
/** One page of the stored dry run, optionally only rows with some verdicts. */ export async function readTransferPlanRows(deps, input) {
|
|
1428
|
+
var _input_offset, _input_limit, _job_chunkCount;
|
|
1429
|
+
var _input_verdicts;
|
|
1430
|
+
const job = await readJob(deps, input.orgId, input.jobId);
|
|
1431
|
+
if (job.plannedAt === undefined) throw new TransferEngineError('state', 409, 'This import has no dry run yet.');
|
|
1432
|
+
const offset = Math.max(0, Math.floor(Number((_input_offset = input.offset) != null ? _input_offset : 0)) || 0);
|
|
1433
|
+
const limit = Math.min(TRANSFER_PLAN_PAGE_MAX, Math.max(1, Math.floor(Number((_input_limit = input.limit) != null ? _input_limit : 50)) || 50));
|
|
1434
|
+
const filter = ((_input_verdicts = input.verdicts) == null ? void 0 : _input_verdicts.length) ? new Set(input.verdicts) : null;
|
|
1435
|
+
const jobRef = transferJobsCollection(deps.firestore, job.orgId).doc(job.id);
|
|
1436
|
+
const rows = [];
|
|
1437
|
+
let seen = 0;
|
|
1438
|
+
let more = false;
|
|
1439
|
+
scan: for(let index = 0; index < ((_job_chunkCount = job.chunkCount) != null ? _job_chunkCount : 0); index += 1){
|
|
1440
|
+
const chunk = await readChunkRows(jobRef, index);
|
|
1441
|
+
if (!chunk) break;
|
|
1442
|
+
for (const row of chunk.rows){
|
|
1443
|
+
if (filter && !filter.has(row.verdict)) continue;
|
|
1444
|
+
if (seen >= offset) {
|
|
1445
|
+
if (rows.length >= limit) {
|
|
1446
|
+
more = true;
|
|
1447
|
+
break scan;
|
|
1448
|
+
}
|
|
1449
|
+
rows.push(row);
|
|
1450
|
+
}
|
|
1451
|
+
seen += 1;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
return {
|
|
1455
|
+
rows,
|
|
1456
|
+
offset,
|
|
1457
|
+
next: more ? offset + rows.length : null
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
/** A row the plan does not write, as its result. */ function plannedResult(row) {
|
|
1461
|
+
if (row.verdict === 'unchanged') return _extends({
|
|
1462
|
+
row: row.index,
|
|
1463
|
+
outcome: 'unchanged'
|
|
1464
|
+
}, row.recordId ? {
|
|
1465
|
+
recordId: row.recordId
|
|
1466
|
+
} : {});
|
|
1467
|
+
if (row.verdict === 'skip') return _extends({
|
|
1468
|
+
row: row.index,
|
|
1469
|
+
outcome: 'skipped'
|
|
1470
|
+
}, row.reason ? {
|
|
1471
|
+
reason: row.reason
|
|
1472
|
+
} : {});
|
|
1473
|
+
return _extends({
|
|
1474
|
+
row: row.index,
|
|
1475
|
+
outcome: 'failed'
|
|
1476
|
+
}, row.recordId ? {
|
|
1477
|
+
recordId: row.recordId
|
|
1478
|
+
} : {}, row.reason ? {
|
|
1479
|
+
reason: row.reason
|
|
1480
|
+
} : {});
|
|
1481
|
+
}
|
|
1482
|
+
export function addResults(total, more) {
|
|
1483
|
+
return {
|
|
1484
|
+
created: total.created + more.created,
|
|
1485
|
+
updated: total.updated + more.updated,
|
|
1486
|
+
unchanged: total.unchanged + more.unchanged,
|
|
1487
|
+
skipped: total.skipped + more.skipped,
|
|
1488
|
+
failed: total.failed + more.failed,
|
|
1489
|
+
total: total.total + more.total
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
/** Takes the job for `driver`, or refuses: another driver holds it, or another import of the resource is running. */ export async function takeLease(deps, job, driver, expiresAt, enter) {
|
|
1493
|
+
const jobs = transferJobsCollection(deps.firestore, job.orgId);
|
|
1494
|
+
return deps.firestore.runTransaction(async (transaction)=>{
|
|
1495
|
+
const ref = jobs.doc(job.id);
|
|
1496
|
+
const snapshot = await transaction.get(ref);
|
|
1497
|
+
if (!snapshot.exists) throw new TransferEngineError('notFound', 404, 'No such import.');
|
|
1498
|
+
const current = _extends({}, snapshot.data(), {
|
|
1499
|
+
id: job.id
|
|
1500
|
+
});
|
|
1501
|
+
const now = clock(deps);
|
|
1502
|
+
if (current.lease && current.lease.expiresAt > now && current.lease.owner !== driver) {
|
|
1503
|
+
throw new TransferEngineError('busy', 409, 'This import is already running in another window. Its progress shows here.');
|
|
1504
|
+
}
|
|
1505
|
+
const running = await transaction.get(jobs.where('resource', '==', current.resource).where('status', '==', 'applying').limit(5));
|
|
1506
|
+
const other = running.docs.find((doc)=>doc.id !== job.id);
|
|
1507
|
+
if (other) {
|
|
1508
|
+
throw new TransferEngineError('busy', 409, 'Another import of the same records is running. Wait for it to finish.', {
|
|
1509
|
+
jobId: other.id
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
const next = _extends({}, enter(current, now), {
|
|
1513
|
+
lease: {
|
|
1514
|
+
owner: driver,
|
|
1515
|
+
expiresAt
|
|
1516
|
+
}
|
|
1517
|
+
});
|
|
1518
|
+
transaction.set(ref, storedJob(next));
|
|
1519
|
+
return next;
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Writes chunks until the budget runs out (see the block header). The first
|
|
1524
|
+
* call needs every required acknowledgement; later calls — the browser's
|
|
1525
|
+
* next, or the sweep's — resume at the cursor. A plugin `apply` that throws
|
|
1526
|
+
* fails the job with the chunk named, and calling again resumes it.
|
|
1527
|
+
*/ export async function applyTransferJob(deps, input) {
|
|
1528
|
+
var _ref, _input_acknowledged, _ref1, _job_chunkCount;
|
|
1529
|
+
var _job_cursor;
|
|
1530
|
+
let job = await readJob(deps, input.orgId, input.jobId);
|
|
1531
|
+
if (job.status === 'applied') {
|
|
1532
|
+
return {
|
|
1533
|
+
job,
|
|
1534
|
+
progress: transferJobProgress(job),
|
|
1535
|
+
done: true,
|
|
1536
|
+
started: false,
|
|
1537
|
+
resumed: false,
|
|
1538
|
+
results: []
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
if (job.status !== 'planned' && job.status !== 'applying' && job.status !== 'failed') {
|
|
1542
|
+
throw new TransferEngineError('state', 409, `This import is ${job.status}; plan it before applying.`);
|
|
1543
|
+
}
|
|
1544
|
+
if (job.plannedAt === undefined || !job.summary) {
|
|
1545
|
+
throw new TransferEngineError('state', 409, 'This import has no dry run yet.');
|
|
1546
|
+
}
|
|
1547
|
+
const acknowledged = [
|
|
1548
|
+
...(_ref = (_input_acknowledged = input.acknowledged) != null ? _input_acknowledged : job.acknowledged) != null ? _ref : []
|
|
1549
|
+
];
|
|
1550
|
+
if (job.applyStartedAt === undefined) {
|
|
1551
|
+
var _job_acknowledgementsRequired;
|
|
1552
|
+
const plan = {
|
|
1553
|
+
acknowledgementsRequired: (_job_acknowledgementsRequired = job.acknowledgementsRequired) != null ? _job_acknowledgementsRequired : [],
|
|
1554
|
+
summary: job.summary
|
|
1555
|
+
};
|
|
1556
|
+
if (!canApplyTransferPlan(plan, acknowledged)) {
|
|
1557
|
+
const missing = missingAcknowledgements(plan, acknowledged);
|
|
1558
|
+
if (!missing.length) throw new TransferEngineError('state', 409, 'The dry run writes nothing.');
|
|
1559
|
+
throw new TransferEngineError('acknowledgementsMissing', 422, 'Acknowledge every warning before applying.', {
|
|
1560
|
+
missing
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
const resource = await resolveResource(deps, job.resource);
|
|
1565
|
+
const hooks = transferRecordsHooks(resource);
|
|
1566
|
+
const ctx = contextFor(job, input.actorUid);
|
|
1567
|
+
let started = false;
|
|
1568
|
+
let resumed = false;
|
|
1569
|
+
job = await takeLease(deps, job, input.driver, input.deadlineMs + TRANSFER_LEASE_GRACE_MS, (current, now)=>{
|
|
1570
|
+
var _current_cursor, _current_applyStartedAt, _current_results;
|
|
1571
|
+
if (current.status === 'applying') return current;
|
|
1572
|
+
started = true;
|
|
1573
|
+
resumed = current.status === 'failed';
|
|
1574
|
+
return moveJob(current, 'applying', now, {
|
|
1575
|
+
acknowledged: current.applyStartedAt === undefined ? acknowledged : current.acknowledged,
|
|
1576
|
+
cursor: (_current_cursor = current.cursor) != null ? _current_cursor : {
|
|
1577
|
+
chunk: 0,
|
|
1578
|
+
rowsDone: 0
|
|
1579
|
+
},
|
|
1580
|
+
applyStartedAt: (_current_applyStartedAt = current.applyStartedAt) != null ? _current_applyStartedAt : now,
|
|
1581
|
+
results: (_current_results = current.results) != null ? _current_results : emptyResults()
|
|
1582
|
+
});
|
|
1583
|
+
});
|
|
1584
|
+
const jobRef = transferJobsCollection(deps.firestore, job.orgId).doc(job.id);
|
|
1585
|
+
const timeLeft = ()=>input.deadlineMs - clock(deps);
|
|
1586
|
+
const written = [];
|
|
1587
|
+
try {
|
|
1588
|
+
var _job_chunkCount1, _ref2;
|
|
1589
|
+
var _job_cursor1;
|
|
1590
|
+
if (!job.picklistsAdded) {
|
|
1591
|
+
var _job_picklistAdditions;
|
|
1592
|
+
for (const [picklistId, values] of Object.entries((_job_picklistAdditions = job.picklistAdditions) != null ? _job_picklistAdditions : {})){
|
|
1593
|
+
if (values.length) await (hooks.addPicklistValues == null ? void 0 : hooks.addPicklistValues.call(hooks, ctx, picklistId, values));
|
|
1594
|
+
}
|
|
1595
|
+
job = _extends({}, job, {
|
|
1596
|
+
picklistsAdded: true,
|
|
1597
|
+
updatedAt: clock(deps)
|
|
1598
|
+
});
|
|
1599
|
+
await saveJob(deps, job);
|
|
1600
|
+
}
|
|
1601
|
+
const chunkCount = (_job_chunkCount1 = job.chunkCount) != null ? _job_chunkCount1 : 0;
|
|
1602
|
+
while(((_ref2 = (_job_cursor1 = job.cursor) == null ? void 0 : _job_cursor1.chunk) != null ? _ref2 : 0) < chunkCount && timeLeft() > TRANSFER_MIN_CHUNK_BUDGET_MS){
|
|
1603
|
+
const next = await applyChunk(deps, job, jobRef, hooks, ctx, timeLeft);
|
|
1604
|
+
if (!next) break;
|
|
1605
|
+
job = next.job;
|
|
1606
|
+
written.push(...next.results);
|
|
1607
|
+
}
|
|
1608
|
+
} catch (error) {
|
|
1609
|
+
var _ref3;
|
|
1610
|
+
var _job_cursor2;
|
|
1611
|
+
const chunk = (_ref3 = (_job_cursor2 = job.cursor) == null ? void 0 : _job_cursor2.chunk) != null ? _ref3 : 0;
|
|
1612
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1613
|
+
job = moveJob(job, 'failed', clock(deps), {
|
|
1614
|
+
error: {
|
|
1615
|
+
code: 'applyFailed',
|
|
1616
|
+
message: `Chunk ${chunk + 1} could not be written: ${message}`,
|
|
1617
|
+
chunk
|
|
1618
|
+
},
|
|
1619
|
+
lease: null
|
|
1620
|
+
});
|
|
1621
|
+
await saveJob(deps, job);
|
|
1622
|
+
return {
|
|
1623
|
+
job,
|
|
1624
|
+
progress: transferJobProgress(job),
|
|
1625
|
+
done: false,
|
|
1626
|
+
started,
|
|
1627
|
+
resumed,
|
|
1628
|
+
results: written
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
const done = ((_ref1 = (_job_cursor = job.cursor) == null ? void 0 : _job_cursor.chunk) != null ? _ref1 : 0) >= ((_job_chunkCount = job.chunkCount) != null ? _job_chunkCount : 0);
|
|
1632
|
+
job = done ? moveJob(job, 'applied', clock(deps), {
|
|
1633
|
+
lease: null
|
|
1634
|
+
}) : _extends({}, job, {
|
|
1635
|
+
lease: null,
|
|
1636
|
+
updatedAt: clock(deps)
|
|
1637
|
+
});
|
|
1638
|
+
await saveJob(deps, job);
|
|
1639
|
+
return {
|
|
1640
|
+
job,
|
|
1641
|
+
progress: transferJobProgress(job),
|
|
1642
|
+
done,
|
|
1643
|
+
started,
|
|
1644
|
+
resumed,
|
|
1645
|
+
results: written
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
/**
|
|
1649
|
+
* One chunk, through the plugin's `apply` and the ledger. Answers the job
|
|
1650
|
+
* with the cursor moved past the chunk and the chunk's results, or `null`
|
|
1651
|
+
* when the chunk is not complete yet (the budget ran short and the plugin
|
|
1652
|
+
* stopped at a row).
|
|
1653
|
+
*/ async function applyChunk(deps, job, jobRef, hooks, ctx, timeLeft) {
|
|
1654
|
+
var _ref, _job_results;
|
|
1655
|
+
var _job_cursor;
|
|
1656
|
+
const index = (_ref = (_job_cursor = job.cursor) == null ? void 0 : _job_cursor.chunk) != null ? _ref : 0;
|
|
1657
|
+
const chunk = await readChunkRows(jobRef, index);
|
|
1658
|
+
if (!chunk) throw new Error(`the dry run has no chunk ${index + 1}`);
|
|
1659
|
+
const ledgerCollection = jobRef.collection('ledger');
|
|
1660
|
+
const ledger = new Map();
|
|
1661
|
+
for (const doc of (await ledgerCollection.where('chunk', '==', index).get()).docs){
|
|
1662
|
+
const data = doc.data();
|
|
1663
|
+
ledger.set(data.row, {
|
|
1664
|
+
result: data.result,
|
|
1665
|
+
undo: data.undoJson ? JSON.parse(data.undoJson) : null
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
const writer = {
|
|
1669
|
+
async alreadyApplied (row) {
|
|
1670
|
+
var _ref;
|
|
1671
|
+
var _ledger_get;
|
|
1672
|
+
return (_ref = (_ledger_get = ledger.get(row)) == null ? void 0 : _ledger_get.result) != null ? _ref : null;
|
|
1673
|
+
},
|
|
1674
|
+
async markApplied (result, undo) {
|
|
1675
|
+
if (ledger.has(result.row)) return;
|
|
1676
|
+
try {
|
|
1677
|
+
await ledgerCollection.doc(transferLedgerKey(job.id, result.row)).create(stored({
|
|
1678
|
+
row: result.row,
|
|
1679
|
+
chunk: index,
|
|
1680
|
+
result,
|
|
1681
|
+
undoJson: undo ? JSON.stringify(undo) : null,
|
|
1682
|
+
at: clock(deps)
|
|
1683
|
+
}));
|
|
1684
|
+
} catch (error) {
|
|
1685
|
+
if (!isAlreadyExists(error)) throw error;
|
|
1686
|
+
}
|
|
1687
|
+
ledger.set(result.row, {
|
|
1688
|
+
result,
|
|
1689
|
+
undo: undo != null ? undo : null
|
|
1690
|
+
});
|
|
1691
|
+
},
|
|
1692
|
+
timeLeftMs: timeLeft
|
|
1693
|
+
};
|
|
1694
|
+
const writes = chunk.rows.filter((row)=>row.verdict === 'create' || row.verdict === 'update');
|
|
1695
|
+
if (writes.some((row)=>!ledger.has(row.index))) {
|
|
1696
|
+
const applied = await hooks.apply(ctx, {
|
|
1697
|
+
jobId: job.id,
|
|
1698
|
+
index,
|
|
1699
|
+
start: chunk.range.start,
|
|
1700
|
+
end: chunk.range.end,
|
|
1701
|
+
rows: writes
|
|
1702
|
+
}, writer);
|
|
1703
|
+
const planned = new Set(writes.map((row)=>row.index));
|
|
1704
|
+
for (const result of applied.results){
|
|
1705
|
+
if (!planned.has(result.row) || ledger.has(result.row)) continue;
|
|
1706
|
+
await writer.markApplied(result, applied.undo.find((entry)=>entry.row === result.row));
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
if (writes.some((row)=>!ledger.has(row.index))) return null;
|
|
1710
|
+
const results = chunk.rows.map((row)=>row.verdict === 'create' || row.verdict === 'update' ? ledger.get(row.index).result : plannedResult(row));
|
|
1711
|
+
const undo = writes.map((row)=>{
|
|
1712
|
+
var _ref;
|
|
1713
|
+
var _ledger_get;
|
|
1714
|
+
return (_ref = (_ledger_get = ledger.get(row.index)) == null ? void 0 : _ledger_get.undo) != null ? _ref : null;
|
|
1715
|
+
}).filter((entry)=>entry !== null);
|
|
1716
|
+
const now = clock(deps);
|
|
1717
|
+
const next = _extends({}, job, {
|
|
1718
|
+
cursor: {
|
|
1719
|
+
chunk: index + 1,
|
|
1720
|
+
rowsDone: chunk.range.end
|
|
1721
|
+
},
|
|
1722
|
+
results: addResults((_job_results = job.results) != null ? _job_results : emptyResults(), summarizeTransferResults(results)),
|
|
1723
|
+
updatedAt: now
|
|
1724
|
+
});
|
|
1725
|
+
const undoJson = JSON.stringify(undo);
|
|
1726
|
+
const batch = deps.firestore.batch();
|
|
1727
|
+
batch.set(jobRef.collection('results').doc(String(index)), stored({
|
|
1728
|
+
jobId: job.id,
|
|
1729
|
+
index,
|
|
1730
|
+
results,
|
|
1731
|
+
completedAt: now
|
|
1732
|
+
}));
|
|
1733
|
+
if (undoJson.length <= JSON_PIECE_CHARS) {
|
|
1734
|
+
batch.set(jobRef.collection('undo').doc(String(index)), {
|
|
1735
|
+
jobId: job.id,
|
|
1736
|
+
chunk: index,
|
|
1737
|
+
json: undoJson
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
batch.set(jobRef, storedJob(next));
|
|
1741
|
+
if (undoJson.length > JSON_PIECE_CHARS) {
|
|
1742
|
+
// Too large for the batch: the undo snapshot is written whole first, so
|
|
1743
|
+
// the cursor never moves past a chunk whose undo is missing.
|
|
1744
|
+
await writeJsonDoc(jobRef.collection('undo').doc(String(index)), {
|
|
1745
|
+
jobId: job.id,
|
|
1746
|
+
chunk: index
|
|
1747
|
+
}, undo);
|
|
1748
|
+
}
|
|
1749
|
+
await batch.commit();
|
|
1750
|
+
// The ledger has done its work once the results are committed.
|
|
1751
|
+
const entries = (await ledgerCollection.where('chunk', '==', index).get()).docs;
|
|
1752
|
+
await eachLimited(entries, TRANSFER_WRITE_CONCURRENCY, async (doc)=>{
|
|
1753
|
+
await doc.ref.delete();
|
|
1754
|
+
});
|
|
1755
|
+
return {
|
|
1756
|
+
job: next,
|
|
1757
|
+
results
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
/*==========================================
|
|
1761
|
+
* STATUS AND THE RESULT FILE
|
|
1762
|
+
*=========================================*/ /** The job, its progress, and whether undo is open. */ export async function readTransferJobStatus(deps, input) {
|
|
1763
|
+
var _job_undo;
|
|
1764
|
+
var _job_undo1;
|
|
1765
|
+
const job = await readJob(deps, input.orgId, input.jobId);
|
|
1766
|
+
const rows = input.include === 'results' ? [
|
|
1767
|
+
...(await readResults(transferJobsCollection(deps.firestore, job.orgId).doc(job.id))).values()
|
|
1768
|
+
].sort((a, b)=>a.row - b.row) : undefined;
|
|
1769
|
+
return _extends({}, rows ? {
|
|
1770
|
+
rows
|
|
1771
|
+
} : {}, {
|
|
1772
|
+
job,
|
|
1773
|
+
progress: transferJobProgress(job),
|
|
1774
|
+
undo: {
|
|
1775
|
+
available: transferUndoAvailable(job, clock(deps)) && ((_job_undo1 = job.undo) == null ? void 0 : _job_undo1.status) !== 'done',
|
|
1776
|
+
expiresAt: transferUndoExpiresAt(job),
|
|
1777
|
+
state: (_job_undo = job.undo) != null ? _job_undo : null
|
|
1778
|
+
}
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
async function readResults(jobRef) {
|
|
1782
|
+
const results = new Map();
|
|
1783
|
+
for (const doc of (await jobRef.collection('results').get()).docs){
|
|
1784
|
+
var _doc_data_results;
|
|
1785
|
+
for (const result of (_doc_data_results = doc.data().results) != null ? _doc_data_results : [])results.set(result.row, result);
|
|
1786
|
+
}
|
|
1787
|
+
return results;
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* The result file: every row of the file as it was sent, then what happened
|
|
1791
|
+
* to it, why, and the record it wrote. A row not written yet says `pending`.
|
|
1792
|
+
*/ export async function transferResultFile(deps, input) {
|
|
1793
|
+
var _job_fileName;
|
|
1794
|
+
const job = await readJob(deps, input.orgId, input.jobId);
|
|
1795
|
+
if (job.applyStartedAt === undefined) throw new TransferEngineError('state', 409, 'Nothing has been written yet.');
|
|
1796
|
+
if (job.kind === 'package') {
|
|
1797
|
+
const { transferPackageResultFile } = await import("./transfer-packages.js");
|
|
1798
|
+
return transferPackageResultFile(deps, job);
|
|
1799
|
+
}
|
|
1800
|
+
if (job.trimmedAt !== undefined) {
|
|
1801
|
+
throw new TransferEngineError('state', 409, 'The file behind this import was cleared once its undo window closed; its results are still counted.');
|
|
1802
|
+
}
|
|
1803
|
+
const table = await loadTable(deps, job);
|
|
1804
|
+
const results = await readResults(transferJobsCollection(deps.firestore, job.orgId).doc(job.id));
|
|
1805
|
+
// Every cell through the one escaper, so a formula the file carried is not
|
|
1806
|
+
// armed again in the copy handed back (a number from a JSON file stays one).
|
|
1807
|
+
const lines = [
|
|
1808
|
+
[
|
|
1809
|
+
...table.headers,
|
|
1810
|
+
...TRANSFER_RESULT_COLUMNS
|
|
1811
|
+
].map((cell)=>escapeCsvCell(cell)).join(',')
|
|
1812
|
+
];
|
|
1813
|
+
table.rows.forEach((cells, row)=>{
|
|
1814
|
+
var _result_message, _result_recordId;
|
|
1815
|
+
const result = results.get(row);
|
|
1816
|
+
const tail = result ? [
|
|
1817
|
+
result.outcome,
|
|
1818
|
+
(_result_message = result.message) != null ? _result_message : result.reason ? String(result.reason) : '',
|
|
1819
|
+
(_result_recordId = result.recordId) != null ? _result_recordId : ''
|
|
1820
|
+
] : [
|
|
1821
|
+
'pending',
|
|
1822
|
+
'',
|
|
1823
|
+
''
|
|
1824
|
+
];
|
|
1825
|
+
lines.push([
|
|
1826
|
+
...table.headers.map((_header, column)=>escapeCsvCell(transferCellText(cells[column]), {
|
|
1827
|
+
numeric: typeof cells[column] === 'number'
|
|
1828
|
+
})),
|
|
1829
|
+
...tail.map((cell)=>escapeCsvCell(cell))
|
|
1830
|
+
].join(','));
|
|
1831
|
+
});
|
|
1832
|
+
const base = ((_job_fileName = job.fileName) != null ? _job_fileName : 'import').replace(/\.[a-z0-9]+$/i, '');
|
|
1833
|
+
return {
|
|
1834
|
+
csv: `${lines.join('\r\n')}\r\n`,
|
|
1835
|
+
rows: table.rows.length,
|
|
1836
|
+
fileName: `${base}-results.csv`
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
/*==========================================
|
|
1840
|
+
* THE HISTORY — the workspace's imports, for the hub (AGL-3535)
|
|
1841
|
+
*=========================================*/ /**
|
|
1842
|
+
* The workspace's imports, newest first, a page at a time, as the hub's
|
|
1843
|
+
* history lists them. Clients cannot read the jobs' subcollections, and
|
|
1844
|
+
* the hub needs more than the job document says — whether undo is still
|
|
1845
|
+
* open, whether the result file can still be made — so the route answers
|
|
1846
|
+
* this. `labels` names each resource; `emailsOf` says who each member is.
|
|
1847
|
+
*/ export async function listTransferJobs(deps, input) {
|
|
1848
|
+
var _input_limit;
|
|
1849
|
+
const limit = Math.min(TRANSFER_JOBS_PAGE_MAX, Math.max(1, Math.floor(Number((_input_limit = input.limit) != null ? _input_limit : 20)) || 20));
|
|
1850
|
+
let query = transferJobsCollection(deps.firestore, input.orgId).orderBy('createdAt', 'desc');
|
|
1851
|
+
if (typeof input.after === 'number' && Number.isFinite(input.after)) query = query.where('createdAt', '<', input.after);
|
|
1852
|
+
const snapshot = await query.limit(limit + 1).get();
|
|
1853
|
+
const page = snapshot.docs.slice(0, limit).map((doc)=>_extends({}, doc.data(), {
|
|
1854
|
+
id: doc.id
|
|
1855
|
+
}));
|
|
1856
|
+
const uids = [
|
|
1857
|
+
...new Set(page.map((job)=>job.createdBy).filter(Boolean))
|
|
1858
|
+
];
|
|
1859
|
+
const emails = input.emailsOf && uids.length ? await input.emailsOf(uids).catch(()=>new Map()) : new Map();
|
|
1860
|
+
const now = clock(deps);
|
|
1861
|
+
const jobs = page.map((job)=>{
|
|
1862
|
+
var _ref, _ref1, _job_hostId, _job_fileName, _emails_get, _job_rowCount, _job_results, _job_appliedAt, _ref2, _ref3;
|
|
1863
|
+
var _input_labels, _input_labels1, _job_undo, _job_undo1, _job_error;
|
|
1864
|
+
return {
|
|
1865
|
+
id: job.id,
|
|
1866
|
+
resource: job.resource,
|
|
1867
|
+
// One instance's job (`data.dataset:<id>`) is named by its resource.
|
|
1868
|
+
label: (_ref = (_ref1 = (_input_labels = input.labels) == null ? void 0 : _input_labels[job.resource]) != null ? _ref1 : (_input_labels1 = input.labels) == null ? void 0 : _input_labels1[parseTransferResourceKey(job.resource).key]) != null ? _ref : job.kind === 'package' ? 'Package' : job.resource,
|
|
1869
|
+
kind: job.kind,
|
|
1870
|
+
status: job.status,
|
|
1871
|
+
hostId: (_job_hostId = job.hostId) != null ? _job_hostId : null,
|
|
1872
|
+
fileName: (_job_fileName = job.fileName) != null ? _job_fileName : null,
|
|
1873
|
+
createdAt: job.createdAt,
|
|
1874
|
+
updatedAt: job.updatedAt,
|
|
1875
|
+
createdBy: job.createdBy,
|
|
1876
|
+
createdByEmail: (_emails_get = emails.get(job.createdBy)) != null ? _emails_get : null,
|
|
1877
|
+
rowCount: (_job_rowCount = job.rowCount) != null ? _job_rowCount : 0,
|
|
1878
|
+
results: (_job_results = job.results) != null ? _job_results : null,
|
|
1879
|
+
appliedAt: (_job_appliedAt = job.appliedAt) != null ? _job_appliedAt : null,
|
|
1880
|
+
undo: {
|
|
1881
|
+
available: transferUndoAvailable(job, now) && ((_job_undo = job.undo) == null ? void 0 : _job_undo.status) !== 'done',
|
|
1882
|
+
expiresAt: transferUndoExpiresAt(job),
|
|
1883
|
+
status: (_ref2 = (_job_undo1 = job.undo) == null ? void 0 : _job_undo1.status) != null ? _ref2 : null
|
|
1884
|
+
},
|
|
1885
|
+
resultFile: job.applyStartedAt !== undefined && job.trimmedAt === undefined,
|
|
1886
|
+
error: (_ref3 = (_job_error = job.error) == null ? void 0 : _job_error.message) != null ? _ref3 : null
|
|
1887
|
+
};
|
|
1888
|
+
});
|
|
1889
|
+
const last = page[page.length - 1];
|
|
1890
|
+
return {
|
|
1891
|
+
jobs,
|
|
1892
|
+
next: snapshot.docs.length > limit && last ? last.createdAt : null
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
/*==========================================
|
|
1896
|
+
* UNFINISHED IMPORTS (AGL-3549)
|
|
1897
|
+
*
|
|
1898
|
+
* An import the person left before it wrote anything — a tab closed
|
|
1899
|
+
* mid-wizard — is a job whose retention is `expire`: the cleanup deletes it
|
|
1900
|
+
* seven days after it was last touched. Until then the surface that started
|
|
1901
|
+
* it, and the hub, offer it back (`openImport({ jobId })` reopens the
|
|
1902
|
+
* wizard on its step) or throw it away now. Only its own creator's: a
|
|
1903
|
+
* colleague's half-made import is theirs to finish, and a statement of
|
|
1904
|
+
* permission made in it is theirs alone.
|
|
1905
|
+
*=========================================*/ const UNFINISHED_STATUSES = new Set([
|
|
1906
|
+
'draft',
|
|
1907
|
+
'analyzed',
|
|
1908
|
+
'planned',
|
|
1909
|
+
'failed'
|
|
1910
|
+
]);
|
|
1911
|
+
/**
|
|
1912
|
+
* The caller's unfinished imports of records, newest first — a Firestore
|
|
1913
|
+
* query on `(createdBy, retention, updatedAt ↓)`, never a page of the
|
|
1914
|
+
* history filtered.
|
|
1915
|
+
*/ export async function listUnfinishedTransferJobs(deps, input) {
|
|
1916
|
+
const snapshot = await transferJobsCollection(deps.firestore, input.orgId).where('createdBy', '==', input.actorUid).where('retention', '==', 'expire').orderBy('updatedAt', 'desc').limit(TRANSFER_UNFINISHED_IMPORTS_MAX).get();
|
|
1917
|
+
return snapshot.docs.map((doc)=>_extends({}, doc.data(), {
|
|
1918
|
+
id: doc.id
|
|
1919
|
+
})).filter((job)=>job.kind !== 'package' && UNFINISHED_STATUSES.has(job.status)).map((job)=>{
|
|
1920
|
+
var _job_hostId, _job_fileName, _ref, _ref1;
|
|
1921
|
+
var _input_labels, _input_labels1;
|
|
1922
|
+
return {
|
|
1923
|
+
jobId: job.id,
|
|
1924
|
+
resource: job.resource,
|
|
1925
|
+
hostId: (_job_hostId = job.hostId) != null ? _job_hostId : null,
|
|
1926
|
+
fileName: (_job_fileName = job.fileName) != null ? _job_fileName : null,
|
|
1927
|
+
status: job.status,
|
|
1928
|
+
updatedAt: job.updatedAt,
|
|
1929
|
+
label: (_ref = (_ref1 = (_input_labels = input.labels) == null ? void 0 : _input_labels[job.resource]) != null ? _ref1 : (_input_labels1 = input.labels) == null ? void 0 : _input_labels1[parseTransferResourceKey(job.resource).key]) != null ? _ref : job.resource
|
|
1930
|
+
};
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
/**
|
|
1934
|
+
* Throws one of the caller's unfinished imports away now: its job, its
|
|
1935
|
+
* pieces and its file, as the cleanup would a week on. Refused for someone
|
|
1936
|
+
* else's import, and for one that has written anything — that is undone.
|
|
1937
|
+
*/ export async function discardTransferJob(deps, input) {
|
|
1938
|
+
var _transferJobRetention;
|
|
1939
|
+
const job = await readJob(deps, input.orgId, input.jobId);
|
|
1940
|
+
if (job.createdBy !== input.actorUid) {
|
|
1941
|
+
throw new TransferEngineError('forbidden', 403, 'Only the person who started an import can discard it.');
|
|
1942
|
+
}
|
|
1943
|
+
if (((_transferJobRetention = transferJobRetention(job)) == null ? void 0 : _transferJobRetention.retention) !== 'expire') {
|
|
1944
|
+
throw new TransferEngineError('state', 409, 'This import has written records; undo it instead of discarding it.');
|
|
1945
|
+
}
|
|
1946
|
+
await expireTransferJob(deps, job);
|
|
1947
|
+
}
|
|
1948
|
+
/*==========================================
|
|
1949
|
+
* UNDO
|
|
1950
|
+
*=========================================*/ async function readUndoSnapshots(jobRef, chunkCount, from = 0) {
|
|
1951
|
+
const snapshots = [];
|
|
1952
|
+
for(let index = from; index < chunkCount; index += 1){
|
|
1953
|
+
const entries = await readJsonDoc(jobRef.collection('undo').doc(String(index)));
|
|
1954
|
+
snapshots.push({
|
|
1955
|
+
jobId: jobRef.id,
|
|
1956
|
+
chunk: index,
|
|
1957
|
+
entries: entries != null ? entries : []
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
return snapshots;
|
|
1961
|
+
}
|
|
1962
|
+
function assertUndoOpen(job, now) {
|
|
1963
|
+
var _job_undo;
|
|
1964
|
+
if (job.status !== 'applied') throw new TransferEngineError('state', 409, `This import is ${job.status}; only an applied import can be undone.`);
|
|
1965
|
+
if (((_job_undo = job.undo) == null ? void 0 : _job_undo.status) === 'done') throw new TransferEngineError('state', 409, 'This import was already undone.');
|
|
1966
|
+
if (!job.undo && !transferUndoAvailable(job, now)) {
|
|
1967
|
+
throw new TransferEngineError('undoExpired', 410, 'An import can be undone for seven days; this one is older.');
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
/**
|
|
1971
|
+
* What undo would do, writing nothing: every record the import touched,
|
|
1972
|
+
* read as it is now, and planned with the core's `planTransferUndo`. A
|
|
1973
|
+
* record edited since comes back as a conflict, with what it holds now and
|
|
1974
|
+
* what undo would put back, for the person to decide.
|
|
1975
|
+
*/ export async function planTransferJobUndo(deps, input) {
|
|
1976
|
+
var _job_chunkCount, _ref, _input_offset, _input_limit;
|
|
1977
|
+
var _job_undo;
|
|
1978
|
+
const job = await readJob(deps, input.orgId, input.jobId);
|
|
1979
|
+
assertUndoOpen(job, clock(deps));
|
|
1980
|
+
const resource = await resolveResource(deps, job.resource);
|
|
1981
|
+
const hooks = transferRecordsHooks(resource);
|
|
1982
|
+
const ctx = contextFor(job, input.actorUid);
|
|
1983
|
+
const jobRef = transferJobsCollection(deps.firestore, job.orgId).doc(job.id);
|
|
1984
|
+
const snapshots = await readUndoSnapshots(jobRef, (_job_chunkCount = job.chunkCount) != null ? _job_chunkCount : 0, (_ref = (_job_undo = job.undo) == null ? void 0 : _job_undo.chunk) != null ? _ref : 0);
|
|
1985
|
+
const entries = snapshots.flatMap((snapshot)=>snapshot.entries);
|
|
1986
|
+
const ids = [
|
|
1987
|
+
...new Set(entries.map((entry)=>entry.recordId))
|
|
1988
|
+
];
|
|
1989
|
+
const records = new Map();
|
|
1990
|
+
for(let at = 0; at < ids.length; at += UNDO_LOOKUP_SLICE){
|
|
1991
|
+
const found = await hooks.lookup(ctx, [
|
|
1992
|
+
{
|
|
1993
|
+
fieldId: TRANSFER_ID_FIELD,
|
|
1994
|
+
normalizer: 'aglynId',
|
|
1995
|
+
values: ids.slice(at, at + UNDO_LOOKUP_SLICE)
|
|
1996
|
+
}
|
|
1997
|
+
]);
|
|
1998
|
+
for (const [id, values] of found.records)records.set(id, values);
|
|
1999
|
+
}
|
|
2000
|
+
const counts = emptyUndoCounts();
|
|
2001
|
+
const conflicts = [];
|
|
2002
|
+
for (const entry of entries){
|
|
2003
|
+
var _records_get;
|
|
2004
|
+
const current = (_records_get = records.get(entry.recordId)) != null ? _records_get : null;
|
|
2005
|
+
const step = planTransferUndo(entry, current);
|
|
2006
|
+
if (step.action === 'conflict') {
|
|
2007
|
+
counts.conflict += 1;
|
|
2008
|
+
const label = transferRowLabel(current);
|
|
2009
|
+
conflicts.push(_extends({
|
|
2010
|
+
row: entry.row,
|
|
2011
|
+
recordId: entry.recordId
|
|
2012
|
+
}, label ? {
|
|
2013
|
+
label
|
|
2014
|
+
} : {}, {
|
|
2015
|
+
action: entry.action,
|
|
2016
|
+
fields: step.fields,
|
|
2017
|
+
current: Object.fromEntries(step.fields.map((fieldId)=>{
|
|
2018
|
+
var _ref;
|
|
2019
|
+
return [
|
|
2020
|
+
fieldId,
|
|
2021
|
+
(_ref = current == null ? void 0 : current[fieldId]) != null ? _ref : null
|
|
2022
|
+
];
|
|
2023
|
+
})),
|
|
2024
|
+
restore: step.values
|
|
2025
|
+
}));
|
|
2026
|
+
} else if (step.action === 'restore') counts.restore += 1;
|
|
2027
|
+
else if (step.action === 'delete') counts.delete += 1;
|
|
2028
|
+
else counts.nothing += 1;
|
|
2029
|
+
}
|
|
2030
|
+
const offset = Math.max(0, Math.floor(Number((_input_offset = input.offset) != null ? _input_offset : 0)) || 0);
|
|
2031
|
+
const limit = Math.min(TRANSFER_UNDO_PAGE_MAX, Math.max(1, Math.floor(Number((_input_limit = input.limit) != null ? _input_limit : 50)) || 50));
|
|
2032
|
+
const page = conflicts.slice(offset, offset + limit);
|
|
2033
|
+
return {
|
|
2034
|
+
job,
|
|
2035
|
+
counts,
|
|
2036
|
+
conflicts: page,
|
|
2037
|
+
offset,
|
|
2038
|
+
next: offset + page.length < conflicts.length ? offset + page.length : null,
|
|
2039
|
+
expiresAt: transferUndoExpiresAt(job)
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
/**
|
|
2043
|
+
* Carries undo out, chunk by chunk, through the plugin's `revert`, each
|
|
2044
|
+
* record with the person's decision (`otherwise` for one the decisions do
|
|
2045
|
+
* not name). Called again until `done`; the last chunk moves the job to
|
|
2046
|
+
* `undone`. Reverting a chunk twice is harmless: a field already restored
|
|
2047
|
+
* plans as nothing.
|
|
2048
|
+
*/ export async function applyTransferJobUndo(deps, input) {
|
|
2049
|
+
var _input_decisions, _job_chunkCount, _undo_decisions, _input_decisions1;
|
|
2050
|
+
if (input.otherwise !== 'keep' && input.otherwise !== 'revert') {
|
|
2051
|
+
throw new TransferEngineError('invalid', 400, 'Say what happens to a record edited since the import.');
|
|
2052
|
+
}
|
|
2053
|
+
let job = await readJob(deps, input.orgId, input.jobId);
|
|
2054
|
+
assertUndoOpen(job, clock(deps));
|
|
2055
|
+
const resource = await resolveResource(deps, job.resource);
|
|
2056
|
+
const hooks = transferRecordsHooks(resource);
|
|
2057
|
+
const ctx = contextFor(job, input.actorUid);
|
|
2058
|
+
const decisions = _extends({}, (_input_decisions = input.decisions) != null ? _input_decisions : {});
|
|
2059
|
+
let started = false;
|
|
2060
|
+
job = await takeLease(deps, job, input.driver, input.deadlineMs + TRANSFER_LEASE_GRACE_MS, (current, now)=>{
|
|
2061
|
+
if (current.undo) {
|
|
2062
|
+
return _extends({}, current, {
|
|
2063
|
+
undo: _extends({}, current.undo, {
|
|
2064
|
+
otherwise: input.otherwise,
|
|
2065
|
+
decisions: _extends({}, current.undo.decisions, decisions)
|
|
2066
|
+
}),
|
|
2067
|
+
updatedAt: now
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
started = true;
|
|
2071
|
+
return _extends({}, current, {
|
|
2072
|
+
updatedAt: now,
|
|
2073
|
+
undo: {
|
|
2074
|
+
status: 'running',
|
|
2075
|
+
chunk: 0,
|
|
2076
|
+
startedAt: now,
|
|
2077
|
+
startedBy: input.actorUid,
|
|
2078
|
+
otherwise: input.otherwise,
|
|
2079
|
+
decisions,
|
|
2080
|
+
counts: emptyUndoCounts()
|
|
2081
|
+
}
|
|
2082
|
+
});
|
|
2083
|
+
});
|
|
2084
|
+
const jobRef = transferJobsCollection(deps.firestore, job.orgId).doc(job.id);
|
|
2085
|
+
const chunkCount = (_job_chunkCount = job.chunkCount) != null ? _job_chunkCount : 0;
|
|
2086
|
+
let undo = job.undo;
|
|
2087
|
+
// The person's decisions, this call's and every earlier call's: a resumed undo decides the same way.
|
|
2088
|
+
Object.assign(decisions, (_undo_decisions = undo.decisions) != null ? _undo_decisions : {}, (_input_decisions1 = input.decisions) != null ? _input_decisions1 : {});
|
|
2089
|
+
try {
|
|
2090
|
+
while(undo.chunk < chunkCount && input.deadlineMs - clock(deps) > TRANSFER_MIN_CHUNK_BUDGET_MS){
|
|
2091
|
+
const [snapshot] = await readUndoSnapshots(jobRef, undo.chunk + 1, undo.chunk);
|
|
2092
|
+
const counts = _extends({}, undo.counts);
|
|
2093
|
+
if (snapshot && snapshot.entries.length) {
|
|
2094
|
+
const chosen = Object.fromEntries(snapshot.entries.map((entry)=>{
|
|
2095
|
+
var _decisions_entry_recordId;
|
|
2096
|
+
return [
|
|
2097
|
+
entry.recordId,
|
|
2098
|
+
(_decisions_entry_recordId = decisions[entry.recordId]) != null ? _decisions_entry_recordId : input.otherwise
|
|
2099
|
+
];
|
|
2100
|
+
}));
|
|
2101
|
+
const reverted = await hooks.revert(ctx, snapshot, chosen);
|
|
2102
|
+
for (const step of reverted.done)counts[step.action] += 1;
|
|
2103
|
+
counts.conflict += reverted.conflicts.length;
|
|
2104
|
+
}
|
|
2105
|
+
undo = _extends({}, undo, {
|
|
2106
|
+
chunk: undo.chunk + 1,
|
|
2107
|
+
counts
|
|
2108
|
+
});
|
|
2109
|
+
job = _extends({}, job, {
|
|
2110
|
+
undo,
|
|
2111
|
+
updatedAt: clock(deps)
|
|
2112
|
+
});
|
|
2113
|
+
await saveJob(deps, job);
|
|
2114
|
+
}
|
|
2115
|
+
} catch (error) {
|
|
2116
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2117
|
+
job = _extends({}, job, {
|
|
2118
|
+
lease: null,
|
|
2119
|
+
updatedAt: clock(deps),
|
|
2120
|
+
error: {
|
|
2121
|
+
code: 'undoFailed',
|
|
2122
|
+
message,
|
|
2123
|
+
chunk: undo.chunk
|
|
2124
|
+
}
|
|
2125
|
+
});
|
|
2126
|
+
await saveJob(deps, job);
|
|
2127
|
+
throw new TransferEngineError('failed', 500, `Undo stopped at chunk ${undo.chunk + 1}: ${message}. Try again to continue.`);
|
|
2128
|
+
}
|
|
2129
|
+
const done = undo.chunk >= chunkCount;
|
|
2130
|
+
if (done) {
|
|
2131
|
+
undo = _extends({}, undo, {
|
|
2132
|
+
status: 'done'
|
|
2133
|
+
});
|
|
2134
|
+
job = moveJob(_extends({}, job, {
|
|
2135
|
+
undo
|
|
2136
|
+
}), 'undone', clock(deps), {
|
|
2137
|
+
lease: null
|
|
2138
|
+
});
|
|
2139
|
+
} else {
|
|
2140
|
+
job = _extends({}, job, {
|
|
2141
|
+
lease: null,
|
|
2142
|
+
updatedAt: clock(deps)
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
await saveJob(deps, job);
|
|
2146
|
+
return {
|
|
2147
|
+
job,
|
|
2148
|
+
undo,
|
|
2149
|
+
done,
|
|
2150
|
+
started
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
/*==========================================
|
|
2154
|
+
* THE SWEEP — what a closed tab left behind, and what has outlived its use
|
|
2155
|
+
*
|
|
2156
|
+
* - an import left `applying`, or an undo left running, untouched for
|
|
2157
|
+
* {@link TRANSFER_STALE_MS}: resumed through the same engine, under the
|
|
2158
|
+
* sweep's lease (undo with the person's stored decisions);
|
|
2159
|
+
* - a job that never wrote, untouched for seven days
|
|
2160
|
+
* (`TRANSFER_DRAFT_RETENTION_MS`): deleted with its subcollections and
|
|
2161
|
+
* its file;
|
|
2162
|
+
* - a job that wrote, once its seven-day undo window has closed: its dry
|
|
2163
|
+
* run, undo snapshots, ledger and file are cleared, and the job and its
|
|
2164
|
+
* per-row results kept (`trimmedAt`).
|
|
2165
|
+
*
|
|
2166
|
+
* The last two are found by the `retention` and `retainUntil` every job
|
|
2167
|
+
* write stamps (`transferJobRetention`), so a trimmed job never matches
|
|
2168
|
+
* again.
|
|
2169
|
+
*=========================================*/ /** The most jobs one sweep expires or trims. */ const CLEANUP_LIMIT = 50;
|
|
2170
|
+
/** Deletes a Storage object, if it is there. */ async function deleteObject(deps, path) {
|
|
2171
|
+
await deps.bucket.file(path).delete({
|
|
2172
|
+
ignoreNotFound: true
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
/** The job's stored file and any parts still waiting for the rest. */ async function deleteSourceFiles(deps, job) {
|
|
2176
|
+
var _ref;
|
|
2177
|
+
var _job_upload;
|
|
2178
|
+
await deleteObject(deps, transferSourcePath(job.orgId, job.id));
|
|
2179
|
+
const parts = (_ref = (_job_upload = job.upload) == null ? void 0 : _job_upload.parts) != null ? _ref : 0;
|
|
2180
|
+
await eachLimited(Array.from({
|
|
2181
|
+
length: parts > 1 ? parts : 0
|
|
2182
|
+
}, (_unused, index)=>index), TRANSFER_WRITE_CONCURRENCY, async (index)=>deleteObject(deps, transferPartPath(job.orgId, job.id, index)));
|
|
2183
|
+
}
|
|
2184
|
+
/** A job that never wrote, gone: its subcollections, its file, then the job. */ export async function expireTransferJob(deps, job) {
|
|
2185
|
+
const jobRef = transferJobsCollection(deps.firestore, job.orgId).doc(job.id);
|
|
2186
|
+
for (const name of [
|
|
2187
|
+
'chunks',
|
|
2188
|
+
'ledger',
|
|
2189
|
+
'results',
|
|
2190
|
+
'undo'
|
|
2191
|
+
])await clearCollection(jobRef.collection(name));
|
|
2192
|
+
await deleteSourceFiles(deps, job);
|
|
2193
|
+
await jobRef.delete();
|
|
2194
|
+
}
|
|
2195
|
+
/** A job past its undo window, kept as the job and its results: the dry run, undo snapshots, ledger and file cleared. */ export async function trimTransferJob(deps, job) {
|
|
2196
|
+
const jobRef = transferJobsCollection(deps.firestore, job.orgId).doc(job.id);
|
|
2197
|
+
for (const name of [
|
|
2198
|
+
'chunks',
|
|
2199
|
+
'ledger',
|
|
2200
|
+
'undo'
|
|
2201
|
+
])await clearCollection(jobRef.collection(name));
|
|
2202
|
+
await deleteSourceFiles(deps, job);
|
|
2203
|
+
const trimmed = _extends({}, job, {
|
|
2204
|
+
trimmedAt: clock(deps)
|
|
2205
|
+
});
|
|
2206
|
+
delete trimmed.sourcePath;
|
|
2207
|
+
await saveJob(deps, trimmed);
|
|
2208
|
+
return trimmed;
|
|
2209
|
+
}
|
|
2210
|
+
function jobOf(doc) {
|
|
2211
|
+
var _ref, _ref1;
|
|
2212
|
+
var _doc_ref_parent_parent, _doc_ref_parent_parent1;
|
|
2213
|
+
const job = _extends({}, doc.data(), {
|
|
2214
|
+
id: doc.id
|
|
2215
|
+
});
|
|
2216
|
+
return {
|
|
2217
|
+
orgId: (_ref = (_doc_ref_parent_parent = doc.ref.parent.parent) == null ? void 0 : _doc_ref_parent_parent.id) != null ? _ref : job.orgId,
|
|
2218
|
+
job: _extends({}, job, {
|
|
2219
|
+
orgId: (_ref1 = (_doc_ref_parent_parent1 = doc.ref.parent.parent) == null ? void 0 : _doc_ref_parent_parent1.id) != null ? _ref1 : job.orgId
|
|
2220
|
+
})
|
|
2221
|
+
};
|
|
2222
|
+
}
|
|
2223
|
+
/**
|
|
2224
|
+
* The sweep (see the block header). Each resume runs under the sweep's
|
|
2225
|
+
* lease until the budget is spent; the ledger makes overlapping with a
|
|
2226
|
+
* returning browser harmless. `dryRun` lists what it would do and does
|
|
2227
|
+
* nothing.
|
|
2228
|
+
*/ export async function sweepAbandonedTransferJobs(deps, input) {
|
|
2229
|
+
var _input_staleMs, _input_limit, _input_limit1;
|
|
2230
|
+
const now = clock(deps);
|
|
2231
|
+
const stale = now - ((_input_staleMs = input.staleMs) != null ? _input_staleMs : TRANSFER_STALE_MS);
|
|
2232
|
+
const jobs = deps.firestore.collectionGroup(TRANSFER_JOBS_COLLECTION);
|
|
2233
|
+
const budgetLeft = ()=>input.deadlineMs - clock(deps) > TRANSFER_MIN_CHUNK_BUDGET_MS;
|
|
2234
|
+
const snapshot = await jobs.where('status', '==', 'applying').where('updatedAt', '<=', stale).orderBy('updatedAt', 'asc').limit((_input_limit = input.limit) != null ? _input_limit : 10).get();
|
|
2235
|
+
const outcome = {
|
|
2236
|
+
found: snapshot.docs.length,
|
|
2237
|
+
resumed: [],
|
|
2238
|
+
skipped: [],
|
|
2239
|
+
undone: [],
|
|
2240
|
+
expired: [],
|
|
2241
|
+
trimmed: []
|
|
2242
|
+
};
|
|
2243
|
+
for (const doc of snapshot.docs){
|
|
2244
|
+
const { orgId, job } = jobOf(doc);
|
|
2245
|
+
if (input.dryRun) {
|
|
2246
|
+
outcome.skipped.push({
|
|
2247
|
+
orgId,
|
|
2248
|
+
jobId: doc.id,
|
|
2249
|
+
reason: 'dryRun'
|
|
2250
|
+
});
|
|
2251
|
+
continue;
|
|
2252
|
+
}
|
|
2253
|
+
if (!budgetLeft()) {
|
|
2254
|
+
outcome.skipped.push({
|
|
2255
|
+
orgId,
|
|
2256
|
+
jobId: doc.id,
|
|
2257
|
+
reason: 'budget'
|
|
2258
|
+
});
|
|
2259
|
+
continue;
|
|
2260
|
+
}
|
|
2261
|
+
try {
|
|
2262
|
+
const resume = {
|
|
2263
|
+
orgId,
|
|
2264
|
+
jobId: doc.id,
|
|
2265
|
+
actorUid: job.createdBy,
|
|
2266
|
+
deadlineMs: input.deadlineMs,
|
|
2267
|
+
driver: `sweep:${doc.id}`
|
|
2268
|
+
};
|
|
2269
|
+
// A workspace package is resumed by its own engine, under the same lease and ledger.
|
|
2270
|
+
const applied = job.kind === 'package' ? await (await import("./transfer-packages.js")).applyTransferPackage(deps, resume) : await applyTransferJob(deps, resume);
|
|
2271
|
+
outcome.resumed.push({
|
|
2272
|
+
orgId,
|
|
2273
|
+
jobId: doc.id,
|
|
2274
|
+
status: applied.job.status,
|
|
2275
|
+
done: applied.done
|
|
2276
|
+
});
|
|
2277
|
+
} catch (error) {
|
|
2278
|
+
outcome.skipped.push({
|
|
2279
|
+
orgId,
|
|
2280
|
+
jobId: doc.id,
|
|
2281
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
// An undo the browser left part-way, finished with the person's own decisions.
|
|
2286
|
+
const undoing = await jobs.where('undo.status', '==', 'running').where('updatedAt', '<=', stale).orderBy('updatedAt', 'asc').limit((_input_limit1 = input.limit) != null ? _input_limit1 : 10).get();
|
|
2287
|
+
for (const doc of undoing.docs){
|
|
2288
|
+
const { orgId, job } = jobOf(doc);
|
|
2289
|
+
if (input.dryRun || !budgetLeft() || !job.undo) {
|
|
2290
|
+
outcome.skipped.push({
|
|
2291
|
+
orgId,
|
|
2292
|
+
jobId: doc.id,
|
|
2293
|
+
reason: input.dryRun ? 'dryRun' : 'budget'
|
|
2294
|
+
});
|
|
2295
|
+
continue;
|
|
2296
|
+
}
|
|
2297
|
+
try {
|
|
2298
|
+
var _job_undo_decisions;
|
|
2299
|
+
const finish = {
|
|
2300
|
+
orgId,
|
|
2301
|
+
jobId: doc.id,
|
|
2302
|
+
actorUid: job.undo.startedBy,
|
|
2303
|
+
decisions: (_job_undo_decisions = job.undo.decisions) != null ? _job_undo_decisions : {},
|
|
2304
|
+
otherwise: job.undo.otherwise,
|
|
2305
|
+
deadlineMs: input.deadlineMs,
|
|
2306
|
+
driver: `sweep-undo:${doc.id}`
|
|
2307
|
+
};
|
|
2308
|
+
const undone = job.kind === 'package' ? await (await import("./transfer-packages.js")).applyTransferPackageUndo(deps, finish) : await applyTransferJobUndo(deps, finish);
|
|
2309
|
+
outcome.undone.push({
|
|
2310
|
+
orgId,
|
|
2311
|
+
jobId: doc.id,
|
|
2312
|
+
done: undone.done
|
|
2313
|
+
});
|
|
2314
|
+
} catch (error) {
|
|
2315
|
+
outcome.skipped.push({
|
|
2316
|
+
orgId,
|
|
2317
|
+
jobId: doc.id,
|
|
2318
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
// What has outlived its use: drafts that never wrote, and applied jobs past their undo window.
|
|
2323
|
+
for (const retention of [
|
|
2324
|
+
'expire',
|
|
2325
|
+
'trim'
|
|
2326
|
+
]){
|
|
2327
|
+
const due = await jobs.where('retention', '==', retention).where('retainUntil', '<=', now).orderBy('retainUntil', 'asc').limit(CLEANUP_LIMIT).get();
|
|
2328
|
+
for (const doc of due.docs){
|
|
2329
|
+
const { orgId } = jobOf(doc);
|
|
2330
|
+
if (input.dryRun || !budgetLeft()) {
|
|
2331
|
+
outcome.skipped.push({
|
|
2332
|
+
orgId,
|
|
2333
|
+
jobId: doc.id,
|
|
2334
|
+
reason: input.dryRun ? 'dryRun' : 'budget'
|
|
2335
|
+
});
|
|
2336
|
+
continue;
|
|
2337
|
+
}
|
|
2338
|
+
// Read again: the job may have moved on since the query, and is decided as it is now.
|
|
2339
|
+
const current = await readJob(deps, orgId, doc.id).catch(()=>null);
|
|
2340
|
+
const verdict = current ? transferJobRetention(current) : null;
|
|
2341
|
+
if (!current || !verdict || verdict.retention !== retention || verdict.retainUntil > clock(deps)) continue;
|
|
2342
|
+
try {
|
|
2343
|
+
if (retention === 'expire') {
|
|
2344
|
+
await expireTransferJob(deps, current);
|
|
2345
|
+
outcome.expired.push({
|
|
2346
|
+
orgId,
|
|
2347
|
+
jobId: doc.id
|
|
2348
|
+
});
|
|
2349
|
+
} else {
|
|
2350
|
+
await trimTransferJob(deps, current);
|
|
2351
|
+
outcome.trimmed.push({
|
|
2352
|
+
orgId,
|
|
2353
|
+
jobId: doc.id
|
|
2354
|
+
});
|
|
2355
|
+
}
|
|
2356
|
+
} catch (error) {
|
|
2357
|
+
outcome.skipped.push({
|
|
2358
|
+
orgId,
|
|
2359
|
+
jobId: doc.id,
|
|
2360
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
2361
|
+
});
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
return outcome;
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
//# sourceMappingURL=transfer-jobs.js.map
|