primitive-admin 1.2.0-alpha.4 → 1.2.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/skill/skills/primitive-platform/SKILL.md +17 -0
- package/dist/src/commands/auth.js +7 -4
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/connections.js +11 -12
- package/dist/src/commands/connections.js.map +1 -1
- package/dist/src/commands/database-type-configs.js +2 -76
- package/dist/src/commands/database-type-configs.js.map +1 -1
- package/dist/src/commands/database-types.js +313 -13
- package/dist/src/commands/database-types.js.map +1 -1
- package/dist/src/commands/databases.d.ts +9 -0
- package/dist/src/commands/databases.js +67 -367
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.d.ts +32 -2
- package/dist/src/commands/documents.js +281 -685
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/functions.js +2 -2
- package/dist/src/commands/functions.js.map +1 -1
- package/dist/src/commands/llm.js +6 -9
- package/dist/src/commands/llm.js.map +1 -1
- package/dist/src/commands/settings.d.ts +9 -12
- package/dist/src/commands/settings.js +79 -31
- package/dist/src/commands/settings.js.map +1 -1
- package/dist/src/commands/sync.d.ts +31 -81
- package/dist/src/commands/sync.js +228 -1113
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +30 -199
- package/dist/src/lib/api-client.js +31 -297
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/codegen-shared/generatedFiles.d.ts +6 -7
- package/dist/src/lib/codegen-shared/generatedFiles.js +2 -2
- package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
- package/dist/src/lib/codegen-shared/prettierStable.d.ts +1 -1
- package/dist/src/lib/codegen-shared/prettierStable.js +1 -1
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +2 -2
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +2 -2
- package/dist/src/lib/config-object-descriptor.js +1 -5
- package/dist/src/lib/config-object-descriptor.js.map +1 -1
- package/dist/src/lib/config-surface.d.ts +2 -1
- package/dist/src/lib/config-surface.js +4 -1
- package/dist/src/lib/config-surface.js.map +1 -1
- package/dist/src/lib/connections-list.d.ts +10 -0
- package/dist/src/lib/connections-list.js +18 -0
- package/dist/src/lib/connections-list.js.map +1 -0
- package/dist/src/lib/csv.d.ts +8 -9
- package/dist/src/lib/csv.js +3 -4
- package/dist/src/lib/csv.js.map +1 -1
- package/dist/src/lib/db-codegen/dbNaming.d.ts +2 -1
- package/dist/src/lib/db-codegen/dbNaming.js +2 -1
- package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTsTypes.d.ts +2 -2
- package/dist/src/lib/db-codegen/dbTsTypes.js +2 -2
- package/dist/src/lib/db-codegen/dbTypeIR.d.ts +59 -1
- package/dist/src/lib/db-codegen/dbTypeIR.js +12 -1
- package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -1
- package/dist/src/lib/document-export-record-log.d.ts +57 -0
- package/dist/src/lib/document-export-record-log.js +215 -0
- package/dist/src/lib/document-export-record-log.js.map +1 -0
- package/dist/src/lib/document-ingest-artifact.d.ts +13 -2
- package/dist/src/lib/document-ingest-artifact.js +7 -3
- package/dist/src/lib/document-ingest-artifact.js.map +1 -1
- package/dist/src/lib/document-ingest-rows.d.ts +16 -0
- package/dist/src/lib/document-ingest-rows.js +17 -0
- package/dist/src/lib/document-ingest-rows.js.map +1 -1
- package/dist/src/lib/document-ingest.d.ts +22 -0
- package/dist/src/lib/document-ingest.js +33 -0
- package/dist/src/lib/document-ingest.js.map +1 -1
- package/dist/src/lib/function-triggers.d.ts +3 -15
- package/dist/src/lib/function-triggers.js +10 -72
- package/dist/src/lib/function-triggers.js.map +1 -1
- package/dist/src/lib/generated-allowlist.js +0 -20
- package/dist/src/lib/generated-allowlist.js.map +1 -1
- package/dist/src/lib/generated-config-surfaces.d.ts +40 -2
- package/dist/src/lib/generated-config-surfaces.js +160 -94
- package/dist/src/lib/generated-config-surfaces.js.map +1 -1
- package/dist/src/lib/generated-sdk-types.d.ts +1 -1
- package/dist/src/lib/generated-sdk-types.js +1 -1
- package/dist/src/lib/generated-sdk-types.js.map +1 -1
- package/dist/src/lib/login-scope-lines.d.ts +15 -0
- package/dist/src/lib/login-scope-lines.js +22 -0
- package/dist/src/lib/login-scope-lines.js.map +1 -0
- package/dist/src/lib/paginate.d.ts +0 -5
- package/dist/src/lib/paginate.js +0 -5
- package/dist/src/lib/paginate.js.map +1 -1
- package/dist/src/lib/refresh-admin-credentials.d.ts +7 -0
- package/dist/src/lib/refresh-admin-credentials.js +16 -4
- package/dist/src/lib/refresh-admin-credentials.js.map +1 -1
- package/dist/src/lib/root-import-decision.d.ts +15 -14
- package/dist/src/lib/root-import-decision.js +44 -51
- package/dist/src/lib/root-import-decision.js.map +1 -1
- package/dist/src/lib/snapshot-build-rows.d.ts +21 -0
- package/dist/src/lib/snapshot-build-rows.js +22 -0
- package/dist/src/lib/snapshot-build-rows.js.map +1 -1
- package/dist/src/lib/snapshot-build.js +8 -3
- package/dist/src/lib/snapshot-build.js.map +1 -1
- package/dist/src/lib/swift-codegen/banners.d.ts +34 -7
- package/dist/src/lib/swift-codegen/banners.js +37 -7
- package/dist/src/lib/swift-codegen/banners.js.map +1 -1
- package/dist/src/lib/swift-codegen/functionGenerator.d.ts +3 -3
- package/dist/src/lib/swift-codegen/functionGenerator.js +22 -17
- package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -1
- package/dist/src/lib/swift-codegen/generator.js +27 -27
- package/dist/src/lib/swift-codegen/generator.js.map +1 -1
- package/dist/src/lib/toml-database-config.d.ts +14 -75
- package/dist/src/lib/toml-database-config.js +14 -305
- package/dist/src/lib/toml-database-config.js.map +1 -1
- package/dist/src/lib/workflow-toml-validator.js +15 -1
- package/dist/src/lib/workflow-toml-validator.js.map +1 -1
- package/package.json +3 -5
- package/dist/src/lib/generated-workflow-model-fields.d.ts +0 -20
- package/dist/src/lib/generated-workflow-model-fields.js +0 -53
- package/dist/src/lib/generated-workflow-model-fields.js.map +0 -1
- package/dist/src/lib/workflow-field-descriptor.d.ts +0 -91
- package/dist/src/lib/workflow-field-descriptor.js +0 -153
- package/dist/src/lib/workflow-field-descriptor.js.map +0 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { snapshotBuildRows } from "../lib/snapshot-build-rows.js";
|
|
1
|
+
import { segmentRows, snapshotBuildRows } from "../lib/snapshot-build-rows.js";
|
|
2
|
+
import { exportRecordLogDocument } from "../lib/document-export-record-log.js";
|
|
2
3
|
import { driveSnapshotBuild } from "../lib/snapshot-build.js";
|
|
3
4
|
import { ingestClientRows, ingestSessionRows, } from "../lib/document-ingest-rows.js";
|
|
4
5
|
import { ApiClient } from "../lib/api-client.js";
|
|
@@ -13,14 +14,9 @@ import { parseDataOption } from "../lib/data-input.js";
|
|
|
13
14
|
import { parseFilterOptions } from "../lib/record-filter.js";
|
|
14
15
|
import { buildPermissionsExport, PENDING_INVITATIONS_ABSENT_STATUSES, } from "../lib/document-export-permissions.js";
|
|
15
16
|
import { readExportLookup } from "../lib/document-export-lookups.js";
|
|
16
|
-
import { chunkAddressOf, renumberManifestForExport, } from "../lib/snapshot-manifest-layout.js";
|
|
17
|
-
import { validateSnapshotManifest } from "js-bao";
|
|
18
|
-
import { auditSnapshotChain, } from "../lib/snapshot-audit.js";
|
|
19
|
-
import { createAuditApiSource } from "../lib/snapshot-audit-source.js";
|
|
20
|
-
import { createAuditLocalStore, createChainFingerprintReader, } from "../lib/snapshot-audit-store.js";
|
|
21
17
|
import { discoverIngestInput, } from "../lib/document-ingest-input.js";
|
|
22
18
|
import { buildIngestArtifact, ingestSchemaFromIntrospection, } from "../lib/document-ingest-artifact.js";
|
|
23
|
-
import { driveDocumentIngest, summariseIngestPlan, assertIngestConfirmable, } from "../lib/document-ingest.js";
|
|
19
|
+
import { driveDocumentIngest, summariseIngestPlan, assertIngestConfirmable, ingestCreateBody, parseIngestFlags, } from "../lib/document-ingest.js";
|
|
24
20
|
import { withTransientRetry } from "../lib/transient-retry.js";
|
|
25
21
|
import { ulid } from "ulid";
|
|
26
22
|
import * as Y from "yjs";
|
|
@@ -40,6 +36,17 @@ function printIngestSession(session) {
|
|
|
40
36
|
printResult(label, value);
|
|
41
37
|
}
|
|
42
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* One refused document call, as one line for an operator: the server's
|
|
41
|
+
* sentence and, when it sent one, the stable code a script branches on — a
|
|
42
|
+
* large document's refusal names `DOCUMENT_FORMAT_UNSUPPORTED` on every verb
|
|
43
|
+
* that reaches its data (#4313).
|
|
44
|
+
*/
|
|
45
|
+
export function describeDocumentFailure(err) {
|
|
46
|
+
const message = (typeof err?.message === "string" && err.message) || String(err);
|
|
47
|
+
const code = typeof err?.code === "string" && err.code ? err.code : undefined;
|
|
48
|
+
return code && !message.includes(code) ? `${message} [${code}]` : message;
|
|
49
|
+
}
|
|
43
50
|
/**
|
|
44
51
|
* One refused records call, as one line for an operator (#3764).
|
|
45
52
|
*
|
|
@@ -82,24 +89,53 @@ function printDocumentFormat(doc) {
|
|
|
82
89
|
* Refused HERE rather than by the server, because an operator CAN act on it
|
|
83
90
|
* (principle 6) and a round trip to be told what they typed wrong is a round
|
|
84
91
|
* trip they do not need to spend. Commander hands the raw string over, so the
|
|
85
|
-
* digits are what is read; an unset flag states nothing.
|
|
92
|
+
* digits are what is read; an unset flag states nothing. `flag` names the
|
|
93
|
+
* option in the refusal: `documents create` reads its `--format` here too.
|
|
94
|
+
*
|
|
95
|
+
* `2` — a large document, which the platform no longer serves — is refused
|
|
96
|
+
* with `DOCUMENT_FORMAT_UNSUPPORTED` named, as the server would refuse it.
|
|
86
97
|
*/
|
|
87
|
-
export function parseDocumentFormatOption(value) {
|
|
98
|
+
export function parseDocumentFormatOption(value, flag = "--document-format") {
|
|
88
99
|
if (value === undefined || value === null)
|
|
89
100
|
return undefined;
|
|
90
101
|
const text = String(value).trim();
|
|
91
102
|
if (text === "1")
|
|
92
103
|
return 1;
|
|
93
|
-
if (text === "2")
|
|
94
|
-
return 2;
|
|
95
104
|
if (text === "3")
|
|
96
105
|
return 3;
|
|
97
|
-
|
|
98
|
-
`
|
|
106
|
+
if (text === "2") {
|
|
107
|
+
throw new Error(`documentFormat 2 (large document) is no longer served; use ${flag} 3 (DOCUMENT_FORMAT_UNSUPPORTED)`);
|
|
108
|
+
}
|
|
109
|
+
throw new Error(`Invalid ${flag} "${String(value)}". Expected 1 (an ordinary ` +
|
|
110
|
+
`document) or 3 (a record-log document).`);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The `documentFormat` a `documents create` sends, from its two flags.
|
|
114
|
+
*
|
|
115
|
+
* `--format` is read by {@link parseDocumentFormatOption}, so a value outside
|
|
116
|
+
* 1 and 3 is refused before any request with the same sentence every records
|
|
117
|
+
* verb uses, and 2 with `DOCUMENT_FORMAT_UNSUPPORTED`. `--large` is
|
|
118
|
+
* `--format 3`: alone it sends 3, beside `--format 3` it changes nothing, and
|
|
119
|
+
* beside `--format 1` the two disagree and the command is refused rather than
|
|
120
|
+
* guessing which one was meant. Neither flag sends nothing, so the server's
|
|
121
|
+
* default stands.
|
|
122
|
+
*
|
|
123
|
+
* @param flags - The raw `--format` value and whether `--large` was given.
|
|
124
|
+
* @returns The format to send, or `undefined` to send none.
|
|
125
|
+
* @throws Error when `--format` is not 1 or 3, or disagrees with `--large`.
|
|
126
|
+
*/
|
|
127
|
+
export function documentFormatForCreate(flags) {
|
|
128
|
+
const format = parseDocumentFormatOption(flags.format, "--format");
|
|
129
|
+
if (!flags.large)
|
|
130
|
+
return format;
|
|
131
|
+
if (format !== undefined && format !== 3) {
|
|
132
|
+
throw new Error("--large is --format 3; pass one of them");
|
|
133
|
+
}
|
|
134
|
+
return 3;
|
|
99
135
|
}
|
|
100
|
-
/** The `--document-format <1|
|
|
101
|
-
const DOCUMENT_FORMAT_FLAG = "--document-format <1|
|
|
102
|
-
const DOCUMENT_FORMAT_HELP = "The document format you expect: 1 (ordinary)
|
|
136
|
+
/** The `--document-format <1|3>` flag, said once for every records verb. */
|
|
137
|
+
const DOCUMENT_FORMAT_FLAG = "--document-format <1|3>";
|
|
138
|
+
const DOCUMENT_FORMAT_HELP = "The document format you expect: 1 (ordinary) or 3 (record log). A disagreement " +
|
|
103
139
|
"with the platform's own answer is refused with DOCUMENT_FORMAT_MISMATCH " +
|
|
104
140
|
"rather than answered out of the wrong tables";
|
|
105
141
|
/**
|
|
@@ -219,6 +255,7 @@ Examples:
|
|
|
219
255
|
$ primitive documents get <document-id>
|
|
220
256
|
$ primitive documents create "My Doc"
|
|
221
257
|
$ primitive documents create "My Doc" --owner user@example.com
|
|
258
|
+
$ primitive documents create "Ledger" --format 3
|
|
222
259
|
$ primitive documents delete <document-id> -y
|
|
223
260
|
$ primitive documents permissions list <document-id>
|
|
224
261
|
$ primitive documents permissions grant <document-id> --user-id <user-id> --permission reader
|
|
@@ -341,7 +378,8 @@ Examples:
|
|
|
341
378
|
.argument("<title>", "Document title")
|
|
342
379
|
.option("--app <app-id>", "App ID")
|
|
343
380
|
.option("--owner <userId-or-email>", "User ID or email to own the document (super-admin or assigned-console-admin tokens only)")
|
|
344
|
-
.option("--
|
|
381
|
+
.option("--format <n>", "Document format: 1 (ordinary) or 3 (record log). Chosen at creation and never changed")
|
|
382
|
+
.option("--large", "Create a record-log document (format 3), for documents that outgrow a single in-memory Y.Doc. The same as --format 3")
|
|
345
383
|
.option("--json", "Output as JSON")
|
|
346
384
|
.addHelpText("after", `
|
|
347
385
|
Ownership:
|
|
@@ -357,6 +395,20 @@ Ownership:
|
|
|
357
395
|
document.
|
|
358
396
|
`)
|
|
359
397
|
.action(async (title, options) => {
|
|
398
|
+
// Decided before anything else: a format the operator mistyped, one that
|
|
399
|
+
// disagrees with --large, or 2 (a large document, no longer served) is
|
|
400
|
+
// refused without a request (#4308, #4313).
|
|
401
|
+
let documentFormat;
|
|
402
|
+
try {
|
|
403
|
+
documentFormat = documentFormatForCreate({
|
|
404
|
+
format: options.format,
|
|
405
|
+
large: options.large === true,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
error(err.message);
|
|
410
|
+
process.exit(1);
|
|
411
|
+
}
|
|
360
412
|
const resolvedAppId = resolveAppId(undefined, options);
|
|
361
413
|
const client = new ApiClient();
|
|
362
414
|
let ownerUserId;
|
|
@@ -373,9 +425,9 @@ Ownership:
|
|
|
373
425
|
const result = await client.createDocument(resolvedAppId, {
|
|
374
426
|
title,
|
|
375
427
|
...(ownerUserId ? { createdBy: ownerUserId } : {}),
|
|
376
|
-
// Opt-in only: without --large nothing is sent, so the
|
|
377
|
-
// created in the legacy format exactly as before (#2816).
|
|
378
|
-
...(
|
|
428
|
+
// Opt-in only: without --format or --large nothing is sent, so the
|
|
429
|
+
// document is created in the legacy format exactly as before (#2816).
|
|
430
|
+
...(documentFormat !== undefined ? { documentFormat } : {}),
|
|
379
431
|
});
|
|
380
432
|
if (options.json) {
|
|
381
433
|
json(result);
|
|
@@ -385,7 +437,7 @@ Ownership:
|
|
|
385
437
|
keyValue("Document ID", result.documentId);
|
|
386
438
|
keyValue("Title", result.title);
|
|
387
439
|
keyValue("Owner", result.createdBy);
|
|
388
|
-
// Read back from the RESPONSE, not from
|
|
440
|
+
// Read back from the RESPONSE, not from the flags: what the server
|
|
389
441
|
// created is what the document will be for the rest of its life.
|
|
390
442
|
printDocumentFormat(result);
|
|
391
443
|
}
|
|
@@ -445,7 +497,7 @@ Permissions (enforced by the server, not the CLI):
|
|
|
445
497
|
catch (err) {
|
|
446
498
|
// Server refusals (404 not found, 403 root document / owner-only, the
|
|
447
499
|
// non-owner fail-closed 503) are printed verbatim, never swallowed.
|
|
448
|
-
error(err
|
|
500
|
+
error(describeDocumentFailure(err));
|
|
449
501
|
process.exit(1);
|
|
450
502
|
}
|
|
451
503
|
});
|
|
@@ -1144,7 +1196,7 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1144
1196
|
console.log(output);
|
|
1145
1197
|
}
|
|
1146
1198
|
catch (err) {
|
|
1147
|
-
error(err
|
|
1199
|
+
error(describeDocumentFailure(err));
|
|
1148
1200
|
process.exit(1);
|
|
1149
1201
|
}
|
|
1150
1202
|
});
|
|
@@ -1176,21 +1228,49 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1176
1228
|
keyValue("Last modified", result.lastModifiedAt ? formatDate(result.lastModifiedAt) : "—");
|
|
1177
1229
|
}
|
|
1178
1230
|
catch (err) {
|
|
1179
|
-
error(err
|
|
1231
|
+
error(describeDocumentFailure(err));
|
|
1180
1232
|
process.exit(1);
|
|
1181
1233
|
}
|
|
1182
1234
|
});
|
|
1183
1235
|
// #4306 — a record-log document's room: the inspection route's payload.
|
|
1184
1236
|
documents
|
|
1185
1237
|
.command("inspect")
|
|
1186
|
-
.description("Show a record-log document's room: epochs, log number, floors, writers and checkpoint")
|
|
1238
|
+
.description("Show a record-log document's room: epochs, log number, floors, writers, the bulk load in progress and checkpoint")
|
|
1187
1239
|
.argument("<document-id>", "Document ID")
|
|
1188
1240
|
.option("--app <app-id>", "App ID")
|
|
1241
|
+
.option("--refused <session-id>", "Print every row that bulk load refused, with its code, instead")
|
|
1242
|
+
.option("--limit <n>", "With --refused: rows per page read (1–100)", "100")
|
|
1189
1243
|
.option("--json", "Output as JSON")
|
|
1190
1244
|
.action(async (documentId, options) => {
|
|
1191
1245
|
const resolvedAppId = resolveAppId(undefined, options);
|
|
1192
1246
|
const client = new ApiClient();
|
|
1193
1247
|
try {
|
|
1248
|
+
if (options.refused !== undefined) {
|
|
1249
|
+
// Every page, in order: the room keeps each refused row with its code.
|
|
1250
|
+
const items = [];
|
|
1251
|
+
let cursor;
|
|
1252
|
+
do {
|
|
1253
|
+
const page = await client.getDocumentRoom(resolvedAppId, documentId, {
|
|
1254
|
+
refused: options.refused,
|
|
1255
|
+
limit: Number(options.limit),
|
|
1256
|
+
cursor,
|
|
1257
|
+
});
|
|
1258
|
+
items.push(...page.refused.items);
|
|
1259
|
+
cursor = page.refused.hasMore ? page.refused.nextCursor : undefined;
|
|
1260
|
+
} while (cursor);
|
|
1261
|
+
if (options.json) {
|
|
1262
|
+
json({ documentId, sessionId: options.refused, items });
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
keyValue("Document ID", documentId);
|
|
1266
|
+
keyValue("Session", options.refused);
|
|
1267
|
+
keyValue("Refused rows", items.length);
|
|
1268
|
+
for (const row of items) {
|
|
1269
|
+
const conflicting = row.conflicting ? ` (held by ${row.conflicting.model} ${row.conflicting.id})` : "";
|
|
1270
|
+
keyValue(` ${row.model} ${row.id}`, `${row.code}${row.constraint ? ` ${row.constraint}` : ""}${conflicting}`);
|
|
1271
|
+
}
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1194
1274
|
const result = await client.getDocumentRoom(resolvedAppId, documentId);
|
|
1195
1275
|
if (options.json) {
|
|
1196
1276
|
json(result);
|
|
@@ -1212,7 +1292,24 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1212
1292
|
keyValue("Writers", `${room.writers.count} (${room.writers.connected} connected)`);
|
|
1213
1293
|
keyValue("Subscribers", room.subscribers);
|
|
1214
1294
|
keyValue("Held entries", room.held.entries);
|
|
1215
|
-
|
|
1295
|
+
const load = room.load;
|
|
1296
|
+
keyValue("Load", load
|
|
1297
|
+
? `${load.sessionId} ${load.state} (${load.mode}, epoch reserved at ${load.reserveEpoch}` +
|
|
1298
|
+
(load.epoch !== null ? `: epoch ${load.epoch} from log number ${load.k}` : "") +
|
|
1299
|
+
`), ${load.chunksApplied}/${load.chunks} chunks, ${load.rowsApplied} rows applied, ` +
|
|
1300
|
+
`${load.refusedRows} refused, ${load.touched} keys touched`
|
|
1301
|
+
: "none");
|
|
1302
|
+
const loads = Array.isArray(room.loads) ? room.loads : [];
|
|
1303
|
+
keyValue("Loads", loads.length
|
|
1304
|
+
? loads
|
|
1305
|
+
.map((l) => `${l.sessionId} (${l.mode}, log numbers ${l.firstLsn ?? "—"}–${l.lastLsn ?? "—"}, ${l.rows} rows, ${l.touched} keys touched)`)
|
|
1306
|
+
.join("; ")
|
|
1307
|
+
: "none kept");
|
|
1308
|
+
const segmentLine = (s) => `${s.segmentId} ${s.state} (log number ${s.lsn}` +
|
|
1309
|
+
(s.completedLsn !== null && s.completedLsn !== undefined ? `, completed at ${s.completedLsn}` : "") +
|
|
1310
|
+
`, ${s.rows} rows, ${s.chunks} chunks)`;
|
|
1311
|
+
keyValue("Segment", room.segment ? segmentLine(room.segment) : "none");
|
|
1312
|
+
keyValue("Segment export", room.segmentExport ? segmentLine(room.segmentExport) : "none");
|
|
1216
1313
|
keyValue("Last checkpoint", room.checkpoint
|
|
1217
1314
|
? `${formatDate(room.checkpoint.at)} (${room.checkpoint.durationMs} ms, ` +
|
|
1218
1315
|
`${room.checkpoint.pruned} pruned, ${room.checkpoint.dropped} dropped, ` +
|
|
@@ -1222,7 +1319,7 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1222
1319
|
keyValue("Storage (bytes)", room.storage?.databaseSize ?? "—");
|
|
1223
1320
|
}
|
|
1224
1321
|
catch (err) {
|
|
1225
|
-
error(err
|
|
1322
|
+
error(describeDocumentFailure(err));
|
|
1226
1323
|
process.exit(1);
|
|
1227
1324
|
}
|
|
1228
1325
|
});
|
|
@@ -1240,6 +1337,8 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1240
1337
|
.argument("<document-id>", "Document ID")
|
|
1241
1338
|
.requiredOption("--input <dir>", "Directory of <model>.ndjson[.gz] files, or a `documents export` output")
|
|
1242
1339
|
.option("--app <app-id>", "App ID")
|
|
1340
|
+
.option("--reserve-epoch <when>", "Record-log (format 3) documents: reserve the load's epoch at session open (open, the default) or at commit")
|
|
1341
|
+
.option("--mode <mode>", "Record-log (format 3) documents: merge the rows (merge, the default), or refresh: also delete every record of the input's models that no row names")
|
|
1243
1342
|
.option("-y, --yes", "Skip the confirmation prompt")
|
|
1244
1343
|
.option("--no-wait", "Exit once the session is committed")
|
|
1245
1344
|
.option("--timeout <seconds>", "Give up waiting after this many seconds")
|
|
@@ -1254,6 +1353,8 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1254
1353
|
// artifact — up to the whole input, gzipped — in the OS temp directory
|
|
1255
1354
|
// on every run, successful or not (finding 3435-FROZEN-05).
|
|
1256
1355
|
const run = async () => {
|
|
1356
|
+
// A flag's value is checked before any request.
|
|
1357
|
+
const flags = parseIngestFlags({ reserveEpoch: options.reserveEpoch, mode: options.mode });
|
|
1257
1358
|
// The preflight, in this order and before a byte is read from disk:
|
|
1258
1359
|
// the schema read says whether this caller may see the document's
|
|
1259
1360
|
// declarations at all and hands over the ones the lines are checked
|
|
@@ -1264,15 +1365,19 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1264
1365
|
const introspection = await client.getDocumentSchema(resolvedAppId, documentId);
|
|
1265
1366
|
await client.listDocumentIngests(resolvedAppId, documentId, { limit: 1 });
|
|
1266
1367
|
const schema = ingestSchemaFromIntrospection(introspection);
|
|
1368
|
+
// A record-log (format 3) document takes models and fields it does
|
|
1369
|
+
// not declare, as its writes do (#4310); its schema read says so.
|
|
1370
|
+
const recordLog = Number(introspection?.documentFormat) === 3;
|
|
1267
1371
|
const input = discoverIngestInput(options.input, documentId);
|
|
1268
1372
|
for (const ignored of input.ignored) {
|
|
1269
1373
|
info(`Ignoring ${ignored}: a bulk load reads the snapshot only.`);
|
|
1270
1374
|
}
|
|
1375
|
+
const body = ingestCreateBody(flags, input.sources.map((source) => source.model));
|
|
1271
1376
|
// #3598 — reading, checking, sorting, cutting and gzipping is this
|
|
1272
1377
|
// side's biggest phase on a large input, and it used to be invisible
|
|
1273
1378
|
// inside one wall-clock number that also covered the platform's work.
|
|
1274
1379
|
const buildStartedAt = Date.now();
|
|
1275
|
-
plan = await buildIngestArtifact({ input, schema });
|
|
1380
|
+
plan = await buildIngestArtifact({ input, schema, open: recordLog });
|
|
1276
1381
|
const buildMs = Date.now() - buildStartedAt;
|
|
1277
1382
|
info(`Built artifact in ${(buildMs / 1000).toFixed(1)}s.`);
|
|
1278
1383
|
for (const line of summariseIngestPlan(plan))
|
|
@@ -1300,7 +1405,7 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1300
1405
|
try {
|
|
1301
1406
|
outcome = await driveDocumentIngest({
|
|
1302
1407
|
client: {
|
|
1303
|
-
createIngest: (id) => client.createDocumentIngest(resolvedAppId, id),
|
|
1408
|
+
createIngest: (id) => client.createDocumentIngest(resolvedAppId, id, body),
|
|
1304
1409
|
uploadIngestChunk: (id, sessionId, chunk) => client.uploadDocumentIngestChunk(resolvedAppId, id, sessionId, chunk),
|
|
1305
1410
|
commitIngest: (id, sessionId) => client.commitDocumentIngest(resolvedAppId, id, sessionId),
|
|
1306
1411
|
abortIngest: (id, sessionId) => client.abortDocumentIngest(resolvedAppId, id, sessionId),
|
|
@@ -1348,7 +1453,7 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1348
1453
|
code = await run();
|
|
1349
1454
|
}
|
|
1350
1455
|
catch (err) {
|
|
1351
|
-
error(err
|
|
1456
|
+
error(describeDocumentFailure(err));
|
|
1352
1457
|
code = 1;
|
|
1353
1458
|
}
|
|
1354
1459
|
finally {
|
|
@@ -1408,7 +1513,7 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1408
1513
|
}
|
|
1409
1514
|
}
|
|
1410
1515
|
catch (err) {
|
|
1411
|
-
error(err
|
|
1516
|
+
error(describeDocumentFailure(err));
|
|
1412
1517
|
process.exit(1);
|
|
1413
1518
|
}
|
|
1414
1519
|
});
|
|
@@ -1431,7 +1536,7 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1431
1536
|
printIngestSession(session);
|
|
1432
1537
|
}
|
|
1433
1538
|
catch (err) {
|
|
1434
|
-
error(err
|
|
1539
|
+
error(describeDocumentFailure(err));
|
|
1435
1540
|
process.exit(1);
|
|
1436
1541
|
}
|
|
1437
1542
|
});
|
|
@@ -1466,6 +1571,20 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1466
1571
|
info("No snapshot builds yet.");
|
|
1467
1572
|
return;
|
|
1468
1573
|
}
|
|
1574
|
+
// A record-log (format 3) document's are its segments (#4310).
|
|
1575
|
+
if (page.items.some((item) => typeof item?.segmentId === "string")) {
|
|
1576
|
+
console.log(formatTable(page.items, [
|
|
1577
|
+
{ header: "SEGMENT", key: "segmentId" },
|
|
1578
|
+
{ header: "LOG NUMBER", key: "lsn" },
|
|
1579
|
+
{ header: "COMPLETED AT LOG NUMBER", key: "completedLsn", format: (v) => (v === null || v === undefined ? "-" : String(v)) },
|
|
1580
|
+
{ header: "STATE", key: "state" },
|
|
1581
|
+
{ header: "ROWS", key: "rows" },
|
|
1582
|
+
{ header: "CHUNKS", key: "chunks" },
|
|
1583
|
+
{ header: "STARTED", key: "startedAt", format: formatDate },
|
|
1584
|
+
{ header: "COMPLETED", key: "completedAt", format: formatDate },
|
|
1585
|
+
]));
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1469
1588
|
console.log(formatTable(page.items, [
|
|
1470
1589
|
{ header: "BUILD", key: "buildId" },
|
|
1471
1590
|
{ header: "EPOCH", key: "epoch" },
|
|
@@ -1490,7 +1609,7 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1490
1609
|
}
|
|
1491
1610
|
}
|
|
1492
1611
|
catch (err) {
|
|
1493
|
-
error(err
|
|
1612
|
+
error(describeDocumentFailure(err));
|
|
1494
1613
|
process.exit(1);
|
|
1495
1614
|
}
|
|
1496
1615
|
});
|
|
@@ -1512,13 +1631,14 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1512
1631
|
}
|
|
1513
1632
|
// The rows are composed by a pure function (#3435), so what a
|
|
1514
1633
|
// terminal shows is checkable without spawning one; the command still
|
|
1515
|
-
// owns the printing.
|
|
1516
|
-
|
|
1634
|
+
// owns the printing. A record-log document's build is a segment (#4310).
|
|
1635
|
+
const rows = typeof build?.segmentId === "string" ? segmentRows(build) : snapshotBuildRows(build);
|
|
1636
|
+
for (const [label, value] of rows) {
|
|
1517
1637
|
printResult(label, value);
|
|
1518
1638
|
}
|
|
1519
1639
|
}
|
|
1520
1640
|
catch (err) {
|
|
1521
|
-
error(err
|
|
1641
|
+
error(describeDocumentFailure(err));
|
|
1522
1642
|
process.exit(1);
|
|
1523
1643
|
}
|
|
1524
1644
|
});
|
|
@@ -1576,20 +1696,20 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1576
1696
|
json(outcome.build ? { ...outcome.response, build: outcome.build } : outcome.response);
|
|
1577
1697
|
return outcome.exitCode;
|
|
1578
1698
|
}
|
|
1579
|
-
if (outcome.response?.
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
}
|
|
1585
|
-
printResult("Sealed epoch", outcome.response.sealedEpoch);
|
|
1586
|
-
printResult("Open epoch", outcome.response.nextEpoch);
|
|
1587
|
-
printResult("Build", outcome.response.buildId);
|
|
1588
|
-
if (outcome.build) {
|
|
1589
|
-
for (const [label, value] of snapshotBuildRows(outcome.build)) {
|
|
1590
|
-
printResult(label, value);
|
|
1699
|
+
if (typeof outcome.response?.segmentId === "string") {
|
|
1700
|
+
// A record-log document's segment export (#4310).
|
|
1701
|
+
if (outcome.build) {
|
|
1702
|
+
for (const [label, value] of segmentRows(outcome.build))
|
|
1703
|
+
printResult(label, value);
|
|
1591
1704
|
}
|
|
1705
|
+
else {
|
|
1706
|
+
printResult("Segment", outcome.response.segmentId);
|
|
1707
|
+
printResult("State", outcome.response.state);
|
|
1708
|
+
printResult("Log number", outcome.response.lsn);
|
|
1709
|
+
}
|
|
1710
|
+
return outcome.exitCode;
|
|
1592
1711
|
}
|
|
1712
|
+
printResult("Build", outcome.response?.buildId);
|
|
1593
1713
|
return outcome.exitCode;
|
|
1594
1714
|
};
|
|
1595
1715
|
let code = 1;
|
|
@@ -1612,74 +1732,6 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1612
1732
|
await flushOutput();
|
|
1613
1733
|
process.exit(code);
|
|
1614
1734
|
});
|
|
1615
|
-
// #3433, criterion 10 — the operator audit: `snapshot ⊕ overlays == table`,
|
|
1616
|
-
// recomputed on this side with the CLIENT's loader and fold and compared
|
|
1617
|
-
// with the authoritative table page by page. The one check that can catch a
|
|
1618
|
-
// DO/client fold divergence, which per-build verification runs on one side
|
|
1619
|
-
// of and therefore cannot see.
|
|
1620
|
-
snapshots
|
|
1621
|
-
.command("audit")
|
|
1622
|
-
.description("Recompute a large document's snapshot chain locally and compare it with the table")
|
|
1623
|
-
.argument("<document-id>", "Document ID")
|
|
1624
|
-
.option("--app <app-id>", "App ID")
|
|
1625
|
-
.option("--retries <n>", "Attempts before giving up on a document that keeps changing", "3")
|
|
1626
|
-
.option("--json", "Output as JSON")
|
|
1627
|
-
.action(async (documentId, options) => {
|
|
1628
|
-
const resolvedAppId = resolveAppId(undefined, options);
|
|
1629
|
-
const client = new ApiClient();
|
|
1630
|
-
const report = await auditDocumentChain(client, resolvedAppId, documentId, Number(options.retries));
|
|
1631
|
-
if (options.json) {
|
|
1632
|
-
json(report);
|
|
1633
|
-
}
|
|
1634
|
-
else if (report.verdict === "none") {
|
|
1635
|
-
// No verdict is not a clean bill of health, so nothing that could be
|
|
1636
|
-
// read as one is printed: the reason, and nothing else.
|
|
1637
|
-
error(`No verdict: ${report.reason}`);
|
|
1638
|
-
}
|
|
1639
|
-
else {
|
|
1640
|
-
printResult("Document", documentId);
|
|
1641
|
-
printResult("Base build", report.buildId);
|
|
1642
|
-
printResult("Base epoch", report.epoch);
|
|
1643
|
-
printResult("Attempts", report.attempts);
|
|
1644
|
-
printResult("Verdict", report.verdict === "match"
|
|
1645
|
-
? "match — the table is the chain"
|
|
1646
|
-
: "DIVERGENCE");
|
|
1647
|
-
console.log(formatTable(report.models, [
|
|
1648
|
-
{ header: "MODEL", key: "model" },
|
|
1649
|
-
{ header: "COMPARED", key: "compared" },
|
|
1650
|
-
{
|
|
1651
|
-
header: "ONLY IN CHAIN",
|
|
1652
|
-
key: "onlyRecomputed",
|
|
1653
|
-
format: (ids) => (ids.length ? ids.join(", ") : "-"),
|
|
1654
|
-
},
|
|
1655
|
-
{
|
|
1656
|
-
header: "ONLY IN TABLE",
|
|
1657
|
-
key: "onlyTable",
|
|
1658
|
-
format: (ids) => (ids.length ? ids.join(", ") : "-"),
|
|
1659
|
-
},
|
|
1660
|
-
{
|
|
1661
|
-
header: "DIFFERING",
|
|
1662
|
-
key: "differing",
|
|
1663
|
-
format: (rows) => rows.length
|
|
1664
|
-
? rows.map((row) => `${row.id} (${row.fields.join(", ")})`).join("; ")
|
|
1665
|
-
: "-",
|
|
1666
|
-
},
|
|
1667
|
-
]));
|
|
1668
|
-
if (report.models.some((model) => model.truncated)) {
|
|
1669
|
-
info("Some lists were truncated; re-run with --json for the full report.");
|
|
1670
|
-
}
|
|
1671
|
-
if (report.verdict === "match")
|
|
1672
|
-
success("snapshot ⊕ overlays == table");
|
|
1673
|
-
}
|
|
1674
|
-
// The report has to reach the pipe before the process ends: stdout to a
|
|
1675
|
-
// pipe is asynchronous and `process.exit` does not wait for it, so a
|
|
1676
|
-
// large report piped into anything that is not reading as fast as this
|
|
1677
|
-
// writes used to arrive cut off mid-document (3433-C12).
|
|
1678
|
-
await flushOutput();
|
|
1679
|
-
// 0 match, 1 divergence, 2 no verdict — so the validation child can
|
|
1680
|
-
// script on "no verdict" without reading the text.
|
|
1681
|
-
process.exit(report.verdict === "match" ? 0 : report.verdict === "divergence" ? 1 : 2);
|
|
1682
|
-
});
|
|
1683
1735
|
// Transfer document ownership
|
|
1684
1736
|
documents
|
|
1685
1737
|
.command("transfer-owner")
|
|
@@ -1973,7 +2025,7 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1973
2025
|
await exportSingleDocument(client, resolvedAppId, documentId, options.output, options.blobs !== false ? true : false, options.json);
|
|
1974
2026
|
}
|
|
1975
2027
|
catch (err) {
|
|
1976
|
-
error(err
|
|
2028
|
+
error(describeDocumentFailure(err));
|
|
1977
2029
|
process.exit(1);
|
|
1978
2030
|
}
|
|
1979
2031
|
});
|
|
@@ -2080,9 +2132,8 @@ Permissions (enforced by the server, not the CLI):
|
|
|
2080
2132
|
// `--overwrite` has never replaced a document: the import path issues no
|
|
2081
2133
|
// delete and calls `import/state`, whose room handler merges the exported
|
|
2082
2134
|
// state into the document with `Y.applyUpdate`. The description says so
|
|
2083
|
-
// (#3135), and names the one id remapping on top of it
|
|
2084
|
-
|
|
2085
|
-
.option("--overwrite", "Merge the exported state into a document that already exists (a root document export is merged into the target user's root document; a large-document export is installed, so it needs a document that holds nothing yet)")
|
|
2135
|
+
// (#3135), and names the one id remapping on top of it.
|
|
2136
|
+
.option("--overwrite", "Merge the exported state into a document that already exists (a root document export is merged into the target user's root document)")
|
|
2086
2137
|
.option("--dry-run", "Show what would be imported without making changes")
|
|
2087
2138
|
.option("--owner <userId-or-email>", "User ID or email to set as document owner (admin only)")
|
|
2088
2139
|
.option("--json", "Output as JSON")
|
|
@@ -2148,6 +2199,30 @@ Permissions (enforced by the server, not the CLI):
|
|
|
2148
2199
|
info("No documents to import.");
|
|
2149
2200
|
return;
|
|
2150
2201
|
}
|
|
2202
|
+
// A record-log (format 3) export is a document's rows at one log
|
|
2203
|
+
// number, not a state to install (#4310): it is loaded with a bulk
|
|
2204
|
+
// load. Refused before any request, naming the way in.
|
|
2205
|
+
// A large-document (format 2) export cannot be installed: the platform
|
|
2206
|
+
// no longer serves the format. Its snapshot row chunks are the way
|
|
2207
|
+
// across, loaded into a record-log document. Refused before any write.
|
|
2208
|
+
const largeDocumentExports = docDirs.filter((docDir) => isLargeDocumentExport(docDir));
|
|
2209
|
+
if (largeDocumentExports.length > 0) {
|
|
2210
|
+
for (const docDir of largeDocumentExports) {
|
|
2211
|
+
error(`${docDir}: this export is a large document (format 2), which the platform no longer serves; ` +
|
|
2212
|
+
`create a record-log document and run \`primitive documents ingest <document-id> --input ${docDir}\` to load its row chunks`);
|
|
2213
|
+
}
|
|
2214
|
+
await flushOutput();
|
|
2215
|
+
process.exit(1);
|
|
2216
|
+
}
|
|
2217
|
+
const recordLogExports = docDirs.filter((docDir) => Number(readExportMetadata(docDir)?.documentFormat) === 3);
|
|
2218
|
+
if (recordLogExports.length > 0) {
|
|
2219
|
+
for (const docDir of recordLogExports) {
|
|
2220
|
+
error(`${docDir} is a record-log (format 3) export; \`documents import\` does not install one. ` +
|
|
2221
|
+
`Load its rows into a format-3 document with \`documents ingest <document-id> --input ${docDir}\`.`);
|
|
2222
|
+
}
|
|
2223
|
+
await flushOutput();
|
|
2224
|
+
process.exit(1);
|
|
2225
|
+
}
|
|
2151
2226
|
const summary = {
|
|
2152
2227
|
created: 0,
|
|
2153
2228
|
updated: 0,
|
|
@@ -2163,28 +2238,6 @@ Permissions (enforced by the server, not the CLI):
|
|
|
2163
2238
|
// alone and before any write (#3135): a refusal, or two exports
|
|
2164
2239
|
// competing for one user's root document, is reported up front.
|
|
2165
2240
|
const rootPlans = await planRootImports(client, resolvedAppId, docDirs, options.owner, ownerUserId, options.overwrite || false);
|
|
2166
|
-
// Nothing is created until every artifact is one this CLI can install:
|
|
2167
|
-
// a refusal halfway through a multi-document import would leave the
|
|
2168
|
-
// app half-populated (#2816). A root export is vouched for only when
|
|
2169
|
-
// its plan installs it (#4195): a refused or skipped root export is
|
|
2170
|
-
// never read past `metadata.json`, so a broken chain in it cannot
|
|
2171
|
-
// fail the run.
|
|
2172
|
-
for (const docDir of docDirs) {
|
|
2173
|
-
const rootPlan = rootPlans.get(docDir);
|
|
2174
|
-
if (rootPlan) {
|
|
2175
|
-
if (rootPlan.mode === "install") {
|
|
2176
|
-
readChainArtifact(docDir, rootPlan.exportedDocumentId);
|
|
2177
|
-
}
|
|
2178
|
-
continue;
|
|
2179
|
-
}
|
|
2180
|
-
if (!largeDocumentArtifactAt(docDir))
|
|
2181
|
-
continue;
|
|
2182
|
-
const metadataPath = path.join(docDir, "metadata.json");
|
|
2183
|
-
const documentId = fs.existsSync(metadataPath)
|
|
2184
|
-
? JSON.parse(fs.readFileSync(metadataPath, "utf-8")).documentId
|
|
2185
|
-
: path.basename(docDir);
|
|
2186
|
-
readChainArtifact(docDir, documentId);
|
|
2187
|
-
}
|
|
2188
2241
|
// One document failing does not stop the run (#4148): it is recorded
|
|
2189
2242
|
// by id, the rest are imported, and the run exits non-zero at the end.
|
|
2190
2243
|
for (const docDir of docDirs) {
|
|
@@ -2287,97 +2340,6 @@ Permissions (enforced by the server, not the CLI):
|
|
|
2287
2340
|
}
|
|
2288
2341
|
});
|
|
2289
2342
|
}
|
|
2290
|
-
// ============================================
|
|
2291
|
-
// Helper functions for export/import
|
|
2292
|
-
// ============================================
|
|
2293
|
-
/**
|
|
2294
|
-
* Write a large document's export chain into `docDir` (#2816, behavior 20).
|
|
2295
|
-
*
|
|
2296
|
-
* The layout mirrors the chain and is applied in that order on import:
|
|
2297
|
-
*
|
|
2298
|
-
* ```
|
|
2299
|
-
* chain.json what this artifact is, and the order to apply it in
|
|
2300
|
-
* snapshot/ manifest.json + {model}/{n}.ndjson.gz, when there is a base
|
|
2301
|
-
* epochs/{E}.yjs each sealed overlay after the base's epoch, oldest first
|
|
2302
|
-
* current.yjs the open epoch's overlay
|
|
2303
|
-
* ```
|
|
2304
|
-
*
|
|
2305
|
-
* The server refuses a chain it cannot vouch for, so nothing here has to guess
|
|
2306
|
-
* whether an artifact is complete; what this function must not do is write a
|
|
2307
|
-
* PARTIAL one, which is why `chain.json` — the file that says "this is a
|
|
2308
|
-
* complete v2 export" — is written last.
|
|
2309
|
-
*
|
|
2310
|
-
* Returns the bytes written, which is what `--json` reports as the document's
|
|
2311
|
-
* size: for a large document that is the artifact, not one Yjs update.
|
|
2312
|
-
*/
|
|
2313
|
-
async function exportLargeDocumentChain(client, appId, documentId, docDir) {
|
|
2314
|
-
const chain = await client.exportDocumentChain(appId, documentId);
|
|
2315
|
-
let bytes = 0;
|
|
2316
|
-
// The open epoch's overlay travels inline: the rotation threshold bounds it.
|
|
2317
|
-
const current = Buffer.from(chain.current.update, "base64");
|
|
2318
|
-
fs.writeFileSync(path.join(docDir, "current.yjs"), current);
|
|
2319
|
-
bytes += current.byteLength;
|
|
2320
|
-
const overlays = [];
|
|
2321
|
-
if (Array.isArray(chain.overlays) && chain.overlays.length > 0) {
|
|
2322
|
-
const epochsDir = path.join(docDir, "epochs");
|
|
2323
|
-
fs.mkdirSync(epochsDir, { recursive: true });
|
|
2324
|
-
for (const overlay of chain.overlays) {
|
|
2325
|
-
const archive = await client.downloadDocumentArtifact(appId, overlay.download.path);
|
|
2326
|
-
fs.writeFileSync(path.join(epochsDir, `${overlay.epoch}.yjs`), archive);
|
|
2327
|
-
bytes += archive.byteLength;
|
|
2328
|
-
overlays.push({ epoch: overlay.epoch, sealedAt: overlay.sealedAt ?? null });
|
|
2329
|
-
}
|
|
2330
|
-
}
|
|
2331
|
-
let base = null;
|
|
2332
|
-
if (chain.base) {
|
|
2333
|
-
const snapshotDir = path.join(docDir, "snapshot");
|
|
2334
|
-
fs.mkdirSync(snapshotDir, { recursive: true });
|
|
2335
|
-
const manifestBytes = await client.downloadDocumentArtifact(appId, chain.base.download.path);
|
|
2336
|
-
const downloaded = JSON.parse(manifestBytes.toString("utf-8"));
|
|
2337
|
-
// Addressed within the granted build by each entry's `path` — the
|
|
2338
|
-
// own-build `{model}/{n}` or the `{epoch}-{buildId}/{model}/{n}` of a
|
|
2339
|
-
// chunk reused from an earlier build — never by key (#3432). On disk the
|
|
2340
|
-
// chunks are renumbered per model into the own-build form and the stored
|
|
2341
|
-
// manifest says so, which is the one layout `documents import` reads.
|
|
2342
|
-
const { manifest, files } = renumberManifestForExport(downloaded);
|
|
2343
|
-
for (const file of files) {
|
|
2344
|
-
const body = await client.downloadDocumentArtifact(appId, `${chain.base.download.path}/${file.download}`);
|
|
2345
|
-
const target = path.join(snapshotDir, file.file);
|
|
2346
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
2347
|
-
fs.writeFileSync(target, body);
|
|
2348
|
-
bytes += body.byteLength;
|
|
2349
|
-
}
|
|
2350
|
-
fs.writeFileSync(path.join(snapshotDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
2351
|
-
base = {
|
|
2352
|
-
epoch: chain.base.epoch,
|
|
2353
|
-
buildId: chain.base.buildId,
|
|
2354
|
-
rows: chain.base.rows,
|
|
2355
|
-
};
|
|
2356
|
-
}
|
|
2357
|
-
// Last, and without the signatures: a grant is this run's read of the
|
|
2358
|
-
// document, and an artifact that outlives it must not carry one.
|
|
2359
|
-
fs.writeFileSync(path.join(docDir, "chain.json"), JSON.stringify({
|
|
2360
|
-
version: 2,
|
|
2361
|
-
documentFormat: 2,
|
|
2362
|
-
documentId,
|
|
2363
|
-
epoch: chain.epoch,
|
|
2364
|
-
base,
|
|
2365
|
-
overlays,
|
|
2366
|
-
current: { epoch: chain.current.epoch, file: "current.yjs" },
|
|
2367
|
-
}, null, 2));
|
|
2368
|
-
return bytes;
|
|
2369
|
-
}
|
|
2370
|
-
/**
|
|
2371
|
-
* Where a stored manifest entry's chunk is on disk: `snapshot/{model}/{n}`.
|
|
2372
|
-
*
|
|
2373
|
-
* An exported manifest is in the own-build form (renumbered per model), so
|
|
2374
|
-
* the entry's `path` — or, for an artifact written before the field existed,
|
|
2375
|
-
* its key — names the file directly.
|
|
2376
|
-
*/
|
|
2377
|
-
function chunkFileOf(chunk) {
|
|
2378
|
-
const address = chunkAddressOf(chunk);
|
|
2379
|
-
return { model: address.model, index: address.index };
|
|
2380
|
-
}
|
|
2381
2343
|
async function exportSingleDocument(client, appId, documentId, outputDir, includeBlobs, jsonOutput) {
|
|
2382
2344
|
const docDir = path.join(outputDir, "documents", documentId);
|
|
2383
2345
|
fs.mkdirSync(docDir, { recursive: true });
|
|
@@ -2389,13 +2351,26 @@ async function exportSingleDocument(client, appId, documentId, outputDir, includ
|
|
|
2389
2351
|
fs.rmSync(path.join(docDir, "metadata.json"), { force: true });
|
|
2390
2352
|
// 1. Fetch document metadata
|
|
2391
2353
|
const docMeta = await client.getDocument(appId, documentId);
|
|
2392
|
-
// 2. Export the document's state. A legacy document is one blob; a
|
|
2393
|
-
//
|
|
2394
|
-
//
|
|
2395
|
-
// exists to avoid.
|
|
2354
|
+
// 2. Export the document's state. A legacy document is one blob; a
|
|
2355
|
+
// record-log document is its rows at one log number. A large document
|
|
2356
|
+
// (format 2) is no longer served, and the server refuses its export.
|
|
2396
2357
|
let stateBytes;
|
|
2397
|
-
if (Number(docMeta?.documentFormat) ===
|
|
2398
|
-
|
|
2358
|
+
if (Number(docMeta?.documentFormat) === 3) {
|
|
2359
|
+
// A record-log document (#4310): its current segment, with the rows
|
|
2360
|
+
// written while it exported merged in, so the directory is the document
|
|
2361
|
+
// at one log number.
|
|
2362
|
+
const exported = await exportRecordLogDocument({
|
|
2363
|
+
client: {
|
|
2364
|
+
requestSegment: () => client.requestDocumentSnapshot(appId, documentId),
|
|
2365
|
+
getSegment: (segmentId) => client.getDocumentSnapshot(appId, documentId, segmentId),
|
|
2366
|
+
listSegments: () => client.listDocumentSnapshots(appId, documentId, { limit: 100 }),
|
|
2367
|
+
exportChain: () => client.exportDocumentChain(appId, documentId),
|
|
2368
|
+
download: (artifactPath) => client.downloadDocumentArtifact(appId, artifactPath),
|
|
2369
|
+
},
|
|
2370
|
+
documentId,
|
|
2371
|
+
docDir,
|
|
2372
|
+
});
|
|
2373
|
+
stateBytes = exported.bytes;
|
|
2399
2374
|
}
|
|
2400
2375
|
else {
|
|
2401
2376
|
const stateResult = await client.exportDocumentState(appId, documentId);
|
|
@@ -2428,15 +2403,16 @@ async function exportSingleDocument(client, appId, documentId, outputDir, includ
|
|
|
2428
2403
|
fs.writeFileSync(path.join(docDir, "permissions.json"), JSON.stringify(permissionsExport, null, 2));
|
|
2429
2404
|
// 5. Aliases (user-scoped only)
|
|
2430
2405
|
const aliases = (Array.isArray(aliasResult) ? aliasResult : aliasResult?.aliases || []).filter((a) => (a.scope || a.aliasScope) === "user");
|
|
2431
|
-
// 6. Write metadata. A large document says so here as well
|
|
2432
|
-
// `chain.json`: the format decides how the artifact has to be
|
|
2433
|
-
// and an import that reads only `metadata.json` must not
|
|
2434
|
-
// a legacy document (#2816).
|
|
2406
|
+
// 6. Write metadata. A large or record-log document says so here as well
|
|
2407
|
+
// as in `chain.json`: the format decides how the artifact has to be
|
|
2408
|
+
// installed, and an import that reads only `metadata.json` must not
|
|
2409
|
+
// mistake one for a legacy document (#2816, #4310).
|
|
2435
2410
|
const metadata = {
|
|
2436
2411
|
documentId: docMeta.documentId || documentId,
|
|
2437
2412
|
title: docMeta.title,
|
|
2438
2413
|
tags: docMeta.tags || [],
|
|
2439
2414
|
...(Number(docMeta?.documentFormat) === 2 ? { documentFormat: 2 } : {}),
|
|
2415
|
+
...(Number(docMeta?.documentFormat) === 3 ? { documentFormat: 3 } : {}),
|
|
2440
2416
|
createdAt: docMeta.createdAt,
|
|
2441
2417
|
createdBy: docMeta.createdByEmail || docMeta.createdBy,
|
|
2442
2418
|
aliases: aliases.map((a) => ({
|
|
@@ -2513,135 +2489,6 @@ async function readExportBlobList(client, appId, documentId) {
|
|
|
2513
2489
|
} while (cursor);
|
|
2514
2490
|
return blobs;
|
|
2515
2491
|
}
|
|
2516
|
-
/**
|
|
2517
|
-
* Whether `docDir` holds a large document's export (#2816, behavior 20).
|
|
2518
|
-
*
|
|
2519
|
-
* Either marker is enough: `chain.json` is what the chain export writes last,
|
|
2520
|
-
* and `metadata.json` carries the format for artifacts inspected on their own.
|
|
2521
|
-
*/
|
|
2522
|
-
function largeDocumentArtifactAt(docDir) {
|
|
2523
|
-
if (fs.existsSync(path.join(docDir, "chain.json")))
|
|
2524
|
-
return true;
|
|
2525
|
-
const metadataPath = path.join(docDir, "metadata.json");
|
|
2526
|
-
if (!fs.existsSync(metadataPath))
|
|
2527
|
-
return false;
|
|
2528
|
-
try {
|
|
2529
|
-
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
|
|
2530
|
-
return Number(metadata?.documentFormat) === 2;
|
|
2531
|
-
}
|
|
2532
|
-
catch {
|
|
2533
|
-
return false;
|
|
2534
|
-
}
|
|
2535
|
-
}
|
|
2536
|
-
/**
|
|
2537
|
-
* Read (and vouch for) a large document's chain artifact.
|
|
2538
|
-
*
|
|
2539
|
-
* `chain.json` is what the export writes LAST, so its absence means the
|
|
2540
|
-
* artifact is partial — a directory recognizable as a large document with no
|
|
2541
|
-
* description of what to install. Importing that as a legacy document would
|
|
2542
|
-
* read a `document.yjs` that is not there, create an empty document, and
|
|
2543
|
-
* report success: total data loss from the operator's point of view, since the
|
|
2544
|
-
* export is the only copy they were told they had. Refusing is the honest
|
|
2545
|
-
* answer, and it happens before anything is created.
|
|
2546
|
-
*/
|
|
2547
|
-
function readChainArtifact(docDir, documentId) {
|
|
2548
|
-
const chainPath = path.join(docDir, "chain.json");
|
|
2549
|
-
if (!fs.existsSync(chainPath)) {
|
|
2550
|
-
throw new Error(`Document ${documentId} was exported as a large document (documentFormat 2), ` +
|
|
2551
|
-
`but ${chainPath} is missing, so this artifact is incomplete. A large document's ` +
|
|
2552
|
-
`data IS its chain — a base snapshot, the sealed overlays after it, and the open ` +
|
|
2553
|
-
`epoch — and importing this directory as a legacy document would create an EMPTY ` +
|
|
2554
|
-
`document. Nothing was imported.`);
|
|
2555
|
-
}
|
|
2556
|
-
let chain;
|
|
2557
|
-
try {
|
|
2558
|
-
chain = JSON.parse(fs.readFileSync(chainPath, "utf-8"));
|
|
2559
|
-
}
|
|
2560
|
-
catch (parseError) {
|
|
2561
|
-
throw new Error(`Document ${documentId}: ${chainPath} is not readable as a chain description ` +
|
|
2562
|
-
`(${parseError.message}), so nothing was imported.`);
|
|
2563
|
-
}
|
|
2564
|
-
if (!Number.isSafeInteger(Number(chain?.epoch))) {
|
|
2565
|
-
throw new Error(`Document ${documentId}: ${chainPath} names no open epoch, so nothing was imported.`);
|
|
2566
|
-
}
|
|
2567
|
-
const artifact = {
|
|
2568
|
-
epoch: Number(chain.epoch),
|
|
2569
|
-
base: chain.base
|
|
2570
|
-
? {
|
|
2571
|
-
epoch: Number(chain.base.epoch),
|
|
2572
|
-
buildId: String(chain.base.buildId),
|
|
2573
|
-
rows: Number(chain.base.rows ?? 0),
|
|
2574
|
-
}
|
|
2575
|
-
: null,
|
|
2576
|
-
overlays: (Array.isArray(chain.overlays) ? chain.overlays : []).map((overlay) => ({ epoch: Number(overlay.epoch) })),
|
|
2577
|
-
};
|
|
2578
|
-
verifyChainArtifact(docDir, documentId, artifact);
|
|
2579
|
-
return artifact;
|
|
2580
|
-
}
|
|
2581
|
-
/**
|
|
2582
|
-
* Check that the directory holds every file the chain names, and that the
|
|
2583
|
-
* chain is one.
|
|
2584
|
-
*
|
|
2585
|
-
* `chain.json` being present says the export finished writing; it does not say
|
|
2586
|
-
* the files are there, and until this check the first missing chunk was found
|
|
2587
|
-
* AFTER the destination document had been created — leaving an empty document
|
|
2588
|
-
* behind, and in a multi-document import an app half-populated. The chain's
|
|
2589
|
-
* shape is checked here too, for the same reason the server checks it: a
|
|
2590
|
-
* chain with an epoch missing from the middle installs cleanly and is missing
|
|
2591
|
-
* everything that epoch held.
|
|
2592
|
-
*/
|
|
2593
|
-
function verifyChainArtifact(docDir, documentId, chain) {
|
|
2594
|
-
const refuse = (reason) => {
|
|
2595
|
-
throw new Error(`Document ${documentId}: its large-document export ${reason}, so nothing was imported.`);
|
|
2596
|
-
};
|
|
2597
|
-
const from = chain.base ? chain.base.epoch : 1;
|
|
2598
|
-
const expected = [];
|
|
2599
|
-
for (let epoch = from; epoch < chain.epoch; epoch++)
|
|
2600
|
-
expected.push(epoch);
|
|
2601
|
-
const named = chain.overlays
|
|
2602
|
-
.map((overlay) => overlay.epoch)
|
|
2603
|
-
.sort((left, right) => left - right);
|
|
2604
|
-
if (named.length !== expected.length ||
|
|
2605
|
-
named.some((epoch, index) => epoch !== expected[index])) {
|
|
2606
|
-
refuse(`describes sealed overlays [${named.join(", ")}] where the chain from ` +
|
|
2607
|
-
(chain.base ? `its base at epoch ${chain.base.epoch}` : "epoch 1") +
|
|
2608
|
-
` to its open epoch ${chain.epoch} is [${expected.join(", ")}]`);
|
|
2609
|
-
}
|
|
2610
|
-
const require = (file, what) => {
|
|
2611
|
-
if (!fs.existsSync(file))
|
|
2612
|
-
refuse(`is missing ${what} (${file})`);
|
|
2613
|
-
};
|
|
2614
|
-
if (chain.base) {
|
|
2615
|
-
const manifestPath = path.join(docDir, "snapshot", "manifest.json");
|
|
2616
|
-
require(manifestPath, "its base snapshot's manifest");
|
|
2617
|
-
let manifest;
|
|
2618
|
-
try {
|
|
2619
|
-
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
2620
|
-
}
|
|
2621
|
-
catch (parseError) {
|
|
2622
|
-
refuse(`has a base snapshot manifest that is not readable ` +
|
|
2623
|
-
`(${parseError.message})`);
|
|
2624
|
-
}
|
|
2625
|
-
// Validated where it enters (#3432): the fields the server keys on — an
|
|
2626
|
-
// entry's ordinal, its path, its range — fail silently rather than loudly
|
|
2627
|
-
// if they are wrong, and the server refuses the same manifest later with
|
|
2628
|
-
// the same message.
|
|
2629
|
-
try {
|
|
2630
|
-
validateSnapshotManifest(manifest);
|
|
2631
|
-
}
|
|
2632
|
-
catch (invalid) {
|
|
2633
|
-
refuse(`has a base snapshot manifest that is not valid (${invalid.message})`);
|
|
2634
|
-
}
|
|
2635
|
-
for (const chunk of manifest.chunks ?? []) {
|
|
2636
|
-
const { model, index } = chunkFileOf(chunk);
|
|
2637
|
-
require(path.join(docDir, "snapshot", model, `${index}.ndjson.gz`), `chunk ${index} of ${model}`);
|
|
2638
|
-
}
|
|
2639
|
-
}
|
|
2640
|
-
for (const overlay of chain.overlays) {
|
|
2641
|
-
require(path.join(docDir, "epochs", `${overlay.epoch}.yjs`), `the archived overlay of epoch ${overlay.epoch}`);
|
|
2642
|
-
}
|
|
2643
|
-
require(path.join(docDir, "current.yjs"), "its open epoch's overlay");
|
|
2644
|
-
}
|
|
2645
2492
|
/**
|
|
2646
2493
|
* One idempotent write of an import, retried through a transient failure
|
|
2647
2494
|
* (#4148): a blob PUT, a state merge, an artifact upload or an install read.
|
|
@@ -2651,109 +2498,21 @@ function retryingImportStep(what, attempt) {
|
|
|
2651
2498
|
return withTransientRetry(attempt, (failure, delayMs) => warn(`${what} failed (${failure?.message || "no reason given"}); ` +
|
|
2652
2499
|
`retrying in ${delayMs / 1000}s`));
|
|
2653
2500
|
}
|
|
2654
|
-
/** Upload one chain artifact; the server stores it under a fixed key. */
|
|
2655
|
-
function uploadChainArtifact(client, appId, documentId, query, body) {
|
|
2656
|
-
const what = query.kind === "chunk"
|
|
2657
|
-
? `chunk ${query.index} of ${query.model}`
|
|
2658
|
-
: query.kind === "overlay"
|
|
2659
|
-
? `the overlay of epoch ${query.epoch}`
|
|
2660
|
-
: "the base snapshot's manifest";
|
|
2661
|
-
return retryingImportStep(`Uploading ${what} of ${documentId}`, () => client.uploadDocumentChainArtifact(appId, documentId, query, body));
|
|
2662
|
-
}
|
|
2663
|
-
/** How long an install is waited on before the CLI gives up on it. */
|
|
2664
|
-
const CHAIN_INSTALL_TIMEOUT_MS = 60 * 60 * 1000;
|
|
2665
|
-
/** How often the install's progress is asked for. */
|
|
2666
|
-
const CHAIN_INSTALL_POLL_MS = 1000;
|
|
2667
|
-
/**
|
|
2668
|
-
* Install a large document's chain export into a freshly created document
|
|
2669
|
-
* (#2816, behavior 20).
|
|
2670
|
-
*
|
|
2671
|
-
* Every artifact is uploaded byte-for-byte into the new document's own prefix
|
|
2672
|
-
* — the manifest's digests are over the STORED bytes, so re-encoding a chunk
|
|
2673
|
-
* here would fail its own verification on install — and then the server
|
|
2674
|
-
* streams them into the document's `records` table a tick at a time. The full
|
|
2675
|
-
* document is never built as a Y.Doc on either side, which is what makes
|
|
2676
|
-
* importing a multi-hundred-MB artifact possible at all; it is also the
|
|
2677
|
-
* efficient way to bulk-seed a new large document.
|
|
2678
|
-
*/
|
|
2679
|
-
async function importLargeDocumentChain(client, appId, docDir, documentId) {
|
|
2680
|
-
const chain = readChainArtifact(docDir, documentId);
|
|
2681
|
-
await uploadAndInstallChain(client, appId, docDir, documentId, chain);
|
|
2682
|
-
await awaitChainInstall(client, appId, documentId);
|
|
2683
|
-
}
|
|
2684
|
-
/** Upload every artifact of the chain, then start installing it. */
|
|
2685
|
-
async function uploadAndInstallChain(client, appId, docDir, documentId, chain) {
|
|
2686
|
-
// The base's manifest and chunks. The manifest names the chunks, so nothing
|
|
2687
|
-
// here has to walk the directory and guess what belongs to the build.
|
|
2688
|
-
if (chain.base) {
|
|
2689
|
-
const manifestPath = path.join(docDir, "snapshot", "manifest.json");
|
|
2690
|
-
const manifestBytes = fs.readFileSync(manifestPath);
|
|
2691
|
-
await uploadChainArtifact(client, appId, documentId, {
|
|
2692
|
-
kind: "manifest",
|
|
2693
|
-
epoch: chain.base.epoch,
|
|
2694
|
-
buildId: chain.base.buildId,
|
|
2695
|
-
}, manifestBytes);
|
|
2696
|
-
const manifest = JSON.parse(manifestBytes.toString("utf-8"));
|
|
2697
|
-
for (const chunk of manifest.chunks ?? []) {
|
|
2698
|
-
// Addressed by the entry's `path` (#3432); the server installs by it too.
|
|
2699
|
-
const { model, index } = chunkFileOf(chunk);
|
|
2700
|
-
await uploadChainArtifact(client, appId, documentId, {
|
|
2701
|
-
kind: "chunk",
|
|
2702
|
-
epoch: chain.base.epoch,
|
|
2703
|
-
buildId: chain.base.buildId,
|
|
2704
|
-
model,
|
|
2705
|
-
index,
|
|
2706
|
-
}, fs.readFileSync(path.join(docDir, "snapshot", model, `${index}.ndjson.gz`)));
|
|
2707
|
-
}
|
|
2708
|
-
}
|
|
2709
|
-
// The sealed overlays, oldest first, then the open epoch's — which travels
|
|
2710
|
-
// archived exactly like a sealed one, so the imported document's chain is
|
|
2711
|
-
// the chain that was exported.
|
|
2712
|
-
for (const overlay of chain.overlays) {
|
|
2713
|
-
await uploadChainArtifact(client, appId, documentId, { kind: "overlay", epoch: overlay.epoch }, fs.readFileSync(path.join(docDir, "epochs", `${overlay.epoch}.yjs`)));
|
|
2714
|
-
}
|
|
2715
|
-
await uploadChainArtifact(client, appId, documentId, { kind: "overlay", epoch: chain.epoch }, fs.readFileSync(path.join(docDir, "current.yjs")));
|
|
2716
|
-
await client.installDocumentChain(appId, documentId, {
|
|
2717
|
-
base: chain.base
|
|
2718
|
-
? { epoch: chain.base.epoch, buildId: chain.base.buildId }
|
|
2719
|
-
: null,
|
|
2720
|
-
overlays: chain.overlays.map((overlay) => overlay.epoch),
|
|
2721
|
-
currentEpoch: chain.epoch,
|
|
2722
|
-
});
|
|
2723
|
-
}
|
|
2724
|
-
/**
|
|
2725
|
-
* Wait for the document's chain install to finish.
|
|
2726
|
-
*
|
|
2727
|
-
* The install runs on the document's alarm, a bounded tick at a time, so it
|
|
2728
|
-
* is waited on rather than awaited: reporting "imported" while the records
|
|
2729
|
-
* are still arriving is the failure this whole path exists to avoid.
|
|
2730
|
-
*/
|
|
2731
|
-
async function awaitChainInstall(client, appId, documentId) {
|
|
2732
|
-
const deadline = Date.now() + CHAIN_INSTALL_TIMEOUT_MS;
|
|
2733
|
-
for (;;) {
|
|
2734
|
-
const status = await retryingImportStep(`Reading the chain install of ${documentId}`, () => client.getDocumentChainInstall(appId, documentId));
|
|
2735
|
-
const install = status?.install;
|
|
2736
|
-
if (install?.state === "complete")
|
|
2737
|
-
return;
|
|
2738
|
-
if (install?.state === "failed") {
|
|
2739
|
-
throw new Error(`Document ${documentId}: installing its chain failed — ` +
|
|
2740
|
-
`${install.error || "no reason given"}. The document is incomplete; ` +
|
|
2741
|
-
`re-run the import to install it again.`);
|
|
2742
|
-
}
|
|
2743
|
-
if (Date.now() >= deadline) {
|
|
2744
|
-
throw new Error(`Document ${documentId}: its chain install has not finished after ` +
|
|
2745
|
-
`${Math.round(CHAIN_INSTALL_TIMEOUT_MS / 60000)} minutes. It continues ` +
|
|
2746
|
-
`on the server; check with 'primitive documents get ${documentId}'.`);
|
|
2747
|
-
}
|
|
2748
|
-
await new Promise((resolve) => setTimeout(resolve, CHAIN_INSTALL_POLL_MS));
|
|
2749
|
-
}
|
|
2750
|
-
}
|
|
2751
2501
|
/** Mirrors `ROOT_DOCUMENT_TAG` in `src/config/constants.ts`. */
|
|
2752
2502
|
const ROOT_DOCUMENT_TAG = "__ROOT_TAG__";
|
|
2753
2503
|
/** Does this `metadata.json` describe somebody's root document? */
|
|
2754
2504
|
function isRootDocumentExport(tags) {
|
|
2755
2505
|
return Array.isArray(tags) && tags.includes(ROOT_DOCUMENT_TAG);
|
|
2756
2506
|
}
|
|
2507
|
+
/**
|
|
2508
|
+
* Whether `docDir` holds a large document's (format 2) export: the
|
|
2509
|
+
* `chain.json` its export wrote last, or a `metadata.json` naming format 2.
|
|
2510
|
+
*/
|
|
2511
|
+
function isLargeDocumentExport(docDir) {
|
|
2512
|
+
if (fs.existsSync(path.join(docDir, "chain.json")))
|
|
2513
|
+
return true;
|
|
2514
|
+
return Number(readExportMetadata(docDir)?.documentFormat) === 2;
|
|
2515
|
+
}
|
|
2757
2516
|
/** Read an export directory's `metadata.json`, or null when it has none. */
|
|
2758
2517
|
function readExportMetadata(docDir) {
|
|
2759
2518
|
const metadataPath = path.join(docDir, "metadata.json");
|
|
@@ -2967,20 +2726,10 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
|
|
|
2967
2726
|
// A root-marked export is never restored under its exported id: that id is
|
|
2968
2727
|
// the SOURCE user's root document. It goes to the target user's own root
|
|
2969
2728
|
// instead, per the plan made before this run wrote anything (#3135).
|
|
2970
|
-
//
|
|
2971
|
-
// Decided BEFORE the chain artifact below is read: a root export's chain is
|
|
2972
|
-
// installed into the target user's root, never under its exported id, and
|
|
2973
|
-
// only when the plan says so (#4195) — reading it here would fail the whole
|
|
2974
|
-
// run over an artifact a refused or skipped plan never touches.
|
|
2975
2729
|
if (isRootDocumentExport(metadata.tags)) {
|
|
2976
2730
|
await importRootDocument(client, appId, docDir, metadata, rootPlan, overwrite, aliasMode, dryRun, summary, progress);
|
|
2977
2731
|
return;
|
|
2978
2732
|
}
|
|
2979
|
-
// A large document's data is the chain, not `document.yjs` (#2816), and it
|
|
2980
|
-
// is installed rather than replayed. Vouched for before anything is created.
|
|
2981
|
-
const isLargeDocument = largeDocumentArtifactAt(docDir);
|
|
2982
|
-
if (isLargeDocument)
|
|
2983
|
-
readChainArtifact(docDir, documentId);
|
|
2984
2733
|
// `--dry-run` decides from the same facts the real run does (#3096), so a
|
|
2985
2734
|
// preview never promises a skip that the run turns into a merge: it looks
|
|
2986
2735
|
// the document up as well, and only the writes below are withheld.
|
|
@@ -2989,16 +2738,6 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
|
|
|
2989
2738
|
progress.exists = exists;
|
|
2990
2739
|
const wouldSkip = dryRun ? "[dry-run] Would skip" : "Skipping";
|
|
2991
2740
|
if (exists && !overwrite) {
|
|
2992
|
-
// A same-id document of the other format is not one this import created:
|
|
2993
|
-
// the format is decided at creation and never migrated. It is skipped
|
|
2994
|
-
// before any read of what it lacks, which its format's endpoints refuse.
|
|
2995
|
-
if ((Number(existing?.documentFormat) === 2) !== isLargeDocument) {
|
|
2996
|
-
warn(`${wouldSkip} ${documentId}: already exists as a ` +
|
|
2997
|
-
`${isLargeDocument ? "legacy" : "large"} document, and the export ` +
|
|
2998
|
-
`is a ${isLargeDocument ? "large" : "legacy"} one`);
|
|
2999
|
-
summary.skipped++;
|
|
3000
|
-
return;
|
|
3001
|
-
}
|
|
3002
2741
|
// A document an earlier run created and did not finish is completed, not
|
|
3003
2742
|
// skipped (#4148): what it lacks of the export is read from the server,
|
|
3004
2743
|
// and only that is written. A document that lacks nothing, or that holds
|
|
@@ -3006,24 +2745,9 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
|
|
|
3006
2745
|
// re-uploads to one already imported, nor writes to one it did not start.
|
|
3007
2746
|
// With an owner named, the export's user aliases are among what it can
|
|
3008
2747
|
// lack.
|
|
3009
|
-
const gaps = await findImportGaps(client, appId, docDir, documentId,
|
|
2748
|
+
const gaps = await findImportGaps(client, appId, docDir, documentId, ownerUserId
|
|
3010
2749
|
? { userId: ownerUserId, aliasMode, exported: metadata.aliases }
|
|
3011
2750
|
: undefined);
|
|
3012
|
-
// Never installed and not empty: a document of its own, which the server
|
|
3013
|
-
// would refuse to install into.
|
|
3014
|
-
const skipHoldingRecords = (held = "holds records of its own") => {
|
|
3015
|
-
warn(`${wouldSkip} ${documentId}: already exists and ${held}, so its ` +
|
|
3016
|
-
`large-document export cannot be installed into it`);
|
|
3017
|
-
summary.skipped++;
|
|
3018
|
-
};
|
|
3019
|
-
if (gaps === "holds-records") {
|
|
3020
|
-
skipHoldingRecords();
|
|
3021
|
-
return;
|
|
3022
|
-
}
|
|
3023
|
-
if (gaps === "rotated-epoch") {
|
|
3024
|
-
skipHoldingRecords("has rotated an epoch of its own");
|
|
3025
|
-
return;
|
|
3026
|
-
}
|
|
3027
2751
|
if (gaps) {
|
|
3028
2752
|
if (dryRun) {
|
|
3029
2753
|
info(`[dry-run] Would complete document ${documentId} ` +
|
|
@@ -3033,22 +2757,6 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
|
|
|
3033
2757
|
}
|
|
3034
2758
|
if (gaps.state)
|
|
3035
2759
|
await importExportedState(client, appId, docDir, documentId);
|
|
3036
|
-
if (gaps.chain === "install") {
|
|
3037
|
-
try {
|
|
3038
|
-
await uploadAndInstallChain(client, appId, docDir, documentId, readChainArtifact(docDir, documentId));
|
|
3039
|
-
}
|
|
3040
|
-
catch (refusal) {
|
|
3041
|
-
// The server's own check, at the first artifact and before it
|
|
3042
|
-
// stores anything: it decides when the document changed after the
|
|
3043
|
-
// reads above.
|
|
3044
|
-
if (refusal?.code !== "DOCUMENT_NOT_IMPORTABLE")
|
|
3045
|
-
throw refusal;
|
|
3046
|
-
skipHoldingRecords();
|
|
3047
|
-
return;
|
|
3048
|
-
}
|
|
3049
|
-
}
|
|
3050
|
-
if (gaps.chain)
|
|
3051
|
-
await awaitChainInstall(client, appId, documentId);
|
|
3052
2760
|
await uploadExportedBlobs(client, appId, documentId, docDir, gaps.blobs, summary);
|
|
3053
2761
|
await restoreExportedAliases(client, appId, documentId, ownerUserId ? { ...metadata, aliases: gaps.aliases } : metadata, ownerUserId, aliasMode, summary);
|
|
3054
2762
|
summary.completed++;
|
|
@@ -3056,17 +2764,8 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
|
|
|
3056
2764
|
`imported ${describeImportGaps(gaps)}`);
|
|
3057
2765
|
return;
|
|
3058
2766
|
}
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
// that already holds records (`assertDocumentIsImportable`), so the remedy
|
|
3062
|
-
// is only honest for the format the state path handles.
|
|
3063
|
-
warn(isLargeDocument
|
|
3064
|
-
? `${wouldSkip} ${documentId}: already exists, and ` +
|
|
3065
|
-
`a large-document export is installed rather than merged — ` +
|
|
3066
|
-
`--overwrite can only install it into a document that holds ` +
|
|
3067
|
-
`nothing yet`
|
|
3068
|
-
: `${wouldSkip} ${documentId}: already exists (use --overwrite to merge ` +
|
|
3069
|
-
`the exported state into it)`);
|
|
2767
|
+
warn(`${wouldSkip} ${documentId}: already exists (use --overwrite to merge ` +
|
|
2768
|
+
`the exported state into it)`);
|
|
3070
2769
|
summary.skipped++;
|
|
3071
2770
|
return;
|
|
3072
2771
|
}
|
|
@@ -3079,8 +2778,7 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
|
|
|
3079
2778
|
summary.created++;
|
|
3080
2779
|
return;
|
|
3081
2780
|
}
|
|
3082
|
-
// Create document if it doesn't exist.
|
|
3083
|
-
// never migrated, so a large document has to be created as one (#2816).
|
|
2781
|
+
// Create document if it doesn't exist.
|
|
3084
2782
|
//
|
|
3085
2783
|
// Tags go back exactly as `metadata.json` recorded them (#3096): an export
|
|
3086
2784
|
// is a faithful record of what the document is, and editing the list on the
|
|
@@ -3090,7 +2788,6 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
|
|
|
3090
2788
|
title: metadata.title || "Imported Document",
|
|
3091
2789
|
documentId,
|
|
3092
2790
|
tags: metadata.tags,
|
|
3093
|
-
...(isLargeDocument ? { documentFormat: 2 } : {}),
|
|
3094
2791
|
...(ownerUserId ? { createdBy: ownerUserId } : {}),
|
|
3095
2792
|
});
|
|
3096
2793
|
progress.exists = true;
|
|
@@ -3099,14 +2796,7 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
|
|
|
3099
2796
|
else {
|
|
3100
2797
|
summary.updated++;
|
|
3101
2798
|
}
|
|
3102
|
-
|
|
3103
|
-
// The chain, installed: streamed into the document's records table on the
|
|
3104
|
-
// server, never replayed here as one Yjs update.
|
|
3105
|
-
await importLargeDocumentChain(client, appId, docDir, documentId);
|
|
3106
|
-
}
|
|
3107
|
-
else {
|
|
3108
|
-
await importExportedState(client, appId, docDir, documentId);
|
|
3109
|
-
}
|
|
2799
|
+
await importExportedState(client, appId, docDir, documentId);
|
|
3110
2800
|
await uploadExportedBlobs(client, appId, documentId, docDir, exportedBlobsOf(docDir), summary);
|
|
3111
2801
|
// Permissions are exported for reference but not restored during import.
|
|
3112
2802
|
// The importing admin is the new owner; sharing is managed in the target app.
|
|
@@ -3173,10 +2863,6 @@ function describeImportGaps(gaps) {
|
|
|
3173
2863
|
const parts = [];
|
|
3174
2864
|
if (gaps.state)
|
|
3175
2865
|
parts.push("its state");
|
|
3176
|
-
if (gaps.chain === "install")
|
|
3177
|
-
parts.push("its chain");
|
|
3178
|
-
if (gaps.chain === "await")
|
|
3179
|
-
parts.push("its chain (installing)");
|
|
3180
2866
|
if (gaps.blobs.length > 0) {
|
|
3181
2867
|
parts.push(`${gaps.blobs.length} blob${gaps.blobs.length === 1 ? "" : "s"}`);
|
|
3182
2868
|
}
|
|
@@ -3219,76 +2905,36 @@ function holdsYjsUpdate(held, exported) {
|
|
|
3219
2905
|
return Y.equalSnapshots(before, Y.snapshot(doc));
|
|
3220
2906
|
}
|
|
3221
2907
|
/**
|
|
3222
|
-
* Read what an existing document lacks of its export (#4148): the gaps,
|
|
3223
|
-
* when there is nothing a re-run may write
|
|
3224
|
-
* `"rotated-epoch"` for a large document that was never imported into and
|
|
3225
|
-
* holds records or has rotated an epoch of its own.
|
|
2908
|
+
* Read what an existing document lacks of its export (#4148): the gaps, or
|
|
2909
|
+
* null when there is nothing a re-run may write.
|
|
3226
2910
|
*
|
|
3227
|
-
* A
|
|
3228
|
-
*
|
|
3229
|
-
*
|
|
3230
|
-
*
|
|
3231
|
-
*
|
|
3232
|
-
*
|
|
3233
|
-
*
|
|
3234
|
-
*
|
|
3235
|
-
*
|
|
3236
|
-
*
|
|
3237
|
-
* lists. When `aliasOwner` is given, the export's user aliases are read as
|
|
3238
|
-
* well: one the owner holds for another document counts as missing only when
|
|
3239
|
-
* the alias mode would move it.
|
|
2911
|
+
* A document is only completed when it is the one an earlier run started: it
|
|
2912
|
+
* holds no state at all — what `createDocument` leaves, and what a failed
|
|
2913
|
+
* `import/state` leaves, since the server stores the merge before it applies
|
|
2914
|
+
* it — or it already holds the exported state, and then only its blobs can be
|
|
2915
|
+
* missing. A document holding state of its own is not written to at all,
|
|
2916
|
+
* neither merged into nor given blobs or aliases: that is `--overwrite`'s
|
|
2917
|
+
* decision, not a re-run's. Blobs are compared by id against every page the
|
|
2918
|
+
* document lists. When `aliasOwner` is given, the export's user aliases are
|
|
2919
|
+
* read as well: one the owner holds for another document counts as missing
|
|
2920
|
+
* only when the alias mode would move it.
|
|
3240
2921
|
*/
|
|
3241
|
-
async function findImportGaps(client, appId, docDir, documentId,
|
|
2922
|
+
async function findImportGaps(client, appId, docDir, documentId, aliasOwner) {
|
|
3242
2923
|
let state = false;
|
|
3243
|
-
let chain = null;
|
|
3244
2924
|
const exported = exportedBlobsOf(docDir);
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
if (chain === "install" && !status?.install) {
|
|
3256
|
-
const schema = await retryingImportStep(`Reading the records of ${documentId}`, () => client.getDocumentSchema(appId, documentId));
|
|
3257
|
-
if (Array.isArray(schema?.tableModels) && schema.tableModels.length > 0) {
|
|
3258
|
-
return "holds-records";
|
|
3259
|
-
}
|
|
3260
|
-
// The server refuses one past its first epoch as well. Its open epoch is
|
|
3261
|
-
// the one its chain ends on; a chain it cannot describe has sealed an
|
|
3262
|
-
// epoch, which only a rotation does.
|
|
3263
|
-
let openEpoch;
|
|
3264
|
-
try {
|
|
3265
|
-
const described = await retryingImportStep(`Reading the epoch of ${documentId}`, () => client.exportDocumentChain(appId, documentId));
|
|
3266
|
-
openEpoch = Number(described?.epoch);
|
|
3267
|
-
}
|
|
3268
|
-
catch (refusal) {
|
|
3269
|
-
if (refusal?.code !== "EXPORT_CHAIN_INCOMPLETE")
|
|
3270
|
-
throw refusal;
|
|
3271
|
-
return "rotated-epoch";
|
|
3272
|
-
}
|
|
3273
|
-
if (openEpoch !== 1)
|
|
3274
|
-
return "rotated-epoch";
|
|
3275
|
-
}
|
|
2925
|
+
const exportedState = exportedStateOf(docDir);
|
|
2926
|
+
// An export with no state and no blobs can still carry a root's aliases.
|
|
2927
|
+
const exportsUserAliases = (Array.isArray(aliasOwner?.exported) ? aliasOwner.exported : [])
|
|
2928
|
+
.some((alias) => alias?.aliasScope === "user");
|
|
2929
|
+
if (!exportedState && exported.length === 0 && !exportsUserAliases)
|
|
2930
|
+
return null;
|
|
2931
|
+
const held = await retryingImportStep(`Reading the state of ${documentId}`, () => client.exportDocumentState(appId, documentId));
|
|
2932
|
+
const heldState = Buffer.from(String(held?.state ?? ""), "base64");
|
|
2933
|
+
if (isEmptyYjsUpdate(heldState)) {
|
|
2934
|
+
state = exportedState !== null;
|
|
3276
2935
|
}
|
|
3277
|
-
else {
|
|
3278
|
-
|
|
3279
|
-
// An export with no state and no blobs can still carry a root's aliases.
|
|
3280
|
-
const exportsUserAliases = (Array.isArray(aliasOwner?.exported) ? aliasOwner.exported : [])
|
|
3281
|
-
.some((alias) => alias?.aliasScope === "user");
|
|
3282
|
-
if (!exportedState && exported.length === 0 && !exportsUserAliases)
|
|
3283
|
-
return null;
|
|
3284
|
-
const held = await retryingImportStep(`Reading the state of ${documentId}`, () => client.exportDocumentState(appId, documentId));
|
|
3285
|
-
const heldState = Buffer.from(String(held?.state ?? ""), "base64");
|
|
3286
|
-
if (isEmptyYjsUpdate(heldState)) {
|
|
3287
|
-
state = exportedState !== null;
|
|
3288
|
-
}
|
|
3289
|
-
else if (!exportedState || !holdsYjsUpdate(heldState, exportedState)) {
|
|
3290
|
-
return null;
|
|
3291
|
-
}
|
|
2936
|
+
else if (!exportedState || !holdsYjsUpdate(heldState, exportedState)) {
|
|
2937
|
+
return null;
|
|
3292
2938
|
}
|
|
3293
2939
|
let blobs = [];
|
|
3294
2940
|
if (exported.length > 0) {
|
|
@@ -3298,8 +2944,8 @@ async function findImportGaps(client, appId, docDir, documentId, isLargeDocument
|
|
|
3298
2944
|
const aliases = aliasOwner
|
|
3299
2945
|
? await findMissingAliases(client, appId, documentId, aliasOwner)
|
|
3300
2946
|
: [];
|
|
3301
|
-
return state ||
|
|
3302
|
-
? { state,
|
|
2947
|
+
return state || blobs.length > 0 || aliases.length > 0
|
|
2948
|
+
? { state, blobs, aliases }
|
|
3303
2949
|
: null;
|
|
3304
2950
|
}
|
|
3305
2951
|
/** The form the server stores an alias key in. */
|
|
@@ -3351,8 +2997,7 @@ async function listedBlobIds(client, appId, documentId) {
|
|
|
3351
2997
|
}
|
|
3352
2998
|
/**
|
|
3353
2999
|
* Restore a root-marked export into the target user's own root document
|
|
3354
|
-
* (#3135)
|
|
3355
|
-
* root, a large-document export's chain is installed into a large root.
|
|
3000
|
+
* (#3135): its state is merged into the target user's root.
|
|
3356
3001
|
*
|
|
3357
3002
|
* The exported id is never created and never looked up: it is the source
|
|
3358
3003
|
* user's root id, which means nothing in this app. Everything — the Yjs state,
|
|
@@ -3379,7 +3024,6 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
|
|
|
3379
3024
|
...(targetRootDocId ? { targetRootDocId } : {}),
|
|
3380
3025
|
action,
|
|
3381
3026
|
...(reason ? { reason } : {}),
|
|
3382
|
-
...(targetFormat === 2 ? { documentFormat: 2 } : {}),
|
|
3383
3027
|
});
|
|
3384
3028
|
};
|
|
3385
3029
|
if (!plan || plan.action === "refused") {
|
|
@@ -3395,21 +3039,17 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
|
|
|
3395
3039
|
// What a root an earlier run started still lacks (#4148), when completing it.
|
|
3396
3040
|
let gaps = null;
|
|
3397
3041
|
if (plan.action === "skip") {
|
|
3398
|
-
// The root exists and `--overwrite` was not given. A
|
|
3399
|
-
//
|
|
3400
|
-
//
|
|
3401
|
-
//
|
|
3402
|
-
// merged into. A large root is skipped as before: a chain is installed
|
|
3403
|
-
// only into a root that holds nothing, which `--overwrite` asks for.
|
|
3042
|
+
// The root exists and `--overwrite` was not given. A root an earlier run
|
|
3043
|
+
// started and did not finish is completed, by the same rule as any
|
|
3044
|
+
// document: it holds no state, or already holds the exported state. A
|
|
3045
|
+
// root holding state of its own — its user's — is never merged into.
|
|
3404
3046
|
progress.exists = true;
|
|
3405
|
-
const found =
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
});
|
|
3412
|
-
if (!found || typeof found === "string") {
|
|
3047
|
+
const found = await findImportGaps(client, appId, docDir, targetRootDocId, {
|
|
3048
|
+
userId: plan.targetUserId,
|
|
3049
|
+
aliasMode,
|
|
3050
|
+
exported: metadata.aliases,
|
|
3051
|
+
});
|
|
3052
|
+
if (!found) {
|
|
3413
3053
|
const reason = plan.reason ||
|
|
3414
3054
|
`the root document of ${plan.owner} already exists ` +
|
|
3415
3055
|
`(${targetRootDocId}); use --overwrite to merge the exported ` +
|
|
@@ -3421,7 +3061,6 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
|
|
|
3421
3061
|
}
|
|
3422
3062
|
gaps = found;
|
|
3423
3063
|
}
|
|
3424
|
-
const install = plan.mode === "install";
|
|
3425
3064
|
if (dryRun) {
|
|
3426
3065
|
if (gaps) {
|
|
3427
3066
|
info(`[dry-run] Would complete root document export ${exportedDocumentId} ` +
|
|
@@ -3431,19 +3070,14 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
|
|
|
3431
3070
|
report("completed", targetRootDocId);
|
|
3432
3071
|
}
|
|
3433
3072
|
else if (plan.action === "create") {
|
|
3434
|
-
info(
|
|
3435
|
-
|
|
3436
|
-
`${plan.owner} and install root document export ` +
|
|
3437
|
-
`${exportedDocumentId} into it`
|
|
3438
|
-
: `[dry-run] Would create the root document for ${plan.owner} and ` +
|
|
3439
|
-
`apply root document export ${exportedDocumentId} to it`);
|
|
3073
|
+
info(`[dry-run] Would create the root document for ${plan.owner} and ` +
|
|
3074
|
+
`apply root document export ${exportedDocumentId} to it`);
|
|
3440
3075
|
summary.created++;
|
|
3441
3076
|
report("created", undefined, undefined, plan.targetFormat);
|
|
3442
3077
|
}
|
|
3443
3078
|
else {
|
|
3444
|
-
info(`[dry-run] Would
|
|
3445
|
-
`
|
|
3446
|
-
`${plan.owner}'s root document ${plan.targetRootDocId}`);
|
|
3079
|
+
info(`[dry-run] Would apply root document export ${exportedDocumentId} ` +
|
|
3080
|
+
`to ${plan.owner}'s root document ${plan.targetRootDocId}`);
|
|
3447
3081
|
summary.updated++;
|
|
3448
3082
|
report("applied", plan.targetRootDocId, undefined, plan.targetFormat);
|
|
3449
3083
|
}
|
|
@@ -3485,8 +3119,7 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
|
|
|
3485
3119
|
if (!created && !overwrite) {
|
|
3486
3120
|
const reason = `the root document of ${plan.owner} (${targetRootDocId}) was created ` +
|
|
3487
3121
|
`by a concurrent sign-in while this import was running; use ` +
|
|
3488
|
-
`--overwrite to
|
|
3489
|
-
`content into it`;
|
|
3122
|
+
`--overwrite to merge the exported content into it`;
|
|
3490
3123
|
warn(`Skipping ${exportedDocumentId}: ${reason}`);
|
|
3491
3124
|
summary.skipped++;
|
|
3492
3125
|
report("skipped", targetRootDocId, reason, minted.targetFormat);
|
|
@@ -3496,13 +3129,7 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
|
|
|
3496
3129
|
progress.exists = true;
|
|
3497
3130
|
// Only content moves. The target root keeps its own id, title, tags and
|
|
3498
3131
|
// permissions — the ensure path already granted the owner read-write.
|
|
3499
|
-
if (
|
|
3500
|
-
// A large root's content is its chain, installed — the server refuses a
|
|
3501
|
-
// root that already holds records or has rotated, as it does for any
|
|
3502
|
-
// large document (#4195).
|
|
3503
|
-
await importLargeDocumentChain(client, appId, docDir, targetRootDocId);
|
|
3504
|
-
}
|
|
3505
|
-
else if (!gaps || gaps.state) {
|
|
3132
|
+
if (!gaps || gaps.state) {
|
|
3506
3133
|
await importExportedState(client, appId, docDir, targetRootDocId);
|
|
3507
3134
|
}
|
|
3508
3135
|
// The blobId is preserved: a document's own references travel by blobId,
|
|
@@ -3512,7 +3139,6 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
|
|
|
3512
3139
|
// so they are restored without `--owner` — the target user is already known.
|
|
3513
3140
|
// A completion restores only the ones the root is missing.
|
|
3514
3141
|
await restoreExportedAliases(client, appId, targetRootDocId, gaps ? { ...metadata, aliases: gaps.aliases } : metadata, plan.targetUserId, aliasMode, summary);
|
|
3515
|
-
const verb = install ? "installed" : "applied";
|
|
3516
3142
|
if (gaps) {
|
|
3517
3143
|
summary.completed++;
|
|
3518
3144
|
report("completed", targetRootDocId);
|
|
@@ -3523,44 +3149,14 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
|
|
|
3523
3149
|
else if (created) {
|
|
3524
3150
|
summary.created++;
|
|
3525
3151
|
report("created", targetRootDocId, undefined, plan.targetFormat);
|
|
3526
|
-
info(`Created root document ${targetRootDocId} for ${plan.owner} and
|
|
3527
|
-
`root document export ${exportedDocumentId}
|
|
3152
|
+
info(`Created root document ${targetRootDocId} for ${plan.owner} and applied ` +
|
|
3153
|
+
`root document export ${exportedDocumentId} to it`);
|
|
3528
3154
|
}
|
|
3529
3155
|
else {
|
|
3530
3156
|
summary.updated++;
|
|
3531
3157
|
report("applied", targetRootDocId, undefined, plan.targetFormat);
|
|
3532
|
-
info(
|
|
3533
|
-
`${
|
|
3534
|
-
`root document ${targetRootDocId}`);
|
|
3158
|
+
info(`Applied root document export ${exportedDocumentId} to ` +
|
|
3159
|
+
`${plan.owner}'s root document ${targetRootDocId}`);
|
|
3535
3160
|
}
|
|
3536
3161
|
}
|
|
3537
|
-
/**
|
|
3538
|
-
* Wire the audit to the app API (#3433, criterion 10).
|
|
3539
|
-
*
|
|
3540
|
-
* Two permissions, not one (3433-C08): the chain export takes the app-admin
|
|
3541
|
-
* arm, and the schema and records reads it is compared against take the
|
|
3542
|
-
* console arm or a read grant on the document — the documents surface grants
|
|
3543
|
-
* no app-role bypass on a read (#3279, D3279-002). The schema is read first,
|
|
3544
|
-
* so a caller holding only the first is refused before anything is exported.
|
|
3545
|
-
*/
|
|
3546
|
-
async function auditDocumentChain(client, appId, documentId, retries) {
|
|
3547
|
-
// One page's worth of records is the audit's whole working set on the
|
|
3548
|
-
// server side; everything else lives in the local store.
|
|
3549
|
-
const AUDIT_PAGE_SIZE = 200;
|
|
3550
|
-
const fingerprint = await createChainFingerprintReader();
|
|
3551
|
-
// Nothing the server answers is carried from one attempt to the next: the
|
|
3552
|
-
// attempt a moved document discards is exactly the one whose schema has
|
|
3553
|
-
// changed under it (3433-C09).
|
|
3554
|
-
const source = createAuditApiSource(client, {
|
|
3555
|
-
appId,
|
|
3556
|
-
documentId,
|
|
3557
|
-
pageSize: AUDIT_PAGE_SIZE,
|
|
3558
|
-
});
|
|
3559
|
-
return await auditSnapshotChain(source, {
|
|
3560
|
-
store: () => createAuditLocalStore(documentId),
|
|
3561
|
-
fingerprint,
|
|
3562
|
-
retries,
|
|
3563
|
-
pageSize: AUDIT_PAGE_SIZE,
|
|
3564
|
-
});
|
|
3565
|
-
}
|
|
3566
3162
|
//# sourceMappingURL=documents.js.map
|