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.
Files changed (114) hide show
  1. package/assets/skill/skills/primitive-platform/SKILL.md +17 -0
  2. package/dist/src/commands/auth.js +7 -4
  3. package/dist/src/commands/auth.js.map +1 -1
  4. package/dist/src/commands/connections.js +11 -12
  5. package/dist/src/commands/connections.js.map +1 -1
  6. package/dist/src/commands/database-type-configs.js +2 -76
  7. package/dist/src/commands/database-type-configs.js.map +1 -1
  8. package/dist/src/commands/database-types.js +313 -13
  9. package/dist/src/commands/database-types.js.map +1 -1
  10. package/dist/src/commands/databases.d.ts +9 -0
  11. package/dist/src/commands/databases.js +67 -367
  12. package/dist/src/commands/databases.js.map +1 -1
  13. package/dist/src/commands/documents.d.ts +32 -2
  14. package/dist/src/commands/documents.js +281 -685
  15. package/dist/src/commands/documents.js.map +1 -1
  16. package/dist/src/commands/functions.js +2 -2
  17. package/dist/src/commands/functions.js.map +1 -1
  18. package/dist/src/commands/llm.js +6 -9
  19. package/dist/src/commands/llm.js.map +1 -1
  20. package/dist/src/commands/settings.d.ts +9 -12
  21. package/dist/src/commands/settings.js +79 -31
  22. package/dist/src/commands/settings.js.map +1 -1
  23. package/dist/src/commands/sync.d.ts +31 -81
  24. package/dist/src/commands/sync.js +228 -1113
  25. package/dist/src/commands/sync.js.map +1 -1
  26. package/dist/src/lib/api-client.d.ts +30 -199
  27. package/dist/src/lib/api-client.js +31 -297
  28. package/dist/src/lib/api-client.js.map +1 -1
  29. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +6 -7
  30. package/dist/src/lib/codegen-shared/generatedFiles.js +2 -2
  31. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
  32. package/dist/src/lib/codegen-shared/prettierStable.d.ts +1 -1
  33. package/dist/src/lib/codegen-shared/prettierStable.js +1 -1
  34. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +2 -2
  35. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +2 -2
  36. package/dist/src/lib/config-object-descriptor.js +1 -5
  37. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  38. package/dist/src/lib/config-surface.d.ts +2 -1
  39. package/dist/src/lib/config-surface.js +4 -1
  40. package/dist/src/lib/config-surface.js.map +1 -1
  41. package/dist/src/lib/connections-list.d.ts +10 -0
  42. package/dist/src/lib/connections-list.js +18 -0
  43. package/dist/src/lib/connections-list.js.map +1 -0
  44. package/dist/src/lib/csv.d.ts +8 -9
  45. package/dist/src/lib/csv.js +3 -4
  46. package/dist/src/lib/csv.js.map +1 -1
  47. package/dist/src/lib/db-codegen/dbNaming.d.ts +2 -1
  48. package/dist/src/lib/db-codegen/dbNaming.js +2 -1
  49. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  50. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +2 -2
  51. package/dist/src/lib/db-codegen/dbTsTypes.js +2 -2
  52. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +59 -1
  53. package/dist/src/lib/db-codegen/dbTypeIR.js +12 -1
  54. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -1
  55. package/dist/src/lib/document-export-record-log.d.ts +57 -0
  56. package/dist/src/lib/document-export-record-log.js +215 -0
  57. package/dist/src/lib/document-export-record-log.js.map +1 -0
  58. package/dist/src/lib/document-ingest-artifact.d.ts +13 -2
  59. package/dist/src/lib/document-ingest-artifact.js +7 -3
  60. package/dist/src/lib/document-ingest-artifact.js.map +1 -1
  61. package/dist/src/lib/document-ingest-rows.d.ts +16 -0
  62. package/dist/src/lib/document-ingest-rows.js +17 -0
  63. package/dist/src/lib/document-ingest-rows.js.map +1 -1
  64. package/dist/src/lib/document-ingest.d.ts +22 -0
  65. package/dist/src/lib/document-ingest.js +33 -0
  66. package/dist/src/lib/document-ingest.js.map +1 -1
  67. package/dist/src/lib/function-triggers.d.ts +3 -15
  68. package/dist/src/lib/function-triggers.js +10 -72
  69. package/dist/src/lib/function-triggers.js.map +1 -1
  70. package/dist/src/lib/generated-allowlist.js +0 -20
  71. package/dist/src/lib/generated-allowlist.js.map +1 -1
  72. package/dist/src/lib/generated-config-surfaces.d.ts +40 -2
  73. package/dist/src/lib/generated-config-surfaces.js +160 -94
  74. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  75. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  76. package/dist/src/lib/generated-sdk-types.js +1 -1
  77. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  78. package/dist/src/lib/login-scope-lines.d.ts +15 -0
  79. package/dist/src/lib/login-scope-lines.js +22 -0
  80. package/dist/src/lib/login-scope-lines.js.map +1 -0
  81. package/dist/src/lib/paginate.d.ts +0 -5
  82. package/dist/src/lib/paginate.js +0 -5
  83. package/dist/src/lib/paginate.js.map +1 -1
  84. package/dist/src/lib/refresh-admin-credentials.d.ts +7 -0
  85. package/dist/src/lib/refresh-admin-credentials.js +16 -4
  86. package/dist/src/lib/refresh-admin-credentials.js.map +1 -1
  87. package/dist/src/lib/root-import-decision.d.ts +15 -14
  88. package/dist/src/lib/root-import-decision.js +44 -51
  89. package/dist/src/lib/root-import-decision.js.map +1 -1
  90. package/dist/src/lib/snapshot-build-rows.d.ts +21 -0
  91. package/dist/src/lib/snapshot-build-rows.js +22 -0
  92. package/dist/src/lib/snapshot-build-rows.js.map +1 -1
  93. package/dist/src/lib/snapshot-build.js +8 -3
  94. package/dist/src/lib/snapshot-build.js.map +1 -1
  95. package/dist/src/lib/swift-codegen/banners.d.ts +34 -7
  96. package/dist/src/lib/swift-codegen/banners.js +37 -7
  97. package/dist/src/lib/swift-codegen/banners.js.map +1 -1
  98. package/dist/src/lib/swift-codegen/functionGenerator.d.ts +3 -3
  99. package/dist/src/lib/swift-codegen/functionGenerator.js +22 -17
  100. package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -1
  101. package/dist/src/lib/swift-codegen/generator.js +27 -27
  102. package/dist/src/lib/swift-codegen/generator.js.map +1 -1
  103. package/dist/src/lib/toml-database-config.d.ts +14 -75
  104. package/dist/src/lib/toml-database-config.js +14 -305
  105. package/dist/src/lib/toml-database-config.js.map +1 -1
  106. package/dist/src/lib/workflow-toml-validator.js +15 -1
  107. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  108. package/package.json +3 -5
  109. package/dist/src/lib/generated-workflow-model-fields.d.ts +0 -20
  110. package/dist/src/lib/generated-workflow-model-fields.js +0 -53
  111. package/dist/src/lib/generated-workflow-model-fields.js.map +0 -1
  112. package/dist/src/lib/workflow-field-descriptor.d.ts +0 -91
  113. package/dist/src/lib/workflow-field-descriptor.js +0 -153
  114. 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
