primitive-admin 1.1.0-alpha.7 → 1.1.0-alpha.71

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 (438) hide show
  1. package/README.md +404 -80
  2. package/assets/skill/skills/primitive-platform/SKILL.md +808 -0
  3. package/dist/bin/primitive.d.ts +2 -0
  4. package/dist/bin/primitive.js +294 -21
  5. package/dist/bin/primitive.js.map +1 -1
  6. package/dist/src/commands/admins.d.ts +2 -0
  7. package/dist/src/commands/admins.js +138 -19
  8. package/dist/src/commands/admins.js.map +1 -1
  9. package/dist/src/commands/analytics.d.ts +2 -0
  10. package/dist/src/commands/analytics.js +544 -55
  11. package/dist/src/commands/analytics.js.map +1 -1
  12. package/dist/src/commands/apps.d.ts +2 -0
  13. package/dist/src/commands/apps.js +51 -96
  14. package/dist/src/commands/apps.js.map +1 -1
  15. package/dist/src/commands/auth.d.ts +2 -0
  16. package/dist/src/commands/auth.js +177 -7
  17. package/dist/src/commands/auth.js.map +1 -1
  18. package/dist/src/commands/blob-buckets.d.ts +2 -0
  19. package/dist/src/commands/blob-buckets.js +330 -0
  20. package/dist/src/commands/blob-buckets.js.map +1 -0
  21. package/dist/src/commands/catalog.d.ts +2 -0
  22. package/dist/src/commands/catalog.js +37 -38
  23. package/dist/src/commands/catalog.js.map +1 -1
  24. package/dist/src/commands/collection-type-configs.d.ts +2 -0
  25. package/dist/src/commands/collection-type-configs.js +92 -0
  26. package/dist/src/commands/collection-type-configs.js.map +1 -0
  27. package/dist/src/commands/collections.d.ts +2 -0
  28. package/dist/src/commands/collections.js +565 -0
  29. package/dist/src/commands/collections.js.map +1 -0
  30. package/dist/src/commands/comparisons.d.ts +2 -0
  31. package/dist/src/commands/comparisons.js +6 -6
  32. package/dist/src/commands/comparisons.js.map +1 -1
  33. package/dist/src/commands/config.d.ts +46 -0
  34. package/dist/src/commands/config.js +479 -0
  35. package/dist/src/commands/config.js.map +1 -0
  36. package/dist/src/commands/connections.d.ts +2 -0
  37. package/dist/src/commands/connections.js +100 -0
  38. package/dist/src/commands/connections.js.map +1 -0
  39. package/dist/src/commands/cron-triggers.d.ts +2 -0
  40. package/dist/src/commands/cron-triggers.js +265 -0
  41. package/dist/src/commands/cron-triggers.js.map +1 -0
  42. package/dist/src/commands/database-type-configs.d.ts +2 -0
  43. package/dist/src/commands/database-type-configs.js +171 -0
  44. package/dist/src/commands/database-type-configs.js.map +1 -0
  45. package/dist/src/commands/database-types.d.ts +2 -0
  46. package/dist/src/commands/database-types.js +471 -0
  47. package/dist/src/commands/database-types.js.map +1 -0
  48. package/dist/src/commands/databases.d.ts +65 -0
  49. package/dist/src/commands/databases.js +2140 -112
  50. package/dist/src/commands/databases.js.map +1 -1
  51. package/dist/src/commands/documents.d.ts +2 -0
  52. package/dist/src/commands/documents.js +1357 -19
  53. package/dist/src/commands/documents.js.map +1 -1
  54. package/dist/src/commands/email-templates.d.ts +2 -0
  55. package/dist/src/commands/email-templates.js +174 -0
  56. package/dist/src/commands/email-templates.js.map +1 -0
  57. package/dist/src/commands/env.d.ts +23 -0
  58. package/dist/src/commands/env.js +333 -0
  59. package/dist/src/commands/env.js.map +1 -0
  60. package/dist/src/commands/feature-flags.d.ts +14 -0
  61. package/dist/src/commands/feature-flags.js +116 -0
  62. package/dist/src/commands/feature-flags.js.map +1 -0
  63. package/dist/src/commands/group-type-configs.d.ts +2 -0
  64. package/dist/src/commands/group-type-configs.js +86 -0
  65. package/dist/src/commands/group-type-configs.js.map +1 -0
  66. package/dist/src/commands/groups.d.ts +2 -0
  67. package/dist/src/commands/groups.js +38 -99
  68. package/dist/src/commands/groups.js.map +1 -1
  69. package/dist/src/commands/guides.d.ts +223 -0
  70. package/dist/src/commands/guides.js +617 -65
  71. package/dist/src/commands/guides.js.map +1 -1
  72. package/dist/src/commands/init.d.ts +25 -0
  73. package/dist/src/commands/init.js +1605 -208
  74. package/dist/src/commands/init.js.map +1 -1
  75. package/dist/src/commands/integrations.d.ts +2 -0
  76. package/dist/src/commands/integrations.js +380 -178
  77. package/dist/src/commands/integrations.js.map +1 -1
  78. package/dist/src/commands/llm.d.ts +2 -0
  79. package/dist/src/commands/llm.js +4 -2
  80. package/dist/src/commands/llm.js.map +1 -1
  81. package/dist/src/commands/locks.d.ts +8 -0
  82. package/dist/src/commands/locks.js +160 -0
  83. package/dist/src/commands/locks.js.map +1 -0
  84. package/dist/src/commands/metadata-category-configs.d.ts +12 -0
  85. package/dist/src/commands/metadata-category-configs.js +112 -0
  86. package/dist/src/commands/metadata-category-configs.js.map +1 -0
  87. package/dist/src/commands/metadata.d.ts +2 -0
  88. package/dist/src/commands/metadata.js +281 -0
  89. package/dist/src/commands/metadata.js.map +1 -0
  90. package/dist/src/commands/prompts.d.ts +2 -0
  91. package/dist/src/commands/prompts.js +225 -584
  92. package/dist/src/commands/prompts.js.map +1 -1
  93. package/dist/src/commands/rule-sets.d.ts +3 -0
  94. package/dist/src/commands/rule-sets.js +272 -0
  95. package/dist/src/commands/rule-sets.js.map +1 -0
  96. package/dist/src/commands/scripts.d.ts +20 -0
  97. package/dist/src/commands/scripts.js +554 -0
  98. package/dist/src/commands/scripts.js.map +1 -0
  99. package/dist/src/commands/secrets.d.ts +2 -0
  100. package/dist/src/commands/secrets.js +108 -0
  101. package/dist/src/commands/secrets.js.map +1 -0
  102. package/dist/src/commands/sessions.d.ts +2 -0
  103. package/dist/src/commands/sessions.js +75 -0
  104. package/dist/src/commands/sessions.js.map +1 -0
  105. package/dist/src/commands/skill.d.ts +2 -0
  106. package/dist/src/commands/skill.js +29 -0
  107. package/dist/src/commands/skill.js.map +1 -0
  108. package/dist/src/commands/sync-app-settings.d.ts +158 -0
  109. package/dist/src/commands/sync-app-settings.js +330 -0
  110. package/dist/src/commands/sync-app-settings.js.map +1 -0
  111. package/dist/src/commands/sync.d.ts +2323 -0
  112. package/dist/src/commands/sync.js +15065 -843
  113. package/dist/src/commands/sync.js.map +1 -1
  114. package/dist/src/commands/tokens.d.ts +2 -0
  115. package/dist/src/commands/tokens.js +130 -21
  116. package/dist/src/commands/tokens.js.map +1 -1
  117. package/dist/src/commands/users.d.ts +2 -0
  118. package/dist/src/commands/users.js +532 -23
  119. package/dist/src/commands/users.js.map +1 -1
  120. package/dist/src/commands/vars.d.ts +8 -0
  121. package/dist/src/commands/vars.js +96 -0
  122. package/dist/src/commands/vars.js.map +1 -0
  123. package/dist/src/commands/waitlist.d.ts +2 -0
  124. package/dist/src/commands/waitlist.js +10 -10
  125. package/dist/src/commands/waitlist.js.map +1 -1
  126. package/dist/src/commands/webhooks.d.ts +2 -0
  127. package/dist/src/commands/webhooks.js +562 -0
  128. package/dist/src/commands/webhooks.js.map +1 -0
  129. package/dist/src/commands/workflows.d.ts +116 -0
  130. package/dist/src/commands/workflows.js +1583 -681
  131. package/dist/src/commands/workflows.js.map +1 -1
  132. package/dist/src/lib/access-rule-display.d.ts +21 -0
  133. package/dist/src/lib/access-rule-display.js +34 -0
  134. package/dist/src/lib/access-rule-display.js.map +1 -0
  135. package/dist/src/lib/api-client.d.ts +1936 -0
  136. package/dist/src/lib/api-client.js +1826 -138
  137. package/dist/src/lib/api-client.js.map +1 -1
  138. package/dist/src/lib/app-settings-descriptor.d.ts +263 -0
  139. package/dist/src/lib/app-settings-descriptor.js +575 -0
  140. package/dist/src/lib/app-settings-descriptor.js.map +1 -0
  141. package/dist/src/lib/auth-flow.d.ts +8 -0
  142. package/dist/src/lib/batch.d.ts +26 -0
  143. package/dist/src/lib/batch.js +32 -0
  144. package/dist/src/lib/batch.js.map +1 -0
  145. package/dist/src/lib/block-layout.d.ts +160 -0
  146. package/dist/src/lib/block-layout.js +451 -0
  147. package/dist/src/lib/block-layout.js.map +1 -0
  148. package/dist/src/lib/canonical-json.d.ts +12 -0
  149. package/dist/src/lib/canonical-json.js +35 -0
  150. package/dist/src/lib/canonical-json.js.map +1 -0
  151. package/dist/src/lib/channel.d.ts +30 -0
  152. package/dist/src/lib/channel.js +68 -0
  153. package/dist/src/lib/channel.js.map +1 -0
  154. package/dist/src/lib/cli-manifest.d.ts +68 -0
  155. package/dist/src/lib/cli-manifest.js +71 -0
  156. package/dist/src/lib/cli-manifest.js.map +1 -0
  157. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +101 -0
  158. package/dist/src/lib/codegen-shared/generatedFiles.js +191 -0
  159. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -0
  160. package/dist/src/lib/codegen-shared/prettierStable.d.ts +262 -0
  161. package/dist/src/lib/codegen-shared/prettierStable.js +610 -0
  162. package/dist/src/lib/codegen-shared/prettierStable.js.map +1 -0
  163. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +68 -0
  164. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +168 -0
  165. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -0
  166. package/dist/src/lib/config-object-descriptor.d.ts +127 -0
  167. package/dist/src/lib/config-object-descriptor.js +658 -0
  168. package/dist/src/lib/config-object-descriptor.js.map +1 -0
  169. package/dist/src/lib/config-payload.d.ts +85 -0
  170. package/dist/src/lib/config-payload.js +116 -0
  171. package/dist/src/lib/config-payload.js.map +1 -0
  172. package/dist/src/lib/config-surface.d.ts +130 -0
  173. package/dist/src/lib/config-surface.js +300 -0
  174. package/dist/src/lib/config-surface.js.map +1 -0
  175. package/dist/src/lib/config-toml.d.ts +10 -0
  176. package/dist/src/lib/config-toml.js +42 -0
  177. package/dist/src/lib/config-toml.js.map +1 -0
  178. package/dist/src/lib/config.d.ts +53 -0
  179. package/dist/src/lib/config.js +92 -53
  180. package/dist/src/lib/config.js.map +1 -1
  181. package/dist/src/lib/confirm-prompt.d.ts +83 -0
  182. package/dist/src/lib/confirm-prompt.js +110 -0
  183. package/dist/src/lib/confirm-prompt.js.map +1 -0
  184. package/dist/src/lib/constants.d.ts +11 -0
  185. package/dist/src/lib/constants.js +12 -0
  186. package/dist/src/lib/constants.js.map +1 -0
  187. package/dist/src/lib/crash-handlers.d.ts +20 -0
  188. package/dist/src/lib/crash-handlers.js +49 -0
  189. package/dist/src/lib/crash-handlers.js.map +1 -0
  190. package/dist/src/lib/credentials-store.d.ts +89 -0
  191. package/dist/src/lib/credentials-store.js +330 -0
  192. package/dist/src/lib/credentials-store.js.map +1 -0
  193. package/dist/src/lib/csv.d.ts +47 -0
  194. package/dist/src/lib/csv.js +172 -0
  195. package/dist/src/lib/csv.js.map +1 -0
  196. package/dist/src/lib/data-input.d.ts +23 -0
  197. package/dist/src/lib/data-input.js +50 -0
  198. package/dist/src/lib/data-input.js.map +1 -0
  199. package/dist/src/lib/db-codegen/dbFingerprint.d.ts +10 -0
  200. package/dist/src/lib/db-codegen/dbFingerprint.js +17 -0
  201. package/dist/src/lib/db-codegen/dbFingerprint.js.map +1 -0
  202. package/dist/src/lib/db-codegen/dbGenerator.d.ts +67 -0
  203. package/dist/src/lib/db-codegen/dbGenerator.js +170 -0
  204. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -0
  205. package/dist/src/lib/db-codegen/dbNaming.d.ts +87 -0
  206. package/dist/src/lib/db-codegen/dbNaming.js +180 -0
  207. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -0
  208. package/dist/src/lib/db-codegen/dbTemplates.d.ts +272 -0
  209. package/dist/src/lib/db-codegen/dbTemplates.js +480 -0
  210. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -0
  211. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +73 -0
  212. package/dist/src/lib/db-codegen/dbTsTypes.js +139 -0
  213. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -0
  214. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +146 -0
  215. package/dist/src/lib/db-codegen/dbTypeIR.js +517 -0
  216. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -0
  217. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.d.ts +112 -0
  218. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js +211 -0
  219. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js.map +1 -0
  220. package/dist/src/lib/env-resolver-core.d.ts +147 -0
  221. package/dist/src/lib/env-resolver-core.js +265 -0
  222. package/dist/src/lib/env-resolver-core.js.map +1 -0
  223. package/dist/src/lib/env-resolver.d.ts +84 -0
  224. package/dist/src/lib/env-resolver.js +133 -0
  225. package/dist/src/lib/env-resolver.js.map +1 -0
  226. package/dist/src/lib/fetch.d.ts +5 -0
  227. package/dist/src/lib/generated-allowlist.d.ts +28 -0
  228. package/dist/src/lib/generated-allowlist.js +277 -0
  229. package/dist/src/lib/generated-allowlist.js.map +1 -0
  230. package/dist/src/lib/generated-config-surfaces.d.ts +682 -0
  231. package/dist/src/lib/generated-config-surfaces.js +4058 -0
  232. package/dist/src/lib/generated-config-surfaces.js.map +1 -0
  233. package/dist/src/lib/generated-template-lint.d.ts +212 -0
  234. package/dist/src/lib/generated-template-lint.js +624 -0
  235. package/dist/src/lib/generated-template-lint.js.map +1 -0
  236. package/dist/src/lib/init-adopt.d.ts +16 -0
  237. package/dist/src/lib/init-adopt.js +34 -0
  238. package/dist/src/lib/init-adopt.js.map +1 -0
  239. package/dist/src/lib/init-assets.d.ts +39 -0
  240. package/dist/src/lib/init-assets.js +97 -0
  241. package/dist/src/lib/init-assets.js.map +1 -0
  242. package/dist/src/lib/init-config.d.ts +98 -0
  243. package/dist/src/lib/init-config.js +186 -0
  244. package/dist/src/lib/init-config.js.map +1 -0
  245. package/dist/src/lib/init-ios-links.d.ts +50 -0
  246. package/dist/src/lib/init-ios-links.js +153 -0
  247. package/dist/src/lib/init-ios-links.js.map +1 -0
  248. package/dist/src/lib/init-plan.d.ts +80 -0
  249. package/dist/src/lib/init-plan.js +95 -0
  250. package/dist/src/lib/init-plan.js.map +1 -0
  251. package/dist/src/lib/init-production-env.d.ts +48 -0
  252. package/dist/src/lib/init-production-env.js +59 -0
  253. package/dist/src/lib/init-production-env.js.map +1 -0
  254. package/dist/src/lib/init-schema.d.ts +74 -0
  255. package/dist/src/lib/init-schema.js +358 -0
  256. package/dist/src/lib/init-schema.js.map +1 -0
  257. package/dist/src/lib/init-xcode.d.ts +33 -0
  258. package/dist/src/lib/init-xcode.js +114 -0
  259. package/dist/src/lib/init-xcode.js.map +1 -0
  260. package/dist/src/lib/integration-request-config.d.ts +30 -0
  261. package/dist/src/lib/integration-request-config.js +145 -0
  262. package/dist/src/lib/integration-request-config.js.map +1 -0
  263. package/dist/src/lib/local-state.d.ts +55 -0
  264. package/dist/src/lib/local-state.js +167 -0
  265. package/dist/src/lib/local-state.js.map +1 -0
  266. package/dist/src/lib/log-inspection.d.ts +568 -0
  267. package/dist/src/lib/log-inspection.js +639 -0
  268. package/dist/src/lib/log-inspection.js.map +1 -0
  269. package/dist/src/lib/migration-nag.d.ts +49 -0
  270. package/dist/src/lib/migration-nag.js +163 -0
  271. package/dist/src/lib/migration-nag.js.map +1 -0
  272. package/dist/src/lib/object-status-filter.d.ts +22 -0
  273. package/dist/src/lib/object-status-filter.js +45 -0
  274. package/dist/src/lib/object-status-filter.js.map +1 -0
  275. package/dist/src/lib/output.d.ts +109 -0
  276. package/dist/src/lib/output.js +191 -8
  277. package/dist/src/lib/output.js.map +1 -1
  278. package/dist/src/lib/package-manager.d.ts +140 -0
  279. package/dist/src/lib/package-manager.js +305 -0
  280. package/dist/src/lib/package-manager.js.map +1 -0
  281. package/dist/src/lib/paginate.d.ts +83 -0
  282. package/dist/src/lib/paginate.js +95 -0
  283. package/dist/src/lib/paginate.js.map +1 -0
  284. package/dist/src/lib/platform-owned.d.ts +63 -0
  285. package/dist/src/lib/platform-owned.js +85 -0
  286. package/dist/src/lib/platform-owned.js.map +1 -0
  287. package/dist/src/lib/project-config.d.ts +97 -0
  288. package/dist/src/lib/project-config.js +217 -0
  289. package/dist/src/lib/project-config.js.map +1 -0
  290. package/dist/src/lib/query-operators.d.ts +43 -0
  291. package/dist/src/lib/query-operators.js +80 -0
  292. package/dist/src/lib/query-operators.js.map +1 -0
  293. package/dist/src/lib/record-filter.d.ts +18 -0
  294. package/dist/src/lib/record-filter.js +55 -0
  295. package/dist/src/lib/record-filter.js.map +1 -0
  296. package/dist/src/lib/refresh-admin-credentials.d.ts +65 -0
  297. package/dist/src/lib/refresh-admin-credentials.js +103 -0
  298. package/dist/src/lib/refresh-admin-credentials.js.map +1 -0
  299. package/dist/src/lib/resolve-init-dev-port.d.ts +56 -0
  300. package/dist/src/lib/resolve-init-dev-port.js +55 -0
  301. package/dist/src/lib/resolve-init-dev-port.js.map +1 -0
  302. package/dist/src/lib/resolve-init-server.d.ts +64 -0
  303. package/dist/src/lib/resolve-init-server.js +77 -0
  304. package/dist/src/lib/resolve-init-server.js.map +1 -0
  305. package/dist/src/lib/resolve-platform.d.ts +74 -0
  306. package/dist/src/lib/resolve-platform.js +105 -0
  307. package/dist/src/lib/resolve-platform.js.map +1 -0
  308. package/dist/src/lib/run-status.d.ts +19 -0
  309. package/dist/src/lib/run-status.generated.d.ts +39 -0
  310. package/dist/src/lib/run-status.generated.js +66 -0
  311. package/dist/src/lib/run-status.generated.js.map +1 -0
  312. package/dist/src/lib/run-status.js +19 -0
  313. package/dist/src/lib/run-status.js.map +1 -0
  314. package/dist/src/lib/server-text-normalization.d.ts +51 -0
  315. package/dist/src/lib/server-text-normalization.js +90 -0
  316. package/dist/src/lib/server-text-normalization.js.map +1 -0
  317. package/dist/src/lib/server-url.d.ts +22 -0
  318. package/dist/src/lib/server-url.js +33 -0
  319. package/dist/src/lib/server-url.js.map +1 -0
  320. package/dist/src/lib/signing-secret-status.d.ts +81 -0
  321. package/dist/src/lib/signing-secret-status.js +116 -0
  322. package/dist/src/lib/signing-secret-status.js.map +1 -0
  323. package/dist/src/lib/skill-installer.d.ts +25 -0
  324. package/dist/src/lib/skill-installer.js +266 -0
  325. package/dist/src/lib/skill-installer.js.map +1 -0
  326. package/dist/src/lib/snapshots.d.ts +99 -0
  327. package/dist/src/lib/snapshots.js +357 -0
  328. package/dist/src/lib/snapshots.js.map +1 -0
  329. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +113 -0
  330. package/dist/src/lib/swift-codegen/dbGenerator.js +914 -0
  331. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -0
  332. package/dist/src/lib/swift-codegen/dbSwiftTypes.d.ts +42 -0
  333. package/dist/src/lib/swift-codegen/dbSwiftTypes.js +100 -0
  334. package/dist/src/lib/swift-codegen/dbSwiftTypes.js.map +1 -0
  335. package/dist/src/lib/swift-codegen/generator.d.ts +94 -0
  336. package/dist/src/lib/swift-codegen/generator.js +440 -0
  337. package/dist/src/lib/swift-codegen/generator.js.map +1 -0
  338. package/dist/src/lib/swift-codegen/schemaToSwift.d.ts +72 -0
  339. package/dist/src/lib/swift-codegen/schemaToSwift.js +644 -0
  340. package/dist/src/lib/swift-codegen/schemaToSwift.js.map +1 -0
  341. package/dist/src/lib/swift-codegen/siblingSymbols.d.ts +94 -0
  342. package/dist/src/lib/swift-codegen/siblingSymbols.js +155 -0
  343. package/dist/src/lib/swift-codegen/siblingSymbols.js.map +1 -0
  344. package/dist/src/lib/swift-codegen/swiftNaming.d.ts +85 -0
  345. package/dist/src/lib/swift-codegen/swiftNaming.js +198 -0
  346. package/dist/src/lib/swift-codegen/swiftNaming.js.map +1 -0
  347. package/dist/src/lib/sync-dir-selector.d.ts +21 -0
  348. package/dist/src/lib/sync-dir-selector.js +30 -0
  349. package/dist/src/lib/sync-dir-selector.js.map +1 -0
  350. package/dist/src/lib/sync-paths.d.ts +111 -0
  351. package/dist/src/lib/sync-paths.js +198 -0
  352. package/dist/src/lib/sync-paths.js.map +1 -0
  353. package/dist/src/lib/sync-resource-types.d.ts +544 -0
  354. package/dist/src/lib/sync-resource-types.js +975 -0
  355. package/dist/src/lib/sync-resource-types.js.map +1 -0
  356. package/dist/src/lib/sync-selectors.d.ts +95 -0
  357. package/dist/src/lib/sync-selectors.js +228 -0
  358. package/dist/src/lib/sync-selectors.js.map +1 -0
  359. package/dist/src/lib/template.d.ts +170 -0
  360. package/dist/src/lib/template.js +484 -68
  361. package/dist/src/lib/template.js.map +1 -1
  362. package/dist/src/lib/test-case-keys.d.ts +29 -0
  363. package/dist/src/lib/test-case-keys.js +55 -0
  364. package/dist/src/lib/test-case-keys.js.map +1 -0
  365. package/dist/src/lib/test-case-variables.d.ts +15 -0
  366. package/dist/src/lib/test-case-variables.js +29 -0
  367. package/dist/src/lib/test-case-variables.js.map +1 -0
  368. package/dist/src/lib/token-inject.d.ts +56 -0
  369. package/dist/src/lib/token-inject.js +204 -0
  370. package/dist/src/lib/token-inject.js.map +1 -0
  371. package/dist/src/lib/toml-database-config.d.ts +123 -0
  372. package/dist/src/lib/toml-database-config.js +527 -0
  373. package/dist/src/lib/toml-database-config.js.map +1 -0
  374. package/dist/src/lib/toml-metadata-config.d.ts +151 -0
  375. package/dist/src/lib/toml-metadata-config.js +476 -0
  376. package/dist/src/lib/toml-metadata-config.js.map +1 -0
  377. package/dist/src/lib/toml-native-form.d.ts +46 -0
  378. package/dist/src/lib/toml-native-form.js +78 -0
  379. package/dist/src/lib/toml-native-form.js.map +1 -0
  380. package/dist/src/lib/toml-params-validator.d.ts +129 -0
  381. package/dist/src/lib/toml-params-validator.js +298 -0
  382. package/dist/src/lib/toml-params-validator.js.map +1 -0
  383. package/dist/src/lib/toml-scalar-edit.d.ts +43 -0
  384. package/dist/src/lib/toml-scalar-edit.js +283 -0
  385. package/dist/src/lib/toml-scalar-edit.js.map +1 -0
  386. package/dist/src/lib/user-selector.d.ts +24 -0
  387. package/dist/src/lib/user-selector.js +33 -0
  388. package/dist/src/lib/user-selector.js.map +1 -0
  389. package/dist/src/lib/version-check.d.ts +35 -0
  390. package/dist/src/lib/version-check.js +241 -0
  391. package/dist/src/lib/version-check.js.map +1 -0
  392. package/dist/src/lib/watch.d.ts +121 -0
  393. package/dist/src/lib/watch.js +169 -0
  394. package/dist/src/lib/watch.js.map +1 -0
  395. package/dist/src/lib/workflow-apply.d.ts +110 -0
  396. package/dist/src/lib/workflow-apply.js +164 -0
  397. package/dist/src/lib/workflow-apply.js.map +1 -0
  398. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.d.ts +129 -0
  399. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js +269 -0
  400. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js.map +1 -0
  401. package/dist/src/lib/workflow-codegen/generator.d.ts +96 -0
  402. package/dist/src/lib/workflow-codegen/generator.js +361 -0
  403. package/dist/src/lib/workflow-codegen/generator.js.map +1 -0
  404. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +94 -0
  405. package/dist/src/lib/workflow-codegen/invokerIR.js +76 -0
  406. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -0
  407. package/dist/src/lib/workflow-codegen/naming.d.ts +33 -0
  408. package/dist/src/lib/workflow-codegen/naming.js +81 -0
  409. package/dist/src/lib/workflow-codegen/naming.js.map +1 -0
  410. package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +80 -0
  411. package/dist/src/lib/workflow-codegen/schemaToTs.js +303 -0
  412. package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -0
  413. package/dist/src/lib/workflow-config-apply.d.ts +70 -0
  414. package/dist/src/lib/workflow-config-apply.js +137 -0
  415. package/dist/src/lib/workflow-config-apply.js.map +1 -0
  416. package/dist/src/lib/workflow-config-sidecar.d.ts +63 -0
  417. package/dist/src/lib/workflow-config-sidecar.js +96 -0
  418. package/dist/src/lib/workflow-config-sidecar.js.map +1 -0
  419. package/dist/src/lib/workflow-defaults.d.ts +29 -0
  420. package/dist/src/lib/workflow-defaults.js +41 -0
  421. package/dist/src/lib/workflow-defaults.js.map +1 -0
  422. package/dist/src/lib/workflow-fragments.d.ts +64 -0
  423. package/dist/src/lib/workflow-fragments.js +342 -0
  424. package/dist/src/lib/workflow-fragments.js.map +1 -0
  425. package/dist/src/lib/workflow-include-preserve.d.ts +76 -0
  426. package/dist/src/lib/workflow-include-preserve.js +286 -0
  427. package/dist/src/lib/workflow-include-preserve.js.map +1 -0
  428. package/dist/src/lib/workflow-payload.d.ts +98 -0
  429. package/dist/src/lib/workflow-payload.js +178 -0
  430. package/dist/src/lib/workflow-payload.js.map +1 -0
  431. package/dist/src/lib/workflow-toml-validator.d.ts +202 -0
  432. package/dist/src/lib/workflow-toml-validator.js +757 -0
  433. package/dist/src/lib/workflow-toml-validator.js.map +1 -0
  434. package/dist/src/types/index.d.ts +581 -0
  435. package/dist/src/validators.d.ts +65 -0
  436. package/dist/src/validators.js +64 -0
  437. package/dist/src/validators.js.map +1 -0
  438. package/package.json +32 -8
