primitive-admin 1.2.0-alpha.3 → 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 (143) hide show
  1. package/assets/skill/skills/primitive-platform/SKILL.md +17 -0
  2. package/dist/src/commands/agent-sessions.js +4 -0
  3. package/dist/src/commands/agent-sessions.js.map +1 -1
  4. package/dist/src/commands/auth.js +7 -4
  5. package/dist/src/commands/auth.js.map +1 -1
  6. package/dist/src/commands/connections.js +11 -12
  7. package/dist/src/commands/connections.js.map +1 -1
  8. package/dist/src/commands/database-type-configs.js +2 -76
  9. package/dist/src/commands/database-type-configs.js.map +1 -1
  10. package/dist/src/commands/database-types.js +313 -13
  11. package/dist/src/commands/database-types.js.map +1 -1
  12. package/dist/src/commands/databases.d.ts +9 -0
  13. package/dist/src/commands/databases.js +78 -403
  14. package/dist/src/commands/databases.js.map +1 -1
  15. package/dist/src/commands/documents.d.ts +32 -2
  16. package/dist/src/commands/documents.js +344 -711
  17. package/dist/src/commands/documents.js.map +1 -1
  18. package/dist/src/commands/functions.js +2 -2
  19. package/dist/src/commands/functions.js.map +1 -1
  20. package/dist/src/commands/llm.js +6 -9
  21. package/dist/src/commands/llm.js.map +1 -1
  22. package/dist/src/commands/prompts.d.ts +7 -0
  23. package/dist/src/commands/prompts.js +44 -0
  24. package/dist/src/commands/prompts.js.map +1 -1
  25. package/dist/src/commands/settings.d.ts +9 -12
  26. package/dist/src/commands/settings.js +79 -31
  27. package/dist/src/commands/settings.js.map +1 -1
  28. package/dist/src/commands/sync.d.ts +43 -110
  29. package/dist/src/commands/sync.js +356 -1251
  30. package/dist/src/commands/sync.js.map +1 -1
  31. package/dist/src/lib/aggregate-output.d.ts +25 -0
  32. package/dist/src/lib/aggregate-output.js +53 -0
  33. package/dist/src/lib/aggregate-output.js.map +1 -0
  34. package/dist/src/lib/api-client.d.ts +48 -204
  35. package/dist/src/lib/api-client.js +57 -321
  36. package/dist/src/lib/api-client.js.map +1 -1
  37. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +6 -7
  38. package/dist/src/lib/codegen-shared/generatedFiles.js +2 -2
  39. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
  40. package/dist/src/lib/codegen-shared/prettierStable.d.ts +1 -1
  41. package/dist/src/lib/codegen-shared/prettierStable.js +1 -1
  42. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +2 -2
  43. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +2 -2
  44. package/dist/src/lib/config-object-descriptor.js +15 -10
  45. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  46. package/dist/src/lib/config-surface.d.ts +2 -1
  47. package/dist/src/lib/config-surface.js +4 -1
  48. package/dist/src/lib/config-surface.js.map +1 -1
  49. package/dist/src/lib/connections-list.d.ts +10 -0
  50. package/dist/src/lib/connections-list.js +18 -0
  51. package/dist/src/lib/connections-list.js.map +1 -0
  52. package/dist/src/lib/csv.d.ts +8 -9
  53. package/dist/src/lib/csv.js +3 -4
  54. package/dist/src/lib/csv.js.map +1 -1
  55. package/dist/src/lib/db-codegen/dbGenerator.js +5 -16
  56. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  57. package/dist/src/lib/db-codegen/dbNaming.d.ts +5 -13
  58. package/dist/src/lib/db-codegen/dbNaming.js +5 -17
  59. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  60. package/dist/src/lib/db-codegen/dbTemplates.d.ts +7 -56
  61. package/dist/src/lib/db-codegen/dbTemplates.js +25 -125
  62. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  63. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +2 -2
  64. package/dist/src/lib/db-codegen/dbTsTypes.js +2 -2
  65. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +60 -2
  66. package/dist/src/lib/db-codegen/dbTypeIR.js +12 -1
  67. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -1
  68. package/dist/src/lib/document-export-record-log.d.ts +57 -0
  69. package/dist/src/lib/document-export-record-log.js +215 -0
  70. package/dist/src/lib/document-export-record-log.js.map +1 -0
  71. package/dist/src/lib/document-ingest-artifact.d.ts +13 -2
  72. package/dist/src/lib/document-ingest-artifact.js +7 -3
  73. package/dist/src/lib/document-ingest-artifact.js.map +1 -1
  74. package/dist/src/lib/document-ingest-rows.d.ts +16 -0
  75. package/dist/src/lib/document-ingest-rows.js +17 -0
  76. package/dist/src/lib/document-ingest-rows.js.map +1 -1
  77. package/dist/src/lib/document-ingest.d.ts +22 -0
  78. package/dist/src/lib/document-ingest.js +33 -0
  79. package/dist/src/lib/document-ingest.js.map +1 -1
  80. package/dist/src/lib/function-sync.d.ts +3 -16
  81. package/dist/src/lib/function-sync.js +14 -18
  82. package/dist/src/lib/function-sync.js.map +1 -1
  83. package/dist/src/lib/function-triggers.d.ts +3 -15
  84. package/dist/src/lib/function-triggers.js +10 -72
  85. package/dist/src/lib/function-triggers.js.map +1 -1
  86. package/dist/src/lib/generated-allowlist.js +0 -20
  87. package/dist/src/lib/generated-allowlist.js.map +1 -1
  88. package/dist/src/lib/generated-config-surfaces.d.ts +212 -49
  89. package/dist/src/lib/generated-config-surfaces.js +759 -184
  90. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  91. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  92. package/dist/src/lib/generated-sdk-types.js +1 -1
  93. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  94. package/dist/src/lib/login-scope-lines.d.ts +15 -0
  95. package/dist/src/lib/login-scope-lines.js +22 -0
  96. package/dist/src/lib/login-scope-lines.js.map +1 -0
  97. package/dist/src/lib/paginate.d.ts +0 -5
  98. package/dist/src/lib/paginate.js +0 -5
  99. package/dist/src/lib/paginate.js.map +1 -1
  100. package/dist/src/lib/prompt-schema-codegen.js +5 -2
  101. package/dist/src/lib/prompt-schema-codegen.js.map +1 -1
  102. package/dist/src/lib/refresh-admin-credentials.d.ts +7 -0
  103. package/dist/src/lib/refresh-admin-credentials.js +16 -4
  104. package/dist/src/lib/refresh-admin-credentials.js.map +1 -1
  105. package/dist/src/lib/root-import-decision.d.ts +15 -14
  106. package/dist/src/lib/root-import-decision.js +44 -51
  107. package/dist/src/lib/root-import-decision.js.map +1 -1
  108. package/dist/src/lib/snapshot-build-rows.d.ts +21 -0
  109. package/dist/src/lib/snapshot-build-rows.js +22 -0
  110. package/dist/src/lib/snapshot-build-rows.js.map +1 -1
  111. package/dist/src/lib/snapshot-build.js +8 -3
  112. package/dist/src/lib/snapshot-build.js.map +1 -1
  113. package/dist/src/lib/swift-codegen/banners.d.ts +34 -7
  114. package/dist/src/lib/swift-codegen/banners.js +37 -7
  115. package/dist/src/lib/swift-codegen/banners.js.map +1 -1
  116. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +4 -3
  117. package/dist/src/lib/swift-codegen/dbGenerator.js +14 -117
  118. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -1
  119. package/dist/src/lib/swift-codegen/functionGenerator.d.ts +3 -3
  120. package/dist/src/lib/swift-codegen/functionGenerator.js +22 -17
  121. package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -1
  122. package/dist/src/lib/swift-codegen/generator.d.ts +5 -5
  123. package/dist/src/lib/swift-codegen/generator.js +35 -40
  124. package/dist/src/lib/swift-codegen/generator.js.map +1 -1
  125. package/dist/src/lib/toml-database-config.d.ts +14 -75
  126. package/dist/src/lib/toml-database-config.js +14 -305
  127. package/dist/src/lib/toml-database-config.js.map +1 -1
  128. package/dist/src/lib/workflow-codegen/generator.d.ts +7 -7
  129. package/dist/src/lib/workflow-codegen/generator.js +16 -23
  130. package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
  131. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +6 -12
  132. package/dist/src/lib/workflow-codegen/invokerIR.js +4 -5
  133. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -1
  134. package/dist/src/lib/workflow-toml-validator.d.ts +2 -0
  135. package/dist/src/lib/workflow-toml-validator.js +28 -1
  136. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  137. package/package.json +3 -5
  138. package/dist/src/lib/generated-workflow-model-fields.d.ts +0 -20
  139. package/dist/src/lib/generated-workflow-model-fields.js +0 -53
  140. package/dist/src/lib/generated-workflow-model-fields.js.map +0 -1
  141. package/dist/src/lib/workflow-field-descriptor.d.ts +0 -91
  142. package/dist/src/lib/workflow-field-descriptor.js +0 -153
  143. package/dist/src/lib/workflow-field-descriptor.js.map +0 -1
@@ -1,9 +1,11 @@
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";
5
6
  import { resolveAppId } from "../lib/config.js";
6
7
  import { success, error, info, formatTable, formatId, formatDate, json, keyValue, result as printResult, warn, flushOutput, } from "../lib/output.js";
8
+ import { renderAggregateResult } from "../lib/aggregate-output.js";
7
9
  import { confirmPrompt } from "../lib/confirm-prompt.js";
