primitive-admin 1.1.0-alpha.4 → 1.1.0-alpha.41

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 (219) hide show
  1. package/README.md +325 -31
  2. package/assets/skill/skills/primitive-platform/SKILL.md +281 -0
  3. package/dist/bin/primitive.d.ts +2 -0
  4. package/dist/bin/primitive.js +268 -16
  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 +123 -18
  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 +464 -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 +55 -13
  14. package/dist/src/commands/apps.js.map +1 -1
  15. package/dist/src/commands/auth.d.ts +2 -0
  16. package/dist/src/commands/auth.js +177 -7
  17. package/dist/src/commands/auth.js.map +1 -1
  18. package/dist/src/commands/blob-buckets.d.ts +2 -0
  19. package/dist/src/commands/blob-buckets.js +422 -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 +34 -35
  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 +178 -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 +515 -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/cron-triggers.d.ts +2 -0
  34. package/dist/src/commands/cron-triggers.js +357 -0
  35. package/dist/src/commands/cron-triggers.js.map +1 -0
  36. package/dist/src/commands/database-types.d.ts +2 -0
  37. package/dist/src/commands/database-types.js +471 -0
  38. package/dist/src/commands/database-types.js.map +1 -0
  39. package/dist/src/commands/databases.d.ts +2 -0
  40. package/dist/src/commands/databases.js +1462 -83
  41. package/dist/src/commands/databases.js.map +1 -1
  42. package/dist/src/commands/documents.d.ts +2 -0
  43. package/dist/src/commands/documents.js +518 -11
  44. package/dist/src/commands/documents.js.map +1 -1
  45. package/dist/src/commands/email-templates.d.ts +2 -0
  46. package/dist/src/commands/email-templates.js +281 -0
  47. package/dist/src/commands/email-templates.js.map +1 -0
  48. package/dist/src/commands/env.d.ts +12 -0
  49. package/dist/src/commands/env.js +260 -0
  50. package/dist/src/commands/env.js.map +1 -0
  51. package/dist/src/commands/group-type-configs.d.ts +2 -0
  52. package/dist/src/commands/group-type-configs.js +181 -0
  53. package/dist/src/commands/group-type-configs.js.map +1 -0
  54. package/dist/src/commands/groups.d.ts +2 -0
  55. package/dist/src/commands/groups.js +48 -45
  56. package/dist/src/commands/groups.js.map +1 -1
  57. package/dist/src/commands/guides.d.ts +159 -0
  58. package/dist/src/commands/guides.js +449 -35
  59. package/dist/src/commands/guides.js.map +1 -1
  60. package/dist/src/commands/init.d.ts +17 -0
  61. package/dist/src/commands/init.js +811 -201
  62. package/dist/src/commands/init.js.map +1 -1
  63. package/dist/src/commands/integrations.d.ts +2 -0
  64. package/dist/src/commands/integrations.js +533 -46
  65. package/dist/src/commands/integrations.js.map +1 -1
  66. package/dist/src/commands/llm.d.ts +2 -0
  67. package/dist/src/commands/llm.js +4 -2
  68. package/dist/src/commands/llm.js.map +1 -1
  69. package/dist/src/commands/metadata.d.ts +2 -0
  70. package/dist/src/commands/metadata.js +194 -0
  71. package/dist/src/commands/metadata.js.map +1 -0
  72. package/dist/src/commands/prompts.d.ts +2 -0
  73. package/dist/src/commands/prompts.js +50 -52
  74. package/dist/src/commands/prompts.js.map +1 -1
  75. package/dist/src/commands/rule-sets.d.ts +3 -0
  76. package/dist/src/commands/rule-sets.js +388 -0
  77. package/dist/src/commands/rule-sets.js.map +1 -0
  78. package/dist/src/commands/scripts.d.ts +2 -0
  79. package/dist/src/commands/scripts.js +667 -0
  80. package/dist/src/commands/scripts.js.map +1 -0
  81. package/dist/src/commands/secrets.d.ts +2 -0
  82. package/dist/src/commands/secrets.js +108 -0
  83. package/dist/src/commands/secrets.js.map +1 -0
  84. package/dist/src/commands/skill.d.ts +2 -0
  85. package/dist/src/commands/skill.js +29 -0
  86. package/dist/src/commands/skill.js.map +1 -0
  87. package/dist/src/commands/sync.d.ts +175 -0
  88. package/dist/src/commands/sync.js +4412 -233
  89. package/dist/src/commands/sync.js.map +1 -1
  90. package/dist/src/commands/tokens.d.ts +2 -0
  91. package/dist/src/commands/tokens.js +122 -19
  92. package/dist/src/commands/tokens.js.map +1 -1
  93. package/dist/src/commands/users.d.ts +2 -0
  94. package/dist/src/commands/users.js +440 -22
  95. package/dist/src/commands/users.js.map +1 -1
  96. package/dist/src/commands/waitlist.d.ts +2 -0
  97. package/dist/src/commands/waitlist.js +10 -10
  98. package/dist/src/commands/waitlist.js.map +1 -1
  99. package/dist/src/commands/webhooks.d.ts +2 -0
  100. package/dist/src/commands/webhooks.js +391 -0
  101. package/dist/src/commands/webhooks.js.map +1 -0
  102. package/dist/src/commands/workflows.d.ts +49 -0
  103. package/dist/src/commands/workflows.js +1009 -123
  104. package/dist/src/commands/workflows.js.map +1 -1
  105. package/dist/src/lib/api-client.d.ts +1283 -0
  106. package/dist/src/lib/api-client.js +1129 -83
  107. package/dist/src/lib/api-client.js.map +1 -1
  108. package/dist/src/lib/auth-flow.d.ts +8 -0
  109. package/dist/src/lib/block-layout.d.ts +160 -0
  110. package/dist/src/lib/block-layout.js +451 -0
  111. package/dist/src/lib/block-layout.js.map +1 -0
  112. package/dist/src/lib/cli-manifest.d.ts +60 -0
  113. package/dist/src/lib/cli-manifest.js +70 -0
  114. package/dist/src/lib/cli-manifest.js.map +1 -0
  115. package/dist/src/lib/config.d.ts +53 -0
  116. package/dist/src/lib/config.js +92 -53
  117. package/dist/src/lib/config.js.map +1 -1
  118. package/dist/src/lib/confirm-prompt.d.ts +83 -0
  119. package/dist/src/lib/confirm-prompt.js +110 -0
  120. package/dist/src/lib/confirm-prompt.js.map +1 -0
  121. package/dist/src/lib/constants.d.ts +2 -0
  122. package/dist/src/lib/constants.js +3 -0
  123. package/dist/src/lib/constants.js.map +1 -0
  124. package/dist/src/lib/crash-handlers.d.ts +20 -0
  125. package/dist/src/lib/crash-handlers.js +49 -0
  126. package/dist/src/lib/crash-handlers.js.map +1 -0
  127. package/dist/src/lib/credentials-store.d.ts +79 -0
  128. package/dist/src/lib/credentials-store.js +307 -0
  129. package/dist/src/lib/credentials-store.js.map +1 -0
  130. package/dist/src/lib/csv.d.ts +48 -0
  131. package/dist/src/lib/csv.js +177 -0
  132. package/dist/src/lib/csv.js.map +1 -0
  133. package/dist/src/lib/db-codegen/dbFingerprint.d.ts +10 -0
  134. package/dist/src/lib/db-codegen/dbFingerprint.js +17 -0
  135. package/dist/src/lib/db-codegen/dbFingerprint.js.map +1 -0
  136. package/dist/src/lib/db-codegen/dbGenerator.d.ts +111 -0
  137. package/dist/src/lib/db-codegen/dbGenerator.js +371 -0
  138. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -0
  139. package/dist/src/lib/db-codegen/dbNaming.d.ts +45 -0
  140. package/dist/src/lib/db-codegen/dbNaming.js +104 -0
  141. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -0
  142. package/dist/src/lib/db-codegen/dbTemplates.d.ts +97 -0
  143. package/dist/src/lib/db-codegen/dbTemplates.js +159 -0
  144. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -0
  145. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +78 -0
  146. package/dist/src/lib/db-codegen/dbTsTypes.js +112 -0
  147. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -0
  148. package/dist/src/lib/env-resolver.d.ts +62 -0
  149. package/dist/src/lib/env-resolver.js +121 -0
  150. package/dist/src/lib/env-resolver.js.map +1 -0
  151. package/dist/src/lib/fetch.d.ts +5 -0
  152. package/dist/src/lib/generated-allowlist.d.ts +28 -0
  153. package/dist/src/lib/generated-allowlist.js +220 -0
  154. package/dist/src/lib/generated-allowlist.js.map +1 -0
  155. package/dist/src/lib/init-config.d.ts +59 -0
  156. package/dist/src/lib/init-config.js +113 -0
  157. package/dist/src/lib/init-config.js.map +1 -0
  158. package/dist/src/lib/migration-nag.d.ts +49 -0
  159. package/dist/src/lib/migration-nag.js +163 -0
  160. package/dist/src/lib/migration-nag.js.map +1 -0
  161. package/dist/src/lib/output.d.ts +86 -0
  162. package/dist/src/lib/output.js +150 -8
  163. package/dist/src/lib/output.js.map +1 -1
  164. package/dist/src/lib/paginate.d.ts +33 -0
  165. package/dist/src/lib/paginate.js +42 -0
  166. package/dist/src/lib/paginate.js.map +1 -0
  167. package/dist/src/lib/project-config.d.ts +97 -0
  168. package/dist/src/lib/project-config.js +209 -0
  169. package/dist/src/lib/project-config.js.map +1 -0
  170. package/dist/src/lib/query-operators.d.ts +43 -0
  171. package/dist/src/lib/query-operators.js +80 -0
  172. package/dist/src/lib/query-operators.js.map +1 -0
  173. package/dist/src/lib/refresh-admin-credentials.d.ts +65 -0
  174. package/dist/src/lib/refresh-admin-credentials.js +103 -0
  175. package/dist/src/lib/refresh-admin-credentials.js.map +1 -0
  176. package/dist/src/lib/resolve-platform.d.ts +45 -0
  177. package/dist/src/lib/resolve-platform.js +43 -0
  178. package/dist/src/lib/resolve-platform.js.map +1 -0
  179. package/dist/src/lib/skill-installer.d.ts +23 -0
  180. package/dist/src/lib/skill-installer.js +135 -0
  181. package/dist/src/lib/skill-installer.js.map +1 -0
  182. package/dist/src/lib/snapshots.d.ts +99 -0
  183. package/dist/src/lib/snapshots.js +357 -0
  184. package/dist/src/lib/snapshots.js.map +1 -0
  185. package/dist/src/lib/sync-paths.d.ts +72 -0
  186. package/dist/src/lib/sync-paths.js +130 -0
  187. package/dist/src/lib/sync-paths.js.map +1 -0
  188. package/dist/src/lib/template.d.ts +97 -0
  189. package/dist/src/lib/template.js +336 -62
  190. package/dist/src/lib/template.js.map +1 -1
  191. package/dist/src/lib/token-inject.d.ts +56 -0
  192. package/dist/src/lib/token-inject.js +204 -0
  193. package/dist/src/lib/token-inject.js.map +1 -0
  194. package/dist/src/lib/toml-database-config.d.ts +152 -0
  195. package/dist/src/lib/toml-database-config.js +600 -0
  196. package/dist/src/lib/toml-database-config.js.map +1 -0
  197. package/dist/src/lib/toml-metadata-config.d.ts +139 -0
  198. package/dist/src/lib/toml-metadata-config.js +427 -0
  199. package/dist/src/lib/toml-metadata-config.js.map +1 -0
  200. package/dist/src/lib/toml-params-validator.d.ts +129 -0
  201. package/dist/src/lib/toml-params-validator.js +298 -0
  202. package/dist/src/lib/toml-params-validator.js.map +1 -0
  203. package/dist/src/lib/version-check.d.ts +10 -0
  204. package/dist/src/lib/version-check.js +172 -0
  205. package/dist/src/lib/version-check.js.map +1 -0
  206. package/dist/src/lib/workflow-apply.d.ts +66 -0
  207. package/dist/src/lib/workflow-apply.js +117 -0
  208. package/dist/src/lib/workflow-apply.js.map +1 -0
  209. package/dist/src/lib/workflow-fragments.d.ts +41 -0
  210. package/dist/src/lib/workflow-fragments.js +121 -0
  211. package/dist/src/lib/workflow-fragments.js.map +1 -0
  212. package/dist/src/lib/workflow-toml-validator.d.ts +95 -0
  213. package/dist/src/lib/workflow-toml-validator.js +323 -0
  214. package/dist/src/lib/workflow-toml-validator.js.map +1 -0
  215. package/dist/src/types/index.d.ts +520 -0
  216. package/dist/src/validators.d.ts +64 -0
  217. package/dist/src/validators.js +63 -0
  218. package/dist/src/validators.js.map +1 -0
  219. package/package.json +18 -4
