@aglyn/tenant-data-admin 1.0.0-beta.221 → 1.0.0-beta.223

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/api-keys.js +3 -3
  3. package/src/lib/server/api-keys.js.map +1 -1
  4. package/src/lib/server/api-v1-resources.d.ts +44 -3
  5. package/src/lib/server/api-v1-resources.js +82 -5
  6. package/src/lib/server/api-v1-resources.js.map +1 -1
  7. package/src/lib/server/collection-live-pages.d.ts +2 -2
  8. package/src/lib/server/collection-live-pages.js +1 -1
  9. package/src/lib/server/collection-live-pages.js.map +1 -1
  10. package/src/lib/server/email-delivery-log.d.ts +17 -73
  11. package/src/lib/server/email-delivery-log.js +44 -222
  12. package/src/lib/server/email-delivery-log.js.map +1 -1
  13. package/src/lib/server/email-flow-gate.js +27 -38
  14. package/src/lib/server/email-flow-gate.js.map +1 -1
  15. package/src/lib/server/erase-person.d.ts +34 -54
  16. package/src/lib/server/erase-person.js +44 -239
  17. package/src/lib/server/erase-person.js.map +1 -1
  18. package/src/lib/server/ga4-measurement-protocol.js +41 -28
  19. package/src/lib/server/ga4-measurement-protocol.js.map +1 -1
  20. package/src/lib/server/host-visitor-records.d.ts +25 -4
  21. package/src/lib/server/host-visitor-records.js +39 -11
  22. package/src/lib/server/host-visitor-records.js.map +1 -1
  23. package/src/lib/server/hosted-page-review.js +52 -36
  24. package/src/lib/server/hosted-page-review.js.map +1 -1
  25. package/src/lib/server/link-reputation-review.d.ts +6 -2
  26. package/src/lib/server/link-reputation-review.js +61 -20
  27. package/src/lib/server/link-reputation-review.js.map +1 -1
  28. package/src/lib/server/live-page-drops.d.ts +67 -0
  29. package/src/lib/server/live-page-drops.js +57 -0
  30. package/src/lib/server/live-page-drops.js.map +1 -0
  31. package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
  32. package/src/lib/server/media-bandwidth-cap.js +180 -0
  33. package/src/lib/server/media-bandwidth-cap.js.map +1 -0
  34. package/src/lib/server/media-cdn-icon.d.ts +77 -0
  35. package/src/lib/server/media-cdn-icon.js +140 -0
  36. package/src/lib/server/media-cdn-icon.js.map +1 -0
  37. package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
  38. package/src/lib/server/media-delivery-regeneration.js +216 -0
  39. package/src/lib/server/media-delivery-regeneration.js.map +1 -0
  40. package/src/lib/server/media-delivery.d.ts +2 -0
  41. package/src/lib/server/media-delivery.js +2 -1
  42. package/src/lib/server/media-delivery.js.map +1 -1
  43. package/src/lib/server/media-serve-count.d.ts +50 -0
  44. package/src/lib/server/media-serve-count.js +62 -0
  45. package/src/lib/server/media-serve-count.js.map +1 -0
  46. package/src/lib/server/media-signing.d.ts +13 -2
  47. package/src/lib/server/media-signing.js +12 -8
  48. package/src/lib/server/media-signing.js.map +1 -1
  49. package/src/lib/server/media-tombstone.js +7 -5
  50. package/src/lib/server/media-tombstone.js.map +1 -1
  51. package/src/lib/server/media-variants.d.ts +151 -20
  52. package/src/lib/server/media-variants.js +204 -27
  53. package/src/lib/server/media-variants.js.map +1 -1
  54. package/src/lib/server/organizations.d.ts +102 -2
  55. package/src/lib/server/organizations.js +388 -58
  56. package/src/lib/server/organizations.js.map +1 -1
  57. package/src/lib/server/outbound-send-review.js +11 -7
  58. package/src/lib/server/outbound-send-review.js.map +1 -1
  59. package/src/lib/server/paged-sweeps.d.ts +51 -0
  60. package/src/lib/server/paged-sweeps.js +79 -0
  61. package/src/lib/server/paged-sweeps.js.map +1 -0
  62. package/src/lib/server/payment-fraud-signal.d.ts +49 -9
  63. package/src/lib/server/payment-fraud-signal.js +192 -48
  64. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  65. package/src/lib/server/platform-marketing-consent.d.ts +0 -49
  66. package/src/lib/server/platform-marketing-consent.js +27 -11
  67. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  68. package/src/lib/server/render-monitor.d.ts +166 -0
  69. package/src/lib/server/render-monitor.js +412 -0
  70. package/src/lib/server/render-monitor.js.map +1 -0
  71. package/src/lib/server/risk-notice.d.ts +7 -0
  72. package/src/lib/server/risk-notice.js +6 -0
  73. package/src/lib/server/risk-notice.js.map +1 -1
  74. package/src/lib/server/serve-media-cdn.d.ts +69 -1
  75. package/src/lib/server/serve-media-cdn.js +424 -70
  76. package/src/lib/server/serve-media-cdn.js.map +1 -1
  77. package/src/lib/server/site-icon-render.d.ts +68 -0
  78. package/src/lib/server/site-icon-render.js +157 -0
  79. package/src/lib/server/site-icon-render.js.map +1 -0
  80. package/src/lib/server/staff-seat.d.ts +50 -0
  81. package/src/lib/server/staff-seat.js +118 -0
  82. package/src/lib/server/staff-seat.js.map +1 -0
  83. package/src/lib/server/transfer-export.d.ts +46 -0
  84. package/src/lib/server/transfer-export.js +221 -0
  85. package/src/lib/server/transfer-export.js.map +1 -0
  86. package/src/lib/server/transfer-jobs.d.ts +385 -0
  87. package/src/lib/server/transfer-jobs.js +2368 -0
  88. package/src/lib/server/transfer-jobs.js.map +1 -0
  89. package/src/lib/server/transfer-packages.d.ts +127 -0
  90. package/src/lib/server/transfer-packages.js +1036 -0
  91. package/src/lib/server/transfer-packages.js.map +1 -0
  92. package/src/lib/server/upgrade-proposal.d.ts +97 -0
  93. package/src/lib/server/upgrade-proposal.js +312 -0
  94. package/src/lib/server/upgrade-proposal.js.map +1 -0
  95. package/src/lib/server/upsert-contact.d.ts +17 -8
  96. package/src/lib/server/upsert-contact.js +82 -16
  97. package/src/lib/server/upsert-contact.js.map +1 -1
  98. package/src/lib/server/web-risk.d.ts +50 -9
  99. package/src/lib/server/web-risk.js +290 -155
  100. package/src/lib/server/web-risk.js.map +1 -1
  101. package/src/tenancy.d.ts +3 -3
  102. package/src/tenancy.js +5 -3
  103. package/src/tenancy.js.map +1 -1
  104. package/src/lib/server/campaign-attribution-store.d.ts +0 -55
  105. package/src/lib/server/campaign-attribution-store.js +0 -95
  106. package/src/lib/server/campaign-attribution-store.js.map +0 -1
  107. package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
  108. package/src/lib/server/campaign-conversion-attribution.js +0 -450
  109. package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
  110. package/src/lib/server/crm-email-activity.d.ts +0 -118
  111. package/src/lib/server/crm-email-activity.js +0 -148
  112. package/src/lib/server/crm-email-activity.js.map +0 -1
  113. package/src/lib/server/crm-next-activity.d.ts +0 -74
  114. package/src/lib/server/crm-next-activity.js +0 -194
  115. package/src/lib/server/crm-next-activity.js.map +0 -1
  116. package/src/lib/server/dataset-live-pages.d.ts +0 -179
  117. package/src/lib/server/dataset-live-pages.js +0 -421
  118. package/src/lib/server/dataset-live-pages.js.map +0 -1
  119. package/src/lib/server/email-revenue-attribution.d.ts +0 -185
  120. package/src/lib/server/email-revenue-attribution.js +0 -321
  121. 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