8
10
  import { resolveOwnerUserId } from "../lib/resolve-owner.js";
9
11
  import { pageCursorOption, pageLimitOption, parsePageLimit, printEmptyPage, printPageHint, } from "../lib/list-options.js";
@@ -12,14 +14,9 @@ import { parseDataOption } from "../lib/data-input.js";
12
14
  import { parseFilterOptions } from "../lib/record-filter.js";
13
15
  import { buildPermissionsExport, PENDING_INVITATIONS_ABSENT_STATUSES, } from "../lib/document-export-permissions.js";
14
16
  import { readExportLookup } from "../lib/document-export-lookups.js";
15
- import { chunkAddressOf, renumberManifestForExport, } from "../lib/snapshot-manifest-layout.js";
16
- import { validateSnapshotManifest } from "js-bao";
17
- import { auditSnapshotChain, } from "../lib/snapshot-audit.js";
18
- import { createAuditApiSource } from "../lib/snapshot-audit-source.js";
19
- import { createAuditLocalStore, createChainFingerprintReader, } from "../lib/snapshot-audit-store.js";
20
17
  import { discoverIngestInput, } from "../lib/document-ingest-input.js";
21
18
  import { buildIngestArtifact, ingestSchemaFromIntrospection, } from "../lib/document-ingest-artifact.js";
22
- import { driveDocumentIngest, summariseIngestPlan, assertIngestConfirmable, } from "../lib/document-ingest.js";
19
+ import { driveDocumentIngest, summariseIngestPlan, assertIngestConfirmable, ingestCreateBody, parseIngestFlags, } from "../lib/document-ingest.js";
23
20
  import { withTransientRetry } from "../lib/transient-retry.js";
24
21
  import { ulid } from "ulid";
25
22
  import * as Y from "yjs";
@@ -39,6 +36,17 @@ function printIngestSession(session) {
39
36
  printResult(label, value);
40
37
  }
41
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
+ }
42
50
  /**
43
51
  * One refused records call, as one line for an operator (#3764).
44
52
  *
@@ -57,7 +65,8 @@ export function describeRecordsFailure(err) {
57
65
  return `${message}${code ? ` [${code}]` : ""}${status !== undefined ? ` (${status})` : ""}`;
58
66
  }
59
67
  /**
60
- * Report a document's format (#2816) — but only when it is a large document.
68
+ * Report a document's format (#2816) — but only when it is a large (2) or a
69
+ * record-log (3, #4306) document.
61
70
  *
62
71
  * The format is chosen at creation and never migrated, so an operator has to
63
72
  * be able to read it back: `--large` that the server ignored would otherwise
@@ -66,9 +75,13 @@ export function describeRecordsFailure(err) {
66
75
  * stored row spells the format (absent, null or 1).
67
76
  */
