primitive-admin 1.1.0-alpha.3 → 1.1.0-alpha.31

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 (99) hide show
  1. package/README.md +168 -21
  2. package/assets/skill/skills/primitive-platform/SKILL.md +226 -0
  3. package/dist/bin/primitive.js +198 -15
  4. package/dist/bin/primitive.js.map +1 -1
  5. package/dist/src/commands/admins.js +107 -0
  6. package/dist/src/commands/admins.js.map +1 -1
  7. package/dist/src/commands/analytics.js +464 -55
  8. package/dist/src/commands/analytics.js.map +1 -1
  9. package/dist/src/commands/apps.js +27 -13
  10. package/dist/src/commands/apps.js.map +1 -1
  11. package/dist/src/commands/auth.js +165 -5
  12. package/dist/src/commands/auth.js.map +1 -1
  13. package/dist/src/commands/blob-buckets.js +354 -0
  14. package/dist/src/commands/blob-buckets.js.map +1 -0
  15. package/dist/src/commands/catalog.js +17 -17
  16. package/dist/src/commands/catalog.js.map +1 -1
  17. package/dist/src/commands/collection-type-configs.js +178 -0
  18. package/dist/src/commands/collection-type-configs.js.map +1 -0
  19. package/dist/src/commands/collections.js +526 -0
  20. package/dist/src/commands/collections.js.map +1 -0
  21. package/dist/src/commands/comparisons.js +6 -6
  22. package/dist/src/commands/comparisons.js.map +1 -1
  23. package/dist/src/commands/cron-triggers.js +364 -0
  24. package/dist/src/commands/cron-triggers.js.map +1 -0
  25. package/dist/src/commands/database-types.js +462 -0
  26. package/dist/src/commands/database-types.js.map +1 -0
  27. package/dist/src/commands/databases.js +1067 -58
  28. package/dist/src/commands/databases.js.map +1 -1
  29. package/dist/src/commands/documents.js +510 -2
  30. package/dist/src/commands/documents.js.map +1 -1
  31. package/dist/src/commands/email-templates.js +281 -0
  32. package/dist/src/commands/email-templates.js.map +1 -0
  33. package/dist/src/commands/env.js +260 -0
  34. package/dist/src/commands/env.js.map +1 -0
  35. package/dist/src/commands/group-type-configs.js +189 -0
  36. package/dist/src/commands/group-type-configs.js.map +1 -0
  37. package/dist/src/commands/groups.js +28 -25
  38. package/dist/src/commands/groups.js.map +1 -1
  39. package/dist/src/commands/init.js +719 -188
  40. package/dist/src/commands/init.js.map +1 -1
  41. package/dist/src/commands/integrations.js +504 -33
  42. package/dist/src/commands/integrations.js.map +1 -1
  43. package/dist/src/commands/prompts.js +17 -16
  44. package/dist/src/commands/prompts.js.map +1 -1
  45. package/dist/src/commands/rule-sets.js +366 -0
  46. package/dist/src/commands/rule-sets.js.map +1 -0
  47. package/dist/src/commands/secrets.js +108 -0
  48. package/dist/src/commands/secrets.js.map +1 -0
  49. package/dist/src/commands/skill.js +29 -0
  50. package/dist/src/commands/skill.js.map +1 -0
  51. package/dist/src/commands/sync.js +2363 -182
  52. package/dist/src/commands/sync.js.map +1 -1
  53. package/dist/src/commands/tokens.js +9 -9
  54. package/dist/src/commands/tokens.js.map +1 -1
  55. package/dist/src/commands/users.js +426 -4
  56. package/dist/src/commands/users.js.map +1 -1
  57. package/dist/src/commands/webhooks.js +386 -0
  58. package/dist/src/commands/webhooks.js.map +1 -0
  59. package/dist/src/commands/workflows.js +614 -64
  60. package/dist/src/commands/workflows.js.map +1 -1
  61. package/dist/src/lib/api-client.js +942 -83
  62. package/dist/src/lib/api-client.js.map +1 -1
  63. package/dist/src/lib/config.js +51 -53
  64. package/dist/src/lib/config.js.map +1 -1
  65. package/dist/src/lib/constants.js +3 -0
  66. package/dist/src/lib/constants.js.map +1 -0
  67. package/dist/src/lib/credentials-store.js +307 -0
  68. package/dist/src/lib/credentials-store.js.map +1 -0
  69. package/dist/src/lib/env-resolver.js +121 -0
  70. package/dist/src/lib/env-resolver.js.map +1 -0
  71. package/dist/src/lib/init-config.js +87 -0
  72. package/dist/src/lib/init-config.js.map +1 -0
  73. package/dist/src/lib/migration-nag.js +163 -0
  74. package/dist/src/lib/migration-nag.js.map +1 -0
  75. package/dist/src/lib/output.js +58 -6
  76. package/dist/src/lib/output.js.map +1 -1
  77. package/dist/src/lib/paginate.js +42 -0
  78. package/dist/src/lib/paginate.js.map +1 -0
  79. package/dist/src/lib/project-config.js +209 -0
  80. package/dist/src/lib/project-config.js.map +1 -0
  81. package/dist/src/lib/refresh-admin-credentials.js +103 -0
  82. package/dist/src/lib/refresh-admin-credentials.js.map +1 -0
  83. package/dist/src/lib/skill-installer.js +135 -0
  84. package/dist/src/lib/skill-installer.js.map +1 -0
  85. package/dist/src/lib/sync-paths.js +102 -0
  86. package/dist/src/lib/sync-paths.js.map +1 -0
  87. package/dist/src/lib/template.js +279 -18
  88. package/dist/src/lib/template.js.map +1 -1
  89. package/dist/src/lib/toml-database-config.js +384 -0
  90. package/dist/src/lib/toml-database-config.js.map +1 -0
  91. package/dist/src/lib/toml-params-validator.js +183 -0
  92. package/dist/src/lib/toml-params-validator.js.map +1 -0
  93. package/dist/src/lib/version-check.js +172 -0
  94. package/dist/src/lib/version-check.js.map +1 -0
  95. package/dist/src/lib/workflow-fragments.js +121 -0
  96. package/dist/src/lib/workflow-fragments.js.map +1 -0
  97. package/dist/src/lib/workflow-toml-validator.js +328 -0
  98. package/dist/src/lib/workflow-toml-validator.js.map +1 -0
  99. package/package.json +7 -4
