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.
- package/README.md +18 -25
- package/assets/skill/skills/primitive-platform/SKILL.md +30 -14
- package/dist/bin/primitive.js +14 -11
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/analytics.js +117 -2
- package/dist/src/commands/analytics.js.map +1 -1
- package/dist/src/commands/auth.js +13 -107
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/collection-type-configs.js +1 -1
- package/dist/src/commands/collection-type-configs.js.map +1 -1
- package/dist/src/commands/database-type-configs.js +1 -1
- package/dist/src/commands/database-type-configs.js.map +1 -1
- package/dist/src/commands/databases.js +1 -1
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.js +749 -29
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/env.js +44 -6
- package/dist/src/commands/env.js.map +1 -1
- package/dist/src/commands/functions.d.ts +20 -0
- package/dist/src/commands/functions.js +329 -0
- package/dist/src/commands/functions.js.map +1 -0
- package/dist/src/commands/groups.js +1 -1
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/init.js +2 -15
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/integrations.js +5 -3
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/prompts.js +5 -7
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/rule-sets.js +1 -1
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/scripts.js +6 -7
- package/dist/src/commands/scripts.js.map +1 -1
- package/dist/src/commands/sync.d.ts +63 -0
- package/dist/src/commands/sync.js +650 -2
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/workflows.js +5 -7
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +156 -1
- package/dist/src/lib/api-client.js +219 -3
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/app-settings-descriptor.js +4 -0
- package/dist/src/lib/app-settings-descriptor.js.map +1 -1
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +5 -7
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
- package/dist/src/lib/config-object-descriptor.js +39 -0
- package/dist/src/lib/config-object-descriptor.js.map +1 -1
- package/dist/src/lib/config-surface.d.ts +10 -0
- package/dist/src/lib/config-surface.js +17 -0
- package/dist/src/lib/config-surface.js.map +1 -1
- package/dist/src/lib/config.d.ts +13 -2
- package/dist/src/lib/config.js +20 -37
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/credentials-store.d.ts +4 -12
- package/dist/src/lib/credentials-store.js +49 -82
- package/dist/src/lib/credentials-store.js.map +1 -1
- package/dist/src/lib/env-resolver-core.d.ts +23 -7
- package/dist/src/lib/env-resolver-core.js +44 -4
- package/dist/src/lib/env-resolver-core.js.map +1 -1
- package/dist/src/lib/function-bundle.d.ts +131 -0
- package/dist/src/lib/function-bundle.js +321 -0
- package/dist/src/lib/function-bundle.js.map +1 -0
- package/dist/src/lib/function-sync.d.ts +157 -0
- package/dist/src/lib/function-sync.js +371 -0
- package/dist/src/lib/function-sync.js.map +1 -0
- package/dist/src/lib/function-triggers.d.ts +54 -0
- package/dist/src/lib/function-triggers.js +223 -0
- package/dist/src/lib/function-triggers.js.map +1 -0
- package/dist/src/lib/generated-config-surfaces.d.ts +1 -0
- package/dist/src/lib/generated-config-surfaces.js +279 -1
- package/dist/src/lib/generated-config-surfaces.js.map +1 -1
- package/dist/src/lib/init-ios-links.d.ts +6 -5
- package/dist/src/lib/init-ios-links.js +8 -7
- package/dist/src/lib/init-ios-links.js.map +1 -1
- package/dist/src/lib/init-xcode.d.ts +4 -3
- package/dist/src/lib/init-xcode.js +28 -5
- package/dist/src/lib/init-xcode.js.map +1 -1
- package/dist/src/lib/ios-app-id.d.ts +34 -0
- package/dist/src/lib/ios-app-id.js +69 -0
- package/dist/src/lib/ios-app-id.js.map +1 -0
- package/dist/src/lib/project-config.d.ts +15 -1
- package/dist/src/lib/project-config.js +31 -3
- package/dist/src/lib/project-config.js.map +1 -1
- package/dist/src/lib/sync-resource-types.d.ts +21 -2
- package/dist/src/lib/sync-resource-types.js +104 -6
- package/dist/src/lib/sync-resource-types.js.map +1 -1
- package/dist/src/lib/test-case-variables.d.ts +14 -0
- package/dist/src/lib/test-case-variables.js +42 -0
- package/dist/src/lib/test-case-variables.js.map +1 -1
- package/dist/src/lib/workflow-usage.d.ts +198 -0
- package/dist/src/lib/workflow-usage.js +312 -0
- package/dist/src/lib/workflow-usage.js.map +1 -0
- package/dist/src/types/index.d.ts +12 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
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
|
-
//
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
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
|