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

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