primitive-admin 1.1.0-alpha.9 → 1.1.0
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 +523 -109
- package/assets/skill/skills/primitive-platform/SKILL.md +289 -0
- package/dist/bin/primitive.d.ts +2 -0
- package/dist/bin/primitive.js +372 -21
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/admins.d.ts +2 -0
- package/dist/src/commands/admins.js +136 -30
- package/dist/src/commands/admins.js.map +1 -1
- package/dist/src/commands/analytics.d.ts +2 -0
- package/dist/src/commands/analytics.js +713 -55
- package/dist/src/commands/analytics.js.map +1 -1
- package/dist/src/commands/apps.d.ts +2 -0
- package/dist/src/commands/apps.js +65 -100
- 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.d.ts +2 -0
- package/dist/src/commands/auth.js +238 -108
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/blob-buckets.d.ts +2 -0
- package/dist/src/commands/blob-buckets.js +331 -0
- package/dist/src/commands/blob-buckets.js.map +1 -0
- package/dist/src/commands/catalog.d.ts +2 -0
- package/dist/src/commands/catalog.js +63 -48
- package/dist/src/commands/catalog.js.map +1 -1
- package/dist/src/commands/collection-type-configs.d.ts +2 -0
- package/dist/src/commands/collection-type-configs.js +85 -0
- package/dist/src/commands/collection-type-configs.js.map +1 -0
- package/dist/src/commands/collections.d.ts +2 -0
- package/dist/src/commands/collections.js +1282 -0
- package/dist/src/commands/collections.js.map +1 -0
- package/dist/src/commands/comparisons.d.ts +2 -0
- package/dist/src/commands/comparisons.js +6 -6
- package/dist/src/commands/comparisons.js.map +1 -1
- package/dist/src/commands/config.d.ts +46 -0
- package/dist/src/commands/config.js +465 -0
- package/dist/src/commands/config.js.map +1 -0
- package/dist/src/commands/connections.d.ts +2 -0
- package/dist/src/commands/connections.js +99 -0
- package/dist/src/commands/connections.js.map +1 -0
- package/dist/src/commands/cron-triggers.d.ts +2 -0
- package/dist/src/commands/cron-triggers.js +266 -0
- package/dist/src/commands/cron-triggers.js.map +1 -0
- package/dist/src/commands/database-type-configs.d.ts +2 -0
- package/dist/src/commands/database-type-configs.js +164 -0
- package/dist/src/commands/database-type-configs.js.map +1 -0
- package/dist/src/commands/database-types.d.ts +2 -0
- package/dist/src/commands/database-types.js +471 -0
- package/dist/src/commands/database-types.js.map +1 -0
- package/dist/src/commands/databases.d.ts +65 -0
- package/dist/src/commands/databases.js +1887 -243
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.d.ts +60 -0
- package/dist/src/commands/documents.js +2888 -22
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/email-templates.d.ts +2 -0
- package/dist/src/commands/email-templates.js +175 -0
- package/dist/src/commands/email-templates.js.map +1 -0
- package/dist/src/commands/env.d.ts +23 -0
- package/dist/src/commands/env.js +395 -0
- package/dist/src/commands/env.js.map +1 -0
- package/dist/src/commands/feature-flags.d.ts +14 -0
- package/dist/src/commands/feature-flags.js +117 -0
- package/dist/src/commands/feature-flags.js.map +1 -0
- 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.d.ts +2 -0
- package/dist/src/commands/group-type-configs.js +87 -0
- package/dist/src/commands/group-type-configs.js.map +1 -0
- package/dist/src/commands/groups.d.ts +2 -0
- package/dist/src/commands/groups.js +65 -113
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/guides.d.ts +223 -0
- package/dist/src/commands/guides.js +627 -69
- package/dist/src/commands/guides.js.map +1 -1
- package/dist/src/commands/init.d.ts +37 -0
- package/dist/src/commands/init.js +1693 -208
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/integrations.d.ts +2 -0
- package/dist/src/commands/integrations.js +428 -187
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/llm.d.ts +2 -0
- package/dist/src/commands/llm.js +4 -2
- package/dist/src/commands/llm.js.map +1 -1
- package/dist/src/commands/locks.d.ts +8 -0
- package/dist/src/commands/locks.js +175 -0
- package/dist/src/commands/locks.js.map +1 -0
- package/dist/src/commands/metadata-category-configs.d.ts +12 -0
- package/dist/src/commands/metadata-category-configs.js +113 -0
- package/dist/src/commands/metadata-category-configs.js.map +1 -0
- package/dist/src/commands/metadata.d.ts +2 -0
- package/dist/src/commands/metadata.js +288 -0
- package/dist/src/commands/metadata.js.map +1 -0
- package/dist/src/commands/prompts.d.ts +2 -0
- package/dist/src/commands/prompts.js +322 -634
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/rule-sets.d.ts +3 -0
- package/dist/src/commands/rule-sets.js +139 -148
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/scripts.d.ts +30 -0
- package/dist/src/commands/scripts.js +688 -0
- package/dist/src/commands/scripts.js.map +1 -0
- package/dist/src/commands/secrets.d.ts +2 -0
- package/dist/src/commands/secrets.js +109 -0
- package/dist/src/commands/secrets.js.map +1 -0
- package/dist/src/commands/sessions.d.ts +2 -0
- package/dist/src/commands/sessions.js +76 -0
- package/dist/src/commands/sessions.js.map +1 -0
- package/dist/src/commands/skill.d.ts +2 -0
- package/dist/src/commands/skill.js +29 -0
- package/dist/src/commands/skill.js.map +1 -0
- package/dist/src/commands/sync-app-settings.d.ts +158 -0
- package/dist/src/commands/sync-app-settings.js +328 -0
- package/dist/src/commands/sync-app-settings.js.map +1 -0
- package/dist/src/commands/sync.d.ts +2670 -0
- package/dist/src/commands/sync.js +17144 -834
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/tokens.d.ts +2 -0
- package/dist/src/commands/tokens.js +132 -22
- package/dist/src/commands/tokens.js.map +1 -1
- package/dist/src/commands/users.d.ts +2 -0
- package/dist/src/commands/users.js +542 -24
- package/dist/src/commands/users.js.map +1 -1
- package/dist/src/commands/vars.d.ts +8 -0
- package/dist/src/commands/vars.js +97 -0
- package/dist/src/commands/vars.js.map +1 -0
- package/dist/src/commands/waitlist.d.ts +2 -0
- package/dist/src/commands/waitlist.js +12 -11
- package/dist/src/commands/waitlist.js.map +1 -1
- package/dist/src/commands/webhooks.d.ts +31 -0
- package/dist/src/commands/webhooks.js +633 -0
- package/dist/src/commands/webhooks.js.map +1 -0
- package/dist/src/commands/workflows.d.ts +88 -0
- package/dist/src/commands/workflows.js +1568 -742
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/access-rule-display.d.ts +21 -0
- package/dist/src/lib/access-rule-display.js +34 -0
- package/dist/src/lib/access-rule-display.js.map +1 -0
- package/dist/src/lib/api-client.d.ts +2550 -0
- package/dist/src/lib/api-client.js +2503 -160
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/app-settings-descriptor.d.ts +263 -0
- package/dist/src/lib/app-settings-descriptor.js +583 -0
- package/dist/src/lib/app-settings-descriptor.js.map +1 -0
- package/dist/src/lib/auth-flow.d.ts +8 -0
- package/dist/src/lib/batch.d.ts +26 -0
- package/dist/src/lib/batch.js +32 -0
- package/dist/src/lib/batch.js.map +1 -0
- package/dist/src/lib/block-layout.d.ts +160 -0
- package/dist/src/lib/block-layout.js +451 -0
- package/dist/src/lib/block-layout.js.map +1 -0
- 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/canonical-json.d.ts +12 -0
- package/dist/src/lib/canonical-json.js +35 -0
- package/dist/src/lib/canonical-json.js.map +1 -0
- package/dist/src/lib/channel.d.ts +30 -0
- package/dist/src/lib/channel.js +68 -0
- package/dist/src/lib/channel.js.map +1 -0
- package/dist/src/lib/cli-manifest.d.ts +68 -0
- package/dist/src/lib/cli-manifest.js +71 -0
- package/dist/src/lib/cli-manifest.js.map +1 -0
- package/dist/src/lib/codegen-shared/generatedFiles.d.ts +101 -0
- package/dist/src/lib/codegen-shared/generatedFiles.js +191 -0
- package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -0
- package/dist/src/lib/codegen-shared/prettierStable.d.ts +262 -0
- package/dist/src/lib/codegen-shared/prettierStable.js +610 -0
- package/dist/src/lib/codegen-shared/prettierStable.js.map +1 -0
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +38 -0
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +46 -0
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -0
- 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.d.ts +127 -0
- package/dist/src/lib/config-object-descriptor.js +740 -0
- package/dist/src/lib/config-object-descriptor.js.map +1 -0
- package/dist/src/lib/config-payload.d.ts +92 -0
- package/dist/src/lib/config-payload.js +161 -0
- package/dist/src/lib/config-payload.js.map +1 -0
- package/dist/src/lib/config-surface.d.ts +141 -0
- package/dist/src/lib/config-surface.js +368 -0
- package/dist/src/lib/config-surface.js.map +1 -0
- package/dist/src/lib/config-toml.d.ts +10 -0
- package/dist/src/lib/config-toml.js +42 -0
- package/dist/src/lib/config-toml.js.map +1 -0
- package/dist/src/lib/config.d.ts +71 -0
- package/dist/src/lib/config.js +71 -68
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/confirm-prompt.d.ts +83 -0
- package/dist/src/lib/confirm-prompt.js +110 -0
- package/dist/src/lib/confirm-prompt.js.map +1 -0
- package/dist/src/lib/constants.d.ts +11 -0
- package/dist/src/lib/constants.js +12 -0
- package/dist/src/lib/constants.js.map +1 -0
- package/dist/src/lib/crash-handlers.d.ts +20 -0
- package/dist/src/lib/crash-handlers.js +49 -0
- package/dist/src/lib/crash-handlers.js.map +1 -0
- package/dist/src/lib/credentials-store.d.ts +104 -0
- package/dist/src/lib/credentials-store.js +336 -0
- package/dist/src/lib/credentials-store.js.map +1 -0
- package/dist/src/lib/csv.d.ts +47 -0
- package/dist/src/lib/csv.js +172 -0
- package/dist/src/lib/csv.js.map +1 -0
- package/dist/src/lib/data-input.d.ts +23 -0
- package/dist/src/lib/data-input.js +50 -0
- package/dist/src/lib/data-input.js.map +1 -0
- package/dist/src/lib/db-codegen/dbFingerprint.d.ts +10 -0
- package/dist/src/lib/db-codegen/dbFingerprint.js +17 -0
- package/dist/src/lib/db-codegen/dbFingerprint.js.map +1 -0
- package/dist/src/lib/db-codegen/dbGenerator.d.ts +67 -0
- package/dist/src/lib/db-codegen/dbGenerator.js +170 -0
- package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -0
- package/dist/src/lib/db-codegen/dbNaming.d.ts +87 -0
- package/dist/src/lib/db-codegen/dbNaming.js +180 -0
- package/dist/src/lib/db-codegen/dbNaming.js.map +1 -0
- package/dist/src/lib/db-codegen/dbTemplates.d.ts +272 -0
- package/dist/src/lib/db-codegen/dbTemplates.js +480 -0
- package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -0
- package/dist/src/lib/db-codegen/dbTsTypes.d.ts +73 -0
- package/dist/src/lib/db-codegen/dbTsTypes.js +139 -0
- package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -0
- package/dist/src/lib/db-codegen/dbTypeIR.d.ts +146 -0
- package/dist/src/lib/db-codegen/dbTypeIR.js +525 -0
- package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -0
- package/dist/src/lib/db-codegen/generated-operation-def-descriptor.d.ts +112 -0
- package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js +211 -0
- package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js.map +1 -0
- 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 +258 -0
- package/dist/src/lib/env-resolver-core.js +447 -0
- package/dist/src/lib/env-resolver-core.js.map +1 -0
- package/dist/src/lib/env-resolver.d.ts +99 -0
- package/dist/src/lib/env-resolver.js +153 -0
- package/dist/src/lib/env-resolver.js.map +1 -0
- package/dist/src/lib/fetch.d.ts +5 -0
- 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.d.ts +28 -0
- package/dist/src/lib/generated-allowlist.js +281 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -0
- package/dist/src/lib/generated-config-surfaces.d.ts +2936 -0
- package/dist/src/lib/generated-config-surfaces.js +10569 -0
- package/dist/src/lib/generated-config-surfaces.js.map +1 -0
- 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/generated-template-lint.d.ts +212 -0
- package/dist/src/lib/generated-template-lint.js +624 -0
- package/dist/src/lib/generated-template-lint.js.map +1 -0
- package/dist/src/lib/init-adopt.d.ts +16 -0
- package/dist/src/lib/init-adopt.js +34 -0
- package/dist/src/lib/init-adopt.js.map +1 -0
- package/dist/src/lib/init-assets.d.ts +39 -0
- package/dist/src/lib/init-assets.js +97 -0
- package/dist/src/lib/init-assets.js.map +1 -0
- package/dist/src/lib/init-client-platforms.d.ts +14 -0
- package/dist/src/lib/init-client-platforms.js +71 -0
- package/dist/src/lib/init-client-platforms.js.map +1 -0
- package/dist/src/lib/init-config.d.ts +98 -0
- package/dist/src/lib/init-config.js +186 -0
- package/dist/src/lib/init-config.js.map +1 -0
- package/dist/src/lib/init-email-redirect-uris.d.ts +37 -0
- package/dist/src/lib/init-email-redirect-uris.js +46 -0
- package/dist/src/lib/init-email-redirect-uris.js.map +1 -0
- package/dist/src/lib/init-ios-links.d.ts +91 -0
- package/dist/src/lib/init-ios-links.js +219 -0
- package/dist/src/lib/init-ios-links.js.map +1 -0
- package/dist/src/lib/init-plan.d.ts +80 -0
- package/dist/src/lib/init-plan.js +95 -0
- package/dist/src/lib/init-plan.js.map +1 -0
- package/dist/src/lib/init-production-env.d.ts +48 -0
- package/dist/src/lib/init-production-env.js +59 -0
- package/dist/src/lib/init-production-env.js.map +1 -0
- package/dist/src/lib/init-schema.d.ts +74 -0
- package/dist/src/lib/init-schema.js +358 -0
- package/dist/src/lib/init-schema.js.map +1 -0
- package/dist/src/lib/init-xcode.d.ts +34 -0
- package/dist/src/lib/init-xcode.js +138 -0
- package/dist/src/lib/init-xcode.js.map +1 -0
- package/dist/src/lib/integration-request-config.d.ts +30 -0
- package/dist/src/lib/integration-request-config.js +145 -0
- package/dist/src/lib/integration-request-config.js.map +1 -0
- package/dist/src/lib/integration-selector.d.ts +42 -0
- package/dist/src/lib/integration-selector.js +46 -0
- package/dist/src/lib/integration-selector.js.map +1 -0
- 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 +55 -0
- package/dist/src/lib/local-state.js +167 -0
- package/dist/src/lib/local-state.js.map +1 -0
- package/dist/src/lib/local-test-cases.d.ts +63 -0
- package/dist/src/lib/local-test-cases.js +136 -0
- package/dist/src/lib/local-test-cases.js.map +1 -0
- package/dist/src/lib/log-inspection.d.ts +715 -0
- package/dist/src/lib/log-inspection.js +816 -0
- package/dist/src/lib/log-inspection.js.map +1 -0
- 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/migration-nag.d.ts +49 -0
- package/dist/src/lib/migration-nag.js +163 -0
- package/dist/src/lib/migration-nag.js.map +1 -0
- package/dist/src/lib/object-status-filter.d.ts +22 -0
- package/dist/src/lib/object-status-filter.js +45 -0
- package/dist/src/lib/object-status-filter.js.map +1 -0
- package/dist/src/lib/output.d.ts +124 -0
- package/dist/src/lib/output.js +219 -8
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/package-manager.d.ts +140 -0
- package/dist/src/lib/package-manager.js +305 -0
- package/dist/src/lib/package-manager.js.map +1 -0
- package/dist/src/lib/paginate.d.ts +98 -0
- package/dist/src/lib/paginate.js +112 -0
- package/dist/src/lib/paginate.js.map +1 -0
- package/dist/src/lib/platform-owned.d.ts +63 -0
- package/dist/src/lib/platform-owned.js +85 -0
- package/dist/src/lib/platform-owned.js.map +1 -0
- package/dist/src/lib/project-config.d.ts +122 -0
- package/dist/src/lib/project-config.js +244 -0
- package/dist/src/lib/project-config.js.map +1 -0
- 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/query-operators.d.ts +43 -0
- package/dist/src/lib/query-operators.js +80 -0
- package/dist/src/lib/query-operators.js.map +1 -0
- package/dist/src/lib/record-filter.d.ts +18 -0
- package/dist/src/lib/record-filter.js +55 -0
- package/dist/src/lib/record-filter.js.map +1 -0
- package/dist/src/lib/refresh-admin-credentials.d.ts +73 -0
- package/dist/src/lib/refresh-admin-credentials.js +123 -0
- package/dist/src/lib/refresh-admin-credentials.js.map +1 -0
- package/dist/src/lib/resolve-init-dev-port.d.ts +56 -0
- package/dist/src/lib/resolve-init-dev-port.js +55 -0
- package/dist/src/lib/resolve-init-dev-port.js.map +1 -0
- package/dist/src/lib/resolve-init-server.d.ts +64 -0
- package/dist/src/lib/resolve-init-server.js +77 -0
- package/dist/src/lib/resolve-init-server.js.map +1 -0
- 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/resolve-platform.d.ts +74 -0
- package/dist/src/lib/resolve-platform.js +105 -0
- package/dist/src/lib/resolve-platform.js.map +1 -0
- package/dist/src/lib/run-status.d.ts +19 -0
- package/dist/src/lib/run-status.generated.d.ts +39 -0
- package/dist/src/lib/run-status.generated.js +66 -0
- package/dist/src/lib/run-status.generated.js.map +1 -0
- package/dist/src/lib/run-status.js +19 -0
- package/dist/src/lib/run-status.js.map +1 -0
- package/dist/src/lib/server-text-normalization.d.ts +51 -0
- package/dist/src/lib/server-text-normalization.js +90 -0
- package/dist/src/lib/server-text-normalization.js.map +1 -0
- package/dist/src/lib/server-url.d.ts +22 -0
- package/dist/src/lib/server-url.js +33 -0
- package/dist/src/lib/server-url.js.map +1 -0
- package/dist/src/lib/signing-secret-status.d.ts +81 -0
- package/dist/src/lib/signing-secret-status.js +116 -0
- package/dist/src/lib/signing-secret-status.js.map +1 -0
- package/dist/src/lib/skill-installer.d.ts +71 -0
- package/dist/src/lib/skill-installer.js +441 -0
- package/dist/src/lib/skill-installer.js.map +1 -0
- 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 +98 -0
- package/dist/src/lib/snapshots.js +294 -0
- package/dist/src/lib/snapshots.js.map +1 -0
- 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.d.ts +113 -0
- package/dist/src/lib/swift-codegen/dbGenerator.js +926 -0
- package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -0
- package/dist/src/lib/swift-codegen/dbSwiftTypes.d.ts +42 -0
- package/dist/src/lib/swift-codegen/dbSwiftTypes.js +100 -0
- package/dist/src/lib/swift-codegen/dbSwiftTypes.js.map +1 -0
- 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 +100 -0
- package/dist/src/lib/swift-codegen/generator.js +457 -0
- package/dist/src/lib/swift-codegen/generator.js.map +1 -0
- package/dist/src/lib/swift-codegen/schemaToSwift.d.ts +87 -0
- package/dist/src/lib/swift-codegen/schemaToSwift.js +661 -0
- package/dist/src/lib/swift-codegen/schemaToSwift.js.map +1 -0
- package/dist/src/lib/swift-codegen/siblingSymbols.d.ts +94 -0
- package/dist/src/lib/swift-codegen/siblingSymbols.js +155 -0
- package/dist/src/lib/swift-codegen/siblingSymbols.js.map +1 -0
- package/dist/src/lib/swift-codegen/swiftNaming.d.ts +85 -0
- package/dist/src/lib/swift-codegen/swiftNaming.js +198 -0
- package/dist/src/lib/swift-codegen/swiftNaming.js.map +1 -0
- package/dist/src/lib/sync-dir-selector.d.ts +21 -0
- package/dist/src/lib/sync-dir-selector.js +30 -0
- package/dist/src/lib/sync-dir-selector.js.map +1 -0
- package/dist/src/lib/sync-paths.d.ts +128 -0
- package/dist/src/lib/sync-paths.js +195 -0
- package/dist/src/lib/sync-paths.js.map +1 -0
- package/dist/src/lib/sync-resource-types.d.ts +563 -0
- package/dist/src/lib/sync-resource-types.js +1073 -0
- package/dist/src/lib/sync-resource-types.js.map +1 -0
- package/dist/src/lib/sync-selectors.d.ts +138 -0
- package/dist/src/lib/sync-selectors.js +289 -0
- package/dist/src/lib/sync-selectors.js.map +1 -0
- package/dist/src/lib/template.d.ts +170 -0
- package/dist/src/lib/template.js +484 -68
- package/dist/src/lib/template.js.map +1 -1
- package/dist/src/lib/test-case-file-names.d.ts +40 -0
- package/dist/src/lib/test-case-file-names.js +91 -0
- package/dist/src/lib/test-case-file-names.js.map +1 -0
- package/dist/src/lib/test-case-keys.d.ts +29 -0
- package/dist/src/lib/test-case-keys.js +55 -0
- package/dist/src/lib/test-case-keys.js.map +1 -0
- package/dist/src/lib/test-case-variables.d.ts +29 -0
- package/dist/src/lib/test-case-variables.js +71 -0
- package/dist/src/lib/test-case-variables.js.map +1 -0
- package/dist/src/lib/token-inject.d.ts +56 -0
- package/dist/src/lib/token-inject.js +204 -0
- package/dist/src/lib/token-inject.js.map +1 -0
- package/dist/src/lib/toml-database-config.d.ts +123 -0
- package/dist/src/lib/toml-database-config.js +544 -0
- package/dist/src/lib/toml-database-config.js.map +1 -0
- package/dist/src/lib/toml-metadata-config.d.ts +151 -0
- package/dist/src/lib/toml-metadata-config.js +476 -0
- package/dist/src/lib/toml-metadata-config.js.map +1 -0
- package/dist/src/lib/toml-native-form.d.ts +46 -0
- package/dist/src/lib/toml-native-form.js +78 -0
- package/dist/src/lib/toml-native-form.js.map +1 -0
- package/dist/src/lib/toml-params-validator.d.ts +129 -0
- package/dist/src/lib/toml-params-validator.js +298 -0
- package/dist/src/lib/toml-params-validator.js.map +1 -0
- package/dist/src/lib/toml-scalar-edit.d.ts +43 -0
- package/dist/src/lib/toml-scalar-edit.js +283 -0
- package/dist/src/lib/toml-scalar-edit.js.map +1 -0
- package/dist/src/lib/user-selector.d.ts +24 -0
- package/dist/src/lib/user-selector.js +33 -0
- package/dist/src/lib/user-selector.js.map +1 -0
- package/dist/src/lib/version-check.d.ts +35 -0
- package/dist/src/lib/version-check.js +241 -0
- package/dist/src/lib/version-check.js.map +1 -0
- package/dist/src/lib/watch.d.ts +121 -0
- package/dist/src/lib/watch.js +169 -0
- package/dist/src/lib/watch.js.map +1 -0
- package/dist/src/lib/web-url.d.ts +40 -0
- package/dist/src/lib/web-url.js +76 -0
- package/dist/src/lib/web-url.js.map +1 -0
- 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-apply.d.ts +110 -0
- package/dist/src/lib/workflow-apply.js +164 -0
- package/dist/src/lib/workflow-apply.js.map +1 -0
- package/dist/src/lib/workflow-codegen/generated-schema-descriptor.d.ts +129 -0
- package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js +269 -0
- package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js.map +1 -0
- package/dist/src/lib/workflow-codegen/generator.d.ts +96 -0
- package/dist/src/lib/workflow-codegen/generator.js +361 -0
- package/dist/src/lib/workflow-codegen/generator.js.map +1 -0
- package/dist/src/lib/workflow-codegen/invokerIR.d.ts +94 -0
- package/dist/src/lib/workflow-codegen/invokerIR.js +76 -0
- package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -0
- package/dist/src/lib/workflow-codegen/naming.d.ts +33 -0
- package/dist/src/lib/workflow-codegen/naming.js +81 -0
- package/dist/src/lib/workflow-codegen/naming.js.map +1 -0
- package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +80 -0
- package/dist/src/lib/workflow-codegen/schemaToTs.js +303 -0
- package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -0
- package/dist/src/lib/workflow-config-apply.d.ts +70 -0
- package/dist/src/lib/workflow-config-apply.js +137 -0
- package/dist/src/lib/workflow-config-apply.js.map +1 -0
- package/dist/src/lib/workflow-config-sidecar.d.ts +63 -0
- package/dist/src/lib/workflow-config-sidecar.js +96 -0
- package/dist/src/lib/workflow-config-sidecar.js.map +1 -0
- package/dist/src/lib/workflow-defaults.d.ts +29 -0
- package/dist/src/lib/workflow-defaults.js +41 -0
- package/dist/src/lib/workflow-defaults.js.map +1 -0
- package/dist/src/lib/workflow-fragments.d.ts +64 -0
- package/dist/src/lib/workflow-fragments.js +342 -0
- package/dist/src/lib/workflow-fragments.js.map +1 -0
- package/dist/src/lib/workflow-include-preserve.d.ts +76 -0
- package/dist/src/lib/workflow-include-preserve.js +286 -0
- package/dist/src/lib/workflow-include-preserve.js.map +1 -0
- package/dist/src/lib/workflow-payload.d.ts +98 -0
- package/dist/src/lib/workflow-payload.js +178 -0
- package/dist/src/lib/workflow-payload.js.map +1 -0
- package/dist/src/lib/workflow-toml-validator.d.ts +211 -0
- package/dist/src/lib/workflow-toml-validator.js +770 -0
- package/dist/src/lib/workflow-toml-validator.js.map +1 -0
- 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 +591 -0
- package/dist/src/validators.d.ts +65 -0
- package/dist/src/validators.js +64 -0
- package/dist/src/validators.js.map +1 -0
- package/package.json +34 -9
|
@@ -1,22 +1,1633 @@
|
|
|
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, formatDate, json, } from "../lib/output.js";
|
|
6
|
+
import { success, error, info, formatTable, formatId, formatDate, json, keyValue, result as printResult, warn, flushOutput, } from "../lib/output.js";
|
|
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";
|
|
11
|
+
import { parseDataOption } from "../lib/data-input.js";
|
|
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";
|
|
22
|
+
import { ulid } from "ulid";
|
|
23
|
+
import * as fs from "fs";
|
|
24
|
+
import * as path from "path";
|
|
25
|
+
/**
|
|
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.
|
|
32
|
+
*/
|
|
33
|
+
function printIngestSession(session) {
|
|
34
|
+
for (const [label, value] of ingestSessionRows(session)) {
|
|
35
|
+
printResult(label, value);
|
|
36
|
+
}
|
|
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(", ") : "-";
|
|
173
|
+
}
|
|
4
174
|
export function registerDocumentsCommands(program) {
|
|
5
175
|
const documents = program
|
|
6
176
|
.command("documents")
|
|
7
177
|
.description("Manage documents within an app")
|
|
8
178
|
.addHelpText("after", `
|
|
9
179
|
Examples:
|
|
180
|
+
$ primitive documents list --user-id <user-id>
|
|
181
|
+
$ primitive documents get <document-id>
|
|
182
|
+
$ primitive documents create "My Doc"
|
|
183
|
+
$ primitive documents create "My Doc" --owner user@example.com
|
|
184
|
+
$ primitive documents delete <document-id> -y
|
|
185
|
+
$ primitive documents permissions list <document-id>
|
|
186
|
+
$ primitive documents permissions grant <document-id> --user-id <user-id> --permission reader
|
|
187
|
+
$ primitive documents permissions revoke <document-id> <user-id>
|
|
188
|
+
$ primitive documents records models <document-id>
|
|
189
|
+
$ primitive documents records describe <document-id> <model-name>
|
|
190
|
+
$ primitive documents records query <document-id> <model-name> --filter '{"status":"open"}'
|
|
191
|
+
$ primitive documents records count <document-id> <model-name>
|
|
192
|
+
$ primitive documents records save <document-id> <model-name> --data '{"qty":10}'
|
|
193
|
+
$ primitive documents records patch <document-id> <model-name> <record-id> --data '{"qty":11}'
|
|
194
|
+
$ primitive documents records delete <document-id> <model-name> <record-id> -y
|
|
195
|
+
$ primitive documents records aggregate <document-id> <model-name> --op avg --field price
|
|
196
|
+
$ primitive documents records bulk <document-id> --data-file ops.json -y
|
|
197
|
+
$ primitive documents dump <document-id>
|
|
198
|
+
$ primitive documents stats <document-id>
|
|
10
199
|
$ primitive documents transfer-owner <document-id> <new-owner-id>
|
|
11
200
|
$ primitive documents group-permissions list <document-id>
|
|
12
201
|
`);
|
|
202
|
+
// ---- List a user's documents ----
|
|
203
|
+
// No app-wide enumeration: `--user-id` is required (the admin token has no
|
|
204
|
+
// "self"). Reads the per-user permission partition via the admin endpoint.
|
|
205
|
+
documents
|
|
206
|
+
.command("list")
|
|
207
|
+
.description("List a user's documents")
|
|
208
|
+
.requiredOption("--user-id <id>", "User ID whose documents to list")
|
|
209
|
+
.option("--app <app-id>", "App ID")
|
|
210
|
+
.addOption(pageLimitOption())
|
|
211
|
+
.addOption(pageCursorOption())
|
|
212
|
+
.option("--json", "Output as JSON")
|
|
213
|
+
.action(async (options) => {
|
|
214
|
+
const limit = parsePageLimit(options.limit);
|
|
215
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
216
|
+
const client = new ApiClient();
|
|
217
|
+
try {
|
|
218
|
+
const page = normalizeCliListEnvelope(await client.listAdminDocumentsPage(resolvedAppId, options.userId, {
|
|
219
|
+
limit,
|
|
220
|
+
cursor: options.cursor,
|
|
221
|
+
}));
|
|
222
|
+
const list = page.items;
|
|
223
|
+
if (options.json) {
|
|
224
|
+
json(page);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (list.length === 0) {
|
|
228
|
+
printEmptyPage(page, "documents");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
console.log(formatTable(list, [
|
|
232
|
+
{ header: "DOCUMENT_ID", key: "documentId", format: formatId },
|
|
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 },
|
|
238
|
+
{ header: "PERMISSION", key: "permission" },
|
|
239
|
+
{ header: "GRANTED", key: "grantedAt", format: formatDate },
|
|
240
|
+
]));
|
|
241
|
+
printPageHint(page);
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
error(err.message);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
// ---- Get a single document's metadata ----
|
|
249
|
+
documents
|
|
250
|
+
.command("get")
|
|
251
|
+
.description("Show a document's metadata and the caller's access to it")
|
|
252
|
+
.argument("<document-id>", "Document ID")
|
|
253
|
+
.option("--app <app-id>", "App ID")
|
|
254
|
+
.option("--json", "Output as JSON")
|
|
255
|
+
.action(async (documentId, options) => {
|
|
256
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
257
|
+
const client = new ApiClient();
|
|
258
|
+
try {
|
|
259
|
+
const doc = await client.getDocument(resolvedAppId, documentId);
|
|
260
|
+
if (options.json) {
|
|
261
|
+
json(doc);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
keyValue("Document ID", doc.documentId);
|
|
265
|
+
keyValue("Title", doc.title);
|
|
266
|
+
keyValue("Created By", doc.createdBy);
|
|
267
|
+
keyValue("Created", formatDate(doc.createdAt));
|
|
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(", "));
|
|
279
|
+
// `GET documents/:id` reports the caller's own access, not the
|
|
280
|
+
// document's grant/alias/collection inventory — printing counts for
|
|
281
|
+
// fields the endpoint never returns read as a definitive "0" for
|
|
282
|
+
// documents that demonstrably have them. Use
|
|
283
|
+
// `documents permissions list` for the grant list.
|
|
284
|
+
if (doc.permission)
|
|
285
|
+
keyValue("Your Permission", doc.permission);
|
|
286
|
+
if (doc.accessSource)
|
|
287
|
+
keyValue("Access Source", doc.accessSource);
|
|
288
|
+
if (doc.linkAccess)
|
|
289
|
+
keyValue("Link Access", doc.linkAccess);
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
error(err.message);
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
// ---- Create a document ----
|
|
297
|
+
// The same `POST /documents` every client uses (#2763). Who ends up owning
|
|
298
|
+
// the document is the server's call, decided from the token's identity —
|
|
299
|
+
// the help below states that matrix rather than the CLI guessing at a role.
|
|
300
|
+
documents
|
|
301
|
+
.command("create")
|
|
302
|
+
.description("Create a document")
|
|
303
|
+
.argument("<title>", "Document title")
|
|
304
|
+
.option("--app <app-id>", "App ID")
|
|
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")
|
|
307
|
+
.option("--json", "Output as JSON")
|
|
308
|
+
.addHelpText("after", `
|
|
309
|
+
Ownership:
|
|
310
|
+
An app-user token — member, admin, or owner — always creates the document
|
|
311
|
+
owned by the caller: the server ignores --owner for those tokens.
|
|
312
|
+
A super-admin token, or a console-admin token assigned to this app, acts
|
|
313
|
+
through an admin shadow app user — without --owner that shadow user owns the
|
|
314
|
+
document, with --owner the named user does.
|
|
315
|
+
A console admin not assigned to this app has no access to the app at all.
|
|
316
|
+
|
|
317
|
+
An email given to --owner is resolved to a user id before anything is
|
|
318
|
+
created; a user who is not in the app fails the command without creating a
|
|
319
|
+
document.
|
|
320
|
+
`)
|
|
321
|
+
.action(async (title, options) => {
|
|
322
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
323
|
+
const client = new ApiClient();
|
|
324
|
+
let ownerUserId;
|
|
325
|
+
if (options.owner) {
|
|
326
|
+
try {
|
|
327
|
+
ownerUserId = await resolveOwnerUserId(client, resolvedAppId, options.owner);
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
error(err.message);
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
const result = await client.createDocument(resolvedAppId, {
|
|
336
|
+
title,
|
|
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 } : {}),
|
|
341
|
+
});
|
|
342
|
+
if (options.json) {
|
|
343
|
+
json(result);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
success("Document created.");
|
|
347
|
+
keyValue("Document ID", result.documentId);
|
|
348
|
+
keyValue("Title", result.title);
|
|
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);
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
error(err.message);
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
// ---- Delete a document ----
|
|
360
|
+
// The runtime-resource delete verb (#2756): the server endpoint cascades
|
|
361
|
+
// through records, Yjs history, blobs, aliases and permissions, so an
|
|
362
|
+
// operator resetting an app's data no longer needs a one-off SDK script.
|
|
363
|
+
// Who may run it is the server's decision, so the help below states that
|
|
364
|
+
// model (#2763) rather than leaving it to be discovered from a 403.
|
|
365
|
+
documents
|
|
366
|
+
.command("delete")
|
|
367
|
+
.description("Delete a document and all its data (records, history, blobs, aliases, permissions)")
|
|
368
|
+
.argument("<document-id>", "Document ID")
|
|
369
|
+
.option("--app <app-id>", "App ID")
|
|
370
|
+
.option("-y, --yes", "Skip confirmation prompt")
|
|
371
|
+
.option("--json", "Output as JSON")
|
|
372
|
+
.addHelpText("after", `
|
|
373
|
+
Permissions (enforced by the server, not the CLI):
|
|
374
|
+
The document's owner, the app owner, and super-admin or assigned-console-admin
|
|
375
|
+
tokens — which act with app-owner authority — delete directly.
|
|
376
|
+
Everyone else, including app-role admins, can delete only when a containing
|
|
377
|
+
collection's document.delete rule allows it: an app admin cannot delete a
|
|
378
|
+
standalone document (one in no collection) they do not own.
|
|
379
|
+
The app's root document cannot be deleted.
|
|
380
|
+
`)
|
|
381
|
+
.action(async (documentId, options) => {
|
|
382
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
383
|
+
if (!options.yes) {
|
|
384
|
+
let confirm;
|
|
385
|
+
try {
|
|
386
|
+
confirm = await confirmPrompt(`Delete document ${documentId}? This permanently deletes its records, ` +
|
|
387
|
+
`update history, blobs, aliases, and permissions. This cannot be undone.`);
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
error(err.message);
|
|
391
|
+
process.exit(1);
|
|
392
|
+
}
|
|
393
|
+
if (!confirm) {
|
|
394
|
+
info("Cancelled.");
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const client = new ApiClient();
|
|
399
|
+
try {
|
|
400
|
+
const result = await client.deleteDocument(resolvedAppId, documentId);
|
|
401
|
+
if (options.json) {
|
|
402
|
+
json(result);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
success(`Document ${documentId} deleted.`);
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
// Server refusals (404 not found, 403 root document / owner-only, the
|
|
409
|
+
// non-owner fail-closed 503) are printed verbatim, never swallowed.
|
|
410
|
+
error(err.message);
|
|
411
|
+
process.exit(1);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
// ---- User-level permissions subcommand group ----
|
|
415
|
+
const permissions = documents
|
|
416
|
+
.command("permissions")
|
|
417
|
+
.description("Manage user-level permissions on a document");
|
|
418
|
+
// List user-level permissions
|
|
419
|
+
permissions
|
|
420
|
+
.command("list")
|
|
421
|
+
.description("List user-level permissions for a document")
|
|
422
|
+
.argument("<document-id>", "Document ID")
|
|
423
|
+
.option("--app <app-id>", "App ID")
|
|
424
|
+
.option("--json", "Output as JSON")
|
|
425
|
+
.action(async (documentId, options) => {
|
|
426
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
427
|
+
const client = new ApiClient();
|
|
428
|
+
try {
|
|
429
|
+
const result = await client.listDocumentPermissions(resolvedAppId, documentId);
|
|
430
|
+
const list = Array.isArray(result) ? result : result?.permissions ?? [];
|
|
431
|
+
if (options.json) {
|
|
432
|
+
json(wholeListEnvelope(list));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (list.length === 0) {
|
|
436
|
+
info("No permissions found.");
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
console.log(formatTable(list, [
|
|
440
|
+
{ header: "USER_ID", key: "userId", format: formatId },
|
|
441
|
+
{ header: "EMAIL", key: "email", flex: true },
|
|
442
|
+
{ header: "PERMISSION", key: "permission" },
|
|
443
|
+
{ header: "GRANTED", key: "grantedAt", format: formatDate },
|
|
444
|
+
]));
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
error(err.message);
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
// Grant (or update) a user-level permission
|
|
452
|
+
permissions
|
|
453
|
+
.command("grant")
|
|
454
|
+
.description("Grant or update a user's permission on a document")
|
|
455
|
+
.argument("<document-id>", "Document ID")
|
|
456
|
+
.option("--user-id <id>", "User ID to grant the permission to")
|
|
457
|
+
.option("--email <email>", "Email of the user to grant the permission to")
|
|
458
|
+
.requiredOption("--permission <permission>", "Permission level: reader or read-write")
|
|
459
|
+
.option("--app <app-id>", "App ID")
|
|
460
|
+
.option("--json", "Output as JSON")
|
|
461
|
+
.action(async (documentId, options) => {
|
|
462
|
+
if (!options.userId && !options.email) {
|
|
463
|
+
error("Either --user-id or --email is required.");
|
|
464
|
+
process.exit(1);
|
|
465
|
+
}
|
|
466
|
+
if (options.userId && options.email) {
|
|
467
|
+
error("Provide only one of --user-id or --email, not both.");
|
|
468
|
+
process.exit(1);
|
|
469
|
+
}
|
|
470
|
+
if (!["reader", "read-write"].includes(options.permission)) {
|
|
471
|
+
error("Invalid --permission. Must be 'reader' or 'read-write'.");
|
|
472
|
+
process.exit(1);
|
|
473
|
+
}
|
|
474
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
475
|
+
const client = new ApiClient();
|
|
476
|
+
try {
|
|
477
|
+
const entry = options.userId
|
|
478
|
+
? { userId: options.userId, permission: options.permission }
|
|
479
|
+
: { email: options.email, permission: options.permission };
|
|
480
|
+
const result = await client.grantDocumentPermission(resolvedAppId, documentId, [entry]);
|
|
481
|
+
if (options.json) {
|
|
482
|
+
json(result);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const target = options.userId ? options.userId : options.email;
|
|
486
|
+
success(`Permission '${options.permission}' granted to ${target}.`);
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
error(err.message);
|
|
490
|
+
process.exit(1);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
// Revoke a user-level permission
|
|
494
|
+
permissions
|
|
495
|
+
.command("revoke")
|
|
496
|
+
.description("Revoke a user's permission on a document")
|
|
497
|
+
.argument("<document-id>", "Document ID")
|
|
498
|
+
.argument("[user-id]", "User ID to revoke (or use --email)")
|
|
499
|
+
.option("--email <email>", "Email of the user to revoke (alternative to <user-id>)")
|
|
500
|
+
.option("--app <app-id>", "App ID")
|
|
501
|
+
.option("-y, --yes", "Skip confirmation prompt")
|
|
502
|
+
.action(async (documentId, userId, options) => {
|
|
503
|
+
if (!userId && !options.email) {
|
|
504
|
+
error("Either a <user-id> argument or --email is required.");
|
|
505
|
+
process.exit(1);
|
|
506
|
+
}
|
|
507
|
+
if (userId && options.email) {
|
|
508
|
+
error("Provide only one of <user-id> or --email, not both.");
|
|
509
|
+
process.exit(1);
|
|
510
|
+
}
|
|
511
|
+
const target = userId ?? options.email;
|
|
512
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
513
|
+
if (!options.yes) {
|
|
514
|
+
let confirm;
|
|
515
|
+
try {
|
|
516
|
+
confirm = await confirmPrompt(`Revoke permission for ${target} on document ${documentId}?`);
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
error(err.message);
|
|
520
|
+
process.exit(1);
|
|
521
|
+
}
|
|
522
|
+
if (!confirm) {
|
|
523
|
+
info("Cancelled.");
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const client = new ApiClient();
|
|
528
|
+
try {
|
|
529
|
+
await client.revokeDocumentPermission(resolvedAppId, documentId, {
|
|
530
|
+
userId,
|
|
531
|
+
email: options.email,
|
|
532
|
+
});
|
|
533
|
+
success(`Permission revoked for ${target}.`);
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
error(err.message);
|
|
537
|
+
process.exit(1);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
// ---- Records introspection subcommand group ----
|
|
541
|
+
const records = documents
|
|
542
|
+
.command("records")
|
|
543
|
+
.description("Inspect a document's models and records");
|
|
544
|
+
// List model names
|
|
545
|
+
records
|
|
546
|
+
.command("models")
|
|
547
|
+
.description("List model names in a document")
|
|
548
|
+
.argument("<document-id>", "Document ID")
|
|
549
|
+
.option("--app <app-id>", "App ID")
|
|
550
|
+
.option("--json", "Output as JSON")
|
|
551
|
+
.action(async (documentId, options) => {
|
|
552
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
553
|
+
const client = new ApiClient();
|
|
554
|
+
try {
|
|
555
|
+
const result = await client.getDocumentSchema(resolvedAppId, documentId);
|
|
556
|
+
const models = result?.modelNames ?? Object.keys(result?.schema?.models ?? {});
|
|
557
|
+
if (options.json) {
|
|
558
|
+
json(models);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (models.length === 0) {
|
|
562
|
+
info("No models found.");
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
for (const m of models) {
|
|
566
|
+
console.log(` ${m}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
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));
|
|
573
|
+
process.exit(1);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
// Describe a model's fields and indexes
|
|
577
|
+
records
|
|
578
|
+
.command("describe")
|
|
579
|
+
.description("Show a model's fields and indexes")
|
|
580
|
+
.argument("<document-id>", "Document ID")
|
|
581
|
+
.argument("<model-name>", "Model name")
|
|
582
|
+
.option("--app <app-id>", "App ID")
|
|
583
|
+
.option("--json", "Output as JSON")
|
|
584
|
+
.action(async (documentId, modelName, options) => {
|
|
585
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
586
|
+
const client = new ApiClient();
|
|
587
|
+
try {
|
|
588
|
+
const result = await client.getDocumentSchema(resolvedAppId, documentId);
|
|
589
|
+
const model = result?.schema?.models?.[modelName];
|
|
590
|
+
if (!model) {
|
|
591
|
+
if (options.json) {
|
|
592
|
+
json(null);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
info(`Model '${modelName}' not found in this document.`);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const fieldsObj = model.fields ?? {};
|
|
599
|
+
const rows = Object.entries(fieldsObj).map(([name, meta]) => ({
|
|
600
|
+
field: name,
|
|
601
|
+
type: meta?.type ?? "unknown",
|
|
602
|
+
indexed: meta?.indexed ? "yes" : "",
|
|
603
|
+
unique: meta?.unique ? "yes" : "",
|
|
604
|
+
required: meta?.required ? "yes" : "",
|
|
605
|
+
}));
|
|
606
|
+
if (options.json) {
|
|
607
|
+
json({ fields: fieldsObj, constraints: model.constraints ?? {} });
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (rows.length === 0) {
|
|
611
|
+
info("No fields detected for this model.");
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
console.log(formatTable(rows, [
|
|
615
|
+
{ header: "FIELD", key: "field", flex: true },
|
|
616
|
+
{ header: "TYPE", key: "type" },
|
|
617
|
+
{ header: "INDEXED", key: "indexed" },
|
|
618
|
+
{ header: "UNIQUE", key: "unique" },
|
|
619
|
+
{ header: "REQUIRED", key: "required" },
|
|
620
|
+
]));
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
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));
|
|
626
|
+
process.exit(1);
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
// Query records in a document model
|
|
630
|
+
records
|
|
631
|
+
.command("query")
|
|
632
|
+
.description("Query records in a document model")
|
|
633
|
+
.argument("<document-id>", "Document ID")
|
|
634
|
+
.argument("<model-name>", "Model name to query")
|
|
635
|
+
.option("--app <app-id>", "App ID")
|
|
636
|
+
.option("--filter <json>", "Filter as JSON (e.g. '{\"status\":\"open\"}')")
|
|
637
|
+
.option("--filter-file <path>", "Read filter from a JSON or TOML file")
|
|
638
|
+
.option("--limit <n>", "Maximum number of records to return (max 100)", parseInt)
|
|
639
|
+
.option("--cursor <cursor>", "Pagination cursor from a previous query")
|
|
640
|
+
.option(DOCUMENT_FORMAT_FLAG, DOCUMENT_FORMAT_HELP)
|
|
641
|
+
.option("--json", "Output as JSON")
|
|
642
|
+
.action(async (documentId, modelName, options) => {
|
|
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
|
+
}
|
|
654
|
+
const filter = parseFilterOptions(options);
|
|
655
|
+
const client = new ApiClient();
|
|
656
|
+
try {
|
|
657
|
+
const result = await client.queryDocumentRecords(resolvedAppId, documentId, modelName, { filter, limit: options.limit, cursor: options.cursor, documentFormat });
|
|
658
|
+
if (options.json) {
|
|
659
|
+
json(result);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const items = result.items;
|
|
663
|
+
if (items.length === 0) {
|
|
664
|
+
info("No records found.");
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const allKeys = new Set();
|
|
668
|
+
for (const rec of items) {
|
|
669
|
+
for (const key of Object.keys(rec))
|
|
670
|
+
allKeys.add(key);
|
|
671
|
+
}
|
|
672
|
+
const orderedKeys = [];
|
|
673
|
+
if (allKeys.has("id")) {
|
|
674
|
+
orderedKeys.push("id");
|
|
675
|
+
allKeys.delete("id");
|
|
676
|
+
}
|
|
677
|
+
orderedKeys.push(...[...allKeys].sort());
|
|
678
|
+
const columns = orderedKeys.map((key) => ({
|
|
679
|
+
header: key.toUpperCase(),
|
|
680
|
+
key,
|
|
681
|
+
format: (v) => {
|
|
682
|
+
if (v === null || v === undefined)
|
|
683
|
+
return "—";
|
|
684
|
+
if (typeof v === "object")
|
|
685
|
+
return JSON.stringify(v);
|
|
686
|
+
return String(v);
|
|
687
|
+
},
|
|
688
|
+
}));
|
|
689
|
+
console.log(formatTable(items, columns));
|
|
690
|
+
if (result.nextCursor) {
|
|
691
|
+
info(`More results available. Use --cursor ${result.nextCursor}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
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));
|
|
698
|
+
process.exit(1);
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
// Get a single record by id (mirrors `databases records get`, issue #2357).
|
|
702
|
+
records
|
|
703
|
+
.command("get")
|
|
704
|
+
.description("Get a single record by id from a document model")
|
|
705
|
+
.argument("<document-id>", "Document ID")
|
|
706
|
+
.argument("<model-name>", "Model name")
|
|
707
|
+
.argument("<record-id>", "Record ID")
|
|
708
|
+
.option("--app <app-id>", "App ID")
|
|
709
|
+
.option(DOCUMENT_FORMAT_FLAG, DOCUMENT_FORMAT_HELP)
|
|
710
|
+
.option("--json", "Output as JSON")
|
|
711
|
+
.action(async (documentId, modelName, recordId, options) => {
|
|
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
|
+
}
|
|
723
|
+
const client = new ApiClient();
|
|
724
|
+
try {
|
|
725
|
+
// A single-record fetch is a query filtered by primary-key id (there is
|
|
726
|
+
// no dedicated get endpoint — reuse queryDocumentRecords, exactly as
|
|
727
|
+
// the `databases records get` twin reuses queryDatabaseRecords).
|
|
728
|
+
const result = await client.queryDocumentRecords(resolvedAppId, documentId, modelName, { filter: { id: recordId }, limit: 1, documentFormat });
|
|
729
|
+
const record = result.items[0];
|
|
730
|
+
if (options.json) {
|
|
731
|
+
json(record ?? null);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (!record) {
|
|
735
|
+
info(`No record found with id ${recordId} in ${modelName}.`);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
// `get` is a data command, so its fields go to stdout via `result()`
|
|
739
|
+
// — the #711 split (`result()` = the data the caller asked for,
|
|
740
|
+
// `keyValue()` = a post-action diagnostic summary). Issue #2437; the
|
|
741
|
+
// databases twin renders identically.
|
|
742
|
+
for (const [key, value] of Object.entries(record)) {
|
|
743
|
+
const rendered = value === null || value === undefined
|
|
744
|
+
? "—"
|
|
745
|
+
: typeof value === "object"
|
|
746
|
+
? JSON.stringify(value)
|
|
747
|
+
: String(value);
|
|
748
|
+
printResult(key, rendered);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
catch (err) {
|
|
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));
|
|
755
|
+
process.exit(1);
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
// Count records in a document model
|
|
759
|
+
records
|
|
760
|
+
.command("count")
|
|
761
|
+
.description("Count records in a document model")
|
|
762
|
+
.argument("<document-id>", "Document ID")
|
|
763
|
+
.argument("<model-name>", "Model name to count")
|
|
764
|
+
.option("--app <app-id>", "App ID")
|
|
765
|
+
.option("--filter <json>", "Filter as JSON (e.g. '{\"status\":\"open\"}')")
|
|
766
|
+
.option(DOCUMENT_FORMAT_FLAG, DOCUMENT_FORMAT_HELP)
|
|
767
|
+
.option("--filter-file <path>", "Read filter from a JSON or TOML file")
|
|
768
|
+
.option("--json", "Output as JSON")
|
|
769
|
+
.action(async (documentId, modelName, options) => {
|
|
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
|
+
}
|
|
781
|
+
const filter = parseFilterOptions(options);
|
|
782
|
+
const client = new ApiClient();
|
|
783
|
+
try {
|
|
784
|
+
const result = await client.countDocumentRecords(resolvedAppId, documentId, modelName, { filter, documentFormat });
|
|
785
|
+
if (options.json) {
|
|
786
|
+
json(result);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
console.log(` ${result.count}`);
|
|
790
|
+
}
|
|
791
|
+
catch (err) {
|
|
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));
|
|
795
|
+
process.exit(1);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
// Aggregate records in a document model (issue #2437). Mirrors
|
|
799
|
+
// `databases records aggregate` flag for flag and rendering for rendering —
|
|
800
|
+
// including the deliberate `keyValue()` for the ungrouped scalar, so the two
|
|
801
|
+
// twins keep printing the same thing.
|
|
802
|
+
const AGGREGATE_OPS = ["count", "sum", "avg", "min", "max"];
|
|
803
|
+
records
|
|
804
|
+
.command("aggregate")
|
|
805
|
+
.description("Aggregate records in a document model (count/sum/avg/min/max, optional group-by)")
|
|
806
|
+
.argument("<document-id>", "Document ID")
|
|
807
|
+
.argument("<model-name>", "Model name")
|
|
808
|
+
.option("--app <app-id>", "App ID")
|
|
809
|
+
.requiredOption("--op <operation>", `Aggregate operation: ${AGGREGATE_OPS.join("|")}`)
|
|
810
|
+
.option("--field <field>", "Field to aggregate (required for sum/avg/min/max)")
|
|
811
|
+
.option("--group-by <field>", "Group results by this plain field (repeatable; StringSet fields are not supported)", (value, previous = []) => previous.concat(value), [])
|
|
812
|
+
.option("--filter <json>", "Filter as JSON")
|
|
813
|
+
.option("--filter-file <path>", "Read filter from a JSON or TOML file")
|
|
814
|
+
.option(DOCUMENT_FORMAT_FLAG, DOCUMENT_FORMAT_HELP)
|
|
815
|
+
.option("--json", "Output as JSON")
|
|
816
|
+
.action(async (documentId, modelName, options) => {
|
|
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
|
+
}
|
|
828
|
+
if (!AGGREGATE_OPS.includes(options.op)) {
|
|
829
|
+
error(`Invalid --op "${options.op}". Expected one of: ${AGGREGATE_OPS.join(", ")}.`);
|
|
830
|
+
process.exit(1);
|
|
831
|
+
}
|
|
832
|
+
const operation = { type: options.op };
|
|
833
|
+
if (options.op === "count") {
|
|
834
|
+
if (options.field) {
|
|
835
|
+
error("--field is not valid for --op count.");
|
|
836
|
+
process.exit(1);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
else {
|
|
840
|
+
if (!options.field) {
|
|
841
|
+
error(`--op ${options.op} requires --field.`);
|
|
842
|
+
process.exit(1);
|
|
843
|
+
}
|
|
844
|
+
operation.field = options.field;
|
|
845
|
+
}
|
|
846
|
+
const filter = parseFilterOptions(options);
|
|
847
|
+
const groupBy = options.groupBy || [];
|
|
848
|
+
const client = new ApiClient();
|
|
849
|
+
try {
|
|
850
|
+
const response = await client.aggregateDocumentRecords(resolvedAppId, documentId, modelName, { operations: [operation], groupBy, filter }, { documentFormat });
|
|
851
|
+
if (options.json) {
|
|
852
|
+
json(response);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const aggregated = response?.result;
|
|
856
|
+
// Same labelling rule as the databases twin: the field name is
|
|
857
|
+
// reproduced verbatim, never case-folded (issue #2357).
|
|
858
|
+
const valueLabel = options.op === "count"
|
|
859
|
+
? "COUNT"
|
|
860
|
+
: `${options.op.toUpperCase()}(${options.field})`;
|
|
861
|
+
const scalarLabel = options.op === "count"
|
|
862
|
+
? "count"
|
|
863
|
+
: `${options.op.toLowerCase()}(${options.field})`;
|
|
864
|
+
if (groupBy.length === 0) {
|
|
865
|
+
const value = (aggregated ?? {})[options.op === "count" ? "count" : `${options.op}_${options.field}`];
|
|
866
|
+
keyValue(scalarLabel, value === null || value === undefined ? "—" : String(value));
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (aggregated &&
|
|
870
|
+
typeof aggregated === "object" &&
|
|
871
|
+
!Array.isArray(aggregated) &&
|
|
872
|
+
Object.values(aggregated).every((v) => typeof v !== "object" || v === null)) {
|
|
873
|
+
const rows = Object.entries(aggregated).map(([group, value]) => ({
|
|
874
|
+
group,
|
|
875
|
+
value: value === null || value === undefined ? "—" : String(value),
|
|
876
|
+
}));
|
|
877
|
+
console.log(formatTable(rows, [
|
|
878
|
+
{ header: "GROUP", key: "group" },
|
|
879
|
+
{ header: valueLabel, key: "value" },
|
|
880
|
+
]));
|
|
881
|
+
}
|
|
882
|
+
else {
|
|
883
|
+
console.log(JSON.stringify(aggregated ?? {}, null, 2));
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
catch (err) {
|
|
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));
|
|
890
|
+
process.exit(1);
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
// ---- Record writes (#1964 Phase 4) ----
|
|
894
|
+
// Server-side writes through the document facade: Yjs-first, CRDT-safe,
|
|
895
|
+
// broadcast to connected clients. Requires read-write or higher on the
|
|
896
|
+
// document (or an admin token). `--data` / `--data-file` are validated
|
|
897
|
+
// CLI-side before any request is sent.
|
|
898
|
+
// `--data`/`--data-file` parsing (including the object-shape check and the
|
|
899
|
+
// both-flags rule) is the shared `parseDataOption` helper, so these verbs
|
|
900
|
+
// behave and diagnose identically to `metadata` and `databases records`.
|
|
901
|
+
// Create or replace a record
|
|
902
|
+
records
|
|
903
|
+
.command("save")
|
|
904
|
+
.description("Create or replace a record in a document model")
|
|
905
|
+
.argument("<document-id>", "Document ID")
|
|
906
|
+
.argument("<model-name>", "Model name to write to")
|
|
907
|
+
.option("--app <app-id>", "App ID")
|
|
908
|
+
.option("--id <record-id>", "Record ID (a ULID is generated when omitted)")
|
|
909
|
+
.option("--data <json>", "Record fields as JSON (e.g. '{\"qty\":10}')")
|
|
910
|
+
.option("--data-file <file>", "Read record fields from a JSON file")
|
|
911
|
+
.option("--upsert-on <field>", "Update the record whose <field> matches instead of creating")
|
|
912
|
+
.option(DOCUMENT_FORMAT_FLAG, DOCUMENT_FORMAT_HELP)
|
|
913
|
+
.option("--json", "Output as JSON")
|
|
914
|
+
.action(async (documentId, modelName, options) => {
|
|
915
|
+
const data = parseDataOption(options, "record fields");
|
|
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
|
+
}
|
|
927
|
+
const recordId = options.id || ulid();
|
|
928
|
+
const writeOptions = options.upsertOn ? { upsertOn: options.upsertOn } : undefined;
|
|
929
|
+
const client = new ApiClient();
|
|
930
|
+
try {
|
|
931
|
+
const result = await client.saveDocumentRecord(resolvedAppId, documentId, modelName, { id: recordId, data, ...(writeOptions ? { options: writeOptions } : {}) }, { documentFormat });
|
|
932
|
+
if (options.json) {
|
|
933
|
+
json(result);
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
success(`Record saved: ${result.record?.id ?? recordId}`);
|
|
937
|
+
}
|
|
938
|
+
catch (err) {
|
|
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));
|
|
942
|
+
process.exit(1);
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
// Merge fields into an existing record
|
|
946
|
+
records
|
|
947
|
+
.command("patch")
|
|
948
|
+
.description("Merge fields into an existing record in a document model")
|
|
949
|
+
.argument("<document-id>", "Document ID")
|
|
950
|
+
.argument("<model-name>", "Model name")
|
|
951
|
+
.argument("<record-id>", "Record ID to patch")
|
|
952
|
+
.option("--app <app-id>", "App ID")
|
|
953
|
+
.option("--data <json>", "Fields to merge as JSON (e.g. '{\"status\":\"closed\"}')")
|
|
954
|
+
.option(DOCUMENT_FORMAT_FLAG, DOCUMENT_FORMAT_HELP)
|
|
955
|
+
.option("--data-file <file>", "Read fields from a JSON file")
|
|
956
|
+
.option("--json", "Output as JSON")
|
|
957
|
+
.action(async (documentId, modelName, recordId, options) => {
|
|
958
|
+
const data = parseDataOption(options, "fields to merge");
|
|
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
|
+
}
|
|
970
|
+
const client = new ApiClient();
|
|
971
|
+
try {
|
|
972
|
+
const result = await client.patchDocumentRecord(resolvedAppId, documentId, modelName, recordId, { data }, { documentFormat });
|
|
973
|
+
if (options.json) {
|
|
974
|
+
json(result);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
success(`Record patched: ${recordId}`);
|
|
978
|
+
}
|
|
979
|
+
catch (err) {
|
|
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));
|
|
983
|
+
process.exit(1);
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
// Delete a record
|
|
987
|
+
records
|
|
988
|
+
.command("delete")
|
|
989
|
+
.description("Delete a record from a document model")
|
|
990
|
+
.argument("<document-id>", "Document ID")
|
|
991
|
+
.argument("<model-name>", "Model name")
|
|
992
|
+
.argument("<record-id>", "Record ID to delete")
|
|
993
|
+
.option("--app <app-id>", "App ID")
|
|
994
|
+
.option(DOCUMENT_FORMAT_FLAG, DOCUMENT_FORMAT_HELP)
|
|
995
|
+
.option("-y, --yes", "Skip confirmation prompt")
|
|
996
|
+
.option("--json", "Output as JSON")
|
|
997
|
+
.action(async (documentId, modelName, recordId, options) => {
|
|
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
|
+
}
|
|
1009
|
+
if (!options.yes) {
|
|
1010
|
+
const confirmed = await confirmPrompt(`Delete record ${recordId} from ${modelName} in document ${documentId}?`);
|
|
1011
|
+
if (!confirmed) {
|
|
1012
|
+
info("Cancelled.");
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
const client = new ApiClient();
|
|
1017
|
+
try {
|
|
1018
|
+
const result = await client.deleteDocumentRecord(resolvedAppId, documentId, modelName, recordId, { documentFormat });
|
|
1019
|
+
if (options.json) {
|
|
1020
|
+
json(result);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
// A delete of a missing record is a silent no-op server-side, so
|
|
1024
|
+
// this reports the request outcome, not record existence.
|
|
1025
|
+
success(`Record deleted: ${recordId}`);
|
|
1026
|
+
}
|
|
1027
|
+
catch (err) {
|
|
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));
|
|
1031
|
+
process.exit(1);
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
// Apply an atomic multi-model operations blob
|
|
1035
|
+
records
|
|
1036
|
+
.command("bulk")
|
|
1037
|
+
.description("Apply an atomic create/patch/delete operations blob to a document")
|
|
1038
|
+
.argument("<document-id>", "Document ID")
|
|
1039
|
+
.requiredOption("--data-file <file>", "JSON file with { operations: [...] } or a bare operations array. " +
|
|
1040
|
+
"Each op is { model, action: create|patch|delete, id, data, precondition? } " +
|
|
1041
|
+
"— `data` holds the record fields and is required (non-empty) on " +
|
|
1042
|
+
"create/patch, not allowed on delete; a create `id` must be a 26-char " +
|
|
1043
|
+
"uppercase Crockford ULID")
|
|
1044
|
+
.option("--app <app-id>", "App ID")
|
|
1045
|
+
.option(DOCUMENT_FORMAT_FLAG, DOCUMENT_FORMAT_HELP)
|
|
1046
|
+
.option("-y, --yes", "Skip confirmation prompt")
|
|
1047
|
+
.option("--json", "Output as JSON")
|
|
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
|
+
}
|
|
1059
|
+
const parsed = parseDataOption(options, "operations blob", {
|
|
1060
|
+
allowArray: true,
|
|
1061
|
+
});
|
|
1062
|
+
const operations = Array.isArray(parsed) ? parsed : parsed?.operations;
|
|
1063
|
+
if (!Array.isArray(operations) || operations.length === 0) {
|
|
1064
|
+
error("--data-file must contain { operations: [...] } or a non-empty operations array.");
|
|
1065
|
+
process.exit(1);
|
|
1066
|
+
}
|
|
1067
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
1068
|
+
if (!options.yes) {
|
|
1069
|
+
const confirmed = await confirmPrompt(`Apply ${operations.length} operation(s) atomically to document ${documentId}?`);
|
|
1070
|
+
if (!confirmed) {
|
|
1071
|
+
info("Cancelled.");
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
const client = new ApiClient();
|
|
1076
|
+
try {
|
|
1077
|
+
const result = await client.bulkDocumentRecords(resolvedAppId, documentId, operations, { documentFormat });
|
|
1078
|
+
if (options.json) {
|
|
1079
|
+
json(result);
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
success(`Applied ${result.applied} operation(s): ` +
|
|
1083
|
+
`${result.added.length} added, ${result.updated.length} updated, ${result.deleted} deleted.`);
|
|
1084
|
+
}
|
|
1085
|
+
catch (err) {
|
|
1086
|
+
const report = describeBulkFailure(err);
|
|
1087
|
+
if (options.json)
|
|
1088
|
+
json(report.envelope);
|
|
1089
|
+
else
|
|
1090
|
+
error(report.line);
|
|
1091
|
+
process.exit(1);
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
// Dump a document's records grouped by model (CLI-side composition)
|
|
1095
|
+
documents
|
|
1096
|
+
.command("dump")
|
|
1097
|
+
.description("Dump a document's records grouped by model as JSON")
|
|
1098
|
+
.argument("<document-id>", "Document ID")
|
|
1099
|
+
.option("--app <app-id>", "App ID")
|
|
1100
|
+
.option("--output <file>", "Write the JSON dump to a file instead of stdout")
|
|
1101
|
+
.option("--json", "Output as JSON (default)")
|
|
1102
|
+
.action(async (documentId, options) => {
|
|
1103
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
1104
|
+
const client = new ApiClient();
|
|
1105
|
+
try {
|
|
1106
|
+
// Model names come from the schema; records are assembled from paged
|
|
1107
|
+
// reads per model (not a single atomic snapshot — the paged path is
|
|
1108
|
+
// the contract, same pattern as `databases export`).
|
|
1109
|
+
const schema = await client.getDocumentSchema(resolvedAppId, documentId);
|
|
1110
|
+
const modelNames = schema?.modelNames ?? Object.keys(schema?.schema?.models ?? {});
|
|
1111
|
+
const grouped = {};
|
|
1112
|
+
for (const modelName of modelNames) {
|
|
1113
|
+
const records = [];
|
|
1114
|
+
let cursor;
|
|
1115
|
+
do {
|
|
1116
|
+
const page = await client.queryDocumentRecords(resolvedAppId, documentId, modelName, { limit: 100, cursor });
|
|
1117
|
+
records.push(...page.items);
|
|
1118
|
+
cursor = page.nextCursor;
|
|
1119
|
+
} while (cursor);
|
|
1120
|
+
grouped[modelName] = records;
|
|
1121
|
+
}
|
|
1122
|
+
const output = JSON.stringify(grouped, null, 2);
|
|
1123
|
+
if (options.output) {
|
|
1124
|
+
fs.writeFileSync(options.output, output);
|
|
1125
|
+
success(`Document dump written to ${options.output}`);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
console.log(output);
|
|
1129
|
+
}
|
|
1130
|
+
catch (err) {
|
|
1131
|
+
error(err.message);
|
|
1132
|
+
process.exit(1);
|
|
1133
|
+
}
|
|
1134
|
+
});
|
|
1135
|
+
// Document statistics (platform-vocabulary projection)
|
|
1136
|
+
documents
|
|
1137
|
+
.command("stats")
|
|
1138
|
+
.description("Show a document's record/model/blob counts and approximate size")
|
|
1139
|
+
.argument("<document-id>", "Document ID")
|
|
1140
|
+
.option("--app <app-id>", "App ID")
|
|
1141
|
+
.option("--json", "Output as JSON")
|
|
1142
|
+
.action(async (documentId, options) => {
|
|
1143
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
1144
|
+
const client = new ApiClient();
|
|
1145
|
+
try {
|
|
1146
|
+
const result = await client.getDocumentStats(resolvedAppId, documentId);
|
|
1147
|
+
if (options.json) {
|
|
1148
|
+
json(result);
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
keyValue("Document ID", result.documentId);
|
|
1152
|
+
keyValue("Records", result.recordCount);
|
|
1153
|
+
keyValue("Models", result.modelCount);
|
|
1154
|
+
keyValue("Blobs", result.blobCount);
|
|
1155
|
+
keyValue("Size (bytes, approx)", result.sizeBytes);
|
|
1156
|
+
keyValue("Last modified", result.lastModifiedAt ? formatDate(result.lastModifiedAt) : "—");
|
|
1157
|
+
}
|
|
1158
|
+
catch (err) {
|
|
1159
|
+
error(err.message);
|
|
1160
|
+
process.exit(1);
|
|
1161
|
+
}
|
|
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
|
+
});
|
|
13
1617
|
// Transfer document ownership
|
|
14
1618
|
documents
|
|
15
1619
|
.command("transfer-owner")
|
|
16
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.
|
|
17
1628
|
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
18
|
-
.argument("
|
|
19
|
-
.argument("
|
|
1629
|
+
.argument("[document-id]", "Document ID (required)")
|
|
1630
|
+
.argument("[new-owner-id]", "User ID of the new owner (required)")
|
|
20
1631
|
.option("--app <app-id>", "App ID")
|
|
21
1632
|
.option("-y, --yes", "Skip confirmation prompt")
|
|
22
1633
|
.option("--json", "Output as JSON")
|
|
@@ -43,15 +1654,14 @@ Examples:
|
|
|
43
1654
|
process.exit(1);
|
|
44
1655
|
}
|
|
45
1656
|
if (!options.yes) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
{
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
]);
|
|
1657
|
+
let confirm;
|
|
1658
|
+
try {
|
|
1659
|
+
confirm = await confirmPrompt(`Transfer ownership of document ${documentId} to user ${newOwnerId}? This cannot be undone.`);
|
|
1660
|
+
}
|
|
1661
|
+
catch (err) {
|
|
1662
|
+
error(err.message);
|
|
1663
|
+
process.exit(1);
|
|
1664
|
+
}
|
|
55
1665
|
if (!confirm) {
|
|
56
1666
|
info("Cancelled.");
|
|
57
1667
|
return;
|
|
@@ -89,7 +1699,7 @@ Examples:
|
|
|
89
1699
|
const result = await client.listDocumentGroupPermissions(resolvedAppId, documentId);
|
|
90
1700
|
const list = Array.isArray(result) ? result : result?.permissions ?? [];
|
|
91
1701
|
if (options.json) {
|
|
92
|
-
json(list);
|
|
1702
|
+
json(wholeListEnvelope(list));
|
|
93
1703
|
return;
|
|
94
1704
|
}
|
|
95
1705
|
if (list.length === 0) {
|
|
@@ -138,6 +1748,381 @@ Examples:
|
|
|
138
1748
|
process.exit(1);
|
|
139
1749
|
}
|
|
140
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
|
+
});
|
|
1800
|
+
// ---- Link access ("anyone with the link") ----
|
|
1801
|
+
const linkAccess = documents
|
|
1802
|
+
.command("link-access")
|
|
1803
|
+
.description('Manage "anyone with the link" access on a document');
|
|
1804
|
+
linkAccess
|
|
1805
|
+
.command("get")
|
|
1806
|
+
.description("Show the current link-access level for a document")
|
|
1807
|
+
.argument("<document-id>", "Document ID")
|
|
1808
|
+
.option("--app <app-id>", "App ID")
|
|
1809
|
+
.option("--json", "Output as JSON")
|
|
1810
|
+
.action(async (documentId, options) => {
|
|
1811
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
1812
|
+
const client = new ApiClient();
|
|
1813
|
+
try {
|
|
1814
|
+
const state = await client.getDocumentLinkAccess(resolvedAppId, documentId);
|
|
1815
|
+
const level = state?.linkAccess ?? null;
|
|
1816
|
+
if (options.json) {
|
|
1817
|
+
json({ documentId, linkAccess: level });
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
if (!level) {
|
|
1821
|
+
info("Link access is off for this document.");
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
keyValue("Document", documentId);
|
|
1825
|
+
keyValue("Link access", level);
|
|
1826
|
+
}
|
|
1827
|
+
catch (err) {
|
|
1828
|
+
error(err.message);
|
|
1829
|
+
process.exit(1);
|
|
1830
|
+
}
|
|
1831
|
+
});
|
|
1832
|
+
linkAccess
|
|
1833
|
+
.command("set")
|
|
1834
|
+
.description("Turn on link access at a given level")
|
|
1835
|
+
.argument("<document-id>", "Document ID")
|
|
1836
|
+
.requiredOption("--level <level>", "Link access level: reader or read-write")
|
|
1837
|
+
.option("--app <app-id>", "App ID")
|
|
1838
|
+
.option("--json", "Output as JSON")
|
|
1839
|
+
.action(async (documentId, options) => {
|
|
1840
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
1841
|
+
const client = new ApiClient();
|
|
1842
|
+
try {
|
|
1843
|
+
const result = await client.setDocumentLinkAccess(resolvedAppId, documentId, options.level);
|
|
1844
|
+
if (options.json) {
|
|
1845
|
+
json(result);
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
success(`Link access set to '${options.level}' for document ${documentId}.`);
|
|
1849
|
+
}
|
|
1850
|
+
catch (err) {
|
|
1851
|
+
error(err.message);
|
|
1852
|
+
process.exit(1);
|
|
1853
|
+
}
|
|
1854
|
+
});
|
|
1855
|
+
linkAccess
|
|
1856
|
+
.command("clear")
|
|
1857
|
+
.description("Turn off link access")
|
|
1858
|
+
.argument("<document-id>", "Document ID")
|
|
1859
|
+
.option("--app <app-id>", "App ID")
|
|
1860
|
+
.option("--json", "Output as JSON")
|
|
1861
|
+
.action(async (documentId, options) => {
|
|
1862
|
+
const resolvedAppId = resolveAppId(undefined, options);
|
|
1863
|
+
const client = new ApiClient();
|
|
1864
|
+
try {
|
|
1865
|
+
const result = await client.clearDocumentLinkAccess(resolvedAppId, documentId);
|
|
1866
|
+
if (options.json) {
|
|
1867
|
+
json(result);
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
success(`Link access turned off for document ${documentId}.`);
|
|
1871
|
+
}
|
|
1872
|
+
catch (err) {
|
|
1873
|
+
error(err.message);
|
|
1874
|
+
process.exit(1);
|
|
1875
|
+
}
|
|
1876
|
+
});
|
|
1877
|
+
// ---- Export / Import commands ----
|
|
1878
|
+
documents
|
|
1879
|
+
.command("export")
|
|
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.
|
|
1884
|
+
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
1885
|
+
.argument("[document-id]", "Document ID to export (required)")
|
|
1886
|
+
.option("--app <app-id>", "App ID")
|
|
1887
|
+
.option("--output <dir>", "Output directory", "./primitive-export")
|
|
1888
|
+
.option("--no-blobs", "Skip blob data")
|
|
1889
|
+
.option("--json", "Output as JSON")
|
|
1890
|
+
.action(async (first, second, options) => {
|
|
1891
|
+
let resolvedAppId;
|
|
1892
|
+
let documentId;
|
|
1893
|
+
if (second && !second.startsWith("-")) {
|
|
1894
|
+
resolvedAppId = resolveAppId(first, options);
|
|
1895
|
+
documentId = second;
|
|
1896
|
+
}
|
|
1897
|
+
else {
|
|
1898
|
+
resolvedAppId = resolveAppId(undefined, options);
|
|
1899
|
+
documentId = first;
|
|
1900
|
+
}
|
|
1901
|
+
if (!documentId) {
|
|
1902
|
+
error("Document ID is required.");
|
|
1903
|
+
process.exit(1);
|
|
1904
|
+
}
|
|
1905
|
+
const client = new ApiClient();
|
|
1906
|
+
try {
|
|
1907
|
+
await exportSingleDocument(client, resolvedAppId, documentId, options.output, options.blobs !== false ? true : false, options.json);
|
|
1908
|
+
}
|
|
1909
|
+
catch (err) {
|
|
1910
|
+
error(err.message);
|
|
1911
|
+
process.exit(1);
|
|
1912
|
+
}
|
|
1913
|
+
});
|
|
1914
|
+
documents
|
|
1915
|
+
.command("export-all")
|
|
1916
|
+
.description("Export all documents for a user")
|
|
1917
|
+
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
1918
|
+
.requiredOption("--user-id <id>", "User ID to export documents for")
|
|
1919
|
+
.option("--app <app-id>", "App ID")
|
|
1920
|
+
.option("--owned-only", "Only export documents owned by the user")
|
|
1921
|
+
.option("--output <dir>", "Output directory", "./primitive-export")
|
|
1922
|
+
.option("--no-blobs", "Skip blob data")
|
|
1923
|
+
.option("--json", "Output as JSON")
|
|
1924
|
+
.action(async (appIdArg, options) => {
|
|
1925
|
+
const resolvedAppId = resolveAppId(appIdArg, options);
|
|
1926
|
+
const client = new ApiClient();
|
|
1927
|
+
const includeBlobs = options.blobs !== false;
|
|
1928
|
+
try {
|
|
1929
|
+
const documents = await client.listAdminDocuments(resolvedAppId, options.userId);
|
|
1930
|
+
let docList = Array.isArray(documents) ? documents : [];
|
|
1931
|
+
if (options.ownedOnly) {
|
|
1932
|
+
docList = docList.filter((d) => d.permission === "owner");
|
|
1933
|
+
}
|
|
1934
|
+
if (docList.length === 0) {
|
|
1935
|
+
info("No documents found.");
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
info(`Exporting ${docList.length} document(s)...`);
|
|
1939
|
+
const exportedIds = [];
|
|
1940
|
+
for (const doc of docList) {
|
|
1941
|
+
const docId = doc.documentId || doc.id;
|
|
1942
|
+
try {
|
|
1943
|
+
await exportSingleDocument(client, resolvedAppId, docId, options.output, includeBlobs, false);
|
|
1944
|
+
exportedIds.push(docId);
|
|
1945
|
+
}
|
|
1946
|
+
catch (err) {
|
|
1947
|
+
warn(`Failed to export ${docId}: ${err.message}`);
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
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).
|
|
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
|
+
}
|
|
1972
|
+
const manifest = {
|
|
1973
|
+
version: 1,
|
|
1974
|
+
exportedAt: new Date().toISOString(),
|
|
1975
|
+
sourceAppId: resolvedAppId,
|
|
1976
|
+
documentCount: exportedIds.length,
|
|
1977
|
+
documents: exportedIds,
|
|
1978
|
+
};
|
|
1979
|
+
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
|
|
1980
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
1981
|
+
if (options.json) {
|
|
1982
|
+
json(manifest);
|
|
1983
|
+
}
|
|
1984
|
+
else {
|
|
1985
|
+
success(`Exported ${exportedIds.length} document(s) to ${options.output}`);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
catch (err) {
|
|
1989
|
+
error(err.message);
|
|
1990
|
+
process.exit(1);
|
|
1991
|
+
}
|
|
1992
|
+
});
|
|
1993
|
+
documents
|
|
1994
|
+
.command("import")
|
|
1995
|
+
.description("Import documents from an export directory")
|
|
1996
|
+
// Optional in commander, required in the action (#3645).
|
|
1997
|
+
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
1998
|
+
.argument("[path]", "Path to export directory or single document directory (required)")
|
|
1999
|
+
.option("--app <app-id>", "App ID")
|
|
2000
|
+
.option("--aliases <mode>", "Alias conflict handling: overwrite or skip", "skip")
|
|
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)")
|
|
2007
|
+
.option("--dry-run", "Show what would be imported without making changes")
|
|
2008
|
+
.option("--owner <userId-or-email>", "User ID or email to set as document owner (admin only)")
|
|
2009
|
+
.option("--json", "Output as JSON")
|
|
2010
|
+
.action(async (first, second, options) => {
|
|
2011
|
+
let resolvedAppId;
|
|
2012
|
+
let importPath;
|
|
2013
|
+
if (second && !second.startsWith("-")) {
|
|
2014
|
+
resolvedAppId = resolveAppId(first, options);
|
|
2015
|
+
importPath = second;
|
|
2016
|
+
}
|
|
2017
|
+
else {
|
|
2018
|
+
resolvedAppId = resolveAppId(undefined, options);
|
|
2019
|
+
importPath = first;
|
|
2020
|
+
}
|
|
2021
|
+
if (!importPath) {
|
|
2022
|
+
error("Import path is required.");
|
|
2023
|
+
process.exit(1);
|
|
2024
|
+
}
|
|
2025
|
+
const client = new ApiClient();
|
|
2026
|
+
// Resolve --owner to a userId through the one shared lookup (#2763).
|
|
2027
|
+
let ownerUserId;
|
|
2028
|
+
if (options.owner) {
|
|
2029
|
+
try {
|
|
2030
|
+
ownerUserId = await resolveOwnerUserId(client, resolvedAppId, options.owner);
|
|
2031
|
+
}
|
|
2032
|
+
catch (err) {
|
|
2033
|
+
error(err.message);
|
|
2034
|
+
process.exit(1);
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
try {
|
|
2038
|
+
// Determine if this is a manifest-based export or a single document
|
|
2039
|
+
const manifestPath = path.join(importPath, "manifest.json");
|
|
2040
|
+
let docDirs = [];
|
|
2041
|
+
if (fs.existsSync(manifestPath)) {
|
|
2042
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
2043
|
+
for (const docId of manifest.documents) {
|
|
2044
|
+
const docDir = path.join(importPath, "documents", docId);
|
|
2045
|
+
if (fs.existsSync(docDir)) {
|
|
2046
|
+
docDirs.push(docDir);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
else if (fs.existsSync(path.join(importPath, "metadata.json"))) {
|
|
2051
|
+
// Single document directory
|
|
2052
|
+
docDirs = [importPath];
|
|
2053
|
+
}
|
|
2054
|
+
else if (fs.existsSync(path.join(importPath, "documents"))) {
|
|
2055
|
+
// Export directory without manifest — discover documents
|
|
2056
|
+
const entries = fs.readdirSync(path.join(importPath, "documents"), { withFileTypes: true });
|
|
2057
|
+
for (const entry of entries) {
|
|
2058
|
+
if (entry.isDirectory()) {
|
|
2059
|
+
docDirs.push(path.join(importPath, "documents", entry.name));
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
else {
|
|
2064
|
+
error("No valid export data found at the specified path.");
|
|
2065
|
+
process.exit(1);
|
|
2066
|
+
}
|
|
2067
|
+
if (docDirs.length === 0) {
|
|
2068
|
+
info("No documents to import.");
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
const summary = {
|
|
2072
|
+
created: 0,
|
|
2073
|
+
updated: 0,
|
|
2074
|
+
skipped: 0,
|
|
2075
|
+
blobsUploaded: 0,
|
|
2076
|
+
aliasesSet: 0,
|
|
2077
|
+
aliasesSkipped: 0,
|
|
2078
|
+
rootDocuments: [],
|
|
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.
|
|
2089
|
+
for (const docDir of docDirs) {
|
|
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));
|
|
2102
|
+
}
|
|
2103
|
+
if (options.json) {
|
|
2104
|
+
json(summary);
|
|
2105
|
+
}
|
|
2106
|
+
else {
|
|
2107
|
+
success(`Import complete:`);
|
|
2108
|
+
keyValue(" Documents created", String(summary.created));
|
|
2109
|
+
if (summary.updated > 0)
|
|
2110
|
+
keyValue(" Documents updated", String(summary.updated));
|
|
2111
|
+
if (summary.skipped > 0)
|
|
2112
|
+
keyValue(" Documents skipped", String(summary.skipped));
|
|
2113
|
+
if (summary.blobsUploaded > 0)
|
|
2114
|
+
keyValue(" Blobs uploaded", String(summary.blobsUploaded));
|
|
2115
|
+
if (summary.aliasesSet > 0)
|
|
2116
|
+
keyValue(" Aliases set", String(summary.aliasesSet));
|
|
2117
|
+
if (summary.aliasesSkipped > 0)
|
|
2118
|
+
keyValue(" Aliases skipped", String(summary.aliasesSkipped));
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
catch (err) {
|
|
2122
|
+
error(err.message);
|
|
2123
|
+
process.exit(1);
|
|
2124
|
+
}
|
|
2125
|
+
});
|
|
141
2126
|
// Revoke group permission
|
|
142
2127
|
groupPermissions
|
|
143
2128
|
.command("revoke")
|
|
@@ -150,15 +2135,14 @@ Examples:
|
|
|
150
2135
|
.action(async (documentId, groupType, groupId, options) => {
|
|
151
2136
|
const resolvedAppId = resolveAppId(undefined, options);
|
|
152
2137
|
if (!options.yes) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
{
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
]);
|
|
2138
|
+
let confirm;
|
|
2139
|
+
try {
|
|
2140
|
+
confirm = await confirmPrompt(`Revoke permission for group ${groupType}/${groupId} on document ${documentId}?`);
|
|
2141
|
+
}
|
|
2142
|
+
catch (err) {
|
|
2143
|
+
error(err.message);
|
|
2144
|
+
process.exit(1);
|
|
2145
|
+
}
|
|
162
2146
|
if (!confirm) {
|
|
163
2147
|
info("Cancelled.");
|
|
164
2148
|
return;
|
|
@@ -175,4 +2159,886 @@ Examples:
|
|
|
175
2159
|
}
|
|
176
2160
|
});
|
|
177
2161
|
}
|
|
2162
|
+
// ============================================
|
|
2163
|
+
// Helper functions for export/import
|
|
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
|
+
}
|
|
2253
|
+
async function exportSingleDocument(client, appId, documentId, outputDir, includeBlobs, jsonOutput) {
|
|
2254
|
+
const docDir = path.join(outputDir, "documents", documentId);
|
|
2255
|
+
fs.mkdirSync(docDir, { recursive: true });
|
|
2256
|
+
// 1. Fetch document metadata
|
|
2257
|
+
const docMeta = await client.getDocument(appId, documentId);
|
|
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
|
+
}
|
|
2271
|
+
// 3. Fetch permissions
|
|
2272
|
+
let permResult = null;
|
|
2273
|
+
try {
|
|
2274
|
+
permResult = await client.listDocumentPermissions(appId, documentId);
|
|
2275
|
+
}
|
|
2276
|
+
catch {
|
|
2277
|
+
// Permissions endpoint might not return data for all docs
|
|
2278
|
+
}
|
|
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;
|
|
2282
|
+
try {
|
|
2283
|
+
pendingResult = await client.listDocumentPendingInvitations(appId, documentId);
|
|
2284
|
+
}
|
|
2285
|
+
catch {
|
|
2286
|
+
// May not have pending invitations, or the server predates the endpoint
|
|
2287
|
+
}
|
|
2288
|
+
const permissionsExport = buildPermissionsExport(permResult, pendingResult);
|
|
2289
|
+
fs.writeFileSync(path.join(docDir, "permissions.json"), JSON.stringify(permissionsExport, null, 2));
|
|
2290
|
+
// 5. Fetch aliases (user-scoped only)
|
|
2291
|
+
let aliases = [];
|
|
2292
|
+
try {
|
|
2293
|
+
const aliasResult = await client.listDocumentAliases(appId, documentId);
|
|
2294
|
+
aliases = (Array.isArray(aliasResult) ? aliasResult : aliasResult?.aliases || [])
|
|
2295
|
+
.filter((a) => (a.scope || a.aliasScope) === "user");
|
|
2296
|
+
}
|
|
2297
|
+
catch {
|
|
2298
|
+
// May not have aliases
|
|
2299
|
+
}
|
|
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).
|
|
2304
|
+
const metadata = {
|
|
2305
|
+
documentId: docMeta.documentId || documentId,
|
|
2306
|
+
title: docMeta.title,
|
|
2307
|
+
tags: docMeta.tags || [],
|
|
2308
|
+
...(Number(docMeta?.documentFormat) === 2 ? { documentFormat: 2 } : {}),
|
|
2309
|
+
createdAt: docMeta.createdAt,
|
|
2310
|
+
createdBy: docMeta.createdByEmail || docMeta.createdBy,
|
|
2311
|
+
aliases: aliases.map((a) => ({
|
|
2312
|
+
aliasScope: a.scope || a.aliasScope,
|
|
2313
|
+
aliasKey: a.aliasKey,
|
|
2314
|
+
})),
|
|
2315
|
+
};
|
|
2316
|
+
fs.writeFileSync(path.join(docDir, "metadata.json"), JSON.stringify(metadata, null, 2));
|
|
2317
|
+
// 7. Blobs
|
|
2318
|
+
if (includeBlobs) {
|
|
2319
|
+
let blobs = [];
|
|
2320
|
+
try {
|
|
2321
|
+
const blobResult = await client.listDocumentBlobs(appId, documentId);
|
|
2322
|
+
blobs = Array.isArray(blobResult) ? blobResult : blobResult?.items || blobResult?.blobs || [];
|
|
2323
|
+
}
|
|
2324
|
+
catch {
|
|
2325
|
+
// May not have blobs
|
|
2326
|
+
}
|
|
2327
|
+
if (blobs.length > 0) {
|
|
2328
|
+
const blobsDir = path.join(docDir, "blobs");
|
|
2329
|
+
fs.mkdirSync(blobsDir, { recursive: true });
|
|
2330
|
+
const blobIndex = blobs.map((b) => ({
|
|
2331
|
+
blobId: b.blobId,
|
|
2332
|
+
filename: b.filename,
|
|
2333
|
+
contentType: b.contentType,
|
|
2334
|
+
sha256: b.sha256,
|
|
2335
|
+
numBytes: b.numBytes,
|
|
2336
|
+
}));
|
|
2337
|
+
fs.writeFileSync(path.join(blobsDir, "index.json"), JSON.stringify(blobIndex, null, 2));
|
|
2338
|
+
// Download blobs (sequentially to avoid overwhelming the server)
|
|
2339
|
+
for (const blob of blobs) {
|
|
2340
|
+
const data = await client.downloadBlob(appId, documentId, blob.blobId);
|
|
2341
|
+
fs.writeFileSync(path.join(blobsDir, `${blob.blobId}.bin`), data);
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
if (jsonOutput) {
|
|
2346
|
+
json({
|
|
2347
|
+
documentId,
|
|
2348
|
+
title: metadata.title,
|
|
2349
|
+
stateBytes,
|
|
2350
|
+
permissions: permissionsExport.length,
|
|
2351
|
+
aliases: aliases.length,
|
|
2352
|
+
exportPath: docDir,
|
|
2353
|
+
});
|
|
2354
|
+
}
|
|
2355
|
+
else {
|
|
2356
|
+
success(`Exported document ${documentId} (${metadata.title || "untitled"}) to ${docDir}`);
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
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) {
|
|
2752
|
+
const metadataPath = path.join(docDir, "metadata.json");
|
|
2753
|
+
if (!fs.existsSync(metadataPath)) {
|
|
2754
|
+
warn(`Skipping ${docDir}: no metadata.json found`);
|
|
2755
|
+
summary.skipped++;
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
|
|
2759
|
+
const documentId = metadata.documentId;
|
|
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);
|
|
2770
|
+
return;
|
|
2771
|
+
}
|
|
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";
|
|
2782
|
+
if (exists && !overwrite) {
|
|
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)`);
|
|
2794
|
+
summary.skipped++;
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
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.
|
|
2812
|
+
if (!exists) {
|
|
2813
|
+
await client.createDocument(appId, {
|
|
2814
|
+
title: metadata.title || "Imported Document",
|
|
2815
|
+
documentId,
|
|
2816
|
+
tags: metadata.tags,
|
|
2817
|
+
...(isLargeDocument ? { documentFormat: 2 } : {}),
|
|
2818
|
+
...(ownerUserId ? { createdBy: ownerUserId } : {}),
|
|
2819
|
+
});
|
|
2820
|
+
summary.created++;
|
|
2821
|
+
}
|
|
2822
|
+
else {
|
|
2823
|
+
summary.updated++;
|
|
2824
|
+
}
|
|
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
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
// Upload blobs
|
|
2840
|
+
const blobIndexPath = path.join(docDir, "blobs", "index.json");
|
|
2841
|
+
if (fs.existsSync(blobIndexPath)) {
|
|
2842
|
+
const blobIndex = JSON.parse(fs.readFileSync(blobIndexPath, "utf-8"));
|
|
2843
|
+
for (const blob of blobIndex) {
|
|
2844
|
+
const blobPath = path.join(docDir, "blobs", `${blob.blobId}.bin`);
|
|
2845
|
+
if (fs.existsSync(blobPath)) {
|
|
2846
|
+
const data = fs.readFileSync(blobPath);
|
|
2847
|
+
await client.uploadBlob(appId, documentId, blob.blobId, data, {
|
|
2848
|
+
filename: blob.filename,
|
|
2849
|
+
contentType: blob.contentType,
|
|
2850
|
+
sha256: blob.sha256,
|
|
2851
|
+
});
|
|
2852
|
+
summary.blobsUploaded++;
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
// Permissions are exported for reference but not restored during import.
|
|
2857
|
+
// The importing admin is the new owner; sharing is managed in the target app.
|
|
2858
|
+
// Restore aliases
|
|
2859
|
+
if (metadata.aliases && Array.isArray(metadata.aliases)) {
|
|
2860
|
+
for (const alias of metadata.aliases) {
|
|
2861
|
+
if (alias.aliasScope !== "user")
|
|
2862
|
+
continue;
|
|
2863
|
+
if (!ownerUserId) {
|
|
2864
|
+
summary.aliasesSkipped++;
|
|
2865
|
+
continue;
|
|
2866
|
+
}
|
|
2867
|
+
try {
|
|
2868
|
+
await client.setDocumentAlias(appId, alias.aliasScope, alias.aliasKey, documentId, ownerUserId, aliasMode === "skip" // mustNotExist: true for skip mode, false for overwrite
|
|
2869
|
+
);
|
|
2870
|
+
summary.aliasesSet++;
|
|
2871
|
+
}
|
|
2872
|
+
catch {
|
|
2873
|
+
summary.aliasesSkipped++;
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
info(`Imported document ${documentId} (${metadata.title || "untitled"})`);
|
|
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
|
+
}
|
|
178
3044
|
//# sourceMappingURL=documents.js.map
|