primitive-admin 1.0.63 → 1.0.64
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 +132 -42
- package/assets/skill/skills/primitive-platform/SKILL.md +142 -661
- package/dist/bin/primitive.js +121 -41
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/admins.js +37 -50
- package/dist/src/commands/admins.js.map +1 -1
- package/dist/src/commands/analytics.js +178 -9
- package/dist/src/commands/analytics.js.map +1 -1
- package/dist/src/commands/apps.js +12 -5
- package/dist/src/commands/apps.js.map +1 -1
- package/dist/src/commands/auth-sessions.d.ts +7 -0
- package/dist/src/commands/auth-sessions.js +144 -0
- package/dist/src/commands/auth-sessions.js.map +1 -0
- package/dist/src/commands/auth.js +76 -116
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/blob-buckets.js +2 -1
- package/dist/src/commands/blob-buckets.js.map +1 -1
- package/dist/src/commands/catalog.js +26 -10
- package/dist/src/commands/catalog.js.map +1 -1
- package/dist/src/commands/collection-type-configs.js +3 -10
- package/dist/src/commands/collection-type-configs.js.map +1 -1
- package/dist/src/commands/collections.js +736 -19
- package/dist/src/commands/collections.js.map +1 -1
- package/dist/src/commands/config.js +13 -27
- package/dist/src/commands/config.js.map +1 -1
- package/dist/src/commands/connections.js +8 -9
- package/dist/src/commands/connections.js.map +1 -1
- package/dist/src/commands/cron-triggers.js +2 -1
- package/dist/src/commands/cron-triggers.js.map +1 -1
- package/dist/src/commands/database-type-configs.js +5 -12
- package/dist/src/commands/database-type-configs.js.map +1 -1
- package/dist/src/commands/databases.js +59 -72
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.d.ts +58 -0
- package/dist/src/commands/documents.js +1623 -95
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/email-templates.js +2 -1
- package/dist/src/commands/email-templates.js.map +1 -1
- package/dist/src/commands/env.d.ts +1 -1
- package/dist/src/commands/env.js +89 -43
- package/dist/src/commands/env.js.map +1 -1
- package/dist/src/commands/feature-flags.js +2 -1
- package/dist/src/commands/feature-flags.js.map +1 -1
- package/dist/src/commands/functions.d.ts +20 -0
- package/dist/src/commands/functions.js +1630 -0
- package/dist/src/commands/functions.js.map +1 -0
- package/dist/src/commands/group-type-configs.js +2 -1
- package/dist/src/commands/group-type-configs.js.map +1 -1
- package/dist/src/commands/groups.js +30 -17
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/guides.js +17 -11
- package/dist/src/commands/guides.js.map +1 -1
- package/dist/src/commands/init.d.ts +12 -15
- package/dist/src/commands/init.js +44 -92
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/integrations.js +35 -12
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/locks.js +17 -2
- package/dist/src/commands/locks.js.map +1 -1
- package/dist/src/commands/metadata-category-configs.js +2 -1
- package/dist/src/commands/metadata-category-configs.js.map +1 -1
- package/dist/src/commands/metadata.js +8 -1
- package/dist/src/commands/metadata.js.map +1 -1
- package/dist/src/commands/prompts.js +106 -59
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/rule-sets.js +3 -10
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/scripts.js +33 -28
- package/dist/src/commands/scripts.js.map +1 -1
- package/dist/src/commands/secrets.js +2 -1
- package/dist/src/commands/secrets.js.map +1 -1
- package/dist/src/commands/sessions.js +10 -9
- package/dist/src/commands/sessions.js.map +1 -1
- package/dist/src/commands/sync-app-settings.js +3 -5
- package/dist/src/commands/sync-app-settings.js.map +1 -1
- package/dist/src/commands/sync.d.ts +390 -13
- package/dist/src/commands/sync.js +2613 -451
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/tokens.js +2 -1
- package/dist/src/commands/tokens.js.map +1 -1
- package/dist/src/commands/users.js +19 -10
- package/dist/src/commands/users.js.map +1 -1
- package/dist/src/commands/vars.js +2 -1
- package/dist/src/commands/vars.js.map +1 -1
- package/dist/src/commands/waitlist.js +2 -1
- package/dist/src/commands/waitlist.js.map +1 -1
- package/dist/src/commands/webhooks.d.ts +29 -0
- package/dist/src/commands/webhooks.js +135 -64
- package/dist/src/commands/webhooks.js.map +1 -1
- package/dist/src/commands/workflows.d.ts +1 -29
- package/dist/src/commands/workflows.js +113 -179
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +628 -14
- package/dist/src/lib/api-client.js +776 -70
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/app-settings-descriptor.d.ts +1 -1
- package/dist/src/lib/app-settings-descriptor.js +9 -1
- package/dist/src/lib/app-settings-descriptor.js.map +1 -1
- package/dist/src/lib/block-selector.d.ts +58 -0
- package/dist/src/lib/block-selector.js +92 -0
- package/dist/src/lib/block-selector.js.map +1 -0
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +24 -54
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +31 -153
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
- package/dist/src/lib/collection-export.d.ts +184 -0
- package/dist/src/lib/collection-export.js +252 -0
- package/dist/src/lib/collection-export.js.map +1 -0
- package/dist/src/lib/config-json-field.d.ts +28 -0
- package/dist/src/lib/config-json-field.js +56 -0
- package/dist/src/lib/config-json-field.js.map +1 -0
- package/dist/src/lib/config-object-descriptor.js +88 -6
- package/dist/src/lib/config-object-descriptor.js.map +1 -1
- package/dist/src/lib/config-payload.d.ts +8 -1
- package/dist/src/lib/config-payload.js +49 -4
- package/dist/src/lib/config-payload.js.map +1 -1
- package/dist/src/lib/config-surface.d.ts +12 -1
- package/dist/src/lib/config-surface.js +71 -3
- package/dist/src/lib/config-surface.js.map +1 -1
- package/dist/src/lib/config.d.ts +31 -13
- package/dist/src/lib/config.js +34 -70
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/credentials-store.d.ts +37 -22
- package/dist/src/lib/credentials-store.js +103 -97
- package/dist/src/lib/credentials-store.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTypeIR.js +8 -0
- package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -1
- package/dist/src/lib/deprecation.d.ts +22 -0
- package/dist/src/lib/deprecation.js +43 -0
- package/dist/src/lib/deprecation.js.map +1 -0
- package/dist/src/lib/document-export-permissions.d.ts +30 -0
- package/dist/src/lib/document-export-permissions.js +54 -0
- package/dist/src/lib/document-export-permissions.js.map +1 -0
- package/dist/src/lib/document-ingest-artifact.d.ts +120 -0
- package/dist/src/lib/document-ingest-artifact.js +505 -0
- package/dist/src/lib/document-ingest-artifact.js.map +1 -0
- package/dist/src/lib/document-ingest-input.d.ts +51 -0
- package/dist/src/lib/document-ingest-input.js +132 -0
- package/dist/src/lib/document-ingest-input.js.map +1 -0
- package/dist/src/lib/document-ingest-rows.d.ts +137 -0
- package/dist/src/lib/document-ingest-rows.js +181 -0
- package/dist/src/lib/document-ingest-rows.js.map +1 -0
- package/dist/src/lib/document-ingest.d.ts +101 -0
- package/dist/src/lib/document-ingest.js +384 -0
- package/dist/src/lib/document-ingest.js.map +1 -0
- package/dist/src/lib/env-resolver-core.d.ts +89 -18
- package/dist/src/lib/env-resolver-core.js +145 -23
- package/dist/src/lib/env-resolver-core.js.map +1 -1
- package/dist/src/lib/env-resolver.d.ts +18 -3
- package/dist/src/lib/env-resolver.js +23 -3
- package/dist/src/lib/env-resolver.js.map +1 -1
- package/dist/src/lib/function-bundle.d.ts +147 -0
- package/dist/src/lib/function-bundle.js +341 -0
- package/dist/src/lib/function-bundle.js.map +1 -0
- package/dist/src/lib/function-collect.d.ts +123 -0
- package/dist/src/lib/function-collect.js +610 -0
- package/dist/src/lib/function-collect.js.map +1 -0
- package/dist/src/lib/function-db-types.d.ts +202 -0
- package/dist/src/lib/function-db-types.js +870 -0
- package/dist/src/lib/function-db-types.js.map +1 -0
- package/dist/src/lib/function-document-types.d.ts +144 -0
- package/dist/src/lib/function-document-types.js +370 -0
- package/dist/src/lib/function-document-types.js.map +1 -0
- package/dist/src/lib/function-grants-preflight.d.ts +64 -0
- package/dist/src/lib/function-grants-preflight.js +105 -0
- package/dist/src/lib/function-grants-preflight.js.map +1 -0
- package/dist/src/lib/function-log-lines.d.ts +76 -0
- package/dist/src/lib/function-log-lines.js +160 -0
- package/dist/src/lib/function-log-lines.js.map +1 -0
- package/dist/src/lib/function-log-row.d.ts +29 -0
- package/dist/src/lib/function-log-row.js +73 -0
- package/dist/src/lib/function-log-row.js.map +1 -0
- package/dist/src/lib/function-log-tail.d.ts +132 -0
- package/dist/src/lib/function-log-tail.js +262 -0
- package/dist/src/lib/function-log-tail.js.map +1 -0
- package/dist/src/lib/function-run.d.ts +289 -0
- package/dist/src/lib/function-run.js +389 -0
- package/dist/src/lib/function-run.js.map +1 -0
- package/dist/src/lib/function-schema-codegen.d.ts +143 -0
- package/dist/src/lib/function-schema-codegen.js +420 -0
- package/dist/src/lib/function-schema-codegen.js.map +1 -0
- package/dist/src/lib/function-sync.d.ts +459 -0
- package/dist/src/lib/function-sync.js +1258 -0
- package/dist/src/lib/function-sync.js.map +1 -0
- package/dist/src/lib/function-trigger-listing.d.ts +27 -0
- package/dist/src/lib/function-trigger-listing.js +70 -0
- package/dist/src/lib/function-trigger-listing.js.map +1 -0
- package/dist/src/lib/function-typecheck.d.ts +86 -0
- package/dist/src/lib/function-typecheck.js +370 -0
- package/dist/src/lib/function-typecheck.js.map +1 -0
- package/dist/src/lib/function-versions.d.ts +122 -0
- package/dist/src/lib/function-versions.js +182 -0
- package/dist/src/lib/function-versions.js.map +1 -0
- package/dist/src/lib/generated-allowlist.js +4 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -1
- package/dist/src/lib/generated-config-surfaces.d.ts +2332 -78
- package/dist/src/lib/generated-config-surfaces.js +6979 -468
- package/dist/src/lib/generated-config-surfaces.js.map +1 -1
- package/dist/src/lib/generated-sdk-types.d.ts +12 -0
- package/dist/src/lib/generated-sdk-types.js +13 -0
- package/dist/src/lib/generated-sdk-types.js.map +1 -0
- package/dist/src/lib/init-adopt.js +2 -2
- package/dist/src/lib/init-adopt.js.map +1 -1
- package/dist/src/lib/init-assets.js +3 -3
- package/dist/src/lib/init-config.d.ts +1 -1
- package/dist/src/lib/init-ios-links.d.ts +6 -5
- package/dist/src/lib/init-ios-links.js +9 -8
- package/dist/src/lib/init-ios-links.js.map +1 -1
- package/dist/src/lib/init-plan.d.ts +1 -1
- package/dist/src/lib/init-plan.js +1 -1
- package/dist/src/lib/init-xcode.d.ts +6 -5
- package/dist/src/lib/init-xcode.js +34 -10
- 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/list-options.d.ts +68 -0
- package/dist/src/lib/list-options.js +89 -0
- package/dist/src/lib/list-options.js.map +1 -0
- package/dist/src/lib/local-state.d.ts +1 -1
- package/dist/src/lib/local-state.js +1 -1
- package/dist/src/lib/local-test-cases.d.ts +1 -1
- package/dist/src/lib/local-test-cases.js +2 -1
- package/dist/src/lib/local-test-cases.js.map +1 -1
- package/dist/src/lib/log-inspection.d.ts +152 -5
- package/dist/src/lib/log-inspection.js +179 -2
- package/dist/src/lib/log-inspection.js.map +1 -1
- package/dist/src/lib/logout-admin-session.d.ts +33 -0
- package/dist/src/lib/logout-admin-session.js +70 -0
- package/dist/src/lib/logout-admin-session.js.map +1 -0
- package/dist/src/lib/output.d.ts +15 -0
- package/dist/src/lib/output.js +28 -0
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/paginate.d.ts +15 -0
- package/dist/src/lib/paginate.js +17 -0
- package/dist/src/lib/paginate.js.map +1 -1
- package/dist/src/lib/project-config.d.ts +38 -21
- package/dist/src/lib/project-config.js +57 -47
- package/dist/src/lib/project-config.js.map +1 -1
- package/dist/src/lib/prompt-cost-format.d.ts +11 -0
- package/dist/src/lib/prompt-cost-format.js +41 -0
- package/dist/src/lib/prompt-cost-format.js.map +1 -0
- package/dist/src/lib/prompt-schema-codegen.d.ts +147 -0
- package/dist/src/lib/prompt-schema-codegen.js +462 -0
- package/dist/src/lib/prompt-schema-codegen.js.map +1 -0
- package/dist/src/lib/refresh-admin-credentials.d.ts +9 -1
- package/dist/src/lib/refresh-admin-credentials.js +22 -2
- package/dist/src/lib/refresh-admin-credentials.js.map +1 -1
- package/dist/src/lib/resolve-owner.d.ts +19 -0
- package/dist/src/lib/resolve-owner.js +20 -0
- package/dist/src/lib/resolve-owner.js.map +1 -0
- package/dist/src/lib/skill-installer.d.ts +49 -3
- package/dist/src/lib/skill-installer.js +275 -100
- package/dist/src/lib/skill-installer.js.map +1 -1
- package/dist/src/lib/snapshot-audit-source.d.ts +45 -0
- package/dist/src/lib/snapshot-audit-source.js +58 -0
- package/dist/src/lib/snapshot-audit-source.js.map +1 -0
- package/dist/src/lib/snapshot-audit-store.d.ts +52 -0
- package/dist/src/lib/snapshot-audit-store.js +196 -0
- package/dist/src/lib/snapshot-audit-store.js.map +1 -0
- package/dist/src/lib/snapshot-audit.d.ts +207 -0
- package/dist/src/lib/snapshot-audit.js +431 -0
- package/dist/src/lib/snapshot-audit.js.map +1 -0
- package/dist/src/lib/snapshot-build-rows.d.ts +60 -0
- package/dist/src/lib/snapshot-build-rows.js +87 -0
- package/dist/src/lib/snapshot-build-rows.js.map +1 -0
- package/dist/src/lib/snapshot-build.d.ts +50 -0
- package/dist/src/lib/snapshot-build.js +111 -0
- package/dist/src/lib/snapshot-build.js.map +1 -0
- package/dist/src/lib/snapshot-manifest-layout.d.ts +61 -0
- package/dist/src/lib/snapshot-manifest-layout.js +70 -0
- package/dist/src/lib/snapshot-manifest-layout.js.map +1 -0
- package/dist/src/lib/snapshots.d.ts +8 -9
- package/dist/src/lib/snapshots.js +20 -83
- package/dist/src/lib/snapshots.js.map +1 -1
- package/dist/src/lib/step-run-table.d.ts +43 -0
- package/dist/src/lib/step-run-table.js +129 -0
- package/dist/src/lib/step-run-table.js.map +1 -0
- package/dist/src/lib/storage-pending-retry.d.ts +26 -0
- package/dist/src/lib/storage-pending-retry.js +42 -0
- package/dist/src/lib/storage-pending-retry.js.map +1 -0
- package/dist/src/lib/swift-codegen/agentGenerator.d.ts +42 -0
- package/dist/src/lib/swift-codegen/agentGenerator.js +118 -0
- package/dist/src/lib/swift-codegen/agentGenerator.js.map +1 -0
- package/dist/src/lib/swift-codegen/banners.d.ts +24 -0
- package/dist/src/lib/swift-codegen/banners.js +25 -0
- package/dist/src/lib/swift-codegen/banners.js.map +1 -0
- package/dist/src/lib/swift-codegen/dbGenerator.js +19 -7
- package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -1
- package/dist/src/lib/swift-codegen/functionGenerator.d.ts +139 -0
- package/dist/src/lib/swift-codegen/functionGenerator.js +462 -0
- package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -0
- package/dist/src/lib/swift-codegen/generator.d.ts +7 -1
- package/dist/src/lib/swift-codegen/generator.js +28 -11
- package/dist/src/lib/swift-codegen/generator.js.map +1 -1
- package/dist/src/lib/swift-codegen/schemaToSwift.d.ts +15 -0
- package/dist/src/lib/swift-codegen/schemaToSwift.js +21 -4
- package/dist/src/lib/swift-codegen/schemaToSwift.js.map +1 -1
- package/dist/src/lib/sync-paths.d.ts +72 -55
- package/dist/src/lib/sync-paths.js +97 -100
- package/dist/src/lib/sync-paths.js.map +1 -1
- package/dist/src/lib/sync-resource-types.d.ts +23 -4
- package/dist/src/lib/sync-resource-types.js +106 -8
- package/dist/src/lib/sync-resource-types.js.map +1 -1
- package/dist/src/lib/sync-selectors.d.ts +45 -2
- package/dist/src/lib/sync-selectors.js +66 -5
- package/dist/src/lib/sync-selectors.js.map +1 -1
- package/dist/src/lib/template.d.ts +2 -2
- package/dist/src/lib/template.js +2 -2
- package/dist/src/lib/test-case-keys.d.ts +1 -1
- package/dist/src/lib/test-case-keys.js +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/toml-database-config.js +19 -2
- package/dist/src/lib/toml-database-config.js.map +1 -1
- package/dist/src/lib/webhook-deliver.d.ts +209 -0
- package/dist/src/lib/webhook-deliver.js +519 -0
- package/dist/src/lib/webhook-deliver.js.map +1 -0
- package/dist/src/lib/workflow-codegen/generator.d.ts +1 -1
- package/dist/src/lib/workflow-codegen/generator.js +1 -1
- package/dist/src/lib/workflow-toml-validator.d.ts +17 -8
- package/dist/src/lib/workflow-toml-validator.js +25 -12
- package/dist/src/lib/workflow-toml-validator.js.map +1 -1
- package/dist/src/lib/workflow-usage.d.ts +196 -0
- package/dist/src/lib/workflow-usage.js +310 -0
- package/dist/src/lib/workflow-usage.js.map +1 -0
- package/dist/src/types/index.d.ts +12 -2
- package/package.json +5 -4
|
@@ -1,29 +1,175 @@
|
|
|
1
|
+
import { snapshotBuildRows } from "../lib/snapshot-build-rows.js";
|
|
2
|
+
import { driveSnapshotBuild } from "../lib/snapshot-build.js";
|
|
3
|
+
import { ingestClientRows, ingestSessionRows, } from "../lib/document-ingest-rows.js";
|
|
1
4
|
import { ApiClient } from "../lib/api-client.js";
|
|
2
5
|
import { resolveAppId } from "../lib/config.js";
|
|
3
|
-
import { success, error, info, formatTable, formatId, formatDate, json, keyValue, result as printResult, warn, } from "../lib/output.js";
|
|
6
|
+
import { success, error, info, formatTable, formatId, formatDate, json, keyValue, result as printResult, warn, flushOutput, } from "../lib/output.js";
|
|
4
7
|
import { confirmPrompt } from "../lib/confirm-prompt.js";
|
|
8
|
+
import { resolveOwnerUserId } from "../lib/resolve-owner.js";
|
|
9
|
+
import { pageCursorOption, pageLimitOption, parsePageLimit, printEmptyPage, printPageHint, } from "../lib/list-options.js";
|
|
10
|
+
import { normalizeCliListEnvelope, wholeListEnvelope, } from "../lib/paginate.js";
|
|
5
11
|
import { parseDataOption } from "../lib/data-input.js";
|
|
6
12
|
import { parseFilterOptions } from "../lib/record-filter.js";
|
|
13
|
+
import { buildPermissionsExport } from "../lib/document-export-permissions.js";
|
|
14
|
+
import { chunkAddressOf, renumberManifestForExport, } from "../lib/snapshot-manifest-layout.js";
|
|
15
|
+
import { validateSnapshotManifest } from "js-bao";
|
|
16
|
+
import { auditSnapshotChain, } from "../lib/snapshot-audit.js";
|
|
17
|
+
import { createAuditApiSource } from "../lib/snapshot-audit-source.js";
|
|
18
|
+
import { createAuditLocalStore, createChainFingerprintReader, } from "../lib/snapshot-audit-store.js";
|
|
19
|
+
import { discoverIngestInput, } from "../lib/document-ingest-input.js";
|
|
20
|
+
import { buildIngestArtifact, ingestSchemaFromIntrospection, } from "../lib/document-ingest-artifact.js";
|
|
21
|
+
import { driveDocumentIngest, summariseIngestPlan, assertIngestConfirmable, } from "../lib/document-ingest.js";
|
|
7
22
|
import { ulid } from "ulid";
|
|
8
23
|
import * as fs from "fs";
|
|
9
24
|
import * as path from "path";
|
|
10
25
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
26
|
+
* One bulk-load session as rows (#3434, #3435, #3598).
|
|
27
|
+
*
|
|
28
|
+
* Shared by `documents ingests get` and by `documents ingest`'s own terminal
|
|
29
|
+
* report, so an operator reads the same thing however they arrived at it. The
|
|
30
|
+
* rows themselves are decided in `document-ingest-rows`, where they can be
|
|
31
|
+
* checked without a live server holding a session in the right state.
|
|
17
32
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const lookup = await client.lookupUserByEmail(appId, owner);
|
|
22
|
-
if (!lookup?.exists || !lookup.user) {
|
|
23
|
-
throw new Error(`No app user found with email "${owner}" in app ${appId}.`);
|
|
33
|
+
function printIngestSession(session) {
|
|
34
|
+
for (const [label, value] of ingestSessionRows(session)) {
|
|
35
|
+
printResult(label, value);
|
|
24
36
|
}
|
|
25
|
-
|
|
26
|
-
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* One refused records call, as one line for an operator (#3764).
|
|
40
|
+
*
|
|
41
|
+
* The same composition `describeBulkFailure` makes for the atomic blob, on the
|
|
42
|
+
* verbs that have no `--json` envelope to fill: the server's sentence, the
|
|
43
|
+
* stable code a script branches on, and the status. `statusCode` FIRST —
|
|
44
|
+
* `cli/src/lib/api-client.ts`'s `ApiError` is where the status lives, and a
|
|
45
|
+
* reader that asks for `status` alone prints nothing at all (#3597 paid for
|
|
46
|
+
* that once already).
|
|
47
|
+
*/
|
|
48
|
+
export function describeRecordsFailure(err) {
|
|
49
|
+
const message = (typeof err?.message === "string" && err.message) || String(err);
|
|
50
|
+
const code = typeof err?.code === "string" && err.code ? err.code : undefined;
|
|
51
|
+
const raw = err?.statusCode ?? err?.status;
|
|
52
|
+
const status = Number.isFinite(Number(raw)) ? Number(raw) : undefined;
|
|
53
|
+
return `${message}${code ? ` [${code}]` : ""}${status !== undefined ? ` (${status})` : ""}`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Report a document's format (#2816) — but only when it is a large document.
|
|
57
|
+
*
|
|
58
|
+
* The format is chosen at creation and never migrated, so an operator has to
|
|
59
|
+
* be able to read it back: `--large` that the server ignored would otherwise
|
|
60
|
+
* hand back an ordinary-looking document. A legacy document says nothing at
|
|
61
|
+
* all, so its output is byte-for-byte what it has always been, however the
|
|
62
|
+
* stored row spells the format (absent, null or 1).
|
|
63
|
+
*/
|
|
64
|
+
function printDocumentFormat(doc) {
|
|
65
|
+
if (Number(doc?.documentFormat) === 2) {
|
|
66
|
+
keyValue("Format", "2 (large document)");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The `--document-format` an operator stated, or the refusal (#3764, E10).
|
|
71
|
+
*
|
|
72
|
+
* Refused HERE rather than by the server, because an operator CAN act on it
|
|
73
|
+
* (principle 6) and a round trip to be told what they typed wrong is a round
|
|
74
|
+
* trip they do not need to spend. Commander hands the raw string over, so the
|
|
75
|
+
* digits are what is read; an unset flag states nothing.
|
|
76
|
+
*/
|
|
77
|
+
export function parseDocumentFormatOption(value) {
|
|
78
|
+
if (value === undefined || value === null)
|
|
79
|
+
return undefined;
|
|
80
|
+
const text = String(value).trim();
|
|
81
|
+
if (text === "1")
|
|
82
|
+
return 1;
|
|
83
|
+
if (text === "2")
|
|
84
|
+
return 2;
|
|
85
|
+
throw new Error(`Invalid --document-format "${String(value)}". Expected 1 (an ordinary ` +
|
|
86
|
+
`document) or 2 (a large document).`);
|
|
87
|
+
}
|
|
88
|
+
/** The `--document-format <1|2>` flag, said once for every records verb. */
|
|
89
|
+
const DOCUMENT_FORMAT_FLAG = "--document-format <1|2>";
|
|
90
|
+
const DOCUMENT_FORMAT_HELP = "The document format you expect: 1 (ordinary) or 2 (large). A disagreement " +
|
|
91
|
+
"with the platform's own answer is refused with DOCUMENT_FORMAT_MISMATCH " +
|
|
92
|
+
"rather than answered out of the wrong tables";
|
|
93
|
+
/**
|
|
94
|
+
* What `documents records bulk` reports when the write is refused (#3619).
|
|
95
|
+
*
|
|
96
|
+
* The command used to catch every failure with `error(err.message)` while
|
|
97
|
+
* `ApiError` carried the code and the status on separate fields, so the stable
|
|
98
|
+
* code the server answers never reached the output and `--json` handled
|
|
99
|
+
* successes only. An operator driving a multi-hour load had to read human text
|
|
100
|
+
* to tell "ask again" from "somebody look at the log" (finding 3619-SO-04).
|
|
101
|
+
*
|
|
102
|
+
* The status is read from `statusCode` FIRST: that is where
|
|
103
|
+
* `cli/src/lib/api-client.ts`'s `ApiError` keeps it, and a reader that asks for
|
|
104
|
+
* `status` alone sees `undefined` on every error the client raises and prints
|
|
105
|
+
* nothing at all — #3597 paid for that once already. `status` is the fallback,
|
|
106
|
+
* for an error that came from somewhere else.
|
|
107
|
+
*
|
|
108
|
+
* Its own function so both surfaces are composed once and can be graded
|
|
109
|
+
* without spawning a process: the human line and the `--json` envelope must
|
|
110
|
+
* agree about which failure happened.
|
|
111
|
+
*/
|
|
112
|
+
export function describeBulkFailure(err) {
|
|
113
|
+
const message = (typeof err?.message === "string" && err.message) || String(err);
|
|
114
|
+
const code = typeof err?.code === "string" && err.code ? err.code : undefined;
|
|
115
|
+
const raw = err?.statusCode ?? err?.status;
|
|
116
|
+
const status = Number.isFinite(Number(raw)) ? Number(raw) : undefined;
|
|
117
|
+
return {
|
|
118
|
+
line: `${message}${code ? ` [${code}]` : ""}${status !== undefined ? ` (${status})` : ""}`,
|
|
119
|
+
envelope: {
|
|
120
|
+
ok: false,
|
|
121
|
+
...(code ? { code } : {}),
|
|
122
|
+
...(status !== undefined ? { status } : {}),
|
|
123
|
+
error: message,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A document's tags as a list, from whatever the wire handed back (#3644).
|
|
129
|
+
*
|
|
130
|
+
* `tags` is absent when the document has none — the server's presence rule,
|
|
131
|
+
* shared by the app API's list shape and the admin inventory — so every
|
|
132
|
+
* reader here has to cope with `undefined`. A stray non-array (an older
|
|
133
|
+
* server, a hand-edited fixture) is read as "no tags" rather than printed
|
|
134
|
+
* raw.
|
|
135
|
+
*/
|
|
136
|
+
function documentTagList(tags) {
|
|
137
|
+
return Array.isArray(tags)
|
|
138
|
+
? tags.filter((tag) => typeof tag === "string" && tag !== "")
|
|
139
|
+
: [];
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Tag text a terminal prints rather than obeys (#3644).
|
|
143
|
+
*
|
|
144
|
+
* A tag is app-user text. The write-side validator
|
|
145
|
+
* (`validateTag` in `src/app-api/controllers/documents-controller.ts`) trims
|
|
146
|
+
* it, caps it at 48 characters and refuses dynamo-bao's key separator, but
|
|
147
|
+
* every other byte survives — including the escape character. Printed raw
|
|
148
|
+
* into an operator's terminal, a 15-character tag such as
|
|
149
|
+
* `\x1b[2J\x1b[Hrestored` clears the screen and homes the cursor while
|
|
150
|
+
* the listing is being written, so a member with write access to one document
|
|
151
|
+
* could hide or falsify what `documents list` and `documents get` appear to
|
|
152
|
+
* report about the rest. The C0 range, DEL and the C1 range are therefore
|
|
153
|
+
* rendered as their `\uXXXX` source escapes: readable, one line and one cell
|
|
154
|
+
* per tag, and inert. Ordinary text — accents, CJK, emoji — is untouched.
|
|
155
|
+
*/
|
|
156
|
+
function escapeForTerminal(tag) {
|
|
157
|
+
return tag.replace(/[\x00-\x1f\x7f-\x9f]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* A document's tags as printable text, in the order the wire reported them.
|
|
161
|
+
*
|
|
162
|
+
* Only the plain-text surfaces come through here: `--json` on both commands
|
|
163
|
+
* returns the server's object untouched, so a migration diffing tags against
|
|
164
|
+
* what it wrote still reads the stored bytes.
|
|
165
|
+
*/
|
|
166
|
+
export function displayTags(tags) {
|
|
167
|
+
return documentTagList(tags).map(escapeForTerminal);
|
|
168
|
+
}
|
|
169
|
+
/** The tags cell of a documents table: comma-joined, or `-` when there are none. */
|
|
170
|
+
export function formatTags(tags) {
|
|
171
|
+
const list = displayTags(tags);
|
|
172
|
+
return list.length > 0 ? list.join(", ") : "-";
|
|
27
173
|
}
|
|
28
174
|
export function registerDocumentsCommands(program) {
|
|
29
175
|
const documents = program
|
|
@@ -61,26 +207,38 @@ Examples:
|
|
|
61
207
|
.description("List a user's documents")
|
|
62
208
|
.requiredOption("--user-id <id>", "User ID whose documents to list")
|
|
63
209
|
.option("--app <app-id>", "App ID")
|
|
210
|
+
.addOption(pageLimitOption())
|
|
211
|
+
.addOption(pageCursorOption())
|
|
64
212
|
.option("--json", "Output as JSON")
|
|
65
213
|
.action(async (options) => {
|
|
214
|
+
const limit = parsePageLimit(options.limit);
|
|
66
215
|
const resolvedAppId = resolveAppId(undefined, options);
|
|
67
216
|
const client = new ApiClient();
|
|
68
217
|
try {
|
|
69
|
-
const
|
|
218
|
+
const page = normalizeCliListEnvelope(await client.listAdminDocumentsPage(resolvedAppId, options.userId, {
|
|
219
|
+
limit,
|
|
220
|
+
cursor: options.cursor,
|
|
221
|
+
}));
|
|
222
|
+
const list = page.items;
|
|
70
223
|
if (options.json) {
|
|
71
|
-
json(
|
|
224
|
+
json(page);
|
|
72
225
|
return;
|
|
73
226
|
}
|
|
74
227
|
if (list.length === 0) {
|
|
75
|
-
|
|
228
|
+
printEmptyPage(page, "documents");
|
|
76
229
|
return;
|
|
77
230
|
}
|
|
78
231
|
console.log(formatTable(list, [
|
|
79
232
|
{ header: "DOCUMENT_ID", key: "documentId", format: formatId },
|
|
80
233
|
{ header: "TITLE", key: "title", flex: true },
|
|
234
|
+
// #3644 — the tags the route now reports. An app migration that
|
|
235
|
+
// regenerates tags on import verifies them from here; a document
|
|
236
|
+
// with none shows `-` rather than an empty cell.
|
|
237
|
+
{ header: "TAGS", key: "tags", format: formatTags },
|
|
81
238
|
{ header: "PERMISSION", key: "permission" },
|
|
82
239
|
{ header: "GRANTED", key: "grantedAt", format: formatDate },
|
|
83
240
|
]));
|
|
241
|
+
printPageHint(page);
|
|
84
242
|
}
|
|
85
243
|
catch (err) {
|
|
86
244
|
error(err.message);
|
|
@@ -108,6 +266,16 @@ Examples:
|
|
|
108
266
|
keyValue("Created By", doc.createdBy);
|
|
109
267
|
keyValue("Created", formatDate(doc.createdAt));
|
|
110
268
|
keyValue("Modified", formatDate(doc.modifiedAt ?? doc.lastModified));
|
|
269
|
+
printDocumentFormat(doc);
|
|
270
|
+
// #3644 — tags, when the document has any. The endpoint has returned
|
|
271
|
+
// them since #3096 but only `--json` showed them, so a migration
|
|
272
|
+
// verifying restored tags had to parse JSON to see its own work.
|
|
273
|
+
// Printed only when present, by the same rule the response uses: an
|
|
274
|
+
// untagged document's output is unchanged, and escaped the same way
|
|
275
|
+
// the table's cell is so a crafted tag cannot drive the terminal.
|
|
276
|
+
const tags = displayTags(doc.tags);
|
|
277
|
+
if (tags.length > 0)
|
|
278
|
+
keyValue("Tags", tags.join(", "));
|
|
111
279
|
// `GET documents/:id` reports the caller's own access, not the
|
|
112
280
|
// document's grant/alias/collection inventory — printing counts for
|
|
113
281
|
// fields the endpoint never returns read as a definitive "0" for
|
|
@@ -135,6 +303,7 @@ Examples:
|
|
|
135
303
|
.argument("<title>", "Document title")
|
|
136
304
|
.option("--app <app-id>", "App ID")
|
|
137
305
|
.option("--owner <userId-or-email>", "User ID or email to own the document (super-admin or assigned-console-admin tokens only)")
|
|
306
|
+
.option("--large", "Create a large document (format 2): base snapshots + epoch overlays, for documents that outgrow a single in-memory Y.Doc")
|
|
138
307
|
.option("--json", "Output as JSON")
|
|
139
308
|
.addHelpText("after", `
|
|
140
309
|
Ownership:
|
|
@@ -166,6 +335,9 @@ Ownership:
|
|
|
166
335
|
const result = await client.createDocument(resolvedAppId, {
|
|
167
336
|
title,
|
|
168
337
|
...(ownerUserId ? { createdBy: ownerUserId } : {}),
|
|
338
|
+
// Opt-in only: without --large nothing is sent, so the document is
|
|
339
|
+
// created in the legacy format exactly as before (#2816).
|
|
340
|
+
...(options.large ? { documentFormat: 2 } : {}),
|
|
169
341
|
});
|
|
170
342
|
if (options.json) {
|
|
171
343
|
json(result);
|
|
@@ -175,6 +347,9 @@ Ownership:
|
|
|
175
347
|
keyValue("Document ID", result.documentId);
|
|
176
348
|
keyValue("Title", result.title);
|
|
177
349
|
keyValue("Owner", result.createdBy);
|
|
350
|
+
// Read back from the RESPONSE, not from `--large`: what the server
|
|
351
|
+
// created is what the document will be for the rest of its life.
|
|
352
|
+
printDocumentFormat(result);
|
|
178
353
|
}
|
|
179
354
|
catch (err) {
|
|
180
355
|
error(err.message);
|
|
@@ -254,7 +429,7 @@ Permissions (enforced by the server, not the CLI):
|
|
|
254
429
|
const result = await client.listDocumentPermissions(resolvedAppId, documentId);
|
|
255
430
|
const list = Array.isArray(result) ? result : result?.permissions ?? [];
|
|
256
431
|
if (options.json) {
|
|
257
|
-
json(list);
|
|
432
|
+
json(wholeListEnvelope(list));
|
|
258
433
|
return;
|
|
259
434
|
}
|
|
260
435
|
if (list.length === 0) {
|
|
@@ -392,7 +567,9 @@ Permissions (enforced by the server, not the CLI):
|
|
|
392
567
|
}
|
|
393
568
|
}
|
|
394
569
|
catch (err) {
|
|
395
|
-
|
|
570
|
+
// #3764 — the server's sentence, the stable code and the status, so a
|
|
571
|
+
// refused records call is scriptable rather than only readable.
|
|
572
|
+
error(describeRecordsFailure(err));
|
|
396
573
|
process.exit(1);
|
|
397
574
|
}
|
|
398
575
|
});
|
|
@@ -443,7 +620,9 @@ Permissions (enforced by the server, not the CLI):
|
|
|
443
620
|
]));
|
|
444
621
|
}
|
|
445
622
|
catch (err) {
|
|
446
|
-
|
|
623
|
+
// #3764 — the server's sentence, the stable code and the status, so a
|
|
624
|
+
// refused records call is scriptable rather than only readable.
|
|
625
|
+
error(describeRecordsFailure(err));
|
|
447
626
|
process.exit(1);
|
|
448
627
|
}
|
|
449
628
|
});
|
|
@@ -458,13 +637,24 @@ Permissions (enforced by the server, not the CLI):
|
|
|
458
637
|
.option("--filter-file <path>", "Read filter from a JSON or TOML file")
|
|
459
638
|
.option("--limit <n>", "Maximum number of records to return (max 100)", parseInt)
|
|
460
639
|
.option("--cursor <cursor>", "Pagination cursor from a previous query")
|
|
640
|
+
.option(DOCUMENT_FORMAT_FLAG, DOCUMENT_FORMAT_HELP)
|
|
461
641
|
.option("--json", "Output as JSON")
|
|
462
642
|
.action(async (documentId, modelName, options) => {
|
|
463
643
|
const resolvedAppId = resolveAppId(undefined, options);
|
|
644
|
+
let documentFormat;
|
|
645
|
+
try {
|
|
646
|
+
documentFormat = parseDocumentFormatOption(options.documentFormat);
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
// #3764 — the server's sentence, the stable code and the status, so a
|
|
650
|
+
// refused records call is scriptable rather than only readable.
|
|
651
|
+
error(describeRecordsFailure(err));
|
|
652
|
+
process.exit(1);
|
|
653
|
+
}
|
|
464
654
|
const filter = parseFilterOptions(options);
|
|
465
655
|
const client = new ApiClient();
|
|
466
656
|
try {
|
|
467
|
-
const result = await client.queryDocumentRecords(resolvedAppId, documentId, modelName, { filter, limit: options.limit, cursor: options.cursor });
|
|
657
|
+
const result = await client.queryDocumentRecords(resolvedAppId, documentId, modelName, { filter, limit: options.limit, cursor: options.cursor, documentFormat });
|
|
468
658
|
if (options.json) {
|
|
469
659
|
json(result);
|
|
470
660
|
return;
|
|
@@ -502,7 +692,9 @@ Permissions (enforced by the server, not the CLI):
|
|
|
502
692
|
}
|
|
503
693
|
}
|
|
504
694
|
catch (err) {
|
|
505
|
-
|
|
695
|
+
// #3764 — the server's sentence, the stable code and the status, so a
|
|
696
|
+
// refused records call is scriptable rather than only readable.
|
|
697
|
+
error(describeRecordsFailure(err));
|
|
506
698
|
process.exit(1);
|
|
507
699
|
}
|
|
508
700
|
});
|
|
@@ -514,15 +706,26 @@ Permissions (enforced by the server, not the CLI):
|
|
|
514
706
|
.argument("<model-name>", "Model name")
|
|
515
707
|
.argument("<record-id>", "Record ID")
|
|
516
708
|
.option("--app <app-id>", "App ID")
|
|
709
|
+
.option(DOCUMENT_FORMAT_FLAG, DOCUMENT_FORMAT_HELP)
|
|
517
710
|
.option("--json", "Output as JSON")
|
|
518
711
|
.action(async (documentId, modelName, recordId, options) => {
|
|
519
712
|
const resolvedAppId = resolveAppId(undefined, options);
|
|
713
|
+
let documentFormat;
|
|
714
|
+
try {
|
|
715
|
+
documentFormat = parseDocumentFormatOption(options.documentFormat);
|
|
716
|
+
}
|
|
717
|
+
catch (err) {
|
|
718
|
+
// #3764 — the server's sentence, the stable code and the status, so a
|
|
719
|
+
// refused records call is scriptable rather than only readable.
|
|
720
|
+
error(describeRecordsFailure(err));
|
|
721
|
+
process.exit(1);
|
|
722
|
+
}
|
|
520
723
|
const client = new ApiClient();
|
|
521
724
|
try {
|
|
522
725
|
// A single-record fetch is a query filtered by primary-key id (there is
|
|
523
726
|
// no dedicated get endpoint — reuse queryDocumentRecords, exactly as
|
|
524
727
|
// the `databases records get` twin reuses queryDatabaseRecords).
|
|
525
|
-
const result = await client.queryDocumentRecords(resolvedAppId, documentId, modelName, { filter: { id: recordId }, limit: 1 });
|
|
728
|
+
const result = await client.queryDocumentRecords(resolvedAppId, documentId, modelName, { filter: { id: recordId }, limit: 1, documentFormat });
|
|
526
729
|
const record = result.items[0];
|
|
527
730
|
if (options.json) {
|
|
528
731
|
json(record ?? null);
|
|
@@ -546,7 +749,9 @@ Permissions (enforced by the server, not the CLI):
|
|
|
546
749
|
}
|
|
547
750
|
}
|
|
548
751
|
catch (err) {
|
|
549
|
-
|
|
752
|
+
// #3764 — the server's sentence, the stable code and the status, so a
|
|
753
|
+
// refused records call is scriptable rather than only readable.
|
|
754
|
+
error(describeRecordsFailure(err));
|
|
550
755
|
process.exit(1);
|
|
551
756
|
}
|
|
552
757
|
});
|
|
@@ -558,14 +763,25 @@ Permissions (enforced by the server, not the CLI):
|
|
|
558
763
|
.argument("<model-name>", "Model name to count")
|
|
559
764
|
.option("--app <app-id>", "App ID")
|
|
560
765
|
.option("--filter <json>", "Filter as JSON (e.g. '{\"status\":\"open\"}')")
|
|
766
|
+
.option(DOCUMENT_FORMAT_FLAG, DOCUMENT_FORMAT_HELP)
|
|
561
767
|
.option("--filter-file <path>", "Read filter from a JSON or TOML file")
|
|
562
768
|
.option("--json", "Output as JSON")
|
|
563
769
|
.action(async (documentId, modelName, options) => {
|
|
564
770
|
const resolvedAppId = resolveAppId(undefined, options);
|
|
771
|
+
let documentFormat;
|
|
772
|
+
try {
|
|
773
|
+
documentFormat = parseDocumentFormatOption(options.documentFormat);
|
|
774
|
+
}
|
|
775
|
+
catch (err) {
|
|
776
|
+
// #3764 — the server's sentence, the stable code and the status, so a
|
|
777
|
+
// refused records call is scriptable rather than only readable.
|
|
778
|
+
error(describeRecordsFailure(err));
|
|
779
|
+
process.exit(1);
|
|
780
|
+
}
|
|
565
781
|
const filter = parseFilterOptions(options);
|
|
566
782
|
const client = new ApiClient();
|
|
567
783
|
try {
|
|
568
|
-
const result = await client.countDocumentRecords(resolvedAppId, documentId, modelName, { filter });
|
|
784
|
+
const result = await client.countDocumentRecords(resolvedAppId, documentId, modelName, { filter, documentFormat });
|
|
569
785
|
if (options.json) {
|
|
570
786
|
json(result);
|
|
571
787
|
return;
|
|
@@ -573,7 +789,9 @@ Permissions (enforced by the server, not the CLI):
|
|
|
573
789
|
console.log(` ${result.count}`);
|
|
574
790
|
}
|
|
575
791
|
catch (err) {
|
|
576
|
-
|
|
792
|
+
// #3764 — the server's sentence, the stable code and the status, so a
|
|
793
|
+
// refused records call is scriptable rather than only readable.
|
|
794
|
+
error(describeRecordsFailure(err));
|
|
577
795
|
process.exit(1);
|
|
578
796
|
}
|
|
579
797
|
});
|
|
@@ -593,9 +811,20 @@ Permissions (enforced by the server, not the CLI):
|
|
|
593
811
|
.option("--group-by <field>", "Group results by this plain field (repeatable; StringSet fields are not supported)", (value, previous = []) => previous.concat(value), [])
|
|
594
812
|
.option("--filter <json>", "Filter as JSON")
|
|
595
813
|
.option("--filter-file <path>", "Read filter from a JSON or TOML file")
|
|
814
|
+
.option(DOCUMENT_FORMAT_FLAG, DOCUMENT_FORMAT_HELP)
|
|
596
815
|
.option("--json", "Output as JSON")
|
|
597
816
|
.action(async (documentId, modelName, options) => {
|
|
598
817
|
const resolvedAppId = resolveAppId(undefined, options);
|
|
818
|
+
let documentFormat;
|
|
819
|
+
try {
|
|
820
|
+
documentFormat = parseDocumentFormatOption(options.documentFormat);
|
|
821
|
+
}
|
|
822
|
+
catch (err) {
|
|
823
|
+
// #3764 — the server's sentence, the stable code and the status, so a
|
|
824
|
+
// refused records call is scriptable rather than only readable.
|
|
825
|
+
error(describeRecordsFailure(err));
|
|
826
|
+
process.exit(1);
|
|
827
|
+
}
|
|
599
828
|
if (!AGGREGATE_OPS.includes(options.op)) {
|
|
600
829
|
error(`Invalid --op "${options.op}". Expected one of: ${AGGREGATE_OPS.join(", ")}.`);
|
|
601
830
|
process.exit(1);
|
|
@@ -618,7 +847,7 @@ Permissions (enforced by the server, not the CLI):
|
|
|
618
847
|
const groupBy = options.groupBy || [];
|
|
619
848
|
const client = new ApiClient();
|
|
620
849
|
try {
|
|
621
|
-
const response = await client.aggregateDocumentRecords(resolvedAppId, documentId, modelName, { operations: [operation], groupBy, filter });
|
|
850
|
+
const response = await client.aggregateDocumentRecords(resolvedAppId, documentId, modelName, { operations: [operation], groupBy, filter }, { documentFormat });
|
|
622
851
|
if (options.json) {
|
|
623
852
|
json(response);
|
|
624
853
|
return;
|
|
@@ -655,7 +884,9 @@ Permissions (enforced by the server, not the CLI):
|
|
|
655
884
|
}
|
|
656
885
|
}
|
|
657
886
|
catch (err) {
|
|
658
|
-
|
|
887
|
+
// #3764 — the server's sentence, the stable code and the status, so a
|
|
888
|
+
// refused records call is scriptable rather than only readable.
|
|
889
|
+
error(describeRecordsFailure(err));
|
|
659
890
|
process.exit(1);
|
|
660
891
|
}
|
|
661
892
|
});
|
|
@@ -678,15 +909,26 @@ Permissions (enforced by the server, not the CLI):
|
|
|
678
909
|
.option("--data <json>", "Record fields as JSON (e.g. '{\"qty\":10}')")
|
|
679
910
|
.option("--data-file <file>", "Read record fields from a JSON file")
|
|
680
911
|
.option("--upsert-on <field>", "Update the record whose <field> matches instead of creating")
|
|
912
|
+
.option(DOCUMENT_FORMAT_FLAG, DOCUMENT_FORMAT_HELP)
|
|
681
913
|
.option("--json", "Output as JSON")
|
|
682
914
|
.action(async (documentId, modelName, options) => {
|
|
683
915
|
const data = parseDataOption(options, "record fields");
|
|
684
916
|
const resolvedAppId = resolveAppId(undefined, options);
|
|
917
|
+
let documentFormat;
|
|
918
|
+
try {
|
|
919
|
+
documentFormat = parseDocumentFormatOption(options.documentFormat);
|
|
920
|
+
}
|
|
921
|
+
catch (err) {
|
|
922
|
+
// #3764 — the server's sentence, the stable code and the status, so a
|
|
923
|
+
// refused records call is scriptable rather than only readable.
|
|
924
|
+
error(describeRecordsFailure(err));
|
|
925
|
+
process.exit(1);
|
|
926
|
+
}
|
|
685
927
|
const recordId = options.id || ulid();
|
|
686
928
|
const writeOptions = options.upsertOn ? { upsertOn: options.upsertOn } : undefined;
|
|
687
929
|
const client = new ApiClient();
|
|
688
930
|
try {
|
|
689
|
-
const result = await client.saveDocumentRecord(resolvedAppId, documentId, modelName, { id: recordId, data, ...(writeOptions ? { options: writeOptions } : {}) });
|
|
931
|
+
const result = await client.saveDocumentRecord(resolvedAppId, documentId, modelName, { id: recordId, data, ...(writeOptions ? { options: writeOptions } : {}) }, { documentFormat });
|
|
690
932
|
if (options.json) {
|
|
691
933
|
json(result);
|
|
692
934
|
return;
|
|
@@ -694,7 +936,9 @@ Permissions (enforced by the server, not the CLI):
|
|
|
694
936
|
success(`Record saved: ${result.record?.id ?? recordId}`);
|
|
695
937
|
}
|
|
696
938
|
catch (err) {
|
|
697
|
-
|
|
939
|
+
// #3764 — the server's sentence, the stable code and the status, so a
|
|
940
|
+
// refused records call is scriptable rather than only readable.
|
|
941
|
+
error(describeRecordsFailure(err));
|
|
698
942
|
process.exit(1);
|
|
699
943
|
}
|
|
700
944
|
});
|
|
@@ -707,14 +951,25 @@ Permissions (enforced by the server, not the CLI):
|
|
|
707
951
|
.argument("<record-id>", "Record ID to patch")
|
|
708
952
|
.option("--app <app-id>", "App ID")
|
|
709
953
|
.option("--data <json>", "Fields to merge as JSON (e.g. '{\"status\":\"closed\"}')")
|
|
954
|
+
.option(DOCUMENT_FORMAT_FLAG, DOCUMENT_FORMAT_HELP)
|
|
710
955
|
.option("--data-file <file>", "Read fields from a JSON file")
|
|
711
956
|
.option("--json", "Output as JSON")
|
|
712
957
|
.action(async (documentId, modelName, recordId, options) => {
|
|
713
958
|
const data = parseDataOption(options, "fields to merge");
|
|
714
959
|
const resolvedAppId = resolveAppId(undefined, options);
|
|
960
|
+
let documentFormat;
|
|
961
|
+
try {
|
|
962
|
+
documentFormat = parseDocumentFormatOption(options.documentFormat);
|
|
963
|
+
}
|
|
964
|
+
catch (err) {
|
|
965
|
+
// #3764 — the server's sentence, the stable code and the status, so a
|
|
966
|
+
// refused records call is scriptable rather than only readable.
|
|
967
|
+
error(describeRecordsFailure(err));
|
|
968
|
+
process.exit(1);
|
|
969
|
+
}
|
|
715
970
|
const client = new ApiClient();
|
|
716
971
|
try {
|
|
717
|
-
const result = await client.patchDocumentRecord(resolvedAppId, documentId, modelName, recordId, { data });
|
|
972
|
+
const result = await client.patchDocumentRecord(resolvedAppId, documentId, modelName, recordId, { data }, { documentFormat });
|
|
718
973
|
if (options.json) {
|
|
719
974
|
json(result);
|
|
720
975
|
return;
|
|
@@ -722,7 +977,9 @@ Permissions (enforced by the server, not the CLI):
|
|
|
722
977
|
success(`Record patched: ${recordId}`);
|
|
723
978
|
}
|
|
724
979
|
catch (err) {
|
|
725
|
-
|
|
980
|
+
// #3764 — the server's sentence, the stable code and the status, so a
|
|
981
|
+
// refused records call is scriptable rather than only readable.
|
|
982
|
+
error(describeRecordsFailure(err));
|
|
726
983
|
process.exit(1);
|
|
727
984
|
}
|
|
728
985
|
});
|
|
@@ -734,10 +991,21 @@ Permissions (enforced by the server, not the CLI):
|
|
|
734
991
|
.argument("<model-name>", "Model name")
|
|
735
992
|
.argument("<record-id>", "Record ID to delete")
|
|
736
993
|
.option("--app <app-id>", "App ID")
|
|
994
|
+
.option(DOCUMENT_FORMAT_FLAG, DOCUMENT_FORMAT_HELP)
|
|
737
995
|
.option("-y, --yes", "Skip confirmation prompt")
|
|
738
996
|
.option("--json", "Output as JSON")
|
|
739
997
|
.action(async (documentId, modelName, recordId, options) => {
|
|
740
998
|
const resolvedAppId = resolveAppId(undefined, options);
|
|
999
|
+
let documentFormat;
|
|
1000
|
+
try {
|
|
1001
|
+
documentFormat = parseDocumentFormatOption(options.documentFormat);
|
|
1002
|
+
}
|
|
1003
|
+
catch (err) {
|
|
1004
|
+
// #3764 — the server's sentence, the stable code and the status, so a
|
|
1005
|
+
// refused records call is scriptable rather than only readable.
|
|
1006
|
+
error(describeRecordsFailure(err));
|
|
1007
|
+
process.exit(1);
|
|
1008
|
+
}
|
|
741
1009
|
if (!options.yes) {
|
|
742
1010
|
const confirmed = await confirmPrompt(`Delete record ${recordId} from ${modelName} in document ${documentId}?`);
|
|
743
1011
|
if (!confirmed) {
|
|
@@ -747,7 +1015,7 @@ Permissions (enforced by the server, not the CLI):
|
|
|
747
1015
|
}
|
|
748
1016
|
const client = new ApiClient();
|
|
749
1017
|
try {
|
|
750
|
-
const result = await client.deleteDocumentRecord(resolvedAppId, documentId, modelName, recordId);
|
|
1018
|
+
const result = await client.deleteDocumentRecord(resolvedAppId, documentId, modelName, recordId, { documentFormat });
|
|
751
1019
|
if (options.json) {
|
|
752
1020
|
json(result);
|
|
753
1021
|
return;
|
|
@@ -757,7 +1025,9 @@ Permissions (enforced by the server, not the CLI):
|
|
|
757
1025
|
success(`Record deleted: ${recordId}`);
|
|
758
1026
|
}
|
|
759
1027
|
catch (err) {
|
|
760
|
-
|
|
1028
|
+
// #3764 — the server's sentence, the stable code and the status, so a
|
|
1029
|
+
// refused records call is scriptable rather than only readable.
|
|
1030
|
+
error(describeRecordsFailure(err));
|
|
761
1031
|
process.exit(1);
|
|
762
1032
|
}
|
|
763
1033
|
});
|
|
@@ -772,9 +1042,20 @@ Permissions (enforced by the server, not the CLI):
|
|
|
772
1042
|
"create/patch, not allowed on delete; a create `id` must be a 26-char " +
|
|
773
1043
|
"uppercase Crockford ULID")
|
|
774
1044
|
.option("--app <app-id>", "App ID")
|
|
1045
|
+
.option(DOCUMENT_FORMAT_FLAG, DOCUMENT_FORMAT_HELP)
|
|
775
1046
|
.option("-y, --yes", "Skip confirmation prompt")
|
|
776
1047
|
.option("--json", "Output as JSON")
|
|
777
1048
|
.action(async (documentId, options) => {
|
|
1049
|
+
let documentFormat;
|
|
1050
|
+
try {
|
|
1051
|
+
documentFormat = parseDocumentFormatOption(options.documentFormat);
|
|
1052
|
+
}
|
|
1053
|
+
catch (err) {
|
|
1054
|
+
// #3764 — the server's sentence, the stable code and the status, so a
|
|
1055
|
+
// refused records call is scriptable rather than only readable.
|
|
1056
|
+
error(describeRecordsFailure(err));
|
|
1057
|
+
process.exit(1);
|
|
1058
|
+
}
|
|
778
1059
|
const parsed = parseDataOption(options, "operations blob", {
|
|
779
1060
|
allowArray: true,
|
|
780
1061
|
});
|
|
@@ -793,7 +1074,7 @@ Permissions (enforced by the server, not the CLI):
|
|
|
793
1074
|
}
|
|
794
1075
|
const client = new ApiClient();
|
|
795
1076
|
try {
|
|
796
|
-
const result = await client.bulkDocumentRecords(resolvedAppId, documentId, operations);
|
|
1077
|
+
const result = await client.bulkDocumentRecords(resolvedAppId, documentId, operations, { documentFormat });
|
|
797
1078
|
if (options.json) {
|
|
798
1079
|
json(result);
|
|
799
1080
|
return;
|
|
@@ -802,7 +1083,11 @@ Permissions (enforced by the server, not the CLI):
|
|
|
802
1083
|
`${result.added.length} added, ${result.updated.length} updated, ${result.deleted} deleted.`);
|
|
803
1084
|
}
|
|
804
1085
|
catch (err) {
|
|
805
|
-
|
|
1086
|
+
const report = describeBulkFailure(err);
|
|
1087
|
+
if (options.json)
|
|
1088
|
+
json(report.envelope);
|
|
1089
|
+
else
|
|
1090
|
+
error(report.line);
|
|
806
1091
|
process.exit(1);
|
|
807
1092
|
}
|
|
808
1093
|
});
|
|
@@ -875,13 +1160,474 @@ Permissions (enforced by the server, not the CLI):
|
|
|
875
1160
|
process.exit(1);
|
|
876
1161
|
}
|
|
877
1162
|
});
|
|
1163
|
+
// The action verb (#3435, criterion 9): a directory of records becomes a
|
|
1164
|
+
// chunked, digested, uploaded artifact and one atomic swap of the
|
|
1165
|
+
// document's tables. `documents ingests list|get` below is how an operator
|
|
1166
|
+
// watches one afterwards.
|
|
1167
|
+
//
|
|
1168
|
+
// Destructive by nature — a bulk load deletes and overwrites records in a
|
|
1169
|
+
// live document with no undo — so it confirms unless `-y`, which is what
|
|
1170
|
+
// `docs/cli-design.md` reserves the destructive default for.
|
|
1171
|
+
documents
|
|
1172
|
+
.command("ingest")
|
|
1173
|
+
.description("Bulk-load records into a large document from a directory")
|
|
1174
|
+
.argument("<document-id>", "Document ID")
|
|
1175
|
+
.requiredOption("--input <dir>", "Directory of <model>.ndjson[.gz] files, or a `documents export` output")
|
|
1176
|
+
.option("--app <app-id>", "App ID")
|
|
1177
|
+
.option("-y, --yes", "Skip the confirmation prompt")
|
|
1178
|
+
.option("--no-wait", "Exit once the session is committed")
|
|
1179
|
+
.option("--timeout <seconds>", "Give up waiting after this many seconds")
|
|
1180
|
+
.option("--json", "Output as JSON")
|
|
1181
|
+
.action(async (documentId, options) => {
|
|
1182
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
1183
|
+
const client = new ApiClient();
|
|
1184
|
+
let plan = null;
|
|
1185
|
+
// The exit code is carried out of here rather than taken inside: a
|
|
1186
|
+
// `process.exit` does not unwind, so ending the process in the try block
|
|
1187
|
+
// would skip the cleanup in the finally and leave the compressed
|
|
1188
|
+
// artifact — up to the whole input, gzipped — in the OS temp directory
|
|
1189
|
+
// on every run, successful or not (finding 3435-FROZEN-05).
|
|
1190
|
+
const run = async () => {
|
|
1191
|
+
// The preflight, in this order and before a byte is read from disk:
|
|
1192
|
+
// the schema read says whether this caller may see the document's
|
|
1193
|
+
// declarations at all and hands over the ones the lines are checked
|
|
1194
|
+
// against, and the session reader says whether this is a large
|
|
1195
|
+
// document. Both are READS on the arm the ingest routes admit, so a
|
|
1196
|
+
// caller who would be refused halfway is refused at once, and neither
|
|
1197
|
+
// opens a session.
|
|
1198
|
+
const introspection = await client.getDocumentSchema(resolvedAppId, documentId);
|
|
1199
|
+
await client.listDocumentIngests(resolvedAppId, documentId, { limit: 1 });
|
|
1200
|
+
const schema = ingestSchemaFromIntrospection(introspection);
|
|
1201
|
+
const input = discoverIngestInput(options.input, documentId);
|
|
1202
|
+
for (const ignored of input.ignored) {
|
|
1203
|
+
info(`Ignoring ${ignored}: a bulk load reads the snapshot only.`);
|
|
1204
|
+
}
|
|
1205
|
+
// #3598 — reading, checking, sorting, cutting and gzipping is this
|
|
1206
|
+
// side's biggest phase on a large input, and it used to be invisible
|
|
1207
|
+
// inside one wall-clock number that also covered the platform's work.
|
|
1208
|
+
const buildStartedAt = Date.now();
|
|
1209
|
+
plan = await buildIngestArtifact({ input, schema });
|
|
1210
|
+
const buildMs = Date.now() - buildStartedAt;
|
|
1211
|
+
info(`Built artifact in ${(buildMs / 1000).toFixed(1)}s.`);
|
|
1212
|
+
for (const line of summariseIngestPlan(plan))
|
|
1213
|
+
info(line);
|
|
1214
|
+
assertIngestConfirmable({
|
|
1215
|
+
yes: Boolean(options.yes),
|
|
1216
|
+
isTTY: Boolean(process.stdin.isTTY),
|
|
1217
|
+
});
|
|
1218
|
+
if (!options.yes) {
|
|
1219
|
+
const confirmed = await confirmPrompt(`Bulk-load ${plan.rows} records into ${documentId}? ` +
|
|
1220
|
+
"This replaces records in a live document and cannot be undone.");
|
|
1221
|
+
if (!confirmed) {
|
|
1222
|
+
info("Nothing was uploaded.");
|
|
1223
|
+
return 1;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
// Ctrl-C is a decision the driver makes differently depending on
|
|
1227
|
+
// where it lands, so it is handed the signal rather than killing the
|
|
1228
|
+
// process: mid-upload the session is aborted, mid-poll it is left
|
|
1229
|
+
// running, because by then the document is already being changed.
|
|
1230
|
+
const interrupt = new AbortController();
|
|
1231
|
+
const onSignal = () => interrupt.abort();
|
|
1232
|
+
process.on("SIGINT", onSignal);
|
|
1233
|
+
let outcome;
|
|
1234
|
+
try {
|
|
1235
|
+
outcome = await driveDocumentIngest({
|
|
1236
|
+
client: {
|
|
1237
|
+
createIngest: (id) => client.createDocumentIngest(resolvedAppId, id),
|
|
1238
|
+
uploadIngestChunk: (id, sessionId, chunk) => client.uploadDocumentIngestChunk(resolvedAppId, id, sessionId, chunk),
|
|
1239
|
+
commitIngest: (id, sessionId) => client.commitDocumentIngest(resolvedAppId, id, sessionId),
|
|
1240
|
+
abortIngest: (id, sessionId) => client.abortDocumentIngest(resolvedAppId, id, sessionId),
|
|
1241
|
+
getIngest: (id, sessionId) => client.getDocumentIngest(resolvedAppId, id, sessionId),
|
|
1242
|
+
},
|
|
1243
|
+
documentId,
|
|
1244
|
+
plan,
|
|
1245
|
+
wait: options.wait !== false,
|
|
1246
|
+
timeoutMs: options.timeout ? Number(options.timeout) * 1000 : null,
|
|
1247
|
+
signal: interrupt.signal,
|
|
1248
|
+
onProgress: (line) => info(line),
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
finally {
|
|
1252
|
+
process.off("SIGINT", onSignal);
|
|
1253
|
+
}
|
|
1254
|
+
if (outcome.message)
|
|
1255
|
+
error(outcome.message);
|
|
1256
|
+
// #3598 — the two sides, side by side. The session's own `timings`
|
|
1257
|
+
// say where the platform's share went; `client` says what this
|
|
1258
|
+
// process spent before and around it, so a slow load can be blamed on
|
|
1259
|
+
// the side that was actually slow.
|
|
1260
|
+
const clientTimings = { buildMs, ...outcome.timings };
|
|
1261
|
+
if (options.json) {
|
|
1262
|
+
json({
|
|
1263
|
+
...(outcome.session ?? { sessionId: outcome.sessionId }),
|
|
1264
|
+
client: clientTimings,
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
else {
|
|
1268
|
+
if (outcome.session) {
|
|
1269
|
+
printIngestSession(outcome.session);
|
|
1270
|
+
}
|
|
1271
|
+
else if (outcome.sessionId) {
|
|
1272
|
+
printResult("Session", outcome.sessionId);
|
|
1273
|
+
}
|
|
1274
|
+
for (const [label, value] of ingestClientRows(clientTimings)) {
|
|
1275
|
+
printResult(label, value);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return outcome.exitCode;
|
|
1279
|
+
};
|
|
1280
|
+
let code = 1;
|
|
1281
|
+
try {
|
|
1282
|
+
code = await run();
|
|
1283
|
+
}
|
|
1284
|
+
catch (err) {
|
|
1285
|
+
error(err.message);
|
|
1286
|
+
code = 1;
|
|
1287
|
+
}
|
|
1288
|
+
finally {
|
|
1289
|
+
await plan?.cleanup().catch(() => undefined);
|
|
1290
|
+
}
|
|
1291
|
+
// The result has to reach the pipe before the process ends: stdout to a
|
|
1292
|
+
// pipe is asynchronous and `process.exit` does not wait for it.
|
|
1293
|
+
await flushOutput();
|
|
1294
|
+
process.exit(code);
|
|
1295
|
+
});
|
|
1296
|
+
// Bulk-load sessions of a large document (#3434, criterion 9): a data noun
|
|
1297
|
+
// with `list` and `get` readers, per docs/cli-design.md, beside `snapshots`
|
|
1298
|
+
// below. The ACTION verb `documents ingest` is #3435's; these two are how an
|
|
1299
|
+
// operator sees what a session did and why one stopped.
|
|
1300
|
+
const ingests = documents
|
|
1301
|
+
.command("ingests")
|
|
1302
|
+
.description("Inspect a large document's bulk-load sessions");
|
|
1303
|
+
ingests
|
|
1304
|
+
.command("list")
|
|
1305
|
+
.description("List a large document's bulk-load sessions, newest first")
|
|
1306
|
+
.argument("<document-id>", "Document ID")
|
|
1307
|
+
.option("--app <app-id>", "App ID")
|
|
1308
|
+
.option("--limit <n>", "Page size (1–100)", "20")
|
|
1309
|
+
.option("--cursor <cursor>", "Continue from a previous page's nextCursor")
|
|
1310
|
+
.option("--json", "Output as JSON")
|
|
1311
|
+
.action(async (documentId, options) => {
|
|
1312
|
+
const limit = parsePageLimit(options.limit);
|
|
1313
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
1314
|
+
const client = new ApiClient();
|
|
1315
|
+
try {
|
|
1316
|
+
const page = normalizeCliListEnvelope(await client.listDocumentIngests(resolvedAppId, documentId, {
|
|
1317
|
+
limit,
|
|
1318
|
+
...(options.cursor ? { cursor: options.cursor } : {}),
|
|
1319
|
+
}));
|
|
1320
|
+
if (options.json) {
|
|
1321
|
+
json(page);
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
if (page.items.length === 0) {
|
|
1325
|
+
info("No ingest sessions yet.");
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
console.log(formatTable(page.items, [
|
|
1329
|
+
{ header: "SESSION", key: "sessionId" },
|
|
1330
|
+
{ header: "STATE", key: "state" },
|
|
1331
|
+
{ header: "CREATED", key: "createdAt", format: formatDate },
|
|
1332
|
+
{ header: "CHUNKS", key: "chunks" },
|
|
1333
|
+
{ header: "ROWS", key: "rows" },
|
|
1334
|
+
{
|
|
1335
|
+
header: "COMPLETED",
|
|
1336
|
+
key: "completedAt",
|
|
1337
|
+
format: (at) => (at ? formatDate(at) : "—"),
|
|
1338
|
+
},
|
|
1339
|
+
]));
|
|
1340
|
+
if (page.hasMore && page.nextCursor) {
|
|
1341
|
+
info(`More sessions: --cursor ${page.nextCursor}`);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
catch (err) {
|
|
1345
|
+
error(err.message);
|
|
1346
|
+
process.exit(1);
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
ingests
|
|
1350
|
+
.command("get")
|
|
1351
|
+
.description("Show one bulk-load session, its progress and why it stopped")
|
|
1352
|
+
.argument("<document-id>", "Document ID")
|
|
1353
|
+
.argument("<session-id>", "Session ID")
|
|
1354
|
+
.option("--app <app-id>", "App ID")
|
|
1355
|
+
.option("--json", "Output as JSON")
|
|
1356
|
+
.action(async (documentId, sessionId, options) => {
|
|
1357
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
1358
|
+
const client = new ApiClient();
|
|
1359
|
+
try {
|
|
1360
|
+
const session = await client.getDocumentIngest(resolvedAppId, documentId, sessionId);
|
|
1361
|
+
if (options.json) {
|
|
1362
|
+
json(session);
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
printIngestSession(session);
|
|
1366
|
+
}
|
|
1367
|
+
catch (err) {
|
|
1368
|
+
error(err.message);
|
|
1369
|
+
process.exit(1);
|
|
1370
|
+
}
|
|
1371
|
+
});
|
|
1372
|
+
// Snapshot builds of a large document (#3432, criterion 9): a data noun
|
|
1373
|
+
// with `list` and `get` readers, per docs/cli-design.md, over the app API's
|
|
1374
|
+
// read-only inspection routes.
|
|
1375
|
+
const snapshots = documents
|
|
1376
|
+
.command("snapshots")
|
|
1377
|
+
.description("Inspect a large document's snapshot builds and their verification");
|
|
1378
|
+
snapshots
|
|
1379
|
+
.command("list")
|
|
1380
|
+
.description("List a large document's snapshot builds, newest first")
|
|
1381
|
+
.argument("<document-id>", "Document ID")
|
|
1382
|
+
.option("--app <app-id>", "App ID")
|
|
1383
|
+
.option("--limit <n>", "Page size (1–100)", "20")
|
|
1384
|
+
.option("--cursor <cursor>", "Continue from a previous page's nextCursor")
|
|
1385
|
+
.option("--json", "Output as JSON")
|
|
1386
|
+
.action(async (documentId, options) => {
|
|
1387
|
+
const limit = parsePageLimit(options.limit);
|
|
1388
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
1389
|
+
const client = new ApiClient();
|
|
1390
|
+
try {
|
|
1391
|
+
const page = normalizeCliListEnvelope(await client.listDocumentSnapshots(resolvedAppId, documentId, {
|
|
1392
|
+
limit,
|
|
1393
|
+
...(options.cursor ? { cursor: options.cursor } : {}),
|
|
1394
|
+
}));
|
|
1395
|
+
if (options.json) {
|
|
1396
|
+
json(page);
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
if (page.items.length === 0) {
|
|
1400
|
+
info("No snapshot builds yet.");
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
console.log(formatTable(page.items, [
|
|
1404
|
+
{ header: "BUILD", key: "buildId" },
|
|
1405
|
+
{ header: "EPOCH", key: "epoch" },
|
|
1406
|
+
{ header: "STATE", key: "state" },
|
|
1407
|
+
{ header: "SOURCE", key: "source" },
|
|
1408
|
+
{
|
|
1409
|
+
header: "VERIFICATION",
|
|
1410
|
+
key: "verification",
|
|
1411
|
+
format: (verification) => verification
|
|
1412
|
+
? verification.code
|
|
1413
|
+
? `${verification.state} (${verification.code})`
|
|
1414
|
+
: verification.state
|
|
1415
|
+
: "-",
|
|
1416
|
+
},
|
|
1417
|
+
{ header: "ROWS", key: "rows" },
|
|
1418
|
+
{ header: "CHUNKS", key: "chunks" },
|
|
1419
|
+
{ header: "STARTED", key: "startedAt", format: formatDate },
|
|
1420
|
+
{ header: "COMPLETED", key: "completedAt", format: formatDate },
|
|
1421
|
+
]));
|
|
1422
|
+
if (page.hasMore && page.nextCursor) {
|
|
1423
|
+
info(`More builds: --cursor ${page.nextCursor}`);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
catch (err) {
|
|
1427
|
+
error(err.message);
|
|
1428
|
+
process.exit(1);
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
snapshots
|
|
1432
|
+
.command("get")
|
|
1433
|
+
.description("Show one snapshot build, its verification result and where its time went")
|
|
1434
|
+
.argument("<document-id>", "Document ID")
|
|
1435
|
+
.argument("<build-id>", "Build ID")
|
|
1436
|
+
.option("--app <app-id>", "App ID")
|
|
1437
|
+
.option("--json", "Output as JSON")
|
|
1438
|
+
.action(async (documentId, buildId, options) => {
|
|
1439
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
1440
|
+
const client = new ApiClient();
|
|
1441
|
+
try {
|
|
1442
|
+
const build = await client.getDocumentSnapshot(resolvedAppId, documentId, buildId);
|
|
1443
|
+
if (options.json) {
|
|
1444
|
+
json(build);
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
// The rows are composed by a pure function (#3435), so what a
|
|
1448
|
+
// terminal shows is checkable without spawning one; the command still
|
|
1449
|
+
// owns the printing.
|
|
1450
|
+
for (const [label, value] of snapshotBuildRows(build)) {
|
|
1451
|
+
printResult(label, value);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
catch (err) {
|
|
1455
|
+
error(err.message);
|
|
1456
|
+
process.exit(1);
|
|
1457
|
+
}
|
|
1458
|
+
});
|
|
1459
|
+
// #3666, criterion 7 — the one ACTION verb in this group: snapshot the
|
|
1460
|
+
// document now instead of waiting for the size or age trigger. Its exit
|
|
1461
|
+
// codes are `documents ingest`'s, as `docs/cli-design.md` records them, and
|
|
1462
|
+
// for the same reason: giving up on WATCHING a build is not the build
|
|
1463
|
+
// failing.
|
|
1464
|
+
//
|
|
1465
|
+
// Not destructive, so it does not confirm: a seal rotates the overlay the
|
|
1466
|
+
// room is writing to and loses nothing, which is what the size trigger does
|
|
1467
|
+
// on its own several times a day.
|
|
1468
|
+
snapshots
|
|
1469
|
+
.command("build")
|
|
1470
|
+
.description("Snapshot a large document now: seal the open epoch and build a base")
|
|
1471
|
+
.argument("<document-id>", "Document ID")
|
|
1472
|
+
.option("--app <app-id>", "App ID")
|
|
1473
|
+
.option("--wait", "Poll until the build has verified or failed")
|
|
1474
|
+
.option("--timeout <seconds>", "Give up waiting after this many seconds")
|
|
1475
|
+
.option("--json", "Output as JSON")
|
|
1476
|
+
.action(async (documentId, options) => {
|
|
1477
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
1478
|
+
const client = new ApiClient();
|
|
1479
|
+
const run = async () => {
|
|
1480
|
+
// Ctrl-C while polling leaves the build running — by then the room is
|
|
1481
|
+
// already building it and giving up on watching is not giving up on
|
|
1482
|
+
// the build — so the signal is handed to the driver rather than
|
|
1483
|
+
// killing the process.
|
|
1484
|
+
const interrupt = new AbortController();
|
|
1485
|
+
const onSignal = () => interrupt.abort();
|
|
1486
|
+
process.on("SIGINT", onSignal);
|
|
1487
|
+
let outcome;
|
|
1488
|
+
try {
|
|
1489
|
+
outcome = await driveSnapshotBuild({
|
|
1490
|
+
client: {
|
|
1491
|
+
requestSnapshot: (id) => client.requestDocumentSnapshot(resolvedAppId, id),
|
|
1492
|
+
getSnapshot: (id, buildId) => client.getDocumentSnapshot(resolvedAppId, id, buildId),
|
|
1493
|
+
},
|
|
1494
|
+
documentId,
|
|
1495
|
+
wait: Boolean(options.wait),
|
|
1496
|
+
timeoutMs: options.timeout ? Number(options.timeout) * 1000 : null,
|
|
1497
|
+
signal: interrupt.signal,
|
|
1498
|
+
onProgress: (line) => info(line),
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
finally {
|
|
1502
|
+
process.off("SIGINT", onSignal);
|
|
1503
|
+
}
|
|
1504
|
+
if (outcome.message)
|
|
1505
|
+
error(outcome.message);
|
|
1506
|
+
if (options.json) {
|
|
1507
|
+
// The route's answer as received, with the build the wait settled
|
|
1508
|
+
// on beside it when there was one — so a script branches on the
|
|
1509
|
+
// verdict rather than on an exit code alone.
|
|
1510
|
+
json(outcome.build ? { ...outcome.response, build: outcome.build } : outcome.response);
|
|
1511
|
+
return outcome.exitCode;
|
|
1512
|
+
}
|
|
1513
|
+
if (outcome.response?.sealed === false) {
|
|
1514
|
+
printResult("Sealed", "no — the open epoch carried nothing");
|
|
1515
|
+
printResult("Reason", outcome.response.reason);
|
|
1516
|
+
printResult("Covering build", outcome.response.coveringBuildId ?? "—");
|
|
1517
|
+
return outcome.exitCode;
|
|
1518
|
+
}
|
|
1519
|
+
printResult("Sealed epoch", outcome.response.sealedEpoch);
|
|
1520
|
+
printResult("Open epoch", outcome.response.nextEpoch);
|
|
1521
|
+
printResult("Build", outcome.response.buildId);
|
|
1522
|
+
if (outcome.build) {
|
|
1523
|
+
for (const [label, value] of snapshotBuildRows(outcome.build)) {
|
|
1524
|
+
printResult(label, value);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
return outcome.exitCode;
|
|
1528
|
+
};
|
|
1529
|
+
let code = 1;
|
|
1530
|
+
try {
|
|
1531
|
+
code = await run();
|
|
1532
|
+
}
|
|
1533
|
+
catch (err) {
|
|
1534
|
+
// The server's own sentence, plus the code a script branches on
|
|
1535
|
+
// (#3403) — a refused loop that only says "too young to seal" leaves
|
|
1536
|
+
// a caller parsing prose to tell it from a permission refusal.
|
|
1537
|
+
error(err.code ? `${err.message} (${err.code})` : err.message);
|
|
1538
|
+
const retryAfterMs = err.details?.retryAfterMs;
|
|
1539
|
+
if (typeof retryAfterMs === "number") {
|
|
1540
|
+
error(`Ask again in ${Math.ceil(retryAfterMs / 1000)}s.`);
|
|
1541
|
+
}
|
|
1542
|
+
code = 1;
|
|
1543
|
+
}
|
|
1544
|
+
// The result has to reach the pipe before the process ends: stdout to a
|
|
1545
|
+
// pipe is asynchronous and `process.exit` does not wait for it.
|
|
1546
|
+
await flushOutput();
|
|
1547
|
+
process.exit(code);
|
|
1548
|
+
});
|
|
1549
|
+
// #3433, criterion 10 — the operator audit: `snapshot ⊕ overlays == table`,
|
|
1550
|
+
// recomputed on this side with the CLIENT's loader and fold and compared
|
|
1551
|
+
// with the authoritative table page by page. The one check that can catch a
|
|
1552
|
+
// DO/client fold divergence, which per-build verification runs on one side
|
|
1553
|
+
// of and therefore cannot see.
|
|
1554
|
+
snapshots
|
|
1555
|
+
.command("audit")
|
|
1556
|
+
.description("Recompute a large document's snapshot chain locally and compare it with the table")
|
|
1557
|
+
.argument("<document-id>", "Document ID")
|
|
1558
|
+
.option("--app <app-id>", "App ID")
|
|
1559
|
+
.option("--retries <n>", "Attempts before giving up on a document that keeps changing", "3")
|
|
1560
|
+
.option("--json", "Output as JSON")
|
|
1561
|
+
.action(async (documentId, options) => {
|
|
1562
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
1563
|
+
const client = new ApiClient();
|
|
1564
|
+
const report = await auditDocumentChain(client, resolvedAppId, documentId, Number(options.retries));
|
|
1565
|
+
if (options.json) {
|
|
1566
|
+
json(report);
|
|
1567
|
+
}
|
|
1568
|
+
else if (report.verdict === "none") {
|
|
1569
|
+
// No verdict is not a clean bill of health, so nothing that could be
|
|
1570
|
+
// read as one is printed: the reason, and nothing else.
|
|
1571
|
+
error(`No verdict: ${report.reason}`);
|
|
1572
|
+
}
|
|
1573
|
+
else {
|
|
1574
|
+
printResult("Document", documentId);
|
|
1575
|
+
printResult("Base build", report.buildId);
|
|
1576
|
+
printResult("Base epoch", report.epoch);
|
|
1577
|
+
printResult("Attempts", report.attempts);
|
|
1578
|
+
printResult("Verdict", report.verdict === "match"
|
|
1579
|
+
? "match — the table is the chain"
|
|
1580
|
+
: "DIVERGENCE");
|
|
1581
|
+
console.log(formatTable(report.models, [
|
|
1582
|
+
{ header: "MODEL", key: "model" },
|
|
1583
|
+
{ header: "COMPARED", key: "compared" },
|
|
1584
|
+
{
|
|
1585
|
+
header: "ONLY IN CHAIN",
|
|
1586
|
+
key: "onlyRecomputed",
|
|
1587
|
+
format: (ids) => (ids.length ? ids.join(", ") : "-"),
|
|
1588
|
+
},
|
|
1589
|
+
{
|
|
1590
|
+
header: "ONLY IN TABLE",
|
|
1591
|
+
key: "onlyTable",
|
|
1592
|
+
format: (ids) => (ids.length ? ids.join(", ") : "-"),
|
|
1593
|
+
},
|
|
1594
|
+
{
|
|
1595
|
+
header: "DIFFERING",
|
|
1596
|
+
key: "differing",
|
|
1597
|
+
format: (rows) => rows.length
|
|
1598
|
+
? rows.map((row) => `${row.id} (${row.fields.join(", ")})`).join("; ")
|
|
1599
|
+
: "-",
|
|
1600
|
+
},
|
|
1601
|
+
]));
|
|
1602
|
+
if (report.models.some((model) => model.truncated)) {
|
|
1603
|
+
info("Some lists were truncated; re-run with --json for the full report.");
|
|
1604
|
+
}
|
|
1605
|
+
if (report.verdict === "match")
|
|
1606
|
+
success("snapshot ⊕ overlays == table");
|
|
1607
|
+
}
|
|
1608
|
+
// The report has to reach the pipe before the process ends: stdout to a
|
|
1609
|
+
// pipe is asynchronous and `process.exit` does not wait for it, so a
|
|
1610
|
+
// large report piped into anything that is not reading as fast as this
|
|
1611
|
+
// writes used to arrive cut off mid-document (3433-C12).
|
|
1612
|
+
await flushOutput();
|
|
1613
|
+
// 0 match, 1 divergence, 2 no verdict — so the validation child can
|
|
1614
|
+
// script on "no verdict" without reading the text.
|
|
1615
|
+
process.exit(report.verdict === "match" ? 0 : report.verdict === "divergence" ? 1 : 2);
|
|
1616
|
+
});
|
|
878
1617
|
// Transfer document ownership
|
|
879
1618
|
documents
|
|
880
1619
|
.command("transfer-owner")
|
|
881
1620
|
.description("Transfer document ownership to another user")
|
|
1621
|
+
// Every positional is declared optional and the action decides which is
|
|
1622
|
+
// which (#3645). A required positional AFTER an optional one makes the
|
|
1623
|
+
// optional one mandatory: commander binds the first value it sees to
|
|
1624
|
+
// `[app-id]` and then reports the required argument missing, so
|
|
1625
|
+
// `transfer-owner <document-id> <new-owner-id>` never reached the action.
|
|
1626
|
+
// What each slot actually requires is enforced below, where the shift is
|
|
1627
|
+
// already known.
|
|
882
1628
|
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
883
|
-
.argument("
|
|
884
|
-
.argument("
|
|
1629
|
+
.argument("[document-id]", "Document ID (required)")
|
|
1630
|
+
.argument("[new-owner-id]", "User ID of the new owner (required)")
|
|
885
1631
|
.option("--app <app-id>", "App ID")
|
|
886
1632
|
.option("-y, --yes", "Skip confirmation prompt")
|
|
887
1633
|
.option("--json", "Output as JSON")
|
|
@@ -953,7 +1699,7 @@ Permissions (enforced by the server, not the CLI):
|
|
|
953
1699
|
const result = await client.listDocumentGroupPermissions(resolvedAppId, documentId);
|
|
954
1700
|
const list = Array.isArray(result) ? result : result?.permissions ?? [];
|
|
955
1701
|
if (options.json) {
|
|
956
|
-
json(list);
|
|
1702
|
+
json(wholeListEnvelope(list));
|
|
957
1703
|
return;
|
|
958
1704
|
}
|
|
959
1705
|
if (list.length === 0) {
|
|
@@ -1002,6 +1748,55 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1002
1748
|
process.exit(1);
|
|
1003
1749
|
}
|
|
1004
1750
|
});
|
|
1751
|
+
// ---- Effective access for a named user (#3658) ----
|
|
1752
|
+
const access = documents
|
|
1753
|
+
.command("access")
|
|
1754
|
+
.description("Read a user's effective access to a document");
|
|
1755
|
+
access
|
|
1756
|
+
.command("get")
|
|
1757
|
+
.description("Show what a user may do with a document, across direct, group, " +
|
|
1758
|
+
"collection and link access")
|
|
1759
|
+
.argument("<document-id>", "Document ID")
|
|
1760
|
+
.requiredOption("--user <user-id>", "The user whose access to resolve")
|
|
1761
|
+
.option("--app <app-id>", "App ID")
|
|
1762
|
+
.option("--json", "Output as JSON")
|
|
1763
|
+
.action(async (documentId, options) => {
|
|
1764
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
1765
|
+
const client = new ApiClient();
|
|
1766
|
+
try {
|
|
1767
|
+
const result = await client.validateDocumentAccessForUser(resolvedAppId, documentId, options.user);
|
|
1768
|
+
const row = {
|
|
1769
|
+
documentId,
|
|
1770
|
+
userId: options.user,
|
|
1771
|
+
hasAccess: result?.hasAccess === true,
|
|
1772
|
+
permission: result?.permission ?? null,
|
|
1773
|
+
accessSource: result?.accessSource ?? null,
|
|
1774
|
+
appRole: result?.appRole ?? null,
|
|
1775
|
+
};
|
|
1776
|
+
if (options.json) {
|
|
1777
|
+
json(row);
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
console.log(formatTable([row], [
|
|
1781
|
+
{ header: "DOCUMENT", key: "documentId" },
|
|
1782
|
+
{ header: "USER", key: "userId" },
|
|
1783
|
+
{ header: "ACCESS", key: "hasAccess" },
|
|
1784
|
+
{ header: "PERMISSION", key: "permission" },
|
|
1785
|
+
{ header: "SOURCE", key: "accessSource" },
|
|
1786
|
+
{ header: "APP_ROLE", key: "appRole" },
|
|
1787
|
+
]));
|
|
1788
|
+
if (!row.hasAccess) {
|
|
1789
|
+
// The role is not a grant: only the tag routes admit an
|
|
1790
|
+
// administrator without one, so an operator reading "admin" here
|
|
1791
|
+
// should not conclude the user may write content.
|
|
1792
|
+
info("No document grant. An app role admits tag changes only, never a content write.");
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
catch (err) {
|
|
1796
|
+
error(err.message);
|
|
1797
|
+
process.exit(1);
|
|
1798
|
+
}
|
|
1799
|
+
});
|
|
1005
1800
|
// ---- Link access ("anyone with the link") ----
|
|
1006
1801
|
const linkAccess = documents
|
|
1007
1802
|
.command("link-access")
|
|
@@ -1083,8 +1878,11 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1083
1878
|
documents
|
|
1084
1879
|
.command("export")
|
|
1085
1880
|
.description("Export a document (Yjs state, blobs, permissions, aliases)")
|
|
1881
|
+
// Optional in commander, required in the action (#3645) — see
|
|
1882
|
+
// `transfer-owner` above for why a `<...>` here would make `[app-id]`
|
|
1883
|
+
// mandatory.
|
|
1086
1884
|
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
1087
|
-
.argument("
|
|
1885
|
+
.argument("[document-id]", "Document ID to export (required)")
|
|
1088
1886
|
.option("--app <app-id>", "App ID")
|
|
1089
1887
|
.option("--output <dir>", "Output directory", "./primitive-export")
|
|
1090
1888
|
.option("--no-blobs", "Skip blob data")
|
|
@@ -1149,8 +1947,28 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1149
1947
|
warn(`Failed to export ${docId}: ${err.message}`);
|
|
1150
1948
|
}
|
|
1151
1949
|
}
|
|
1152
|
-
// Write manifest
|
|
1950
|
+
// Write manifest. `export-all` is per-user, and each run REPLACES the
|
|
1951
|
+
// manifest with its own ids — so exporting a second user into the same
|
|
1952
|
+
// directory leaves the first user's documents on disk but unreachable
|
|
1953
|
+
// through the manifest an import reads. The supported migration is one
|
|
1954
|
+
// directory per user; a run that would drop ids says so (#3135).
|
|
1153
1955
|
const manifestPath = path.join(options.output, "manifest.json");
|
|
1956
|
+
if (fs.existsSync(manifestPath)) {
|
|
1957
|
+
try {
|
|
1958
|
+
const existing = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
1959
|
+
const dropped = (Array.isArray(existing?.documents) ? existing.documents : []).filter((id) => !exportedIds.includes(id));
|
|
1960
|
+
if (dropped.length > 0) {
|
|
1961
|
+
warn(`The manifest already in ${options.output} lists ` +
|
|
1962
|
+
`${dropped.length} document(s) this export does not: ` +
|
|
1963
|
+
`${dropped.join(", ")}. Replacing it leaves them on disk but ` +
|
|
1964
|
+
`no longer reachable through the manifest an import reads — ` +
|
|
1965
|
+
`export each user into their own directory.`);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
catch {
|
|
1969
|
+
// An unreadable manifest is not a reason to refuse the export.
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1154
1972
|
const manifest = {
|
|
1155
1973
|
version: 1,
|
|
1156
1974
|
exportedAt: new Date().toISOString(),
|
|
@@ -1175,11 +1993,17 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1175
1993
|
documents
|
|
1176
1994
|
.command("import")
|
|
1177
1995
|
.description("Import documents from an export directory")
|
|
1996
|
+
// Optional in commander, required in the action (#3645).
|
|
1178
1997
|
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
1179
|
-
.argument("
|
|
1998
|
+
.argument("[path]", "Path to export directory or single document directory (required)")
|
|
1180
1999
|
.option("--app <app-id>", "App ID")
|
|
1181
2000
|
.option("--aliases <mode>", "Alias conflict handling: overwrite or skip", "skip")
|
|
1182
|
-
|
|
2001
|
+
// `--overwrite` has never replaced a document: the import path issues no
|
|
2002
|
+
// delete and calls `import/state`, whose room handler merges the exported
|
|
2003
|
+
// state into the document with `Y.applyUpdate`. The description says so
|
|
2004
|
+
// (#3135), and names the one id remapping on top of it — plus the one
|
|
2005
|
+
// format that is installed rather than merged.
|
|
2006
|
+
.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
2007
|
.option("--dry-run", "Show what would be imported without making changes")
|
|
1184
2008
|
.option("--owner <userId-or-email>", "User ID or email to set as document owner (admin only)")
|
|
1185
2009
|
.option("--json", "Output as JSON")
|
|
@@ -1251,9 +2075,30 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1251
2075
|
blobsUploaded: 0,
|
|
1252
2076
|
aliasesSet: 0,
|
|
1253
2077
|
aliasesSkipped: 0,
|
|
2078
|
+
rootDocuments: [],
|
|
1254
2079
|
};
|
|
2080
|
+
// Where every root-marked export in this run lands, decided from reads
|
|
2081
|
+
// alone and before any write (#3135): a refusal, or two exports
|
|
2082
|
+
// competing for one user's root document, is reported up front.
|
|
2083
|
+
const rootPlans = await planRootImports(client, resolvedAppId, docDirs, options.owner, ownerUserId, options.overwrite || false);
|
|
2084
|
+
// Nothing is created until every artifact is one this CLI can install:
|
|
2085
|
+
// a refusal halfway through a multi-document import would leave the
|
|
2086
|
+
// app half-populated (#2816). A root export is never installed as a
|
|
2087
|
+
// chain — `planRootImports` has already refused a format-2 one — so it
|
|
2088
|
+
// is not vouched for here.
|
|
1255
2089
|
for (const docDir of docDirs) {
|
|
1256
|
-
|
|
2090
|
+
if (rootPlans.has(docDir))
|
|
2091
|
+
continue;
|
|
2092
|
+
if (!largeDocumentArtifactAt(docDir))
|
|
2093
|
+
continue;
|
|
2094
|
+
const metadataPath = path.join(docDir, "metadata.json");
|
|
2095
|
+
const documentId = fs.existsSync(metadataPath)
|
|
2096
|
+
? JSON.parse(fs.readFileSync(metadataPath, "utf-8")).documentId
|
|
2097
|
+
: path.basename(docDir);
|
|
2098
|
+
readChainArtifact(docDir, documentId);
|
|
2099
|
+
}
|
|
2100
|
+
for (const docDir of docDirs) {
|
|
2101
|
+
await importSingleDocument(client, resolvedAppId, docDir, options.overwrite || false, options.aliases || "skip", options.dryRun || false, summary, ownerUserId, rootPlans.get(docDir));
|
|
1257
2102
|
}
|
|
1258
2103
|
if (options.json) {
|
|
1259
2104
|
json(summary);
|
|
@@ -1317,48 +2162,130 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1317
2162
|
// ============================================
|
|
1318
2163
|
// Helper functions for export/import
|
|
1319
2164
|
// ============================================
|
|
2165
|
+
/**
|
|
2166
|
+
* Write a large document's export chain into `docDir` (#2816, behavior 20).
|
|
2167
|
+
*
|
|
2168
|
+
* The layout mirrors the chain and is applied in that order on import:
|
|
2169
|
+
*
|
|
2170
|
+
* ```
|
|
2171
|
+
* chain.json what this artifact is, and the order to apply it in
|
|
2172
|
+
* snapshot/ manifest.json + {model}/{n}.ndjson.gz, when there is a base
|
|
2173
|
+
* epochs/{E}.yjs each sealed overlay after the base's epoch, oldest first
|
|
2174
|
+
* current.yjs the open epoch's overlay
|
|
2175
|
+
* ```
|
|
2176
|
+
*
|
|
2177
|
+
* The server refuses a chain it cannot vouch for, so nothing here has to guess
|
|
2178
|
+
* whether an artifact is complete; what this function must not do is write a
|
|
2179
|
+
* PARTIAL one, which is why `chain.json` — the file that says "this is a
|
|
2180
|
+
* complete v2 export" — is written last.
|
|
2181
|
+
*
|
|
2182
|
+
* Returns the bytes written, which is what `--json` reports as the document's
|
|
2183
|
+
* size: for a large document that is the artifact, not one Yjs update.
|
|
2184
|
+
*/
|
|
2185
|
+
async function exportLargeDocumentChain(client, appId, documentId, docDir) {
|
|
2186
|
+
const chain = await client.exportDocumentChain(appId, documentId);
|
|
2187
|
+
let bytes = 0;
|
|
2188
|
+
// The open epoch's overlay travels inline: the rotation threshold bounds it.
|
|
2189
|
+
const current = Buffer.from(chain.current.update, "base64");
|
|
2190
|
+
fs.writeFileSync(path.join(docDir, "current.yjs"), current);
|
|
2191
|
+
bytes += current.byteLength;
|
|
2192
|
+
const overlays = [];
|
|
2193
|
+
if (Array.isArray(chain.overlays) && chain.overlays.length > 0) {
|
|
2194
|
+
const epochsDir = path.join(docDir, "epochs");
|
|
2195
|
+
fs.mkdirSync(epochsDir, { recursive: true });
|
|
2196
|
+
for (const overlay of chain.overlays) {
|
|
2197
|
+
const archive = await client.downloadDocumentArtifact(appId, overlay.download.path);
|
|
2198
|
+
fs.writeFileSync(path.join(epochsDir, `${overlay.epoch}.yjs`), archive);
|
|
2199
|
+
bytes += archive.byteLength;
|
|
2200
|
+
overlays.push({ epoch: overlay.epoch, sealedAt: overlay.sealedAt ?? null });
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
let base = null;
|
|
2204
|
+
if (chain.base) {
|
|
2205
|
+
const snapshotDir = path.join(docDir, "snapshot");
|
|
2206
|
+
fs.mkdirSync(snapshotDir, { recursive: true });
|
|
2207
|
+
const manifestBytes = await client.downloadDocumentArtifact(appId, chain.base.download.path);
|
|
2208
|
+
const downloaded = JSON.parse(manifestBytes.toString("utf-8"));
|
|
2209
|
+
// Addressed within the granted build by each entry's `path` — the
|
|
2210
|
+
// own-build `{model}/{n}` or the `{epoch}-{buildId}/{model}/{n}` of a
|
|
2211
|
+
// chunk reused from an earlier build — never by key (#3432). On disk the
|
|
2212
|
+
// chunks are renumbered per model into the own-build form and the stored
|
|
2213
|
+
// manifest says so, which is the one layout `documents import` reads.
|
|
2214
|
+
const { manifest, files } = renumberManifestForExport(downloaded);
|
|
2215
|
+
for (const file of files) {
|
|
2216
|
+
const body = await client.downloadDocumentArtifact(appId, `${chain.base.download.path}/${file.download}`);
|
|
2217
|
+
const target = path.join(snapshotDir, file.file);
|
|
2218
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
2219
|
+
fs.writeFileSync(target, body);
|
|
2220
|
+
bytes += body.byteLength;
|
|
2221
|
+
}
|
|
2222
|
+
fs.writeFileSync(path.join(snapshotDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
2223
|
+
base = {
|
|
2224
|
+
epoch: chain.base.epoch,
|
|
2225
|
+
buildId: chain.base.buildId,
|
|
2226
|
+
rows: chain.base.rows,
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
// Last, and without the signatures: a grant is this run's read of the
|
|
2230
|
+
// document, and an artifact that outlives it must not carry one.
|
|
2231
|
+
fs.writeFileSync(path.join(docDir, "chain.json"), JSON.stringify({
|
|
2232
|
+
version: 2,
|
|
2233
|
+
documentFormat: 2,
|
|
2234
|
+
documentId,
|
|
2235
|
+
epoch: chain.epoch,
|
|
2236
|
+
base,
|
|
2237
|
+
overlays,
|
|
2238
|
+
current: { epoch: chain.current.epoch, file: "current.yjs" },
|
|
2239
|
+
}, null, 2));
|
|
2240
|
+
return bytes;
|
|
2241
|
+
}
|
|
2242
|
+
/**
|
|
2243
|
+
* Where a stored manifest entry's chunk is on disk: `snapshot/{model}/{n}`.
|
|
2244
|
+
*
|
|
2245
|
+
* An exported manifest is in the own-build form (renumbered per model), so
|
|
2246
|
+
* the entry's `path` — or, for an artifact written before the field existed,
|
|
2247
|
+
* its key — names the file directly.
|
|
2248
|
+
*/
|
|
2249
|
+
function chunkFileOf(chunk) {
|
|
2250
|
+
const address = chunkAddressOf(chunk);
|
|
2251
|
+
return { model: address.model, index: address.index };
|
|
2252
|
+
}
|
|
1320
2253
|
async function exportSingleDocument(client, appId, documentId, outputDir, includeBlobs, jsonOutput) {
|
|
1321
2254
|
const docDir = path.join(outputDir, "documents", documentId);
|
|
1322
2255
|
fs.mkdirSync(docDir, { recursive: true });
|
|
1323
2256
|
// 1. Fetch document metadata
|
|
1324
2257
|
const docMeta = await client.getDocument(appId, documentId);
|
|
1325
|
-
// 2. Export
|
|
1326
|
-
|
|
1327
|
-
|
|
2258
|
+
// 2. Export the document's state. A legacy document is one blob; a large
|
|
2259
|
+
// one (#2816) is the chain — snapshot, sealed overlays, open epoch —
|
|
2260
|
+
// because the blob is exactly the whole-document-in-memory cost format 2
|
|
2261
|
+
// exists to avoid.
|
|
2262
|
+
let stateBytes;
|
|
2263
|
+
if (Number(docMeta?.documentFormat) === 2) {
|
|
2264
|
+
stateBytes = await exportLargeDocumentChain(client, appId, documentId, docDir);
|
|
2265
|
+
}
|
|
2266
|
+
else {
|
|
2267
|
+
const stateResult = await client.exportDocumentState(appId, documentId);
|
|
2268
|
+
fs.writeFileSync(path.join(docDir, "document.yjs"), Buffer.from(stateResult.state, "base64"));
|
|
2269
|
+
stateBytes = stateResult.byteLength;
|
|
2270
|
+
}
|
|
1328
2271
|
// 3. Fetch permissions
|
|
1329
|
-
let
|
|
2272
|
+
let permResult = null;
|
|
1330
2273
|
try {
|
|
1331
|
-
|
|
1332
|
-
permissions = Array.isArray(permResult) ? permResult : permResult?.permissions || [];
|
|
2274
|
+
permResult = await client.listDocumentPermissions(appId, documentId);
|
|
1333
2275
|
}
|
|
1334
2276
|
catch {
|
|
1335
2277
|
// Permissions endpoint might not return data for all docs
|
|
1336
2278
|
}
|
|
1337
|
-
// 4. Fetch invitations
|
|
1338
|
-
|
|
2279
|
+
// 4. Fetch pending invitations (deferred grants). An older server answers
|
|
2280
|
+
// 403/404 here; the export still writes the permission rows.
|
|
2281
|
+
let pendingResult = null;
|
|
1339
2282
|
try {
|
|
1340
|
-
|
|
1341
|
-
invitations = Array.isArray(invResult) ? invResult : invResult?.invitations || [];
|
|
2283
|
+
pendingResult = await client.listDocumentPendingInvitations(appId, documentId);
|
|
1342
2284
|
}
|
|
1343
2285
|
catch {
|
|
1344
|
-
// May not have invitations
|
|
2286
|
+
// May not have pending invitations, or the server predates the endpoint
|
|
1345
2287
|
}
|
|
1346
|
-
|
|
1347
|
-
const permissionsExport = [
|
|
1348
|
-
...permissions.map((p) => ({
|
|
1349
|
-
email: p.email,
|
|
1350
|
-
permission: p.permission,
|
|
1351
|
-
grantedAt: p.grantedAt,
|
|
1352
|
-
})),
|
|
1353
|
-
...invitations
|
|
1354
|
-
.filter((i) => !i.accepted && new Date(i.expiresAt) > new Date())
|
|
1355
|
-
.map((i) => ({
|
|
1356
|
-
email: i.email,
|
|
1357
|
-
permission: i.permission,
|
|
1358
|
-
source: "invitation",
|
|
1359
|
-
status: "pending",
|
|
1360
|
-
})),
|
|
1361
|
-
];
|
|
2288
|
+
const permissionsExport = buildPermissionsExport(permResult, pendingResult);
|
|
1362
2289
|
fs.writeFileSync(path.join(docDir, "permissions.json"), JSON.stringify(permissionsExport, null, 2));
|
|
1363
2290
|
// 5. Fetch aliases (user-scoped only)
|
|
1364
2291
|
let aliases = [];
|
|
@@ -1370,11 +2297,15 @@ async function exportSingleDocument(client, appId, documentId, outputDir, includ
|
|
|
1370
2297
|
catch {
|
|
1371
2298
|
// May not have aliases
|
|
1372
2299
|
}
|
|
1373
|
-
// 6. Write metadata
|
|
2300
|
+
// 6. Write metadata. A large document says so here as well as in
|
|
2301
|
+
// `chain.json`: the format decides how the artifact has to be installed,
|
|
2302
|
+
// and an import that reads only `metadata.json` must not mistake one for
|
|
2303
|
+
// a legacy document (#2816).
|
|
1374
2304
|
const metadata = {
|
|
1375
2305
|
documentId: docMeta.documentId || documentId,
|
|
1376
2306
|
title: docMeta.title,
|
|
1377
2307
|
tags: docMeta.tags || [],
|
|
2308
|
+
...(Number(docMeta?.documentFormat) === 2 ? { documentFormat: 2 } : {}),
|
|
1378
2309
|
createdAt: docMeta.createdAt,
|
|
1379
2310
|
createdBy: docMeta.createdByEmail || docMeta.createdBy,
|
|
1380
2311
|
aliases: aliases.map((a) => ({
|
|
@@ -1415,7 +2346,7 @@ async function exportSingleDocument(client, appId, documentId, outputDir, includ
|
|
|
1415
2346
|
json({
|
|
1416
2347
|
documentId,
|
|
1417
2348
|
title: metadata.title,
|
|
1418
|
-
stateBytes
|
|
2349
|
+
stateBytes,
|
|
1419
2350
|
permissions: permissionsExport.length,
|
|
1420
2351
|
aliases: aliases.length,
|
|
1421
2352
|
exportPath: docDir,
|
|
@@ -1425,7 +2356,399 @@ async function exportSingleDocument(client, appId, documentId, outputDir, includ
|
|
|
1425
2356
|
success(`Exported document ${documentId} (${metadata.title || "untitled"}) to ${docDir}`);
|
|
1426
2357
|
}
|
|
1427
2358
|
}
|
|
1428
|
-
|
|
2359
|
+
/**
|
|
2360
|
+
* Whether `docDir` holds a large document's export (#2816, behavior 20).
|
|
2361
|
+
*
|
|
2362
|
+
* Either marker is enough: `chain.json` is what the chain export writes last,
|
|
2363
|
+
* and `metadata.json` carries the format for artifacts inspected on their own.
|
|
2364
|
+
*/
|
|
2365
|
+
function largeDocumentArtifactAt(docDir) {
|
|
2366
|
+
if (fs.existsSync(path.join(docDir, "chain.json")))
|
|
2367
|
+
return true;
|
|
2368
|
+
const metadataPath = path.join(docDir, "metadata.json");
|
|
2369
|
+
if (!fs.existsSync(metadataPath))
|
|
2370
|
+
return false;
|
|
2371
|
+
try {
|
|
2372
|
+
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
|
|
2373
|
+
return Number(metadata?.documentFormat) === 2;
|
|
2374
|
+
}
|
|
2375
|
+
catch {
|
|
2376
|
+
return false;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
/**
|
|
2380
|
+
* Read (and vouch for) a large document's chain artifact.
|
|
2381
|
+
*
|
|
2382
|
+
* `chain.json` is what the export writes LAST, so its absence means the
|
|
2383
|
+
* artifact is partial — a directory recognizable as a large document with no
|
|
2384
|
+
* description of what to install. Importing that as a legacy document would
|
|
2385
|
+
* read a `document.yjs` that is not there, create an empty document, and
|
|
2386
|
+
* report success: total data loss from the operator's point of view, since the
|
|
2387
|
+
* export is the only copy they were told they had. Refusing is the honest
|
|
2388
|
+
* answer, and it happens before anything is created.
|
|
2389
|
+
*/
|
|
2390
|
+
function readChainArtifact(docDir, documentId) {
|
|
2391
|
+
const chainPath = path.join(docDir, "chain.json");
|
|
2392
|
+
if (!fs.existsSync(chainPath)) {
|
|
2393
|
+
throw new Error(`Document ${documentId} was exported as a large document (documentFormat 2), ` +
|
|
2394
|
+
`but ${chainPath} is missing, so this artifact is incomplete. A large document's ` +
|
|
2395
|
+
`data IS its chain — a base snapshot, the sealed overlays after it, and the open ` +
|
|
2396
|
+
`epoch — and importing this directory as a legacy document would create an EMPTY ` +
|
|
2397
|
+
`document. Nothing was imported.`);
|
|
2398
|
+
}
|
|
2399
|
+
let chain;
|
|
2400
|
+
try {
|
|
2401
|
+
chain = JSON.parse(fs.readFileSync(chainPath, "utf-8"));
|
|
2402
|
+
}
|
|
2403
|
+
catch (parseError) {
|
|
2404
|
+
throw new Error(`Document ${documentId}: ${chainPath} is not readable as a chain description ` +
|
|
2405
|
+
`(${parseError.message}), so nothing was imported.`);
|
|
2406
|
+
}
|
|
2407
|
+
if (!Number.isSafeInteger(Number(chain?.epoch))) {
|
|
2408
|
+
throw new Error(`Document ${documentId}: ${chainPath} names no open epoch, so nothing was imported.`);
|
|
2409
|
+
}
|
|
2410
|
+
const artifact = {
|
|
2411
|
+
epoch: Number(chain.epoch),
|
|
2412
|
+
base: chain.base
|
|
2413
|
+
? {
|
|
2414
|
+
epoch: Number(chain.base.epoch),
|
|
2415
|
+
buildId: String(chain.base.buildId),
|
|
2416
|
+
rows: Number(chain.base.rows ?? 0),
|
|
2417
|
+
}
|
|
2418
|
+
: null,
|
|
2419
|
+
overlays: (Array.isArray(chain.overlays) ? chain.overlays : []).map((overlay) => ({ epoch: Number(overlay.epoch) })),
|
|
2420
|
+
};
|
|
2421
|
+
verifyChainArtifact(docDir, documentId, artifact);
|
|
2422
|
+
return artifact;
|
|
2423
|
+
}
|
|
2424
|
+
/**
|
|
2425
|
+
* Check that the directory holds every file the chain names, and that the
|
|
2426
|
+
* chain is one.
|
|
2427
|
+
*
|
|
2428
|
+
* `chain.json` being present says the export finished writing; it does not say
|
|
2429
|
+
* the files are there, and until this check the first missing chunk was found
|
|
2430
|
+
* AFTER the destination document had been created — leaving an empty document
|
|
2431
|
+
* behind, and in a multi-document import an app half-populated. The chain's
|
|
2432
|
+
* shape is checked here too, for the same reason the server checks it: a
|
|
2433
|
+
* chain with an epoch missing from the middle installs cleanly and is missing
|
|
2434
|
+
* everything that epoch held.
|
|
2435
|
+
*/
|
|
2436
|
+
function verifyChainArtifact(docDir, documentId, chain) {
|
|
2437
|
+
const refuse = (reason) => {
|
|
2438
|
+
throw new Error(`Document ${documentId}: its large-document export ${reason}, so nothing was imported.`);
|
|
2439
|
+
};
|
|
2440
|
+
const from = chain.base ? chain.base.epoch : 1;
|
|
2441
|
+
const expected = [];
|
|
2442
|
+
for (let epoch = from; epoch < chain.epoch; epoch++)
|
|
2443
|
+
expected.push(epoch);
|
|
2444
|
+
const named = chain.overlays
|
|
2445
|
+
.map((overlay) => overlay.epoch)
|
|
2446
|
+
.sort((left, right) => left - right);
|
|
2447
|
+
if (named.length !== expected.length ||
|
|
2448
|
+
named.some((epoch, index) => epoch !== expected[index])) {
|
|
2449
|
+
refuse(`describes sealed overlays [${named.join(", ")}] where the chain from ` +
|
|
2450
|
+
(chain.base ? `its base at epoch ${chain.base.epoch}` : "epoch 1") +
|
|
2451
|
+
` to its open epoch ${chain.epoch} is [${expected.join(", ")}]`);
|
|
2452
|
+
}
|
|
2453
|
+
const require = (file, what) => {
|
|
2454
|
+
if (!fs.existsSync(file))
|
|
2455
|
+
refuse(`is missing ${what} (${file})`);
|
|
2456
|
+
};
|
|
2457
|
+
if (chain.base) {
|
|
2458
|
+
const manifestPath = path.join(docDir, "snapshot", "manifest.json");
|
|
2459
|
+
require(manifestPath, "its base snapshot's manifest");
|
|
2460
|
+
let manifest;
|
|
2461
|
+
try {
|
|
2462
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
2463
|
+
}
|
|
2464
|
+
catch (parseError) {
|
|
2465
|
+
refuse(`has a base snapshot manifest that is not readable ` +
|
|
2466
|
+
`(${parseError.message})`);
|
|
2467
|
+
}
|
|
2468
|
+
// Validated where it enters (#3432): the fields the server keys on — an
|
|
2469
|
+
// entry's ordinal, its path, its range — fail silently rather than loudly
|
|
2470
|
+
// if they are wrong, and the server refuses the same manifest later with
|
|
2471
|
+
// the same message.
|
|
2472
|
+
try {
|
|
2473
|
+
validateSnapshotManifest(manifest);
|
|
2474
|
+
}
|
|
2475
|
+
catch (invalid) {
|
|
2476
|
+
refuse(`has a base snapshot manifest that is not valid (${invalid.message})`);
|
|
2477
|
+
}
|
|
2478
|
+
for (const chunk of manifest.chunks ?? []) {
|
|
2479
|
+
const { model, index } = chunkFileOf(chunk);
|
|
2480
|
+
require(path.join(docDir, "snapshot", model, `${index}.ndjson.gz`), `chunk ${index} of ${model}`);
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
for (const overlay of chain.overlays) {
|
|
2484
|
+
require(path.join(docDir, "epochs", `${overlay.epoch}.yjs`), `the archived overlay of epoch ${overlay.epoch}`);
|
|
2485
|
+
}
|
|
2486
|
+
require(path.join(docDir, "current.yjs"), "its open epoch's overlay");
|
|
2487
|
+
}
|
|
2488
|
+
/** How long an install is waited on before the CLI gives up on it. */
|
|
2489
|
+
const CHAIN_INSTALL_TIMEOUT_MS = 60 * 60 * 1000;
|
|
2490
|
+
/** How often the install's progress is asked for. */
|
|
2491
|
+
const CHAIN_INSTALL_POLL_MS = 1000;
|
|
2492
|
+
/**
|
|
2493
|
+
* Install a large document's chain export into a freshly created document
|
|
2494
|
+
* (#2816, behavior 20).
|
|
2495
|
+
*
|
|
2496
|
+
* Every artifact is uploaded byte-for-byte into the new document's own prefix
|
|
2497
|
+
* — the manifest's digests are over the STORED bytes, so re-encoding a chunk
|
|
2498
|
+
* here would fail its own verification on install — and then the server
|
|
2499
|
+
* streams them into the document's `records` table a tick at a time. The full
|
|
2500
|
+
* document is never built as a Y.Doc on either side, which is what makes
|
|
2501
|
+
* importing a multi-hundred-MB artifact possible at all; it is also the
|
|
2502
|
+
* efficient way to bulk-seed a new large document.
|
|
2503
|
+
*/
|
|
2504
|
+
async function importLargeDocumentChain(client, appId, docDir, documentId) {
|
|
2505
|
+
const chain = readChainArtifact(docDir, documentId);
|
|
2506
|
+
// The base's manifest and chunks. The manifest names the chunks, so nothing
|
|
2507
|
+
// here has to walk the directory and guess what belongs to the build.
|
|
2508
|
+
if (chain.base) {
|
|
2509
|
+
const manifestPath = path.join(docDir, "snapshot", "manifest.json");
|
|
2510
|
+
const manifestBytes = fs.readFileSync(manifestPath);
|
|
2511
|
+
await client.uploadDocumentChainArtifact(appId, documentId, {
|
|
2512
|
+
kind: "manifest",
|
|
2513
|
+
epoch: chain.base.epoch,
|
|
2514
|
+
buildId: chain.base.buildId,
|
|
2515
|
+
}, manifestBytes);
|
|
2516
|
+
const manifest = JSON.parse(manifestBytes.toString("utf-8"));
|
|
2517
|
+
for (const chunk of manifest.chunks ?? []) {
|
|
2518
|
+
// Addressed by the entry's `path` (#3432); the server installs by it too.
|
|
2519
|
+
const { model, index } = chunkFileOf(chunk);
|
|
2520
|
+
await client.uploadDocumentChainArtifact(appId, documentId, {
|
|
2521
|
+
kind: "chunk",
|
|
2522
|
+
epoch: chain.base.epoch,
|
|
2523
|
+
buildId: chain.base.buildId,
|
|
2524
|
+
model,
|
|
2525
|
+
index,
|
|
2526
|
+
}, fs.readFileSync(path.join(docDir, "snapshot", model, `${index}.ndjson.gz`)));
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
// The sealed overlays, oldest first, then the open epoch's — which travels
|
|
2530
|
+
// archived exactly like a sealed one, so the imported document's chain is
|
|
2531
|
+
// the chain that was exported.
|
|
2532
|
+
for (const overlay of chain.overlays) {
|
|
2533
|
+
await client.uploadDocumentChainArtifact(appId, documentId, { kind: "overlay", epoch: overlay.epoch }, fs.readFileSync(path.join(docDir, "epochs", `${overlay.epoch}.yjs`)));
|
|
2534
|
+
}
|
|
2535
|
+
await client.uploadDocumentChainArtifact(appId, documentId, { kind: "overlay", epoch: chain.epoch }, fs.readFileSync(path.join(docDir, "current.yjs")));
|
|
2536
|
+
await client.installDocumentChain(appId, documentId, {
|
|
2537
|
+
base: chain.base
|
|
2538
|
+
? { epoch: chain.base.epoch, buildId: chain.base.buildId }
|
|
2539
|
+
: null,
|
|
2540
|
+
overlays: chain.overlays.map((overlay) => overlay.epoch),
|
|
2541
|
+
currentEpoch: chain.epoch,
|
|
2542
|
+
});
|
|
2543
|
+
// The install runs on the document's alarm, a bounded tick at a time, so it
|
|
2544
|
+
// is waited on rather than awaited: reporting "imported" while the records
|
|
2545
|
+
// are still arriving is the failure this whole path exists to avoid.
|
|
2546
|
+
const deadline = Date.now() + CHAIN_INSTALL_TIMEOUT_MS;
|
|
2547
|
+
for (;;) {
|
|
2548
|
+
const status = await client.getDocumentChainInstall(appId, documentId);
|
|
2549
|
+
const install = status?.install;
|
|
2550
|
+
if (install?.state === "complete")
|
|
2551
|
+
return;
|
|
2552
|
+
if (install?.state === "failed") {
|
|
2553
|
+
throw new Error(`Document ${documentId}: installing its chain failed — ` +
|
|
2554
|
+
`${install.error || "no reason given"}. The document is incomplete ` +
|
|
2555
|
+
`and should be deleted before the import is retried.`);
|
|
2556
|
+
}
|
|
2557
|
+
if (Date.now() >= deadline) {
|
|
2558
|
+
throw new Error(`Document ${documentId}: its chain install has not finished after ` +
|
|
2559
|
+
`${Math.round(CHAIN_INSTALL_TIMEOUT_MS / 60000)} minutes. It continues ` +
|
|
2560
|
+
`on the server; check with 'primitive documents get ${documentId}'.`);
|
|
2561
|
+
}
|
|
2562
|
+
await new Promise((resolve) => setTimeout(resolve, CHAIN_INSTALL_POLL_MS));
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
/** Mirrors `ROOT_DOCUMENT_TAG` in `src/config/constants.ts`. */
|
|
2566
|
+
const ROOT_DOCUMENT_TAG = "__ROOT_TAG__";
|
|
2567
|
+
/** Does this `metadata.json` describe somebody's root document? */
|
|
2568
|
+
function isRootDocumentExport(tags) {
|
|
2569
|
+
return Array.isArray(tags) && tags.includes(ROOT_DOCUMENT_TAG);
|
|
2570
|
+
}
|
|
2571
|
+
/**
|
|
2572
|
+
* Why a large-document root export is refused (#3135, contract 7).
|
|
2573
|
+
*
|
|
2574
|
+
* A format-2 export is a chain installed wholesale into a freshly created
|
|
2575
|
+
* format-2 document — a replace, not a merge, and not something that can be
|
|
2576
|
+
* done to a root document, which the server only ever mints as a legacy
|
|
2577
|
+
* document. Refusing says so rather than installing a chain somewhere it does
|
|
2578
|
+
* not belong.
|
|
2579
|
+
*/
|
|
2580
|
+
const ROOT_LARGE_DOCUMENT_REFUSAL = "it is a root document exported as a large document (documentFormat 2). A " +
|
|
2581
|
+
"root document is minted as a legacy document and a chain is installed " +
|
|
2582
|
+
"wholesale into a new format-2 document, so the export cannot be applied " +
|
|
2583
|
+
"to the target user's root. Nothing was imported for it.";
|
|
2584
|
+
/** Read an export directory's `metadata.json`, or null when it has none. */
|
|
2585
|
+
function readExportMetadata(docDir) {
|
|
2586
|
+
const metadataPath = path.join(docDir, "metadata.json");
|
|
2587
|
+
if (!fs.existsSync(metadataPath))
|
|
2588
|
+
return null;
|
|
2589
|
+
try {
|
|
2590
|
+
return JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
|
|
2591
|
+
}
|
|
2592
|
+
catch {
|
|
2593
|
+
return null;
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
/**
|
|
2597
|
+
* Decide what each root-marked export in this run resolves to (#3135).
|
|
2598
|
+
*
|
|
2599
|
+
* A root document is a per-user singleton the server owns: the exported id is
|
|
2600
|
+
* the SOURCE user's root and means nothing here, so the export is mapped to
|
|
2601
|
+
* the TARGET user's root document instead. The target is `--owner` when given,
|
|
2602
|
+
* otherwise the owner `metadata.json` recorded — an email since #3135, which
|
|
2603
|
+
* is the only owner identity that survives a move between environments.
|
|
2604
|
+
*
|
|
2605
|
+
* Only a definitive answer reads as absence: `exists: false` from the email
|
|
2606
|
+
* lookup, or a 404 from the root-document read. Anything else (an expired
|
|
2607
|
+
* login, a lost permission, a server fault) fails the run, exactly as the
|
|
2608
|
+
* document-existence check does (#3096) — a failed read must never turn into a
|
|
2609
|
+
* confident create.
|
|
2610
|
+
*/
|
|
2611
|
+
async function planRootImports(client, appId, docDirs, explicitOwner, explicitOwnerUserId, overwrite) {
|
|
2612
|
+
const plans = new Map();
|
|
2613
|
+
for (const docDir of docDirs) {
|
|
2614
|
+
const metadata = readExportMetadata(docDir);
|
|
2615
|
+
if (!metadata || !isRootDocumentExport(metadata.tags))
|
|
2616
|
+
continue;
|
|
2617
|
+
const exportedDocumentId = metadata.documentId || path.basename(docDir);
|
|
2618
|
+
const owner = String(explicitOwner || metadata.createdBy || "");
|
|
2619
|
+
const plan = {
|
|
2620
|
+
docDir,
|
|
2621
|
+
exportedDocumentId,
|
|
2622
|
+
owner,
|
|
2623
|
+
action: "refused",
|
|
2624
|
+
};
|
|
2625
|
+
const refuse = (reason) => {
|
|
2626
|
+
plan.action = "refused";
|
|
2627
|
+
plan.reason = reason;
|
|
2628
|
+
plans.set(docDir, plan);
|
|
2629
|
+
};
|
|
2630
|
+
if (Number(metadata.documentFormat) === 2) {
|
|
2631
|
+
refuse(ROOT_LARGE_DOCUMENT_REFUSAL);
|
|
2632
|
+
continue;
|
|
2633
|
+
}
|
|
2634
|
+
if (!owner) {
|
|
2635
|
+
refuse(`it is a root document export that records no owner, so there is no ` +
|
|
2636
|
+
`user in ${appId} to restore it into. Pass --owner <userId-or-email>.`);
|
|
2637
|
+
continue;
|
|
2638
|
+
}
|
|
2639
|
+
// Resolve the target user. An explicit `--owner` was already resolved,
|
|
2640
|
+
// command-level, before anything was read.
|
|
2641
|
+
let targetUserId = explicitOwnerUserId;
|
|
2642
|
+
if (!targetUserId) {
|
|
2643
|
+
if (owner.includes("@")) {
|
|
2644
|
+
// Absence is what the lookup ANSWERS with — `exists: false` on a 200.
|
|
2645
|
+
// A thrown lookup is a failed read (an expired login, a lost
|
|
2646
|
+
// permission, a proxy), and a failed read must never pass for "no such
|
|
2647
|
+
// user": it fails the run instead (#3096).
|
|
2648
|
+
let lookup;
|
|
2649
|
+
try {
|
|
2650
|
+
lookup = await client.lookupUserByEmail(appId, owner);
|
|
2651
|
+
}
|
|
2652
|
+
catch (error) {
|
|
2653
|
+
throw new Error(`Document ${exportedDocumentId}: looking up its recorded owner ` +
|
|
2654
|
+
`"${owner}" failed — ${error?.message || "no reason given"}. ` +
|
|
2655
|
+
`Nothing was imported.`);
|
|
2656
|
+
}
|
|
2657
|
+
if (!lookup?.exists || !lookup.user) {
|
|
2658
|
+
refuse(unresolvableRootOwner(appId, owner));
|
|
2659
|
+
continue;
|
|
2660
|
+
}
|
|
2661
|
+
targetUserId = lookup.user.userId;
|
|
2662
|
+
}
|
|
2663
|
+
else {
|
|
2664
|
+
targetUserId = owner;
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
plan.targetUserId = targetUserId;
|
|
2668
|
+
let assignment;
|
|
2669
|
+
try {
|
|
2670
|
+
assignment = await client.getUserRootDocument(appId, targetUserId);
|
|
2671
|
+
}
|
|
2672
|
+
catch (error) {
|
|
2673
|
+
if (error?.statusCode !== 404) {
|
|
2674
|
+
throw new Error(`Document ${exportedDocumentId}: reading the root document of ` +
|
|
2675
|
+
`"${owner}" failed — ${error?.message || "no reason given"}. ` +
|
|
2676
|
+
`Nothing was imported.`);
|
|
2677
|
+
}
|
|
2678
|
+
refuse(unresolvableRootOwner(appId, owner));
|
|
2679
|
+
continue;
|
|
2680
|
+
}
|
|
2681
|
+
const rootDocId = assignment?.rootDocId || null;
|
|
2682
|
+
if (!rootDocId) {
|
|
2683
|
+
plan.action = "create";
|
|
2684
|
+
}
|
|
2685
|
+
else {
|
|
2686
|
+
plan.targetRootDocId = rootDocId;
|
|
2687
|
+
plan.action = overwrite ? "apply" : "skip";
|
|
2688
|
+
}
|
|
2689
|
+
plans.set(docDir, plan);
|
|
2690
|
+
}
|
|
2691
|
+
assertDistinctRootTargets(plans);
|
|
2692
|
+
return plans;
|
|
2693
|
+
}
|
|
2694
|
+
/** Why a recorded owner cannot be restored into, and what to do about it. */
|
|
2695
|
+
function unresolvableRootOwner(appId, owner) {
|
|
2696
|
+
return (`it is a root document export whose owner "${owner}" is not a user of ` +
|
|
2697
|
+
`app ${appId}. A root document is restored into one specific user's own ` +
|
|
2698
|
+
`root document, so name the target user with --owner <userId-or-email>, ` +
|
|
2699
|
+
`or re-export so the owner's email is recorded.`);
|
|
2700
|
+
}
|
|
2701
|
+
/**
|
|
2702
|
+
* Refuse a run in which two root exports would land in one user's root
|
|
2703
|
+
* document (#3135, contract 5).
|
|
2704
|
+
*
|
|
2705
|
+
* The supported migration is one `export-all --user-id` directory per user,
|
|
2706
|
+
* imported one directory per run. A hand-assembled directory holding two
|
|
2707
|
+
* users' roots — or `--owner` given for a run containing more than one root
|
|
2708
|
+
* export — would merge both sources into a single root and lose one of them,
|
|
2709
|
+
* so it fails before anything is created rather than half-way through.
|
|
2710
|
+
*/
|
|
2711
|
+
function assertDistinctRootTargets(plans) {
|
|
2712
|
+
const byUser = new Map();
|
|
2713
|
+
for (const plan of plans.values()) {
|
|
2714
|
+
if (plan.action === "refused" || !plan.targetUserId)
|
|
2715
|
+
continue;
|
|
2716
|
+
const seen = byUser.get(plan.targetUserId) ?? [];
|
|
2717
|
+
seen.push(plan.exportedDocumentId);
|
|
2718
|
+
byUser.set(plan.targetUserId, seen);
|
|
2719
|
+
}
|
|
2720
|
+
for (const [userId, documents] of byUser) {
|
|
2721
|
+
if (documents.length > 1) {
|
|
2722
|
+
throw new Error(`Root document exports ${documents.join(", ")} all resolve to user ` +
|
|
2723
|
+
`${userId}, and a user has exactly one root document — importing ` +
|
|
2724
|
+
`them would merge them into each other. Export one user per ` +
|
|
2725
|
+
`directory and import one directory per run. Nothing was imported.`);
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
/**
|
|
2730
|
+
* Does the target app already hold this document?
|
|
2731
|
+
*
|
|
2732
|
+
* Only an explicit 404 answers "no" (#3096). An expired login (401), a lost
|
|
2733
|
+
* permission (403), a server fault or a transport failure says nothing about
|
|
2734
|
+
* the document, and reading those as absence turns a failed lookup into a
|
|
2735
|
+
* confident create — or, for a root-marked export, into a reported skip that
|
|
2736
|
+
* silently omits the overwrite the operator asked for. Those propagate, and
|
|
2737
|
+
* `documents import` fails the run.
|
|
2738
|
+
*/
|
|
2739
|
+
async function documentExists(client, appId, documentId) {
|
|
2740
|
+
try {
|
|
2741
|
+
await client.getDocument(appId, documentId);
|
|
2742
|
+
return true;
|
|
2743
|
+
}
|
|
2744
|
+
catch (error) {
|
|
2745
|
+
if (error?.statusCode === 404)
|
|
2746
|
+
return false;
|
|
2747
|
+
throw new Error(`Document ${documentId}: checking whether it already exists failed — ` +
|
|
2748
|
+
`${error?.message || "no reason given"}. Nothing was imported for it.`);
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
async function importSingleDocument(client, appId, docDir, overwrite, aliasMode, dryRun, summary, ownerUserId, rootPlan) {
|
|
1429
2752
|
const metadataPath = path.join(docDir, "metadata.json");
|
|
1430
2753
|
if (!fs.existsSync(metadataPath)) {
|
|
1431
2754
|
warn(`Skipping ${docDir}: no metadata.json found`);
|
|
@@ -1434,31 +2757,64 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
|
|
|
1434
2757
|
}
|
|
1435
2758
|
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
|
|
1436
2759
|
const documentId = metadata.documentId;
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
2760
|
+
// A root-marked export is never restored under its exported id: that id is
|
|
2761
|
+
// the SOURCE user's root document. It goes to the target user's own root
|
|
2762
|
+
// instead, per the plan made before this run wrote anything (#3135).
|
|
2763
|
+
//
|
|
2764
|
+
// Decided BEFORE the chain artifact below is read, because a root export is
|
|
2765
|
+
// never installed as a chain: a large-document root is refused by the plan,
|
|
2766
|
+
// and reading its chain here would fail the whole run over an artifact this
|
|
2767
|
+
// run has already decided not to touch.
|
|
2768
|
+
if (isRootDocumentExport(metadata.tags)) {
|
|
2769
|
+
await importRootDocument(client, appId, docDir, metadata, rootPlan, overwrite, aliasMode, dryRun, summary);
|
|
1440
2770
|
return;
|
|
1441
2771
|
}
|
|
1442
|
-
//
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
2772
|
+
// A large document's data is the chain, not `document.yjs` (#2816), and it
|
|
2773
|
+
// is installed rather than replayed. Vouched for before anything is created.
|
|
2774
|
+
const isLargeDocument = largeDocumentArtifactAt(docDir);
|
|
2775
|
+
if (isLargeDocument)
|
|
2776
|
+
readChainArtifact(docDir, documentId);
|
|
2777
|
+
// `--dry-run` decides from the same facts the real run does (#3096), so a
|
|
2778
|
+
// preview never promises a skip that the run turns into a merge: it looks
|
|
2779
|
+
// the document up as well, and only the writes below are withheld.
|
|
2780
|
+
const exists = await documentExists(client, appId, documentId);
|
|
2781
|
+
const wouldSkip = dryRun ? "[dry-run] Would skip" : "Skipping";
|
|
1451
2782
|
if (exists && !overwrite) {
|
|
1452
|
-
|
|
2783
|
+
// `--overwrite` merges a legacy document's state — but a large document is
|
|
2784
|
+
// INSTALLED, and the server refuses to install a chain into a document
|
|
2785
|
+
// that already holds records (`assertDocumentIsImportable`), so the remedy
|
|
2786
|
+
// is only honest for the format the state path handles.
|
|
2787
|
+
warn(isLargeDocument
|
|
2788
|
+
? `${wouldSkip} ${documentId}: already exists, and ` +
|
|
2789
|
+
`a large-document export is installed rather than merged — ` +
|
|
2790
|
+
`--overwrite can only install it into a document that holds ` +
|
|
2791
|
+
`nothing yet`
|
|
2792
|
+
: `${wouldSkip} ${documentId}: already exists (use --overwrite to merge ` +
|
|
2793
|
+
`the exported state into it)`);
|
|
1453
2794
|
summary.skipped++;
|
|
1454
2795
|
return;
|
|
1455
2796
|
}
|
|
1456
|
-
|
|
2797
|
+
if (dryRun) {
|
|
2798
|
+
info(`[dry-run] Would ${exists ? "update" : "import"} document ${documentId} ` +
|
|
2799
|
+
`(${metadata.title || "untitled"})`);
|
|
2800
|
+
if (exists)
|
|
2801
|
+
summary.updated++;
|
|
2802
|
+
else
|
|
2803
|
+
summary.created++;
|
|
2804
|
+
return;
|
|
2805
|
+
}
|
|
2806
|
+
// Create document if it doesn't exist. The format is decided at creation and
|
|
2807
|
+
// never migrated, so a large document has to be created as one (#2816).
|
|
2808
|
+
//
|
|
2809
|
+
// Tags go back exactly as `metadata.json` recorded them (#3096): an export
|
|
2810
|
+
// is a faithful record of what the document is, and editing the list on the
|
|
2811
|
+
// way back in loses that.
|
|
1457
2812
|
if (!exists) {
|
|
1458
2813
|
await client.createDocument(appId, {
|
|
1459
2814
|
title: metadata.title || "Imported Document",
|
|
1460
2815
|
documentId,
|
|
1461
2816
|
tags: metadata.tags,
|
|
2817
|
+
...(isLargeDocument ? { documentFormat: 2 } : {}),
|
|
1462
2818
|
...(ownerUserId ? { createdBy: ownerUserId } : {}),
|
|
1463
2819
|
});
|
|
1464
2820
|
summary.created++;
|
|
@@ -1466,12 +2822,19 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
|
|
|
1466
2822
|
else {
|
|
1467
2823
|
summary.updated++;
|
|
1468
2824
|
}
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
2825
|
+
if (isLargeDocument) {
|
|
2826
|
+
// The chain, installed: streamed into the document's records table on the
|
|
2827
|
+
// server, never replayed here as one Yjs update.
|
|
2828
|
+
await importLargeDocumentChain(client, appId, docDir, documentId);
|
|
2829
|
+
}
|
|
2830
|
+
else {
|
|
2831
|
+
// Import Yjs state
|
|
2832
|
+
const yjsPath = path.join(docDir, "document.yjs");
|
|
2833
|
+
if (fs.existsSync(yjsPath)) {
|
|
2834
|
+
const stateBuffer = fs.readFileSync(yjsPath);
|
|
2835
|
+
const stateBase64 = stateBuffer.toString("base64");
|
|
2836
|
+
await client.importDocumentState(appId, documentId, stateBase64);
|
|
2837
|
+
}
|
|
1475
2838
|
}
|
|
1476
2839
|
// Upload blobs
|
|
1477
2840
|
const blobIndexPath = path.join(docDir, "blobs", "index.json");
|
|
@@ -1513,4 +2876,169 @@ async function importSingleDocument(client, appId, docDir, overwrite, aliasMode,
|
|
|
1513
2876
|
}
|
|
1514
2877
|
info(`Imported document ${documentId} (${metadata.title || "untitled"})`);
|
|
1515
2878
|
}
|
|
2879
|
+
/**
|
|
2880
|
+
* Restore a root-marked export into the target user's own root document
|
|
2881
|
+
* (#3135).
|
|
2882
|
+
*
|
|
2883
|
+
* The exported id is never created and never looked up: it is the source
|
|
2884
|
+
* user's root id, which means nothing in this app. Everything — the Yjs state,
|
|
2885
|
+
* the blobs, the user-scoped aliases — is written against the target user's
|
|
2886
|
+
* root document id instead, so blob references inside the document still
|
|
2887
|
+
* resolve and the app still holds exactly one root document per user.
|
|
2888
|
+
*
|
|
2889
|
+
* "Apply" is the merge `import/state` has always performed: keys only one side
|
|
2890
|
+
* holds survive, and a key both sides set resolves by Yjs's own conflict rule,
|
|
2891
|
+
* which guarantees neither source-wins nor target-wins. That is why merging
|
|
2892
|
+
* into an existing root needs `--overwrite` — the default must not quietly
|
|
2893
|
+
* rewrite a user's live preferences.
|
|
2894
|
+
*/
|
|
2895
|
+
async function importRootDocument(client, appId, docDir, metadata, plan, overwrite, aliasMode, dryRun, summary) {
|
|
2896
|
+
const exportedDocumentId = metadata.documentId || path.basename(docDir);
|
|
2897
|
+
const wouldSkip = dryRun ? "[dry-run] Would skip" : "Skipping";
|
|
2898
|
+
const report = (action, targetRootDocId, reason) => {
|
|
2899
|
+
summary.rootDocuments.push({
|
|
2900
|
+
exportedDocumentId,
|
|
2901
|
+
owner: plan?.owner ?? "",
|
|
2902
|
+
...(targetRootDocId ? { targetRootDocId } : {}),
|
|
2903
|
+
action,
|
|
2904
|
+
...(reason ? { reason } : {}),
|
|
2905
|
+
});
|
|
2906
|
+
};
|
|
2907
|
+
if (!plan || plan.action === "refused") {
|
|
2908
|
+
const reason = plan?.reason ||
|
|
2909
|
+
`it is a root document export this run could not resolve to a user in ` +
|
|
2910
|
+
`app ${appId}. Pass --owner <userId-or-email>.`;
|
|
2911
|
+
warn(`${wouldSkip} ${exportedDocumentId}: ${reason}`);
|
|
2912
|
+
summary.skipped++;
|
|
2913
|
+
report("refused", undefined, reason);
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2916
|
+
if (plan.action === "skip") {
|
|
2917
|
+
const reason = `the root document of ${plan.owner} already exists ` +
|
|
2918
|
+
`(${plan.targetRootDocId}); use --overwrite to merge the exported ` +
|
|
2919
|
+
`content into it`;
|
|
2920
|
+
warn(`${wouldSkip} ${exportedDocumentId}: ${reason}`);
|
|
2921
|
+
summary.skipped++;
|
|
2922
|
+
report("skipped", plan.targetRootDocId, reason);
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
if (dryRun) {
|
|
2926
|
+
if (plan.action === "create") {
|
|
2927
|
+
info(`[dry-run] Would create the root document for ${plan.owner} and ` +
|
|
2928
|
+
`apply root document export ${exportedDocumentId} to it`);
|
|
2929
|
+
summary.created++;
|
|
2930
|
+
report("created");
|
|
2931
|
+
}
|
|
2932
|
+
else {
|
|
2933
|
+
info(`[dry-run] Would apply root document export ${exportedDocumentId} to ` +
|
|
2934
|
+
`${plan.owner}'s root document ${plan.targetRootDocId}`);
|
|
2935
|
+
summary.updated++;
|
|
2936
|
+
report("applied", plan.targetRootDocId);
|
|
2937
|
+
}
|
|
2938
|
+
return;
|
|
2939
|
+
}
|
|
2940
|
+
let targetRootDocId = plan.targetRootDocId;
|
|
2941
|
+
let created = false;
|
|
2942
|
+
if (plan.action === "create") {
|
|
2943
|
+
// The server's own get-or-create, and the only place a root document is
|
|
2944
|
+
// ever minted. Its answer — not the preflight read — decides both the id
|
|
2945
|
+
// the content lands in and whether this run created it: a first sign-in
|
|
2946
|
+
// may have minted the root between the read and this call.
|
|
2947
|
+
const ensured = await client.ensureUserRootDocument(appId, plan.targetUserId);
|
|
2948
|
+
targetRootDocId = ensured?.rootDocId;
|
|
2949
|
+
if (!targetRootDocId) {
|
|
2950
|
+
throw new Error(`Document ${exportedDocumentId}: the server returned no root document ` +
|
|
2951
|
+
`for ${plan.owner}, so nothing was imported for it.`);
|
|
2952
|
+
}
|
|
2953
|
+
created = ensured.created === true;
|
|
2954
|
+
if (!created && !overwrite) {
|
|
2955
|
+
const reason = `the root document of ${plan.owner} (${targetRootDocId}) was created ` +
|
|
2956
|
+
`by a concurrent sign-in while this import was running; use ` +
|
|
2957
|
+
`--overwrite to merge the exported content into it`;
|
|
2958
|
+
warn(`Skipping ${exportedDocumentId}: ${reason}`);
|
|
2959
|
+
summary.skipped++;
|
|
2960
|
+
report("skipped", targetRootDocId, reason);
|
|
2961
|
+
return;
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
// Only content moves. The target root keeps its own id, title, tags and
|
|
2965
|
+
// permissions — the ensure path already granted the owner read-write.
|
|
2966
|
+
const yjsPath = path.join(docDir, "document.yjs");
|
|
2967
|
+
if (fs.existsSync(yjsPath)) {
|
|
2968
|
+
await client.importDocumentState(appId, targetRootDocId, fs.readFileSync(yjsPath).toString("base64"));
|
|
2969
|
+
}
|
|
2970
|
+
const blobIndexPath = path.join(docDir, "blobs", "index.json");
|
|
2971
|
+
if (fs.existsSync(blobIndexPath)) {
|
|
2972
|
+
const blobIndex = JSON.parse(fs.readFileSync(blobIndexPath, "utf-8"));
|
|
2973
|
+
for (const blob of blobIndex) {
|
|
2974
|
+
const blobPath = path.join(docDir, "blobs", `${blob.blobId}.bin`);
|
|
2975
|
+
if (!fs.existsSync(blobPath))
|
|
2976
|
+
continue;
|
|
2977
|
+
// The blobId is preserved: a document's own references travel by blobId,
|
|
2978
|
+
// so they resolve inside the target root as they did in the source.
|
|
2979
|
+
await client.uploadBlob(appId, targetRootDocId, blob.blobId, fs.readFileSync(blobPath), {
|
|
2980
|
+
filename: blob.filename,
|
|
2981
|
+
contentType: blob.contentType,
|
|
2982
|
+
sha256: blob.sha256,
|
|
2983
|
+
});
|
|
2984
|
+
summary.blobsUploaded++;
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
// A root document's user-scoped aliases belong to its owner by definition,
|
|
2988
|
+
// so they are restored without `--owner` — the target user is already known.
|
|
2989
|
+
if (Array.isArray(metadata.aliases)) {
|
|
2990
|
+
for (const alias of metadata.aliases) {
|
|
2991
|
+
if (alias.aliasScope !== "user")
|
|
2992
|
+
continue;
|
|
2993
|
+
try {
|
|
2994
|
+
await client.setDocumentAlias(appId, alias.aliasScope, alias.aliasKey, targetRootDocId, plan.targetUserId, aliasMode === "skip");
|
|
2995
|
+
summary.aliasesSet++;
|
|
2996
|
+
}
|
|
2997
|
+
catch {
|
|
2998
|
+
summary.aliasesSkipped++;
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
if (created) {
|
|
3003
|
+
summary.created++;
|
|
3004
|
+
report("created", targetRootDocId);
|
|
3005
|
+
info(`Created root document ${targetRootDocId} for ${plan.owner} and applied ` +
|
|
3006
|
+
`root document export ${exportedDocumentId} to it`);
|
|
3007
|
+
}
|
|
3008
|
+
else {
|
|
3009
|
+
summary.updated++;
|
|
3010
|
+
report("applied", targetRootDocId);
|
|
3011
|
+
info(`Applied root document export ${exportedDocumentId} to ${plan.owner}'s ` +
|
|
3012
|
+
`root document ${targetRootDocId}`);
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
/**
|
|
3016
|
+
* Wire the audit to the app API (#3433, criterion 10).
|
|
3017
|
+
*
|
|
3018
|
+
* Two permissions, not one (3433-C08): the chain export takes the app-admin
|
|
3019
|
+
* arm, and the schema and records reads it is compared against take the
|
|
3020
|
+
* console arm or a read grant on the document — the documents surface grants
|
|
3021
|
+
* no app-role bypass on a read (#3279, D3279-002). The schema is read first,
|
|
3022
|
+
* so a caller holding only the first is refused before anything is exported.
|
|
3023
|
+
*/
|
|
3024
|
+
async function auditDocumentChain(client, appId, documentId, retries) {
|
|
3025
|
+
// One page's worth of records is the audit's whole working set on the
|
|
3026
|
+
// server side; everything else lives in the local store.
|
|
3027
|
+
const AUDIT_PAGE_SIZE = 200;
|
|
3028
|
+
const fingerprint = await createChainFingerprintReader();
|
|
3029
|
+
// Nothing the server answers is carried from one attempt to the next: the
|
|
3030
|
+
// attempt a moved document discards is exactly the one whose schema has
|
|
3031
|
+
// changed under it (3433-C09).
|
|
3032
|
+
const source = createAuditApiSource(client, {
|
|
3033
|
+
appId,
|
|
3034
|
+
documentId,
|
|
3035
|
+
pageSize: AUDIT_PAGE_SIZE,
|
|
3036
|
+
});
|
|
3037
|
+
return await auditSnapshotChain(source, {
|
|
3038
|
+
store: () => createAuditLocalStore(documentId),
|
|
3039
|
+
fingerprint,
|
|
3040
|
+
retries,
|
|
3041
|
+
pageSize: AUDIT_PAGE_SIZE,
|
|
3042
|
+
});
|
|
3043
|
+
}
|
|
1516
3044
|
//# sourceMappingURL=documents.js.map
|