primitive-admin 1.1.0-alpha.8 → 1.1.0-alpha.80

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