68
77
  function printDocumentFormat(doc) {
69
- if (Number(doc?.documentFormat) === 2) {
78
+ const format = Number(doc?.documentFormat);
79
+ if (format === 2) {
70
80
  keyValue("Format", "2 (large document)");
71
81
  }
82
+ else if (format === 3) {
83
+ keyValue("Format", "3 (record log)");
84
+ }
72
85
  }
73
86
  /**
74
87
  * The `--document-format` an operator stated, or the refusal (#3764, E10).
@@ -76,22 +89,53 @@ function printDocumentFormat(doc) {
76
89
  * Refused HERE rather than by the server, because an operator CAN act on it
77
90
  * (principle 6) and a round trip to be told what they typed wrong is a round
78
91
  * trip they do not need to spend. Commander hands the raw string over, so the
79
- * 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.
80
97
  */
81
- export function parseDocumentFormatOption(value) {
98
+ export function parseDocumentFormatOption(value, flag = "--document-format") {
82
99
  if (value === undefined || value === null)
83
100
  return undefined;
84
101
  const text = String(value).trim();
85
102
  if (text === "1")
86
103
  return 1;
87
- if (text === "2")
88
- return 2;
89
- throw new Error(`Invalid --document-format "${String(value)}". Expected 1 (an ordinary ` +
90
- `document) or 2 (a large document).`);
104
+ if (text === "3")
105
+ return 3;
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).`);
91
111
  }
92
- /** The `--document-format <1|2>` flag, said once for every records verb. */
93
- const DOCUMENT_FORMAT_FLAG = "--document-format <1|2>";
94
- const DOCUMENT_FORMAT_HELP = "The document format you expect: 1 (ordinary) or 2 (large). A disagreement " +
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;
135
+ }
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 " +
95
139
  "with the platform's own answer is refused with DOCUMENT_FORMAT_MISMATCH " +
96
140
  "rather than answered out of the wrong tables";
97
141
  /**
@@ -211,6 +255,7 @@ Examples:
211
255
  $ primitive documents get <document-id>
212
256
  $ primitive documents create "My Doc"
213
257
  $ primitive documents create "My Doc" --owner user@example.com
258
+ $ primitive documents create "Ledger" --format 3
214
259
  $ primitive documents delete <document-id> -y
215
260
  $ primitive documents permissions list <document-id>
216
261
  $ primitive documents permissions grant <document-id> --user-id <user-id> --permission reader
@@ -333,7 +378,8 @@ Examples:
333
378
  .argument("<title>", "Document title")
334
379
  .option("--app <app-id>", "App ID")
335
380
  .option("--owner <userId-or-email>", "User ID or email to own the document (super-admin or assigned-console-admin tokens only)")
336
- .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")
337
383
  .option("--json", "Output as JSON")
338
384
  .addHelpText("after", `
339
385
  Ownership:
@@ -349,6 +395,20 @@ Ownership:
349
395
  document.
350
396
  `)
351
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
+ }
352
412
  const resolvedAppId = resolveAppId(undefined, options);
353
413
  const client = new ApiClient();
354
414
  let ownerUserId;
@@ -365,9 +425,9 @@ Ownership:
365
425
  const result = await client.createDocument(resolvedAppId, {
366
426
  title,
367
427
  ...(ownerUserId ? { createdBy: ownerUserId } : {}),
368
- // Opt-in only: without --large nothing is sent, so the document is
369
- // created in the legacy format exactly as before (#2816).
370
- ...(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 } : {}),
371
431
  });
372
432
  if (options.json) {
373
433
  json(result);
@@ -377,7 +437,7 @@ Ownership:
377
437
  keyValue("Document ID", result.documentId);
378
438
  keyValue("Title", result.title);
379
439
  keyValue("Owner", result.createdBy);
380
- // Read back from the RESPONSE, not from `--large`: what the server
440
+ // Read back from the RESPONSE, not from the flags: what the server
381
441
  // created is what the document will be for the rest of its life.
382
442
  printDocumentFormat(result);
383
443
  }
@@ -437,7 +497,7 @@ Permissions (enforced by the server, not the CLI):
437
497
  catch (err) {
438
498
  // Server refusals (404 not found, 403 root document / owner-only, the
439
499
  // non-owner fail-closed 503) are printed verbatim, never swallowed.
440
- error(err.message);
500
+ error(describeDocumentFailure(err));
441
501
  process.exit(1);
442
502
  }
443
503
  });
@@ -882,35 +942,18 @@ Permissions (enforced by the server, not the CLI):
882
942
  json(response);
883
943
  return;
884
944
  }
885
- const aggregated = response?.result;
886
- // Same labelling rule as the databases twin: the field name is
887
- // reproduced verbatim, never case-folded (issue #2357).
888
- const valueLabel = options.op === "count"
889
- ? "COUNT"
890
- : `${options.op.toUpperCase()}(${options.field})`;
891
- const scalarLabel = options.op === "count"
892
- ? "count"
893
- : `${options.op.toLowerCase()}(${options.field})`;
894
- if (groupBy.length === 0) {
895
- const value = (aggregated ?? {})[options.op === "count" ? "count" : `${options.op}_${options.field}`];
896
- keyValue(scalarLabel, value === null || value === undefined ? "—" : String(value));
897
- return;
898
- }
899
- if (aggregated &&
900
- typeof aggregated === "object" &&
901
- !Array.isArray(aggregated) &&
902
- Object.values(aggregated).every((v) => typeof v !== "object" || v === null)) {
903
- const rows = Object.entries(aggregated).map(([group, value]) => ({
904
- group,
905
- value: value === null || value === undefined ? "—" : String(value),
906
- }));
907
- console.log(formatTable(rows, [
908
- { header: "GROUP", key: "group" },
909
- { header: valueLabel, key: "value" },
910
- ]));
945
+ // The route answers rows (#4265): one key/value line when
946
+ // ungrouped, a table with one column per group key when grouped.
947
+ const rendered = renderAggregateResult(response?.result, {
948
+ groupBy,
949
+ op: options.op,
950
+ field: options.field,
951
+ });
952
+ if (rendered.kind === "scalar") {
953
+ keyValue(rendered.label, rendered.value);
911
954
  }
912
955
  else {
913
- console.log(JSON.stringify(aggregated ?? {}, null, 2));
956
+ console.log(rendered.text);
914
957
  }
915
958
  }
916
959
  catch (err) {
@@ -1153,7 +1196,7 @@ Permissions (enforced by the server, not the CLI):
1153
1196
  console.log(output);
1154
1197
  }
1155
1198
  catch (err) {
1156
- error(err.message);
1199
+ error(describeDocumentFailure(err));
1157
1200
  process.exit(1);
1158
1201
  }
1159
1202
  });
@@ -1185,7 +1228,98 @@ Permissions (enforced by the server, not the CLI):
1185
1228
  keyValue("Last modified", result.lastModifiedAt ? formatDate(result.lastModifiedAt) : "—");
1186
1229
  }
1187
1230
  catch (err) {
1188
- error(err.message);
1231
+ error(describeDocumentFailure(err));
1232
+ process.exit(1);
1233
+ }
1234
+ });
1235
+ // #4306 — a record-log document's room: the inspection route's payload.
1236
+ documents
1237
+ .command("inspect")
1238
+ .description("Show a record-log document's room: epochs, log number, floors, writers, the bulk load in progress and checkpoint")
1239
+ .argument("<document-id>", "Document ID")
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")
1243
+ .option("--json", "Output as JSON")
1244
+ .action(async (documentId, options) => {
1245
+ const resolvedAppId = resolveAppId(undefined, options);
1246
+ const client = new ApiClient();
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
+ }
1274
+ const result = await client.getDocumentRoom(resolvedAppId, documentId);
1275
+ if (options.json) {
1276
+ json(result);
1277
+ return;
1278
+ }
1279
+ keyValue("Document ID", result.documentId);
1280
+ printDocumentFormat(result);
1281
+ const room = result.room;
1282
+ if (!room) {
1283
+ keyValue("Room", `no record room (format ${result.documentFormat ?? 1})`);
1284
+ return;
1285
+ }
1286
+ keyValue("Log number", room.lsn);
1287
+ keyValue("Epochs", `current ${room.epochs.current}, next ${room.epochs.next}, high ${room.epochs.high}`);
1288
+ keyValue("Epoch floor", room.epochs.floor);
1289
+ keyValue("Cursor floor", room.cursorFloor);
1290
+ keyValue("Entries since rotation", room.epochs.entriesSinceRotation);
1291
+ keyValue("Rotated", room.epochs.rotatedAt ? formatDate(room.epochs.rotatedAt) : "—");
1292
+ keyValue("Writers", `${room.writers.count} (${room.writers.connected} connected)`);
1293
+ keyValue("Subscribers", room.subscribers);
1294
+ keyValue("Held entries", room.held.entries);
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");
1313
+ keyValue("Last checkpoint", room.checkpoint
1314
+ ? `${formatDate(room.checkpoint.at)} (${room.checkpoint.durationMs} ms, ` +
1315
+ `${room.checkpoint.pruned} pruned, ${room.checkpoint.dropped} dropped, ` +
1316
+ `${room.checkpoint.writersDropped} writers dropped)`
1317
+ : "none yet");
1318
+ keyValue("Next alarm", room.nextAlarmAt ? formatDate(room.nextAlarmAt) : "—");
1319
+ keyValue("Storage (bytes)", room.storage?.databaseSize ?? "—");
1320
+ }
1321
+ catch (err) {
1322
+ error(describeDocumentFailure(err));
1189
1323
  process.exit(1);
1190
1324
  }
1191
1325
  });
@@ -1203,6 +1337,8 @@ Permissions (enforced by the server, not the CLI):
1203
1337
  .argument("<document-id>", "Document ID")
1204
1338
  .requiredOption("--input <dir>", "Directory of <model>.ndjson[.gz] files, or a `documents export` output")
1205
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")
1206
1342
  .option("-y, --yes", "Skip the confirmation prompt")
1207
1343
  .option("--no-wait", "Exit once the session is committed")
1208
1344
  .option("--timeout <seconds>", "Give up waiting after this many seconds")
@@ -1217,6 +1353,8 @@ Permissions (enforced by the server, not the CLI):
1217
1353
  // artifact — up to the whole input, gzipped — in the OS temp directory
1218
1354
  // on every run, successful or not (finding 3435-FROZEN-05).
1219
1355
  const run = async () => {
1356
+ // A flag's value is checked before any request.
1357
+ const flags = parseIngestFlags({ reserveEpoch: options.reserveEpoch, mode: options.mode });
1220
1358
  // The preflight, in this order and before a byte is read from disk:
1221
1359
  // the schema read says whether this caller may see the document's
1222
1360
  // declarations at all and hands over the ones the lines are checked
@@ -1227,15 +1365,19 @@ Permissions (enforced by the server, not the CLI):
1227
1365
  const introspection = await client.getDocumentSchema(resolvedAppId, documentId);
1228
1366
  await client.listDocumentIngests(resolvedAppId, documentId, { limit: 1 });
1229
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;
1230
1371
  const input = discoverIngestInput(options.input, documentId);
1231
1372
  for (const ignored of input.ignored) {
1232
1373
  info(`Ignoring ${ignored}: a bulk load reads the snapshot only.`);
1233
1374
  }
1375
+ const body = ingestCreateBody(flags, input.sources.map((source) => source.model));
1234
1376
  // #3598 — reading, checking, sorting, cutting and gzipping is this
1235
1377
  // side's biggest phase on a large input, and it used to be invisible
1236
1378
  // inside one wall-clock number that also covered the platform's work.
1237
1379
  const buildStartedAt = Date.now();
1238
- plan = await buildIngestArtifact({ input, schema });
1380
+ plan = await buildIngestArtifact({ input, schema, open: recordLog });
1239
1381
  const buildMs = Date.now() - buildStartedAt;
1240
1382
  info(`Built artifact in ${(buildMs / 1000).toFixed(1)}s.`);
1241
1383
  for (const line of summariseIngestPlan(plan))
@@ -1263,7 +1405,7 @@ Permissions (enforced by the server, not the CLI):
1263
1405
  try {
1264
1406
  outcome = await driveDocumentIngest({
1265
1407
  client: {
1266
- createIngest: (id) => client.createDocumentIngest(resolvedAppId, id),
1408
+ createIngest: (id) => client.createDocumentIngest(resolvedAppId, id, body),
1267
1409
  uploadIngestChunk: (id, sessionId, chunk) => client.uploadDocumentIngestChunk(resolvedAppId, id, sessionId, chunk),
1268
1410
  commitIngest: (id, sessionId) => client.commitDocumentIngest(resolvedAppId, id, sessionId),
1269
1411
  abortIngest: (id, sessionId) => client.abortDocumentIngest(resolvedAppId, id, sessionId),
@@ -1311,7 +1453,7 @@ Permissions (enforced by the server, not the CLI):
1311
1453
  code = await run();
1312
1454
  }
1313
1455
  catch (err) {
1314
- error(err.message);
1456
+ error(describeDocumentFailure(err));
1315
1457
  code = 1;
1316
1458
  }
1317
1459
  finally {
@@ -1371,7 +1513,7 @@ Permissions (enforced by the server, not the CLI):
1371
1513
  }
1372
1514
  }
1373
1515
  catch (err) {
1374
- error(err.message);
1516
+ error(describeDocumentFailure(err));
1375
1517
  process.exit(1);
1376
1518
  }
1377
1519
  });
@@ -1394,7 +1536,7 @@ Permissions (enforced by the server, not the CLI):
1394
1536
  printIngestSession(session);
1395
1537
  }
1396
1538
  catch (err) {
1397
- error(err.message);
1539
+ error(describeDocumentFailure(err));
1398
1540
  process.exit(1);
1399
1541
  }
1400
1542
  });
@@ -1429,6 +1571,20 @@ Permissions (enforced by the server, not the CLI):
1429
1571
  info("No snapshot builds yet.");
1430
1572
  return;
1431
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
+ }
1432
1588
  console.log(formatTable(page.items, [
1433
1589
  { header: "BUILD", key: "buildId" },
1434
1590
  { header: "EPOCH", key: "epoch" },
@@ -1453,7 +1609,7 @@ Permissions (enforced by the server, not the CLI):
1453
1609
  }
1454
1610
  }
1455
1611
  catch (err) {
1456
- error(err.message);
1612
+ error(describeDocumentFailure(err));
1457
1613
  process.exit(1);
1458
1614
  }
1459
1615
  });
@@ -1475,13 +1631,14 @@ Permissions (enforced by the server, not the CLI):
1475
1631
  }
1476
1632
  // The rows are composed by a pure function (#3435), so what a
1477
1633
  // terminal shows is checkable without spawning one; the command still
1478
- // owns the printing.
1479
- 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) {
1480
1637
  printResult(label, value);
1481
1638
  }
1482
1639
  }
1483
1640
  catch (err) {
1484
- error(err.message);
1641
+ error(describeDocumentFailure(err));
1485
1642
  process.exit(1);
1486
1643
  }
1487
1644
  });
@@ -1539,20 +1696,20 @@ Permissions (enforced by the server, not the CLI):
1539
1696
  json(outcome.build ? { ...outcome.response, build: outcome.build } : outcome.response);
1540
1697
  return outcome.exitCode;
1541
1698
  }
1542
- if (outcome.response?.sealed === false) {
1543
- printResult("Sealed", "no — the open epoch carried nothing");
1544
- printResult("Reason", outcome.response.reason);
1545
- printResult("Covering build", outcome.response.coveringBuildId ?? "—");
1546
- return outcome.exitCode;
1547
- }
1548
- printResult("Sealed epoch", outcome.response.sealedEpoch);
1549
- printResult("Open epoch", outcome.response.nextEpoch);
1550
- printResult("Build", outcome.response.buildId);
1551
- if (outcome.build) {
1552
- for (const [label, value] of snapshotBuildRows(outcome.build)) {
1553
- 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);
1704
+ }
1705
+ else {
1706
+ printResult("Segment", outcome.response.segmentId);
1707
+ printResult("State", outcome.response.state);
1708
+ printResult("Log number", outcome.response.lsn);
1554
1709
  }
1710
+ return outcome.exitCode;
1555
1711
  }
1712
+ printResult("Build", outcome.response?.buildId);
1556
1713
  return outcome.exitCode;
1557
1714
  };
1558
1715
  let code = 1;
@@ -1575,74 +1732,6 @@ Permissions (enforced by the server, not the CLI):
1575
1732
  await flushOutput();
1576
1733
  process.exit(code);
1577
1734
  });
1578
- // #3433, criterion 10 — the operator audit: `snapshot ⊕ overlays == table`,
1579
- // recomputed on this side with the CLIENT's loader and fold and compared
1580
- // with the authoritative table page by page. The one check that can catch a
1581
- // DO/client fold divergence, which per-build verification runs on one side
1582
- // of and therefore cannot see.
1583
- snapshots
1584
- .command("audit")
1585
- .description("Recompute a large document's snapshot chain locally and compare it with the table")
1586
- .argument("<document-id>", "Document ID")
1587
- .option("--app <app-id>", "App ID")
1588
- .option("--retries <n>", "Attempts before giving up on a document that keeps changing", "3")
1589
- .option("--json", "Output as JSON")
1590
- .action(async (documentId, options) => {
1591
- const resolvedAppId = resolveAppId(undefined, options);
1592
- const client = new ApiClient();
1593
- const report = await auditDocumentChain(client, resolvedAppId, documentId, Number(options.retries));
1594
- if (options.json) {
1595
- json(report);
1596
- }
1597
- else if (report.verdict === "none") {
1598
- // No verdict is not a clean bill of health, so nothing that could be
1599
- // read as one is printed: the reason, and nothing else.
1600
- error(`No verdict: ${report.reason}`);
1601
- }
1602
- else {
1603
- printResult("Document", documentId);
1604
- printResult("Base build", report.buildId);
1605
- printResult("Base epoch", report.epoch);
1606
- printResult("Attempts", report.attempts);
1607
- printResult("Verdict", report.verdict === "match"
1608
- ? "match — the table is the chain"
1609
- : "DIVERGENCE");
1610
- console.log(formatTable(report.models, [
1611
- { header: "MODEL", key: "model" },
1612
- { header: "COMPARED", key: "compared" },
1613
- {
1614
- header: "ONLY IN CHAIN",
1615
- key: "onlyRecomputed",
1616
- format: (ids) => (ids.length ? ids.join(", ") : "-"),
1617
- },
1618
- {
1619
- header: "ONLY IN TABLE",
1620
- key: "onlyTable",
1621
- format: (ids) => (ids.length ? ids.join(", ") : "-"),
1622
- },
1623
- {
1624
- header: "DIFFERING",
1625
- key: "differing",
1626
- format: (rows) => rows.length
1627
- ? rows.map((row) => `${row.id} (${row.fields.join(", ")})`).join("; ")
1628
- : "-",
1629
- },
1630
- ]));
1631
- if (report.models.some((model) => model.truncated)) {
1632
- info("Some lists were truncated; re-run with --json for the full report.");
1633
- }
1634
- if (report.verdict === "match")
1635
- success("snapshot ⊕ overlays == table");
1636
- }
1637
- // The report has to reach the pipe before the process ends: stdout to a
1638
- // pipe is asynchronous and `process.exit` does not wait for it, so a
1639
- // large report piped into anything that is not reading as fast as this
1640
- // writes used to arrive cut off mid-document (3433-C12).
1641
- await flushOutput();
1642
- // 0 match, 1 divergence, 2 no verdict — so the validation child can
1643
- // script on "no verdict" without reading the text.
1644
- process.exit(report.verdict === "match" ? 0 : report.verdict === "divergence" ? 1 : 2);
1645
- });
1646
1735
  // Transfer document ownership
1647
1736
  documents
1648
1737
  .command("transfer-owner")
@@ -1936,7 +2025,7 @@ Permissions (enforced by the server, not the CLI):
1936
2025
  await exportSingleDocument(client, resolvedAppId, documentId, options.output, options.blobs !== false ? true : false, options.json);
1937
2026
  }
1938
2027
  catch (err) {
1939
- error(err.message);
2028
+ error(describeDocumentFailure(err));
1940
2029
  process.exit(1);
1941
2030
  }
1942
2031
  });
@@ -2043,9 +2132,8 @@ Permissions (enforced by the server, not the CLI):
2043
2132
  // `--overwrite` has never replaced a document: the import path issues no
2044
2133
  // delete and calls `import/state`, whose room handler merges the exported
2045
2134
  // state into the document with `Y.applyUpdate`. The description says so
2046
- // (#3135), and names the one id remapping on top of it — plus the one
2047
- // format that is installed rather than merged.
2048
- .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)")
2049
2137
  .option("--dry-run", "Show what would be imported without making changes")
2050
2138
  .option("--owner <userId-or-email>", "User ID or email to set as document owner (admin only)")
2051
2139
  .option("--json", "Output as JSON")
@@ -2111,6 +2199,30 @@ Permissions (enforced by the server, not the CLI):
2111
2199
  info("No documents to import.");
2112
2200
  return;
2113
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
+ }
2114
2226
  const summary = {
2115
2227
  created: 0,
2116
2228
  updated: 0,
@@ -2126,28 +2238,6 @@ Permissions (enforced by the server, not the CLI):
2126
2238
  // alone and before any write (#3135): a refusal, or two exports
2127
2239
  // competing for one user's root document, is reported up front.
2128
2240
  const rootPlans = await planRootImports(client, resolvedAppId, docDirs, options.owner, ownerUserId, options.overwrite || false);
2129
- // Nothing is created until every artifact is one this CLI can install:
2130
- // a refusal halfway through a multi-document import would leave the
2131
- // app half-populated (#2816). A root export is vouched for only when
2132
- // its plan installs it (#4195): a refused or skipped root export is
2133
- // never read past `metadata.json`, so a broken chain in it cannot
2134
- // fail the run.
2135
- for (const docDir of docDirs) {
2136
- const rootPlan = rootPlans.get(docDir);
2137
- if (rootPlan) {
2138
- if (rootPlan.mode === "install") {
2139
- readChainArtifact(docDir, rootPlan.exportedDocumentId);
2140
- }
2141
- continue;
2142
- }
2143
- if (!largeDocumentArtifactAt(docDir))
2144
- continue;
2145
- const metadataPath = path.join(docDir, "metadata.json");
2146
- const documentId = fs.existsSync(metadataPath)
2147
- ? JSON.parse(fs.readFileSync(metadataPath, "utf-8")).documentId
2148
- : path.basename(docDir);
2149
- readChainArtifact(docDir, documentId);
2150
- }
2151
2241
  // One document failing does not stop the run (#4148): it is recorded
2152
2242
  // by id, the rest are imported, and the run exits non-zero at the end.
2153
2243
  for (const docDir of docDirs) {
@@ -2250,97 +2340,6 @@ Permissions (enforced by the server, not the CLI):
2250
2340
  }
2251
2341
  });
2252
2342
  }
2253
- // ============================================
2254
- // Helper functions for export/import
2255
- // ============================================
2256
- /**
2257
- * Write a large document's export chain into `docDir` (#2816, behavior 20).
2258
- *
2259
- * The layout mirrors the chain and is applied in that order on import:
2260
- *
2261
- * ```
2262
- * chain.json what this artifact is, and the order to apply it in
2263
- * snapshot/ manifest.json + {model}/{n}.ndjson.gz, when there is a base
2264
- * epochs/{E}.yjs each sealed overlay after the base's epoch, oldest first
2265
- * current.yjs the open epoch's overlay
2266
- * ```
2267
- *
2268
- * The server refuses a chain it cannot vouch for, so nothing here has to guess
2269
- * whether an artifact is complete; what this function must not do is write a
2270
- * PARTIAL one, which is why `chain.json` — the file that says "this is a
2271
- * complete v2 export" — is written last.
2272
- *
2273
- * Returns the bytes written, which is what `--json` reports as the document's
2274
- * size: for a large document that is the artifact, not one Yjs update.
2275
- */
2276
- async function exportLargeDocumentChain(client, appId, documentId, docDir) {
2277
- const chain = await client.exportDocumentChain(appId, documentId);
2278
- let bytes = 0;
2279
- // The open epoch's overlay travels inline: the rotation threshold bounds it.
2280
- const current = Buffer.from(chain.current.update, "base64");
2281
- fs.writeFileSync(path.join(docDir, "current.yjs"), current);
2282
- bytes += current.byteLength;
2283
- const overlays = [];
2284
- if (Array.isArray(chain.overlays) && chain.overlays.length > 0) {
2285
- const epochsDir = path.join(docDir, "epochs");
2286
- fs.mkdirSync(epochsDir, { recursive: true });
2287
- for (const overlay of chain.overlays) {
2288
- const archive = await client.downloadDocumentArtifact(appId, overlay.download.path);
2289
- fs.writeFileSync(path.join(epochsDir, `${overlay.epoch}.yjs`), archive);
2290
- bytes += archive.byteLength;
2291
- overlays.push({ epoch: overlay.epoch, sealedAt: overlay.sealedAt ?? null });
2292
- }
2293
- }
2294
- let base = null;
2295
- if (chain.base) {
2296
- const snapshotDir = path.join(docDir, "snapshot");
2297
- fs.mkdirSync(snapshotDir, { recursive: true });
2298
- const manifestBytes = await client.downloadDocumentArtifact(appId, chain.base.download.path);
2299
- const downloaded = JSON.parse(manifestBytes.toString("utf-8"));
2300
- // Addressed within the granted build by each entry's `path` — the
2301
- // own-build `{model}/{n}` or the `{epoch}-{buildId}/{model}/{n}` of a
2302
- // chunk reused from an earlier build — never by key (#3432). On disk the
2303
- // chunks are renumbered per model into the own-build form and the stored
2304
- // manifest says so, which is the one layout `documents import` reads.
2305
- const { manifest, files } = renumberManifestForExport(downloaded);
2306
- for (const file of files) {
2307
- const body = await client.downloadDocumentArtifact(appId, `${chain.base.download.path}/${file.download}`);
2308
- const target = path.join(snapshotDir, file.file);
2309
- fs.mkdirSync(path.dirname(target), { recursive: true });
2310
- fs.writeFileSync(target, body);
2311
- bytes += body.byteLength;
2312
- }
2313
- fs.writeFileSync(path.join(snapshotDir, "manifest.json"), JSON.stringify(manifest, null, 2));
2314
- base = {
2315
- epoch: chain.base.epoch,
2316
- buildId: chain.base.buildId,
2317
- rows: chain.base.rows,
2318
- };
2319
- }
2320
- // Last, and without the signatures: a grant is this run's read of the
2321
- // document, and an artifact that outlives it must not carry one.
2322
- fs.writeFileSync(path.join(docDir, "chain.json"), JSON.stringify({
2323
- version: 2,
2324
- documentFormat: 2,
2325
- documentId,
2326
- epoch: chain.epoch,
2327
- base,
2328
- overlays,
2329
- current: { epoch: chain.current.epoch, file: "current.yjs" },
2330
- }, null, 2));
2331
- return bytes;
2332
- }
2333
- /**
2334
- * Where a stored manifest entry's chunk is on disk: `snapshot/{model}/{n}`.
2335
- *
2336
- * An exported manifest is in the own-build form (renumbered per model), so
2337
- * the entry's `path` — or, for an artifact written before the field existed,
2338
- * its key — names the file directly.
2339
- */
2340
- function chunkFileOf(chunk) {
2341
- const address = chunkAddressOf(chunk);
2342
- return { model: address.model, index: address.index };
2343
- }
2344
2343
  async function exportSingleDocument(client, appId, documentId, outputDir, includeBlobs, jsonOutput) {
2345
2344
  const docDir = path.join(outputDir, "documents", documentId);
2346
2345
  fs.mkdirSync(docDir, { recursive: true });
@@ -2352,13 +2351,26 @@ async function exportSingleDocument(client, appId, documentId, outputDir, includ
2352
2351
  fs.rmSync(path.join(docDir, "metadata.json"), { force: true });
2353
2352
  // 1. Fetch document metadata
2354
2353
  const docMeta = await client.getDocument(appId, documentId);
2355
- // 2. Export the document's state. A legacy document is one blob; a large
2356
- // one (#2816) is the chain — snapshot, sealed overlays, open epoch —
2357
- // because the blob is exactly the whole-document-in-memory cost format 2
2358
- // 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.
2359
2357
  let stateBytes;
2360
- if (Number(docMeta?.documentFormat) === 2) {
2361
- 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;
2362
2374
  }
2363
2375
  else {
2364
2376
  const stateResult = await client.exportDocumentState(appId, documentId);
@@ -2391,15 +2403,16 @@ async function exportSingleDocument(client, appId, documentId, outputDir, includ
2391
2403
  fs.writeFileSync(path.join(docDir, "permissions.json"), JSON.stringify(permissionsExport, null, 2));
2392
2404
  // 5. Aliases (user-scoped only)
2393
2405
  const aliases = (Array.isArray(aliasResult) ? aliasResult : aliasResult?.aliases || []).filter((a) => (a.scope || a.aliasScope) === "user");
2394
- // 6. Write metadata. A large document says so here as well as in
2395
- // `chain.json`: the format decides how the artifact has to be installed,
2396
- // and an import that reads only `metadata.json` must not mistake one for
2397
- // 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).
2398
2410
  const metadata = {
2399
2411
  documentId: docMeta.documentId || documentId,
2400
2412
  title: docMeta.title,
2401
2413
  tags: docMeta.tags || [],
2402
2414
  ...(Number(docMeta?.documentFormat) === 2 ? { documentFormat: 2 } : {}),
2415
+ ...(Number(docMeta?.documentFormat) === 3 ? { documentFormat: 3 } : {}),
2403
2416
  createdAt: docMeta.createdAt,
2404
2417
  createdBy: docMeta.createdByEmail || docMeta.createdBy,
2405
2418
  aliases: aliases.map((a) => ({
@@ -2476,135 +2489,6 @@ async function readExportBlobList(client, appId, documentId) {
2476
2489
  } while (cursor);
2477
2490
  return blobs;
2478
2491
  }
2479
- /**
2480
- * Whether `docDir` holds a large document's export (#2816, behavior 20).
2481
- *
2482
- * Either marker is enough: `chain.json` is what the chain export writes last,
2483
- * and `metadata.json` carries the format for artifacts inspected on their own.
2484
- */
2485
- function largeDocumentArtifactAt(docDir) {
2486
- if (fs.existsSync(path.join(docDir, "chain.json")))
2487
- return true;
2488
- const metadataPath = path.join(docDir, "metadata.json");
2489
- if (!fs.existsSync(metadataPath))
2490
- return false;
2491
- try {
2492
- const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
2493
- return Number(metadata?.documentFormat) === 2;
2494
- }
2495
- catch {
2496
- return false;
2497
- }
2498
- }
2499
- /**
2500
- * Read (and vouch for) a large document's chain artifact.
2501
- *
2502
- * `chain.json` is what the export writes LAST, so its absence means the
2503
- * artifact is partial — a directory recognizable as a large document with no
2504
- * description of what to install. Importing that as a legacy document would
2505
- * read a `document.yjs` that is not there, create an empty document, and
2506
- * report success: total data loss from the operator's point of view, since the
2507
- * export is the only copy they were told they had. Refusing is the honest
2508
- * answer, and it happens before anything is created.
2509
- */
2510
- function readChainArtifact(docDir, documentId) {
2511
- const chainPath = path.join(docDir, "chain.json");
2512
- if (!fs.existsSync(chainPath)) {
2513
- throw new Error(`Document ${documentId} was exported as a large document (documentFormat 2), ` +
2514
- `but ${chainPath} is missing, so this artifact is incomplete. A large document's ` +
2515
- `data IS its chain — a base snapshot, the sealed overlays after it, and the open ` +
2516
- `epoch — and importing this directory as a legacy document would create an EMPTY ` +
2517
- `document. Nothing was imported.`);
2518
- }
2519
- let chain;
2520
- try {
2521
- chain = JSON.parse(fs.readFileSync(chainPath, "utf-8"));
2522
- }
2523
- catch (parseError) {
2524
- throw new Error(`Document ${documentId}: ${chainPath} is not readable as a chain description ` +
2525
- `(${parseError.message}), so nothing was imported.`);
2526
- }
2527
- if (!Number.isSafeInteger(Number(chain?.epoch))) {
2528
- throw new Error(`Document ${documentId}: ${chainPath} names no open epoch, so nothing was imported.`);
2529
- }
2530
- const artifact = {
2531
- epoch: Number(chain.epoch),
2532
- base: chain.base
2533
- ? {
2534
- epoch: Number(chain.base.epoch),
2535
- buildId: String(chain.base.buildId),
2536
- rows: Number(chain.base.rows ?? 0),
2537
- }
2538
- : null,
2539
- overlays: (Array.isArray(chain.overlays) ? chain.overlays : []).map((overlay) => ({ epoch: Number(overlay.epoch) })),
2540
- };
2541
- verifyChainArtifact(docDir, documentId, artifact);
2542
- return artifact;
2543
- }
2544
- /**
2545
- * Check that the directory holds every file the chain names, and that the
2546
- * chain is one.
2547
- *
2548
- * `chain.json` being present says the export finished writing; it does not say
2549
- * the files are there, and until this check the first missing chunk was found
2550
- * AFTER the destination document had been created — leaving an empty document
2551
- * behind, and in a multi-document import an app half-populated. The chain's
2552
- * shape is checked here too, for the same reason the server checks it: a
2553
- * chain with an epoch missing from the middle installs cleanly and is missing
2554
- * everything that epoch held.
2555
- */
2556
- function verifyChainArtifact(docDir, documentId, chain) {
2557
- const refuse = (reason) => {
2558
- throw new Error(`Document ${documentId}: its large-document export ${reason}, so nothing was imported.`);
2559
- };
2560
- const from = chain.base ? chain.base.epoch : 1;
2561
- const expected = [];
2562
- for (let epoch = from; epoch < chain.epoch; epoch++)
2563
- expected.push(epoch);
2564
- const named = chain.overlays
2565
- .map((overlay) => overlay.epoch)
2566
- .sort((left, right) => left - right);
2567
- if (named.length !== expected.length ||
2568
- named.some((epoch, index) => epoch !== expected[index])) {
2569
- refuse(`describes sealed overlays [${named.join(", ")}] where the chain from ` +
2570
- (chain.base ? `its base at epoch ${chain.base.epoch}` : "epoch 1") +
2571
- ` to its open epoch ${chain.epoch} is [${expected.join(", ")}]`);
2572
- }
2573
- const require = (file, what) => {
2574
- if (!fs.existsSync(file))
2575
- refuse(`is missing ${what} (${file})`);
2576
- };
2577
- if (chain.base) {
2578
- const manifestPath = path.join(docDir, "snapshot", "manifest.json");
2579
- require(manifestPath, "its base snapshot's manifest");
2580
- let manifest;
2581
- try {
2582
- manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
2583
- }
2584
- catch (parseError) {
2585
- refuse(`has a base snapshot manifest that is not readable ` +
2586
- `(${parseError.message})`);
2587
- }
2588
- // Validated where it enters (#3432): the fields the server keys on — an
2589
- // entry's ordinal, its path, its range — fail silently rather than loudly
2590
- // if they are wrong, and the server refuses the same manifest later with
2591
- // the same message.
2592
- try {
2593
- validateSnapshotManifest(manifest);
2594
- }
2595
- catch (invalid) {
2596
- refuse(`has a base snapshot manifest that is not valid (${invalid.message})`);
2597
- }
2598
- for (const chunk of manifest.chunks ?? []) {
2599
- const { model, index } = chunkFileOf(chunk);
2600
- require(path.join(docDir, "snapshot", model, `${index}.ndjson.gz`), `chunk ${index} of ${model}`);
2601
- }
2602
- }
2603
- for (const overlay of chain.overlays) {
2604
- require(path.join(docDir, "epochs", `${overlay.epoch}.yjs`), `the archived overlay of epoch ${overlay.epoch}`);
2605
- }
2606
- require(path.join(docDir, "current.yjs"), "its open epoch's overlay");
2607
- }
2608
2492
  /**
2609
2493
  * One idempotent write of an import, retried through a transient failure
2610
2494
  * (#4148): a blob PUT, a state merge, an artifact upload or an install read.
@@ -2614,109 +2498,21 @@ function retryingImportStep(what, attempt) {
2614
2498
  return withTransientRetry(attempt, (failure, delayMs) => warn(`${what} failed (${failure?.message || "no reason given"}); ` +
2615
2499
  `retrying in ${delayMs / 1000}s`));
2616
2500
  }
2617
- /** Upload one chain artifact; the server stores it under a fixed key. */
2618
- function uploadChainArtifact(client, appId, documentId, query, body) {
2619
- const what = query.kind === "chunk"
2620
- ? `chunk ${query.index} of ${query.model}`
2621
- : query.kind === "overlay"
2622
- ? `the overlay of epoch ${query.epoch}`
2623
- : "the base snapshot's manifest";
2624
- return retryingImportStep(`Uploading ${what} of ${documentId}`, () => client.uploadDocumentChainArtifact(appId, documentId, query, body));
2625
- }
2626
- /** How long an install is waited on before the CLI gives up on it. */
2627
- const CHAIN_INSTALL_TIMEOUT_MS = 60 * 60 * 1000;
2628
- /** How often the install's progress is asked for. */
2629
- const CHAIN_INSTALL_POLL_MS = 1000;
2630
- /**
2631
- * Install a large document's chain export into a freshly created document
2632
- * (#2816, behavior 20).
2633
- *
2634
- * Every artifact is uploaded byte-for-byte into the new document's own prefix
2635
- * — the manifest's digests are over the STORED bytes, so re-encoding a chunk
2636
- * here would fail its own verification on install — and then the server
2637
- * streams them into the document's `records` table a tick at a time. The full
2638
- * document is never built as a Y.Doc on either side, which is what makes
2639
- * importing a multi-hundred-MB artifact possible at all; it is also the
2640
- * efficient way to bulk-seed a new large document.
2641
- */
2642
- async function importLargeDocumentChain(client, appId, docDir, documentId) {
2643
- const chain = readChainArtifact(docDir, documentId);
2644
- await uploadAndInstallChain(client, appId, docDir, documentId, chain);
2645
- await awaitChainInstall(client, appId, documentId);
2646
- }
2647
- /** Upload every artifact of the chain, then start installing it. */
2648
- async function uploadAndInstallChain(client, appId, docDir, documentId, chain) {
2649
- // The base's manifest and chunks. The manifest names the chunks, so nothing
2650
- // here has to walk the directory and guess what belongs to the build.
2651
- if (chain.base) {
2652
- const manifestPath = path.join(docDir, "snapshot", "manifest.json");
2653
- const manifestBytes = fs.readFileSync(manifestPath);
2654
- await uploadChainArtifact(client, appId, documentId, {
2655
- kind: "manifest",
2656
- epoch: chain.base.epoch,
2657
- buildId: chain.base.buildId,
2658
- }, manifestBytes);
2659
- const manifest = JSON.parse(manifestBytes.toString("utf-8"));
2660
- for (const chunk of manifest.chunks ?? []) {
2661
- // Addressed by the entry's `path` (#3432); the server installs by it too.
2662
- const { model, index } = chunkFileOf(chunk);
2663
- await uploadChainArtifact(client, appId, documentId, {
2664
- kind: "chunk",
2665
- epoch: chain.base.epoch,
2666
- buildId: chain.base.buildId,
2667
- model,
2668
- index,
2669
- }, fs.readFileSync(path.join(docDir, "snapshot", model, `${index}.ndjson.gz`)));
2670
- }
2671
- }
2672
- // The sealed overlays, oldest first, then the open epoch's — which travels
2673
- // archived exactly like a sealed one, so the imported document's chain is
2674
- // the chain that was exported.
2675
- for (const overlay of chain.overlays) {
2676
- await uploadChainArtifact(client, appId, documentId, { kind: "overlay", epoch: overlay.epoch }, fs.readFileSync(path.join(docDir, "epochs", `${overlay.epoch}.yjs`)));
2677
- }
2678
- await uploadChainArtifact(client, appId, documentId, { kind: "overlay", epoch: chain.epoch }, fs.readFileSync(path.join(docDir, "current.yjs")));
2679
- await client.installDocumentChain(appId, documentId, {
2680
- base: chain.base
2681
- ? { epoch: chain.base.epoch, buildId: chain.base.buildId }
2682
- : null,
2683
- overlays: chain.overlays.map((overlay) => overlay.epoch),
2684
- currentEpoch: chain.epoch,
2685
- });
2686
- }
2687
- /**
2688
- * Wait for the document's chain install to finish.
2689
- *
2690
- * The install runs on the document's alarm, a bounded tick at a time, so it
2691
- * is waited on rather than awaited: reporting "imported" while the records
2692
- * are still arriving is the failure this whole path exists to avoid.
2693
- */
2694
- async function awaitChainInstall(client, appId, documentId) {
2695
- const deadline = Date.now() + CHAIN_INSTALL_TIMEOUT_MS;
2696
- for (;;) {
2697
- const status = await retryingImportStep(`Reading the chain install of ${documentId}`, () => client.getDocumentChainInstall(appId, documentId));
2698
- const install = status?.install;
2699
- if (install?.state === "complete")
2700
- return;
2701
- if (install?.state === "failed") {
2702
- throw new Error(`Document ${documentId}: installing its chain failed — ` +
2703
- `${install.error || "no reason given"}. The document is incomplete; ` +
2704
- `re-run the import to install it again.`);
2705
- }
2706
- if (Date.now() >= deadline) {
2707
- throw new Error(`Document ${documentId}: its chain install has not finished after ` +
2708
- `${Math.round(CHAIN_INSTALL_TIMEOUT_MS / 60000)} minutes. It continues ` +
2709
- `on the server; check with 'primitive documents get ${documentId}'.`);
2710
- }
2711
- await new Promise((resolve) => setTimeout(resolve, CHAIN_INSTALL_POLL_MS));
2712
- }
2713
- }
2714
2501
  /** Mirrors `ROOT_DOCUMENT_TAG` in `src/config/constants.ts`. */
