primitive-admin 1.1.0-alpha.73 → 1.1.0-alpha.75

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 (150) hide show
  1. package/README.md +18 -25
  2. package/assets/skill/skills/primitive-platform/SKILL.md +30 -14
  3. package/dist/bin/primitive.js +25 -21
  4. package/dist/bin/primitive.js.map +1 -1
  5. package/dist/src/commands/analytics.js +117 -2
  6. package/dist/src/commands/analytics.js.map +1 -1
  7. package/dist/src/commands/auth.js +13 -107
  8. package/dist/src/commands/auth.js.map +1 -1
  9. package/dist/src/commands/collection-type-configs.js +1 -1
  10. package/dist/src/commands/collection-type-configs.js.map +1 -1
  11. package/dist/src/commands/config.js +8 -4
  12. package/dist/src/commands/config.js.map +1 -1
  13. package/dist/src/commands/database-type-configs.js +1 -1
  14. package/dist/src/commands/database-type-configs.js.map +1 -1
  15. package/dist/src/commands/databases.js +25 -31
  16. package/dist/src/commands/databases.js.map +1 -1
  17. package/dist/src/commands/documents.js +758 -53
  18. package/dist/src/commands/documents.js.map +1 -1
  19. package/dist/src/commands/env.d.ts +1 -1
  20. package/dist/src/commands/env.js +57 -16
  21. package/dist/src/commands/env.js.map +1 -1
  22. package/dist/src/commands/functions.d.ts +20 -0
  23. package/dist/src/commands/functions.js +463 -0
  24. package/dist/src/commands/functions.js.map +1 -0
  25. package/dist/src/commands/groups.js +1 -1
  26. package/dist/src/commands/groups.js.map +1 -1
  27. package/dist/src/commands/init.d.ts +12 -15
  28. package/dist/src/commands/init.js +38 -92
  29. package/dist/src/commands/init.js.map +1 -1
  30. package/dist/src/commands/integrations.js +5 -3
  31. package/dist/src/commands/integrations.js.map +1 -1
  32. package/dist/src/commands/prompts.js +5 -7
  33. package/dist/src/commands/prompts.js.map +1 -1
  34. package/dist/src/commands/rule-sets.js +1 -1
  35. package/dist/src/commands/rule-sets.js.map +1 -1
  36. package/dist/src/commands/scripts.js +23 -11
  37. package/dist/src/commands/scripts.js.map +1 -1
  38. package/dist/src/commands/sync-app-settings.js +2 -4
  39. package/dist/src/commands/sync-app-settings.js.map +1 -1
  40. package/dist/src/commands/sync.d.ts +65 -2
  41. package/dist/src/commands/sync.js +867 -21
  42. package/dist/src/commands/sync.js.map +1 -1
  43. package/dist/src/commands/workflows.js +6 -8
  44. package/dist/src/commands/workflows.js.map +1 -1
  45. package/dist/src/lib/api-client.d.ts +163 -2
  46. package/dist/src/lib/api-client.js +227 -5
  47. package/dist/src/lib/api-client.js.map +1 -1
  48. package/dist/src/lib/app-match-guard.d.ts +44 -0
  49. package/dist/src/lib/app-match-guard.js +72 -0
  50. package/dist/src/lib/app-match-guard.js.map +1 -0
  51. package/dist/src/lib/app-settings-descriptor.js +4 -0
  52. package/dist/src/lib/app-settings-descriptor.js.map +1 -1
  53. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +11 -8
  54. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +112 -60
  55. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
  56. package/dist/src/lib/config-object-descriptor.js +39 -0
  57. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  58. package/dist/src/lib/config-surface.d.ts +10 -0
  59. package/dist/src/lib/config-surface.js +17 -0
  60. package/dist/src/lib/config-surface.js.map +1 -1
  61. package/dist/src/lib/config.d.ts +15 -4
  62. package/dist/src/lib/config.js +22 -39
  63. package/dist/src/lib/config.js.map +1 -1
  64. package/dist/src/lib/credentials-store.d.ts +7 -15
  65. package/dist/src/lib/credentials-store.js +52 -85
  66. package/dist/src/lib/credentials-store.js.map +1 -1
  67. package/dist/src/lib/document-export-permissions.d.ts +30 -0
  68. package/dist/src/lib/document-export-permissions.js +54 -0
  69. package/dist/src/lib/document-export-permissions.js.map +1 -0
  70. package/dist/src/lib/env-resolver-core.d.ts +64 -16
  71. package/dist/src/lib/env-resolver-core.js +128 -17
  72. package/dist/src/lib/env-resolver-core.js.map +1 -1
  73. package/dist/src/lib/env-resolver.d.ts +3 -3
  74. package/dist/src/lib/env-resolver.js +2 -2
  75. package/dist/src/lib/function-bundle.d.ts +141 -0
  76. package/dist/src/lib/function-bundle.js +333 -0
  77. package/dist/src/lib/function-bundle.js.map +1 -0
  78. package/dist/src/lib/function-collect.d.ts +101 -0
  79. package/dist/src/lib/function-collect.js +339 -0
  80. package/dist/src/lib/function-collect.js.map +1 -0
  81. package/dist/src/lib/function-db-types.d.ts +149 -0
  82. package/dist/src/lib/function-db-types.js +587 -0
  83. package/dist/src/lib/function-db-types.js.map +1 -0
  84. package/dist/src/lib/function-grants-preflight.d.ts +118 -0
  85. package/dist/src/lib/function-grants-preflight.js +213 -0
  86. package/dist/src/lib/function-grants-preflight.js.map +1 -0
  87. package/dist/src/lib/function-sync.d.ts +191 -0
  88. package/dist/src/lib/function-sync.js +474 -0
  89. package/dist/src/lib/function-sync.js.map +1 -0
  90. package/dist/src/lib/function-triggers.d.ts +54 -0
  91. package/dist/src/lib/function-triggers.js +223 -0
  92. package/dist/src/lib/function-triggers.js.map +1 -0
  93. package/dist/src/lib/generated-config-surfaces.d.ts +389 -0
  94. package/dist/src/lib/generated-config-surfaces.js +1030 -2
  95. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  96. package/dist/src/lib/generated-sdk-types.d.ts +12 -0
  97. package/dist/src/lib/generated-sdk-types.js +13 -0
  98. package/dist/src/lib/generated-sdk-types.js.map +1 -0
  99. package/dist/src/lib/init-adopt.js +2 -2
  100. package/dist/src/lib/init-adopt.js.map +1 -1
  101. package/dist/src/lib/init-assets.js +3 -3
  102. package/dist/src/lib/init-config.d.ts +1 -1
  103. package/dist/src/lib/init-ios-links.d.ts +6 -5
  104. package/dist/src/lib/init-ios-links.js +9 -8
  105. package/dist/src/lib/init-ios-links.js.map +1 -1
  106. package/dist/src/lib/init-plan.d.ts +1 -1
  107. package/dist/src/lib/init-plan.js +1 -1
  108. package/dist/src/lib/init-xcode.d.ts +6 -5
  109. package/dist/src/lib/init-xcode.js +34 -10
  110. package/dist/src/lib/init-xcode.js.map +1 -1
  111. package/dist/src/lib/ios-app-id.d.ts +34 -0
  112. package/dist/src/lib/ios-app-id.js +69 -0
  113. package/dist/src/lib/ios-app-id.js.map +1 -0
  114. package/dist/src/lib/local-state.d.ts +1 -1
  115. package/dist/src/lib/local-state.js +1 -1
  116. package/dist/src/lib/local-test-cases.d.ts +1 -1
  117. package/dist/src/lib/local-test-cases.js +2 -1
  118. package/dist/src/lib/local-test-cases.js.map +1 -1
  119. package/dist/src/lib/migration-nag.d.ts +2 -2
  120. package/dist/src/lib/migration-nag.js +4 -3
  121. package/dist/src/lib/migration-nag.js.map +1 -1
  122. package/dist/src/lib/project-config.d.ts +38 -21
  123. package/dist/src/lib/project-config.js +57 -47
  124. package/dist/src/lib/project-config.js.map +1 -1
  125. package/dist/src/lib/snapshots.d.ts +2 -2
  126. package/dist/src/lib/snapshots.js +2 -2
  127. package/dist/src/lib/swift-codegen/generator.d.ts +1 -1
  128. package/dist/src/lib/swift-codegen/generator.js +1 -1
  129. package/dist/src/lib/sync-dir-selector.d.ts +1 -1
  130. package/dist/src/lib/sync-dir-selector.js +1 -1
  131. package/dist/src/lib/sync-paths.d.ts +52 -16
  132. package/dist/src/lib/sync-paths.js +68 -22
  133. package/dist/src/lib/sync-paths.js.map +1 -1
  134. package/dist/src/lib/sync-resource-types.d.ts +23 -4
  135. package/dist/src/lib/sync-resource-types.js +106 -8
  136. package/dist/src/lib/sync-resource-types.js.map +1 -1
  137. package/dist/src/lib/template.d.ts +2 -2
  138. package/dist/src/lib/template.js +2 -2
  139. package/dist/src/lib/test-case-keys.d.ts +1 -1
  140. package/dist/src/lib/test-case-keys.js +1 -1
  141. package/dist/src/lib/test-case-variables.d.ts +14 -0
  142. package/dist/src/lib/test-case-variables.js +42 -0
  143. package/dist/src/lib/test-case-variables.js.map +1 -1
  144. package/dist/src/lib/workflow-codegen/generator.d.ts +1 -1
  145. package/dist/src/lib/workflow-codegen/generator.js +1 -1
  146. package/dist/src/lib/workflow-usage.d.ts +198 -0
  147. package/dist/src/lib/workflow-usage.js +312 -0
  148. package/dist/src/lib/workflow-usage.js.map +1 -0
  149. package/dist/src/types/index.d.ts +12 -2
  150. package/package.json +4 -3
