primitive-admin 1.0.60 → 1.0.62

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 (310) hide show
  1. package/README.md +125 -69
  2. package/assets/skill/skills/primitive-platform/SKILL.md +336 -37
  3. package/dist/bin/primitive.js +29 -14
  4. package/dist/bin/primitive.js.map +1 -1
  5. package/dist/src/commands/admins.js +22 -8
  6. package/dist/src/commands/admins.js.map +1 -1
  7. package/dist/src/commands/analytics.js +96 -16
  8. package/dist/src/commands/analytics.js.map +1 -1
  9. package/dist/src/commands/apps.js +49 -188
  10. package/dist/src/commands/apps.js.map +1 -1
  11. package/dist/src/commands/blob-buckets.js +50 -155
  12. package/dist/src/commands/blob-buckets.js.map +1 -1
  13. package/dist/src/commands/collection-type-configs.js +14 -100
  14. package/dist/src/commands/collection-type-configs.js.map +1 -1
  15. package/dist/src/commands/collections.js +47 -8
  16. package/dist/src/commands/collections.js.map +1 -1
  17. package/dist/src/commands/config.d.ts +46 -0
  18. package/dist/src/commands/config.js +479 -0
  19. package/dist/src/commands/config.js.map +1 -0
  20. package/dist/src/commands/connections.d.ts +2 -0
  21. package/dist/src/commands/connections.js +100 -0
  22. package/dist/src/commands/connections.js.map +1 -0
  23. package/dist/src/commands/cron-triggers.js +84 -176
  24. package/dist/src/commands/cron-triggers.js.map +1 -1
  25. package/dist/src/commands/database-type-configs.d.ts +2 -0
  26. package/dist/src/commands/database-type-configs.js +171 -0
  27. package/dist/src/commands/database-type-configs.js.map +1 -0
  28. package/dist/src/commands/database-types.js +4 -4
  29. package/dist/src/commands/database-types.js.map +1 -1
  30. package/dist/src/commands/databases.d.ts +63 -0
  31. package/dist/src/commands/databases.js +649 -87
  32. package/dist/src/commands/databases.js.map +1 -1
  33. package/dist/src/commands/documents.js +869 -12
  34. package/dist/src/commands/documents.js.map +1 -1
  35. package/dist/src/commands/email-templates.js +18 -125
  36. package/dist/src/commands/email-templates.js.map +1 -1
  37. package/dist/src/commands/env.d.ts +12 -1
  38. package/dist/src/commands/env.js +98 -25
  39. package/dist/src/commands/env.js.map +1 -1
  40. package/dist/src/commands/feature-flags.d.ts +14 -0
  41. package/dist/src/commands/feature-flags.js +116 -0
  42. package/dist/src/commands/feature-flags.js.map +1 -0
  43. package/dist/src/commands/group-type-configs.js +14 -109
  44. package/dist/src/commands/group-type-configs.js.map +1 -1
  45. package/dist/src/commands/init.d.ts +9 -1
  46. package/dist/src/commands/init.js +1058 -279
  47. package/dist/src/commands/init.js.map +1 -1
  48. package/dist/src/commands/integrations.js +136 -421
  49. package/dist/src/commands/integrations.js.map +1 -1
  50. package/dist/src/commands/metadata-category-configs.d.ts +12 -0
  51. package/dist/src/commands/metadata-category-configs.js +112 -0
  52. package/dist/src/commands/metadata-category-configs.js.map +1 -0
  53. package/dist/src/commands/metadata.js +33 -141
  54. package/dist/src/commands/metadata.js.map +1 -1
  55. package/dist/src/commands/prompts.js +206 -563
  56. package/dist/src/commands/prompts.js.map +1 -1
  57. package/dist/src/commands/rule-sets.d.ts +1 -1
  58. package/dist/src/commands/rule-sets.js +16 -132
  59. package/dist/src/commands/rule-sets.js.map +1 -1
  60. package/dist/src/commands/scripts.js +67 -234
  61. package/dist/src/commands/scripts.js.map +1 -1
  62. package/dist/src/commands/sessions.d.ts +2 -0
  63. package/dist/src/commands/sessions.js +75 -0
  64. package/dist/src/commands/sessions.js.map +1 -0
  65. package/dist/src/commands/skill.js +2 -2
  66. package/dist/src/commands/skill.js.map +1 -1
  67. package/dist/src/commands/sync-app-settings.d.ts +113 -70
  68. package/dist/src/commands/sync-app-settings.js +193 -242
  69. package/dist/src/commands/sync-app-settings.js.map +1 -1
  70. package/dist/src/commands/sync.d.ts +1932 -49
  71. package/dist/src/commands/sync.js +9584 -1725
  72. package/dist/src/commands/sync.js.map +1 -1
  73. package/dist/src/commands/tokens.js +8 -2
  74. package/dist/src/commands/tokens.js.map +1 -1
  75. package/dist/src/commands/users.js +92 -1
  76. package/dist/src/commands/users.js.map +1 -1
  77. package/dist/src/commands/vars.js +44 -58
  78. package/dist/src/commands/vars.js.map +1 -1
  79. package/dist/src/commands/webhooks.js +318 -156
  80. package/dist/src/commands/webhooks.js.map +1 -1
  81. package/dist/src/commands/workflows.d.ts +49 -12
  82. package/dist/src/commands/workflows.js +652 -1043
  83. package/dist/src/commands/workflows.js.map +1 -1
  84. package/dist/src/lib/access-rule-display.d.ts +21 -0
  85. package/dist/src/lib/access-rule-display.js +34 -0
  86. package/dist/src/lib/access-rule-display.js.map +1 -0
  87. package/dist/src/lib/api-client.d.ts +630 -61
  88. package/dist/src/lib/api-client.js +676 -116
  89. package/dist/src/lib/api-client.js.map +1 -1
  90. package/dist/src/lib/app-settings-descriptor.d.ts +180 -25
  91. package/dist/src/lib/app-settings-descriptor.js +403 -82
  92. package/dist/src/lib/app-settings-descriptor.js.map +1 -1
  93. package/dist/src/lib/canonical-json.d.ts +12 -0
  94. package/dist/src/lib/canonical-json.js +35 -0
  95. package/dist/src/lib/canonical-json.js.map +1 -0
  96. package/dist/src/lib/cli-manifest.d.ts +8 -0
  97. package/dist/src/lib/cli-manifest.js +1 -0
  98. package/dist/src/lib/cli-manifest.js.map +1 -1
  99. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +6 -2
  100. package/dist/src/lib/codegen-shared/generatedFiles.js +9 -2
  101. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
  102. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +6 -6
  103. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +8 -8
  104. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
  105. package/dist/src/lib/config-object-descriptor.d.ts +127 -0
  106. package/dist/src/lib/config-object-descriptor.js +658 -0
  107. package/dist/src/lib/config-object-descriptor.js.map +1 -0
  108. package/dist/src/lib/config-payload.d.ts +85 -0
  109. package/dist/src/lib/config-payload.js +116 -0
  110. package/dist/src/lib/config-payload.js.map +1 -0
  111. package/dist/src/lib/config-surface.d.ts +130 -0
  112. package/dist/src/lib/config-surface.js +300 -0
  113. package/dist/src/lib/config-surface.js.map +1 -0
  114. package/dist/src/lib/credentials-store.d.ts +10 -0
  115. package/dist/src/lib/credentials-store.js +23 -0
  116. package/dist/src/lib/credentials-store.js.map +1 -1
  117. package/dist/src/lib/data-input.d.ts +23 -0
  118. package/dist/src/lib/data-input.js +50 -0
  119. package/dist/src/lib/data-input.js.map +1 -0
  120. package/dist/src/lib/db-codegen/dbTemplates.js +1 -1
  121. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  122. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +1 -1
  123. package/dist/src/lib/env-resolver-core.d.ts +147 -0
  124. package/dist/src/lib/env-resolver-core.js +265 -0
  125. package/dist/src/lib/env-resolver-core.js.map +1 -0
  126. package/dist/src/lib/env-resolver.d.ts +28 -6
  127. package/dist/src/lib/env-resolver.js +44 -32
  128. package/dist/src/lib/env-resolver.js.map +1 -1
  129. package/dist/src/lib/generated-allowlist.js +13 -0
  130. package/dist/src/lib/generated-allowlist.js.map +1 -1
  131. package/dist/src/lib/generated-config-surfaces.d.ts +682 -0
  132. package/dist/src/lib/generated-config-surfaces.js +4058 -0
  133. package/dist/src/lib/generated-config-surfaces.js.map +1 -0
  134. package/dist/src/lib/generated-template-lint.d.ts +212 -0
  135. package/dist/src/lib/generated-template-lint.js +624 -0
  136. package/dist/src/lib/generated-template-lint.js.map +1 -0
  137. package/dist/src/lib/init-adopt.d.ts +16 -0
  138. package/dist/src/lib/init-adopt.js +34 -0
  139. package/dist/src/lib/init-adopt.js.map +1 -0
  140. package/dist/src/lib/init-assets.d.ts +39 -0
  141. package/dist/src/lib/init-assets.js +97 -0
  142. package/dist/src/lib/init-assets.js.map +1 -0
  143. package/dist/src/lib/init-config.d.ts +44 -5
  144. package/dist/src/lib/init-config.js +80 -7
  145. package/dist/src/lib/init-config.js.map +1 -1
  146. package/dist/src/lib/init-ios-links.d.ts +50 -0
  147. package/dist/src/lib/init-ios-links.js +153 -0
  148. package/dist/src/lib/init-ios-links.js.map +1 -0
  149. package/dist/src/lib/init-plan.d.ts +80 -0
  150. package/dist/src/lib/init-plan.js +95 -0
  151. package/dist/src/lib/init-plan.js.map +1 -0
  152. package/dist/src/lib/init-production-env.d.ts +48 -0
  153. package/dist/src/lib/init-production-env.js +59 -0
  154. package/dist/src/lib/init-production-env.js.map +1 -0
  155. package/dist/src/lib/init-schema.d.ts +74 -0
  156. package/dist/src/lib/init-schema.js +358 -0
  157. package/dist/src/lib/init-schema.js.map +1 -0
  158. package/dist/src/lib/init-xcode.d.ts +33 -0
  159. package/dist/src/lib/init-xcode.js +114 -0
  160. package/dist/src/lib/init-xcode.js.map +1 -0
  161. package/dist/src/lib/integration-request-config.d.ts +30 -0
  162. package/dist/src/lib/integration-request-config.js +145 -0
  163. package/dist/src/lib/integration-request-config.js.map +1 -0
  164. package/dist/src/lib/local-state.d.ts +55 -0
  165. package/dist/src/lib/local-state.js +167 -0
  166. package/dist/src/lib/local-state.js.map +1 -0
  167. package/dist/src/lib/log-inspection.d.ts +568 -0
  168. package/dist/src/lib/log-inspection.js +639 -0
  169. package/dist/src/lib/log-inspection.js.map +1 -0
  170. package/dist/src/lib/migration-nag.d.ts +2 -2
  171. package/dist/src/lib/migration-nag.js +3 -3
  172. package/dist/src/lib/object-status-filter.d.ts +22 -0
  173. package/dist/src/lib/object-status-filter.js +45 -0
  174. package/dist/src/lib/object-status-filter.js.map +1 -0
  175. package/dist/src/lib/output.d.ts +24 -1
  176. package/dist/src/lib/output.js +42 -1
  177. package/dist/src/lib/output.js.map +1 -1
  178. package/dist/src/lib/package-manager.d.ts +140 -0
  179. package/dist/src/lib/package-manager.js +305 -0
  180. package/dist/src/lib/package-manager.js.map +1 -0
  181. package/dist/src/lib/paginate.d.ts +50 -0
  182. package/dist/src/lib/paginate.js +53 -0
  183. package/dist/src/lib/paginate.js.map +1 -1
  184. package/dist/src/lib/platform-owned.d.ts +29 -3
  185. package/dist/src/lib/platform-owned.js +42 -3
  186. package/dist/src/lib/platform-owned.js.map +1 -1
  187. package/dist/src/lib/project-config.js +13 -5
  188. package/dist/src/lib/project-config.js.map +1 -1
  189. package/dist/src/lib/query-operators.d.ts +1 -1
  190. package/dist/src/lib/query-operators.js +1 -1
  191. package/dist/src/lib/record-filter.d.ts +18 -0
  192. package/dist/src/lib/record-filter.js +55 -0
  193. package/dist/src/lib/record-filter.js.map +1 -0
  194. package/dist/src/lib/resolve-init-dev-port.d.ts +56 -0
  195. package/dist/src/lib/resolve-init-dev-port.js +55 -0
  196. package/dist/src/lib/resolve-init-dev-port.js.map +1 -0
  197. package/dist/src/lib/resolve-init-server.d.ts +64 -0
  198. package/dist/src/lib/resolve-init-server.js +77 -0
  199. package/dist/src/lib/resolve-init-server.js.map +1 -0
  200. package/dist/src/lib/resolve-platform.d.ts +43 -14
  201. package/dist/src/lib/resolve-platform.js +74 -12
  202. package/dist/src/lib/resolve-platform.js.map +1 -1
  203. package/dist/src/lib/run-status.d.ts +19 -0
  204. package/dist/src/lib/run-status.generated.d.ts +39 -0
  205. package/dist/src/lib/run-status.generated.js +66 -0
  206. package/dist/src/lib/run-status.generated.js.map +1 -0
  207. package/dist/src/lib/run-status.js +19 -0
  208. package/dist/src/lib/run-status.js.map +1 -0
  209. package/dist/src/lib/server-text-normalization.d.ts +51 -0
  210. package/dist/src/lib/server-text-normalization.js +90 -0
  211. package/dist/src/lib/server-text-normalization.js.map +1 -0
  212. package/dist/src/lib/server-url.d.ts +22 -0
  213. package/dist/src/lib/server-url.js +33 -0
  214. package/dist/src/lib/server-url.js.map +1 -0
  215. package/dist/src/lib/signing-secret-status.d.ts +81 -0
  216. package/dist/src/lib/signing-secret-status.js +116 -0
  217. package/dist/src/lib/signing-secret-status.js.map +1 -0
  218. package/dist/src/lib/skill-installer.d.ts +4 -2
  219. package/dist/src/lib/skill-installer.js +143 -12
  220. package/dist/src/lib/skill-installer.js.map +1 -1
  221. package/dist/src/lib/snapshots.d.ts +2 -2
  222. package/dist/src/lib/snapshots.js +2 -2
  223. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +15 -2
  224. package/dist/src/lib/swift-codegen/dbGenerator.js +250 -18
  225. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -1
  226. package/dist/src/lib/swift-codegen/generator.d.ts +11 -2
  227. package/dist/src/lib/swift-codegen/generator.js +181 -7
  228. package/dist/src/lib/swift-codegen/generator.js.map +1 -1
  229. package/dist/src/lib/swift-codegen/siblingSymbols.d.ts +7 -3
  230. package/dist/src/lib/swift-codegen/siblingSymbols.js +4 -1
  231. package/dist/src/lib/swift-codegen/siblingSymbols.js.map +1 -1
  232. package/dist/src/lib/sync-dir-selector.d.ts +21 -0
  233. package/dist/src/lib/sync-dir-selector.js +30 -0
  234. package/dist/src/lib/sync-dir-selector.js.map +1 -0
  235. package/dist/src/lib/sync-paths.d.ts +39 -0
  236. package/dist/src/lib/sync-paths.js +70 -2
  237. package/dist/src/lib/sync-paths.js.map +1 -1
  238. package/dist/src/lib/sync-resource-types.d.ts +138 -31
  239. package/dist/src/lib/sync-resource-types.js +241 -47
  240. package/dist/src/lib/sync-resource-types.js.map +1 -1
  241. package/dist/src/lib/sync-selectors.d.ts +95 -0
  242. package/dist/src/lib/sync-selectors.js +228 -0
  243. package/dist/src/lib/sync-selectors.js.map +1 -0
  244. package/dist/src/lib/template.d.ts +62 -4
  245. package/dist/src/lib/template.js +147 -39
  246. package/dist/src/lib/template.js.map +1 -1
  247. package/dist/src/lib/test-case-keys.d.ts +29 -0
  248. package/dist/src/lib/test-case-keys.js +55 -0
  249. package/dist/src/lib/test-case-keys.js.map +1 -0
  250. package/dist/src/lib/test-case-variables.d.ts +15 -0
  251. package/dist/src/lib/test-case-variables.js +29 -0
  252. package/dist/src/lib/test-case-variables.js.map +1 -0
  253. package/dist/src/lib/toml-database-config.d.ts +4 -4
  254. package/dist/src/lib/toml-database-config.js +27 -34
  255. package/dist/src/lib/toml-database-config.js.map +1 -1
  256. package/dist/src/lib/toml-metadata-config.d.ts +9 -5
  257. package/dist/src/lib/toml-metadata-config.js +40 -45
  258. package/dist/src/lib/toml-metadata-config.js.map +1 -1
  259. package/dist/src/lib/toml-native-form.d.ts +1 -1
  260. package/dist/src/lib/toml-native-form.js +1 -1
  261. package/dist/src/lib/toml-params-validator.d.ts +1 -1
  262. package/dist/src/lib/toml-params-validator.js +1 -1
  263. package/dist/src/lib/toml-scalar-edit.d.ts +43 -0
  264. package/dist/src/lib/toml-scalar-edit.js +283 -0
  265. package/dist/src/lib/toml-scalar-edit.js.map +1 -0
  266. package/dist/src/lib/user-selector.d.ts +24 -0
  267. package/dist/src/lib/user-selector.js +33 -0
  268. package/dist/src/lib/user-selector.js.map +1 -0
  269. package/dist/src/lib/version-check.d.ts +25 -0
  270. package/dist/src/lib/version-check.js +77 -8
  271. package/dist/src/lib/version-check.js.map +1 -1
  272. package/dist/src/lib/watch.d.ts +121 -0
  273. package/dist/src/lib/watch.js +169 -0
  274. package/dist/src/lib/watch.js.map +1 -0
  275. package/dist/src/lib/workflow-apply.d.ts +69 -45
  276. package/dist/src/lib/workflow-apply.js +99 -72
  277. package/dist/src/lib/workflow-apply.js.map +1 -1
  278. package/dist/src/lib/workflow-codegen/generator.js +4 -4
  279. package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
  280. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +11 -0
  281. package/dist/src/lib/workflow-codegen/invokerIR.js +5 -0
  282. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -1
  283. package/dist/src/lib/workflow-config-apply.d.ts +70 -0
  284. package/dist/src/lib/workflow-config-apply.js +137 -0
  285. package/dist/src/lib/workflow-config-apply.js.map +1 -0
  286. package/dist/src/lib/workflow-config-sidecar.d.ts +63 -0
  287. package/dist/src/lib/workflow-config-sidecar.js +96 -0
  288. package/dist/src/lib/workflow-config-sidecar.js.map +1 -0
  289. package/dist/src/lib/workflow-defaults.d.ts +29 -0
  290. package/dist/src/lib/workflow-defaults.js +41 -0
  291. package/dist/src/lib/workflow-defaults.js.map +1 -0
  292. package/dist/src/lib/workflow-fragments.d.ts +23 -0
  293. package/dist/src/lib/workflow-fragments.js +229 -8
  294. package/dist/src/lib/workflow-fragments.js.map +1 -1
  295. package/dist/src/lib/workflow-include-preserve.d.ts +76 -0
  296. package/dist/src/lib/workflow-include-preserve.js +286 -0
  297. package/dist/src/lib/workflow-include-preserve.js.map +1 -0
  298. package/dist/src/lib/workflow-payload.d.ts +31 -20
  299. package/dist/src/lib/workflow-payload.js +100 -93
  300. package/dist/src/lib/workflow-payload.js.map +1 -1
  301. package/dist/src/lib/workflow-toml-validator.d.ts +80 -3
  302. package/dist/src/lib/workflow-toml-validator.js +231 -3
  303. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  304. package/dist/src/types/index.d.ts +78 -22
  305. package/dist/src/validators.d.ts +7 -7
  306. package/dist/src/validators.js +6 -6
  307. package/package.json +11 -6
  308. package/dist/src/commands/settings.d.ts +0 -15
  309. package/dist/src/commands/settings.js +0 -102
  310. package/dist/src/commands/settings.js.map +0 -1