2715
2502
  const ROOT_DOCUMENT_TAG = "__ROOT_TAG__";
2716
2503
  /** Does this `metadata.json` describe somebody's root document? */
2717
2504
  function isRootDocumentExport(tags) {
2718
2505
  return Array.isArray(tags) && tags.includes(ROOT_DOCUMENT_TAG);
2719
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
+ }
2720
2516
  /** Read an export directory's `metadata.json`, or null when it has none. */
2721
2517
  function readExportMetadata(docDir) {
2722
2518
  const metadataPath = path.join(docDir, "metadata.json");
@@ -2930,20 +2726,10 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
2930
2726
  // A root-marked export is never restored under its exported id: that id is
2931
2727
  // the SOURCE user's root document. It goes to the target user's own root
2932
2728
  // instead, per the plan made before this run wrote anything (#3135).
2933
- //
2934
- // Decided BEFORE the chain artifact below is read: a root export's chain is
2935
- // installed into the target user's root, never under its exported id, and
2936
- // only when the plan says so (#4195) — reading it here would fail the whole
2937
- // run over an artifact a refused or skipped plan never touches.
2938
2729
  if (isRootDocumentExport(metadata.tags)) {
2939
2730
  await importRootDocument(client, appId, docDir, metadata, rootPlan, overwrite, aliasMode, dryRun, summary, progress);
2940
2731
  return;
2941
2732
  }
2942
- // A large document's data is the chain, not `document.yjs` (#2816), and it
2943
- // is installed rather than replayed. Vouched for before anything is created.
2944
- const isLargeDocument = largeDocumentArtifactAt(docDir);
2945
- if (isLargeDocument)
2946
- readChainArtifact(docDir, documentId);
2947
2733
  // `--dry-run` decides from the same facts the real run does (#3096), so a
2948
2734
  // preview never promises a skip that the run turns into a merge: it looks
2949
2735
  // the document up as well, and only the writes below are withheld.
@@ -2952,16 +2738,6 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
2952
2738
  progress.exists = exists;
2953
2739
  const wouldSkip = dryRun ? "[dry-run] Would skip" : "Skipping";
2954
2740
  if (exists && !overwrite) {
2955
- // A same-id document of the other format is not one this import created:
2956
- // the format is decided at creation and never migrated. It is skipped
2957
- // before any read of what it lacks, which its format's endpoints refuse.
2958
- if ((Number(existing?.documentFormat) === 2) !== isLargeDocument) {
2959
- warn(`${wouldSkip} ${documentId}: already exists as a ` +
2960
- `${isLargeDocument ? "legacy" : "large"} document, and the export ` +
2961
- `is a ${isLargeDocument ? "large" : "legacy"} one`);
2962
- summary.skipped++;
2963
- return;
2964
- }
2965
2741
  // A document an earlier run created and did not finish is completed, not
2966
2742
  // skipped (#4148): what it lacks of the export is read from the server,
2967
2743
  // and only that is written. A document that lacks nothing, or that holds
@@ -2969,24 +2745,9 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
2969
2745
  // re-uploads to one already imported, nor writes to one it did not start.
2970
2746
  // With an owner named, the export's user aliases are among what it can
2971
2747
  // lack.
2972
- const gaps = await findImportGaps(client, appId, docDir, documentId, isLargeDocument, ownerUserId
2748
+ const gaps = await findImportGaps(client, appId, docDir, documentId, ownerUserId
2973
2749
  ? { userId: ownerUserId, aliasMode, exported: metadata.aliases }
2974
2750
  : undefined);
2975
- // Never installed and not empty: a document of its own, which the server
2976
- // would refuse to install into.
2977
- const skipHoldingRecords = (held = "holds records of its own") => {
2978
- warn(`${wouldSkip} ${documentId}: already exists and ${held}, so its ` +
2979
- `large-document export cannot be installed into it`);
2980
- summary.skipped++;
2981
- };
2982
- if (gaps === "holds-records") {
2983
- skipHoldingRecords();
2984
- return;
2985
- }
2986
- if (gaps === "rotated-epoch") {
2987
- skipHoldingRecords("has rotated an epoch of its own");
2988
- return;
2989
- }
2990
2751
  if (gaps) {
2991
2752
  if (dryRun) {
2992
2753
  info(`[dry-run] Would complete document ${documentId} ` +
@@ -2996,22 +2757,6 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
2996
2757
  }
2997
2758
  if (gaps.state)
2998
2759
  await importExportedState(client, appId, docDir, documentId);
2999
- if (gaps.chain === "install") {
3000
- try {
3001
- await uploadAndInstallChain(client, appId, docDir, documentId, readChainArtifact(docDir, documentId));
3002
- }
3003
- catch (refusal) {
3004
- // The server's own check, at the first artifact and before it
3005
- // stores anything: it decides when the document changed after the
3006
- // reads above.
3007
- if (refusal?.code !== "DOCUMENT_NOT_IMPORTABLE")
3008
- throw refusal;
3009
- skipHoldingRecords();
3010
- return;
3011
- }
3012
- }
3013
- if (gaps.chain)
3014
- await awaitChainInstall(client, appId, documentId);
3015
2760
  await uploadExportedBlobs(client, appId, documentId, docDir, gaps.blobs, summary);
3016
2761
  await restoreExportedAliases(client, appId, documentId, ownerUserId ? { ...metadata, aliases: gaps.aliases } : metadata, ownerUserId, aliasMode, summary);
3017
2762
  summary.completed++;
@@ -3019,17 +2764,8 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
3019
2764
  `imported ${describeImportGaps(gaps)}`);
3020
2765
  return;
3021
2766
  }
3022
- // `--overwrite` merges a legacy document's state — but a large document is
3023
- // INSTALLED, and the server refuses to install a chain into a document
3024
- // that already holds records (`assertDocumentIsImportable`), so the remedy
3025
- // is only honest for the format the state path handles.
3026
- warn(isLargeDocument
3027
- ? `${wouldSkip} ${documentId}: already exists, and ` +
3028
- `a large-document export is installed rather than merged — ` +
3029
- `--overwrite can only install it into a document that holds ` +
3030
- `nothing yet`
3031
- : `${wouldSkip} ${documentId}: already exists (use --overwrite to merge ` +
3032
- `the exported state into it)`);
2767
+ warn(`${wouldSkip} ${documentId}: already exists (use --overwrite to merge ` +
2768
+ `the exported state into it)`);
3033
2769
  summary.skipped++;
3034
2770
  return;
3035
2771
  }
@@ -3042,8 +2778,7 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
3042
2778
  summary.created++;
3043
2779
  return;
3044
2780
  }
3045
- // Create document if it doesn't exist. The format is decided at creation and
3046
- // never migrated, so a large document has to be created as one (#2816).
2781
+ // Create document if it doesn't exist.
3047
2782
  //
3048
2783
  // Tags go back exactly as `metadata.json` recorded them (#3096): an export
3049
2784
  // is a faithful record of what the document is, and editing the list on the
@@ -3053,7 +2788,6 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
3053
2788
  title: metadata.title || "Imported Document",
3054
2789
  documentId,
3055
2790
  tags: metadata.tags,
3056
- ...(isLargeDocument ? { documentFormat: 2 } : {}),
3057
2791
  ...(ownerUserId ? { createdBy: ownerUserId } : {}),
3058
2792
  });