@@ -1,6 +1,19 @@
1
+ import { Option } from "commander";
2
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync } from "fs";
3
+ import * as path from "path";
1
4
  import { ApiClient } from "../lib/api-client.js";
2
5
  import { getCurrentAppId } from "../lib/config.js";
3
- import { success, error, info, keyValue, formatTable, formatId, formatDate, json, } from "../lib/output.js";
6
+ import { resolveCodegenSourceDir } from "../lib/codegen-shared/resolveCodegenSourceDir.js";
7
+ import { resolveSyncDirOverride } from "../lib/sync-dir-selector.js";
8
+ import { parseCsv, applyColumnMap, buildRows } from "../lib/csv.js";
9
+ import { chunk, parseBatchSize } from "../lib/batch.js";
10
+ import { generateDbTypes, } from "../lib/db-codegen/dbGenerator.js";
11
+ import { generateDbSwiftTypes } from "../lib/swift-codegen/dbGenerator.js";
12
+ import { success, error, info, warn, keyValue, result as printResult, formatTable, formatId, formatDate, json, } from "../lib/output.js";
13
+ import { confirmPrompt } from "../lib/confirm-prompt.js";
14
+ import { parseFilterOptions } from "../lib/record-filter.js";
15
+ import { parseDataOption } from "../lib/data-input.js";
16
+ import { ulid } from "ulid";
4
17
  function resolveAppId(appId, options) {
5
18
  const resolved = appId || options.app || getCurrentAppId();
6
19
  if (!resolved) {
@@ -9,6 +22,175 @@ function resolveAppId(appId, options) {
9
22
  }
10
23
  return resolved;
11
24
  }
25
+ /**
26
+ * Render the flat `{ success, id, appliedFields }` response that
27
+ * `records/save` and `records/patch` return — these endpoints answer with a
28
+ * write receipt, not the stored record, so the human view shows the resolved id
29
+ * plus any server-applied fields (autoStamps / trigger-computed values).
30
+ */
31
+ function renderRecordWriteResult(result, fallbackId, message) {
32
+ success(message);
33
+ keyValue("id", String(result?.id ?? fallbackId));
34
+ const applied = result?.appliedFields;
35
+ if (applied && typeof applied === "object" && Object.keys(applied).length > 0) {
36
+ for (const [key, value] of Object.entries(applied)) {
37
+ keyValue(`applied.${key}`, value === null || value === undefined ? "—" : String(value));
38
+ }
39
+ }
40
+ }
41
+ /**
42
+ * Refuse a write whose `--data` carries an `id` that is not the record being
43
+ * written. The server strips `id` from the record body on both the save and the
44
+ * patch path, so such an `id` would be dropped silently and the write would
45
+ * land on the id from the argument — somewhere the caller did not intend.
46
+ *
47
+ * `recordId` is the id the caller passed as an argument (absent on a `save`
48
+ * that mints one); `writtenId` is the id actually being written. Exits non-zero
49
+ * when they disagree with `--data` (#1965 review).
50
+ */
51
+ function assertDataIdMatches(data, recordId, writtenId, commandPath) {
52
+ if (!data || typeof data !== "object")
53
+ return;
54
+ const dataId = data.id;
55
+ if (dataId === undefined || dataId === writtenId)
56
+ return;
57
+ error(recordId
58
+ ? `The "id" in --data ("${dataId}") does not match the record id "${recordId}". Remove it, or pass the id you mean as the argument.`
59
+ : `Pass the record id as the argument, not inside --data: ${commandPath} ${dataId} --data ...`);
60
+ process.exit(1);
61
+ }
62
+ /**
63
+ * Validate that the named operation is an import-compatible, save-like
64
+ * mutation before importing (issue #145, Codex finding (a)).
65
+ *
66
+ * The importer always builds `params: { modelName, id, data }`, and the batch
67
+ * endpoint rejects undeclared params per item. So we require the op to be a
68
+ * `mutation` whose declared `params` cover `modelName`, `id`, and `data`. This
69
+ * surfaces a clear up-front error instead of letting every row fail
70
+ * server-side. Exits non-zero on any mismatch.
71
+ */
72
+ async function validateImportOperation(client, appId, databaseId, operationName) {
73
+ let ops;
74
+ try {
75
+ ops = await client.listDatabaseOperations(appId, databaseId);
76
+ }
77
+ catch (err) {
78
+ error(`Could not list operations for this database: ${err?.message || err}`);
79
+ process.exit(1);
80
+ return;
81
+ }
82
+ const op = (ops || []).find((o) => o?.name === operationName);
83
+ if (!op) {
84
+ error(`Operation "${operationName}" not found — create a mutation op (e.g. ` +
85
+ `\`seed_save\`) on this database type first.`);
86
+ process.exit(1);
87
+ return;
88
+ }
89
+ if (op.type !== "mutation") {
90
+ error(`Operation "${operationName}" is a "${op.type}" op, not an import-compatible ` +
91
+ `mutation. CSV import needs a save-like mutation taking {modelName, id, data}.`);
92
+ process.exit(1);
93
+ return;
94
+ }
95
+ const params = op.params || {};
96
+ const missing = ["modelName", "id", "data"].filter((p) => !(p in params));
97
+ if (missing.length > 0) {
98
+ error(`Operation "${operationName}" is not import-compatible: its params are missing ` +
99
+ `${missing.join(", ")}. CSV import builds {modelName, id, data} per item, so the ` +
100
+ `op must declare those params (like \`seed_save\`).`);
101
+ process.exit(1);
102
+ return;
103
+ }
104
+ }
105
+ /**
106
+ * The full `databases records bulk` precondition value space, audited in one
107
+ * pass (issue #2437, Codex cycle 3). A precondition is field-equality only, so
108
+ * a value is one of: `null`, a boolean, a number, or a string. For each, what
109
+ * the DatabaseDO `condition` grammar can express, compared against the
110
+ * documents twin's strict JS equality (`evaluatePrecondition`):
111
+ *
112
+ * | Value | Disposition | Why |
113
+ * |-------------------|-------------|----------------------------------------------------------------------|
114
+ * | string | round-trips | bound as TEXT; SQLite never equates TEXT with a number or a JSON bool |
115
+ * | number ≠ 0 and ≠1 | round-trips | bound as-is; no other JSON type extracts to that value |
116
+ * | `null` | rewritten | paired with `$exists: true` so an absent key fails (cycle-2 fix) |
117
+ * | `true` / `false` | REJECTED | bound as 1/0, and `json_extract` yields 1/0 for JSON booleans too |
118
+ * | number 0 or 1 | REJECTED | same collision seen from the other side: matches a stored `false`/`true` |
119
+ * | `{}` (no fields) | see #2511 | sent as an empty `condition`, which the DO reads as "record must exist" |
120
+ * | object / array | rejected | not field-equality; rejected by the bulk validator before reaching here |
121
+ *
122
+ * The two REJECTED rows are the boolean/number collision: `JsonQueryTranslator`
123
+ * binds `true`/`false` as `1`/`0`, and SQLite's `json_extract` also returns a
124
+ * stored JSON boolean as `1`/`0`, so `{ enabled: true }` matches a record
125
+ * holding the number `1` and `{ retries: 1 }` matches a record holding `true`.
126
+ * The documents twin compares with `!==` and fails both. The `condition`
127
+ * grammar has no type predicate (`$exists` only distinguishes present from
128
+ * absent), so this cannot be translated exactly — the CLI rejects those values
129
+ * at the boundary instead, so the databases twin fails loudly rather than
130
+ * running with a weaker guard than the caller wrote. Issue #2507 tracks giving
131
+ * the grammar a type-aware equality, which would let these values be accepted.
132
+ *
133
+ * Returns an explanation when the value cannot be translated exactly, or
134
+ * `null` when it round-trips or is rewritten.
135
+ */
136
+ export function untranslatablePreconditionValue(value) {
137
+ if (typeof value === "boolean") {
138
+ return (`a boolean precondition cannot be checked exactly on a database — the ` +
139
+ `record store compares JSON booleans as 1/0, so \`${value}\` would also ` +
140
+ `match a record holding the number ${value ? 1 : 0}`);
141
+ }
142
+ if (value === 0 || value === 1) {
143
+ return (`the number ${value} cannot be checked exactly on a database — the ` +
144
+ `record store compares JSON booleans as 1/0, so ${value} would also ` +
145
+ `match a record holding \`${value === 1}\``);
146
+ }
147
+ return null;
148
+ }
149
+ /**
150
+ * Translate a `databases records bulk` op's documents-surface `precondition`
151
+ * into the DatabaseDO's per-op `condition` (issue #2437). See
152
+ * {@link untranslatablePreconditionValue} for the audit of the whole value
153
+ * space; this function handles the one value that is REWRITTEN rather than
154
+ * passed through or rejected.
155
+ *
156
+ * The two are NOT the same grammar, and a straight hand-off silently weakens
157
+ * the guard for one value: `null`. A `condition` is a query filter, so
158
+ * `{ approvedAt: null }` translates to
159
+ * `json_extract(_data, '$.approvedAt') IS NULL` — which is also true when the
160
+ * key is ABSENT. A guarded patch/delete would then execute against a record
161
+ * that never had the field. The documents surface evaluates the same
162
+ * precondition in JS (`evaluatePrecondition` in `src/document-models.ts`): an
163
+ * absent field reads `undefined`, and `undefined !== null`, so the guard fails
164
+ * closed there.
165
+ *
166
+ * Pin the database side to the documents meaning — "the key is present AND
167
+ * holds null" — by pairing each null check with an existence constraint.
168
+ * `$exists: true` translates to `json_type(_data, '$.<field>') IS NOT NULL`,
169
+ * true for a key holding JSON null and false for an absent key, so the pair
170
+ * emits `json_extract(...) IS NULL AND json_type(...) IS NOT NULL`.
171
+ *
172
+ * Non-null scalars need no rewrite: `= ?` against `json_extract` already fails
173
+ * for an absent field, because NULL never equals a bound value.
174
+ *
175
+ * Assumes the caller has already rejected `$`-prefixed keys, non-scalar values,
176
+ * and the values {@link untranslatablePreconditionValue} names — which is what
177
+ * leaves `$and` free to use here and what makes a pass-through exact.
178
+ */
179
+ export function preconditionToCondition(precondition) {
180
+ const nullFields = Object.keys(precondition).filter((f) => precondition[f] === null);
181
+ if (nullFields.length === 0)
182
+ return { ...precondition };
183
+ const condition = {};
184
+ for (const [field, expected] of Object.entries(precondition)) {
185
+ if (expected !== null)
186
+ condition[field] = expected;
187
+ }
188
+ condition.$and = nullFields.flatMap((field) => [
189
+ { [field]: null },
190
+ { [field]: { $exists: true } },
191
+ ]);
192
+ return condition;
193
+ }
12
194
  export function registerDatabasesCommands(program) {
13
195
  const databases = program
14
196
  .command("databases")
@@ -16,10 +198,32 @@ export function registerDatabasesCommands(program) {
16
198
  .addHelpText("after", `
17
199
  Examples:
18
200
  $ primitive databases list
201
+ $ primitive databases list --owner <user-id>
19
202
  $ primitive databases create "My Database"
20
203
  $ primitive databases get <database-id>
204
+ $ primitive databases records models <database-id>
205
+ $ primitive databases records describe <database-id> <model-name>
206
+ $ primitive databases records query <database-id> <model-name> --filter '{"name":"Alice"}'
207
+ $ primitive databases records get <database-id> <model-name> <record-id>
208
+ $ primitive databases records count <database-id> <model-name> --filter '{"status":"open"}'
209
+ $ primitive databases records aggregate <database-id> <model-name> --op avg --field price
210
+ $ primitive databases records save <database-id> <model-name> [record-id] --data '{"name":"Alice"}'
211
+ $ primitive databases records patch <database-id> <model-name> <record-id> --data '{"name":"Alice"}'
212
+ $ primitive databases operations list <database-id>
213
+ $ primitive databases operations execute <database-id> <operation-name> --params '{"key":"value"}'
214
+ $ primitive databases indexes list <database-id>
215
+ $ primitive databases indexes create <database-id> --model contacts --field email
216
+ $ primitive databases records delete <database-id> <model-name> <record-id> --yes
217
+ $ primitive databases records delete-all <database-id> <model-name> --yes
218
+ $ primitive databases records delete-all <database-id> <model-name> --filter '{"status":"draft"}' --yes
219
+ $ primitive databases records bulk <database-id> --data-file ops.json --yes
21
220
  $ primitive databases permissions list <database-id>
22
- $ primitive databases group-permissions list <database-id>
221
+
222
+ Note: this group manages database *instances*. Database *type* configurations
223
+ (schemas, rules, operations) are authored in TOML — see 'primitive config fields
224
+ database-type-config' and the 'database-type-configs' group for inspection:
225
+ $ primitive database-type-configs list
226
+ $ rm database-type-configs/<database-type>.toml && primitive config push --prune
23
227
  `);
24
228
  // List databases
25
229
  databases
@@ -27,12 +231,15 @@ Examples:
27
231
  .description("List databases in an app")
28
232
  .argument("[app-id]", "App ID (uses current app if not specified)")
29
233
  .option("--app <app-id>", "App ID")
234
+ .option("--owner <user-id>", "Only list databases created by this user")
30
235
  .option("--json", "Output as JSON")
31
236
  .action(async (appId, options) => {
32
237
  const resolvedAppId = resolveAppId(appId, options);
33
238
  const client = new ApiClient();
34
239
  try {
35
- const result = await client.listDatabases(resolvedAppId);
240
+ const result = await client.listDatabases(resolvedAppId, {
241
+ owner: options.owner,
242
+ });
36
243
  const list = Array.isArray(result) ? result : result?.databases ?? [];
37
244
  if (options.json) {
38
245
  json(list);
@@ -45,7 +252,20 @@ Examples:
45
252
  console.log(formatTable(list, [
46
253
  { header: "ID", key: "databaseId", format: formatId },
47
254
  { header: "TITLE", key: "title" },
255
+ { header: "TYPE", key: "databaseType", format: (v) => v || "—" },
48
256
  { header: "PERMISSION", key: "permission" },
257
+ // `permission` reads "owner" on every row for an app admin, so it
258
+ // cannot confirm --owner narrowed anything. Show who created each
259
+ // database when the filter is in play.
260
+ ...(options.owner
261
+ ? [
262
+ {
263
+ header: "CREATED BY",
264
+ key: "createdBy",
265
+ format: (v) => (v ? formatId(v) : "—"),
266
+ },
267
+ ]
268
+ : []),
49
269
  { header: "CREATED", key: "createdAt", format: formatDate },
50
270
  ]));
51
271
  }
@@ -60,15 +280,39 @@ Examples:
60
280
  .description("Create a new database")
61
281
  .argument("<title>", "Database title")
62
282
  .option("--app <app-id>", "App ID")
63
- .option("--default-permission <level>", "Default permission for all app users: reader or read-write")
283
+ .requiredOption("--type <database-type>", "Database type (required)")
284
+ .option("--cel-context <json>", "[deprecated] Initial CEL context as JSON (read from rules as database.celContext.*). Prefer --initial-metadata (resource metadata categories, separate readRule/writeRule).")
285
+ .option("--metadata <json>", "[deprecated] Initial CEL context as JSON (legacy alias for --cel-context); prefer --initial-metadata")
286
+ .option("--initial-metadata <json>", "Create-time resource metadata as JSON, keyed by category name (issue #1420). The recommended replacement for --cel-context: read from rules as md.self.<category>.<key>.")
64
287
  .option("--json", "Output as JSON")
65
288
  .action(async (title, options) => {
66
289
  const resolvedAppId = resolveAppId(undefined, options);
67
290
  const client = new ApiClient();
68
291
  try {
69
- const params = { title };
70
- if (options.defaultPermission) {
71
- params.defaultPermission = options.defaultPermission;
292
+ const params = { title, databaseType: options.type };
293
+ // `--cel-context` is the new user-facing name; `--metadata` remains
294
+ // supported for backwards compatibility. When both are provided,
295
+ // --cel-context wins.
296
+ const contextSource = options.celContext ?? options.metadata;
297
+ if (contextSource) {
298
+ try {
299
+ params.metadata = JSON.parse(contextSource);
300
+ }
301
+ catch {
302
+ error(`Invalid ${options.celContext ? "--cel-context" : "--metadata"} JSON.`);
303
+ process.exit(1);
304
+ }
305
+ }
306
+ // `--initial-metadata` is a separate concept: create-time per-category
307
+ // ResourceMetadata rows (issue #1420), not the database CEL context.
308
+ if (options.initialMetadata) {
309
+ try {
310
+ params.initialMetadata = JSON.parse(options.initialMetadata);
311
+ }
312
+ catch {
313
+ error("Invalid --initial-metadata JSON.");
314
+ process.exit(1);
315
+ }
72
316
  }
73
317
  const result = await client.createDatabase(resolvedAppId, params);
74
318
  if (options.json) {
@@ -78,8 +322,8 @@ Examples:
78
322
  success("Database created.");
79
323
  keyValue("Database ID", result.databaseId);
80
324
  keyValue("Title", result.title);
81
- if (result.defaultPermission) {
82
- keyValue("Default Permission", result.defaultPermission);
325
+ if (result.databaseType) {
326
+ keyValue("Database Type", result.databaseType);
83
327
  }
84
328
  }
85
329
  catch (err) {
@@ -103,11 +347,11 @@ Examples:
103
347
  json(result);
104
348
  return;
105
349
  }
106
- keyValue("Database ID", result.databaseId);
107
- keyValue("Title", result.title);
108
- keyValue("Default Permission", result.defaultPermission || "none");
109
- keyValue("Permission", result.permission);
110
- keyValue("Created", formatDate(result.createdAt));
350
+ printResult("Database ID", result.databaseId);
351
+ printResult("Title", result.title);
352
+ printResult("Database Type", result.databaseType || "none");
353
+ printResult("Permission", result.permission || "none");
354
+ printResult("Created", formatDate(result.createdAt));
111
355
  }
112
356
  catch (err) {
113
357
  error(err.message);
@@ -117,11 +361,11 @@ Examples:
117
361
  // Update database
118
362
  databases
119
363
  .command("update")
120
- .description("Update a database (title, default permission)")
364
+ .description("Update a database (title, type)")
121
365
  .argument("<database-id>", "Database ID")
122
366
  .option("--app <app-id>", "App ID")
123
367
  .option("--title <title>", "New title")
124
- .option("--default-permission <level>", "Default permission for all app users: reader, read-write, or none")
368
+ .option("--type <database-type>", "Database type (use 'none' to clear)")
125
369
  .option("--json", "Output as JSON")
126
370
  .action(async (databaseId, options) => {
127
371
  const resolvedAppId = resolveAppId(undefined, options);
@@ -130,14 +374,14 @@ Examples:
130
374
  if (options.title) {
131
375
  params.title = options.title;
132
376
  }
133
- if (options.defaultPermission !== undefined) {
134
- params.defaultPermission =
135
- options.defaultPermission === "none"
377
+ if (options.type !== undefined) {
378
+ params.databaseType =
379
+ options.type === "none"
136
380
  ? null
137
- : options.defaultPermission;
381
+ : options.type;
138
382
  }
139
383
  if (Object.keys(params).length === 0) {
140
- error("Provide at least one of --title or --default-permission.");
384
+ error("Provide at least one of --title or --type.");
141
385
  process.exit(1);
142
386
  }
143
387
  try {
@@ -149,7 +393,7 @@ Examples:
149
393
  success("Database updated.");
150
394
  keyValue("Database ID", result.databaseId);
151
395
  keyValue("Title", result.title);
152
- keyValue("Default Permission", result.defaultPermission || "none");
396
+ keyValue("Database Type", result.databaseType || "none");
153
397
  }
154
398
  catch (err) {
155
399
  error(err.message);
@@ -166,15 +410,14 @@ Examples:
166
410
  .action(async (databaseId, options) => {
167
411
  const resolvedAppId = resolveAppId(undefined, options);
168
412
  if (!options.yes) {
169
- const inquirer = await import("inquirer");
170
- const { confirm } = await inquirer.default.prompt([
171
- {
172
- type: "confirm",
173
- name: "confirm",
174
- message: `Delete database ${databaseId}? This cannot be undone.`,
175
- default: false,
176
- },
177
- ]);
413
+ let confirm;
414
+ try {
415
+ confirm = await confirmPrompt(`Delete database ${databaseId}? This cannot be undone.`);
416
+ }
417
+ catch (err) {
418
+ error(err.message);
419
+ process.exit(1);
420
+ }
178
421
  if (!confirm) {
179
422
  info("Cancelled.");
180
423
  return;
@@ -227,157 +470,709 @@ Examples:
227
470
  process.exit(1);
228
471
  }
229
472
  });
230
- // Grant permission
473
+ // Add manager action handler
474
+ const addManagerAction = async (databaseId, options) => {
475
+ const resolvedAppId = resolveAppId(undefined, options);
476
+ const client = new ApiClient();
477
+ try {
478
+ const result = await client.addDatabaseManager(resolvedAppId, databaseId, options.userId);
479
+ if (options.json) {
480
+ json(result);
481
+ return;
482
+ }
483
+ success(`Manager added: user ${options.userId}.`);
484
+ }
485
+ catch (err) {
486
+ error(err.message);
487
+ process.exit(1);
488
+ }
489
+ };
490
+ // Remove manager action handler
491
+ const removeManagerAction = async (databaseId, userId, options) => {
492
+ const resolvedAppId = resolveAppId(undefined, options);
493
+ if (!options.yes) {
494
+ let confirm;
495
+ try {
496
+ confirm = await confirmPrompt(`Remove manager ${userId} from database ${databaseId}?`);
497
+ }
498
+ catch (err) {
499
+ error(err.message);
500
+ process.exit(1);
501
+ }
502
+ if (!confirm) {
503
+ info("Cancelled.");
504
+ return;
505
+ }
506
+ }
507
+ const client = new ApiClient();
508
+ try {
509
+ await client.removeDatabaseManager(resolvedAppId, databaseId, userId);
510
+ success(`Manager removed: user ${userId}.`);
511
+ }
512
+ catch (err) {
513
+ error(err.message);
514
+ process.exit(1);
515
+ }
516
+ };
517
+ // Add manager (primary command)
518
+ permissions
519
+ .command("add-manager")
520
+ .description("Add a user as a manager of a database")
521
+ .argument("<database-id>", "Database ID")
522
+ .requiredOption("--user-id <user-id>", "User ID to add as manager")
523
+ .option("--app <app-id>", "App ID")
524
+ .option("--json", "Output as JSON")
525
+ .action(addManagerAction);
526
+ // Remove manager (primary command)
527
+ permissions
528
+ .command("remove-manager")
529
+ .description("Remove a manager from a database")
530
+ .argument("<database-id>", "Database ID")
531
+ .argument("<user-id>", "User ID to remove")
532
+ .option("--app <app-id>", "App ID")
533
+ .option("-y, --yes", "Skip confirmation prompt")
534
+ .action(removeManagerAction);
535
+ // Grant permission (deprecated alias for add-manager)
231
536
  permissions
232
537
  .command("grant")
233
- .description("Grant a user permission on a database")
538
+ .description("(deprecated: use add-manager) Grant a user permission on a database")
234
539
  .argument("<database-id>", "Database ID")
235
540
  .requiredOption("--user-id <user-id>", "User ID to grant permission to")
236
- .requiredOption("--permission <permission>", "Permission level: owner, read-write, or reader")
541
+ .option("--permission <permission>", "Permission level (ignored, always 'manager')")
542
+ .option("--app <app-id>", "App ID")
543
+ .option("--json", "Output as JSON")
544
+ .action(addManagerAction);
545
+ // Revoke permission (deprecated alias for remove-manager)
546
+ permissions
547
+ .command("revoke")
548
+ .description("(deprecated: use remove-manager) Revoke a user's permission on a database")
549
+ .argument("<database-id>", "Database ID")
550
+ .argument("<user-id>", "User ID to revoke")
551
+ .option("--app <app-id>", "App ID")
552
+ .option("-y, --yes", "Skip confirmation prompt")
553
+ .action(removeManagerAction);
554
+ // ---- Records subcommand group ----
555
+ const records = databases
556
+ .command("records")
557
+ .description("Inspect and query records in a database");
558
+ // List models
559
+ records
560
+ .command("models")
561
+ .description("List model names in a database")
562
+ .argument("<database-id>", "Database ID")
237
563
  .option("--app <app-id>", "App ID")
238
564
  .option("--json", "Output as JSON")
239
565
  .action(async (databaseId, options) => {
240
566
  const resolvedAppId = resolveAppId(undefined, options);
241
567
  const client = new ApiClient();
242
568
  try {
243
- const result = await client.grantDatabasePermission(resolvedAppId, databaseId, {
244
- userId: options.userId,
245
- permission: options.permission,
246
- });
569
+ const result = await client.listDatabaseModels(resolvedAppId, databaseId);
570
+ const models = result.models || [];
247
571
  if (options.json) {
248
- json(result);
572
+ json(models);
573
+ return;
574
+ }
575
+ if (models.length === 0) {
576
+ info("No models found.");
249
577
  return;
250
578
  }
251
- success(`Permission '${options.permission}' granted to user ${options.userId}.`);
579
+ for (const m of models) {
580
+ console.log(` ${m}`);
581
+ }
252
582
  }
253
583
  catch (err) {
254
584
  error(err.message);
255
585
  process.exit(1);
256
586
  }
257
587
  });
258
- // Revoke permission
259
- permissions
260
- .command("revoke")
261
- .description("Revoke a user's permission on a database")
588
+ // Describe model
589
+ records
590
+ .command("describe")
591
+ .description("Show inferred schema for a model")
262
592
  .argument("<database-id>", "Database ID")
263
- .argument("<user-id>", "User ID to revoke")
593
+ .argument("<model-name>", "Model name")
264
594
  .option("--app <app-id>", "App ID")
265
- .option("-y, --yes", "Skip confirmation prompt")
266
- .action(async (databaseId, userId, options) => {
595
+ .option("--json", "Output as JSON")
596
+ .action(async (databaseId, modelName, options) => {
267
597
  const resolvedAppId = resolveAppId(undefined, options);
268
- if (!options.yes) {
269
- const inquirer = await import("inquirer");
270
- const { confirm } = await inquirer.default.prompt([
271
- {
272
- type: "confirm",
273
- name: "confirm",
274
- message: `Revoke permission for user ${userId} on database ${databaseId}?`,
275
- default: false,
598
+ const client = new ApiClient();
599
+ try {
600
+ const result = await client.describeDatabaseModel(resolvedAppId, databaseId, modelName);
601
+ const fields = result.fields || [];
602
+ if (options.json) {
603
+ json(fields);
604
+ return;
605
+ }
606
+ if (fields.length === 0) {
607
+ info("No fields detected.");
608
+ return;
609
+ }
610
+ console.log(formatTable(fields, [
611
+ { header: "FIELD", key: "field_name" },
612
+ { header: "TYPE", key: "inferred_type" },
613
+ ]));
614
+ }
615
+ catch (err) {
616
+ error(err.message);
617
+ process.exit(1);
618
+ }
619
+ });
620
+ // Query records (direct introspection via the records route)
621
+ records
622
+ .command("query")
623
+ .description("Query records in a database model (admin only)")
624
+ .argument("<database-id>", "Database ID")
625
+ .argument("<model-name>", "Model name to query")
626
+ .option("--app <app-id>", "App ID")
627
+ .option("--filter <json>", "Filter as JSON (e.g. '{\"name\":\"Alice\"}')")
628
+ .option("--filter-file <path>", "Read filter from a JSON or TOML file")
629
+ .option("--limit <n>", "Maximum number of records to return", parseInt)
630
+ .option("--cursor <cursor>", "Pagination cursor from previous query")
631
+ .option("--json", "Output as JSON")
632
+ .action(async (databaseId, modelName, options) => {
633
+ const resolvedAppId = resolveAppId(undefined, options);
634
+ const client = new ApiClient();
635
+ const filter = parseFilterOptions(options);
636
+ try {
637
+ const result = await client.queryDatabaseRecords(resolvedAppId, databaseId, modelName, { filter, limit: options.limit, cursor: options.cursor });
638
+ if (options.json) {
639
+ json(result);
640
+ return;
641
+ }
642
+ const records = result.items;
643
+ if (records.length === 0) {
644
+ info("No records found.");
645
+ return;
646
+ }
647
+ // Auto-detect columns from record keys
648
+ const allKeys = new Set();
649
+ for (const rec of records) {
650
+ for (const key of Object.keys(rec)) {
651
+ allKeys.add(key);
652
+ }
653
+ }
654
+ // Put id first, then type, then the rest sorted
655
+ const orderedKeys = [];
656
+ if (allKeys.has("id")) {
657
+ orderedKeys.push("id");
658
+ allKeys.delete("id");
659
+ }
660
+ if (allKeys.has("type")) {
661
+ orderedKeys.push("type");
662
+ allKeys.delete("type");
663
+ }
664
+ orderedKeys.push(...[...allKeys].sort());
665
+ const columns = orderedKeys.map((key) => ({
666
+ header: key.toUpperCase(),
667
+ key,
668
+ format: (v) => {
669
+ if (v === null || v === undefined)
670
+ return "—";
671
+ if (typeof v === "object")
672
+ return JSON.stringify(v);
673
+ return String(v);
276
674
  },
277
- ]);
278
- if (!confirm) {
279
- info("Cancelled.");
675
+ }));
676
+ console.log(formatTable(records, columns));
677
+ if (result.nextCursor) {
678
+ info(`More results available. Use --cursor ${result.nextCursor}`);
679
+ }
680
+ }
681
+ catch (err) {
682
+ error(err.message);
683
+ process.exit(1);
684
+ }
685
+ });
686
+ // Get a single record by id
687
+ records
688
+ .command("get")
689
+ .description("Get a single record by id (admin only)")
690
+ .argument("<database-id>", "Database ID")
691
+ .argument("<model-name>", "Model name")
692
+ .argument("<record-id>", "Record ID")
693
+ .option("--app <app-id>", "App ID")
694
+ .option("--json", "Output as JSON")
695
+ .action(async (databaseId, modelName, recordId, options) => {
696
+ const resolvedAppId = resolveAppId(undefined, options);
697
+ const client = new ApiClient();
698
+ try {
699
+ // A single-record fetch is a query filtered by primary-key id (there is
700
+ // no dedicated get endpoint — reuse queryDatabaseRecords).
701
+ const result = await client.queryDatabaseRecords(resolvedAppId, databaseId, modelName, { filter: { id: recordId }, limit: 1 });
702
+ const record = result.items[0];
703
+ if (options.json) {
704
+ json(record ?? null);
280
705
  return;
281
706
  }
707
+ if (!record) {
708
+ info(`No record found with id ${recordId} in ${modelName}.`);
709
+ return;
710
+ }
711
+ // `get` is a data command, so its fields go to stdout via `result()`
712
+ // — the #711 split (`result()` = the data the caller asked for,
713
+ // `keyValue()` = a post-action diagnostic summary). Issue #2437; the
714
+ // documents twin renders identically.
715
+ for (const [key, value] of Object.entries(record)) {
716
+ const rendered = value === null || value === undefined
717
+ ? "—"
718
+ : typeof value === "object"
719
+ ? JSON.stringify(value)
720
+ : String(value);
721
+ printResult(key, rendered);
722
+ }
723
+ }
724
+ catch (err) {
725
+ error(err.message);
726
+ process.exit(1);
282
727
  }
728
+ });
729
+ // Count records
730
+ records
731
+ .command("count")
732
+ .description("Count records in a database model (admin only)")
733
+ .argument("<database-id>", "Database ID")
734
+ .argument("<model-name>", "Model name")
735
+ .option("--app <app-id>", "App ID")
736
+ .option("--filter <json>", "Filter as JSON (e.g. '{\"name\":\"Alice\"}')")
737
+ .option("--filter-file <path>", "Read filter from a JSON or TOML file")
738
+ .option("--json", "Output as JSON")
739
+ .action(async (databaseId, modelName, options) => {
740
+ const resolvedAppId = resolveAppId(undefined, options);
741
+ const filter = parseFilterOptions(options);
283
742
  const client = new ApiClient();
284
743
  try {
285
- await client.revokeDatabasePermission(resolvedAppId, databaseId, userId);
286
- success(`Permission revoked for user ${userId}.`);
744
+ const result = await client.countDatabaseRecords(resolvedAppId, databaseId, modelName, filter);
745
+ if (options.json) {
746
+ json(result);
747
+ return;
748
+ }
749
+ keyValue("count", String(result?.count ?? 0));
287
750
  }
288
751
  catch (err) {
289
752
  error(err.message);
290
753
  process.exit(1);
291
754
  }
292
755
  });
293
- // ---- Group Permissions subcommand group ----
294
- const groupPermissions = databases
295
- .command("group-permissions")
296
- .description("Manage group permissions on a database");
297
- // List group permissions
298
- groupPermissions
299
- .command("list")
300
- .description("List group permissions for a database")
756
+ // Aggregate records
757
+ const AGGREGATE_OPS = ["count", "sum", "avg", "min", "max"];
758
+ records
759
+ .command("aggregate")
760
+ .description("Aggregate records in a database model (count/sum/avg/min/max, optional group-by; admin only)")
301
761
  .argument("<database-id>", "Database ID")
762
+ .argument("<model-name>", "Model name")
302
763
  .option("--app <app-id>", "App ID")
764
+ .requiredOption("--op <operation>", `Aggregate operation: ${AGGREGATE_OPS.join("|")}`)
765
+ .option("--field <field>", "Field to aggregate (required for sum/avg/min/max)")
766
+ .option("--group-by <field>", "Group results by this field (repeatable)", (value, previous = []) => previous.concat(value), [])
767
+ .option("--filter <json>", "Filter as JSON")
768
+ .option("--filter-file <path>", "Read filter from a JSON or TOML file")
303
769
  .option("--json", "Output as JSON")
304
- .action(async (databaseId, options) => {
770
+ .action(async (databaseId, modelName, options) => {
305
771
  const resolvedAppId = resolveAppId(undefined, options);
772
+ if (!AGGREGATE_OPS.includes(options.op)) {
773
+ error(`Invalid --op "${options.op}". Expected one of: ${AGGREGATE_OPS.join(", ")}.`);
774
+ process.exit(1);
775
+ }
776
+ const operation = { type: options.op };
777
+ if (options.op === "count") {
778
+ if (options.field) {
779
+ error("--field is not valid for --op count.");
780
+ process.exit(1);
781
+ }
782
+ }
783
+ else {
784
+ if (!options.field) {
785
+ error(`--op ${options.op} requires --field.`);
786
+ process.exit(1);
787
+ }
788
+ operation.field = options.field;
789
+ }
790
+ const filter = parseFilterOptions(options);
791
+ const groupBy = options.groupBy || [];
306
792
  const client = new ApiClient();
307
793
  try {
308
- const result = await client.listDatabaseGroupPermissions(resolvedAppId, databaseId);
309
- const list = Array.isArray(result) ? result : result?.permissions ?? [];
794
+ const result = await client.aggregateDatabaseRecords(resolvedAppId, databaseId, modelName, { operations: [operation], groupBy, filter });
310
795
  if (options.json) {
311
- json(list);
796
+ json(result);
312
797
  return;
313
798
  }
314
- if (list.length === 0) {
315
- info("No group permissions found.");
799
+ const aggregated = result?.result;
800
+ // Label the value column after the operation, including the field it
801
+ // ran over: `SUM` alone does not say what was summed. The field name is
802
+ // reproduced verbatim in both labels — it is a user-supplied
803
+ // identifier, and case-folding it produced `avg(lastvalue)` for
804
+ // `--field lastValue` (issue #2357).
805
+ const valueLabel = options.op === "count"
806
+ ? "COUNT"
807
+ : `${options.op.toUpperCase()}(${options.field})`;
808
+ const scalarLabel = options.op === "count"
809
+ ? "count"
810
+ : `${options.op.toLowerCase()}(${options.field})`;
811
+ if (groupBy.length === 0) {
812
+ // Ungrouped: one scalar per operation, keyed by the endpoint's
813
+ // operation name (`count`, `sum_<field>`, …). Render it as a
814
+ // key/value line — there is no group column to table.
815
+ const value = (aggregated ?? {})[options.op === "count" ? "count" : `${options.op}_${options.field}`];
816
+ keyValue(scalarLabel, value === null || value === undefined ? "—" : String(value));
316
817
  return;
317
818
  }
318
- console.log(formatTable(list, [
319
- { header: "GROUP_TYPE", key: "groupType" },
320
- { header: "GROUP_ID", key: "groupId" },
321
- { header: "PERMISSION", key: "permission" },
322
- { header: "GRANTED", key: "grantedAt", format: formatDate },
323
- ]));
819
+ if (aggregated &&
820
+ typeof aggregated === "object" &&
821
+ !Array.isArray(aggregated) &&
822
+ Object.values(aggregated).every((v) => typeof v !== "object" || v === null)) {
823
+ // Flat { group -> value } shape (single operation, single group-by): render a table.
824
+ const rows = Object.entries(aggregated).map(([group, value]) => ({
825
+ group,
826
+ value: value === null || value === undefined ? "—" : String(value),
827
+ }));
828
+ console.log(formatTable(rows, [
829
+ { header: "GROUP", key: "group" },
830
+ { header: valueLabel, key: "value" },
831
+ ]));
832
+ }
833
+ else {
834
+ // Grouped/nested or multi-operation shape: print the structured result.
835
+ console.log(JSON.stringify(aggregated ?? {}, null, 2));
836
+ }
324
837
  }
325
838
  catch (err) {
326
839
  error(err.message);
327
840
  process.exit(1);
328
841
  }
329
842
  });
330
- // Grant group permission
331
- groupPermissions
332
- .command("grant")
333
- .description("Grant a group permission on a database")
843
+ // Upsert a record (create, or merge into an existing one)
844
+ records
845
+ .command("save")
846
+ .description("Create a record, or merge fields into an existing one (admin only)")
334
847
  .argument("<database-id>", "Database ID")
335
- .requiredOption("--group-type <type>", "Group type")
336
- .requiredOption("--group-id <id>", "Group ID")
337
- .requiredOption("--permission <permission>", "Permission level: read-write or reader")
848
+ .argument("<model-name>", "Model name")
849
+ .argument("[record-id]", "Record ID (generated when omitted)")
338
850
  .option("--app <app-id>", "App ID")
851
+ .option("--data <json>", "Record fields as inline JSON")
852
+ .option("--data-file <path>", "Read the record fields from a JSON file")
339
853
  .option("--json", "Output as JSON")
340
- .action(async (databaseId, options) => {
854
+ .action(async (databaseId, modelName, recordId, options) => {
341
855
  const resolvedAppId = resolveAppId(undefined, options);
856
+ const data = parseDataOption(options, "record");
342
857
  const client = new ApiClient();
858
+ // The DatabaseDO requires an id on the plain save path (it only generates
859
+ // one for `upsertOn` writes). Mint the id client-side for a create so
860
+ // `save` works without an id, the same way CSV import does
861
+ // (`lib/csv.ts`).
862
+ const id = recordId || ulid();
863
+ assertDataIdMatches(data, recordId, id, "primitive databases records save <database-id> <model-name>");
343
864
  try {
344
- const result = await client.grantDatabaseGroupPermission(resolvedAppId, databaseId, {
345
- groupType: options.groupType,
346
- groupId: options.groupId,
347
- permission: options.permission,
348
- });
865
+ const result = await client.saveDatabaseRecord(resolvedAppId, databaseId, modelName, id, data);
349
866
  if (options.json) {
350
867
  json(result);
351
868
  return;
352
869
  }
353
- success(`Permission '${options.permission}' granted to group ${options.groupType}/${options.groupId}.`);
870
+ renderRecordWriteResult(result, id, `Record saved to ${modelName}.`);
354
871
  }
355
872
  catch (err) {
356
873
  error(err.message);
357
874
  process.exit(1);
358
875
  }
359
876
  });
360
- // Revoke group permission
361
- groupPermissions
362
- .command("revoke")
363
- .description("Revoke a group's permission on a database")
877
+ // Patch a record (partial merge)
878
+ records
879
+ .command("patch")
880
+ .description("Merge fields into an existing record (admin only)")
881
+ .argument("<database-id>", "Database ID")
882
+ .argument("<model-name>", "Model name")
883
+ .argument("<record-id>", "Record ID")
884
+ .option("--app <app-id>", "App ID")
885
+ .option("--data <json>", "Fields to merge as inline JSON")
886
+ .option("--data-file <path>", "Read the fields to merge from a JSON file")
887
+ .option("--json", "Output as JSON")
888
+ .action(async (databaseId, modelName, recordId, options) => {
889
+ const resolvedAppId = resolveAppId(undefined, options);
890
+ const data = parseDataOption(options, "fields to merge");
891
+ const client = new ApiClient();
892
+ // Same guard as `save` — on this path `recordId` is required, so the
893
+ // "pass it as the argument" branch is unreachable here.
894
+ assertDataIdMatches(data, recordId, recordId, "primitive databases records patch <database-id> <model-name>");
895
+ try {
896
+ const result = await client.patchDatabaseRecord(resolvedAppId, databaseId, modelName, recordId, data);
897
+ if (options.json) {
898
+ json(result);
899
+ return;
900
+ }
901
+ renderRecordWriteResult(result, recordId, `Record ${recordId} patched in ${modelName}.`);
902
+ }
903
+ catch (err) {
904
+ error(err.message);
905
+ process.exit(1);
906
+ }
907
+ });
908
+ // Delete a single record
909
+ records
910
+ .command("delete")
911
+ .description("Delete a single record from a database model (admin only)")
364
912
  .argument("<database-id>", "Database ID")
365
- .argument("<group-type>", "Group type")
366
- .argument("<group-id>", "Group ID")
913
+ .argument("<model-name>", "Model name")
914
+ .argument("<record-id>", "Record ID to delete")
367
915
  .option("--app <app-id>", "App ID")
368
916
  .option("-y, --yes", "Skip confirmation prompt")
369
- .action(async (databaseId, groupType, groupId, options) => {
917
+ .option("--json", "Output as JSON")
918
+ .action(async (databaseId, modelName, recordId, options) => {
370
919
  const resolvedAppId = resolveAppId(undefined, options);
371
920
  if (!options.yes) {
372
- const inquirer = await import("inquirer");
373
- const { confirm } = await inquirer.default.prompt([
374
- {
375
- type: "confirm",
376
- name: "confirm",
377
- message: `Revoke permission for group ${groupType}/${groupId} on database ${databaseId}?`,
378
- default: false,
379
- },
380
- ]);
921
+ let confirm;
922
+ try {
923
+ confirm = await confirmPrompt(`Delete record ${recordId} from ${modelName}? This cannot be undone.`);
924
+ }
925
+ catch (err) {
926
+ error(err.message);
927
+ process.exit(1);
928
+ }
929
+ if (!confirm) {
930
+ info("Cancelled.");
931
+ return;
932
+ }
933
+ }
934
+ const client = new ApiClient();
935
+ try {
936
+ const result = await client.deleteDatabaseRecord(resolvedAppId, databaseId, modelName, recordId);
937
+ if (options.json) {
938
+ json(result);
939
+ return;
940
+ }
941
+ success(`Record ${recordId} deleted from ${modelName}.`);
942
+ }
943
+ catch (err) {
944
+ error(err.message);
945
+ process.exit(1);
946
+ }
947
+ });
948
+ // Delete all records for a model
949
+ records
950
+ .command("delete-all")
951
+ .description("Delete all records for a model (queries then batch deletes, admin only)")
952
+ .argument("<database-id>", "Database ID")
953
+ .argument("<model-name>", "Model name")
954
+ .option("--app <app-id>", "App ID")
955
+ .option("--filter <json>", "Filter as JSON to select which records to delete")
956
+ .option("--filter-file <path>", "Read filter from a JSON or TOML file")
957
+ .option("-y, --yes", "Skip confirmation prompt")
958
+ .option("--json", "Output as JSON")
959
+ .action(async (databaseId, modelName, options) => {
960
+ const resolvedAppId = resolveAppId(undefined, options);
961
+ const client = new ApiClient();
962
+ const filter = parseFilterOptions(options);
963
+ try {
964
+ // First, query to find all matching records
965
+ let allRecords = [];
966
+ let cursor;
967
+ let hasMore = true;
968
+ while (hasMore) {
969
+ const result = await client.queryDatabaseRecords(resolvedAppId, databaseId, modelName, { filter, limit: 100, cursor });
970
+ const records = result.items;
971
+ allRecords = allRecords.concat(records);
972
+ cursor = result.nextCursor;
973
+ hasMore = !!cursor && records.length > 0;
974
+ }
975
+ if (allRecords.length === 0) {
976
+ if (options.json) {
977
+ json({ deleted: 0, modelName });
978
+ return;
979
+ }
980
+ info("No matching records found.");
981
+ return;
982
+ }
983
+ if (!options.yes) {
984
+ let confirm;
985
+ try {
986
+ confirm = await confirmPrompt(`Delete ${allRecords.length} record(s) from ${modelName}? This cannot be undone.`);
987
+ }
988
+ catch (err) {
989
+ error(err.message);
990
+ process.exit(1);
991
+ }
992
+ if (!confirm) {
993
+ info("Cancelled.");
994
+ return;
995
+ }
996
+ }
997
+ // Batch delete in chunks of 50
998
+ const BATCH_SIZE = 50;
999
+ let deleted = 0;
1000
+ for (let i = 0; i < allRecords.length; i += BATCH_SIZE) {
1001
+ const chunk = allRecords.slice(i, i + BATCH_SIZE);
1002
+ const operations = chunk.map((r) => ({
1003
+ op: "delete",
1004
+ modelName,
1005
+ id: r.id,
1006
+ }));
1007
+ await client.batchDeleteDatabaseRecords(resolvedAppId, databaseId, operations);
1008
+ deleted += chunk.length;
1009
+ }
1010
+ if (options.json) {
1011
+ json({ deleted, modelName });
1012
+ return;
1013
+ }
1014
+ success(`Deleted ${deleted} record(s) from ${modelName}.`);
1015
+ }
1016
+ catch (err) {
1017
+ error(err.message);
1018
+ process.exit(1);
1019
+ }
1020
+ });
1021
+ // Apply an atomic multi-model operations blob (issue #2437).
1022
+ //
1023
+ // Takes the SAME `--data-file` blob as `documents records bulk` — one file
1024
+ // works against either surface — and translates it to the DatabaseDO's
1025
+ // `records/batch` wire shape (`{ op, modelName, id, data }`) with
1026
+ // `atomic: true`, so the whole blob is all-or-nothing like the documents
1027
+ // twin. Both guards the documents blob can carry survive the translation:
1028
+ // `action: create` maps to `save` with `ifNotExists: true` (the DatabaseDO's
1029
+ // strict-create flag, a 409 `ALREADY_EXISTS` inside the transaction), and an
1030
+ // op's `precondition` maps to the DO's per-op `condition` (a 409
1031
+ // `CONDITION_NOT_MET`). Both abort the whole batch, matching the documents
1032
+ // twin.
1033
+ const BULK_ACTIONS = ["create", "patch", "delete"];
1034
+ const BULK_ACTION_TO_OP = {
1035
+ create: "save",
1036
+ patch: "patch",
1037
+ delete: "delete",
1038
+ };
1039
+ // Mirrors the documents surface's `BULK_WRITE_OP_KEYS` / `BULK_DELETE_OP_KEYS`
1040
+ // (`src/document-models.ts`). Unknown keys are an error rather than ignored:
1041
+ // a guard the author asked for must never be dropped silently.
1042
+ const BULK_OP_KEYS = ["model", "action", "id", "data", "precondition"];
1043
+ const BULK_DELETE_OP_KEYS = ["model", "action", "id", "precondition"];
1044
+ records
1045
+ .command("bulk")
1046
+ .description("Apply an atomic create/patch/delete operations blob to a database (admin only)")
1047
+ .argument("<database-id>", "Database ID")
1048
+ .requiredOption("--data-file <file>", "JSON file with { operations: [...] } or a bare operations array. " +
1049
+ "Each op is { model, action: create|patch|delete, id, data, precondition? } " +
1050
+ "— the same shape `documents records bulk` takes. `data` holds the " +
1051
+ "record fields and is required on create/patch, not allowed on delete. " +
1052
+ "A `create` against an id that already exists fails the whole batch, " +
1053
+ "and a `precondition` that does not hold rolls the whole batch back")
1054
+ .option("--app <app-id>", "App ID")
1055
+ .option("-y, --yes", "Skip confirmation prompt")
1056
+ .option("--json", "Output as JSON")
1057
+ .action(async (databaseId, options) => {
1058
+ const parsed = parseDataOption(options, "operations blob", {
1059
+ allowArray: true,
1060
+ });
1061
+ const rawOperations = Array.isArray(parsed) ? parsed : parsed?.operations;
1062
+ if (!Array.isArray(rawOperations) || rawOperations.length === 0) {
1063
+ error("--data-file must contain { operations: [...] } or a non-empty operations array.");
1064
+ process.exit(1);
1065
+ }
1066
+ // Translate to the batch wire shape, rejecting a malformed op before any
1067
+ // request goes out so the caller sees which entry is wrong.
1068
+ const operations = [];
1069
+ for (let i = 0; i < rawOperations.length; i++) {
1070
+ const op = rawOperations[i];
1071
+ if (!op || typeof op !== "object" || Array.isArray(op)) {
1072
+ error(`Operation ${i}: each operation must be a JSON object.`);
1073
+ process.exit(1);
1074
+ }
1075
+ const action = op.action;
1076
+ if (!BULK_ACTIONS.includes(action)) {
1077
+ error(`Operation ${i}: invalid action "${action}". Expected one of: ${BULK_ACTIONS.join(", ")}.`);
1078
+ process.exit(1);
1079
+ }
1080
+ if (!op.model || typeof op.model !== "string") {
1081
+ error(`Operation ${i}: "model" is required and must be a model name.`);
1082
+ process.exit(1);
1083
+ }
1084
+ if (!op.id || typeof op.id !== "string") {
1085
+ error(`Operation ${i}: "id" is required.`);
1086
+ process.exit(1);
1087
+ }
1088
+ // `data` on a delete gets its own message before the allowlist runs, so
1089
+ // the caller is not told a first-class key is "unknown".
1090
+ if (action === "delete" && op.data !== undefined) {
1091
+ error(`Operation ${i}: "data" is not allowed on a delete.`);
1092
+ process.exit(1);
1093
+ }
1094
+ const allowedKeys = action === "delete" ? BULK_DELETE_OP_KEYS : BULK_OP_KEYS;
1095
+ const unknownKey = Object.keys(op).find((k) => !allowedKeys.includes(k));
1096
+ if (unknownKey) {
1097
+ error(`Operation ${i}: unknown key "${unknownKey}". Expected one of: ${allowedKeys.join(", ")}.`);
1098
+ process.exit(1);
1099
+ }
1100
+ // A `precondition` is field-equality only, the same grammar the
1101
+ // documents surface enforces (`validatePrecondition` in
1102
+ // `src/document-models.ts`), so one blob means the same thing on both.
1103
+ // `preconditionToCondition` translates it to the DO's per-op
1104
+ // `condition`, checked inside the transaction: a mismatch is a 409 that
1105
+ // rolls the whole batch back.
1106
+ let condition;
1107
+ if (op.precondition !== undefined && op.precondition !== null) {
1108
+ if (typeof op.precondition !== "object" || Array.isArray(op.precondition)) {
1109
+ error(`Operation ${i}: "precondition" must be a field-equality object ({ field: scalar, … }).`);
1110
+ process.exit(1);
1111
+ }
1112
+ for (const [field, expected] of Object.entries(op.precondition)) {
1113
+ if (field.startsWith("$")) {
1114
+ error(`Operation ${i}: precondition operator "${field}" is not supported — preconditions are field-equality only ({ field: scalar, … }).`);
1115
+ process.exit(1);
1116
+ }
1117
+ if (expected !== null && typeof expected === "object") {
1118
+ error(`Operation ${i}: precondition on field "${field}" must be a scalar equality value — operators and nested objects/arrays are not supported.`);
1119
+ process.exit(1);
1120
+ }
1121
+ // Fail loudly on the values the `condition` grammar cannot check
1122
+ // exactly, rather than running with a weaker guard than the caller
1123
+ // wrote (issue #2437; the grammar fix is tracked in #2507).
1124
+ const untranslatable = untranslatablePreconditionValue(expected);
1125
+ if (untranslatable) {
1126
+ error(`Operation ${i}: precondition on field "${field}" is not supported — ${untranslatable}. ` +
1127
+ `Guard on a field whose value is a string or a number other than 0/1, or read the record ` +
1128
+ `first and gate the write on that.`);
1129
+ process.exit(1);
1130
+ }
1131
+ }
1132
+ condition = preconditionToCondition(op.precondition);
1133
+ }
1134
+ if (action === "delete") {
1135
+ if (op.data !== undefined) {
1136
+ error(`Operation ${i}: "data" is not allowed on a delete.`);
1137
+ process.exit(1);
1138
+ }
1139
+ operations.push({
1140
+ op: "delete",
1141
+ modelName: op.model,
1142
+ id: op.id,
1143
+ ...(condition ? { condition } : {}),
1144
+ });
1145
+ continue;
1146
+ }
1147
+ if (!op.data ||
1148
+ typeof op.data !== "object" ||
1149
+ Array.isArray(op.data) ||
1150
+ Object.keys(op.data).length === 0) {
1151
+ error(`Operation ${i}: "data" is required and must be a non-empty object on a ${action}.`);
1152
+ process.exit(1);
1153
+ }
1154
+ operations.push({
1155
+ op: BULK_ACTION_TO_OP[action],
1156
+ modelName: op.model,
1157
+ id: op.id,
1158
+ data: op.data,
1159
+ // `create` is a STRICT create on both twins: `ifNotExists` is the
1160
+ // DatabaseDO batch's strict-create flag (409 `ALREADY_EXISTS`), so a
1161
+ // create against a live id fails the batch instead of merging.
1162
+ ...(action === "create" ? { ifNotExists: true } : {}),
1163
+ ...(condition ? { condition } : {}),
1164
+ });
1165
+ }
1166
+ const resolvedAppId = resolveAppId(undefined, options);
1167
+ if (!options.yes) {
1168
+ let confirm;
1169
+ try {
1170
+ confirm = await confirmPrompt(`Apply ${operations.length} operation(s) atomically to database ${databaseId}?`);
1171
+ }
1172
+ catch (err) {
1173
+ error(err.message);
1174
+ process.exit(1);
1175
+ }
381
1176
  if (!confirm) {
382
1177
  info("Cancelled.");
383
1178
  return;
@@ -385,13 +1180,1246 @@ Examples:
385
1180
  }
386
1181
  const client = new ApiClient();
387
1182
  try {
388
- await client.revokeDatabaseGroupPermission(resolvedAppId, databaseId, groupType, groupId);
389
- success(`Permission revoked for group ${groupType}/${groupId}.`);
1183
+ await client.batchDatabaseRecords(resolvedAppId, databaseId, operations, {
1184
+ atomic: true,
1185
+ });
1186
+ // The batch endpoint answers with a per-operation `results` array; the
1187
+ // documents twin answers with a rolled-up summary. Report the twin's
1188
+ // summary so one blob produces one contract on both surfaces — the
1189
+ // batch is atomic, so a 2xx means every op in the input applied.
1190
+ //
1191
+ // One reporting difference, documented alongside the verb: the
1192
+ // documents twin's `deleted` counts records actually removed, because
1193
+ // its DO reports that. The database batch issues a plain DELETE and
1194
+ // reports no rows-affected, so `deleted` here counts delete operations
1195
+ // applied — deleting an id that was not there still counts 1.
1196
+ const added = rawOperations
1197
+ .filter((op) => op.action === "create")
1198
+ .map((op) => ({ model: op.model, id: op.id }));
1199
+ const updated = rawOperations
1200
+ .filter((op) => op.action === "patch")
1201
+ .map((op) => ({ model: op.model, id: op.id }));
1202
+ const deleted = rawOperations.filter((op) => op.action === "delete").length;
1203
+ const summary = {
1204
+ applied: added.length + updated.length + deleted,
1205
+ added,
1206
+ updated,
1207
+ deleted,
1208
+ };
1209
+ if (options.json) {
1210
+ json(summary);
1211
+ return;
1212
+ }
1213
+ success(`Applied ${summary.applied} operation(s): ` +
1214
+ `${added.length} added, ${updated.length} updated, ${deleted} deleted.`);
390
1215
  }
391
1216
  catch (err) {
392
1217
  error(err.message);
393
1218
  process.exit(1);
394
1219
  }
395
1220
  });
1221
+ // ---- CEL context subcommand group ----
1222
+ // `cel-context` is the user-facing name for the dict that feeds CEL
1223
+ // access rules and filter placeholders. `metadata` is kept as a hidden
1224
+ // alias for backwards compatibility with existing scripts.
1225
+ const registerCelContextGroup = (name, hidden) => {
1226
+ const group = databases
1227
+ .command(name, { hidden })
1228
+ .description(hidden
1229
+ ? "Manage database CEL context (alias of 'cel-context')"
1230
+ : "Manage database CEL context (the dict referenced by access rules and filters)");
1231
+ group
1232
+ .command("get")
1233
+ .description("Read database CEL context")
1234
+ .argument("<database-id>", "Database ID")
1235
+ .option("--app <app-id>", "App ID")
1236
+ .option("--json", "Output as JSON")
1237
+ .action(async (databaseId, options) => {
1238
+ const resolvedAppId = resolveAppId(undefined, options);
1239
+ const client = new ApiClient();
1240
+ try {
1241
+ const result = await client.getDatabaseCelContext(resolvedAppId, databaseId);
1242
+ if (options.json) {
1243
+ json(result);
1244
+ return;
1245
+ }
1246
+ printResult("Database ID", result.databaseId);
1247
+ const ctx = result.celContext ?? result.metadata;
1248
+ if (ctx && Object.keys(ctx).length > 0) {
1249
+ printResult("CEL Context", JSON.stringify(ctx, null, 2));
1250
+ }
1251
+ else {
1252
+ info("No CEL context set.");
1253
+ }
1254
+ }
1255
+ catch (err) {
1256
+ error(err.message);
1257
+ process.exit(1);
1258
+ }
1259
+ });
1260
+ group
1261
+ .command("update")
1262
+ .description("Update database CEL context (merge with existing)")
1263
+ .argument("<database-id>", "Database ID")
1264
+ .requiredOption("--data <json>", "CEL context fields as JSON (merged with existing)")
1265
+ .option("--app <app-id>", "App ID")
1266
+ .option("--json", "Output as JSON")
1267
+ .action(async (databaseId, options) => {
1268
+ const resolvedAppId = resolveAppId(undefined, options);
1269
+ const client = new ApiClient();
1270
+ let data;
1271
+ try {
1272
+ data = JSON.parse(options.data);
1273
+ }
1274
+ catch {
1275
+ error("Invalid --data JSON.");
1276
+ process.exit(1);
1277
+ return;
1278
+ }
1279
+ try {
1280
+ const result = await client.updateDatabaseCelContext(resolvedAppId, databaseId, data);
1281
+ if (options.json) {
1282
+ json(result);
1283
+ return;
1284
+ }
1285
+ success("CEL context updated.");
1286
+ const ctx = result.celContext ?? result.metadata;
1287
+ if (ctx) {
1288
+ keyValue("CEL Context", JSON.stringify(ctx));
1289
+ }
1290
+ }
1291
+ catch (err) {
1292
+ error(err.message);
1293
+ process.exit(1);
1294
+ }
1295
+ });
1296
+ };
1297
+ registerCelContextGroup("cel-context", false);
1298
+ // Hidden legacy alias — `primitive databases metadata get/update` still works.
1299
+ registerCelContextGroup("metadata", true);
1300
+ // ---- Operations subcommand group ----
1301
+ const operations = databases
1302
+ .command("operations")
1303
+ .description("List and execute registered operations on a database");
1304
+ // List operations
1305
+ operations
1306
+ .command("list")
1307
+ .description("List registered operations available on a database")
1308
+ .argument("<database-id>", "Database ID")
1309
+ .option("--app <app-id>", "App ID")
1310
+ .option("--json", "Output as JSON")
1311
+ .action(async (databaseId, options) => {
1312
+ const resolvedAppId = resolveAppId(undefined, options);
1313
+ const client = new ApiClient();
1314
+ try {
1315
+ const result = await client.listDatabaseOperations(resolvedAppId, databaseId);
1316
+ const list = Array.isArray(result) ? result : [];
1317
+ if (options.json) {
1318
+ json(list);
1319
+ return;
1320
+ }
1321
+ if (list.length === 0) {
1322
+ info("No operations found.");
1323
+ return;
1324
+ }
1325
+ console.log(formatTable(list, [
1326
+ { header: "NAME", key: "name" },
1327
+ { header: "TYPE", key: "type" },
1328
+ { header: "MODEL", key: "modelName" },
1329
+ { header: "ACCESS", key: "access" },
1330
+ ]));
1331
+ }
1332
+ catch (err) {
1333
+ error(err.message);
1334
+ process.exit(1);
1335
+ }
1336
+ });
1337
+ // Execute operation
1338
+ operations
1339
+ .command("execute")
1340
+ .description("Execute a registered operation on a database")
1341
+ .argument("<database-id>", "Database ID")
1342
+ .argument("<operation-name>", "Operation name")
1343
+ .option("--params <json>", "Operation parameters as JSON")
1344
+ .option("--limit <n>", "Max records to return (for queries)")
1345
+ .option("--cursor <cursor>", "Pagination cursor")
1346
+ .option("--token <jwt>", "Execute as a specific user (dev/test — JWT visible in process args)")
1347
+ .option("--app <app-id>", "App ID")
1348
+ .option("--json", "Output as JSON")
1349
+ .option("--timing", "Show server-side timing breakdown")
1350
+ .action(async (databaseId, operationName, options) => {
1351
+ const resolvedAppId = resolveAppId(undefined, options);
1352
+ const client = new ApiClient();
1353
+ let params;
1354
+ if (options.params) {
1355
+ try {
1356
+ params = JSON.parse(options.params);
1357
+ }
1358
+ catch {
1359
+ error("Invalid --params JSON.");
1360
+ process.exit(1);
1361
+ return;
1362
+ }
1363
+ }
1364
+ const body = {};
1365
+ if (params)
1366
+ body.params = params;
1367
+ if (options.limit)
1368
+ body.limit = parseInt(options.limit, 10);
1369
+ if (options.cursor)
1370
+ body.cursor = options.cursor;
1371
+ try {
1372
+ const result = await client.executeDatabaseOperation(resolvedAppId, databaseId, operationName, body, options.token, { timing: !!options.timing });
1373
+ if (options.json) {
1374
+ json(result);
1375
+ return;
1376
+ }
1377
+ // Smart display based on result shape. `executeDatabaseOperation()`
1378
+ // has already normalized a query page to `{ items, hasMore,
1379
+ // nextCursor? }` (#2440), so `items` is exactly "this result was a
1380
+ // list" — the two branches cannot disagree about it.
1381
+ if (Array.isArray(result.items)) {
1382
+ if (result.items.length === 0) {
1383
+ info("No records found.");
1384
+ }
1385
+ else {
1386
+ const cols = Object.keys(result.items[0]).filter((k) => k !== "type");
1387
+ console.log(formatTable(result.items, cols.map((c) => ({
1388
+ header: c.toUpperCase(),
1389
+ key: c,
1390
+ format: (v) => {
1391
+ if (v === null || v === undefined)
1392
+ return "—";
1393
+ if (typeof v === "object") {
1394
+ const s = JSON.stringify(v);
1395
+ return s.length > 50 ? s.slice(0, 47) + "..." : s;
1396
+ }
1397
+ const s = String(v);
1398
+ return s.length > 50 ? s.slice(0, 47) + "..." : s;
1399
+ },
1400
+ }))));
1401
+ }
1402
+ // Gate on the cursor itself, the way `records query` does: `hasMore`
1403
+ // without a cursor used to print the literal `undefined` (#2440).
1404
+ if (result.nextCursor) {
1405
+ info(`More records available. Use --cursor ${result.nextCursor} to continue.`);
1406
+ }
1407
+ }
1408
+ else if (result.count !== undefined) {
1409
+ console.log(`Count: ${result.count}`);
1410
+ }
1411
+ else if (result.result !== undefined) {
1412
+ // Aggregate result
1413
+ json(result);
1414
+ }
1415
+ else if (result.results && Array.isArray(result.results)) {
1416
+ // Batch/mutation results
1417
+ success(`Operation executed (${result.results.length} result${result.results.length === 1 ? "" : "s"}).`);
1418
+ }
1419
+ else if (result.success !== undefined) {
1420
+ success("Operation executed.");
1421
+ }
1422
+ else {
1423
+ json(result);
1424
+ }
1425
+ // Display timing if present
1426
+ if (result._timing) {
1427
+ console.log("");
1428
+ console.log("Timing:");
1429
+ for (const [key, value] of Object.entries(result._timing)) {
1430
+ console.log(` ${key}: ${value}ms`);
1431
+ }
1432
+ }
1433
+ }
1434
+ catch (err) {
1435
+ error(err.message);
1436
+ process.exit(1);
1437
+ }
1438
+ });
1439
+ // ---- Indexes subcommand group ----
1440
+ const indexes = databases
1441
+ .command("indexes")
1442
+ .description("Manage indexes on a database");
1443
+ // List indexes
1444
+ indexes
1445
+ .command("list")
1446
+ .description("List indexes for a database or model")
1447
+ .argument("<database-id>", "Database ID")
1448
+ .option("--model <model-name>", "Filter by model name")
1449
+ .option("--app <app-id>", "App ID")
1450
+ .option("--json", "Output as JSON")
1451
+ .action(async (databaseId, options) => {
1452
+ const resolvedAppId = resolveAppId(undefined, options);
1453
+ const client = new ApiClient();
1454
+ try {
1455
+ const result = await client.listDatabaseIndexes(resolvedAppId, databaseId, options.model);
1456
+ const list = result.indexes || [];
1457
+ if (options.json) {
1458
+ json(list);
1459
+ return;
1460
+ }
1461
+ if (list.length === 0) {
1462
+ info("No indexes found.");
1463
+ return;
1464
+ }
1465
+ console.log(formatTable(list, [
1466
+ { header: "MODEL", key: "model_name" },
1467
+ { header: "FIELD", key: "field_name" },
1468
+ { header: "TYPE", key: "field_type" },
1469
+ { header: "UNIQUE", key: "is_unique", format: (v) => (v ? "Yes" : "No") },
1470
+ ]));
1471
+ }
1472
+ catch (err) {
1473
+ error(err.message);
1474
+ process.exit(1);
1475
+ }
1476
+ });
1477
+ // Register index
1478
+ indexes
1479
+ .command("create")
1480
+ .description("Create an index on a model field")
1481
+ .argument("<database-id>", "Database ID")
1482
+ .requiredOption("--model <model-name>", "Model name")
1483
+ .requiredOption("--field <field-name>", "Field name to index")
1484
+ .option("--type <field-type>", "Field type: string, number, or boolean", "string")
1485
+ .option("--unique", "Make this a unique index")
1486
+ .option("--app <app-id>", "App ID")
1487
+ .option("--json", "Output as JSON")
1488
+ .action(async (databaseId, options) => {
1489
+ const resolvedAppId = resolveAppId(undefined, options);
1490
+ const client = new ApiClient();
1491
+ try {
1492
+ const result = await client.registerDatabaseIndex(resolvedAppId, databaseId, {
1493
+ modelName: options.model,
1494
+ fieldName: options.field,
1495
+ fieldType: options.type,
1496
+ unique: !!options.unique,
1497
+ });
1498
+ if (options.json) {
1499
+ json(result);
1500
+ return;
1501
+ }
1502
+ success(`Index created on ${options.model}.${options.field} (${options.type}${options.unique ? ", unique" : ""}).`);
1503
+ }
1504
+ catch (err) {
1505
+ error(err.message);
1506
+ process.exit(1);
1507
+ }
1508
+ });
1509
+ // Drop index
1510
+ indexes
1511
+ .command("drop")
1512
+ .description("Drop an index from a model field")
1513
+ .argument("<database-id>", "Database ID")
1514
+ .requiredOption("--model <model-name>", "Model name")
1515
+ .requiredOption("--field <field-name>", "Field name")
1516
+ .option("--app <app-id>", "App ID")
1517
+ .option("-y, --yes", "Skip confirmation prompt")
1518
+ .action(async (databaseId, options) => {
1519
+ const resolvedAppId = resolveAppId(undefined, options);
1520
+ if (!options.yes) {
1521
+ let confirm;
1522
+ try {
1523
+ confirm = await confirmPrompt(`Drop index on ${options.model}.${options.field}?`);
1524
+ }
1525
+ catch (err) {
1526
+ error(err.message);
1527
+ process.exit(1);
1528
+ }
1529
+ if (!confirm) {
1530
+ info("Cancelled.");
1531
+ return;
1532
+ }
1533
+ }
1534
+ const client = new ApiClient();
1535
+ try {
1536
+ await client.dropDatabaseIndex(resolvedAppId, databaseId, {
1537
+ modelName: options.model,
1538
+ fieldName: options.field,
1539
+ });
1540
+ success(`Index dropped on ${options.model}.${options.field}.`);
1541
+ }
1542
+ catch (err) {
1543
+ error(err.message);
1544
+ process.exit(1);
1545
+ }
1546
+ });
1547
+ // Reindex an existing instance from its type schema (issue #974). The
1548
+ // on-demand remedy that brings a pre-existing database into compliance after
1549
+ // a schema declares a field `unique = true` — registers any declared-but-
1550
+ // missing unique index so `upsertOn` ops resolve. Idempotent.
1551
+ databases
1552
+ .command("reindex")
1553
+ .description("Reprovision a database's unique indexes from its type schema")
1554
+ .argument("<database-id>", "Database ID")
1555
+ .requiredOption("--from-schema", "Reprovision unique indexes declared in the type schema")
1556
+ .option("--app <app-id>", "App ID")
1557
+ .option("--json", "Output as JSON")
1558
+ .action(async (databaseId, options) => {
1559
+ const resolvedAppId = resolveAppId(undefined, options);
1560
+ const client = new ApiClient();
1561
+ try {
1562
+ const res = await client.reindexDatabaseFromSchema(resolvedAppId, databaseId);
1563
+ if (options.json) {
1564
+ json(res);
1565
+ return;
1566
+ }
1567
+ if (res.skipped) {
1568
+ info(`Nothing to reindex (${res.reason}).`);
1569
+ return;
1570
+ }
1571
+ success(`Reindexed from schema: ${res.registered} unique index${res.registered === 1 ? "" : "es"} provisioned.`);
1572
+ }
1573
+ catch (err) {
1574
+ error(err.message);
1575
+ process.exit(1);
1576
+ }
1577
+ });
1578
+ // ---- Export / Import commands ----
1579
+ databases
1580
+ .command("export")
1581
+ .description("Export a database (records, indexes, constraints)")
1582
+ .argument("[app-id]", "App ID (uses current app if not specified)")
1583
+ .argument("<database-id>", "Database ID to export")
1584
+ .option("--app <app-id>", "App ID")
1585
+ .option("--output <dir>", "Output directory", "./primitive-export")
1586
+ .option("--json", "Output as JSON")
1587
+ .action(async (first, second, options) => {
1588
+ let resolvedAppId;
1589
+ let databaseId;
1590
+ if (second && !second.startsWith("-")) {
1591
+ resolvedAppId = resolveAppId(first, options);
1592
+ databaseId = second;
1593
+ }
1594
+ else {
1595
+ resolvedAppId = resolveAppId(undefined, options);
1596
+ databaseId = first;
1597
+ }
1598
+ if (!databaseId) {
1599
+ error("Database ID is required.");
1600
+ process.exit(1);
1601
+ }
1602
+ const client = new ApiClient();
1603
+ try {
1604
+ await exportSingleDatabase(client, resolvedAppId, databaseId, options.output, options.json);
1605
+ }
1606
+ catch (err) {
1607
+ error(err.message);
1608
+ process.exit(1);
1609
+ }
1610
+ });
1611
+ databases
1612
+ .command("import")
1613
+ .description("Import a database from an export directory")
1614
+ .argument("[app-id]", "App ID (uses current app if not specified)")
1615
+ .argument("<path>", "Path to database export directory")
1616
+ .option("--app <app-id>", "App ID")
1617
+ .option("--overwrite", "Replace existing database if ID collides")
1618
+ .option("--dry-run", "Show what would be imported without making changes")
1619
+ .option("--batch-size <n>", "Records per batch write request (default: 5000)", "5000")
1620
+ .option("--stop-on-error", "Abort the whole run on the first failing chunk (default: continue and report)")
1621
+ .option("--json", "Output as JSON")
1622
+ .action(async (first, second, options) => {
1623
+ let resolvedAppId;
1624
+ let importPath;
1625
+ if (second && !second.startsWith("-")) {
1626
+ resolvedAppId = resolveAppId(first, options);
1627
+ importPath = second;
1628
+ }
1629
+ else {
1630
+ resolvedAppId = resolveAppId(undefined, options);
1631
+ importPath = first;
1632
+ }
1633
+ if (!importPath) {
1634
+ error("Import path is required.");
1635
+ process.exit(1);
1636
+ }
1637
+ // Validate --batch-size up front so a bad value fails before any write.
1638
+ // Unlike `import-csv` (registered-batch path, hard 100k server cap),
1639
+ // `records/batch` has no fixed server item cap — bound it with a
1640
+ // conservative client-side ceiling to keep each request within
1641
+ // Cloudflare body-size and Durable Object memory limits.
1642
+ const parsedBatchSize = parseBatchSize(options.batchSize, {
1643
+ max: IMPORT_BATCH_SIZE_CEILING,
1644
+ overMaxMessage: `--batch-size cannot exceed ${IMPORT_BATCH_SIZE_CEILING} (a conservative client-side limit for batch write requests).`,
1645
+ });
1646
+ if ("error" in parsedBatchSize) {
1647
+ error(parsedBatchSize.error);
1648
+ process.exit(1);
1649
+ return;
1650
+ }
1651
+ const batchSize = parsedBatchSize.value;
1652
+ const client = new ApiClient();
1653
+ try {
1654
+ // Determine if this is a manifest-based export or a single database
1655
+ let dbDirs = [];
1656
+ const manifestPath = path.join(importPath, "manifest.json");
1657
+ if (existsSync(path.join(importPath, "metadata.json"))) {
1658
+ // Single database directory
1659
+ dbDirs = [importPath];
1660
+ }
1661
+ else if (existsSync(manifestPath)) {
1662
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
1663
+ for (const dbId of manifest.databases || []) {
1664
+ const dbDir = path.join(importPath, "databases", dbId);
1665
+ if (existsSync(dbDir)) {
1666
+ dbDirs.push(dbDir);
1667
+ }
1668
+ }
1669
+ }
1670
+ else if (existsSync(path.join(importPath, "databases"))) {
1671
+ const entries = readdirSync(path.join(importPath, "databases"), { withFileTypes: true });
1672
+ for (const entry of entries) {
1673
+ if (entry.isDirectory()) {
1674
+ dbDirs.push(path.join(importPath, "databases", entry.name));
1675
+ }
1676
+ }
1677
+ }
1678
+ else {
1679
+ error("No valid database export data found at the specified path.");
1680
+ process.exit(1);
1681
+ }
1682
+ if (dbDirs.length === 0) {
1683
+ info("No databases to import.");
1684
+ return;
1685
+ }
1686
+ const summary = {
1687
+ created: 0,
1688
+ updated: 0,
1689
+ skipped: 0,
1690
+ recordsImported: 0,
1691
+ recordsFailed: 0,
1692
+ batches: 0,
1693
+ aborted: false,
1694
+ indexesRegistered: 0,
1695
+ batchErrors: [],
1696
+ };
1697
+ for (const dbDir of dbDirs) {
1698
+ await importSingleDatabase(client, resolvedAppId, dbDir, {
1699
+ overwrite: options.overwrite || false,
1700
+ dryRun: options.dryRun || false,
1701
+ batchSize,
1702
+ stopOnError: options.stopOnError || false,
1703
+ json: options.json || false,
1704
+ summary,
1705
+ });
1706
+ // Fork 3: --stop-on-error stops the whole run across a multi-DB dir.
1707
+ if (summary.aborted)
1708
+ break;
1709
+ }
1710
+ if (options.json) {
1711
+ json({ ...summary, dryRun: options.dryRun || false });
1712
+ }
1713
+ else {
1714
+ success("Database import complete:");
1715
+ keyValue(" Databases created", String(summary.created));
1716
+ if (summary.updated > 0)
1717
+ keyValue(" Databases updated", String(summary.updated));
1718
+ if (summary.skipped > 0)
1719
+ keyValue(" Databases skipped", String(summary.skipped));
1720
+ keyValue(" Records imported", String(summary.recordsImported));
1721
+ if (summary.recordsFailed > 0)
1722
+ keyValue(" Records failed", String(summary.recordsFailed));
1723
+ keyValue(" Batches", String(summary.batches));
1724
+ if (summary.indexesRegistered > 0)
1725
+ keyValue(" Indexes registered", String(summary.indexesRegistered));
1726
+ if (summary.aborted)
1727
+ warn("Aborted after a failing chunk (--stop-on-error).");
1728
+ if (summary.batchErrors.length > 0) {
1729
+ error(`${summary.batchErrors.length} chunk(s) errored:`);
1730
+ for (const e of summary.batchErrors) {
1731
+ error(` ${e.database} batch ${e.batch + 1}: ${e.error}`);
1732
+ }
1733
+ }
1734
+ }
1735
+ // Non-zero exit if any chunk errored (default continue-and-report or
1736
+ // --stop-on-error both surface failures this way).
1737
+ if (summary.batchErrors.length > 0) {
1738
+ process.exit(1);
1739
+ }
1740
+ }
1741
+ catch (err) {
1742
+ error(err.message);
1743
+ process.exit(1);
1744
+ }
1745
+ });
1746
+ // ============================================
1747
+ // CSV import (issue #145, Phase 1)
1748
+ // ============================================
1749
+ databases
1750
+ .command("import-csv")
1751
+ .description("Import rows from a CSV file into a database via a registered batch (bulk) save operation")
1752
+ .argument("<database-id>", "Database ID")
1753
+ .argument("<file>", "Path to the CSV file")
1754
+ .requiredOption("--model <name>", "Model name passed in each item's params")
1755
+ .option("--operation <op>", "Registered mutation operation to use (must be a {modelName,id,data} save-like op)", "seed_save")
1756
+ .option("--column-map <json>", 'Map CSV headers to field names, e.g. \'{"CSV Header":"fieldName"}\'')
1757
+ .option("--types <json>", 'Coerce fields by type, e.g. \'{"price":"number","active":"boolean"}\'')
1758
+ .option("--id-column <col>", "Use this CSV column for record ids (else a ULID is generated)")
1759
+ .option("--batch-size <n>", "Records per batch request (default: 5000)", "5000")
1760
+ .option("--delimiter <char>", "CSV field delimiter (default: ',')", ",")
1761
+ .option("--app <app-id>", "App ID")
1762
+ .option("--dry-run", "Parse + report row/batch counts without writing")
1763
+ .option("--stop-on-error", "Abort the whole import on the first chunk error (default: best-effort continue)")
1764
+ .option("--json", "Output a machine-readable result")
1765
+ .action(async (databaseId, file, options) => {
1766
+ const resolvedAppId = resolveAppId(undefined, options);
1767
+ const client = new ApiClient();
1768
+ // Parse JSON flags up front for clear errors.
1769
+ let columnMap;
1770
+ if (options.columnMap) {
1771
+ try {
1772
+ columnMap = JSON.parse(options.columnMap);
1773
+ }
1774
+ catch {
1775
+ error("Invalid --column-map JSON.");
1776
+ process.exit(1);
1777
+ return;
1778
+ }
1779
+ }
1780
+ let types;
1781
+ if (options.types) {
1782
+ try {
1783
+ types = JSON.parse(options.types);
1784
+ }
1785
+ catch {
1786
+ error("Invalid --types JSON.");
1787
+ process.exit(1);
1788
+ return;
1789
+ }
1790
+ }
1791
+ // The registered-batch (`executeBatch`) path rejects requests over
1792
+ // 100000 items server-side; keep chunks under it.
1793
+ const parsedBatchSize = parseBatchSize(options.batchSize, {
1794
+ max: 100000,
1795
+ overMaxMessage: "--batch-size cannot exceed 100000 (the server's per-request cap).",
1796
+ });
1797
+ if ("error" in parsedBatchSize) {
1798
+ error(parsedBatchSize.error);
1799
+ process.exit(1);
1800
+ return;
1801
+ }
1802
+ const batchSize = parsedBatchSize.value;
1803
+ if (!existsSync(file)) {
1804
+ error(`CSV file not found: ${file}`);
1805
+ process.exit(1);
1806
+ return;
1807
+ }
1808
+ try {
1809
+ // 1. Read + parse the CSV.
1810
+ const raw = readFileSync(file, "utf-8");
1811
+ let rows = parseCsv(raw, options.delimiter);
1812
+ // 2. Apply column map.
1813
+ rows = applyColumnMap(rows, columnMap);
1814
+ // 3. Coerce types, assign ids, shape into batch items.
1815
+ const { batch, rowCount } = buildRows(rows, {
1816
+ modelName: options.model,
1817
+ types,
1818
+ idColumn: options.idColumn,
1819
+ });
1820
+ const chunks = chunk(batch, batchSize);
1821
+ // 4. Validate the chosen op is an import-compatible save-like mutation
1822
+ // BEFORE writing (op-not-found / incompatible → clear up-front error
1823
+ // rather than per-row server-side failure). Skip the round-trip only
1824
+ // when there is genuinely nothing to import in a dry run.
1825
+ if (!options.dryRun || rowCount > 0) {
1826
+ await validateImportOperation(client, resolvedAppId, databaseId, options.operation);
1827
+ }
1828
+ // 5. Empty / headers-only CSV: no request, report 0/0.
1829
+ if (rowCount === 0) {
1830
+ if (options.json) {
1831
+ json({ imported: 0, failed: 0, rows: 0, batches: 0, errors: [], dryRun: !!options.dryRun });
1832
+ }
1833
+ else {
1834
+ info("No data rows found in CSV — nothing to import.");
1835
+ success("Imported: 0, Failed: 0");
1836
+ }
1837
+ return;
1838
+ }
1839
+ // 6. Dry run: report counts, no writes.
1840
+ if (options.dryRun) {
1841
+ if (options.json) {
1842
+ json({ dryRun: true, rows: rowCount, batches: chunks.length, batchSize });
1843
+ }
1844
+ else {
1845
+ info(`Dry run — no records written.`);
1846
+ keyValue(" Rows", String(rowCount));
1847
+ keyValue(" Batches", String(chunks.length));
1848
+ keyValue(" Batch size", String(batchSize));
1849
+ }
1850
+ return;
1851
+ }
1852
+ // 7. Import chunk by chunk; accumulate imported/failed + per-chunk errors.
1853
+ let imported = 0;
1854
+ let failed = 0;
1855
+ const errors = [];
1856
+ let aborted = false;
1857
+ for (let i = 0; i < chunks.length; i++) {
1858
+ const piece = chunks[i];
1859
+ try {
1860
+ const res = await client.executeBatch(resolvedAppId, databaseId, options.operation, piece);
1861
+ imported += res.imported ?? 0;
1862
+ failed += res.failed ?? 0;
1863
+ if (!options.json) {
1864
+ info(`Batch ${i + 1}/${chunks.length}: imported ${res.imported ?? 0}, failed ${res.failed ?? 0}`);
1865
+ }
1866
+ }
1867
+ catch (err) {
1868
+ const msg = err?.message || String(err);
1869
+ errors.push({ batch: i, error: msg });
1870
+ failed += piece.length;
1871
+ if (!options.json) {
1872
+ warn(`Batch ${i + 1}/${chunks.length} failed: ${msg}`);
1873
+ }
1874
+ if (options.stopOnError) {
1875
+ aborted = true;
1876
+ break;
1877
+ }
1878
+ }
1879
+ }
1880
+ if (options.json) {
1881
+ json({ imported, failed, rows: rowCount, batches: chunks.length, errors, aborted });
1882
+ }
1883
+ else {
1884
+ if (aborted) {
1885
+ warn(`Aborted after a chunk error (--stop-on-error).`);
1886
+ }
1887
+ success(`Imported: ${imported}, Failed: ${failed}`);
1888
+ if (errors.length > 0) {
1889
+ error(`${errors.length} batch(es) errored:`);
1890
+ for (const e of errors) {
1891
+ error(` Batch ${e.batch + 1}: ${e.error}`);
1892
+ }
1893
+ }
1894
+ }
1895
+ // Non-zero exit if any chunk errored.
1896
+ if (errors.length > 0) {
1897
+ process.exit(1);
1898
+ }
1899
+ }
1900
+ catch (err) {
1901
+ error(err.message);
1902
+ process.exit(1);
1903
+ }
1904
+ });
1905
+ // ============================================
1906
+ // Schema commands (issue #666)
1907
+ // ============================================
1908
+ const schema = databases
1909
+ .command("schema")
1910
+ .description("Manage the TOML schema attached to a database type");
1911
+ schema
1912
+ .command("generate")
1913
+ .description("Scaffold a starting TOML schema for <database-type> from existing operations + DO introspection, and insert it into the local database-type-configs/<type>.toml file.")
1914
+ .argument("<database-type>", "Database type name")
1915
+ .option("--app <app-id>", "App ID")
1916
+ .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
1917
+ .addOption(new Option("--sync-dir <path>", "Deprecated alias of --dir").hideHelp())
1918
+ .option("-f, --force", "Overwrite an existing [models.*] block in the local file")
1919
+ .option("--json", "Output the generated TOML as JSON")
1920
+ .action(async (databaseType, options) => {
1921
+ // Resolve the sync-dir override before the API call, so a caller who
1922
+ // passed both spellings hears about it without a network round trip.
1923
+ const configDir = resolveSyncDirOverride(options, "databases schema generate");
1924
+ const resolvedAppId = resolveAppId(undefined, options);
1925
+ const client = new ApiClient();
1926
+ try {
1927
+ // 1. Call the server's :scaffold endpoint.
1928
+ const result = await client.scaffoldDatabaseTypeSchema(resolvedAppId, databaseType);
1929
+ const scaffoldedToml = result?.schema ?? "";
1930
+ if (options.json) {
1931
+ json({ schema: scaffoldedToml });
1932
+ return;
1933
+ }
1934
+ // 2. Locate the local `database-type-configs/<type>.toml` file. Per the
1935
+ // plan-of-record CLI sync layout, the file lives at
1936
+ // `<config-dir>/database-type-configs/<type>.toml`. We don't need to know
1937
+ // the exact config dir for the happy path — just look for an existing
1938
+ // file relative to the CWD's `.primitive/sync/` directory tree.
1939
+ const candidateFiles = [];
1940
+ if (configDir) {
1941
+ candidateFiles.push(path.join(configDir, "database-type-configs", `${databaseType}.toml`));
1942
+ }
1943
+ else {
1944
+ // Search any env/appId subtree under .primitive/sync that matches.
1945
+ const root = path.join(process.cwd(), ".primitive", "sync");
1946
+ if (existsSync(root)) {
1947
+ for (const env of readdirSync(root)) {
1948
+ const envDir = path.join(root, env);
1949
+ try {
1950
+ for (const app of readdirSync(envDir)) {
1951
+ const candidate = path.join(envDir, app, "database-type-configs", `${databaseType}.toml`);
1952
+ if (existsSync(candidate)) {
1953
+ candidateFiles.push(candidate);
1954
+ }
1955
+ }
1956
+ }
1957
+ catch {
1958
+ // skip non-dirs
1959
+ }
1960
+ }
1961
+ }
1962
+ }
1963
+ if (candidateFiles.length === 0) {
1964
+ // No local file found — print the scaffold to stdout and tell
1965
+ // the user where to put it.
1966
+ info(`No local database-type-configs/${databaseType}.toml found under .primitive/sync/. Generated TOML:`);
1967
+ console.log("");
1968
+ console.log(scaffoldedToml);
1969
+ return;
1970
+ }
1971
+ // 3. For each candidate, splice the scaffolded TOML at a stable
1972
+ // anchor (before the first `[[operations]]` block, or at EOF). Per
1973
+ // the plan-of-record, textual insertion is deliberate — smol-toml
1974
+ // parse/stringify drops comments, so we splice as a raw string.
1975
+ for (const targetPath of candidateFiles) {
1976
+ const original = readFileSync(targetPath, "utf-8");
1977
+ // Detect existing [models.*] block. If present, refuse unless --force.
1978
+ const hasModelsBlock = /\n\[models\./.test("\n" + original);
1979
+ if (hasModelsBlock && !options.force) {
1980
+ error(`${targetPath} already has [models.*] blocks. Re-run with --force to overwrite.`);
1981
+ process.exit(1);
1982
+ }
1983
+ // Strip existing [models.*] blocks if --force.
1984
+ let cleaned = original;
1985
+ if (hasModelsBlock && options.force) {
1986
+ cleaned = stripModelsBlocks(original);
1987
+ }
1988
+ // Splice: insert before the first [[operations]] block, otherwise
1989
+ // at the end of file.
1990
+ let merged;
1991
+ const opsAnchor = cleaned.indexOf("\n[[operations]]");
1992
+ if (opsAnchor >= 0) {
1993
+ merged =
1994
+ cleaned.slice(0, opsAnchor + 1) +
1995
+ scaffoldedToml +
1996
+ "\n" +
1997
+ cleaned.slice(opsAnchor + 1);
1998
+ }
1999
+ else {
2000
+ const sep = cleaned.endsWith("\n") ? "" : "\n";
2001
+ merged = cleaned + sep + scaffoldedToml + "\n";
2002
+ }
2003
+ writeFileSync(targetPath, merged);
2004
+ success(`Inserted schema into ${targetPath}`);
2005
+ }
2006
+ info("Review the generated schema, fix types the generator guessed wrong, then run `primitive config push` (or `primitive config push --dry-run`).");
2007
+ }
2008
+ catch (err) {
2009
+ error(err.message);
2010
+ process.exit(1);
2011
+ }
2012
+ });
2013
+ // ============================================
2014
+ // Codegen command (issue #814)
2015
+ // ============================================
2016
+ databases
2017
+ .command("codegen")
2018
+ .description("Generate record + operation types from the local database-type-configs/*.toml schema. TypeScript (default): a <type>.generated.ts per database type (record interfaces, per-op input-params interfaces, per-op result aliases, and a typed <type>Ops factory). Swift (--lang swift): a <type>.generated.swift per database type (record structs, per-op params structs, per-op result typealiases, and a typed <Type>Ops factory).\n\nFormatting: the emitted TypeScript is already formatted the way Prettier's DEFAULT config formats it, so a `codegen` -> `prettier --write` -> `codegen --check` pipeline stays green. If your project uses a NON-default Prettier config (a different printWidth, trailingComma, or quote style), Prettier will reformat the generated files and `--check` will then fail: add the generated directory to `.prettierignore` and commit the raw emission instead.")
2019
+ .argument("[database-type]", "Generate for a single database type (defaults to every database-type-configs/*.toml found)")
2020
+ .option("--app <app-id>", "App ID")
2021
+ .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
2022
+ .addOption(new Option("--sync-dir <path>", "Deprecated alias of --dir").hideHelp())
2023
+ .option("-o, --output <dir>", "Output directory for the generated files (defaults to <config-dir>/database-type-configs/generated/)")
2024
+ .option("--lang <lang>", "Target language for the generated types: 'ts' (default) or 'swift'.", "ts")
2025
+ .option("--check", "Exit non-zero if generated output is out of date (CI guard); does not write. Compares byte-for-byte, so the file on disk must match the emission exactly.")
2026
+ .option("--json", "Output the result summary as JSON")
2027
+ .action(async (databaseType, options) => {
2028
+ try {
2029
+ // 0. Validate --lang up front so an unknown value fails clearly rather
2030
+ // than silently defaulting to the TS emitter.
2031
+ const lang = String(options.lang ?? "ts").toLowerCase();
2032
+ if (lang !== "ts" && lang !== "swift") {
2033
+ error(`Unknown --lang "${options.lang}". Use "ts" or "swift".`);
2034
+ process.exit(1);
2035
+ }
2036
+ // 1. Resolve the single source database-type-configs/ directory via the
2037
+ // shared active-environment resolver (issue #1510). Honors
2038
+ // --dir / --app overrides and resolves the active env
2039
+ // (--env → PRIMITIVE_ENV → defaultEnvironment → single-env) instead
2040
+ // of silently merging types across every synced env.
2041
+ const dbTypesSourceDir = resolveCodegenSourceDir({
2042
+ subdir: "database-type-configs",
2043
+ options: {
2044
+ app: options.app,
2045
+ syncDir: resolveSyncDirOverride(options, "databases codegen"),
2046
+ },
2047
+ });
2048
+ // 2. Collect the source .toml files (one per database type), filtered
2049
+ // to a single type when an argument is given.
2050
+ const inputs = [];
2051
+ for (const fileName of readdirSync(dbTypesSourceDir)) {
2052
+ if (!fileName.endsWith(".toml"))
2053
+ continue;
2054
+ const typeName = fileName.slice(0, -".toml".length);
2055
+ if (databaseType && typeName !== databaseType)
2056
+ continue;
2057
+ const tomlPath = path.join(dbTypesSourceDir, fileName);
2058
+ inputs.push({
2059
+ databaseType: typeName,
2060
+ tomlPath,
2061
+ tomlContent: readFileSync(tomlPath, "utf-8"),
2062
+ });
2063
+ }
2064
+ if (inputs.length === 0) {
2065
+ error(databaseType
2066
+ ? `No database-type-configs/${databaseType}.toml found under .primitive/sync/.`
2067
+ : "No database-type-configs/*.toml files found to generate from.");
2068
+ process.exit(1);
2069
+ }
2070
+ // 3. Resolve the output directory. Default: a `generated/` subdir of
2071
+ // the first source dir (keeps generated output beside the schema).
2072
+ const outputDir = options.output
2073
+ ? path.resolve(options.output)
2074
+ : path.join(path.dirname(inputs[0].tomlPath), "generated");
2075
+ // 4. Run codegen (or --check) with the selected emitter. Both the TS
2076
+ // and Swift generators return the same WriteOrCheckResult shape, so
2077
+ // the reporting below is emitter-agnostic; only the emitter (and the
2078
+ // regenerate hint) differs. When a single database-type filter
2079
+ // argument is given, the input set is partial — flag it so stale
2080
+ // cleanup is scoped to that type and sibling types' generated files
2081
+ // are left intact.
2082
+ const codegenResult = lang === "swift"
2083
+ ? await generateDbSwiftTypes({
2084
+ inputs,
2085
+ outputDir,
2086
+ check: !!options.check,
2087
+ singleType: !!databaseType,
2088
+ })
2089
+ : await generateDbTypes({
2090
+ inputs,
2091
+ outputDir,
2092
+ check: !!options.check,
2093
+ singleType: !!databaseType,
2094
+ });
2095
+ if (options.check) {
2096
+ if (codegenResult.mismatches.length > 0) {
2097
+ if (options.json) {
2098
+ json({ ok: false, mismatches: codegenResult.mismatches });
2099
+ }
2100
+ else {
2101
+ error(`Check failed: ${codegenResult.mismatches.length} file(s) out of date.`);
2102
+ for (const m of codegenResult.mismatches) {
2103
+ error(` ${m.reason}: ${path.relative(process.cwd(), m.filePath)}`);
2104
+ }
2105
+ // Echo back the options that decide WHERE and WHAT was
2106
+ // checked, the way `workflows codegen` does (#2895). Suggesting
2107
+ // a bare `databases codegen` after a `--check -o <dir>` sent
2108
+ // people to regenerate into the default
2109
+ // <config-dir>/database-type-configs/generated/ instead of the
2110
+ // directory that is actually out of date, so the files stayed
2111
+ // stale and the next check failed the same way (#2911).
2112
+ const regenerate = ["primitive databases codegen"];
2113
+ if (databaseType)
2114
+ regenerate.push(databaseType);
2115
+ if (lang === "swift")
2116
+ regenerate.push("--lang swift");
2117
+ if (options.output)
2118
+ regenerate.push(`-o ${options.output}`);
2119
+ info(`Run \`${regenerate.join(" ")}\` to regenerate.`);
2120
+ }
2121
+ process.exit(1);
2122
+ }
2123
+ if (options.json) {
2124
+ json({ ok: true, checked: codegenResult.writtenFiles.length });
2125
+ }
2126
+ else {
2127
+ success(`Check passed: ${codegenResult.writtenFiles.length} file(s) up to date.`);
2128
+ }
2129
+ return;
2130
+ }
2131
+ if (options.json) {
2132
+ json({
2133
+ written: codegenResult.writtenFiles,
2134
+ deleted: codegenResult.deletedFiles,
2135
+ });
2136
+ return;
2137
+ }
2138
+ success(`Generated ${codegenResult.writtenFiles.length} file(s)` +
2139
+ (codegenResult.deletedFiles.length > 0
2140
+ ? `, deleted ${codegenResult.deletedFiles.length} stale file(s).`
2141
+ : "."));
2142
+ for (const f of codegenResult.writtenFiles) {
2143
+ keyValue(" wrote", path.relative(process.cwd(), f));
2144
+ }
2145
+ for (const f of codegenResult.deletedFiles) {
2146
+ keyValue(" deleted", path.relative(process.cwd(), f));
2147
+ }
2148
+ }
2149
+ catch (err) {
2150
+ error(err.message);
2151
+ process.exit(1);
2152
+ }
2153
+ });
2154
+ }
2155
+ /** Remove every existing `[models.*]` block from a TOML file body. */
2156
+ function stripModelsBlocks(text) {
2157
+ const lines = text.split("\n");
2158
+ const result = [];
2159
+ let skipping = false;
2160
+ for (const line of lines) {
2161
+ const isModelsHeader = /^\s*\[models\b/.test(line);
2162
+ const isOtherHeader = /^\s*\[(?!models\b)/.test(line);
2163
+ if (isModelsHeader) {
2164
+ skipping = true;
2165
+ continue;
2166
+ }
2167
+ if (skipping && isOtherHeader) {
2168
+ skipping = false;
2169
+ }
2170
+ if (!skipping)
2171
+ result.push(line);
2172
+ }
2173
+ return result.join("\n");
2174
+ }
2175
+ // ============================================
2176
+ // Database export/import helpers
2177
+ // ============================================
2178
+ async function exportSingleDatabase(client, appId, databaseId, outputDir, jsonOutput) {
2179
+ const dbDir = path.join(outputDir, "databases", databaseId);
2180
+ mkdirSync(dbDir, { recursive: true });
2181
+ // 1. Fetch database metadata
2182
+ const dbMeta = await client.getDatabase(appId, databaseId);
2183
+ // Warn about databaseType sync
2184
+ if (dbMeta.databaseType) {
2185
+ warn(`This database uses type "${dbMeta.databaseType}". Make sure to \`primitive config push\` the type config before importing into a new app.`);
2186
+ }
2187
+ // 2. Write metadata
2188
+ const metadata = {
2189
+ databaseId: dbMeta.databaseId || databaseId,
2190
+ title: dbMeta.title,
2191
+ databaseType: dbMeta.databaseType || null,
2192
+ metadata: dbMeta.metadata || null,
2193
+ };
2194
+ writeFileSync(path.join(dbDir, "metadata.json"), JSON.stringify(metadata, null, 2));
2195
+ // 3. Export records: list models, then query each.
2196
+ // listDatabaseModels() resolves to `{ models: string[] }`, not a bare array,
2197
+ // so unwrap it before iterating (mirrors the `databases records models`
2198
+ // consumer above). Without this, `for (const modelName of models)` throws
2199
+ // "models is not iterable" and records.jsonl is left empty (#1396).
2200
+ const models = (await client.listDatabaseModels(appId, databaseId)).models || [];
2201
+ let totalRecords = 0;
2202
+ const recordsFile = path.join(dbDir, "records.jsonl");
2203
+ // Clear file
2204
+ writeFileSync(recordsFile, "");
2205
+ for (const modelName of models) {
2206
+ let cursor;
2207
+ let hasMore = true;
2208
+ while (hasMore) {
2209
+ const result = await client.queryDatabaseRecords(appId, databaseId, modelName, {
2210
+ limit: 100,
2211
+ cursor,
2212
+ });
2213
+ const records = result.items;
2214
+ for (const record of records) {
2215
+ const line = JSON.stringify({ _type: modelName, _id: record.id, _data: record });
2216
+ appendFileSync(recordsFile, line + "\n");
2217
+ totalRecords++;
2218
+ }
2219
+ cursor = result.nextCursor;
2220
+ hasMore = !!cursor && records.length > 0;
2221
+ }
2222
+ }
2223
+ // 4. Export indexes
2224
+ const indexes = await client.listDatabaseIndexes(appId, databaseId);
2225
+ writeFileSync(path.join(dbDir, "indexes.json"), JSON.stringify(indexes, null, 2));
2226
+ // 5. Export unique constraints
2227
+ const constraints = await client.listDatabaseUniqueConstraints(appId, databaseId);
2228
+ writeFileSync(path.join(dbDir, "constraints.json"), JSON.stringify(constraints, null, 2));
2229
+ if (jsonOutput) {
2230
+ json({
2231
+ databaseId,
2232
+ title: metadata.title,
2233
+ databaseType: metadata.databaseType,
2234
+ models: models.length,
2235
+ records: totalRecords,
2236
+ indexes: indexes.length,
2237
+ constraints: constraints.length,
2238
+ exportPath: dbDir,
2239
+ });
2240
+ }
2241
+ else {
2242
+ success(`Exported database ${databaseId} (${metadata.title || "untitled"}) to ${dbDir}`);
2243
+ keyValue(" Models", String(models.length));
2244
+ keyValue(" Records", String(totalRecords));
2245
+ keyValue(" Indexes", String(indexes.length));
2246
+ }
2247
+ }
2248
+ /**
2249
+ * Conservative client-side ceiling for `databases import --batch-size`.
2250
+ * `records/batch` has no fixed server-side item cap (unlike the
2251
+ * registered-batch path used by `import-csv`), so this bounds each request to
2252
+ * keep it within Cloudflare body-size and Durable Object memory limits.
2253
+ */
2254
+ const IMPORT_BATCH_SIZE_CEILING = 25000;
2255
+ async function importSingleDatabase(client, appId, dbDir, options) {
2256
+ const { overwrite, dryRun, batchSize, stopOnError, json: jsonOutput, summary } = options;
2257
+ const metadataPath = path.join(dbDir, "metadata.json");
2258
+ if (!existsSync(metadataPath)) {
2259
+ warn(`Skipping ${dbDir}: no metadata.json found`);
2260
+ summary.skipped++;
2261
+ return;
2262
+ }
2263
+ const metadata = JSON.parse(readFileSync(metadataPath, "utf-8"));
2264
+ const databaseId = metadata.databaseId;
2265
+ if (dryRun) {
2266
+ // Report record/batch counts without writing anything (no DB creation,
2267
+ // no index registration, no batch write).
2268
+ const recordCount = countRecordLines(dbDir);
2269
+ const batchCount = chunk(new Array(recordCount), batchSize).length;
2270
+ summary.created++;
2271
+ summary.recordsImported += recordCount;
2272
+ summary.batches += batchCount;
2273
+ info(`[dry-run] Would import database ${databaseId} (${metadata.title || "untitled"}) — ${recordCount} record(s), ${batchCount} batch(es)`);
2274
+ return;
2275
+ }
2276
+ // Check if database type exists — warn if not
2277
+ if (metadata.databaseType) {
2278
+ warn(`Database uses type "${metadata.databaseType}". Ensure the type config exists in the target app (use \`primitive config push\`).`);
2279
+ }
2280
+ // Check if database exists — use the original ID for overwrite scenarios
2281
+ let targetDatabaseId = databaseId;
2282
+ let exists = false;
2283
+ try {
2284
+ await client.getDatabase(appId, databaseId);
2285
+ exists = true;
2286
+ }
2287
+ catch {
2288
+ exists = false;
2289
+ }
2290
+ if (exists && !overwrite) {
2291
+ warn(`Skipping ${databaseId}: already exists (use --overwrite to replace)`);
2292
+ summary.skipped++;
2293
+ return;
2294
+ }
2295
+ // Create database if it doesn't exist
2296
+ // Note: server auto-generates the ID, so we capture it for subsequent operations
2297
+ if (!exists) {
2298
+ const createData = {
2299
+ title: metadata.title || "Imported Database",
2300
+ };
2301
+ if (metadata.databaseType) {
2302
+ createData.databaseType = metadata.databaseType;
2303
+ }
2304
+ if (metadata.metadata) {
2305
+ createData.metadata = metadata.metadata;
2306
+ }
2307
+ const created = await client.createDatabase(appId, createData);
2308
+ targetDatabaseId = created.databaseId || created.id || databaseId;
2309
+ summary.created++;
2310
+ }
2311
+ else {
2312
+ summary.updated++;
2313
+ }
2314
+ // Register indexes
2315
+ const indexesPath = path.join(dbDir, "indexes.json");
2316
+ if (existsSync(indexesPath)) {
2317
+ const indexes = JSON.parse(readFileSync(indexesPath, "utf-8"));
2318
+ for (const idx of indexes) {
2319
+ try {
2320
+ await client.registerDatabaseIndex(appId, targetDatabaseId, {
2321
+ modelName: idx.modelName || idx.model_name,
2322
+ fieldName: idx.fieldName || idx.field_name,
2323
+ fieldType: idx.fieldType || idx.field_type || "string",
2324
+ unique: idx.isUnique || idx.is_unique || false,
2325
+ });
2326
+ summary.indexesRegistered++;
2327
+ }
2328
+ catch {
2329
+ // Index may already exist
2330
+ }
2331
+ }
2332
+ }
2333
+ // Register unique constraints
2334
+ const constraintsPath = path.join(dbDir, "constraints.json");
2335
+ if (existsSync(constraintsPath)) {
2336
+ const constraints = JSON.parse(readFileSync(constraintsPath, "utf-8"));
2337
+ for (const c of constraints) {
2338
+ try {
2339
+ await client.registerDatabaseUniqueConstraint(appId, targetDatabaseId, {
2340
+ modelName: c.modelName || c.model_name,
2341
+ constraintName: c.constraintName || c.constraint_name,
2342
+ fields: c.fields ? (typeof c.fields === "string" ? JSON.parse(c.fields) : c.fields) : [],
2343
+ });
2344
+ }
2345
+ catch {
2346
+ // Constraint may already exist
2347
+ }
2348
+ }
2349
+ }
2350
+ // Import records via chunked batch writes (was one save per record).
2351
+ const recordsPath = path.join(dbDir, "records.jsonl");
2352
+ if (existsSync(recordsPath)) {
2353
+ const lines = readFileSync(recordsPath, "utf-8").split("\n").filter(Boolean);
2354
+ // Map each records.jsonl line to a batch save op. Preserve the explicit
2355
+ // `_type`/`_id`; strip the redundant id/type keys out of `_data` (the
2356
+ // per-record loop did the same) so they don't duplicate into stored data.
2357
+ const ops = lines.map((line) => {
2358
+ const record = JSON.parse(line);
2359
+ const { _type, _id, _data } = record;
2360
+ const { id: _ignoredId, type: _ignoredType, ...data } = _data;
2361
+ return { op: "save", modelName: _type, id: _id, data };
2362
+ });
2363
+ const chunks = chunk(ops, batchSize);
2364
+ summary.batches += chunks.length;
2365
+ for (let i = 0; i < chunks.length; i++) {
2366
+ const piece = chunks[i];
2367
+ let chunkFailed = false;
2368
+ try {
2369
+ const res = await client.batchDatabaseRecords(appId, targetDatabaseId, piece);
2370
+ const results = res?.results ?? [];
2371
+ let imported = 0;
2372
+ let failed = 0;
2373
+ const firstErrors = [];
2374
+ for (let j = 0; j < piece.length; j++) {
2375
+ const r = results[j];
2376
+ if (r && r.success) {
2377
+ imported++;
2378
+ }
2379
+ else {
2380
+ failed++;
2381
+ if (r?.error && firstErrors.length < 3)
2382
+ firstErrors.push(r.error);
2383
+ }
2384
+ }
2385
+ summary.recordsImported += imported;
2386
+ summary.recordsFailed += failed;
2387
+ if (failed > 0) {
2388
+ chunkFailed = true;
2389
+ summary.batchErrors.push({
2390
+ database: databaseId,
2391
+ batch: i,
2392
+ error: `${failed} record(s) failed${firstErrors.length ? `: ${firstErrors.join("; ")}` : ""}`,
2393
+ });
2394
+ }
2395
+ if (!jsonOutput) {
2396
+ info(` ${databaseId} batch ${i + 1}/${chunks.length}: imported ${imported}, failed ${failed}`);
2397
+ }
2398
+ }
2399
+ catch (err) {
2400
+ // Chunk-level failure (e.g. a 400 for a malformed op): the whole chunk
2401
+ // is rejected, so every item in it counts as failed.
2402
+ const msg = err?.message || String(err);
2403
+ chunkFailed = true;
2404
+ summary.recordsFailed += piece.length;
2405
+ summary.batchErrors.push({ database: databaseId, batch: i, error: msg });
2406
+ if (!jsonOutput) {
2407
+ warn(` ${databaseId} batch ${i + 1}/${chunks.length} failed: ${msg}`);
2408
+ }
2409
+ }
2410
+ if (chunkFailed && stopOnError) {
2411
+ summary.aborted = true;
2412
+ return;
2413
+ }
2414
+ }
2415
+ }
2416
+ info(`Imported database ${databaseId} (${metadata.title || "untitled"})`);
2417
+ }
2418
+ /** Count non-empty lines in a database export's records.jsonl (0 if absent). */
2419
+ function countRecordLines(dbDir) {
2420
+ const recordsPath = path.join(dbDir, "records.jsonl");
2421
+ if (!existsSync(recordsPath))
2422
+ return 0;
2423
+ return readFileSync(recordsPath, "utf-8").split("\n").filter(Boolean).length;
396
2424
  }
397
2425
  //# sourceMappingURL=databases.js.map