@@ -1,15 +1,19 @@
1
+ import { Option } from "commander";
1
2
  import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync } from "fs";
2
3
  import * as path from "path";
3
- import { parseConfigToml } from "../lib/config-toml.js";
4
4
  import { ApiClient } from "../lib/api-client.js";
5
5
  import { getCurrentAppId } from "../lib/config.js";
6
6
  import { resolveCodegenSourceDir } from "../lib/codegen-shared/resolveCodegenSourceDir.js";
7
+ import { resolveSyncDirOverride } from "../lib/sync-dir-selector.js";
7
8
  import { parseCsv, applyColumnMap, buildRows } from "../lib/csv.js";
8
9
  import { chunk, parseBatchSize } from "../lib/batch.js";
9
10
  import { generateDbTypes, } from "../lib/db-codegen/dbGenerator.js";
10
11
  import { generateDbSwiftTypes } from "../lib/swift-codegen/dbGenerator.js";
11
12
  import { success, error, info, warn, keyValue, result as printResult, formatTable, formatId, formatDate, json, } from "../lib/output.js";
12
13
  import { confirmPrompt } from "../lib/confirm-prompt.js";
14
+ import { parseFilterOptions } from "../lib/record-filter.js";
15
+ import { parseDataOption } from "../lib/data-input.js";
16
+ import { ulid } from "ulid";
13
17
  function resolveAppId(appId, options) {
14
18
  const resolved = appId || options.app || getCurrentAppId();
15
19
  if (!resolved) {
@@ -18,6 +22,43 @@ function resolveAppId(appId, options) {
18
22
  }
19
23
  return resolved;
20
24
  }
25
+ /**
26
+ * Render the flat `{ success, id, appliedFields }` response that
27
+ * `records/save` and `records/patch` return — these endpoints answer with a
28
+ * write receipt, not the stored record, so the human view shows the resolved id
29
+ * plus any server-applied fields (autoStamps / trigger-computed values).
30
+ */
31
+ function renderRecordWriteResult(result, fallbackId, message) {
32
+ success(message);
33
+ keyValue("id", String(result?.id ?? fallbackId));
34
+ const applied = result?.appliedFields;
35
+ if (applied && typeof applied === "object" && Object.keys(applied).length > 0) {
36
+ for (const [key, value] of Object.entries(applied)) {
37
+ keyValue(`applied.${key}`, value === null || value === undefined ? "—" : String(value));
38
+ }
39
+ }
40
+ }
41
+ /**
42
+ * Refuse a write whose `--data` carries an `id` that is not the record being
43
+ * written. The server strips `id` from the record body on both the save and the
44
+ * patch path, so such an `id` would be dropped silently and the write would
45
+ * land on the id from the argument — somewhere the caller did not intend.
46
+ *
47
+ * `recordId` is the id the caller passed as an argument (absent on a `save`
48
+ * that mints one); `writtenId` is the id actually being written. Exits non-zero
49
+ * when they disagree with `--data` (#1965 review).
50
+ */
51
+ function assertDataIdMatches(data, recordId, writtenId, commandPath) {
52
+ if (!data || typeof data !== "object")
53
+ return;
54
+ const dataId = data.id;
55
+ if (dataId === undefined || dataId === writtenId)
56
+ return;
57
+ error(recordId
58
+ ? `The "id" in --data ("${dataId}") does not match the record id "${recordId}". Remove it, or pass the id you mean as the argument.`
59
+ : `Pass the record id as the argument, not inside --data: ${commandPath} ${dataId} --data ...`);
60
+ process.exit(1);
61
+ }
21
62
  /**
22
63
  * Validate that the named operation is an import-compatible, save-like
23
64
  * mutation before importing (issue #145, Codex finding (a)).
@@ -61,6 +102,95 @@ async function validateImportOperation(client, appId, databaseId, operationName)
61
102
  return;
62
103
  }
63
104
  }
105
+ /**
106
+ * The full `databases records bulk` precondition value space, audited in one
107
+ * pass (issue #2437, Codex cycle 3). A precondition is field-equality only, so
108
+ * a value is one of: `null`, a boolean, a number, or a string. For each, what
109
+ * the DatabaseDO `condition` grammar can express, compared against the
110
+ * documents twin's strict JS equality (`evaluatePrecondition`):
111
+ *
112
+ * | Value | Disposition | Why |
113
+ * |-------------------|-------------|----------------------------------------------------------------------|
114
+ * | string | round-trips | bound as TEXT; SQLite never equates TEXT with a number or a JSON bool |
115
+ * | number ≠ 0 and ≠1 | round-trips | bound as-is; no other JSON type extracts to that value |
116
+ * | `null` | rewritten | paired with `$exists: true` so an absent key fails (cycle-2 fix) |
117
+ * | `true` / `false` | REJECTED | bound as 1/0, and `json_extract` yields 1/0 for JSON booleans too |
118
+ * | number 0 or 1 | REJECTED | same collision seen from the other side: matches a stored `false`/`true` |
119
+ * | `{}` (no fields) | see #2511 | sent as an empty `condition`, which the DO reads as "record must exist" |
120
+ * | object / array | rejected | not field-equality; rejected by the bulk validator before reaching here |
121
+ *
122
+ * The two REJECTED rows are the boolean/number collision: `JsonQueryTranslator`
123
+ * binds `true`/`false` as `1`/`0`, and SQLite's `json_extract` also returns a
124
+ * stored JSON boolean as `1`/`0`, so `{ enabled: true }` matches a record
125
+ * holding the number `1` and `{ retries: 1 }` matches a record holding `true`.
126
+ * The documents twin compares with `!==` and fails both. The `condition`
127
+ * grammar has no type predicate (`$exists` only distinguishes present from
128
+ * absent), so this cannot be translated exactly — the CLI rejects those values
129
+ * at the boundary instead, so the databases twin fails loudly rather than
130
+ * running with a weaker guard than the caller wrote. Issue #2507 tracks giving
131
+ * the grammar a type-aware equality, which would let these values be accepted.
132
+ *
133
+ * Returns an explanation when the value cannot be translated exactly, or
134
+ * `null` when it round-trips or is rewritten.
135
+ */
136
+ export function untranslatablePreconditionValue(value) {
137
+ if (typeof value === "boolean") {
138
+ return (`a boolean precondition cannot be checked exactly on a database — the ` +
139
+ `record store compares JSON booleans as 1/0, so \`${value}\` would also ` +
140
+ `match a record holding the number ${value ? 1 : 0}`);
141
+ }
142
+ if (value === 0 || value === 1) {
143
+ return (`the number ${value} cannot be checked exactly on a database — the ` +
144
+ `record store compares JSON booleans as 1/0, so ${value} would also ` +
145
+ `match a record holding \`${value === 1}\``);
146
+ }
147
+ return null;
148
+ }
149
+ /**
150
+ * Translate a `databases records bulk` op's documents-surface `precondition`
151
+ * into the DatabaseDO's per-op `condition` (issue #2437). See
152
+ * {@link untranslatablePreconditionValue} for the audit of the whole value
153
+ * space; this function handles the one value that is REWRITTEN rather than
154
+ * passed through or rejected.
155
+ *
156
+ * The two are NOT the same grammar, and a straight hand-off silently weakens
157
+ * the guard for one value: `null`. A `condition` is a query filter, so
158
+ * `{ approvedAt: null }` translates to
159
+ * `json_extract(_data, '$.approvedAt') IS NULL` — which is also true when the
160
+ * key is ABSENT. A guarded patch/delete would then execute against a record
161
+ * that never had the field. The documents surface evaluates the same
162
+ * precondition in JS (`evaluatePrecondition` in `src/document-models.ts`): an
163
+ * absent field reads `undefined`, and `undefined !== null`, so the guard fails
164
+ * closed there.
165
+ *
166
+ * Pin the database side to the documents meaning — "the key is present AND
167
+ * holds null" — by pairing each null check with an existence constraint.
168
+ * `$exists: true` translates to `json_type(_data, '$.<field>') IS NOT NULL`,
169
+ * true for a key holding JSON null and false for an absent key, so the pair
170
+ * emits `json_extract(...) IS NULL AND json_type(...) IS NOT NULL`.
171
+ *
172
+ * Non-null scalars need no rewrite: `= ?` against `json_extract` already fails
173
+ * for an absent field, because NULL never equals a bound value.
174
+ *
175
+ * Assumes the caller has already rejected `$`-prefixed keys, non-scalar values,
176
+ * and the values {@link untranslatablePreconditionValue} names — which is what
177
+ * leaves `$and` free to use here and what makes a pass-through exact.
178
+ */
179
+ export function preconditionToCondition(precondition) {
180
+ const nullFields = Object.keys(precondition).filter((f) => precondition[f] === null);
181
+ if (nullFields.length === 0)
182
+ return { ...precondition };
183
+ const condition = {};
184
+ for (const [field, expected] of Object.entries(precondition)) {
185
+ if (expected !== null)
186
+ condition[field] = expected;
187
+ }
188
+ condition.$and = nullFields.flatMap((field) => [
189
+ { [field]: null },
190
+ { [field]: { $exists: true } },
191
+ ]);
192
+ return condition;
193
+ }
64
194
  export function registerDatabasesCommands(program) {
65
195
  const databases = program
66
196
  .command("databases")
@@ -68,11 +198,17 @@ export function registerDatabasesCommands(program) {
68
198
  .addHelpText("after", `
69
199
  Examples:
70
200
  $ primitive databases list
201
+ $ primitive databases list --owner <user-id>
71
202
  $ primitive databases create "My Database"
72
203
  $ primitive databases get <database-id>
73
204
  $ primitive databases records models <database-id>
74
205
  $ primitive databases records describe <database-id> <model-name>
75
206
  $ primitive databases records query <database-id> <model-name> --filter '{"name":"Alice"}'
207
+ $ primitive databases records get <database-id> <model-name> <record-id>
208
+ $ primitive databases records count <database-id> <model-name> --filter '{"status":"open"}'
209
+ $ primitive databases records aggregate <database-id> <model-name> --op avg --field price
210
+ $ primitive databases records save <database-id> <model-name> [record-id] --data '{"name":"Alice"}'
211
+ $ primitive databases records patch <database-id> <model-name> <record-id> --data '{"name":"Alice"}'
76
212
  $ primitive databases operations list <database-id>
77
213
  $ primitive databases operations execute <database-id> <operation-name> --params '{"key":"value"}'
78
214
  $ primitive databases indexes list <database-id>
@@ -80,13 +216,14 @@ Examples:
80
216
  $ primitive databases records delete <database-id> <model-name> <record-id> --yes
81
217
  $ primitive databases records delete-all <database-id> <model-name> --yes
82
218
  $ primitive databases records delete-all <database-id> <model-name> --filter '{"status":"draft"}' --yes
219
+ $ primitive databases records bulk <database-id> --data-file ops.json --yes
83
220
  $ primitive databases permissions list <database-id>
84
221
 
85
- Note: this group manages database *instances*. To manage database *type*
86
- configurations (schemas, rules, operations) including deleting a type use
87
- the 'database-types' group:
88
- $ primitive database-types list
89
- $ primitive database-types delete <database-type> # refuses if instances exist; pass --force to override
222
+ Note: this group manages database *instances*. Database *type* configurations
223
+ (schemas, rules, operations) are authored in TOMLsee 'primitive config fields
224
+ database-type-config' and the 'database-type-configs' group for inspection:
225
+ $ primitive database-type-configs list
226
+ $ rm database-type-configs/<database-type>.toml && primitive config push --prune
90
227
  `);
91
228
  // List databases
92
229
  databases
@@ -94,12 +231,15 @@ the 'database-types' group:
94
231
  .description("List databases in an app")
95
232
  .argument("[app-id]", "App ID (uses current app if not specified)")
96
233
  .option("--app <app-id>", "App ID")
234
+ .option("--owner <user-id>", "Only list databases created by this user")
97
235
  .option("--json", "Output as JSON")
98
236
  .action(async (appId, options) => {
99
237
  const resolvedAppId = resolveAppId(appId, options);
100
238
  const client = new ApiClient();
101
239
  try {
102
- const result = await client.listDatabases(resolvedAppId);
240
+ const result = await client.listDatabases(resolvedAppId, {
241
+ owner: options.owner,
242
+ });
103
243
  const list = Array.isArray(result) ? result : result?.databases ?? [];
104
244
  if (options.json) {
105
245
  json(list);
@@ -114,6 +254,18 @@ the 'database-types' group:
114
254
  { header: "TITLE", key: "title" },
115
255
  { header: "TYPE", key: "databaseType", format: (v) => v || "—" },
116
256
  { header: "PERMISSION", key: "permission" },
257
+ // `permission` reads "owner" on every row for an app admin, so it
258
+ // cannot confirm --owner narrowed anything. Show who created each
259
+ // database when the filter is in play.
260
+ ...(options.owner
261
+ ? [
262
+ {
263
+ header: "CREATED BY",
264
+ key: "createdBy",
265
+ format: (v) => (v ? formatId(v) : "—"),
266
+ },
267
+ ]
268
+ : []),
117
269
  { header: "CREATED", key: "createdAt", format: formatDate },
118
270
  ]));
119
271
  }
@@ -465,7 +617,7 @@ the 'database-types' group:
465
617
  process.exit(1);
466
618
  }
467
619
  });