@@ -1,6 +1,12 @@
1
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync } from "fs";
2
+ import * as path from "path";
3
+ import * as TOML from "@iarna/toml";
1
4
  import { ApiClient } from "../lib/api-client.js";
2
5
  import { getCurrentAppId } from "../lib/config.js";
3
- import { success, error, info, keyValue, formatTable, formatId, formatDate, json, } from "../lib/output.js";
6
+ import { parseCsv, applyColumnMap, buildRows, chunk } from "../lib/csv.js";
7
+ import { generateDbTypes, } from "../lib/db-codegen/dbGenerator.js";
8
+ import { success, error, info, warn, keyValue, result as printResult, formatTable, formatId, formatDate, json, } from "../lib/output.js";
9
+ import { confirmPrompt } from "../lib/confirm-prompt.js";
4
10
  function resolveAppId(appId, options) {
5
11
  const resolved = appId || options.app || getCurrentAppId();
6
12
  if (!resolved) {
@@ -9,6 +15,49 @@ function resolveAppId(appId, options) {
9
15
  }
10
16
  return resolved;
11
17
  }
18
+ /**
19
+ * Validate that the named operation is an import-compatible, save-like
20
+ * mutation before importing (issue #145, Codex finding (a)).
21
+ *
22
+ * The importer always builds `params: { modelName, id, data }`, and the batch
23
+ * endpoint rejects undeclared params per item. So we require the op to be a
24
+ * `mutation` whose declared `params` cover `modelName`, `id`, and `data`. This
25
+ * surfaces a clear up-front error instead of letting every row fail
26
+ * server-side. Exits non-zero on any mismatch.
27
+ */
28
+ async function validateImportOperation(client, appId, databaseId, operationName) {
29
+ let ops;
30
+ try {
31
+ ops = await client.listDatabaseOperations(appId, databaseId);
32
+ }
33
+ catch (err) {
34
+ error(`Could not list operations for this database: ${err?.message || err}`);
35
+ process.exit(1);
36
+ return;
37
+ }
38
+ const op = (ops || []).find((o) => o?.name === operationName);
39
+ if (!op) {
40
+ error(`Operation "${operationName}" not found — create a mutation op (e.g. ` +
41
+ `\`seed_save\`) on this database type first.`);
42
+ process.exit(1);
43
+ return;
44
+ }
45
+ if (op.type !== "mutation") {
46
+ error(`Operation "${operationName}" is a "${op.type}" op, not an import-compatible ` +
47
+ `mutation. CSV import needs a save-like mutation taking {modelName, id, data}.`);
48
+ process.exit(1);
49
+ return;
50
+ }
51
+ const params = op.params || {};
52
+ const missing = ["modelName", "id", "data"].filter((p) => !(p in params));
53
+ if (missing.length > 0) {
54
+ error(`Operation "${operationName}" is not import-compatible: its params are missing ` +
55
+ `${missing.join(", ")}. CSV import builds {modelName, id, data} per item, so the ` +
56
+ `op must declare those params (like \`seed_save\`).`);
57
+ process.exit(1);
58
+ return;
59
+ }
60
+ }
12
61
  export function registerDatabasesCommands(program) {
13
62
  const databases = program
14
63
  .command("databases")
@@ -18,8 +67,23 @@ Examples:
18
67
  $ primitive databases list
19
68
  $ primitive databases create "My Database"
20
69
  $ primitive databases get <database-id>
70
+ $ primitive databases records models <database-id>
71
+ $ primitive databases records describe <database-id> <model-name>
72
+ $ primitive databases records query <database-id> <model-name> --filter '{"name":"Alice"}'
73
+ $ primitive databases operations list <database-id>
74
+ $ primitive databases operations execute <database-id> <operation-name> --params '{"key":"value"}'
75
+ $ primitive databases indexes list <database-id>
76
+ $ primitive databases indexes create <database-id> --model contacts --field email
77
+ $ primitive databases records delete <database-id> <model-name> <record-id> --yes
78
+ $ primitive databases records delete-all <database-id> <model-name> --yes
79
+ $ primitive databases records delete-all <database-id> <model-name> --filter '{"status":"draft"}' --yes
21
80
  $ primitive databases permissions list <database-id>
22
- $ primitive databases group-permissions list <database-id>
81
+
82
+ Note: this group manages database *instances*. To manage database *type*
83
+ configurations (schemas, rules, operations) — including deleting a type — use
84
+ the 'database-types' group:
85
+ $ primitive database-types list
86
+ $ primitive database-types delete <database-type> # refuses if instances exist; pass --force to override
23
87
  `);
24
88
  // List databases
25
89
  databases
@@ -45,6 +109,7 @@ Examples:
45
109
  console.log(formatTable(list, [
46
110
  { header: "ID", key: "databaseId", format: formatId },
47
111
  { header: "TITLE", key: "title" },
112
+ { header: "TYPE", key: "databaseType", format: (v) => v || "—" },
48
113
  { header: "PERMISSION", key: "permission" },
49
114
  { header: "CREATED", key: "createdAt", format: formatDate },
50
115
  ]));
@@ -60,12 +125,29 @@ Examples:
60
125
  .description("Create a new database")
61
126
  .argument("<title>", "Database title")
62
127
  .option("--app <app-id>", "App ID")
128
+ .requiredOption("--type <database-type>", "Database type (required)")
129
+ .option("--cel-context <json>", "Initial CEL context as JSON (referenced from access rules as database.celContext.*)")
130
+ .option("--metadata <json>", "Initial CEL context as JSON (legacy alias for --cel-context)")
63
131
  .option("--json", "Output as JSON")
64
132
  .action(async (title, options) => {
65
133
  const resolvedAppId = resolveAppId(undefined, options);
66
134
  const client = new ApiClient();
67
135
  try {
68
- const result = await client.createDatabase(resolvedAppId, { title });
136
+ const params = { title, databaseType: options.type };
137
+ // `--cel-context` is the new user-facing name; `--metadata` remains
138
+ // supported for backwards compatibility. When both are provided,
139
+ // --cel-context wins.
140
+ const contextSource = options.celContext ?? options.metadata;
141
+ if (contextSource) {
142
+ try {
143
+ params.metadata = JSON.parse(contextSource);
144
+ }
145
+ catch {
146
+ error(`Invalid ${options.celContext ? "--cel-context" : "--metadata"} JSON.`);
147
+ process.exit(1);
148
+ }
149
+ }
150
+ const result = await client.createDatabase(resolvedAppId, params);
69
151
  if (options.json) {
70
152
  json(result);
71
153
  return;
@@ -73,6 +155,9 @@ Examples:
73
155
  success("Database created.");
74
156
  keyValue("Database ID", result.databaseId);
75
157
  keyValue("Title", result.title);
158
+ if (result.databaseType) {
159
+ keyValue("Database Type", result.databaseType);
160
+ }
76
161
  }
77
162
  catch (err) {
78
163
  error(err.message);
@@ -95,10 +180,53 @@ Examples:
95
180
  json(result);
96
181
  return;
97
182
  }
183
+ printResult("Database ID", result.databaseId);
184
+ printResult("Title", result.title);
185
+ printResult("Database Type", result.databaseType || "none");
186
+ printResult("Permission", result.permission || "none");
187
+ printResult("Created", formatDate(result.createdAt));
188
+ }
189
+ catch (err) {
190
+ error(err.message);
191
+ process.exit(1);
192
+ }
193
+ });
194
+ // Update database
195
+ databases
196
+ .command("update")
197
+ .description("Update a database (title, type)")
198
+ .argument("<database-id>", "Database ID")
199
+ .option("--app <app-id>", "App ID")
200
+ .option("--title <title>", "New title")
201
+ .option("--type <database-type>", "Database type (use 'none' to clear)")
202
+ .option("--json", "Output as JSON")
203
+ .action(async (databaseId, options) => {
204
+ const resolvedAppId = resolveAppId(undefined, options);
205
+ const client = new ApiClient();
206
+ const params = {};
207
+ if (options.title) {
208
+ params.title = options.title;
209
+ }
210
+ if (options.type !== undefined) {
211
+ params.databaseType =
212
+ options.type === "none"
213
+ ? null
214
+ : options.type;
215
+ }
216
+ if (Object.keys(params).length === 0) {
217
+ error("Provide at least one of --title or --type.");
218
+ process.exit(1);
219
+ }
220
+ try {
221
+ const result = await client.updateDatabase(resolvedAppId, databaseId, params);
222
+ if (options.json) {
223
+ json(result);
224
+ return;
225
+ }
226
+ success("Database updated.");
98
227
  keyValue("Database ID", result.databaseId);
99
228
  keyValue("Title", result.title);
100
- keyValue("Permission", result.permission);
101
- keyValue("Created", formatDate(result.createdAt));
229
+ keyValue("Database Type", result.databaseType || "none");
102
230
  }
103
231
  catch (err) {
104
232
  error(err.message);
@@ -115,15 +243,14 @@ Examples:
115
243
  .action(async (databaseId, options) => {
116
244
  const resolvedAppId = resolveAppId(undefined, options);
117
245
  if (!options.yes) {
118
- const inquirer = await import("inquirer");
119
- const { confirm } = await inquirer.default.prompt([
120
- {
121
- type: "confirm",
122
- name: "confirm",
123
- message: `Delete database ${databaseId}? This cannot be undone.`,
124
- default: false,
125
- },
126
- ]);
246
+ let confirm;
247
+ try {
248
+ confirm = await confirmPrompt(`Delete database ${databaseId}? This cannot be undone.`);
249
+ }
250
+ catch (err) {
251
+ error(err.message);
252
+ process.exit(1);
253
+ }
127
254
  if (!confirm) {
128
255
  info("Cancelled.");
129
256
  return;
@@ -176,54 +303,269 @@ Examples:
176
303
  process.exit(1);
177
304
  }
178
305
  });
179
- // Grant permission
306
+ // Add manager action handler
307
+ const addManagerAction = async (databaseId, options) => {
308
+ const resolvedAppId = resolveAppId(undefined, options);
309
+ const client = new ApiClient();
310
+ try {
311
+ const result = await client.addDatabaseManager(resolvedAppId, databaseId, options.userId);
312
+ if (options.json) {
313
+ json(result);
314
+ return;
315
+ }
316
+ success(`Manager added: user ${options.userId}.`);
317
+ }
318
+ catch (err) {
319
+ error(err.message);
320
+ process.exit(1);
321
+ }
322
+ };
323
+ // Remove manager action handler
324
+ const removeManagerAction = async (databaseId, userId, options) => {
325
+ const resolvedAppId = resolveAppId(undefined, options);
326
+ if (!options.yes) {
327
+ let confirm;
328
+ try {
329
+ confirm = await confirmPrompt(`Remove manager ${userId} from database ${databaseId}?`);
330
+ }
331
+ catch (err) {
332
+ error(err.message);
333
+ process.exit(1);
334
+ }
335
+ if (!confirm) {
336
+ info("Cancelled.");
337
+ return;
338
+ }
339
+ }
340
+ const client = new ApiClient();
341
+ try {
342
+ await client.removeDatabaseManager(resolvedAppId, databaseId, userId);
343
+ success(`Manager removed: user ${userId}.`);
344
+ }
345
+ catch (err) {
346
+ error(err.message);
347
+ process.exit(1);
348
+ }
349
+ };
350
+ // Add manager (primary command)
351
+ permissions
352
+ .command("add-manager")
353
+ .description("Add a user as a manager of a database")
354
+ .argument("<database-id>", "Database ID")
355
+ .requiredOption("--user-id <user-id>", "User ID to add as manager")
356
+ .option("--app <app-id>", "App ID")
357
+ .option("--json", "Output as JSON")
358
+ .action(addManagerAction);
359
+ // Remove manager (primary command)
360
+ permissions
361
+ .command("remove-manager")
362
+ .description("Remove a manager from a database")
363
+ .argument("<database-id>", "Database ID")
364
+ .argument("<user-id>", "User ID to remove")
365
+ .option("--app <app-id>", "App ID")
366
+ .option("-y, --yes", "Skip confirmation prompt")
367
+ .action(removeManagerAction);
368
+ // Grant permission (deprecated alias for add-manager)
180
369
  permissions
181
370
  .command("grant")
182
- .description("Grant a user permission on a database")
371
+ .description("(deprecated: use add-manager) Grant a user permission on a database")
183
372
  .argument("<database-id>", "Database ID")
184
373
  .requiredOption("--user-id <user-id>", "User ID to grant permission to")
185
- .requiredOption("--permission <permission>", "Permission level: owner, read-write, or reader")
374
+ .option("--permission <permission>", "Permission level (ignored, always 'manager')")
375
+ .option("--app <app-id>", "App ID")
376
+ .option("--json", "Output as JSON")
377
+ .action(addManagerAction);
378
+ // Revoke permission (deprecated alias for remove-manager)
379
+ permissions
380
+ .command("revoke")
381
+ .description("(deprecated: use remove-manager) Revoke a user's permission on a database")
382
+ .argument("<database-id>", "Database ID")
383
+ .argument("<user-id>", "User ID to revoke")
384
+ .option("--app <app-id>", "App ID")
385
+ .option("-y, --yes", "Skip confirmation prompt")
386
+ .action(removeManagerAction);
387
+ // ---- Records subcommand group ----
388
+ const records = databases
389
+ .command("records")
390
+ .description("Inspect and query records in a database");
391
+ // List models
392
+ records
393
+ .command("models")
394
+ .description("List model names in a database")
395
+ .argument("<database-id>", "Database ID")
186
396
  .option("--app <app-id>", "App ID")
187
397
  .option("--json", "Output as JSON")
188
398
  .action(async (databaseId, options) => {
189
399
  const resolvedAppId = resolveAppId(undefined, options);
190
400
  const client = new ApiClient();
191
401
  try {
192
- const result = await client.grantDatabasePermission(resolvedAppId, databaseId, {
193
- userId: options.userId,
194
- permission: options.permission,
195
- });
402
+ const result = await client.listDatabaseModels(resolvedAppId, databaseId);
403
+ const models = result.models || [];
404
+ if (options.json) {
405
+ json(models);
406
+ return;
407
+ }
408
+ if (models.length === 0) {
409
+ info("No models found.");
410
+ return;
411
+ }
412
+ for (const m of models) {
413
+ console.log(` ${m}`);
414
+ }
415
+ }
416
+ catch (err) {
417
+ error(err.message);
418
+ process.exit(1);
419
+ }
420
+ });
421
+ // Describe model
422
+ records
423
+ .command("describe")
424
+ .description("Show inferred schema for a model")
425
+ .argument("<database-id>", "Database ID")
426
+ .argument("<model-name>", "Model name")
427
+ .option("--app <app-id>", "App ID")
428
+ .option("--json", "Output as JSON")
429
+ .action(async (databaseId, modelName, options) => {
430
+ const resolvedAppId = resolveAppId(undefined, options);
431
+ const client = new ApiClient();
432
+ try {
433
+ const result = await client.describeDatabaseModel(resolvedAppId, databaseId, modelName);
434
+ const fields = result.fields || [];
435
+ if (options.json) {
436
+ json(fields);
437
+ return;
438
+ }
439
+ if (fields.length === 0) {
440
+ info("No fields detected.");
441
+ return;
442
+ }
443
+ console.log(formatTable(fields, [
444
+ { header: "FIELD", key: "field_name" },
445
+ { header: "TYPE", key: "inferred_type" },
446
+ ]));
447
+ }
448
+ catch (err) {
449
+ error(err.message);
450
+ process.exit(1);
451
+ }
452
+ });
453
+ // Query records (direct introspection via admin-data route)
454
+ records
455
+ .command("query")
456
+ .description("Query records in a database model (admin only)")
457
+ .argument("<database-id>", "Database ID")
458
+ .argument("<model-name>", "Model name to query")
459
+ .option("--app <app-id>", "App ID")
460
+ .option("--filter <json>", "Filter as JSON (e.g. '{\"name\":\"Alice\"}')")
461
+ .option("--filter-file <path>", "Read filter from a JSON or TOML file")
462
+ .option("--limit <n>", "Maximum number of records to return", parseInt)
463
+ .option("--cursor <cursor>", "Pagination cursor from previous query")
464
+ .option("--json", "Output as JSON")
465
+ .action(async (databaseId, modelName, options) => {
466
+ const resolvedAppId = resolveAppId(undefined, options);
467
+ const client = new ApiClient();
468
+ if (options.filter && options.filterFile) {
469
+ error("Cannot use both --filter and --filter-file.");
470
+ process.exit(1);
471
+ }
472
+ let filter;
473
+ if (options.filterFile) {
474
+ try {
475
+ const content = readFileSync(options.filterFile, "utf-8");
476
+ if (options.filterFile.endsWith(".toml")) {
477
+ const parsed = TOML.parse(content);
478
+ filter = parsed.filter || parsed;
479
+ }
480
+ else {
481
+ filter = JSON.parse(content);
482
+ }
483
+ }
484
+ catch (err) {
485
+ error(`Failed to read filter file: ${err.message}`);
486
+ process.exit(1);
487
+ }
488
+ }
489
+ else if (options.filter) {
490
+ try {
491
+ filter = JSON.parse(options.filter);
492
+ }
493
+ catch {
494
+ error("Invalid --filter JSON.");
495
+ process.exit(1);
496
+ }
497
+ }
498
+ try {
499
+ const result = await client.queryDatabaseRecords(resolvedAppId, databaseId, modelName, { filter, limit: options.limit, cursor: options.cursor });
196
500
  if (options.json) {
197
501
  json(result);
198
502
  return;
199
503
  }
200
- success(`Permission '${options.permission}' granted to user ${options.userId}.`);
504
+ const records = result.data || [];
505
+ if (records.length === 0) {
506
+ info("No records found.");
507
+ return;
508
+ }
509
+ // Auto-detect columns from record keys
510
+ const allKeys = new Set();
511
+ for (const rec of records) {
512
+ for (const key of Object.keys(rec)) {
513
+ allKeys.add(key);
514
+ }
515
+ }
516
+ // Put id first, then type, then the rest sorted
517
+ const orderedKeys = [];
518
+ if (allKeys.has("id")) {
519
+ orderedKeys.push("id");
520
+ allKeys.delete("id");
521
+ }
522
+ if (allKeys.has("type")) {
523
+ orderedKeys.push("type");
524
+ allKeys.delete("type");
525
+ }
526
+ orderedKeys.push(...[...allKeys].sort());
527
+ const columns = orderedKeys.map((key) => ({
528
+ header: key.toUpperCase(),
529
+ key,
530
+ format: (v) => {
531
+ if (v === null || v === undefined)
532
+ return "—";
533
+ if (typeof v === "object")
534
+ return JSON.stringify(v);
535
+ return String(v);
536
+ },
537
+ }));
538
+ console.log(formatTable(records, columns));
539
+ if (result.nextCursor) {
540
+ info(`More results available. Use --cursor ${result.nextCursor}`);
541
+ }
201
542
  }
202
543
  catch (err) {
203
544
  error(err.message);
204
545
  process.exit(1);
205
546
  }
206
547
  });
207
- // Revoke permission
208
- permissions
209
- .command("revoke")
210
- .description("Revoke a user's permission on a database")
548
+ // Delete a single record
549
+ records
550
+ .command("delete")
551
+ .description("Delete a single record from a database model (admin only)")
211
552
  .argument("<database-id>", "Database ID")
212
- .argument("<user-id>", "User ID to revoke")
553
+ .argument("<model-name>", "Model name")
554
+ .argument("<record-id>", "Record ID to delete")
213
555
  .option("--app <app-id>", "App ID")
214
556
  .option("-y, --yes", "Skip confirmation prompt")
215
- .action(async (databaseId, userId, options) => {
557
+ .option("--json", "Output as JSON")
558
+ .action(async (databaseId, modelName, recordId, options) => {
216
559
  const resolvedAppId = resolveAppId(undefined, options);
217
560
  if (!options.yes) {
218
- const inquirer = await import("inquirer");
219
- const { confirm } = await inquirer.default.prompt([
220
- {
221
- type: "confirm",
222
- name: "confirm",
223
- message: `Revoke permission for user ${userId} on database ${databaseId}?`,
224
- default: false,
225
- },
226
- ]);
561
+ let confirm;
562
+ try {
563
+ confirm = await confirmPrompt(`Delete record ${recordId} from ${modelName}? This cannot be undone.`);
564
+ }
565
+ catch (err) {
566
+ error(err.message);
567
+ process.exit(1);
568
+ }
227
569
  if (!confirm) {
228
570
  info("Cancelled.");
229
571
  return;
@@ -231,22 +573,186 @@ Examples:
231
573
  }
232
574
  const client = new ApiClient();
233
575
  try {
234
- await client.revokeDatabasePermission(resolvedAppId, databaseId, userId);
235
- success(`Permission revoked for user ${userId}.`);
576
+ const result = await client.deleteDatabaseRecord(resolvedAppId, databaseId, modelName, recordId);
577
+ if (options.json) {
578
+ json(result);
579
+ return;
580
+ }
581
+ success(`Record ${recordId} deleted from ${modelName}.`);
236
582
  }
237
583
  catch (err) {
238
584
  error(err.message);
239
585
  process.exit(1);
240
586
  }
241
587
  });
242
- // ---- Group Permissions subcommand group ----
243
- const groupPermissions = databases
244
- .command("group-permissions")
245
- .description("Manage group permissions on a database");
246
- // List group permissions
247
- groupPermissions
588
+ // Delete all records for a model
589
+ records
590
+ .command("delete-all")
591
+ .description("Delete all records for a model (queries then batch deletes, admin only)")
592
+ .argument("<database-id>", "Database ID")
593
+ .argument("<model-name>", "Model name")
594
+ .option("--app <app-id>", "App ID")
595
+ .option("--filter <json>", "Filter as JSON to select which records to delete")
596
+ .option("-y, --yes", "Skip confirmation prompt")
597
+ .option("--json", "Output as JSON")
598
+ .action(async (databaseId, modelName, options) => {
599
+ const resolvedAppId = resolveAppId(undefined, options);
600
+ const client = new ApiClient();
601
+ let filter;
602
+ if (options.filter) {
603
+ try {
604
+ filter = JSON.parse(options.filter);
605
+ }
606
+ catch {
607
+ error("Invalid --filter JSON.");
608
+ process.exit(1);
609
+ }
610
+ }
611
+ try {
612
+ // First, query to find all matching records
613
+ let allRecords = [];
614
+ let cursor;
615
+ let hasMore = true;
616
+ while (hasMore) {
617
+ const result = await client.queryDatabaseRecords(resolvedAppId, databaseId, modelName, { filter, limit: 100, cursor });
618
+ const records = result?.data || [];
619
+ allRecords = allRecords.concat(records);
620
+ cursor = result?.nextCursor;
621
+ hasMore = !!cursor && records.length > 0;
622
+ }
623
+ if (allRecords.length === 0) {
624
+ if (options.json) {
625
+ json({ deleted: 0, modelName });
626
+ return;
627
+ }
628
+ info("No matching records found.");
629
+ return;
630
+ }
631
+ if (!options.yes) {
632
+ let confirm;
633
+ try {
634
+ confirm = await confirmPrompt(`Delete ${allRecords.length} record(s) from ${modelName}? This cannot be undone.`);
635
+ }
636
+ catch (err) {
637
+ error(err.message);
638
+ process.exit(1);
639
+ }
640
+ if (!confirm) {
641
+ info("Cancelled.");
642
+ return;
643
+ }
644
+ }
645
+ // Batch delete in chunks of 50
646
+ const BATCH_SIZE = 50;
647
+ let deleted = 0;
648
+ for (let i = 0; i < allRecords.length; i += BATCH_SIZE) {
649
+ const chunk = allRecords.slice(i, i + BATCH_SIZE);
650
+ const operations = chunk.map((r) => ({
651
+ op: "delete",
652
+ modelName,
653
+ id: r.id,
654
+ }));
655
+ await client.batchDeleteDatabaseRecords(resolvedAppId, databaseId, operations);
656
+ deleted += chunk.length;
657
+ }
658
+ if (options.json) {
659
+ json({ deleted, modelName });
660
+ return;
661
+ }
662
+ success(`Deleted ${deleted} record(s) from ${modelName}.`);
663
+ }
664
+ catch (err) {
665
+ error(err.message);
666
+ process.exit(1);
667
+ }
668
+ });
669
+ // ---- CEL context subcommand group ----
670
+ // `cel-context` is the user-facing name for the dict that feeds CEL
671
+ // access rules and filter placeholders. `metadata` is kept as a hidden
672
+ // alias for backwards compatibility with existing scripts.
673
+ const registerCelContextGroup = (name, hidden) => {
674
+ const group = databases
675
+ .command(name, { hidden })
676
+ .description(hidden
677
+ ? "Manage database CEL context (alias of 'cel-context')"
678
+ : "Manage database CEL context (the dict referenced by access rules and filters)");
679
+ group
680
+ .command("get")
681
+ .description("Read database CEL context")
682
+ .argument("<database-id>", "Database ID")
683
+ .option("--app <app-id>", "App ID")
684
+ .option("--json", "Output as JSON")
685
+ .action(async (databaseId, options) => {
686
+ const resolvedAppId = resolveAppId(undefined, options);
687
+ const client = new ApiClient();
688
+ try {
689
+ const result = await client.getDatabaseCelContext(resolvedAppId, databaseId);
690
+ if (options.json) {
691
+ json(result);
692
+ return;
693
+ }
694
+ printResult("Database ID", result.databaseId);
695
+ const ctx = result.celContext ?? result.metadata;
696
+ if (ctx && Object.keys(ctx).length > 0) {
697
+ printResult("CEL Context", JSON.stringify(ctx, null, 2));
698
+ }
699
+ else {
700
+ info("No CEL context set.");
701
+ }
702
+ }
703
+ catch (err) {
704
+ error(err.message);
705
+ process.exit(1);
706
+ }
707
+ });
708
+ group
709
+ .command("update")
710
+ .description("Update database CEL context (merge with existing)")
711
+ .argument("<database-id>", "Database ID")
712
+ .requiredOption("--data <json>", "CEL context fields as JSON (merged with existing)")
713
+ .option("--app <app-id>", "App ID")
714
+ .option("--json", "Output as JSON")
715
+ .action(async (databaseId, options) => {
716
+ const resolvedAppId = resolveAppId(undefined, options);
717
+ const client = new ApiClient();
718
+ let data;
719
+ try {
720
+ data = JSON.parse(options.data);
721
+ }
722
+ catch {
723
+ error("Invalid --data JSON.");
724
+ process.exit(1);
725
+ return;
726
+ }
727
+ try {
728
+ const result = await client.updateDatabaseCelContext(resolvedAppId, databaseId, data);
729
+ if (options.json) {
730
+ json(result);
731
+ return;
732
+ }
733
+ success("CEL context updated.");
734
+ const ctx = result.celContext ?? result.metadata;
735
+ if (ctx) {
736
+ keyValue("CEL Context", JSON.stringify(ctx));
737
+ }
738
+ }
739
+ catch (err) {
740
+ error(err.message);
741
+ process.exit(1);
742
+ }
743
+ });
744
+ };
745
+ registerCelContextGroup("cel-context", false);
746
+ // Hidden legacy alias — `primitive databases metadata get/update` still works.
747
+ registerCelContextGroup("metadata", true);
748
+ // ---- Operations subcommand group ----
749
+ const operations = databases
750
+ .command("operations")
751
+ .description("List and execute registered operations on a database");
752
+ // List operations
753
+ operations
248
754
  .command("list")
249
- .description("List group permissions for a database")
755
+ .description("List registered operations available on a database")
250
756
  .argument("<database-id>", "Database ID")
251
757
  .option("--app <app-id>", "App ID")
252
758
  .option("--json", "Output as JSON")
@@ -254,21 +760,21 @@ Examples:
254
760
  const resolvedAppId = resolveAppId(undefined, options);
255
761
  const client = new ApiClient();
256
762
  try {
257
- const result = await client.listDatabaseGroupPermissions(resolvedAppId, databaseId);
258
- const list = Array.isArray(result) ? result : result?.permissions ?? [];
763
+ const result = await client.listDatabaseOperations(resolvedAppId, databaseId);
764
+ const list = Array.isArray(result) ? result : [];
259
765
  if (options.json) {
260
766
  json(list);
261
767
  return;
262
768
  }
263
769
  if (list.length === 0) {
264
- info("No group permissions found.");
770
+ info("No operations found.");
265
771
  return;
266
772
  }
267
773
  console.log(formatTable(list, [
268
- { header: "GROUP_TYPE", key: "groupType" },
269
- { header: "GROUP_ID", key: "groupId" },
270
- { header: "PERMISSION", key: "permission" },
271
- { header: "GRANTED", key: "grantedAt", format: formatDate },
774
+ { header: "NAME", key: "name" },
775
+ { header: "TYPE", key: "type" },
776
+ { header: "MODEL", key: "modelName" },
777
+ { header: "ACCESS", key: "access" },
272
778
  ]));
273
779
  }
274
780
  catch (err) {
@@ -276,57 +782,193 @@ Examples:
276
782
  process.exit(1);
277
783
  }
278
784
  });
279
- // Grant group permission
280
- groupPermissions
281
- .command("grant")
282
- .description("Grant a group permission on a database")
785
+ // Execute operation
786
+ operations
787
+ .command("execute")
788
+ .description("Execute a registered operation on a database")
283
789
  .argument("<database-id>", "Database ID")
284
- .requiredOption("--group-type <type>", "Group type")
285
- .requiredOption("--group-id <id>", "Group ID")
286
- .requiredOption("--permission <permission>", "Permission level: read-write or reader")
790
+ .argument("<operation-name>", "Operation name")
791
+ .option("--params <json>", "Operation parameters as JSON")
792
+ .option("--limit <n>", "Max records to return (for queries)")
793
+ .option("--cursor <cursor>", "Pagination cursor")
794
+ .option("--token <jwt>", "Execute as a specific user (dev/test — JWT visible in process args)")
795
+ .option("--app <app-id>", "App ID")
796
+ .option("--json", "Output as JSON")
797
+ .option("--timing", "Show server-side timing breakdown")
798
+ .action(async (databaseId, operationName, options) => {
799
+ const resolvedAppId = resolveAppId(undefined, options);
800
+ const client = new ApiClient();
801
+ let params;
802
+ if (options.params) {
803
+ try {
804
+ params = JSON.parse(options.params);
805
+ }
806
+ catch {
807
+ error("Invalid --params JSON.");
808
+ process.exit(1);
809
+ return;
810
+ }
811
+ }
812
+ const body = {};
813
+ if (params)
814
+ body.params = params;
815
+ if (options.limit)
816
+ body.limit = parseInt(options.limit, 10);
817
+ if (options.cursor)
818
+ body.cursor = options.cursor;
819
+ try {
820
+ const result = await client.executeDatabaseOperation(resolvedAppId, databaseId, operationName, body, options.token, { timing: !!options.timing });
821
+ if (options.json) {
822
+ json(result);
823
+ return;
824
+ }
825
+ // Smart display based on result shape
826
+ if (result.data && Array.isArray(result.data)) {
827
+ if (result.data.length === 0) {
828
+ info("No records found.");
829
+ }
830
+ else {
831
+ const cols = Object.keys(result.data[0]).filter((k) => k !== "type");
832
+ console.log(formatTable(result.data, cols.map((c) => ({
833
+ header: c.toUpperCase(),
834
+ key: c,
835
+ format: (v) => {
836
+ if (v === null || v === undefined)
837
+ return "—";
838
+ if (typeof v === "object") {
839
+ const s = JSON.stringify(v);
840
+ return s.length > 50 ? s.slice(0, 47) + "..." : s;
841
+ }
842
+ const s = String(v);
843
+ return s.length > 50 ? s.slice(0, 47) + "..." : s;
844
+ },
845
+ }))));
846
+ }
847
+ if (result.hasMore) {
848
+ info(`More records available. Use --cursor ${result.cursor} to continue.`);
849
+ }
850
+ }
851
+ else if (result.count !== undefined) {
852
+ console.log(`Count: ${result.count}`);
853
+ }
854
+ else if (result.result !== undefined) {
855
+ // Aggregate result
856
+ json(result);
857
+ }
858
+ else if (result.results && Array.isArray(result.results)) {
859
+ // Batch/mutation results
860
+ success(`Operation executed (${result.results.length} result${result.results.length === 1 ? "" : "s"}).`);
861
+ }
862
+ else if (result.success !== undefined) {
863
+ success("Operation executed.");
864
+ }
865
+ else {
866
+ json(result);
867
+ }
868
+ // Display timing if present
869
+ if (result._timing) {
870
+ console.log("");
871
+ console.log("Timing:");
872
+ for (const [key, value] of Object.entries(result._timing)) {
873
+ console.log(` ${key}: ${value}ms`);
874
+ }
875
+ }
876
+ }
877
+ catch (err) {
878
+ error(err.message);
879
+ process.exit(1);
880
+ }
881
+ });
882
+ // ---- Indexes subcommand group ----
883
+ const indexes = databases
884
+ .command("indexes")
885
+ .description("Manage indexes on a database");
886
+ // List indexes
887
+ indexes
888
+ .command("list")
889
+ .description("List indexes for a database or model")
890
+ .argument("<database-id>", "Database ID")
891
+ .option("--model <model-name>", "Filter by model name")
892
+ .option("--app <app-id>", "App ID")
893
+ .option("--json", "Output as JSON")
894
+ .action(async (databaseId, options) => {
895
+ const resolvedAppId = resolveAppId(undefined, options);
896
+ const client = new ApiClient();
897
+ try {
898
+ const result = await client.listDatabaseIndexes(resolvedAppId, databaseId, options.model);
899
+ const list = result.indexes || [];
900
+ if (options.json) {
901
+ json(list);
902
+ return;
903
+ }
904
+ if (list.length === 0) {
905
+ info("No indexes found.");
906
+ return;
907
+ }
908
+ console.log(formatTable(list, [
909
+ { header: "MODEL", key: "model_name" },
910
+ { header: "FIELD", key: "field_name" },
911
+ { header: "TYPE", key: "field_type" },
912
+ { header: "UNIQUE", key: "is_unique", format: (v) => (v ? "Yes" : "No") },
913
+ ]));
914
+ }
915
+ catch (err) {
916
+ error(err.message);
917
+ process.exit(1);
918
+ }
919
+ });
920
+ // Register index
921
+ indexes
922
+ .command("create")
923
+ .description("Create an index on a model field")
924
+ .argument("<database-id>", "Database ID")
925
+ .requiredOption("--model <model-name>", "Model name")
926
+ .requiredOption("--field <field-name>", "Field name to index")
927
+ .option("--type <field-type>", "Field type: string, number, or boolean", "string")
928
+ .option("--unique", "Make this a unique index")
287
929
  .option("--app <app-id>", "App ID")
288
930
  .option("--json", "Output as JSON")
289
931
  .action(async (databaseId, options) => {
290
932
  const resolvedAppId = resolveAppId(undefined, options);
291
933
  const client = new ApiClient();
292
934
  try {
293
- const result = await client.grantDatabaseGroupPermission(resolvedAppId, databaseId, {
294
- groupType: options.groupType,
295
- groupId: options.groupId,
296
- permission: options.permission,
935
+ const result = await client.registerDatabaseIndex(resolvedAppId, databaseId, {
936
+ modelName: options.model,
937
+ fieldName: options.field,
938
+ fieldType: options.type,
939
+ unique: !!options.unique,
297
940
  });
298
941
  if (options.json) {
299
942
  json(result);
300
943
  return;
301
944
  }
302
- success(`Permission '${options.permission}' granted to group ${options.groupType}/${options.groupId}.`);
945
+ success(`Index created on ${options.model}.${options.field} (${options.type}${options.unique ? ", unique" : ""}).`);
303
946
  }
304
947
  catch (err) {
305
948
  error(err.message);
306
949
  process.exit(1);
307
950
  }
308
951
  });
309
- // Revoke group permission
310
- groupPermissions
311
- .command("revoke")
312
- .description("Revoke a group's permission on a database")
952
+ // Drop index
953
+ indexes
954
+ .command("drop")
955
+ .description("Drop an index from a model field")
313
956
  .argument("<database-id>", "Database ID")
314
- .argument("<group-type>", "Group type")
315
- .argument("<group-id>", "Group ID")
957
+ .requiredOption("--model <model-name>", "Model name")
958
+ .requiredOption("--field <field-name>", "Field name")
316
959
  .option("--app <app-id>", "App ID")
317
960
  .option("-y, --yes", "Skip confirmation prompt")
318
- .action(async (databaseId, groupType, groupId, options) => {
961
+ .action(async (databaseId, options) => {
319
962
  const resolvedAppId = resolveAppId(undefined, options);
320
963
  if (!options.yes) {
321
- const inquirer = await import("inquirer");
322
- const { confirm } = await inquirer.default.prompt([
323
- {
324
- type: "confirm",
325
- name: "confirm",
326
- message: `Revoke permission for group ${groupType}/${groupId} on database ${databaseId}?`,
327
- default: false,
328
- },
329
- ]);
964
+ let confirm;
965
+ try {
966
+ confirm = await confirmPrompt(`Drop index on ${options.model}.${options.field}?`);
967
+ }
968
+ catch (err) {
969
+ error(err.message);
970
+ process.exit(1);
971
+ }
330
972
  if (!confirm) {
331
973
  info("Cancelled.");
332
974
  return;
@@ -334,13 +976,750 @@ Examples:
334
976
  }
335
977
  const client = new ApiClient();
336
978
  try {
337
- await client.revokeDatabaseGroupPermission(resolvedAppId, databaseId, groupType, groupId);
338
- success(`Permission revoked for group ${groupType}/${groupId}.`);
979
+ await client.dropDatabaseIndex(resolvedAppId, databaseId, {
980
+ modelName: options.model,
981
+ fieldName: options.field,
982
+ });
983
+ success(`Index dropped on ${options.model}.${options.field}.`);
339
984
  }
340
985
  catch (err) {
341
986
  error(err.message);
342
987
  process.exit(1);
343
988
  }
344
989
  });
990
+ // Reindex an existing instance from its type schema (issue #974). The
991
+ // on-demand remedy that brings a pre-existing database into compliance after
992
+ // a schema declares a field `unique = true` — registers any declared-but-
993
+ // missing unique index so `upsertOn` ops resolve. Idempotent.
994
+ databases
995
+ .command("reindex")
996
+ .description("Reprovision a database's unique indexes from its type schema")
997
+ .argument("<database-id>", "Database ID")
998
+ .requiredOption("--from-schema", "Reprovision unique indexes declared in the type schema")
999
+ .option("--app <app-id>", "App ID")
1000
+ .option("--json", "Output as JSON")
1001
+ .action(async (databaseId, options) => {
1002
+ const resolvedAppId = resolveAppId(undefined, options);
1003
+ const client = new ApiClient();
1004
+ try {
1005
+ const res = await client.reindexDatabaseFromSchema(resolvedAppId, databaseId);
1006
+ if (options.json) {
1007
+ json(res);
1008
+ return;
1009
+ }
1010
+ if (res.skipped) {
1011
+ info(`Nothing to reindex (${res.reason}).`);
1012
+ return;
1013
+ }
1014
+ success(`Reindexed from schema: ${res.registered} unique index${res.registered === 1 ? "" : "es"} provisioned.`);
1015
+ }
1016
+ catch (err) {
1017
+ error(err.message);
1018
+ process.exit(1);
1019
+ }
1020
+ });
1021
+ // ---- Export / Import commands ----
1022
+ databases
1023
+ .command("export")
1024
+ .description("Export a database (records, indexes, constraints)")
1025
+ .argument("[app-id]", "App ID (uses current app if not specified)")
1026
+ .argument("<database-id>", "Database ID to export")
1027
+ .option("--app <app-id>", "App ID")
1028
+ .option("--output <dir>", "Output directory", "./primitive-export")
1029
+ .option("--json", "Output as JSON")
1030
+ .action(async (first, second, options) => {
1031
+ let resolvedAppId;
1032
+ let databaseId;
1033
+ if (second && !second.startsWith("-")) {
1034
+ resolvedAppId = resolveAppId(first, options);
1035
+ databaseId = second;
1036
+ }
1037
+ else {
1038
+ resolvedAppId = resolveAppId(undefined, options);
1039
+ databaseId = first;
1040
+ }
1041
+ if (!databaseId) {
1042
+ error("Database ID is required.");
1043
+ process.exit(1);
1044
+ }
1045
+ const client = new ApiClient();
1046
+ try {
1047
+ await exportSingleDatabase(client, resolvedAppId, databaseId, options.output, options.json);
1048
+ }
1049
+ catch (err) {
1050
+ error(err.message);
1051
+ process.exit(1);
1052
+ }
1053
+ });
1054
+ databases
1055
+ .command("import")
1056
+ .description("Import a database from an export directory")
1057
+ .argument("[app-id]", "App ID (uses current app if not specified)")
1058
+ .argument("<path>", "Path to database export directory")
1059
+ .option("--app <app-id>", "App ID")
1060
+ .option("--overwrite", "Replace existing database if ID collides")
1061
+ .option("--dry-run", "Show what would be imported without making changes")
1062
+ .option("--json", "Output as JSON")
1063
+ .action(async (first, second, options) => {
1064
+ let resolvedAppId;
1065
+ let importPath;
1066
+ if (second && !second.startsWith("-")) {
1067
+ resolvedAppId = resolveAppId(first, options);
1068
+ importPath = second;
1069
+ }
1070
+ else {
1071
+ resolvedAppId = resolveAppId(undefined, options);
1072
+ importPath = first;
1073
+ }
1074
+ if (!importPath) {
1075
+ error("Import path is required.");
1076
+ process.exit(1);
1077
+ }
1078
+ const client = new ApiClient();
1079
+ try {
1080
+ // Determine if this is a manifest-based export or a single database
1081
+ let dbDirs = [];
1082
+ const manifestPath = path.join(importPath, "manifest.json");
1083
+ if (existsSync(path.join(importPath, "metadata.json"))) {
1084
+ // Single database directory
1085
+ dbDirs = [importPath];
1086
+ }
1087
+ else if (existsSync(manifestPath)) {
1088
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
1089
+ for (const dbId of manifest.databases || []) {
1090
+ const dbDir = path.join(importPath, "databases", dbId);
1091
+ if (existsSync(dbDir)) {
1092
+ dbDirs.push(dbDir);
1093
+ }
1094
+ }
1095
+ }
1096
+ else if (existsSync(path.join(importPath, "databases"))) {
1097
+ const entries = readdirSync(path.join(importPath, "databases"), { withFileTypes: true });
1098
+ for (const entry of entries) {
1099
+ if (entry.isDirectory()) {
1100
+ dbDirs.push(path.join(importPath, "databases", entry.name));
1101
+ }
1102
+ }
1103
+ }
1104
+ else {
1105
+ error("No valid database export data found at the specified path.");
1106
+ process.exit(1);
1107
+ }
1108
+ if (dbDirs.length === 0) {
1109
+ info("No databases to import.");
1110
+ return;
1111
+ }
1112
+ const summary = { created: 0, updated: 0, skipped: 0, recordsImported: 0, indexesRegistered: 0 };
1113
+ for (const dbDir of dbDirs) {
1114
+ await importSingleDatabase(client, resolvedAppId, dbDir, options.overwrite || false, options.dryRun || false, summary);
1115
+ }
1116
+ if (options.json) {
1117
+ json(summary);
1118
+ }
1119
+ else {
1120
+ success("Database import complete:");
1121
+ keyValue(" Databases created", String(summary.created));
1122
+ if (summary.updated > 0)
1123
+ keyValue(" Databases updated", String(summary.updated));
1124
+ if (summary.skipped > 0)
1125
+ keyValue(" Databases skipped", String(summary.skipped));
1126
+ keyValue(" Records imported", String(summary.recordsImported));
1127
+ if (summary.indexesRegistered > 0)
1128
+ keyValue(" Indexes registered", String(summary.indexesRegistered));
1129
+ }
1130
+ }
1131
+ catch (err) {
1132
+ error(err.message);
1133
+ process.exit(1);
1134
+ }
1135
+ });
1136
+ // ============================================
1137
+ // CSV import (issue #145, Phase 1)
1138
+ // ============================================
1139
+ databases
1140
+ .command("import-csv")
1141
+ .description("Import rows from a CSV file into a database via a registered batch (bulk) save operation")
1142
+ .argument("<database-id>", "Database ID")
1143
+ .argument("<file>", "Path to the CSV file")
1144
+ .requiredOption("--model <name>", "Model name passed in each item's params")
1145
+ .option("--operation <op>", "Registered mutation operation to use (must be a {modelName,id,data} save-like op)", "seed_save")
1146
+ .option("--column-map <json>", 'Map CSV headers to field names, e.g. \'{"CSV Header":"fieldName"}\'')
1147
+ .option("--types <json>", 'Coerce fields by type, e.g. \'{"price":"number","active":"boolean"}\'')
1148
+ .option("--id-column <col>", "Use this CSV column for record ids (else a ULID is generated)")
1149
+ .option("--batch-size <n>", "Records per batch request (default: 5000)", "5000")
1150
+ .option("--delimiter <char>", "CSV field delimiter (default: ',')", ",")
1151
+ .option("--app <app-id>", "App ID")
1152
+ .option("--dry-run", "Parse + report row/batch counts without writing")
1153
+ .option("--stop-on-error", "Abort the whole import on the first chunk error (default: best-effort continue)")
1154
+ .option("--json", "Output a machine-readable result")
1155
+ .action(async (databaseId, file, options) => {
1156
+ const resolvedAppId = resolveAppId(undefined, options);
1157
+ const client = new ApiClient();
1158
+ // Parse JSON flags up front for clear errors.
1159
+ let columnMap;
1160
+ if (options.columnMap) {
1161
+ try {
1162
+ columnMap = JSON.parse(options.columnMap);
1163
+ }
1164
+ catch {
1165
+ error("Invalid --column-map JSON.");
1166
+ process.exit(1);
1167
+ return;
1168
+ }
1169
+ }
1170
+ let types;
1171
+ if (options.types) {
1172
+ try {
1173
+ types = JSON.parse(options.types);
1174
+ }
1175
+ catch {
1176
+ error("Invalid --types JSON.");
1177
+ process.exit(1);
1178
+ return;
1179
+ }
1180
+ }
1181
+ const batchSize = parseInt(options.batchSize, 10);
1182
+ if (!Number.isInteger(batchSize) || batchSize < 1) {
1183
+ error("--batch-size must be a positive integer.");
1184
+ process.exit(1);
1185
+ return;
1186
+ }
1187
+ // The server rejects batch requests over 100000 items; keep chunks under it.
1188
+ if (batchSize > 100000) {
1189
+ error("--batch-size cannot exceed 100000 (the server's per-request cap).");
1190
+ process.exit(1);
1191
+ return;
1192
+ }
1193
+ if (!existsSync(file)) {
1194
+ error(`CSV file not found: ${file}`);
1195
+ process.exit(1);
1196
+ return;
1197
+ }
1198
+ try {
1199
+ // 1. Read + parse the CSV.
1200
+ const raw = readFileSync(file, "utf-8");
1201
+ let rows = parseCsv(raw, options.delimiter);
1202
+ // 2. Apply column map.
1203
+ rows = applyColumnMap(rows, columnMap);
1204
+ // 3. Coerce types, assign ids, shape into batch items.
1205
+ const { batch, rowCount } = buildRows(rows, {
1206
+ modelName: options.model,
1207
+ types,
1208
+ idColumn: options.idColumn,
1209
+ });
1210
+ const chunks = chunk(batch, batchSize);
1211
+ // 4. Validate the chosen op is an import-compatible save-like mutation
1212
+ // BEFORE writing (op-not-found / incompatible → clear up-front error
1213
+ // rather than per-row server-side failure). Skip the round-trip only
1214
+ // when there is genuinely nothing to import in a dry run.
1215
+ if (!options.dryRun || rowCount > 0) {
1216
+ await validateImportOperation(client, resolvedAppId, databaseId, options.operation);
1217
+ }
1218
+ // 5. Empty / headers-only CSV: no request, report 0/0.
1219
+ if (rowCount === 0) {
1220
+ if (options.json) {
1221
+ json({ imported: 0, failed: 0, rows: 0, batches: 0, errors: [], dryRun: !!options.dryRun });
1222
+ }
1223
+ else {
1224
+ info("No data rows found in CSV — nothing to import.");
1225
+ success("Imported: 0, Failed: 0");
1226
+ }
1227
+ return;
1228
+ }
1229
+ // 6. Dry run: report counts, no writes.
1230
+ if (options.dryRun) {
1231
+ if (options.json) {
1232
+ json({ dryRun: true, rows: rowCount, batches: chunks.length, batchSize });
1233
+ }
1234
+ else {
1235
+ info(`Dry run — no records written.`);
1236
+ keyValue(" Rows", String(rowCount));
1237
+ keyValue(" Batches", String(chunks.length));
1238
+ keyValue(" Batch size", String(batchSize));
1239
+ }
1240
+ return;
1241
+ }
1242
+ // 7. Import chunk by chunk; accumulate imported/failed + per-chunk errors.
1243
+ let imported = 0;
1244
+ let failed = 0;
1245
+ const errors = [];
1246
+ let aborted = false;
1247
+ for (let i = 0; i < chunks.length; i++) {
1248
+ const piece = chunks[i];
1249
+ try {
1250
+ const res = await client.executeBatch(resolvedAppId, databaseId, options.operation, piece);
1251
+ imported += res.imported ?? 0;
1252
+ failed += res.failed ?? 0;
1253
+ if (!options.json) {
1254
+ info(`Batch ${i + 1}/${chunks.length}: imported ${res.imported ?? 0}, failed ${res.failed ?? 0}`);
1255
+ }
1256
+ }
1257
+ catch (err) {
1258
+ const msg = err?.message || String(err);
1259
+ errors.push({ batch: i, error: msg });
1260
+ failed += piece.length;
1261
+ if (!options.json) {
1262
+ warn(`Batch ${i + 1}/${chunks.length} failed: ${msg}`);
1263
+ }
1264
+ if (options.stopOnError) {
1265
+ aborted = true;
1266
+ break;
1267
+ }
1268
+ }
1269
+ }
1270
+ if (options.json) {
1271
+ json({ imported, failed, rows: rowCount, batches: chunks.length, errors, aborted });
1272
+ }
1273
+ else {
1274
+ if (aborted) {
1275
+ warn(`Aborted after a chunk error (--stop-on-error).`);
1276
+ }
1277
+ success(`Imported: ${imported}, Failed: ${failed}`);
1278
+ if (errors.length > 0) {
1279
+ error(`${errors.length} batch(es) errored:`);
1280
+ for (const e of errors) {
1281
+ error(` Batch ${e.batch + 1}: ${e.error}`);
1282
+ }
1283
+ }
1284
+ }
1285
+ // Non-zero exit if any chunk errored.
1286
+ if (errors.length > 0) {
1287
+ process.exit(1);
1288
+ }
1289
+ }
1290
+ catch (err) {
1291
+ error(err.message);
1292
+ process.exit(1);
1293
+ }
1294
+ });
1295
+ // ============================================
1296
+ // Schema commands (issue #666)
1297
+ // ============================================
1298
+ const schema = databases
1299
+ .command("schema")
1300
+ .description("Manage the TOML schema attached to a database type");
1301
+ schema
1302
+ .command("generate")
1303
+ .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.")
1304
+ .argument("<database-type>", "Database type name")
1305
+ .option("--app <app-id>", "App ID")
1306
+ .option("--sync-dir <path>", "Override path to the sync directory (defaults to ./.primitive/sync/<env>/<appId>/)")
1307
+ .option("-f, --force", "Overwrite an existing [models.*] block in the local file")
1308
+ .option("--json", "Output the generated TOML as JSON")
1309
+ .action(async (databaseType, options) => {
1310
+ const resolvedAppId = resolveAppId(undefined, options);
1311
+ const client = new ApiClient();
1312
+ try {
1313
+ // 1. Call the server's :scaffold endpoint.
1314
+ const result = await client.scaffoldDatabaseTypeSchema(resolvedAppId, databaseType);
1315
+ const scaffoldedToml = result?.schema ?? "";
1316
+ if (options.json) {
1317
+ json({ schema: scaffoldedToml });
1318
+ return;
1319
+ }
1320
+ // 2. Locate the local `database-types/<type>.toml` file. Per the
1321
+ // plan-of-record CLI sync layout, the file lives at
1322
+ // `<sync-dir>/database-types/<type>.toml`. We don't need to know the
1323
+ // exact sync dir for the happy path — just look for an existing
1324
+ // file relative to the CWD's `.primitive/sync/` directory tree.
1325
+ const candidateFiles = [];
1326
+ if (options.syncDir) {
1327
+ candidateFiles.push(path.join(options.syncDir, "database-types", `${databaseType}.toml`));
1328
+ }
1329
+ else {
1330
+ // Search any env/appId subtree under .primitive/sync that matches.
1331
+ const root = path.join(process.cwd(), ".primitive", "sync");
1332
+ if (existsSync(root)) {
1333
+ for (const env of readdirSync(root)) {
1334
+ const envDir = path.join(root, env);
1335
+ try {
1336
+ for (const app of readdirSync(envDir)) {
1337
+ const candidate = path.join(envDir, app, "database-types", `${databaseType}.toml`);
1338
+ if (existsSync(candidate)) {
1339
+ candidateFiles.push(candidate);
1340
+ }
1341
+ }
1342
+ }
1343
+ catch {
1344
+ // skip non-dirs
1345
+ }
1346
+ }
1347
+ }
1348
+ }
1349
+ if (candidateFiles.length === 0) {
1350
+ // No local file found — print the scaffold to stdout and tell
1351
+ // the user where to put it.
1352
+ info(`No local database-types/${databaseType}.toml found under .primitive/sync/. Generated TOML:`);
1353
+ console.log("");
1354
+ console.log(scaffoldedToml);
1355
+ return;
1356
+ }
1357
+ // 3. For each candidate, splice the scaffolded TOML at a stable
1358
+ // anchor (before the first `[[operations]]` block, or at EOF). Per
1359
+ // the plan-of-record, textual insertion is deliberate — `@iarna/toml`
1360
+ // parse/stringify drops comments, so we splice as a raw string.
1361
+ for (const targetPath of candidateFiles) {
1362
+ const original = readFileSync(targetPath, "utf-8");
1363
+ // Detect existing [models.*] block. If present, refuse unless --force.
1364
+ const hasModelsBlock = /\n\[models\./.test("\n" + original);
1365
+ if (hasModelsBlock && !options.force) {
1366
+ error(`${targetPath} already has [models.*] blocks. Re-run with --force to overwrite.`);
1367
+ process.exit(1);
1368
+ }
1369
+ // Strip existing [models.*] blocks if --force.
1370
+ let cleaned = original;
1371
+ if (hasModelsBlock && options.force) {
1372
+ cleaned = stripModelsBlocks(original);
1373
+ }
1374
+ // Splice: insert before the first [[operations]] block, otherwise
1375
+ // at the end of file.
1376
+ let merged;
1377
+ const opsAnchor = cleaned.indexOf("\n[[operations]]");
1378
+ if (opsAnchor >= 0) {
1379
+ merged =
1380
+ cleaned.slice(0, opsAnchor + 1) +
1381
+ scaffoldedToml +
1382
+ "\n" +
1383
+ cleaned.slice(opsAnchor + 1);
1384
+ }
1385
+ else {
1386
+ const sep = cleaned.endsWith("\n") ? "" : "\n";
1387
+ merged = cleaned + sep + scaffoldedToml + "\n";
1388
+ }
1389
+ writeFileSync(targetPath, merged);
1390
+ success(`Inserted schema into ${targetPath}`);
1391
+ }
1392
+ info("Review the generated schema, fix types the generator guessed wrong, then run `primitive sync push` (or `primitive sync push --dry-run`).");
1393
+ }
1394
+ catch (err) {
1395
+ error(err.message);
1396
+ process.exit(1);
1397
+ }
1398
+ });
1399
+ // ============================================
1400
+ // Codegen command (issue #814)
1401
+ // ============================================
1402
+ databases
1403
+ .command("codegen")
1404
+ .description("Generate TypeScript record + operation types from the local database-types/*.toml schema. Emits a <type>.generated.ts per database type (record interfaces, per-op input-params interfaces, and per-op result aliases).")
1405
+ .argument("[database-type]", "Generate for a single database type (defaults to every database-types/*.toml found)")
1406
+ .option("--app <app-id>", "App ID")
1407
+ .option("--sync-dir <path>", "Override path to the sync directory (defaults to ./.primitive/sync/<env>/<appId>/)")
1408
+ .option("-o, --output <dir>", "Output directory for *.generated.ts files (defaults to <sync-dir>/database-types/generated/)")
1409
+ .option("--check", "Exit non-zero if generated output is out of date (CI guard); does not write.")
1410
+ .option("--json", "Output the result summary as JSON")
1411
+ .action(async (databaseType, options) => {
1412
+ try {
1413
+ // 1. Locate the database-types directory. Mirrors `schema generate`'s
1414
+ // sync-dir resolution: honor --sync-dir, else search any
1415
+ // env/appId subtree under ./.primitive/sync that has a
1416
+ // database-types/ directory.
1417
+ const dbTypesDirs = [];
1418
+ if (options.syncDir) {
1419
+ dbTypesDirs.push(path.join(options.syncDir, "database-types"));
1420
+ }
1421
+ else {
1422
+ const root = path.join(process.cwd(), ".primitive", "sync");
1423
+ if (existsSync(root)) {
1424
+ for (const env of readdirSync(root)) {
1425
+ const envDir = path.join(root, env);
1426
+ try {
1427
+ for (const app of readdirSync(envDir)) {
1428
+ const candidate = path.join(envDir, app, "database-types");
1429
+ if (existsSync(candidate)) {
1430
+ dbTypesDirs.push(candidate);
1431
+ }
1432
+ }
1433
+ }
1434
+ catch {
1435
+ // skip non-dirs
1436
+ }
1437
+ }
1438
+ }
1439
+ }
1440
+ if (dbTypesDirs.length === 0) {
1441
+ error("No database-types/ directory found. Pass --sync-dir or run `primitive sync pull` first.");
1442
+ process.exit(1);
1443
+ }
1444
+ // 2. Collect the source .toml files (one per database type), filtered
1445
+ // to a single type when an argument is given.
1446
+ const inputs = [];
1447
+ const seenTypes = new Set();
1448
+ for (const dir of dbTypesDirs) {
1449
+ for (const fileName of readdirSync(dir)) {
1450
+ if (!fileName.endsWith(".toml"))
1451
+ continue;
1452
+ const typeName = fileName.slice(0, -".toml".length);
1453
+ if (databaseType && typeName !== databaseType)
1454
+ continue;
1455
+ if (seenTypes.has(typeName))
1456
+ continue;
1457
+ seenTypes.add(typeName);
1458
+ const tomlPath = path.join(dir, fileName);
1459
+ inputs.push({
1460
+ databaseType: typeName,
1461
+ tomlPath,
1462
+ tomlContent: readFileSync(tomlPath, "utf-8"),
1463
+ });
1464
+ }
1465
+ }
1466
+ if (inputs.length === 0) {
1467
+ error(databaseType
1468
+ ? `No database-types/${databaseType}.toml found under .primitive/sync/.`
1469
+ : "No database-types/*.toml files found to generate from.");
1470
+ process.exit(1);
1471
+ }
1472
+ // 3. Resolve the output directory. Default: a `generated/` subdir of
1473
+ // the first source dir (keeps generated output beside the schema).
1474
+ const outputDir = options.output
1475
+ ? path.resolve(options.output)
1476
+ : path.join(path.dirname(inputs[0].tomlPath), "generated");
1477
+ // 4. Run codegen (or --check). When a single database-type filter
1478
+ // argument is given, the input set is partial — flag it so stale
1479
+ // cleanup is scoped to that type and sibling types' generated files
1480
+ // are left intact.
1481
+ const codegenResult = await generateDbTypes({
1482
+ inputs,
1483
+ outputDir,
1484
+ check: !!options.check,
1485
+ singleType: !!databaseType,
1486
+ });
1487
+ if (options.check) {
1488
+ if (codegenResult.mismatches.length > 0) {
1489
+ if (options.json) {
1490
+ json({ ok: false, mismatches: codegenResult.mismatches });
1491
+ }
1492
+ else {
1493
+ error(`Check failed: ${codegenResult.mismatches.length} file(s) out of date.`);
1494
+ for (const m of codegenResult.mismatches) {
1495
+ error(` ${m.reason}: ${path.relative(process.cwd(), m.filePath)}`);
1496
+ }
1497
+ info("Run `primitive databases codegen` to regenerate.");
1498
+ }
1499
+ process.exit(1);
1500
+ }
1501
+ if (options.json) {
1502
+ json({ ok: true, checked: codegenResult.writtenFiles.length });
1503
+ }
1504
+ else {
1505
+ success(`Check passed: ${codegenResult.writtenFiles.length} file(s) up to date.`);
1506
+ }
1507
+ return;
1508
+ }
1509
+ if (options.json) {
1510
+ json({
1511
+ written: codegenResult.writtenFiles,
1512
+ deleted: codegenResult.deletedFiles,
1513
+ });
1514
+ return;
1515
+ }
1516
+ success(`Generated ${codegenResult.writtenFiles.length} file(s)` +
1517
+ (codegenResult.deletedFiles.length > 0
1518
+ ? `, deleted ${codegenResult.deletedFiles.length} stale file(s).`
1519
+ : "."));
1520
+ for (const f of codegenResult.writtenFiles) {
1521
+ keyValue(" wrote", path.relative(process.cwd(), f));
1522
+ }
1523
+ for (const f of codegenResult.deletedFiles) {
1524
+ keyValue(" deleted", path.relative(process.cwd(), f));
1525
+ }
1526
+ }
1527
+ catch (err) {
1528
+ error(err.message);
1529
+ process.exit(1);
1530
+ }
1531
+ });
1532
+ }
1533
+ /** Remove every existing `[models.*]` block from a TOML file body. */
1534
+ function stripModelsBlocks(text) {
1535
+ const lines = text.split("\n");
1536
+ const result = [];
1537
+ let skipping = false;
1538
+ for (const line of lines) {
1539
+ const isModelsHeader = /^\s*\[models\b/.test(line);
1540
+ const isOtherHeader = /^\s*\[(?!models\b)/.test(line);
1541
+ if (isModelsHeader) {
1542
+ skipping = true;
1543
+ continue;
1544
+ }
1545
+ if (skipping && isOtherHeader) {
1546
+ skipping = false;
1547
+ }
1548
+ if (!skipping)
1549
+ result.push(line);
1550
+ }
1551
+ return result.join("\n");
1552
+ }
1553
+ // ============================================
1554
+ // Database export/import helpers
1555
+ // ============================================
1556
+ async function exportSingleDatabase(client, appId, databaseId, outputDir, jsonOutput) {
1557
+ const dbDir = path.join(outputDir, "databases", databaseId);
1558
+ mkdirSync(dbDir, { recursive: true });
1559
+ // 1. Fetch database metadata
1560
+ const dbMeta = await client.getDatabase(appId, databaseId);
1561
+ // Warn about databaseType sync
1562
+ if (dbMeta.databaseType) {
1563
+ warn(`This database uses type "${dbMeta.databaseType}". Make sure to \`primitive sync push\` the type config before importing into a new app.`);
1564
+ }
1565
+ // 2. Write metadata
1566
+ const metadata = {
1567
+ databaseId: dbMeta.databaseId || databaseId,
1568
+ title: dbMeta.title,
1569
+ databaseType: dbMeta.databaseType || null,
1570
+ metadata: dbMeta.metadata || null,
1571
+ };
1572
+ writeFileSync(path.join(dbDir, "metadata.json"), JSON.stringify(metadata, null, 2));
1573
+ // 3. Export records: list models, then query each
1574
+ const models = await client.listDatabaseModels(appId, databaseId);
1575
+ let totalRecords = 0;
1576
+ const recordsFile = path.join(dbDir, "records.jsonl");
1577
+ // Clear file
1578
+ writeFileSync(recordsFile, "");
1579
+ for (const modelName of models) {
1580
+ let cursor;
1581
+ let hasMore = true;
1582
+ while (hasMore) {
1583
+ const result = await client.queryDatabaseRecords(appId, databaseId, modelName, {
1584
+ limit: 100,
1585
+ cursor,
1586
+ });
1587
+ const records = result?.data || [];
1588
+ for (const record of records) {
1589
+ const line = JSON.stringify({ _type: modelName, _id: record.id, _data: record });
1590
+ appendFileSync(recordsFile, line + "\n");
1591
+ totalRecords++;
1592
+ }
1593
+ cursor = result?.nextCursor;
1594
+ hasMore = !!cursor && records.length > 0;
1595
+ }
1596
+ }
1597
+ // 4. Export indexes
1598
+ const indexes = await client.listDatabaseIndexes(appId, databaseId);
1599
+ writeFileSync(path.join(dbDir, "indexes.json"), JSON.stringify(indexes, null, 2));
1600
+ // 5. Export unique constraints
1601
+ const constraints = await client.listDatabaseUniqueConstraints(appId, databaseId);
1602
+ writeFileSync(path.join(dbDir, "constraints.json"), JSON.stringify(constraints, null, 2));
1603
+ if (jsonOutput) {
1604
+ json({
1605
+ databaseId,
1606
+ title: metadata.title,
1607
+ databaseType: metadata.databaseType,
1608
+ models: models.length,
1609
+ records: totalRecords,
1610
+ indexes: indexes.length,
1611
+ constraints: constraints.length,
1612
+ exportPath: dbDir,
1613
+ });
1614
+ }
1615
+ else {
1616
+ success(`Exported database ${databaseId} (${metadata.title || "untitled"}) to ${dbDir}`);
1617
+ keyValue(" Models", String(models.length));
1618
+ keyValue(" Records", String(totalRecords));
1619
+ keyValue(" Indexes", String(indexes.length));
1620
+ }
1621
+ }
1622
+ async function importSingleDatabase(client, appId, dbDir, overwrite, dryRun, summary) {
1623
+ const metadataPath = path.join(dbDir, "metadata.json");
1624
+ if (!existsSync(metadataPath)) {
1625
+ warn(`Skipping ${dbDir}: no metadata.json found`);
1626
+ summary.skipped++;
1627
+ return;
1628
+ }
1629
+ const metadata = JSON.parse(readFileSync(metadataPath, "utf-8"));
1630
+ const databaseId = metadata.databaseId;
1631
+ if (dryRun) {
1632
+ info(`[dry-run] Would import database ${databaseId} (${metadata.title || "untitled"})`);
1633
+ summary.created++;
1634
+ return;
1635
+ }
1636
+ // Check if database type exists — warn if not
1637
+ if (metadata.databaseType) {
1638
+ warn(`Database uses type "${metadata.databaseType}". Ensure the type config exists in the target app (use \`primitive sync push\`).`);
1639
+ }
1640
+ // Check if database exists — use the original ID for overwrite scenarios
1641
+ let targetDatabaseId = databaseId;
1642
+ let exists = false;
1643
+ try {
1644
+ await client.getDatabase(appId, databaseId);
1645
+ exists = true;
1646
+ }
1647
+ catch {
1648
+ exists = false;
1649
+ }
1650
+ if (exists && !overwrite) {
1651
+ warn(`Skipping ${databaseId}: already exists (use --overwrite to replace)`);
1652
+ summary.skipped++;
1653
+ return;
1654
+ }
1655
+ // Create database if it doesn't exist
1656
+ // Note: server auto-generates the ID, so we capture it for subsequent operations
1657
+ if (!exists) {
1658
+ const createData = {
1659
+ title: metadata.title || "Imported Database",
1660
+ };
1661
+ if (metadata.databaseType) {
1662
+ createData.databaseType = metadata.databaseType;
1663
+ }
1664
+ if (metadata.metadata) {
1665
+ createData.metadata = metadata.metadata;
1666
+ }
1667
+ const created = await client.createDatabase(appId, createData);
1668
+ targetDatabaseId = created.databaseId || created.id || databaseId;
1669
+ summary.created++;
1670
+ }
1671
+ else {
1672
+ summary.updated++;
1673
+ }
1674
+ // Register indexes
1675
+ const indexesPath = path.join(dbDir, "indexes.json");
1676
+ if (existsSync(indexesPath)) {
1677
+ const indexes = JSON.parse(readFileSync(indexesPath, "utf-8"));
1678
+ for (const idx of indexes) {
1679
+ try {
1680
+ await client.registerDatabaseIndex(appId, targetDatabaseId, {
1681
+ modelName: idx.modelName || idx.model_name,
1682
+ fieldName: idx.fieldName || idx.field_name,
1683
+ fieldType: idx.fieldType || idx.field_type || "string",
1684
+ unique: idx.isUnique || idx.is_unique || false,
1685
+ });
1686
+ summary.indexesRegistered++;
1687
+ }
1688
+ catch {
1689
+ // Index may already exist
1690
+ }
1691
+ }
1692
+ }
1693
+ // Register unique constraints
1694
+ const constraintsPath = path.join(dbDir, "constraints.json");
1695
+ if (existsSync(constraintsPath)) {
1696
+ const constraints = JSON.parse(readFileSync(constraintsPath, "utf-8"));
1697
+ for (const c of constraints) {
1698
+ try {
1699
+ await client.registerDatabaseUniqueConstraint(appId, targetDatabaseId, {
1700
+ modelName: c.modelName || c.model_name,
1701
+ constraintName: c.constraintName || c.constraint_name,
1702
+ fields: c.fields ? (typeof c.fields === "string" ? JSON.parse(c.fields) : c.fields) : [],
1703
+ });
1704
+ }
1705
+ catch {
1706
+ // Constraint may already exist
1707
+ }
1708
+ }
1709
+ }
1710
+ // Import records
1711
+ const recordsPath = path.join(dbDir, "records.jsonl");
1712
+ if (existsSync(recordsPath)) {
1713
+ const lines = readFileSync(recordsPath, "utf-8").split("\n").filter(Boolean);
1714
+ for (const line of lines) {
1715
+ const record = JSON.parse(line);
1716
+ const { _type, _id, _data } = record;
1717
+ // Remove 'id' and 'type' from _data to avoid duplication
1718
+ const { id: _ignoredId, type: _ignoredType, ...data } = _data;
1719
+ await client.saveDatabaseRecord(appId, targetDatabaseId, _type, _id, data);
1720
+ summary.recordsImported++;
1721
+ }
1722
+ }
1723
+ info(`Imported database ${databaseId} (${metadata.title || "untitled"})`);
345
1724
  }
346
1725
  //# sourceMappingURL=databases.js.map