3059
2793
  progress.exists = true;
@@ -3062,14 +2796,7 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
3062
2796
  else {
3063
2797
  summary.updated++;
3064
2798
  }
3065
- if (isLargeDocument) {
3066
- // The chain, installed: streamed into the document's records table on the
3067
- // server, never replayed here as one Yjs update.
3068
- await importLargeDocumentChain(client, appId, docDir, documentId);
3069
- }
3070
- else {
3071
- await importExportedState(client, appId, docDir, documentId);
3072
- }
2799
+ await importExportedState(client, appId, docDir, documentId);
3073
2800
  await uploadExportedBlobs(client, appId, documentId, docDir, exportedBlobsOf(docDir), summary);
3074
2801
  // Permissions are exported for reference but not restored during import.
3075
2802
  // The importing admin is the new owner; sharing is managed in the target app.
@@ -3136,10 +2863,6 @@ function describeImportGaps(gaps) {
3136
2863
  const parts = [];
3137
2864
  if (gaps.state)
3138
2865
  parts.push("its state");
3139
- if (gaps.chain === "install")
3140
- parts.push("its chain");
3141
- if (gaps.chain === "await")
3142
- parts.push("its chain (installing)");
3143
2866
  if (gaps.blobs.length > 0) {
3144
2867
  parts.push(`${gaps.blobs.length} blob${gaps.blobs.length === 1 ? "" : "s"}`);
3145
2868
  }
@@ -3182,76 +2905,36 @@ function holdsYjsUpdate(held, exported) {
3182
2905
  return Y.equalSnapshots(before, Y.snapshot(doc));
3183
2906
  }
3184
2907
  /**
3185
- * Read what an existing document lacks of its export (#4148): the gaps, null
3186
- * when there is nothing a re-run may write, or `"holds-records"` /
3187
- * `"rotated-epoch"` for a large document that was never imported into and
3188
- * 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.
3189
2910
  *
3190
- * A legacy document is only completed when it is the one an earlier run
3191
- * started: it holds no state at all — what `createDocument` leaves, and what a
3192
- * failed `import/state` leaves, since the server stores the merge before it
3193
- * applies it — or it already holds the exported state, and then only its
3194
- * blobs can be missing. A document holding state of its own is not written
3195
- * to at all, neither merged into nor given blobs or aliases: that is
3196
- * `--overwrite`'s decision, not a re-run's. A large document's chain is
3197
- * missing unless its install completed; the server takes a new install over a
3198
- * failed one, but never one into a document with records and no install
3199
- * behind them. Blobs are compared by id against every page the document
3200
- * lists. When `aliasOwner` is given, the export's user aliases are read as
3201
- * well: one the owner holds for another document counts as missing only when
3202
- * 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.
3203
2921
  */
3204
- async function findImportGaps(client, appId, docDir, documentId, isLargeDocument, aliasOwner) {
2922
+ async function findImportGaps(client, appId, docDir, documentId, aliasOwner) {
3205
2923
  let state = false;
3206
- let chain = null;
3207
2924
  const exported = exportedBlobsOf(docDir);
3208
- if (isLargeDocument) {
3209
- const status = await retryingImportStep(`Reading the chain install of ${documentId}`, () => client.getDocumentChainInstall(appId, documentId));
3210
- const installState = status?.install?.state;
3211
- if (installState === "installing")
3212
- chain = "await";
3213
- else if (installState !== "complete")
3214
- chain = "install";
3215
- // Read, not discovered by the first artifact upload, so `--dry-run`
3216
- // reaches the same decision the run does.
3217
- // `tableModels` is every model the document's records table holds.
3218
- if (chain === "install" && !status?.install) {
3219
- const schema = await retryingImportStep(`Reading the records of ${documentId}`, () => client.getDocumentSchema(appId, documentId));
3220
- if (Array.isArray(schema?.tableModels) && schema.tableModels.length > 0) {
3221
- return "holds-records";
3222
- }
3223
- // The server refuses one past its first epoch as well. Its open epoch is
3224
- // the one its chain ends on; a chain it cannot describe has sealed an
3225
- // epoch, which only a rotation does.
3226
- let openEpoch;
3227
- try {
3228
- const described = await retryingImportStep(`Reading the epoch of ${documentId}`, () => client.exportDocumentChain(appId, documentId));
3229
- openEpoch = Number(described?.epoch);
3230
- }
3231
- catch (refusal) {
3232
- if (refusal?.code !== "EXPORT_CHAIN_INCOMPLETE")
3233
- throw refusal;
3234
- return "rotated-epoch";
3235
- }
3236
- if (openEpoch !== 1)
3237
- return "rotated-epoch";
3238
- }
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;
3239
2935
  }
3240
- else {
3241
- const exportedState = exportedStateOf(docDir);
3242
- // An export with no state and no blobs can still carry a root's aliases.
3243
- const exportsUserAliases = (Array.isArray(aliasOwner?.exported) ? aliasOwner.exported : [])
3244
- .some((alias) => alias?.aliasScope === "user");
3245
- if (!exportedState && exported.length === 0 && !exportsUserAliases)
3246
- return null;
3247
- const held = await retryingImportStep(`Reading the state of ${documentId}`, () => client.exportDocumentState(appId, documentId));
3248
- const heldState = Buffer.from(String(held?.state ?? ""), "base64");
3249
- if (isEmptyYjsUpdate(heldState)) {
3250
- state = exportedState !== null;
3251
- }
3252
- else if (!exportedState || !holdsYjsUpdate(heldState, exportedState)) {
3253
- return null;
3254
- }
2936
+ else if (!exportedState || !holdsYjsUpdate(heldState, exportedState)) {
2937
+ return null;
3255
2938
  }
3256
2939
  let blobs = [];
3257
2940
  if (exported.length > 0) {
@@ -3261,8 +2944,8 @@ async function findImportGaps(client, appId, docDir, documentId, isLargeDocument
3261
2944
  const aliases = aliasOwner
3262
2945
  ? await findMissingAliases(client, appId, documentId, aliasOwner)
3263
2946
  : [];
3264
- return state || chain || blobs.length > 0 || aliases.length > 0
3265
- ? { state, chain, blobs, aliases }
2947
+ return state || blobs.length > 0 || aliases.length > 0
2948
+ ? { state, blobs, aliases }
3266
2949
  : null;
3267
2950
  }
3268
2951
  /** The form the server stores an alias key in. */
@@ -3314,8 +2997,7 @@ async function listedBlobIds(client, appId, documentId) {
3314
2997
  }
3315
2998
  /**
3316
2999
  * Restore a root-marked export into the target user's own root document
3317
- * (#3135), by format (#4195): a legacy export's state is merged into a legacy
3318
- * 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.
3319
3001
  *
3320
3002
  * The exported id is never created and never looked up: it is the source
3321
3003
  * user's root id, which means nothing in this app. Everything — the Yjs state,
@@ -3342,7 +3024,6 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
3342
3024
  ...(targetRootDocId ? { targetRootDocId } : {}),
3343
3025
  action,
3344
3026
  ...(reason ? { reason } : {}),
3345
- ...(targetFormat === 2 ? { documentFormat: 2 } : {}),
3346
3027
  });
3347
3028
  };
3348
3029
  if (!plan || plan.action === "refused") {
@@ -3358,21 +3039,17 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
3358
3039
  // What a root an earlier run started still lacks (#4148), when completing it.
3359
3040
  let gaps = null;
3360
3041
  if (plan.action === "skip") {
3361
- // The root exists and `--overwrite` was not given. A legacy root an
3362
- // earlier run started and did not finish is completed, by the same rule
3363
- // as any legacy document: it holds no state, or already holds the
3364
- // exported state. A root holding state of its own — its user's — is never
3365
- // merged into. A large root is skipped as before: a chain is installed
3366
- // 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.
3367
3046
  progress.exists = true;
3368
- const found = plan.targetFormat === 2
3369
- ? null
3370
- : await findImportGaps(client, appId, docDir, targetRootDocId, false, {
3371
- userId: plan.targetUserId,
3372
- aliasMode,
3373
- exported: metadata.aliases,
3374
- });
3375
- 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) {
3376
3053
  const reason = plan.reason ||
3377
3054
  `the root document of ${plan.owner} already exists ` +
3378
3055
  `(${targetRootDocId}); use --overwrite to merge the exported ` +
@@ -3384,7 +3061,6 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
3384
3061
  }
3385
3062
  gaps = found;
3386
3063
  }
3387
- const install = plan.mode === "install";
3388
3064
  if (dryRun) {
3389
3065
  if (gaps) {
3390
3066
  info(`[dry-run] Would complete root document export ${exportedDocumentId} ` +
@@ -3394,19 +3070,14 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
3394
3070
  report("completed", targetRootDocId);
3395
3071
  }
3396
3072
  else if (plan.action === "create") {
3397
- info(install
3398
- ? `[dry-run] Would create the root document (large) for ` +
3399
- `${plan.owner} and install root document export ` +
3400
- `${exportedDocumentId} into it`
3401
- : `[dry-run] Would create the root document for ${plan.owner} and ` +
3402
- `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`);
3403
3075
  summary.created++;
3404
3076
  report("created", undefined, undefined, plan.targetFormat);
3405
3077
  }
3406
3078
  else {
3407
- info(`[dry-run] Would ${install ? "install" : "apply"} root document ` +
3408
- `export ${exportedDocumentId} ${install ? "into" : "to"} ` +
3409
- `${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}`);
3410
3081
  summary.updated++;
3411
3082
  report("applied", plan.targetRootDocId, undefined, plan.targetFormat);
3412
3083
  }
@@ -3448,8 +3119,7 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
3448
3119
  if (!created && !overwrite) {
3449
3120
  const reason = `the root document of ${plan.owner} (${targetRootDocId}) was created ` +
3450
3121
  `by a concurrent sign-in while this import was running; use ` +
3451
- `--overwrite to ${install ? "install" : "merge"} the exported ` +
3452
- `content into it`;
3122
+ `--overwrite to merge the exported content into it`;
3453
3123
  warn(`Skipping ${exportedDocumentId}: ${reason}`);
3454
3124
  summary.skipped++;
3455
3125
  report("skipped", targetRootDocId, reason, minted.targetFormat);
@@ -3459,13 +3129,7 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
3459
3129
  progress.exists = true;
3460
3130
  // Only content moves. The target root keeps its own id, title, tags and
3461
3131
  // permissions — the ensure path already granted the owner read-write.
3462
- if (install) {
3463
- // A large root's content is its chain, installed — the server refuses a
3464
- // root that already holds records or has rotated, as it does for any
3465
- // large document (#4195).
3466
- await importLargeDocumentChain(client, appId, docDir, targetRootDocId);
3467
- }
3468
- else if (!gaps || gaps.state) {
3132
+ if (!gaps || gaps.state) {
3469
3133
  await importExportedState(client, appId, docDir, targetRootDocId);
3470
3134
  }
3471
3135
  // The blobId is preserved: a document's own references travel by blobId,
@@ -3475,7 +3139,6 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
3475
3139
  // so they are restored without `--owner` — the target user is already known.
3476
3140
  // A completion restores only the ones the root is missing.
3477
3141
  await restoreExportedAliases(client, appId, targetRootDocId, gaps ? { ...metadata, aliases: gaps.aliases } : metadata, plan.targetUserId, aliasMode, summary);
3478
- const verb = install ? "installed" : "applied";
3479
3142
  if (gaps) {
3480
3143
  summary.completed++;
3481
3144
  report("completed", targetRootDocId);
@@ -3486,44 +3149,14 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
3486
3149
  else if (created) {
3487
3150
  summary.created++;
3488
3151
  report("created", targetRootDocId, undefined, plan.targetFormat);
3489
- info(`Created root document ${targetRootDocId} for ${plan.owner} and ${verb} ` +
3490
- `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`);
3491
3154
  }
3492
3155
  else {
3493
3156
  summary.updated++;
3494
3157
  report("applied", targetRootDocId, undefined, plan.targetFormat);
3495
- info(`${install ? "Installed" : "Applied"} root document export ` +
3496
- `${exportedDocumentId} ${install ? "into" : "to"} ${plan.owner}'s ` +
3497
- `root document ${targetRootDocId}`);
3158
+ info(`Applied root document export ${exportedDocumentId} to ` +
3159
+ `${plan.owner}'s root document ${targetRootDocId}`);
3498
3160
  }
3499
3161
  }
3500
- /**
3501
- * Wire the audit to the app API (#3433, criterion 10).
3502
- *
3503
- * Two permissions, not one (3433-C08): the chain export takes the app-admin
3504
- * arm, and the schema and records reads it is compared against take the
3505
- * console arm or a read grant on the document — the documents surface grants
3506
- * no app-role bypass on a read (#3279, D3279-002). The schema is read first,
3507
- * so a caller holding only the first is refused before anything is exported.
3508
- */
3509
- async function auditDocumentChain(client, appId, documentId, retries) {
3510
- // One page's worth of records is the audit's whole working set on the
3511
- // server side; everything else lives in the local store.
3512
- const AUDIT_PAGE_SIZE = 200;
3513
- const fingerprint = await createChainFingerprintReader();
3514
- // Nothing the server answers is carried from one attempt to the next: the
3515
- // attempt a moved document discards is exactly the one whose schema has
3516
- // changed under it (3433-C09).
3517
- const source = createAuditApiSource(client, {
3518
- appId,
3519
- documentId,
3520
- pageSize: AUDIT_PAGE_SIZE,
3521
- });
3522
- return await auditSnapshotChain(source, {
3523
- store: () => createAuditLocalStore(documentId),
3524
- fingerprint,
3525
- retries,
3526
- pageSize: AUDIT_PAGE_SIZE,
3527
- });
3528
- }
3529
3162
  //# sourceMappingURL=documents.js.map