468
- // Query records (direct introspection via admin-data route)
620
+ // Query records (direct introspection via the records route)
469
621
  records
470
622
  .command("query")
471
623
  .description("Query records in a database model (admin only)")
@@ -480,43 +632,14 @@ the 'database-types' group:
480
632
  .action(async (databaseId, modelName, options) => {
481
633
  const resolvedAppId = resolveAppId(undefined, options);
482
634
  const client = new ApiClient();
483
- if (options.filter && options.filterFile) {
484
- error("Cannot use both --filter and --filter-file.");
485
- process.exit(1);
486
- }
487
- let filter;
488
- if (options.filterFile) {
489
- try {
490
- const content = readFileSync(options.filterFile, "utf-8");
491
- if (options.filterFile.endsWith(".toml")) {
492
- const parsed = parseConfigToml(content);
493
- filter = parsed.filter || parsed;
494
- }
495
- else {
496
- filter = JSON.parse(content);
497
- }
498
- }
499
- catch (err) {
500
- error(`Failed to read filter file: ${err.message}`);
501
- process.exit(1);
502
- }
503
- }
504
- else if (options.filter) {
505
- try {
506
- filter = JSON.parse(options.filter);
507
- }
508
- catch {
509
- error("Invalid --filter JSON.");
510
- process.exit(1);
511
- }
512
- }
635
+ const filter = parseFilterOptions(options);
513
636
  try {
514
637
  const result = await client.queryDatabaseRecords(resolvedAppId, databaseId, modelName, { filter, limit: options.limit, cursor: options.cursor });
515
638
  if (options.json) {
516
639
  json(result);
517
640
  return;
518
641
  }
519
- const records = result.data || [];
642
+ const records = result.items;
520
643
  if (records.length === 0) {
521
644
  info("No records found.");
522
645
  return;
@@ -560,6 +683,228 @@ the 'database-types' group:
560
683
  process.exit(1);
561
684
  }
562
685
  });
686
+ // Get a single record by id
687
+ records
688
+ .command("get")
689
+ .description("Get a single record by id (admin only)")
690
+ .argument("<database-id>", "Database ID")
691
+ .argument("<model-name>", "Model name")
692
+ .argument("<record-id>", "Record ID")
693
+ .option("--app <app-id>", "App ID")
694
+ .option("--json", "Output as JSON")
695
+ .action(async (databaseId, modelName, recordId, options) => {
696
+ const resolvedAppId = resolveAppId(undefined, options);
697
+ const client = new ApiClient();
698
+ try {
699
+ // A single-record fetch is a query filtered by primary-key id (there is
700
+ // no dedicated get endpoint — reuse queryDatabaseRecords).
701
+ const result = await client.queryDatabaseRecords(resolvedAppId, databaseId, modelName, { filter: { id: recordId }, limit: 1 });
702
+ const record = result.items[0];
703
+ if (options.json) {
704
+ json(record ?? null);
705
+ return;
706
+ }
707
+ if (!record) {
708
+ info(`No record found with id ${recordId} in ${modelName}.`);
709
+ return;
710
+ }
711
+ // `get` is a data command, so its fields go to stdout via `result()`
712
+ // — the #711 split (`result()` = the data the caller asked for,
713
+ // `keyValue()` = a post-action diagnostic summary). Issue #2437; the
714
+ // documents twin renders identically.
715
+ for (const [key, value] of Object.entries(record)) {
716
+ const rendered = value === null || value === undefined
717
+ ? "—"
718
+ : typeof value === "object"
719
+ ? JSON.stringify(value)
720
+ : String(value);
721
+ printResult(key, rendered);
722
+ }
723
+ }
724
+ catch (err) {
725
+ error(err.message);
726
+ process.exit(1);
727
+ }
728
+ });
729
+ // Count records
730
+ records
731
+ .command("count")
732
+ .description("Count records in a database model (admin only)")
733
+ .argument("<database-id>", "Database ID")
734
+ .argument("<model-name>", "Model name")
735
+ .option("--app <app-id>", "App ID")
736
+ .option("--filter <json>", "Filter as JSON (e.g. '{\"name\":\"Alice\"}')")
737
+ .option("--filter-file <path>", "Read filter from a JSON or TOML file")
738
+ .option("--json", "Output as JSON")
739
+ .action(async (databaseId, modelName, options) => {
740
+ const resolvedAppId = resolveAppId(undefined, options);
741
+ const filter = parseFilterOptions(options);
742
+ const client = new ApiClient();
743
+ try {
744
+ const result = await client.countDatabaseRecords(resolvedAppId, databaseId, modelName, filter);
745
+ if (options.json) {
746
+ json(result);
747
+ return;
748
+ }
749
+ keyValue("count", String(result?.count ?? 0));
750
+ }
751
+ catch (err) {
752
+ error(err.message);
753
+ process.exit(1);
754
+ }
755
+ });
756
+ // Aggregate records
757
+ const AGGREGATE_OPS = ["count", "sum", "avg", "min", "max"];
758
+ records
759
+ .command("aggregate")
760
+ .description("Aggregate records in a database model (count/sum/avg/min/max, optional group-by; admin only)")
761
+ .argument("<database-id>", "Database ID")
762
+ .argument("<model-name>", "Model name")
763
+ .option("--app <app-id>", "App ID")
764
+ .requiredOption("--op <operation>", `Aggregate operation: ${AGGREGATE_OPS.join("|")}`)
765
+ .option("--field <field>", "Field to aggregate (required for sum/avg/min/max)")
766
+ .option("--group-by <field>", "Group results by this field (repeatable)", (value, previous = []) => previous.concat(value), [])
767
+ .option("--filter <json>", "Filter as JSON")
768
+ .option("--filter-file <path>", "Read filter from a JSON or TOML file")
769
+ .option("--json", "Output as JSON")
770
+ .action(async (databaseId, modelName, options) => {
771
+ const resolvedAppId = resolveAppId(undefined, options);
772
+ if (!AGGREGATE_OPS.includes(options.op)) {
773
+ error(`Invalid --op "${options.op}". Expected one of: ${AGGREGATE_OPS.join(", ")}.`);
774
+ process.exit(1);
775
+ }
776
+ const operation = { type: options.op };
777
+ if (options.op === "count") {
778
+ if (options.field) {
779
+ error("--field is not valid for --op count.");
780
+ process.exit(1);
781
+ }
782
+ }
783
+ else {
784
+ if (!options.field) {
785
+ error(`--op ${options.op} requires --field.`);
786
+ process.exit(1);
787
+ }
788
+ operation.field = options.field;
789
+ }
790
+ const filter = parseFilterOptions(options);
791
+ const groupBy = options.groupBy || [];
792
+ const client = new ApiClient();
793
+ try {
794
+ const result = await client.aggregateDatabaseRecords(resolvedAppId, databaseId, modelName, { operations: [operation], groupBy, filter });
795
+ if (options.json) {
796
+ json(result);
797
+ return;
798
+ }
799
+ const aggregated = result?.result;
800
+ // Label the value column after the operation, including the field it
801
+ // ran over: `SUM` alone does not say what was summed. The field name is
802
+ // reproduced verbatim in both labels — it is a user-supplied
803
+ // identifier, and case-folding it produced `avg(lastvalue)` for
804
+ // `--field lastValue` (issue #2357).
805
+ const valueLabel = options.op === "count"
806
+ ? "COUNT"
807
+ : `${options.op.toUpperCase()}(${options.field})`;
808
+ const scalarLabel = options.op === "count"
809
+ ? "count"
810
+ : `${options.op.toLowerCase()}(${options.field})`;
811
+ if (groupBy.length === 0) {
812
+ // Ungrouped: one scalar per operation, keyed by the endpoint's
813
+ // operation name (`count`, `sum_<field>`, …). Render it as a
814
+ // key/value line — there is no group column to table.
815
+ const value = (aggregated ?? {})[options.op === "count" ? "count" : `${options.op}_${options.field}`];
816
+ keyValue(scalarLabel, value === null || value === undefined ? "—" : String(value));
817
+ return;
818
+ }
819
+ if (aggregated &&
820
+ typeof aggregated === "object" &&
821
+ !Array.isArray(aggregated) &&
822
+ Object.values(aggregated).every((v) => typeof v !== "object" || v === null)) {
823
+ // Flat { group -> value } shape (single operation, single group-by): render a table.
824
+ const rows = Object.entries(aggregated).map(([group, value]) => ({
825
+ group,
826
+ value: value === null || value === undefined ? "—" : String(value),
827
+ }));
828
+ console.log(formatTable(rows, [
829
+ { header: "GROUP", key: "group" },
830
+ { header: valueLabel, key: "value" },
831
+ ]));
832
+ }
833
+ else {
834
+ // Grouped/nested or multi-operation shape: print the structured result.
835
+ console.log(JSON.stringify(aggregated ?? {}, null, 2));
836
+ }
837
+ }
838
+ catch (err) {
839
+ error(err.message);
840
+ process.exit(1);
841
+ }
842
+ });
843
+ // Upsert a record (create, or merge into an existing one)
844
+ records
845
+ .command("save")
846
+ .description("Create a record, or merge fields into an existing one (admin only)")
847
+ .argument("<database-id>", "Database ID")
848
+ .argument("<model-name>", "Model name")
849
+ .argument("[record-id]", "Record ID (generated when omitted)")
850
+ .option("--app <app-id>", "App ID")
851
+ .option("--data <json>", "Record fields as inline JSON")
852
+ .option("--data-file <path>", "Read the record fields from a JSON file")
853
+ .option("--json", "Output as JSON")
854
+ .action(async (databaseId, modelName, recordId, options) => {
855
+ const resolvedAppId = resolveAppId(undefined, options);
856
+ const data = parseDataOption(options, "record");
857
+ const client = new ApiClient();
858
+ // The DatabaseDO requires an id on the plain save path (it only generates
859
+ // one for `upsertOn` writes). Mint the id client-side for a create so
860
+ // `save` works without an id, the same way CSV import does
861
+ // (`lib/csv.ts`).
862
+ const id = recordId || ulid();
863
+ assertDataIdMatches(data, recordId, id, "primitive databases records save <database-id> <model-name>");
864
+ try {
865
+ const result = await client.saveDatabaseRecord(resolvedAppId, databaseId, modelName, id, data);
866
+ if (options.json) {
867
+ json(result);
868
+ return;
869
+ }
870
+ renderRecordWriteResult(result, id, `Record saved to ${modelName}.`);
871
+ }
872
+ catch (err) {
873
+ error(err.message);
874
+ process.exit(1);
875
+ }
876
+ });
877
+ // Patch a record (partial merge)
878
+ records
879
+ .command("patch")
880
+ .description("Merge fields into an existing record (admin only)")
881
+ .argument("<database-id>", "Database ID")
882
+ .argument("<model-name>", "Model name")
883
+ .argument("<record-id>", "Record ID")
884
+ .option("--app <app-id>", "App ID")
885
+ .option("--data <json>", "Fields to merge as inline JSON")
886
+ .option("--data-file <path>", "Read the fields to merge from a JSON file")
887
+ .option("--json", "Output as JSON")
888
+ .action(async (databaseId, modelName, recordId, options) => {
889
+ const resolvedAppId = resolveAppId(undefined, options);
890
+ const data = parseDataOption(options, "fields to merge");
891
+ const client = new ApiClient();
892
+ // Same guard as `save` — on this path `recordId` is required, so the
893
+ // "pass it as the argument" branch is unreachable here.
894
+ assertDataIdMatches(data, recordId, recordId, "primitive databases records patch <database-id> <model-name>");
895
+ try {
896
+ const result = await client.patchDatabaseRecord(resolvedAppId, databaseId, modelName, recordId, data);
897
+ if (options.json) {
898
+ json(result);
899
+ return;
900
+ }
901
+ renderRecordWriteResult(result, recordId, `Record ${recordId} patched in ${modelName}.`);
902
+ }
903
+ catch (err) {
904
+ error(err.message);
905
+ process.exit(1);
906
+ }
907
+ });
563
908
  // Delete a single record
564
909
  records
565
910
  .command("delete")
@@ -608,21 +953,13 @@ the 'database-types' group:
608
953
  .argument("<model-name>", "Model name")
609
954
  .option("--app <app-id>", "App ID")
610
955
  .option("--filter <json>", "Filter as JSON to select which records to delete")
956
+ .option("--filter-file <path>", "Read filter from a JSON or TOML file")
611
957
  .option("-y, --yes", "Skip confirmation prompt")
612
958
  .option("--json", "Output as JSON")
613
959
  .action(async (databaseId, modelName, options) => {
614
960
  const resolvedAppId = resolveAppId(undefined, options);
615
961
  const client = new ApiClient();
616
- let filter;
617
- if (options.filter) {
618
- try {
619
- filter = JSON.parse(options.filter);
620
- }
621
- catch {
622
- error("Invalid --filter JSON.");
623
- process.exit(1);
624
- }
625
- }
962
+ const filter = parseFilterOptions(options);
626
963
  try {
627
964
  // First, query to find all matching records
628
965
  let allRecords = [];
@@ -630,9 +967,9 @@ the 'database-types' group:
630
967
  let hasMore = true;
631
968
  while (hasMore) {
632
969
  const result = await client.queryDatabaseRecords(resolvedAppId, databaseId, modelName, { filter, limit: 100, cursor });
633
- const records = result?.data || [];
970
+ const records = result.items;
634
971
  allRecords = allRecords.concat(records);
635
- cursor = result?.nextCursor;
972
+ cursor = result.nextCursor;
636
973
  hasMore = !!cursor && records.length > 0;
637
974
  }
638
975
  if (allRecords.length === 0) {
@@ -681,6 +1018,206 @@ the 'database-types' group:
681
1018
  process.exit(1);
682
1019
  }
683
1020
  });
1021
+ // Apply an atomic multi-model operations blob (issue #2437).
1022
+ //
1023
+ // Takes the SAME `--data-file` blob as `documents records bulk` — one file
1024
+ // works against either surface — and translates it to the DatabaseDO's
1025
+ // `records/batch` wire shape (`{ op, modelName, id, data }`) with
1026
+ // `atomic: true`, so the whole blob is all-or-nothing like the documents
1027
+ // twin. Both guards the documents blob can carry survive the translation:
1028
+ // `action: create` maps to `save` with `ifNotExists: true` (the DatabaseDO's
1029
+ // strict-create flag, a 409 `ALREADY_EXISTS` inside the transaction), and an
1030
+ // op's `precondition` maps to the DO's per-op `condition` (a 409
1031
+ // `CONDITION_NOT_MET`). Both abort the whole batch, matching the documents
1032
+ // twin.
1033
+ const BULK_ACTIONS = ["create", "patch", "delete"];
1034
+ const BULK_ACTION_TO_OP = {
1035
+ create: "save",
1036
+ patch: "patch",
1037
+ delete: "delete",
1038
+ };
1039
+ // Mirrors the documents surface's `BULK_WRITE_OP_KEYS` / `BULK_DELETE_OP_KEYS`
1040
+ // (`src/document-models.ts`). Unknown keys are an error rather than ignored:
1041
+ // a guard the author asked for must never be dropped silently.
1042
+ const BULK_OP_KEYS = ["model", "action", "id", "data", "precondition"];
1043
+ const BULK_DELETE_OP_KEYS = ["model", "action", "id", "precondition"];
1044
+ records
1045
+ .command("bulk")
1046
+ .description("Apply an atomic create/patch/delete operations blob to a database (admin only)")
1047
+ .argument("<database-id>", "Database ID")
1048
+ .requiredOption("--data-file <file>", "JSON file with { operations: [...] } or a bare operations array. " +
1049
+ "Each op is { model, action: create|patch|delete, id, data, precondition? } " +
1050
+ "— the same shape `documents records bulk` takes. `data` holds the " +
1051
+ "record fields and is required on create/patch, not allowed on delete. " +
1052
+ "A `create` against an id that already exists fails the whole batch, " +
1053
+ "and a `precondition` that does not hold rolls the whole batch back")
1054
+ .option("--app <app-id>", "App ID")
1055
+ .option("-y, --yes", "Skip confirmation prompt")
1056
+ .option("--json", "Output as JSON")
1057
+ .action(async (databaseId, options) => {
1058
+ const parsed = parseDataOption(options, "operations blob", {
1059
+ allowArray: true,
1060
+ });
1061
+ const rawOperations = Array.isArray(parsed) ? parsed : parsed?.operations;
1062
+ if (!Array.isArray(rawOperations) || rawOperations.length === 0) {
1063
+ error("--data-file must contain { operations: [...] } or a non-empty operations array.");
1064
+ process.exit(1);
1065
+ }
1066
+ // Translate to the batch wire shape, rejecting a malformed op before any
1067
+ // request goes out so the caller sees which entry is wrong.
1068
+ const operations = [];
1069
+ for (let i = 0; i < rawOperations.length; i++) {
1070
+ const op = rawOperations[i];
1071
+ if (!op || typeof op !== "object" || Array.isArray(op)) {
1072
+ error(`Operation ${i}: each operation must be a JSON object.`);
1073
+ process.exit(1);
1074
+ }
1075
+ const action = op.action;
1076
+ if (!BULK_ACTIONS.includes(action)) {
1077
+ error(`Operation ${i}: invalid action "${action}". Expected one of: ${BULK_ACTIONS.join(", ")}.`);
1078
+ process.exit(1);
1079
+ }
1080
+ if (!op.model || typeof op.model !== "string") {
1081
+ error(`Operation ${i}: "model" is required and must be a model name.`);
1082
+ process.exit(1);
1083
+ }
1084
+ if (!op.id || typeof op.id !== "string") {
1085
+ error(`Operation ${i}: "id" is required.`);
1086
+ process.exit(1);
1087
+ }
1088
+ // `data` on a delete gets its own message before the allowlist runs, so
1089
+ // the caller is not told a first-class key is "unknown".
1090
+ if (action === "delete" && op.data !== undefined) {
1091
+ error(`Operation ${i}: "data" is not allowed on a delete.`);
1092
+ process.exit(1);
1093
+ }
1094
+ const allowedKeys = action === "delete" ? BULK_DELETE_OP_KEYS : BULK_OP_KEYS;
1095
+ const unknownKey = Object.keys(op).find((k) => !allowedKeys.includes(k));
1096
+ if (unknownKey) {
1097
+ error(`Operation ${i}: unknown key "${unknownKey}". Expected one of: ${allowedKeys.join(", ")}.`);
1098
+ process.exit(1);
1099
+ }
1100
+ // A `precondition` is field-equality only, the same grammar the
1101
+ // documents surface enforces (`validatePrecondition` in
1102
+ // `src/document-models.ts`), so one blob means the same thing on both.
1103
+ // `preconditionToCondition` translates it to the DO's per-op
1104
+ // `condition`, checked inside the transaction: a mismatch is a 409 that
1105
+ // rolls the whole batch back.
1106
+ let condition;
1107
+ if (op.precondition !== undefined && op.precondition !== null) {
1108
+ if (typeof op.precondition !== "object" || Array.isArray(op.precondition)) {
1109
+ error(`Operation ${i}: "precondition" must be a field-equality object ({ field: scalar, … }).`);
1110
+ process.exit(1);
1111
+ }
1112
+ for (const [field, expected] of Object.entries(op.precondition)) {
1113
+ if (field.startsWith("$")) {
1114
+ error(`Operation ${i}: precondition operator "${field}" is not supported — preconditions are field-equality only ({ field: scalar, … }).`);
1115
+ process.exit(1);
1116
+ }
1117
+ if (expected !== null && typeof expected === "object") {
1118
+ error(`Operation ${i}: precondition on field "${field}" must be a scalar equality value — operators and nested objects/arrays are not supported.`);
1119
+ process.exit(1);
1120
+ }
1121
+ // Fail loudly on the values the `condition` grammar cannot check
1122
+ // exactly, rather than running with a weaker guard than the caller
1123
+ // wrote (issue #2437; the grammar fix is tracked in #2507).
1124
+ const untranslatable = untranslatablePreconditionValue(expected);
1125
+ if (untranslatable) {
1126
+ error(`Operation ${i}: precondition on field "${field}" is not supported — ${untranslatable}. ` +
1127
+ `Guard on a field whose value is a string or a number other than 0/1, or read the record ` +
1128
+ `first and gate the write on that.`);
1129
+ process.exit(1);
1130
+ }
1131
+ }
1132
+ condition = preconditionToCondition(op.precondition);
1133
+ }
1134
+ if (action === "delete") {
1135
+ if (op.data !== undefined) {
1136
+ error(`Operation ${i}: "data" is not allowed on a delete.`);
1137
+ process.exit(1);
1138
+ }
1139
+ operations.push({
1140
+ op: "delete",
1141
+ modelName: op.model,
1142
+ id: op.id,
1143
+ ...(condition ? { condition } : {}),
1144
+ });
1145
+ continue;
1146
+ }
1147
+ if (!op.data ||
1148
+ typeof op.data !== "object" ||
1149
+ Array.isArray(op.data) ||
1150
+ Object.keys(op.data).length === 0) {
1151
+ error(`Operation ${i}: "data" is required and must be a non-empty object on a ${action}.`);
1152
+ process.exit(1);
1153
+ }
1154
+ operations.push({
1155
+ op: BULK_ACTION_TO_OP[action],
1156
+ modelName: op.model,
1157
+ id: op.id,
1158
+ data: op.data,
1159
+ // `create` is a STRICT create on both twins: `ifNotExists` is the
1160
+ // DatabaseDO batch's strict-create flag (409 `ALREADY_EXISTS`), so a
1161
+ // create against a live id fails the batch instead of merging.
1162
+ ...(action === "create" ? { ifNotExists: true } : {}),
1163
+ ...(condition ? { condition } : {}),
1164
+ });
1165
+ }
1166
+ const resolvedAppId = resolveAppId(undefined, options);
1167
+ if (!options.yes) {
1168
+ let confirm;
1169
+ try {
1170
+ confirm = await confirmPrompt(`Apply ${operations.length} operation(s) atomically to database ${databaseId}?`);
1171
+ }
1172
+ catch (err) {
1173
+ error(err.message);
1174
+ process.exit(1);
1175
+ }
1176
+ if (!confirm) {
1177
+ info("Cancelled.");
1178
+ return;
1179
+ }
1180
+ }
1181
+ const client = new ApiClient();
1182
+ try {
1183
+ await client.batchDatabaseRecords(resolvedAppId, databaseId, operations, {
1184
+ atomic: true,
1185
+ });
1186
+ // The batch endpoint answers with a per-operation `results` array; the
1187
+ // documents twin answers with a rolled-up summary. Report the twin's
1188
+ // summary so one blob produces one contract on both surfaces — the
1189
+ // batch is atomic, so a 2xx means every op in the input applied.
1190
+ //
1191
+ // One reporting difference, documented alongside the verb: the
1192
+ // documents twin's `deleted` counts records actually removed, because
1193
+ // its DO reports that. The database batch issues a plain DELETE and
1194
+ // reports no rows-affected, so `deleted` here counts delete operations
1195
+ // applied — deleting an id that was not there still counts 1.
1196
+ const added = rawOperations
1197
+ .filter((op) => op.action === "create")
1198
+ .map((op) => ({ model: op.model, id: op.id }));
1199
+ const updated = rawOperations
1200
+ .filter((op) => op.action === "patch")
1201
+ .map((op) => ({ model: op.model, id: op.id }));
1202
+ const deleted = rawOperations.filter((op) => op.action === "delete").length;
1203
+ const summary = {
1204
+ applied: added.length + updated.length + deleted,
1205
+ added,
1206
+ updated,
1207
+ deleted,
1208
+ };
1209
+ if (options.json) {
1210
+ json(summary);
1211
+ return;
1212
+ }
1213
+ success(`Applied ${summary.applied} operation(s): ` +
1214
+ `${added.length} added, ${updated.length} updated, ${deleted} deleted.`);
1215
+ }
1216
+ catch (err) {
1217
+ error(err.message);
1218
+ process.exit(1);
1219
+ }
1220
+ });
684
1221
  // ---- CEL context subcommand group ----
685
1222
  // `cel-context` is the user-facing name for the dict that feeds CEL
686
1223
  // access rules and filter placeholders. `metadata` is kept as a hidden
@@ -837,14 +1374,17 @@ the 'database-types' group:
837
1374
  json(result);
838
1375
  return;
839
1376
  }
840
- // Smart display based on result shape
841
- if (result.data && Array.isArray(result.data)) {
842
- if (result.data.length === 0) {
1377
+ // Smart display based on result shape. `executeDatabaseOperation()`
1378
+ // has already normalized a query page to `{ items, hasMore,
1379
+ // nextCursor? }` (#2440), so `items` is exactly "this result was a
1380
+ // list" — the two branches cannot disagree about it.
1381
+ if (Array.isArray(result.items)) {
1382
+ if (result.items.length === 0) {
843
1383
  info("No records found.");
844
1384
  }
845
1385
  else {
846
- const cols = Object.keys(result.data[0]).filter((k) => k !== "type");
847
- console.log(formatTable(result.data, cols.map((c) => ({
1386
+ const cols = Object.keys(result.items[0]).filter((k) => k !== "type");
1387
+ console.log(formatTable(result.items, cols.map((c) => ({
848
1388
  header: c.toUpperCase(),
849
1389
  key: c,
850
1390
  format: (v) => {
@@ -859,8 +1399,10 @@ the 'database-types' group:
859
1399
  },
860
1400
  }))));
861
1401
  }
862
- if (result.hasMore) {
863
- info(`More records available. Use --cursor ${result.cursor} to continue.`);
1402
+ // Gate on the cursor itself, the way `records query` does: `hasMore`
1403
+ // without a cursor used to print the literal `undefined` (#2440).
1404
+ if (result.nextCursor) {
1405
+ info(`More records available. Use --cursor ${result.nextCursor} to continue.`);
864
1406
  }
865
1407
  }
866
1408
  else if (result.count !== undefined) {
@@ -1094,7 +1636,7 @@ the 'database-types' group:
1094
1636
  }
1095
1637
  // Validate --batch-size up front so a bad value fails before any write.
1096
1638
  // Unlike `import-csv` (registered-batch path, hard 100k server cap),
1097
- // `admin-data/batch` has no fixed server item cap — bound it with a
1639
+ // `records/batch` has no fixed server item cap — bound it with a
1098
1640
  // conservative client-side ceiling to keep each request within
1099
1641
  // Cloudflare body-size and Durable Object memory limits.
1100
1642
  const parsedBatchSize = parseBatchSize(options.batchSize, {
@@ -1368,13 +1910,17 @@ the 'database-types' group:
1368
1910
  .description("Manage the TOML schema attached to a database type");
1369
1911
  schema
1370
1912
  .command("generate")
1371
- .description("Scaffold a starting TOML schema for <database-type> from existing operations + DO introspection, and insert it into the local database-types/<type>.toml file.")
1913
+ .description("Scaffold a starting TOML schema for <database-type> from existing operations + DO introspection, and insert it into the local database-type-configs/<type>.toml file.")
1372
1914
  .argument("<database-type>", "Database type name")
1373
1915
  .option("--app <app-id>", "App ID")
1374
- .option("--sync-dir <path>", "Override path to the sync directory (defaults to ./.primitive/sync/<env>/<appId>/)")
1916
+ .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
1917
+ .addOption(new Option("--sync-dir <path>", "Deprecated alias of --dir").hideHelp())
1375
1918
  .option("-f, --force", "Overwrite an existing [models.*] block in the local file")
1376
1919
  .option("--json", "Output the generated TOML as JSON")
1377
1920
  .action(async (databaseType, options) => {
1921
+ // Resolve the sync-dir override before the API call, so a caller who
1922
+ // passed both spellings hears about it without a network round trip.
1923
+ const configDir = resolveSyncDirOverride(options, "databases schema generate");
1378
1924
  const resolvedAppId = resolveAppId(undefined, options);
1379
1925
  const client = new ApiClient();
1380
1926
  try {
@@ -1385,14 +1931,14 @@ the 'database-types' group:
1385
1931
  json({ schema: scaffoldedToml });
1386
1932
  return;
1387
1933
  }
1388
- // 2. Locate the local `database-types/<type>.toml` file. Per the
1934
+ // 2. Locate the local `database-type-configs/<type>.toml` file. Per the
1389
1935
  // plan-of-record CLI sync layout, the file lives at
1390
- // `<sync-dir>/database-types/<type>.toml`. We don't need to know the
1391
- // exact sync dir for the happy path — just look for an existing
1936
+ // `<config-dir>/database-type-configs/<type>.toml`. We don't need to know
1937
+ // the exact config dir for the happy path — just look for an existing
1392
1938
  // file relative to the CWD's `.primitive/sync/` directory tree.
1393
1939
  const candidateFiles = [];
1394
- if (options.syncDir) {
1395
- candidateFiles.push(path.join(options.syncDir, "database-types", `${databaseType}.toml`));
1940
+ if (configDir) {
1941
+ candidateFiles.push(path.join(configDir, "database-type-configs", `${databaseType}.toml`));
1396
1942
  }
1397
1943
  else {
1398
1944
  // Search any env/appId subtree under .primitive/sync that matches.
@@ -1402,7 +1948,7 @@ the 'database-types' group:
1402
1948
  const envDir = path.join(root, env);
1403
1949
  try {
1404
1950
  for (const app of readdirSync(envDir)) {
1405
- const candidate = path.join(envDir, app, "database-types", `${databaseType}.toml`);
1951
+ const candidate = path.join(envDir, app, "database-type-configs", `${databaseType}.toml`);
1406
1952
  if (existsSync(candidate)) {
1407
1953
  candidateFiles.push(candidate);
1408
1954
  }
@@ -1417,7 +1963,7 @@ the 'database-types' group:
1417
1963
  if (candidateFiles.length === 0) {
1418
1964
  // No local file found — print the scaffold to stdout and tell
1419
1965
  // the user where to put it.
1420
- info(`No local database-types/${databaseType}.toml found under .primitive/sync/. Generated TOML:`);
1966
+ info(`No local database-type-configs/${databaseType}.toml found under .primitive/sync/. Generated TOML:`);
1421
1967
  console.log("");
1422
1968
  console.log(scaffoldedToml);
1423
1969
  return;
@@ -1457,7 +2003,7 @@ the 'database-types' group:
1457
2003
  writeFileSync(targetPath, merged);
1458
2004
  success(`Inserted schema into ${targetPath}`);
1459
2005
  }
1460
- info("Review the generated schema, fix types the generator guessed wrong, then run `primitive sync push` (or `primitive sync push --dry-run`).");
2006
+ info("Review the generated schema, fix types the generator guessed wrong, then run `primitive config push` (or `primitive config push --dry-run`).");
1461
2007
  }
1462
2008
  catch (err) {
1463
2009
  error(err.message);
@@ -1469,11 +2015,12 @@ the 'database-types' group:
1469
2015
  // ============================================
1470
2016
  databases
1471
2017
  .command("codegen")
1472
- .description("Generate record + operation types from the local database-types/*.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.")
1473
- .argument("[database-type]", "Generate for a single database type (defaults to every database-types/*.toml found)")
2018
+ .description("Generate record + operation types from the local database-type-configs/*.toml schema. TypeScript (default): a <type>.generated.ts per database type (record interfaces, per-op input-params interfaces, per-op result aliases, and a typed <type>Ops factory). Swift (--lang swift): a <type>.generated.swift per database type (record structs, per-op params structs, per-op result typealiases, and a typed <Type>Ops factory).\n\nFormatting: the emitted TypeScript is already formatted the way Prettier's DEFAULT config formats it, so a `codegen` -> `prettier --write` -> `codegen --check` pipeline stays green. If your project uses a NON-default Prettier config (a different printWidth, trailingComma, or quote style), Prettier will reformat the generated files and `--check` will then fail: add the generated directory to `.prettierignore` and commit the raw emission instead.")
2019
+ .argument("[database-type]", "Generate for a single database type (defaults to every database-type-configs/*.toml found)")
1474
2020
  .option("--app <app-id>", "App ID")
1475
- .option("--sync-dir <path>", "Override path to the sync directory (defaults to ./.primitive/sync/<env>/<appId>/)")
1476
- .option("-o, --output <dir>", "Output directory for the generated files (defaults to <sync-dir>/database-types/generated/)")
2021
+ .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
2022
+ .addOption(new Option("--sync-dir <path>", "Deprecated alias of --dir").hideHelp())
2023
+ .option("-o, --output <dir>", "Output directory for the generated files (defaults to <config-dir>/database-type-configs/generated/)")
1477
2024
  .option("--lang <lang>", "Target language for the generated types: 'ts' (default) or 'swift'.", "ts")
1478
2025
  .option("--check", "Exit non-zero if generated output is out of date (CI guard); does not write. Compares byte-for-byte, so the file on disk must match the emission exactly.")
1479
2026
  .option("--json", "Output the result summary as JSON")
@@ -1486,14 +2033,17 @@ the 'database-types' group:
1486
2033
  error(`Unknown --lang "${options.lang}". Use "ts" or "swift".`);
1487
2034
  process.exit(1);
1488
2035
  }
1489
- // 1. Resolve the single source database-types/ directory via the
2036
+ // 1. Resolve the single source database-type-configs/ directory via the
1490
2037
  // shared active-environment resolver (issue #1510). Honors
1491
- // --sync-dir / --app overrides and resolves the active env
2038
+ // --dir / --app overrides and resolves the active env
1492
2039
  // (--env → PRIMITIVE_ENV → defaultEnvironment → single-env) instead
1493
2040
  // of silently merging types across every synced env.
1494
2041
  const dbTypesSourceDir = resolveCodegenSourceDir({
1495
- subdir: "database-types",
1496
- options: { app: options.app, syncDir: options.syncDir },
2042
+ subdir: "database-type-configs",
2043
+ options: {
2044
+ app: options.app,
2045
+ syncDir: resolveSyncDirOverride(options, "databases codegen"),
2046
+ },
1497
2047
  });
1498
2048
  // 2. Collect the source .toml files (one per database type), filtered
1499
2049
  // to a single type when an argument is given.
@@ -1513,8 +2063,8 @@ the 'database-types' group:
1513
2063
  }
1514
2064
  if (inputs.length === 0) {
1515
2065
  error(databaseType
1516
- ? `No database-types/${databaseType}.toml found under .primitive/sync/.`
1517
- : "No database-types/*.toml files found to generate from.");
2066
+ ? `No database-type-configs/${databaseType}.toml found under .primitive/sync/.`
2067
+ : "No database-type-configs/*.toml files found to generate from.");
1518
2068
  process.exit(1);
1519
2069
  }
1520
2070
  // 3. Resolve the output directory. Default: a `generated/` subdir of
@@ -1552,9 +2102,21 @@ the 'database-types' group:
1552
2102
  for (const m of codegenResult.mismatches) {
1553
2103
  error(` ${m.reason}: ${path.relative(process.cwd(), m.filePath)}`);
1554
2104
  }
1555
- info(lang === "swift"
1556
- ? "Run `primitive databases codegen --lang swift` to regenerate."
1557
- : "Run `primitive databases codegen` to regenerate.");
2105
+ // Echo back the options that decide WHERE and WHAT was
2106
+ // checked, the way `workflows codegen` does (#2895). Suggesting
2107
+ // a bare `databases codegen` after a `--check -o <dir>` sent
2108
+ // people to regenerate into the default
2109
+ // <config-dir>/database-type-configs/generated/ instead of the
2110
+ // directory that is actually out of date, so the files stayed
2111
+ // stale and the next check failed the same way (#2911).
2112
+ const regenerate = ["primitive databases codegen"];
2113
+ if (databaseType)
2114
+ regenerate.push(databaseType);
2115
+ if (lang === "swift")
2116
+ regenerate.push("--lang swift");
2117
+ if (options.output)
2118
+ regenerate.push(`-o ${options.output}`);
2119
+ info(`Run \`${regenerate.join(" ")}\` to regenerate.`);
1558
2120
  }
1559
2121
  process.exit(1);
1560
2122
  }
@@ -1620,7 +2182,7 @@ async function exportSingleDatabase(client, appId, databaseId, outputDir, jsonOu
1620
2182
  const dbMeta = await client.getDatabase(appId, databaseId);
1621
2183
  // Warn about databaseType sync
1622
2184
  if (dbMeta.databaseType) {
1623
- warn(`This database uses type "${dbMeta.databaseType}". Make sure to \`primitive sync push\` the type config before importing into a new app.`);
2185
+ warn(`This database uses type "${dbMeta.databaseType}". Make sure to \`primitive config push\` the type config before importing into a new app.`);
1624
2186
  }
1625
2187
  // 2. Write metadata
1626
2188
  const metadata = {
@@ -1648,13 +2210,13 @@ async function exportSingleDatabase(client, appId, databaseId, outputDir, jsonOu
1648
2210
  limit: 100,
1649
2211
  cursor,
1650
2212
  });
1651
- const records = result?.data || [];
2213
+ const records = result.items;
1652
2214
  for (const record of records) {
1653
2215
  const line = JSON.stringify({ _type: modelName, _id: record.id, _data: record });
1654
2216
  appendFileSync(recordsFile, line + "\n");
1655
2217
  totalRecords++;
1656
2218
  }
1657
- cursor = result?.nextCursor;
2219
+ cursor = result.nextCursor;
1658
2220
  hasMore = !!cursor && records.length > 0;
1659
2221
  }
1660
2222
  }
@@ -1685,7 +2247,7 @@ async function exportSingleDatabase(client, appId, databaseId, outputDir, jsonOu
1685
2247
  }
1686
2248
  /**
1687
2249
  * Conservative client-side ceiling for `databases import --batch-size`.
1688
- * `admin-data/batch` has no fixed server-side item cap (unlike the
2250
+ * `records/batch` has no fixed server-side item cap (unlike the
1689
2251
  * registered-batch path used by `import-csv`), so this bounds each request to
1690
2252
  * keep it within Cloudflare body-size and Durable Object memory limits.
1691
2253
  */
@@ -1713,7 +2275,7 @@ async function importSingleDatabase(client, appId, dbDir, options) {
1713
2275
  }
1714
2276
  // Check if database type exists — warn if not
1715
2277
  if (metadata.databaseType) {
1716
- warn(`Database uses type "${metadata.databaseType}". Ensure the type config exists in the target app (use \`primitive sync push\`).`);
2278
+ warn(`Database uses type "${metadata.databaseType}". Ensure the type config exists in the target app (use \`primitive config push\`).`);
1717
2279
  }
1718
2280
  // Check if database exists — use the original ID for overwrite scenarios
1719
2281
  let targetDatabaseId = databaseId;