@@ -4,9 +4,24 @@ import { success, error, info, formatTable, formatId, formatDate, json, keyValue
4
4
  import { confirmPrompt } from "../lib/confirm-prompt.js";
5
5
  import { parseDataOption } from "../lib/data-input.js";
6
6
  import { parseFilterOptions } from "../lib/record-filter.js";
7
+ import { buildPermissionsExport } from "../lib/document-export-permissions.js";
7
8
  import { ulid } from "ulid";
8
9
  import * as fs from "fs";
9
10
  import * as path from "path";
11
+ /**
12
+ * Report a document's format (#2816) — but only when it is a large document.
13
+ *
14
+ * The format is chosen at creation and never migrated, so an operator has to
15
+ * be able to read it back: `--large` that the server ignored would otherwise
16
+ * hand back an ordinary-looking document. A legacy document says nothing at
17
+ * all, so its output is byte-for-byte what it has always been, however the
18
+ * stored row spells the format (absent, null or 1).
19
+ */
20
+ function printDocumentFormat(doc) {
21
+ if (Number(doc?.documentFormat) === 2) {
22
+ keyValue("Format", "2 (large document)");
23
+ }
24
+ }
10
25
  /**
11
26
  * Resolve a `--owner` value to a userId. A value containing "@" is looked up
12
27
  * by exact email through the member-accessible `users/lookup` endpoint
@@ -108,6 +123,7 @@ Examples:
108
123
  keyValue("Created By", doc.createdBy);
109
124
  keyValue("Created", formatDate(doc.createdAt));
110
125
  keyValue("Modified", formatDate(doc.modifiedAt ?? doc.lastModified));
126
+ printDocumentFormat(doc);
111
127
  // `GET documents/:id` reports the caller's own access, not the
112
128
  // document's grant/alias/collection inventory — printing counts for
113
129
  // fields the endpoint never returns read as a definitive "0" for
@@ -135,6 +151,7 @@ Examples:
135
151
  .argument("<title>", "Document title")
136
152
  .option("--app <app-id>", "App ID")
137
153
  .option("--owner <userId-or-email>", "User ID or email to own the document (super-admin or assigned-console-admin tokens only)")
154
+ .option("--large", "Create a large document (format 2): base snapshots + epoch overlays, for documents that outgrow a single in-memory Y.Doc")
138
155
  .option("--json", "Output as JSON")
139
156
  .addHelpText("after", `
140
157
  Ownership:
@@ -166,6 +183,9 @@ Ownership:
166
183
  const result = await client.createDocument(resolvedAppId, {
167
184
  title,
168
185
  ...(ownerUserId ? { createdBy: ownerUserId } : {}),
186
+ // Opt-in only: without --large nothing is sent, so the document is
187
+ // created in the legacy format exactly as before (#2816).
188
+ ...(options.large ? { documentFormat: 2 } : {}),
169
189
  });
170
190
  if (options.json) {
171
191
  json(result);
@@ -175,6 +195,9 @@ Ownership:
175
195
  keyValue("Document ID", result.documentId);
176
196
  keyValue("Title", result.title);
177
197
  keyValue("Owner", result.createdBy);
198
+ // Read back from the RESPONSE, not from `--large`: what the server
199
+ // created is what the document will be for the rest of its life.
200
+ printDocumentFormat(result);
178
201
  }
179
202
  catch (err) {
180
203
  error(err.message);
@@ -1149,8 +1172,28 @@ Permissions (enforced by the server, not the CLI):
1149
1172
  warn(`Failed to export ${docId}: ${err.message}`);
1150
1173
  }
1151
1174
  }
1152
- // Write manifest
1175
+ // Write manifest. `export-all` is per-user, and each run REPLACES the
1176
+ // manifest with its own ids — so exporting a second user into the same
1177
+ // directory leaves the first user's documents on disk but unreachable
1178
+ // through the manifest an import reads. The supported migration is one
1179
+ // directory per user; a run that would drop ids says so (#3135).
1153
1180
  const manifestPath = path.join(options.output, "manifest.json");
1181
+ if (fs.existsSync(manifestPath)) {
1182
+ try {
1183
+ const existing = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
1184
+ const dropped = (Array.isArray(existing?.documents) ? existing.documents : []).filter((id) => !exportedIds.includes(id));
1185
+ if (dropped.length > 0) {
1186
+ warn(`The manifest already in ${options.output} lists ` +
1187
+ `${dropped.length} document(s) this export does not: ` +
1188
+ `${dropped.join(", ")}. Replacing it leaves them on disk but ` +
1189
+ `no longer reachable through the manifest an import reads — ` +
1190
+ `export each user into their own directory.`);
1191
+ }
1192
+ }
1193
+ catch {
1194
+ // An unreadable manifest is not a reason to refuse the export.
1195
+ }
1196
+ }
1154
1197
  const manifest = {
1155
1198
  version: 1,
1156
1199
  exportedAt: new Date().toISOString(),
@@ -1179,7 +1222,12 @@ Permissions (enforced by the server, not the CLI):
1179
1222
  .argument("<path>", "Path to export directory or single document directory")
1180
1223
  .option("--app <app-id>", "App ID")
1181
1224
  .option("--aliases <mode>", "Alias conflict handling: overwrite or skip", "skip")
1182
- .option("--overwrite", "Replace existing documents if IDs collide")
1225
+ // `--overwrite` has never replaced a document: the import path issues no
1226
+ // delete and calls `import/state`, whose room handler merges the exported
1227
+ // state into the document with `Y.applyUpdate`. The description says so
1228
+ // (#3135), and names the one id remapping on top of it — plus the one
1229
+ // format that is installed rather than merged.
1230
+ .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)")
1183
1231
  .option("--dry-run", "Show what would be imported without making changes")
1184
1232
  .option("--owner <userId-or-email>", "User ID or email to set as document owner (admin only)")
1185
1233
  .option("--json", "Output as JSON")
@@ -1251,9 +1299,30 @@ Permissions (enforced by the server, not the CLI):
1251
1299
  blobsUploaded: 0,
1252
1300
  aliasesSet: 0,
1253
1301
  aliasesSkipped: 0,
1302
+ rootDocuments: [],
1254
1303
  };
1304
+ // Where every root-marked export in this run lands, decided from reads
1305
+ // alone and before any write (#3135): a refusal, or two exports
1306
+ // competing for one user's root document, is reported up front.
1307
+ const rootPlans = await planRootImports(client, resolvedAppId, docDirs, options.owner, ownerUserId, options.overwrite || false);
1308
+ // Nothing is created until every artifact is one this CLI can install:
1309
+ // a refusal halfway through a multi-document import would leave the
1310
+ // app half-populated (#2816). A root export is never installed as a
1311
+ // chain — `planRootImports` has already refused a format-2 one — so it
1312
+ // is not vouched for here.
1255
1313
  for (const docDir of docDirs) {
1256
- await importSingleDocument(client, resolvedAppId, docDir, options.overwrite || false, options.aliases || "skip", options.dryRun || false, summary, ownerUserId);
1314
+ if (rootPlans.has(docDir))
1315
+ continue;
1316
+ if (!largeDocumentArtifactAt(docDir))
1317
+ continue;
1318
+ const metadataPath = path.join(docDir, "metadata.json");
1319
+ const documentId = fs.existsSync(metadataPath)
1320
+ ? JSON.parse(fs.readFileSync(metadataPath, "utf-8")).documentId
1321
+ : path.basename(docDir);
1322
+ readChainArtifact(docDir, documentId);
1323
+ }
1324
+ for (const docDir of docDirs) {
1325
+ await importSingleDocument(client, resolvedAppId, docDir, options.overwrite || false, options.aliases || "skip", options.dryRun || false, summary, ownerUserId, rootPlans.get(docDir));
1257
1326
  }
1258
1327
  if (options.json) {
1259
1328
  json(summary);
@@ -1317,48 +1386,123 @@ Permissions (enforced by the server, not the CLI):
1317
1386
  // ============================================
1318
1387
  // Helper functions for export/import
1319
1388
  // ============================================
1389
+ /**
1390
+ * Write a large document's export chain into `docDir` (#2816, behavior 20).
1391
+ *
1392
+ * The layout mirrors the chain and is applied in that order on import:
1393
+ *
1394
+ * ```
1395
+ * chain.json what this artifact is, and the order to apply it in
1396
+ * snapshot/ manifest.json + {model}/{n}.ndjson.gz, when there is a base
1397
+ * epochs/{E}.yjs each sealed overlay after the base's epoch, oldest first
1398
+ * current.yjs the open epoch's overlay
1399
+ * ```
1400
+ *
1401
+ * The server refuses a chain it cannot vouch for, so nothing here has to guess
1402
+ * whether an artifact is complete; what this function must not do is write a
1403
+ * PARTIAL one, which is why `chain.json` — the file that says "this is a
1404
+ * complete v2 export" — is written last.
1405
+ *
1406
+ * Returns the bytes written, which is what `--json` reports as the document's
1407
+ * size: for a large document that is the artifact, not one Yjs update.
1408
+ */
1409
+ async function exportLargeDocumentChain(client, appId, documentId, docDir) {
1410
+ const chain = await client.exportDocumentChain(appId, documentId);
1411
+ let bytes = 0;
1412
+ // The open epoch's overlay travels inline: the rotation threshold bounds it.
1413
+ const current = Buffer.from(chain.current.update, "base64");
1414
+ fs.writeFileSync(path.join(docDir, "current.yjs"), current);
1415
+ bytes += current.byteLength;
1416
+ const overlays = [];
1417
+ if (Array.isArray(chain.overlays) && chain.overlays.length > 0) {
1418
+ const epochsDir = path.join(docDir, "epochs");
1419
+ fs.mkdirSync(epochsDir, { recursive: true });
1420
+ for (const overlay of chain.overlays) {
1421
+ const archive = await client.downloadDocumentArtifact(appId, overlay.download.path);
1422
+ fs.writeFileSync(path.join(epochsDir, `${overlay.epoch}.yjs`), archive);
1423
+ bytes += archive.byteLength;
1424
+ overlays.push({ epoch: overlay.epoch, sealedAt: overlay.sealedAt ?? null });
1425
+ }
1426
+ }
1427
+ let base = null;
1428
+ if (chain.base) {
1429
+ const snapshotDir = path.join(docDir, "snapshot");
1430
+ fs.mkdirSync(snapshotDir, { recursive: true });
1431
+ const manifestBytes = await client.downloadDocumentArtifact(appId, chain.base.download.path);
1432
+ fs.writeFileSync(path.join(snapshotDir, "manifest.json"), manifestBytes);
1433
+ const manifest = JSON.parse(manifestBytes.toString("utf-8"));
1434
+ for (const chunk of manifest.chunks ?? []) {
1435
+ // Addressed within the granted build — `{model}/{n}` — never by key.
1436
+ const index = chunkIndexFromKey(chunk.key);
1437
+ const body = await client.downloadDocumentArtifact(appId, `${chain.base.download.path}/${chunk.model}/${index}`);
1438
+ const modelDir = path.join(snapshotDir, chunk.model);
1439
+ fs.mkdirSync(modelDir, { recursive: true });
1440
+ fs.writeFileSync(path.join(modelDir, `${index}.ndjson.gz`), body);
1441
+ bytes += body.byteLength;
1442
+ }
1443
+ base = {
1444
+ epoch: chain.base.epoch,
1445
+ buildId: chain.base.buildId,
1446
+ rows: chain.base.rows,
1447
+ };
1448
+ }
1449
+ // Last, and without the signatures: a grant is this run's read of the
1450
+ // document, and an artifact that outlives it must not carry one.
1451
+ fs.writeFileSync(path.join(docDir, "chain.json"), JSON.stringify({
1452
+ version: 2,
1453
+ documentFormat: 2,
1454
+ documentId,
1455
+ epoch: chain.epoch,
1456
+ base,
1457
+ overlays,
1458
+ current: { epoch: chain.current.epoch, file: "current.yjs" },
1459
+ }, null, 2));
1460
+ return bytes;
1461
+ }
1462
+ /** The chunk number out of a manifest entry's key (`…/{model}/{n}.ndjson.gz`). */
1463
+ function chunkIndexFromKey(key) {
1464
+ const match = /\/(\d+)\.ndjson\.gz$/.exec(String(key ?? ""));
1465
+ if (!match) {
1466
+ throw new Error(`Snapshot manifest names a chunk this CLI cannot address: ${key}`);
1467
+ }
1468
+ return Number(match[1]);
1469
+ }
1320
1470
  async function exportSingleDocument(client, appId, documentId, outputDir, includeBlobs, jsonOutput) {
1321
1471
  const docDir = path.join(outputDir, "documents", documentId);
1322
1472
  fs.mkdirSync(docDir, { recursive: true });
1323
1473
  // 1. Fetch document metadata
1324
1474
  const docMeta = await client.getDocument(appId, documentId);
1325
- // 2. Export Yjs state
1326
- const stateResult = await client.exportDocumentState(appId, documentId);
1327
- fs.writeFileSync(path.join(docDir, "document.yjs"), Buffer.from(stateResult.state, "base64"));
1475
+ // 2. Export the document's state. A legacy document is one blob; a large
1476
+ // one (#2816) is the chain — snapshot, sealed overlays, open epoch —
1477
+ // because the blob is exactly the whole-document-in-memory cost format 2
1478
+ // exists to avoid.
1479
+ let stateBytes;
1480
+ if (Number(docMeta?.documentFormat) === 2) {
1481
+ stateBytes = await exportLargeDocumentChain(client, appId, documentId, docDir);
1482
+ }
1483
+ else {
1484
+ const stateResult = await client.exportDocumentState(appId, documentId);
1485
+ fs.writeFileSync(path.join(docDir, "document.yjs"), Buffer.from(stateResult.state, "base64"));
1486
+ stateBytes = stateResult.byteLength;
1487
+ }
1328
1488
  // 3. Fetch permissions
1329
- let permissions = [];
1489
+ let permResult = null;
1330
1490
  try {
1331
- const permResult = await client.listDocumentPermissions(appId, documentId);
1332
- permissions = Array.isArray(permResult) ? permResult : permResult?.permissions || [];
1491
+ permResult = await client.listDocumentPermissions(appId, documentId);
1333
1492
  }
1334
1493
  catch {
1335
1494
  // Permissions endpoint might not return data for all docs
1336
1495
  }
1337
- // 4. Fetch invitations
1338
- let invitations = [];
1496
+ // 4. Fetch pending invitations (deferred grants). An older server answers
1497
+ // 403/404 here; the export still writes the permission rows.
1498
+ let pendingResult = null;
1339
1499
  try {
1340
- const invResult = await client.listDocumentInvitations(appId, documentId);
1341
- invitations = Array.isArray(invResult) ? invResult : invResult?.invitations || [];
1500
+ pendingResult = await client.listDocumentPendingInvitations(appId, documentId);
1342
1501
  }
1343
1502
  catch {
1344
- // May not have invitations
1503
+ // May not have pending invitations, or the server predates the endpoint
1345
1504
  }
1346
- // Merge permissions and invitations into a single list with email identifiers
1347
- const permissionsExport = [
1348
- ...permissions.map((p) => ({
1349
- email: p.email,
1350
- permission: p.permission,
1351
- grantedAt: p.grantedAt,
1352
- })),
1353
- ...invitations
1354
- .filter((i) => !i.accepted && new Date(i.expiresAt) > new Date())
1355
- .map((i) => ({
1356
- email: i.email,
1357
- permission: i.permission,
1358
- source: "invitation",
1359
- status: "pending",
1360
- })),
1361
- ];
1505
+ const permissionsExport = buildPermissionsExport(permResult, pendingResult);
1362
1506
  fs.writeFileSync(path.join(docDir, "permissions.json"), JSON.stringify(permissionsExport, null, 2));
1363
1507
  // 5. Fetch aliases (user-scoped only)
1364
1508
  let aliases = [];
@@ -1370,11 +1514,15 @@ async function exportSingleDocument(client, appId, documentId, outputDir, includ
1370
1514
  catch {
1371
1515
  // May not have aliases
1372
1516
  }
1373
- // 6. Write metadata
1517
+ // 6. Write metadata. A large document says so here as well as in
1518
+ // `chain.json`: the format decides how the artifact has to be installed,
1519
+ // and an import that reads only `metadata.json` must not mistake one for
1520
+ // a legacy document (#2816).
1374
1521
  const metadata = {
1375
1522
  documentId: docMeta.documentId || documentId,
1376
1523
  title: docMeta.title,
1377
1524
  tags: docMeta.tags || [],
1525
+ ...(Number(docMeta?.documentFormat) === 2 ? { documentFormat: 2 } : {}),
1378
1526
  createdAt: docMeta.createdAt,
1379
1527
  createdBy: docMeta.createdByEmail || docMeta.createdBy,
1380
1528
  aliases: aliases.map((a) => ({
@@ -1415,7 +1563,7 @@ async function exportSingleDocument(client, appId, documentId, outputDir, includ
1415
1563
  json({
1416
1564
  documentId,
1417
1565
  title: metadata.title,
1418
- stateBytes: stateResult.byteLength,
1566
+ stateBytes,
1419
1567
  permissions: permissionsExport.length,
1420
1568
  aliases: aliases.length,
1421
1569
  exportPath: docDir,
@@ -1425,7 +1573,388 @@ async function exportSingleDocument(client, appId, documentId, outputDir, includ
1425
1573
  success(`Exported document ${documentId} (${metadata.title || "untitled"}) to ${docDir}`);
1426
1574
  }
1427
1575
  }
1428
- async function importSingleDocument(client, appId, docDir, overwrite, aliasMode, dryRun, summary, ownerUserId) {
1576
+ /**
1577
+ * Whether `docDir` holds a large document's export (#2816, behavior 20).
1578
+ *
1579
+ * Either marker is enough: `chain.json` is what the chain export writes last,
1580
+ * and `metadata.json` carries the format for artifacts inspected on their own.
1581
+ */
1582
+ function largeDocumentArtifactAt(docDir) {
1583
+ if (fs.existsSync(path.join(docDir, "chain.json")))
1584
+ return true;
1585
+ const metadataPath = path.join(docDir, "metadata.json");
1586
+ if (!fs.existsSync(metadataPath))
1587
+ return false;
1588
+ try {
1589
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
1590
+ return Number(metadata?.documentFormat) === 2;
1591
+ }
1592
+ catch {
1593
+ return false;
1594
+ }
1595
+ }
1596
+ /**
1597
+ * Read (and vouch for) a large document's chain artifact.
1598
+ *
1599
+ * `chain.json` is what the export writes LAST, so its absence means the
1600
+ * artifact is partial — a directory recognizable as a large document with no
1601
+ * description of what to install. Importing that as a legacy document would
1602
+ * read a `document.yjs` that is not there, create an empty document, and
1603
+ * report success: total data loss from the operator's point of view, since the
1604
+ * export is the only copy they were told they had. Refusing is the honest
1605
+ * answer, and it happens before anything is created.
1606
+ */
1607
+ function readChainArtifact(docDir, documentId) {
1608
+ const chainPath = path.join(docDir, "chain.json");
1609
+ if (!fs.existsSync(chainPath)) {
1610
+ throw new Error(`Document ${documentId} was exported as a large document (documentFormat 2), ` +
1611
+ `but ${chainPath} is missing, so this artifact is incomplete. A large document's ` +
1612
+ `data IS its chain — a base snapshot, the sealed overlays after it, and the open ` +
1613
+ `epoch — and importing this directory as a legacy document would create an EMPTY ` +
1614
+ `document. Nothing was imported.`);
1615
+ }
1616
+ let chain;
1617
+ try {
1618
+ chain = JSON.parse(fs.readFileSync(chainPath, "utf-8"));
1619
+ }
1620
+ catch (parseError) {
1621
+ throw new Error(`Document ${documentId}: ${chainPath} is not readable as a chain description ` +
1622
+ `(${parseError.message}), so nothing was imported.`);
1623
+ }
1624
+ if (!Number.isSafeInteger(Number(chain?.epoch))) {
1625
+ throw new Error(`Document ${documentId}: ${chainPath} names no open epoch, so nothing was imported.`);
1626
+ }
1627
+ const artifact = {
1628
+ epoch: Number(chain.epoch),
1629
+ base: chain.base
1630
+ ? {
1631
+ epoch: Number(chain.base.epoch),
1632
+ buildId: String(chain.base.buildId),
1633
+ rows: Number(chain.base.rows ?? 0),
1634
+ }
1635
+ : null,
1636
+ overlays: (Array.isArray(chain.overlays) ? chain.overlays : []).map((overlay) => ({ epoch: Number(overlay.epoch) })),
1637
+ };
1638
+ verifyChainArtifact(docDir, documentId, artifact);
1639
+ return artifact;
1640
+ }
1641
+ /**
1642
+ * Check that the directory holds every file the chain names, and that the
1643
+ * chain is one.
1644
+ *
1645
+ * `chain.json` being present says the export finished writing; it does not say
1646
+ * the files are there, and until this check the first missing chunk was found
1647
+ * AFTER the destination document had been created — leaving an empty document
1648
+ * behind, and in a multi-document import an app half-populated. The chain's
1649
+ * shape is checked here too, for the same reason the server checks it: a
1650
+ * chain with an epoch missing from the middle installs cleanly and is missing
1651
+ * everything that epoch held.
1652
+ */
1653
+ function verifyChainArtifact(docDir, documentId, chain) {
1654
+ const refuse = (reason) => {
1655
+ throw new Error(`Document ${documentId}: its large-document export ${reason}, so nothing was imported.`);
1656
+ };
1657
+ const from = chain.base ? chain.base.epoch : 1;
1658
+ const expected = [];
1659
+ for (let epoch = from; epoch < chain.epoch; epoch++)
1660
+ expected.push(epoch);
1661
+ const named = chain.overlays
1662
+ .map((overlay) => overlay.epoch)
1663
+ .sort((left, right) => left - right);
1664
+ if (named.length !== expected.length ||
1665
+ named.some((epoch, index) => epoch !== expected[index])) {
1666
+ refuse(`describes sealed overlays [${named.join(", ")}] where the chain from ` +
1667
+ (chain.base ? `its base at epoch ${chain.base.epoch}` : "epoch 1") +
1668
+ ` to its open epoch ${chain.epoch} is [${expected.join(", ")}]`);
1669
+ }
1670
+ const require = (file, what) => {
1671
+ if (!fs.existsSync(file))
1672
+ refuse(`is missing ${what} (${file})`);
1673
+ };
1674
+ if (chain.base) {
1675
+ const manifestPath = path.join(docDir, "snapshot", "manifest.json");
1676
+ require(manifestPath, "its base snapshot's manifest");
1677
+ let manifest;
1678
+ try {
1679
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
1680
+ }
1681
+ catch (parseError) {
1682
+ refuse(`has a base snapshot manifest that is not readable ` +
1683
+ `(${parseError.message})`);
1684
+ }
1685
+ for (const chunk of manifest.chunks ?? []) {
1686
+ const index = chunkIndexFromKey(String(chunk.key));
1687
+ require(path.join(docDir, "snapshot", String(chunk.model), `${index}.ndjson.gz`), `chunk ${index} of ${chunk.model}`);
1688
+ }
1689
+ }
1690
+ for (const overlay of chain.overlays) {
1691
+ require(path.join(docDir, "epochs", `${overlay.epoch}.yjs`), `the archived overlay of epoch ${overlay.epoch}`);
1692
+ }
1693
+ require(path.join(docDir, "current.yjs"), "its open epoch's overlay");
1694
+ }
1695
+ /** How long an install is waited on before the CLI gives up on it. */
1696
+ const CHAIN_INSTALL_TIMEOUT_MS = 60 * 60 * 1000;
1697
+ /** How often the install's progress is asked for. */
1698
+ const CHAIN_INSTALL_POLL_MS = 1000;
1699
+ /**
1700
+ * Install a large document's chain export into a freshly created document
1701
+ * (#2816, behavior 20).
1702
+ *
1703
+ * Every artifact is uploaded byte-for-byte into the new document's own prefix
1704
+ * — the manifest's digests are over the STORED bytes, so re-encoding a chunk
1705
+ * here would fail its own verification on install — and then the server
1706
+ * streams them into the document's `records` table a tick at a time. The full
1707
+ * document is never built as a Y.Doc on either side, which is what makes
1708
+ * importing a multi-hundred-MB artifact possible at all; it is also the
1709
+ * efficient way to bulk-seed a new large document.
1710
+ */
1711
+ async function importLargeDocumentChain(client, appId, docDir, documentId) {
1712
+ const chain = readChainArtifact(docDir, documentId);
1713
+ // The base's manifest and chunks. The manifest names the chunks, so nothing
1714
+ // here has to walk the directory and guess what belongs to the build.
1715
+ if (chain.base) {
1716
+ const manifestPath = path.join(docDir, "snapshot", "manifest.json");
1717
+ const manifestBytes = fs.readFileSync(manifestPath);
1718
+ await client.uploadDocumentChainArtifact(appId, documentId, {
1719
+ kind: "manifest",
1720
+ epoch: chain.base.epoch,
1721
+ buildId: chain.base.buildId,
1722
+ }, manifestBytes);
1723
+ const manifest = JSON.parse(manifestBytes.toString("utf-8"));
1724
+ for (const chunk of manifest.chunks ?? []) {
1725
+ const index = chunkIndexFromKey(chunk.key);
1726
+ await client.uploadDocumentChainArtifact(appId, documentId, {
1727
+ kind: "chunk",
1728
+ epoch: chain.base.epoch,
1729
+ buildId: chain.base.buildId,
1730
+ model: chunk.model,
1731
+ index,
1732
+ }, fs.readFileSync(path.join(docDir, "snapshot", chunk.model, `${index}.ndjson.gz`)));
1733
+ }
1734
+ }
1735
+ // The sealed overlays, oldest first, then the open epoch's — which travels
1736
+ // archived exactly like a sealed one, so the imported document's chain is
1737
+ // the chain that was exported.
1738
+ for (const overlay of chain.overlays) {
1739
+ await client.uploadDocumentChainArtifact(appId, documentId, { kind: "overlay", epoch: overlay.epoch }, fs.readFileSync(path.join(docDir, "epochs", `${overlay.epoch}.yjs`)));
1740
+ }
1741
+ await client.uploadDocumentChainArtifact(appId, documentId, { kind: "overlay", epoch: chain.epoch }, fs.readFileSync(path.join(docDir, "current.yjs")));
1742
+ await client.installDocumentChain(appId, documentId, {
1743
+ base: chain.base
1744
+ ? { epoch: chain.base.epoch, buildId: chain.base.buildId }
1745
+ : null,
1746
+ overlays: chain.overlays.map((overlay) => overlay.epoch),
1747
+ currentEpoch: chain.epoch,
1748
+ });
1749
+ // The install runs on the document's alarm, a bounded tick at a time, so it
1750
+ // is waited on rather than awaited: reporting "imported" while the records
1751
+ // are still arriving is the failure this whole path exists to avoid.
1752
+ const deadline = Date.now() + CHAIN_INSTALL_TIMEOUT_MS;
1753
+ for (;;) {
1754
+ const status = await client.getDocumentChainInstall(appId, documentId);
1755
+ const install = status?.install;
1756
+ if (install?.state === "complete")
1757
+ return;
1758
+ if (install?.state === "failed") {
1759
+ throw new Error(`Document ${documentId}: installing its chain failed — ` +
1760
+ `${install.error || "no reason given"}. The document is incomplete ` +
1761
+ `and should be deleted before the import is retried.`);
1762
+ }
1763
+ if (Date.now() >= deadline) {
1764
+ throw new Error(`Document ${documentId}: its chain install has not finished after ` +
1765
+ `${Math.round(CHAIN_INSTALL_TIMEOUT_MS / 60000)} minutes. It continues ` +
1766
+ `on the server; check with 'primitive documents get ${documentId}'.`);
1767
+ }
1768
+ await new Promise((resolve) => setTimeout(resolve, CHAIN_INSTALL_POLL_MS));
1769
+ }
1770
+ }
1771
+ /** Mirrors `ROOT_DOCUMENT_TAG` in `src/config/constants.ts`. */
1772
+ const ROOT_DOCUMENT_TAG = "__ROOT_TAG__";
1773
+ /** Does this `metadata.json` describe somebody's root document? */
1774
+ function isRootDocumentExport(tags) {
1775
+ return Array.isArray(tags) && tags.includes(ROOT_DOCUMENT_TAG);
1776
+ }
1777
+ /**
1778
+ * Why a large-document root export is refused (#3135, contract 7).
1779
+ *
1780
+ * A format-2 export is a chain installed wholesale into a freshly created
1781
+ * format-2 document — a replace, not a merge, and not something that can be
1782
+ * done to a root document, which the server only ever mints as a legacy
1783
+ * document. Refusing says so rather than installing a chain somewhere it does
1784
+ * not belong.
1785
+ */
1786
+ const ROOT_LARGE_DOCUMENT_REFUSAL = "it is a root document exported as a large document (documentFormat 2). A " +
1787
+ "root document is minted as a legacy document and a chain is installed " +
1788
+ "wholesale into a new format-2 document, so the export cannot be applied " +
1789
+ "to the target user's root. Nothing was imported for it.";
1790
+ /** Read an export directory's `metadata.json`, or null when it has none. */
1791
+ function readExportMetadata(docDir) {
1792
+ const metadataPath = path.join(docDir, "metadata.json");
1793
+ if (!fs.existsSync(metadataPath))
1794
+ return null;
1795
+ try {
1796
+ return JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
1797
+ }
1798
+ catch {
1799
+ return null;
1800
+ }
1801
+ }
1802
+ /**
1803
+ * Decide what each root-marked export in this run resolves to (#3135).
1804
+ *
1805
+ * A root document is a per-user singleton the server owns: the exported id is
1806
+ * the SOURCE user's root and means nothing here, so the export is mapped to
1807
+ * the TARGET user's root document instead. The target is `--owner` when given,
1808
+ * otherwise the owner `metadata.json` recorded — an email since #3135, which
1809
+ * is the only owner identity that survives a move between environments.
1810
+ *
1811
+ * Only a definitive answer reads as absence: `exists: false` from the email
1812
+ * lookup, or a 404 from the root-document read. Anything else (an expired
1813
+ * login, a lost permission, a server fault) fails the run, exactly as the
1814
+ * document-existence check does (#3096) — a failed read must never turn into a
1815
+ * confident create.
1816
+ */
1817
+ async function planRootImports(client, appId, docDirs, explicitOwner, explicitOwnerUserId, overwrite) {
1818
+ const plans = new Map();
1819
+ for (const docDir of docDirs) {
1820
+ const metadata = readExportMetadata(docDir);
1821
+ if (!metadata || !isRootDocumentExport(metadata.tags))
1822
+ continue;
1823
+ const exportedDocumentId = metadata.documentId || path.basename(docDir);
1824
+ const owner = String(explicitOwner || metadata.createdBy || "");
1825
+ const plan = {
1826
+ docDir,
1827
+ exportedDocumentId,
1828
+ owner,
1829
+ action: "refused",
1830
+ };
1831
+ const refuse = (reason) => {
1832
+ plan.action = "refused";
1833
+ plan.reason = reason;
1834
+ plans.set(docDir, plan);
1835
+ };
1836
+ if (Number(metadata.documentFormat) === 2) {
1837
+ refuse(ROOT_LARGE_DOCUMENT_REFUSAL);
1838
+ continue;
1839
+ }
1840
+ if (!owner) {
1841
+ refuse(`it is a root document export that records no owner, so there is no ` +
1842
+ `user in ${appId} to restore it into. Pass --owner <userId-or-email>.`);
1843
+ continue;
1844
+ }
1845
+ // Resolve the target user. An explicit `--owner` was already resolved,
1846
+ // command-level, before anything was read.
1847
+ let targetUserId = explicitOwnerUserId;
1848
+ if (!targetUserId) {
1849
+ if (owner.includes("@")) {
1850
+ // Absence is what the lookup ANSWERS with — `exists: false` on a 200.
1851
+ // A thrown lookup is a failed read (an expired login, a lost
1852
+ // permission, a proxy), and a failed read must never pass for "no such
1853
+ // user": it fails the run instead (#3096).
1854
+ let lookup;
1855
+ try {
1856
+ lookup = await client.lookupUserByEmail(appId, owner);
1857
+ }
1858
+ catch (error) {
1859
+ throw new Error(`Document ${exportedDocumentId}: looking up its recorded owner ` +
1860
+ `"${owner}" failed — ${error?.message || "no reason given"}. ` +
1861
+ `Nothing was imported.`);
1862
+ }
1863
+ if (!lookup?.exists || !lookup.user) {
1864
+ refuse(unresolvableRootOwner(appId, owner));
1865
+ continue;
1866
+ }
1867
+ targetUserId = lookup.user.userId;
1868
+ }
1869
+ else {
1870
+ targetUserId = owner;
1871
+ }
1872
+ }
1873
+ plan.targetUserId = targetUserId;
1874
+ let assignment;
1875
+ try {
1876
+ assignment = await client.getUserRootDocument(appId, targetUserId);
1877
+ }
1878
+ catch (error) {
1879
+ if (error?.statusCode !== 404) {
1880
+ throw new Error(`Document ${exportedDocumentId}: reading the root document of ` +
1881
+ `"${owner}" failed — ${error?.message || "no reason given"}. ` +
1882
+ `Nothing was imported.`);
1883
+ }
1884
+ refuse(unresolvableRootOwner(appId, owner));
1885
+ continue;
1886
+ }
1887
+ const rootDocId = assignment?.rootDocId || null;
1888
+ if (!rootDocId) {
1889
+ plan.action = "create";
1890
+ }
1891
+ else {
1892
+ plan.targetRootDocId = rootDocId;
1893
+ plan.action = overwrite ? "apply" : "skip";
1894
+ }
1895
+ plans.set(docDir, plan);
1896
+ }
1897
+ assertDistinctRootTargets(plans);
1898
+ return plans;
1899
+ }
1900
+ /** Why a recorded owner cannot be restored into, and what to do about it. */
1901
+ function unresolvableRootOwner(appId, owner) {
1902
+ return (`it is a root document export whose owner "${owner}" is not a user of ` +
1903
+ `app ${appId}. A root document is restored into one specific user's own ` +
1904
+ `root document, so name the target user with --owner <userId-or-email>, ` +
1905
+ `or re-export so the owner's email is recorded.`);
1906
+ }
1907
+ /**
1908
+ * Refuse a run in which two root exports would land in one user's root
1909
+ * document (#3135, contract 5).
1910
+ *
1911
+ * The supported migration is one `export-all --user-id` directory per user,
1912
+ * imported one directory per run. A hand-assembled directory holding two
1913
+ * users' roots — or `--owner` given for a run containing more than one root
1914
+ * export — would merge both sources into a single root and lose one of them,
1915
+ * so it fails before anything is created rather than half-way through.
1916
+ */
1917
+ function assertDistinctRootTargets(plans) {
1918
+ const byUser = new Map();
1919
+ for (const plan of plans.values()) {
1920
+ if (plan.action === "refused" || !plan.targetUserId)
1921
+ continue;
1922
+ const seen = byUser.get(plan.targetUserId) ?? [];
1923
+ seen.push(plan.exportedDocumentId);
1924
+ byUser.set(plan.targetUserId, seen);
1925
+ }
1926
+ for (const [userId, documents] of byUser) {
1927
+ if (documents.length > 1) {
1928
+ throw new Error(`Root document exports ${documents.join(", ")} all resolve to user ` +
1929
+ `${userId}, and a user has exactly one root document — importing ` +
1930
+ `them would merge them into each other. Export one user per ` +
1931
+ `directory and import one directory per run. Nothing was imported.`);
1932
+ }
1933
+ }
1934
+ }
1935
+ /**
1936
+ * Does the target app already hold this document?
1937
+ *
1938
+ * Only an explicit 404 answers "no" (#3096). An expired login (401), a lost
1939
+ * permission (403), a server fault or a transport failure says nothing about
1940
+ * the document, and reading those as absence turns a failed lookup into a
1941
+ * confident create — or, for a root-marked export, into a reported skip that
1942
+ * silently omits the overwrite the operator asked for. Those propagate, and
1943
+ * `documents import` fails the run.
1944
+ */
1945
+ async function documentExists(client, appId, documentId) {
1946
+ try {
1947
+ await client.getDocument(appId, documentId);
1948
+ return true;
1949
+ }
1950
+ catch (error) {
1951
+ if (error?.statusCode === 404)
1952
+ return false;
1953
+ throw new Error(`Document ${documentId}: checking whether it already exists failed — ` +
1954
+ `${error?.message || "no reason given"}. Nothing was imported for it.`);
1955
+ }
1956
+ }
1957
+ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode, dryRun, summary, ownerUserId, rootPlan) {
1429
1958
  const metadataPath = path.join(docDir, "metadata.json");
1430
1959
  if (!fs.existsSync(metadataPath)) {
1431
1960
  warn(`Skipping ${docDir}: no metadata.json found`);
@@ -1434,31 +1963,64 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
1434
1963
  }
1435
1964
  const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
1436
1965
  const documentId = metadata.documentId;
1437
- if (dryRun) {
1438
- info(`[dry-run] Would import document ${documentId} (${metadata.title || "untitled"})`);
1439
- summary.created++;
1966
+ // A root-marked export is never restored under its exported id: that id is
1967
+ // the SOURCE user's root document. It goes to the target user's own root
1968
+ // instead, per the plan made before this run wrote anything (#3135).
1969
+ //
1970
+ // Decided BEFORE the chain artifact below is read, because a root export is
1971
+ // never installed as a chain: a large-document root is refused by the plan,
1972
+ // and reading its chain here would fail the whole run over an artifact this
1973
+ // run has already decided not to touch.
1974
+ if (isRootDocumentExport(metadata.tags)) {
1975
+ await importRootDocument(client, appId, docDir, metadata, rootPlan, overwrite, aliasMode, dryRun, summary);
1440
1976
  return;
1441
1977
  }
1442
- // Check if document exists
1443
- let exists = false;
1444
- try {
1445
- await client.getDocument(appId, documentId);
1446
- exists = true;
1447
- }
1448
- catch {
1449
- exists = false;
1450
- }
1978
+ // A large document's data is the chain, not `document.yjs` (#2816), and it
1979
+ // is installed rather than replayed. Vouched for before anything is created.
1980
+ const isLargeDocument = largeDocumentArtifactAt(docDir);
1981
+ if (isLargeDocument)
1982
+ readChainArtifact(docDir, documentId);
1983
+ // `--dry-run` decides from the same facts the real run does (#3096), so a
1984
+ // preview never promises a skip that the run turns into a merge: it looks
1985
+ // the document up as well, and only the writes below are withheld.
1986
+ const exists = await documentExists(client, appId, documentId);
1987
+ const wouldSkip = dryRun ? "[dry-run] Would skip" : "Skipping";
1451
1988
  if (exists && !overwrite) {
1452
- warn(`Skipping ${documentId}: already exists (use --overwrite to replace)`);
1989
+ // `--overwrite` merges a legacy document's state — but a large document is
1990
+ // INSTALLED, and the server refuses to install a chain into a document
1991
+ // that already holds records (`assertDocumentIsImportable`), so the remedy
1992
+ // is only honest for the format the state path handles.
1993
+ warn(isLargeDocument
1994
+ ? `${wouldSkip} ${documentId}: already exists, and ` +
1995
+ `a large-document export is installed rather than merged — ` +
1996
+ `--overwrite can only install it into a document that holds ` +
1997
+ `nothing yet`
1998
+ : `${wouldSkip} ${documentId}: already exists (use --overwrite to merge ` +
1999
+ `the exported state into it)`);
1453
2000
  summary.skipped++;
1454
2001
  return;
1455
2002
  }
1456
- // Create document if it doesn't exist
2003
+ if (dryRun) {
2004
+ info(`[dry-run] Would ${exists ? "update" : "import"} document ${documentId} ` +
2005
+ `(${metadata.title || "untitled"})`);
2006
+ if (exists)
2007
+ summary.updated++;
2008
+ else
2009
+ summary.created++;
2010
+ return;
2011
+ }
2012
+ // Create document if it doesn't exist. The format is decided at creation and
2013
+ // never migrated, so a large document has to be created as one (#2816).
2014
+ //
2015
+ // Tags go back exactly as `metadata.json` recorded them (#3096): an export
2016
+ // is a faithful record of what the document is, and editing the list on the
2017
+ // way back in loses that.
1457
2018
  if (!exists) {
1458
2019
  await client.createDocument(appId, {
1459
2020
  title: metadata.title || "Imported Document",
1460
2021
  documentId,
1461
2022
  tags: metadata.tags,
2023
+ ...(isLargeDocument ? { documentFormat: 2 } : {}),
1462
2024
  ...(ownerUserId ? { createdBy: ownerUserId } : {}),
1463
2025
  });
1464
2026
  summary.created++;
@@ -1466,12 +2028,19 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
1466
2028
  else {
1467
2029
  summary.updated++;
1468
2030
  }
1469
- // Import Yjs state
1470
- const yjsPath = path.join(docDir, "document.yjs");
1471
- if (fs.existsSync(yjsPath)) {
1472
- const stateBuffer = fs.readFileSync(yjsPath);
1473
- const stateBase64 = stateBuffer.toString("base64");
1474
- await client.importDocumentState(appId, documentId, stateBase64);
2031
+ if (isLargeDocument) {
2032
+ // The chain, installed: streamed into the document's records table on the
2033
+ // server, never replayed here as one Yjs update.
2034
+ await importLargeDocumentChain(client, appId, docDir, documentId);
2035
+ }
2036
+ else {
2037
+ // Import Yjs state
2038
+ const yjsPath = path.join(docDir, "document.yjs");
2039
+ if (fs.existsSync(yjsPath)) {
2040
+ const stateBuffer = fs.readFileSync(yjsPath);
2041
+ const stateBase64 = stateBuffer.toString("base64");
2042
+ await client.importDocumentState(appId, documentId, stateBase64);
2043
+ }
1475
2044
  }
1476
2045
  // Upload blobs
1477
2046
  const blobIndexPath = path.join(docDir, "blobs", "index.json");
@@ -1513,4 +2082,140 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
1513
2082
  }
1514
2083
  info(`Imported document ${documentId} (${metadata.title || "untitled"})`);
1515
2084
  }
2085
+ /**
2086
+ * Restore a root-marked export into the target user's own root document
2087
+ * (#3135).
2088
+ *
2089
+ * The exported id is never created and never looked up: it is the source
2090
+ * user's root id, which means nothing in this app. Everything — the Yjs state,
2091
+ * the blobs, the user-scoped aliases — is written against the target user's
2092
+ * root document id instead, so blob references inside the document still
2093
+ * resolve and the app still holds exactly one root document per user.
2094
+ *
2095
+ * "Apply" is the merge `import/state` has always performed: keys only one side
2096
+ * holds survive, and a key both sides set resolves by Yjs's own conflict rule,
2097
+ * which guarantees neither source-wins nor target-wins. That is why merging
2098
+ * into an existing root needs `--overwrite` — the default must not quietly
2099
+ * rewrite a user's live preferences.
2100
+ */
2101
+ async function importRootDocument(client, appId, docDir, metadata, plan, overwrite, aliasMode, dryRun, summary) {
2102
+ const exportedDocumentId = metadata.documentId || path.basename(docDir);
2103
+ const wouldSkip = dryRun ? "[dry-run] Would skip" : "Skipping";
2104
+ const report = (action, targetRootDocId, reason) => {
2105
+ summary.rootDocuments.push({
2106
+ exportedDocumentId,
2107
+ owner: plan?.owner ?? "",
2108
+ ...(targetRootDocId ? { targetRootDocId } : {}),
2109
+ action,
2110
+ ...(reason ? { reason } : {}),
2111
+ });
2112
+ };
2113
+ if (!plan || plan.action === "refused") {
2114
+ const reason = plan?.reason ||
2115
+ `it is a root document export this run could not resolve to a user in ` +
2116
+ `app ${appId}. Pass --owner <userId-or-email>.`;
2117
+ warn(`${wouldSkip} ${exportedDocumentId}: ${reason}`);
2118
+ summary.skipped++;
2119
+ report("refused", undefined, reason);
2120
+ return;
2121
+ }
2122
+ if (plan.action === "skip") {
2123
+ const reason = `the root document of ${plan.owner} already exists ` +
2124
+ `(${plan.targetRootDocId}); use --overwrite to merge the exported ` +
2125
+ `content into it`;
2126
+ warn(`${wouldSkip} ${exportedDocumentId}: ${reason}`);
2127
+ summary.skipped++;
2128
+ report("skipped", plan.targetRootDocId, reason);
2129
+ return;
2130
+ }
2131
+ if (dryRun) {
2132
+ if (plan.action === "create") {
2133
+ info(`[dry-run] Would create the root document for ${plan.owner} and ` +
2134
+ `apply root document export ${exportedDocumentId} to it`);
2135
+ summary.created++;
2136
+ report("created");
2137
+ }
2138
+ else {
2139
+ info(`[dry-run] Would apply root document export ${exportedDocumentId} to ` +
2140
+ `${plan.owner}'s root document ${plan.targetRootDocId}`);
2141
+ summary.updated++;
2142
+ report("applied", plan.targetRootDocId);
2143
+ }
2144
+ return;
2145
+ }
2146
+ let targetRootDocId = plan.targetRootDocId;
2147
+ let created = false;
2148
+ if (plan.action === "create") {
2149
+ // The server's own get-or-create, and the only place a root document is
2150
+ // ever minted. Its answer — not the preflight read — decides both the id
2151
+ // the content lands in and whether this run created it: a first sign-in
2152
+ // may have minted the root between the read and this call.
2153
+ const ensured = await client.ensureUserRootDocument(appId, plan.targetUserId);
2154
+ targetRootDocId = ensured?.rootDocId;
2155
+ if (!targetRootDocId) {
2156
+ throw new Error(`Document ${exportedDocumentId}: the server returned no root document ` +
2157
+ `for ${plan.owner}, so nothing was imported for it.`);
2158
+ }
2159
+ created = ensured.created === true;
2160
+ if (!created && !overwrite) {
2161
+ const reason = `the root document of ${plan.owner} (${targetRootDocId}) was created ` +
2162
+ `by a concurrent sign-in while this import was running; use ` +
2163
+ `--overwrite to merge the exported content into it`;
2164
+ warn(`Skipping ${exportedDocumentId}: ${reason}`);
2165
+ summary.skipped++;
2166
+ report("skipped", targetRootDocId, reason);
2167
+ return;
2168
+ }
2169
+ }
2170
+ // Only content moves. The target root keeps its own id, title, tags and
2171
+ // permissions — the ensure path already granted the owner read-write.
2172
+ const yjsPath = path.join(docDir, "document.yjs");
2173
+ if (fs.existsSync(yjsPath)) {
2174
+ await client.importDocumentState(appId, targetRootDocId, fs.readFileSync(yjsPath).toString("base64"));
2175
+ }
2176
+ const blobIndexPath = path.join(docDir, "blobs", "index.json");
2177
+ if (fs.existsSync(blobIndexPath)) {
2178
+ const blobIndex = JSON.parse(fs.readFileSync(blobIndexPath, "utf-8"));
2179
+ for (const blob of blobIndex) {
2180
+ const blobPath = path.join(docDir, "blobs", `${blob.blobId}.bin`);
2181
+ if (!fs.existsSync(blobPath))
2182
+ continue;
2183
+ // The blobId is preserved: a document's own references travel by blobId,
2184
+ // so they resolve inside the target root as they did in the source.
2185
+ await client.uploadBlob(appId, targetRootDocId, blob.blobId, fs.readFileSync(blobPath), {
2186
+ filename: blob.filename,
2187
+ contentType: blob.contentType,
2188
+ sha256: blob.sha256,
2189
+ });
2190
+ summary.blobsUploaded++;
2191
+ }
2192
+ }
2193
+ // A root document's user-scoped aliases belong to its owner by definition,
2194
+ // so they are restored without `--owner` — the target user is already known.
2195
+ if (Array.isArray(metadata.aliases)) {
2196
+ for (const alias of metadata.aliases) {
2197
+ if (alias.aliasScope !== "user")
2198
+ continue;
2199
+ try {
2200
+ await client.setDocumentAlias(appId, alias.aliasScope, alias.aliasKey, targetRootDocId, plan.targetUserId, aliasMode === "skip");
2201
+ summary.aliasesSet++;
2202
+ }
2203
+ catch {
2204
+ summary.aliasesSkipped++;
2205
+ }
2206
+ }
2207
+ }
2208
+ if (created) {
2209
+ summary.created++;
2210
+ report("created", targetRootDocId);
2211
+ info(`Created root document ${targetRootDocId} for ${plan.owner} and applied ` +
2212
+ `root document export ${exportedDocumentId} to it`);
2213
+ }
2214
+ else {
2215
+ summary.updated++;
2216
+ report("applied", targetRootDocId);
2217
+ info(`Applied root document export ${exportedDocumentId} to ${plan.owner}'s ` +
2218
+ `root document ${targetRootDocId}`);
2219
+ }
2220
+ }
1516
2221
  //# sourceMappingURL=documents.js.map