@@ -1,6 +1,9 @@
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 { success, error, info, warn, keyValue, result as printResult, formatTable, formatId, formatDate, json, } from "../lib/output.js";
4
7
  function resolveAppId(appId, options) {
5
8
  const resolved = appId || options.app || getCurrentAppId();
6
9
  if (!resolved) {
@@ -18,8 +21,17 @@ Examples:
18
21
  $ primitive databases list
19
22
  $ primitive databases create "My Database"
20
23
  $ primitive databases get <database-id>
24
+ $ primitive databases records models <database-id>
25
+ $ primitive databases records describe <database-id> <model-name>
26
+ $ primitive databases records query <database-id> <model-name> --filter '{"name":"Alice"}'
27
+ $ primitive databases operations list <database-id>
28
+ $ primitive databases operations execute <database-id> <operation-name> --params '{"key":"value"}'
29
+ $ primitive databases indexes list <database-id>
30
+ $ primitive databases indexes create <database-id> --model contacts --field email
31
+ $ primitive databases records delete <database-id> <model-name> <record-id> --yes
32
+ $ primitive databases records delete-all <database-id> <model-name> --yes
33
+ $ primitive databases records delete-all <database-id> <model-name> --filter '{"status":"draft"}' --yes
21
34
  $ primitive databases permissions list <database-id>
22
- $ primitive databases group-permissions list <database-id>
23
35
  `);
24
36
  // List databases
25
37
  databases
@@ -45,6 +57,7 @@ Examples:
45
57
  console.log(formatTable(list, [
46
58
  { header: "ID", key: "databaseId", format: formatId },
47
59
  { header: "TITLE", key: "title" },
60
+ { header: "TYPE", key: "databaseType", format: (v) => v || "—" },
48
61
  { header: "PERMISSION", key: "permission" },
49
62
  { header: "CREATED", key: "createdAt", format: formatDate },
50
63
  ]));
@@ -60,12 +73,29 @@ Examples:
60
73
  .description("Create a new database")
61
74
  .argument("<title>", "Database title")
62
75
  .option("--app <app-id>", "App ID")
76
+ .requiredOption("--type <database-type>", "Database type (required)")
77
+ .option("--cel-context <json>", "Initial CEL context as JSON (referenced from access rules as database.celContext.*)")
78
+ .option("--metadata <json>", "Initial CEL context as JSON (legacy alias for --cel-context)")
63
79
  .option("--json", "Output as JSON")
64
80
  .action(async (title, options) => {
65
81
  const resolvedAppId = resolveAppId(undefined, options);
66
82
  const client = new ApiClient();
67
83
  try {
68
- const result = await client.createDatabase(resolvedAppId, { title });
84
+ const params = { title, databaseType: options.type };
85
+ // `--cel-context` is the new user-facing name; `--metadata` remains
86
+ // supported for backwards compatibility. When both are provided,
87
+ // --cel-context wins.
88
+ const contextSource = options.celContext ?? options.metadata;
89
+ if (contextSource) {
90
+ try {
91
+ params.metadata = JSON.parse(contextSource);
92
+ }
93
+ catch {
94
+ error(`Invalid ${options.celContext ? "--cel-context" : "--metadata"} JSON.`);
95
+ process.exit(1);
96
+ }
97
+ }
98
+ const result = await client.createDatabase(resolvedAppId, params);
69
99
  if (options.json) {
70
100
  json(result);
71
101
  return;
@@ -73,6 +103,9 @@ Examples:
73
103
  success("Database created.");
74
104
  keyValue("Database ID", result.databaseId);
75
105
  keyValue("Title", result.title);
106
+ if (result.databaseType) {
107
+ keyValue("Database Type", result.databaseType);
108
+ }
76
109
  }
77
110
  catch (err) {
78
111
  error(err.message);
@@ -95,10 +128,53 @@ Examples:
95
128
  json(result);
96
129
  return;
97
130
  }
131
+ printResult("Database ID", result.databaseId);
132
+ printResult("Title", result.title);
133
+ printResult("Database Type", result.databaseType || "none");
134
+ printResult("Permission", result.permission || "none");
135
+ printResult("Created", formatDate(result.createdAt));
136
+ }
137
+ catch (err) {
138
+ error(err.message);
139
+ process.exit(1);
140
+ }
141
+ });
142
+ // Update database
143
+ databases
144
+ .command("update")
145
+ .description("Update a database (title, type)")
146
+ .argument("<database-id>", "Database ID")
147
+ .option("--app <app-id>", "App ID")
148
+ .option("--title <title>", "New title")
149
+ .option("--type <database-type>", "Database type (use 'none' to clear)")
150
+ .option("--json", "Output as JSON")
151
+ .action(async (databaseId, options) => {
152
+ const resolvedAppId = resolveAppId(undefined, options);
153
+ const client = new ApiClient();
154
+ const params = {};
155
+ if (options.title) {
156
+ params.title = options.title;
157
+ }
158
+ if (options.type !== undefined) {
159
+ params.databaseType =
160
+ options.type === "none"
161
+ ? null
162
+ : options.type;
163
+ }
164
+ if (Object.keys(params).length === 0) {
165
+ error("Provide at least one of --title or --type.");
166
+ process.exit(1);
167
+ }
168
+ try {
169
+ const result = await client.updateDatabase(resolvedAppId, databaseId, params);
170
+ if (options.json) {
171
+ json(result);
172
+ return;
173
+ }
174
+ success("Database updated.");
98
175
  keyValue("Database ID", result.databaseId);
99
176
  keyValue("Title", result.title);
100
- keyValue("Permission", result.permission);
101
- keyValue("Created", formatDate(result.createdAt));
177
+ keyValue("Database Type", result.databaseType || "none");
102
178
  }
103
179
  catch (err) {
104
180
  error(err.message);
@@ -176,43 +252,260 @@ Examples:
176
252
  process.exit(1);
177
253
  }
178
254
  });
179
- // Grant permission
255
+ // Add manager action handler
256
+ const addManagerAction = async (databaseId, options) => {
257
+ const resolvedAppId = resolveAppId(undefined, options);
258
+ const client = new ApiClient();
259
+ try {
260
+ const result = await client.addDatabaseManager(resolvedAppId, databaseId, options.userId);
261
+ if (options.json) {
262
+ json(result);
263
+ return;
264
+ }
265
+ success(`Manager added: user ${options.userId}.`);
266
+ }
267
+ catch (err) {
268
+ error(err.message);
269
+ process.exit(1);
270
+ }
271
+ };
272
+ // Remove manager action handler
273
+ const removeManagerAction = async (databaseId, userId, options) => {
274
+ const resolvedAppId = resolveAppId(undefined, options);
275
+ if (!options.yes) {
276
+ const inquirer = await import("inquirer");
277
+ const { confirm } = await inquirer.default.prompt([
278
+ {
279
+ type: "confirm",
280
+ name: "confirm",
281
+ message: `Remove manager ${userId} from database ${databaseId}?`,
282
+ default: false,
283
+ },
284
+ ]);
285
+ if (!confirm) {
286
+ info("Cancelled.");
287
+ return;
288
+ }
289
+ }
290
+ const client = new ApiClient();
291
+ try {
292
+ await client.removeDatabaseManager(resolvedAppId, databaseId, userId);
293
+ success(`Manager removed: user ${userId}.`);
294
+ }
295
+ catch (err) {
296
+ error(err.message);
297
+ process.exit(1);
298
+ }
299
+ };
300
+ // Add manager (primary command)
301
+ permissions
302
+ .command("add-manager")
303
+ .description("Add a user as a manager of a database")
304
+ .argument("<database-id>", "Database ID")
305
+ .requiredOption("--user-id <user-id>", "User ID to add as manager")
306
+ .option("--app <app-id>", "App ID")
307
+ .option("--json", "Output as JSON")
308
+ .action(addManagerAction);
309
+ // Remove manager (primary command)
310
+ permissions
311
+ .command("remove-manager")
312
+ .description("Remove a manager from a database")
313
+ .argument("<database-id>", "Database ID")
314
+ .argument("<user-id>", "User ID to remove")
315
+ .option("--app <app-id>", "App ID")
316
+ .option("-y, --yes", "Skip confirmation prompt")
317
+ .action(removeManagerAction);
318
+ // Grant permission (deprecated alias for add-manager)
180
319
  permissions
181
320
  .command("grant")
182
- .description("Grant a user permission on a database")
321
+ .description("(deprecated: use add-manager) Grant a user permission on a database")
183
322
  .argument("<database-id>", "Database ID")
184
323
  .requiredOption("--user-id <user-id>", "User ID to grant permission to")
185
- .requiredOption("--permission <permission>", "Permission level: owner, read-write, or reader")
324
+ .option("--permission <permission>", "Permission level (ignored, always 'manager')")
325
+ .option("--app <app-id>", "App ID")
326
+ .option("--json", "Output as JSON")
327
+ .action(addManagerAction);
328
+ // Revoke permission (deprecated alias for remove-manager)
329
+ permissions
330
+ .command("revoke")
331
+ .description("(deprecated: use remove-manager) Revoke a user's permission on a database")
332
+ .argument("<database-id>", "Database ID")
333
+ .argument("<user-id>", "User ID to revoke")
334
+ .option("--app <app-id>", "App ID")
335
+ .option("-y, --yes", "Skip confirmation prompt")
336
+ .action(removeManagerAction);
337
+ // ---- Records subcommand group ----
338
+ const records = databases
339
+ .command("records")
340
+ .description("Inspect and query records in a database");
341
+ // List models
342
+ records
343
+ .command("models")
344
+ .description("List model names in a database")
345
+ .argument("<database-id>", "Database ID")
186
346
  .option("--app <app-id>", "App ID")
187
347
  .option("--json", "Output as JSON")
188
348
  .action(async (databaseId, options) => {
189
349
  const resolvedAppId = resolveAppId(undefined, options);
190
350
  const client = new ApiClient();
191
351
  try {
192
- const result = await client.grantDatabasePermission(resolvedAppId, databaseId, {
193
- userId: options.userId,
194
- permission: options.permission,
195
- });
352
+ const result = await client.listDatabaseModels(resolvedAppId, databaseId);
353
+ const models = result.models || [];
354
+ if (options.json) {
355
+ json(models);
356
+ return;
357
+ }
358
+ if (models.length === 0) {
359
+ info("No models found.");
360
+ return;
361
+ }
362
+ for (const m of models) {
363
+ console.log(` ${m}`);
364
+ }
365
+ }
366
+ catch (err) {
367
+ error(err.message);
368
+ process.exit(1);
369
+ }
370
+ });
371
+ // Describe model
372
+ records
373
+ .command("describe")
374
+ .description("Show inferred schema for a model")
375
+ .argument("<database-id>", "Database ID")
376
+ .argument("<model-name>", "Model name")
377
+ .option("--app <app-id>", "App ID")
378
+ .option("--json", "Output as JSON")
379
+ .action(async (databaseId, modelName, options) => {
380
+ const resolvedAppId = resolveAppId(undefined, options);
381
+ const client = new ApiClient();
382
+ try {
383
+ const result = await client.describeDatabaseModel(resolvedAppId, databaseId, modelName);
384
+ const fields = result.fields || [];
385
+ if (options.json) {
386
+ json(fields);
387
+ return;
388
+ }
389
+ if (fields.length === 0) {
390
+ info("No fields detected.");
391
+ return;
392
+ }
393
+ console.log(formatTable(fields, [
394
+ { header: "FIELD", key: "field_name" },
395
+ { header: "TYPE", key: "inferred_type" },
396
+ ]));
397
+ }
398
+ catch (err) {
399
+ error(err.message);
400
+ process.exit(1);
401
+ }
402
+ });
403
+ // Query records (direct introspection via admin-data route)
404
+ records
405
+ .command("query")
406
+ .description("Query records in a database model (admin only)")
407
+ .argument("<database-id>", "Database ID")
408
+ .argument("<model-name>", "Model name to query")
409
+ .option("--app <app-id>", "App ID")
410
+ .option("--filter <json>", "Filter as JSON (e.g. '{\"name\":\"Alice\"}')")
411
+ .option("--filter-file <path>", "Read filter from a JSON or TOML file")
412
+ .option("--limit <n>", "Maximum number of records to return", parseInt)
413
+ .option("--cursor <cursor>", "Pagination cursor from previous query")
414
+ .option("--json", "Output as JSON")
415
+ .action(async (databaseId, modelName, options) => {
416
+ const resolvedAppId = resolveAppId(undefined, options);
417
+ const client = new ApiClient();
418
+ if (options.filter && options.filterFile) {
419
+ error("Cannot use both --filter and --filter-file.");
420
+ process.exit(1);
421
+ }
422
+ let filter;
423
+ if (options.filterFile) {
424
+ try {
425
+ const content = readFileSync(options.filterFile, "utf-8");
426
+ if (options.filterFile.endsWith(".toml")) {
427
+ const parsed = TOML.parse(content);
428
+ filter = parsed.filter || parsed;
429
+ }
430
+ else {
431
+ filter = JSON.parse(content);
432
+ }
433
+ }
434
+ catch (err) {
435
+ error(`Failed to read filter file: ${err.message}`);
436
+ process.exit(1);
437
+ }
438
+ }
439
+ else if (options.filter) {
440
+ try {
441
+ filter = JSON.parse(options.filter);
442
+ }
443
+ catch {
444
+ error("Invalid --filter JSON.");
445
+ process.exit(1);
446
+ }
447
+ }
448
+ try {
449
+ const result = await client.queryDatabaseRecords(resolvedAppId, databaseId, modelName, { filter, limit: options.limit, cursor: options.cursor });
196
450
  if (options.json) {
197
451
  json(result);
198
452
  return;
199
453
  }
200
- success(`Permission '${options.permission}' granted to user ${options.userId}.`);
454
+ const records = result.data || [];
455
+ if (records.length === 0) {
456
+ info("No records found.");
457
+ return;
458
+ }
459
+ // Auto-detect columns from record keys
460
+ const allKeys = new Set();
461
+ for (const rec of records) {
462
+ for (const key of Object.keys(rec)) {
463
+ allKeys.add(key);
464
+ }
465
+ }
466
+ // Put id first, then type, then the rest sorted
467
+ const orderedKeys = [];
468
+ if (allKeys.has("id")) {
469
+ orderedKeys.push("id");
470
+ allKeys.delete("id");
471
+ }
472
+ if (allKeys.has("type")) {
473
+ orderedKeys.push("type");
474
+ allKeys.delete("type");
475
+ }
476
+ orderedKeys.push(...[...allKeys].sort());
477
+ const columns = orderedKeys.map((key) => ({
478
+ header: key.toUpperCase(),
479
+ key,
480
+ format: (v) => {
481
+ if (v === null || v === undefined)
482
+ return "—";
483
+ if (typeof v === "object")
484
+ return JSON.stringify(v);
485
+ return String(v);
486
+ },
487
+ }));
488
+ console.log(formatTable(records, columns));
489
+ if (result.nextCursor) {
490
+ info(`More results available. Use --cursor ${result.nextCursor}`);
491
+ }
201
492
  }
202
493
  catch (err) {
203
494
  error(err.message);
204
495
  process.exit(1);
205
496
  }
206
497
  });
207
- // Revoke permission
208
- permissions
209
- .command("revoke")
210
- .description("Revoke a user's permission on a database")
498
+ // Delete a single record
499
+ records
500
+ .command("delete")
501
+ .description("Delete a single record from a database model (admin only)")
211
502
  .argument("<database-id>", "Database ID")
212
- .argument("<user-id>", "User ID to revoke")
503
+ .argument("<model-name>", "Model name")
504
+ .argument("<record-id>", "Record ID to delete")
213
505
  .option("--app <app-id>", "App ID")
214
506
  .option("-y, --yes", "Skip confirmation prompt")
215
- .action(async (databaseId, userId, options) => {
507
+ .option("--json", "Output as JSON")
508
+ .action(async (databaseId, modelName, recordId, options) => {
216
509
  const resolvedAppId = resolveAppId(undefined, options);
217
510
  if (!options.yes) {
218
511
  const inquirer = await import("inquirer");
@@ -220,7 +513,7 @@ Examples:
220
513
  {
221
514
  type: "confirm",
222
515
  name: "confirm",
223
- message: `Revoke permission for user ${userId} on database ${databaseId}?`,
516
+ message: `Delete record ${recordId} from ${modelName}? This cannot be undone.`,
224
517
  default: false,
225
518
  },
226
519
  ]);
@@ -231,44 +524,344 @@ Examples:
231
524
  }
232
525
  const client = new ApiClient();
233
526
  try {
234
- await client.revokeDatabasePermission(resolvedAppId, databaseId, userId);
235
- success(`Permission revoked for user ${userId}.`);
527
+ const result = await client.deleteDatabaseRecord(resolvedAppId, databaseId, modelName, recordId);
528
+ if (options.json) {
529
+ json(result);
530
+ return;
531
+ }
532
+ success(`Record ${recordId} deleted from ${modelName}.`);
533
+ }
534
+ catch (err) {
535
+ error(err.message);
536
+ process.exit(1);
537
+ }
538
+ });
539
+ // Delete all records for a model
540
+ records
541
+ .command("delete-all")
542
+ .description("Delete all records for a model (queries then batch deletes, admin only)")
543
+ .argument("<database-id>", "Database ID")
544
+ .argument("<model-name>", "Model name")
545
+ .option("--app <app-id>", "App ID")
546
+ .option("--filter <json>", "Filter as JSON to select which records to delete")
547
+ .option("-y, --yes", "Skip confirmation prompt")
548
+ .option("--json", "Output as JSON")
549
+ .action(async (databaseId, modelName, options) => {
550
+ const resolvedAppId = resolveAppId(undefined, options);
551
+ const client = new ApiClient();
552
+ let filter;
553
+ if (options.filter) {
554
+ try {
555
+ filter = JSON.parse(options.filter);
556
+ }
557
+ catch {
558
+ error("Invalid --filter JSON.");
559
+ process.exit(1);
560
+ }
561
+ }
562
+ try {
563
+ // First, query to find all matching records
564
+ let allRecords = [];
565
+ let cursor;
566
+ let hasMore = true;
567
+ while (hasMore) {
568
+ const result = await client.queryDatabaseRecords(resolvedAppId, databaseId, modelName, { filter, limit: 100, cursor });
569
+ const records = result?.data || [];
570
+ allRecords = allRecords.concat(records);
571
+ cursor = result?.nextCursor;
572
+ hasMore = !!cursor && records.length > 0;
573
+ }
574
+ if (allRecords.length === 0) {
575
+ if (options.json) {
576
+ json({ deleted: 0, modelName });
577
+ return;
578
+ }
579
+ info("No matching records found.");
580
+ return;
581
+ }
582
+ if (!options.yes) {
583
+ const inquirer = await import("inquirer");
584
+ const { confirm } = await inquirer.default.prompt([
585
+ {
586
+ type: "confirm",
587
+ name: "confirm",
588
+ message: `Delete ${allRecords.length} record(s) from ${modelName}? This cannot be undone.`,
589
+ default: false,
590
+ },
591
+ ]);
592
+ if (!confirm) {
593
+ info("Cancelled.");
594
+ return;
595
+ }
596
+ }
597
+ // Batch delete in chunks of 50
598
+ const BATCH_SIZE = 50;
599
+ let deleted = 0;
600
+ for (let i = 0; i < allRecords.length; i += BATCH_SIZE) {
601
+ const chunk = allRecords.slice(i, i + BATCH_SIZE);
602
+ const operations = chunk.map((r) => ({
603
+ op: "delete",
604
+ modelName,
605
+ id: r.id,
606
+ }));
607
+ await client.batchDeleteDatabaseRecords(resolvedAppId, databaseId, operations);
608
+ deleted += chunk.length;
609
+ }
610
+ if (options.json) {
611
+ json({ deleted, modelName });
612
+ return;
613
+ }
614
+ success(`Deleted ${deleted} record(s) from ${modelName}.`);
615
+ }
616
+ catch (err) {
617
+ error(err.message);
618
+ process.exit(1);
619
+ }
620
+ });
621
+ // ---- CEL context subcommand group ----
622
+ // `cel-context` is the user-facing name for the dict that feeds CEL
623
+ // access rules and filter placeholders. `metadata` is kept as a hidden
624
+ // alias for backwards compatibility with existing scripts.
625
+ const registerCelContextGroup = (name, hidden) => {
626
+ const group = databases
627
+ .command(name, { hidden })
628
+ .description(hidden
629
+ ? "Manage database CEL context (alias of 'cel-context')"
630
+ : "Manage database CEL context (the dict referenced by access rules and filters)");
631
+ group
632
+ .command("get")
633
+ .description("Read database CEL context")
634
+ .argument("<database-id>", "Database ID")
635
+ .option("--app <app-id>", "App ID")
636
+ .option("--json", "Output as JSON")
637
+ .action(async (databaseId, options) => {
638
+ const resolvedAppId = resolveAppId(undefined, options);
639
+ const client = new ApiClient();
640
+ try {
641
+ const result = await client.getDatabaseCelContext(resolvedAppId, databaseId);
642
+ if (options.json) {
643
+ json(result);
644
+ return;
645
+ }
646
+ printResult("Database ID", result.databaseId);
647
+ const ctx = result.celContext ?? result.metadata;
648
+ if (ctx && Object.keys(ctx).length > 0) {
649
+ printResult("CEL Context", JSON.stringify(ctx, null, 2));
650
+ }
651
+ else {
652
+ info("No CEL context set.");
653
+ }
654
+ }
655
+ catch (err) {
656
+ error(err.message);
657
+ process.exit(1);
658
+ }
659
+ });
660
+ group
661
+ .command("update")
662
+ .description("Update database CEL context (merge with existing)")
663
+ .argument("<database-id>", "Database ID")
664
+ .requiredOption("--data <json>", "CEL context fields as JSON (merged with existing)")
665
+ .option("--app <app-id>", "App ID")
666
+ .option("--json", "Output as JSON")
667
+ .action(async (databaseId, options) => {
668
+ const resolvedAppId = resolveAppId(undefined, options);
669
+ const client = new ApiClient();
670
+ let data;
671
+ try {
672
+ data = JSON.parse(options.data);
673
+ }
674
+ catch {
675
+ error("Invalid --data JSON.");
676
+ process.exit(1);
677
+ return;
678
+ }
679
+ try {
680
+ const result = await client.updateDatabaseCelContext(resolvedAppId, databaseId, data);
681
+ if (options.json) {
682
+ json(result);
683
+ return;
684
+ }
685
+ success("CEL context updated.");
686
+ const ctx = result.celContext ?? result.metadata;
687
+ if (ctx) {
688
+ keyValue("CEL Context", JSON.stringify(ctx));
689
+ }
690
+ }
691
+ catch (err) {
692
+ error(err.message);
693
+ process.exit(1);
694
+ }
695
+ });
696
+ };
697
+ registerCelContextGroup("cel-context", false);
698
+ // Hidden legacy alias — `primitive databases metadata get/update` still works.
699
+ registerCelContextGroup("metadata", true);
700
+ // ---- Operations subcommand group ----
701
+ const operations = databases
702
+ .command("operations")
703
+ .description("List and execute registered operations on a database");
704
+ // List operations
705
+ operations
706
+ .command("list")
707
+ .description("List registered operations available on a database")
708
+ .argument("<database-id>", "Database ID")
709
+ .option("--app <app-id>", "App ID")
710
+ .option("--json", "Output as JSON")
711
+ .action(async (databaseId, options) => {
712
+ const resolvedAppId = resolveAppId(undefined, options);
713
+ const client = new ApiClient();
714
+ try {
715
+ const result = await client.listDatabaseOperations(resolvedAppId, databaseId);
716
+ const list = Array.isArray(result) ? result : [];
717
+ if (options.json) {
718
+ json(list);
719
+ return;
720
+ }
721
+ if (list.length === 0) {
722
+ info("No operations found.");
723
+ return;
724
+ }
725
+ console.log(formatTable(list, [
726
+ { header: "NAME", key: "name" },
727
+ { header: "TYPE", key: "type" },
728
+ { header: "MODEL", key: "modelName" },
729
+ { header: "ACCESS", key: "access" },
730
+ ]));
731
+ }
732
+ catch (err) {
733
+ error(err.message);
734
+ process.exit(1);
735
+ }
736
+ });
737
+ // Execute operation
738
+ operations
739
+ .command("execute")
740
+ .description("Execute a registered operation on a database")
741
+ .argument("<database-id>", "Database ID")
742
+ .argument("<operation-name>", "Operation name")
743
+ .option("--params <json>", "Operation parameters as JSON")
744
+ .option("--limit <n>", "Max records to return (for queries)")
745
+ .option("--cursor <cursor>", "Pagination cursor")
746
+ .option("--token <jwt>", "Execute as a specific user (dev/test — JWT visible in process args)")
747
+ .option("--app <app-id>", "App ID")
748
+ .option("--json", "Output as JSON")
749
+ .option("--timing", "Show server-side timing breakdown")
750
+ .action(async (databaseId, operationName, options) => {
751
+ const resolvedAppId = resolveAppId(undefined, options);
752
+ const client = new ApiClient();
753
+ let params;
754
+ if (options.params) {
755
+ try {
756
+ params = JSON.parse(options.params);
757
+ }
758
+ catch {
759
+ error("Invalid --params JSON.");
760
+ process.exit(1);
761
+ return;
762
+ }
763
+ }
764
+ const body = {};
765
+ if (params)
766
+ body.params = params;
767
+ if (options.limit)
768
+ body.limit = parseInt(options.limit, 10);
769
+ if (options.cursor)
770
+ body.cursor = options.cursor;
771
+ try {
772
+ const result = await client.executeDatabaseOperation(resolvedAppId, databaseId, operationName, body, options.token, { timing: !!options.timing });
773
+ if (options.json) {
774
+ json(result);
775
+ return;
776
+ }
777
+ // Smart display based on result shape
778
+ if (result.data && Array.isArray(result.data)) {
779
+ if (result.data.length === 0) {
780
+ info("No records found.");
781
+ }
782
+ else {
783
+ const cols = Object.keys(result.data[0]).filter((k) => k !== "type");
784
+ console.log(formatTable(result.data, cols.map((c) => ({
785
+ header: c.toUpperCase(),
786
+ key: c,
787
+ format: (v) => {
788
+ if (v === null || v === undefined)
789
+ return "—";
790
+ if (typeof v === "object") {
791
+ const s = JSON.stringify(v);
792
+ return s.length > 50 ? s.slice(0, 47) + "..." : s;
793
+ }
794
+ const s = String(v);
795
+ return s.length > 50 ? s.slice(0, 47) + "..." : s;
796
+ },
797
+ }))));
798
+ }
799
+ if (result.hasMore) {
800
+ info(`More records available. Use --cursor ${result.cursor} to continue.`);
801
+ }
802
+ }
803
+ else if (result.count !== undefined) {
804
+ console.log(`Count: ${result.count}`);
805
+ }
806
+ else if (result.result !== undefined) {
807
+ // Aggregate result
808
+ json(result);
809
+ }
810
+ else if (result.results && Array.isArray(result.results)) {
811
+ // Batch/mutation results
812
+ success(`Operation executed (${result.results.length} result${result.results.length === 1 ? "" : "s"}).`);
813
+ }
814
+ else if (result.success !== undefined) {
815
+ success("Operation executed.");
816
+ }
817
+ else {
818
+ json(result);
819
+ }
820
+ // Display timing if present
821
+ if (result._timing) {
822
+ console.log("");
823
+ console.log("Timing:");
824
+ for (const [key, value] of Object.entries(result._timing)) {
825
+ console.log(` ${key}: ${value}ms`);
826
+ }
827
+ }
236
828
  }
237
829
  catch (err) {
238
830
  error(err.message);
239
831
  process.exit(1);
240
832
  }
241
833
  });
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
834
+ // ---- Indexes subcommand group ----
835
+ const indexes = databases
836
+ .command("indexes")
837
+ .description("Manage indexes on a database");
838
+ // List indexes
839
+ indexes
248
840
  .command("list")
249
- .description("List group permissions for a database")
841
+ .description("List indexes for a database or model")
250
842
  .argument("<database-id>", "Database ID")
843
+ .option("--model <model-name>", "Filter by model name")
251
844
  .option("--app <app-id>", "App ID")
252
845
  .option("--json", "Output as JSON")
253
846
  .action(async (databaseId, options) => {
254
847
  const resolvedAppId = resolveAppId(undefined, options);
255
848
  const client = new ApiClient();
256
849
  try {
257
- const result = await client.listDatabaseGroupPermissions(resolvedAppId, databaseId);
258
- const list = Array.isArray(result) ? result : result?.permissions ?? [];
850
+ const result = await client.listDatabaseIndexes(resolvedAppId, databaseId, options.model);
851
+ const list = result.indexes || [];
259
852
  if (options.json) {
260
853
  json(list);
261
854
  return;
262
855
  }
263
856
  if (list.length === 0) {
264
- info("No group permissions found.");
857
+ info("No indexes found.");
265
858
  return;
266
859
  }
267
860
  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 },
861
+ { header: "MODEL", key: "model_name" },
862
+ { header: "FIELD", key: "field_name" },
863
+ { header: "TYPE", key: "field_type" },
864
+ { header: "UNIQUE", key: "is_unique", format: (v) => (v ? "Yes" : "No") },
272
865
  ]));
273
866
  }
274
867
  catch (err) {
@@ -276,46 +869,48 @@ Examples:
276
869
  process.exit(1);
277
870
  }
278
871
  });
279
- // Grant group permission
280
- groupPermissions
281
- .command("grant")
282
- .description("Grant a group permission on a database")
872
+ // Register index
873
+ indexes
874
+ .command("create")
875
+ .description("Create an index on a model field")
283
876
  .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")
877
+ .requiredOption("--model <model-name>", "Model name")
878
+ .requiredOption("--field <field-name>", "Field name to index")
879
+ .option("--type <field-type>", "Field type: string, number, or boolean", "string")
880
+ .option("--unique", "Make this a unique index")
287
881
  .option("--app <app-id>", "App ID")
288
882
  .option("--json", "Output as JSON")
289
883
  .action(async (databaseId, options) => {
290
884
  const resolvedAppId = resolveAppId(undefined, options);
291
885
  const client = new ApiClient();
292
886
  try {
293
- const result = await client.grantDatabaseGroupPermission(resolvedAppId, databaseId, {
294
- groupType: options.groupType,
295
- groupId: options.groupId,
296
- permission: options.permission,
887
+ const result = await client.registerDatabaseIndex(resolvedAppId, databaseId, {
888
+ modelName: options.model,
889
+ fieldName: options.field,
890
+ fieldType: options.type,
891
+ unique: !!options.unique,
297
892
  });
298
893
  if (options.json) {
299
894
  json(result);
300
895
  return;
301
896
  }
302
- success(`Permission '${options.permission}' granted to group ${options.groupType}/${options.groupId}.`);
897
+ success(`Index created on ${options.model}.${options.field} (${options.type}${options.unique ? ", unique" : ""}).`);
303
898
  }
304
899
  catch (err) {
305
900
  error(err.message);
306
901
  process.exit(1);
307
902
  }
308
903
  });
309
- // Revoke group permission
310
- groupPermissions
311
- .command("revoke")
312
- .description("Revoke a group's permission on a database")
904
+ // Drop index
905
+ indexes
906
+ .command("drop")
907
+ .description("Drop an index from a model field")
313
908
  .argument("<database-id>", "Database ID")
314
- .argument("<group-type>", "Group type")
315
- .argument("<group-id>", "Group ID")
909
+ .requiredOption("--model <model-name>", "Model name")
910
+ .requiredOption("--field <field-name>", "Field name")
316
911
  .option("--app <app-id>", "App ID")
317
912
  .option("-y, --yes", "Skip confirmation prompt")
318
- .action(async (databaseId, groupType, groupId, options) => {
913
+ .action(async (databaseId, options) => {
319
914
  const resolvedAppId = resolveAppId(undefined, options);
320
915
  if (!options.yes) {
321
916
  const inquirer = await import("inquirer");
@@ -323,7 +918,7 @@ Examples:
323
918
  {
324
919
  type: "confirm",
325
920
  name: "confirm",
326
- message: `Revoke permission for group ${groupType}/${groupId} on database ${databaseId}?`,
921
+ message: `Drop index on ${options.model}.${options.field}?`,
327
922
  default: false,
328
923
  },
329
924
  ]);
@@ -334,8 +929,230 @@ Examples:
334
929
  }
335
930
  const client = new ApiClient();
336
931
  try {
337
- await client.revokeDatabaseGroupPermission(resolvedAppId, databaseId, groupType, groupId);
338
- success(`Permission revoked for group ${groupType}/${groupId}.`);
932
+ await client.dropDatabaseIndex(resolvedAppId, databaseId, {
933
+ modelName: options.model,
934
+ fieldName: options.field,
935
+ });
936
+ success(`Index dropped on ${options.model}.${options.field}.`);
937
+ }
938
+ catch (err) {
939
+ error(err.message);
940
+ process.exit(1);
941
+ }
942
+ });
943
+ // ---- Export / Import commands ----
944
+ databases
945
+ .command("export")
946
+ .description("Export a database (records, indexes, constraints)")
947
+ .argument("[app-id]", "App ID (uses current app if not specified)")
948
+ .argument("<database-id>", "Database ID to export")
949
+ .option("--app <app-id>", "App ID")
950
+ .option("--output <dir>", "Output directory", "./primitive-export")
951
+ .option("--json", "Output as JSON")
952
+ .action(async (first, second, options) => {
953
+ let resolvedAppId;
954
+ let databaseId;
955
+ if (second && !second.startsWith("-")) {
956
+ resolvedAppId = resolveAppId(first, options);
957
+ databaseId = second;
958
+ }
959
+ else {
960
+ resolvedAppId = resolveAppId(undefined, options);
961
+ databaseId = first;
962
+ }
963
+ if (!databaseId) {
964
+ error("Database ID is required.");
965
+ process.exit(1);
966
+ }
967
+ const client = new ApiClient();
968
+ try {
969
+ await exportSingleDatabase(client, resolvedAppId, databaseId, options.output, options.json);
970
+ }
971
+ catch (err) {
972
+ error(err.message);
973
+ process.exit(1);
974
+ }
975
+ });
976
+ databases
977
+ .command("import")
978
+ .description("Import a database from an export directory")
979
+ .argument("[app-id]", "App ID (uses current app if not specified)")
980
+ .argument("<path>", "Path to database export directory")
981
+ .option("--app <app-id>", "App ID")
982
+ .option("--overwrite", "Replace existing database if ID collides")
983
+ .option("--dry-run", "Show what would be imported without making changes")
984
+ .option("--json", "Output as JSON")
985
+ .action(async (first, second, options) => {
986
+ let resolvedAppId;
987
+ let importPath;
988
+ if (second && !second.startsWith("-")) {
989
+ resolvedAppId = resolveAppId(first, options);
990
+ importPath = second;
991
+ }
992
+ else {
993
+ resolvedAppId = resolveAppId(undefined, options);
994
+ importPath = first;
995
+ }
996
+ if (!importPath) {
997
+ error("Import path is required.");
998
+ process.exit(1);
999
+ }
1000
+ const client = new ApiClient();
1001
+ try {
1002
+ // Determine if this is a manifest-based export or a single database
1003
+ let dbDirs = [];
1004
+ const manifestPath = path.join(importPath, "manifest.json");
1005
+ if (existsSync(path.join(importPath, "metadata.json"))) {
1006
+ // Single database directory
1007
+ dbDirs = [importPath];
1008
+ }
1009
+ else if (existsSync(manifestPath)) {
1010
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
1011
+ for (const dbId of manifest.databases || []) {
1012
+ const dbDir = path.join(importPath, "databases", dbId);
1013
+ if (existsSync(dbDir)) {
1014
+ dbDirs.push(dbDir);
1015
+ }
1016
+ }
1017
+ }
1018
+ else if (existsSync(path.join(importPath, "databases"))) {
1019
+ const entries = readdirSync(path.join(importPath, "databases"), { withFileTypes: true });
1020
+ for (const entry of entries) {
1021
+ if (entry.isDirectory()) {
1022
+ dbDirs.push(path.join(importPath, "databases", entry.name));
1023
+ }
1024
+ }
1025
+ }
1026
+ else {
1027
+ error("No valid database export data found at the specified path.");
1028
+ process.exit(1);
1029
+ }
1030
+ if (dbDirs.length === 0) {
1031
+ info("No databases to import.");
1032
+ return;
1033
+ }
1034
+ const summary = { created: 0, updated: 0, skipped: 0, recordsImported: 0, indexesRegistered: 0 };
1035
+ for (const dbDir of dbDirs) {
1036
+ await importSingleDatabase(client, resolvedAppId, dbDir, options.overwrite || false, options.dryRun || false, summary);
1037
+ }
1038
+ if (options.json) {
1039
+ json(summary);
1040
+ }
1041
+ else {
1042
+ success("Database import complete:");
1043
+ keyValue(" Databases created", String(summary.created));
1044
+ if (summary.updated > 0)
1045
+ keyValue(" Databases updated", String(summary.updated));
1046
+ if (summary.skipped > 0)
1047
+ keyValue(" Databases skipped", String(summary.skipped));
1048
+ keyValue(" Records imported", String(summary.recordsImported));
1049
+ if (summary.indexesRegistered > 0)
1050
+ keyValue(" Indexes registered", String(summary.indexesRegistered));
1051
+ }
1052
+ }
1053
+ catch (err) {
1054
+ error(err.message);
1055
+ process.exit(1);
1056
+ }
1057
+ });
1058
+ // ============================================
1059
+ // Schema commands (issue #666)
1060
+ // ============================================
1061
+ const schema = databases
1062
+ .command("schema")
1063
+ .description("Manage the TOML schema attached to a database type");
1064
+ schema
1065
+ .command("generate")
1066
+ .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.")
1067
+ .argument("<database-type>", "Database type name")
1068
+ .option("--app <app-id>", "App ID")
1069
+ .option("--sync-dir <path>", "Override path to the sync directory (defaults to ./.primitive/sync/<env>/<appId>/)")
1070
+ .option("-f, --force", "Overwrite an existing [models.*] block in the local file")
1071
+ .option("--json", "Output the generated TOML as JSON")
1072
+ .action(async (databaseType, options) => {
1073
+ const resolvedAppId = resolveAppId(undefined, options);
1074
+ const client = new ApiClient();
1075
+ try {
1076
+ // 1. Call the server's :scaffold endpoint.
1077
+ const result = await client.scaffoldDatabaseTypeSchema(resolvedAppId, databaseType);
1078
+ const scaffoldedToml = result?.schema ?? "";
1079
+ if (options.json) {
1080
+ json({ schema: scaffoldedToml });
1081
+ return;
1082
+ }
1083
+ // 2. Locate the local `database-types/<type>.toml` file. Per the
1084
+ // plan-of-record CLI sync layout, the file lives at
1085
+ // `<sync-dir>/database-types/<type>.toml`. We don't need to know the
1086
+ // exact sync dir for the happy path — just look for an existing
1087
+ // file relative to the CWD's `.primitive/sync/` directory tree.
1088
+ const candidateFiles = [];
1089
+ if (options.syncDir) {
1090
+ candidateFiles.push(path.join(options.syncDir, "database-types", `${databaseType}.toml`));
1091
+ }
1092
+ else {
1093
+ // Search any env/appId subtree under .primitive/sync that matches.
1094
+ const root = path.join(process.cwd(), ".primitive", "sync");
1095
+ if (existsSync(root)) {
1096
+ for (const env of readdirSync(root)) {
1097
+ const envDir = path.join(root, env);
1098
+ try {
1099
+ for (const app of readdirSync(envDir)) {
1100
+ const candidate = path.join(envDir, app, "database-types", `${databaseType}.toml`);
1101
+ if (existsSync(candidate)) {
1102
+ candidateFiles.push(candidate);
1103
+ }
1104
+ }
1105
+ }
1106
+ catch {
1107
+ // skip non-dirs
1108
+ }
1109
+ }
1110
+ }
1111
+ }
1112
+ if (candidateFiles.length === 0) {
1113
+ // No local file found — print the scaffold to stdout and tell
1114
+ // the user where to put it.
1115
+ info(`No local database-types/${databaseType}.toml found under .primitive/sync/. Generated TOML:`);
1116
+ console.log("");
1117
+ console.log(scaffoldedToml);
1118
+ return;
1119
+ }
1120
+ // 3. For each candidate, splice the scaffolded TOML at a stable
1121
+ // anchor (before the first `[[operations]]` block, or at EOF). Per
1122
+ // the plan-of-record, textual insertion is deliberate — `@iarna/toml`
1123
+ // parse/stringify drops comments, so we splice as a raw string.
1124
+ for (const targetPath of candidateFiles) {
1125
+ const original = readFileSync(targetPath, "utf-8");
1126
+ // Detect existing [models.*] block. If present, refuse unless --force.
1127
+ const hasModelsBlock = /\n\[models\./.test("\n" + original);
1128
+ if (hasModelsBlock && !options.force) {
1129
+ error(`${targetPath} already has [models.*] blocks. Re-run with --force to overwrite.`);
1130
+ process.exit(1);
1131
+ }
1132
+ // Strip existing [models.*] blocks if --force.
1133
+ let cleaned = original;
1134
+ if (hasModelsBlock && options.force) {
1135
+ cleaned = stripModelsBlocks(original);
1136
+ }
1137
+ // Splice: insert before the first [[operations]] block, otherwise
1138
+ // at the end of file.
1139
+ let merged;
1140
+ const opsAnchor = cleaned.indexOf("\n[[operations]]");
1141
+ if (opsAnchor >= 0) {
1142
+ merged =
1143
+ cleaned.slice(0, opsAnchor + 1) +
1144
+ scaffoldedToml +
1145
+ "\n" +
1146
+ cleaned.slice(opsAnchor + 1);
1147
+ }
1148
+ else {
1149
+ const sep = cleaned.endsWith("\n") ? "" : "\n";
1150
+ merged = cleaned + sep + scaffoldedToml + "\n";
1151
+ }
1152
+ writeFileSync(targetPath, merged);
1153
+ success(`Inserted schema into ${targetPath}`);
1154
+ }
1155
+ info("Review the generated schema, fix types the generator guessed wrong, then run `primitive sync push` (or `primitive sync push --dry-run`).");
339
1156
  }
340
1157
  catch (err) {
341
1158
  error(err.message);
@@ -343,4 +1160,196 @@ Examples:
343
1160
  }
344
1161
  });
345
1162
  }
1163
+ /** Remove every existing `[models.*]` block from a TOML file body. */
1164
+ function stripModelsBlocks(text) {
1165
+ const lines = text.split("\n");
1166
+ const result = [];
1167
+ let skipping = false;
1168
+ for (const line of lines) {
1169
+ const isModelsHeader = /^\s*\[models\b/.test(line);
1170
+ const isOtherHeader = /^\s*\[(?!models\b)/.test(line);
1171
+ if (isModelsHeader) {
1172
+ skipping = true;
1173
+ continue;
1174
+ }
1175
+ if (skipping && isOtherHeader) {
1176
+ skipping = false;
1177
+ }
1178
+ if (!skipping)
1179
+ result.push(line);
1180
+ }
1181
+ return result.join("\n");
1182
+ }
1183
+ // ============================================
1184
+ // Database export/import helpers
1185
+ // ============================================
1186
+ async function exportSingleDatabase(client, appId, databaseId, outputDir, jsonOutput) {
1187
+ const dbDir = path.join(outputDir, "databases", databaseId);
1188
+ mkdirSync(dbDir, { recursive: true });
1189
+ // 1. Fetch database metadata
1190
+ const dbMeta = await client.getDatabase(appId, databaseId);
1191
+ // Warn about databaseType sync
1192
+ if (dbMeta.databaseType) {
1193
+ warn(`This database uses type "${dbMeta.databaseType}". Make sure to \`primitive sync push\` the type config before importing into a new app.`);
1194
+ }
1195
+ // 2. Write metadata
1196
+ const metadata = {
1197
+ databaseId: dbMeta.databaseId || databaseId,
1198
+ title: dbMeta.title,
1199
+ databaseType: dbMeta.databaseType || null,
1200
+ metadata: dbMeta.metadata || null,
1201
+ };
1202
+ writeFileSync(path.join(dbDir, "metadata.json"), JSON.stringify(metadata, null, 2));
1203
+ // 3. Export records: list models, then query each
1204
+ const models = await client.listDatabaseModels(appId, databaseId);
1205
+ let totalRecords = 0;
1206
+ const recordsFile = path.join(dbDir, "records.jsonl");
1207
+ // Clear file
1208
+ writeFileSync(recordsFile, "");
1209
+ for (const modelName of models) {
1210
+ let cursor;
1211
+ let hasMore = true;
1212
+ while (hasMore) {
1213
+ const result = await client.queryDatabaseRecords(appId, databaseId, modelName, {
1214
+ limit: 100,
1215
+ cursor,
1216
+ });
1217
+ const records = result?.data || [];
1218
+ for (const record of records) {
1219
+ const line = JSON.stringify({ _type: modelName, _id: record.id, _data: record });
1220
+ appendFileSync(recordsFile, line + "\n");
1221
+ totalRecords++;
1222
+ }
1223
+ cursor = result?.nextCursor;
1224
+ hasMore = !!cursor && records.length > 0;
1225
+ }
1226
+ }
1227
+ // 4. Export indexes
1228
+ const indexes = await client.listDatabaseIndexes(appId, databaseId);
1229
+ writeFileSync(path.join(dbDir, "indexes.json"), JSON.stringify(indexes, null, 2));
1230
+ // 5. Export unique constraints
1231
+ const constraints = await client.listDatabaseUniqueConstraints(appId, databaseId);
1232
+ writeFileSync(path.join(dbDir, "constraints.json"), JSON.stringify(constraints, null, 2));
1233
+ if (jsonOutput) {
1234
+ json({
1235
+ databaseId,
1236
+ title: metadata.title,
1237
+ databaseType: metadata.databaseType,
1238
+ models: models.length,
1239
+ records: totalRecords,
1240
+ indexes: indexes.length,
1241
+ constraints: constraints.length,
1242
+ exportPath: dbDir,
1243
+ });
1244
+ }
1245
+ else {
1246
+ success(`Exported database ${databaseId} (${metadata.title || "untitled"}) to ${dbDir}`);
1247
+ keyValue(" Models", String(models.length));
1248
+ keyValue(" Records", String(totalRecords));
1249
+ keyValue(" Indexes", String(indexes.length));
1250
+ }
1251
+ }
1252
+ async function importSingleDatabase(client, appId, dbDir, overwrite, dryRun, summary) {
1253
+ const metadataPath = path.join(dbDir, "metadata.json");
1254
+ if (!existsSync(metadataPath)) {
1255
+ warn(`Skipping ${dbDir}: no metadata.json found`);
1256
+ summary.skipped++;
1257
+ return;
1258
+ }
1259
+ const metadata = JSON.parse(readFileSync(metadataPath, "utf-8"));
1260
+ const databaseId = metadata.databaseId;
1261
+ if (dryRun) {
1262
+ info(`[dry-run] Would import database ${databaseId} (${metadata.title || "untitled"})`);
1263
+ summary.created++;
1264
+ return;
1265
+ }
1266
+ // Check if database type exists — warn if not
1267
+ if (metadata.databaseType) {
1268
+ warn(`Database uses type "${metadata.databaseType}". Ensure the type config exists in the target app (use \`primitive sync push\`).`);
1269
+ }
1270
+ // Check if database exists — use the original ID for overwrite scenarios
1271
+ let targetDatabaseId = databaseId;
1272
+ let exists = false;
1273
+ try {
1274
+ await client.getDatabase(appId, databaseId);
1275
+ exists = true;
1276
+ }
1277
+ catch {
1278
+ exists = false;
1279
+ }
1280
+ if (exists && !overwrite) {
1281
+ warn(`Skipping ${databaseId}: already exists (use --overwrite to replace)`);
1282
+ summary.skipped++;
1283
+ return;
1284
+ }
1285
+ // Create database if it doesn't exist
1286
+ // Note: server auto-generates the ID, so we capture it for subsequent operations
1287
+ if (!exists) {
1288
+ const createData = {
1289
+ title: metadata.title || "Imported Database",
1290
+ };
1291
+ if (metadata.databaseType) {
1292
+ createData.databaseType = metadata.databaseType;
1293
+ }
1294
+ if (metadata.metadata) {
1295
+ createData.metadata = metadata.metadata;
1296
+ }
1297
+ const created = await client.createDatabase(appId, createData);
1298
+ targetDatabaseId = created.databaseId || created.id || databaseId;
1299
+ summary.created++;
1300
+ }
1301
+ else {
1302
+ summary.updated++;
1303
+ }
1304
+ // Register indexes
1305
+ const indexesPath = path.join(dbDir, "indexes.json");
1306
+ if (existsSync(indexesPath)) {
1307
+ const indexes = JSON.parse(readFileSync(indexesPath, "utf-8"));
1308
+ for (const idx of indexes) {
1309
+ try {
1310
+ await client.registerDatabaseIndex(appId, targetDatabaseId, {
1311
+ modelName: idx.modelName || idx.model_name,
1312
+ fieldName: idx.fieldName || idx.field_name,
1313
+ fieldType: idx.fieldType || idx.field_type || "string",
1314
+ unique: idx.isUnique || idx.is_unique || false,
1315
+ });
1316
+ summary.indexesRegistered++;
1317
+ }
1318
+ catch {
1319
+ // Index may already exist
1320
+ }
1321
+ }
1322
+ }
1323
+ // Register unique constraints
1324
+ const constraintsPath = path.join(dbDir, "constraints.json");
1325
+ if (existsSync(constraintsPath)) {
1326
+ const constraints = JSON.parse(readFileSync(constraintsPath, "utf-8"));
1327
+ for (const c of constraints) {
1328
+ try {
1329
+ await client.registerDatabaseUniqueConstraint(appId, targetDatabaseId, {
1330
+ modelName: c.modelName || c.model_name,
1331
+ constraintName: c.constraintName || c.constraint_name,
1332
+ fields: c.fields ? (typeof c.fields === "string" ? JSON.parse(c.fields) : c.fields) : [],
1333
+ });
1334
+ }
1335
+ catch {
1336
+ // Constraint may already exist
1337
+ }
1338
+ }
1339
+ }
1340
+ // Import records
1341
+ const recordsPath = path.join(dbDir, "records.jsonl");
1342
+ if (existsSync(recordsPath)) {
1343
+ const lines = readFileSync(recordsPath, "utf-8").split("\n").filter(Boolean);
1344
+ for (const line of lines) {
1345
+ const record = JSON.parse(line);
1346
+ const { _type, _id, _data } = record;
1347
+ // Remove 'id' and 'type' from _data to avoid duplication
1348
+ const { id: _ignoredId, type: _ignoredType, ...data } = _data;
1349
+ await client.saveDatabaseRecord(appId, targetDatabaseId, _type, _id, data);
1350
+ summary.recordsImported++;
1351
+ }
1352
+ }
1353
+ info(`Imported database ${databaseId} (${metadata.title || "untitled"})`);
1354
+ }
346
1355
  //# sourceMappingURL=databases.js.map