primitive-admin 1.0.62 → 1.0.64

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