- throw new Error(`Invalid --document-format "${String(value)}". Expected 1 (an ordinary ` +
98
- `document), 2 (a large document) or 3 (a record-log document).`);
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|2|3>` flag, said once for every records verb. */
101
- const DOCUMENT_FORMAT_FLAG = "--document-format <1|2|3>";
102
- const DOCUMENT_FORMAT_HELP = "The document format you expect: 1 (ordinary), 2 (large) or 3 (record log). A disagreement " +
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("--large", "Create a large document (format 2): base snapshots + epoch overlays, for documents that outgrow a single in-memory Y.Doc")
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 document is
377
- // created in the legacy format exactly as before (#2816).
378
- ...(options.large ? { documentFormat: 2 } : {}),
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 `--large`: what the server
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.message);
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.message);
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.message);
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
- keyValue("Load", room.load ? JSON.stringify(room.load) : "none");
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.message);
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.message);
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.message);
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.message);
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.message);
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
- for (const [label, value] of snapshotBuildRows(build)) {
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.message);
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?.sealed === false) {
1580
- printResult("Sealed", "no — the open epoch carried nothing");
1581
- printResult("Reason", outcome.response.reason);
1582
- printResult("Covering build", outcome.response.coveringBuildId ?? "—");
1583
- return outcome.exitCode;
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.message);
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 — plus the one
2084
- // format that is installed rather than merged.
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 large
2393
- // one (#2816) is the chain — snapshot, sealed overlays, open epoch —
2394
- // because the blob is exactly the whole-document-in-memory cost format 2
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) === 2) {
2398
- stateBytes = await exportLargeDocumentChain(client, appId, documentId, docDir);
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 as in
2432
- // `chain.json`: the format decides how the artifact has to be installed,
2433
- // and an import that reads only `metadata.json` must not mistake one for
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, isLargeDocument, ownerUserId
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
- // `--overwrite` merges a legacy document's state — but a large document is
3060
- // INSTALLED, and the server refuses to install a chain into a document
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. The format is decided at creation and
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
- if (isLargeDocument) {
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, null
3223
- * when there is nothing a re-run may write, or `"holds-records"` /
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 legacy document is only completed when it is the one an earlier run
3228
- * started: it holds no state at all — what `createDocument` leaves, and what a
3229
- * failed `import/state` leaves, since the server stores the merge before it
3230
- * applies it — or it already holds the exported state, and then only its
3231
- * blobs can be missing. A document holding state of its own is not written
3232
- * to at all, neither merged into nor given blobs or aliases: that is
3233
- * `--overwrite`'s decision, not a re-run's. A large document's chain is
3234
- * missing unless its install completed; the server takes a new install over a
3235
- * failed one, but never one into a document with records and no install
3236
- * behind them. Blobs are compared by id against every page the document
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, isLargeDocument, aliasOwner) {
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
- if (isLargeDocument) {
3246
- const status = await retryingImportStep(`Reading the chain install of ${documentId}`, () => client.getDocumentChainInstall(appId, documentId));
3247
- const installState = status?.install?.state;
3248
- if (installState === "installing")
3249
- chain = "await";
3250
- else if (installState !== "complete")
3251
- chain = "install";
3252
- // Read, not discovered by the first artifact upload, so `--dry-run`
3253
- // reaches the same decision the run does.
3254
- // `tableModels` is every model the document's records table holds.
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
- const exportedState = exportedStateOf(docDir);
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 || chain || blobs.length > 0 || aliases.length > 0
3302
- ? { state, chain, blobs, aliases }
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), by format (#4195): a legacy export's state is merged into a legacy
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 legacy root an
3399
- // earlier run started and did not finish is completed, by the same rule
3400
- // as any legacy document: it holds no state, or already holds the
3401
- // exported state. A root holding state of its own — its user's — is never
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 = plan.targetFormat === 2
3406
- ? null
3407
- : await findImportGaps(client, appId, docDir, targetRootDocId, false, {
3408
- userId: plan.targetUserId,
3409
- aliasMode,
3410
- exported: metadata.aliases,
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(install
3435
- ? `[dry-run] Would create the root document (large) for ` +
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 ${install ? "install" : "apply"} root document ` +
3445
- `export ${exportedDocumentId} ${install ? "into" : "to"} ` +
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 ${install ? "install" : "merge"} the exported ` +
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 (install) {
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 ${verb} ` +
3527
- `root document export ${exportedDocumentId} ${install ? "into" : "to"} it`);
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(`${install ? "Installed" : "Applied"} root document export ` +
3533
- `${exportedDocumentId} ${install ? "into" : "to"} ${plan.owner}'s ` +
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