primitive-admin 1.1.0-alpha.53 → 1.1.0-alpha.55

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 (88) hide show
  1. package/README.md +24 -10
  2. package/assets/skill/skills/primitive-platform/SKILL.md +134 -0
  3. package/dist/bin/primitive.js +4 -0
  4. package/dist/bin/primitive.js.map +1 -1
  5. package/dist/src/commands/admins.js +22 -8
  6. package/dist/src/commands/admins.js.map +1 -1
  7. package/dist/src/commands/analytics.js +71 -1
  8. package/dist/src/commands/analytics.js.map +1 -1
  9. package/dist/src/commands/apps.js +16 -14
  10. package/dist/src/commands/apps.js.map +1 -1
  11. package/dist/src/commands/blob-buckets.js +37 -2
  12. package/dist/src/commands/blob-buckets.js.map +1 -1
  13. package/dist/src/commands/connections.d.ts +2 -0
  14. package/dist/src/commands/connections.js +95 -0
  15. package/dist/src/commands/connections.js.map +1 -0
  16. package/dist/src/commands/databases.js +280 -45
  17. package/dist/src/commands/databases.js.map +1 -1
  18. package/dist/src/commands/documents.js +615 -1
  19. package/dist/src/commands/documents.js.map +1 -1
  20. package/dist/src/commands/init.js +2 -2
  21. package/dist/src/commands/init.js.map +1 -1
  22. package/dist/src/commands/integrations.js +11 -88
  23. package/dist/src/commands/integrations.js.map +1 -1
  24. package/dist/src/commands/metadata.js +47 -30
  25. package/dist/src/commands/metadata.js.map +1 -1
  26. package/dist/src/commands/prompts.js +14 -5
  27. package/dist/src/commands/prompts.js.map +1 -1
  28. package/dist/src/commands/sessions.d.ts +2 -0
  29. package/dist/src/commands/sessions.js +63 -0
  30. package/dist/src/commands/sessions.js.map +1 -0
  31. package/dist/src/commands/settings.d.ts +1 -2
  32. package/dist/src/commands/settings.js +8 -6
  33. package/dist/src/commands/settings.js.map +1 -1
  34. package/dist/src/commands/skill.js +2 -2
  35. package/dist/src/commands/skill.js.map +1 -1
  36. package/dist/src/commands/sync-app-settings.d.ts +26 -37
  37. package/dist/src/commands/sync-app-settings.js +43 -56
  38. package/dist/src/commands/sync-app-settings.js.map +1 -1
  39. package/dist/src/commands/sync.d.ts +4 -3
  40. package/dist/src/commands/sync.js +245 -30
  41. package/dist/src/commands/sync.js.map +1 -1
  42. package/dist/src/commands/webhooks.js +71 -23
  43. package/dist/src/commands/webhooks.js.map +1 -1
  44. package/dist/src/commands/workflows.js +364 -55
  45. package/dist/src/commands/workflows.js.map +1 -1
  46. package/dist/src/lib/api-client.d.ts +294 -13
  47. package/dist/src/lib/api-client.js +345 -42
  48. package/dist/src/lib/api-client.js.map +1 -1
  49. package/dist/src/lib/app-settings-descriptor.d.ts +15 -13
  50. package/dist/src/lib/app-settings-descriptor.js +25 -23
  51. package/dist/src/lib/app-settings-descriptor.js.map +1 -1
  52. package/dist/src/lib/data-input.d.ts +23 -0
  53. package/dist/src/lib/data-input.js +50 -0
  54. package/dist/src/lib/data-input.js.map +1 -0
  55. package/dist/src/lib/generated-allowlist.js +1 -0
  56. package/dist/src/lib/generated-allowlist.js.map +1 -1
  57. package/dist/src/lib/generated-workflow-model-fields.d.ts +20 -0
  58. package/dist/src/lib/generated-workflow-model-fields.js +54 -0
  59. package/dist/src/lib/generated-workflow-model-fields.js.map +1 -0
  60. package/dist/src/lib/log-inspection.d.ts +550 -0
  61. package/dist/src/lib/log-inspection.js +609 -0
  62. package/dist/src/lib/log-inspection.js.map +1 -0
  63. package/dist/src/lib/output.d.ts +10 -0
  64. package/dist/src/lib/output.js +12 -0
  65. package/dist/src/lib/output.js.map +1 -1
  66. package/dist/src/lib/record-filter.d.ts +16 -0
  67. package/dist/src/lib/record-filter.js +53 -0
  68. package/dist/src/lib/record-filter.js.map +1 -0
  69. package/dist/src/lib/skill-installer.d.ts +4 -2
  70. package/dist/src/lib/skill-installer.js +139 -11
  71. package/dist/src/lib/skill-installer.js.map +1 -1
  72. package/dist/src/lib/watch.d.ts +121 -0
  73. package/dist/src/lib/watch.js +169 -0
  74. package/dist/src/lib/watch.js.map +1 -0
  75. package/dist/src/lib/workflow-field-descriptor.d.ts +92 -0
  76. package/dist/src/lib/workflow-field-descriptor.js +163 -0
  77. package/dist/src/lib/workflow-field-descriptor.js.map +1 -0
  78. package/dist/src/lib/workflow-fragments.d.ts +23 -0
  79. package/dist/src/lib/workflow-fragments.js +229 -8
  80. package/dist/src/lib/workflow-fragments.js.map +1 -1
  81. package/dist/src/lib/workflow-payload.d.ts +1 -1
  82. package/dist/src/lib/workflow-payload.js +17 -0
  83. package/dist/src/lib/workflow-payload.js.map +1 -1
  84. package/dist/src/lib/workflow-toml-validator.d.ts +57 -1
  85. package/dist/src/lib/workflow-toml-validator.js +104 -1
  86. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  87. package/dist/src/types/index.d.ts +21 -16
  88. package/package.json +7 -4
@@ -1,7 +1,9 @@
1
1
  import { ApiClient } from "../lib/api-client.js";
2
2
  import { resolveAppId } from "../lib/config.js";
3
- import { success, error, info, formatTable, formatDate, json, keyValue, warn, } from "../lib/output.js";
3
+ import { success, error, info, formatTable, formatId, formatDate, json, keyValue, warn, } from "../lib/output.js";
4
4
  import { confirmPrompt } from "../lib/confirm-prompt.js";
5
+ import { parseDataOption } from "../lib/data-input.js";
6
+ import { ulid } from "ulid";
5
7
  import * as fs from "fs";
6
8
  import * as path from "path";
7
9
  export function registerDocumentsCommands(program) {
@@ -10,9 +12,621 @@ export function registerDocumentsCommands(program) {
10
12
  .description("Manage documents within an app")
11
13
  .addHelpText("after", `
12
14
  Examples:
15
+ $ primitive documents list --user-id <user-id>
16
+ $ primitive documents get <document-id>
17
+ $ primitive documents permissions list <document-id>
18
+ $ primitive documents permissions grant <document-id> --user-id <user-id> --permission reader
19
+ $ primitive documents permissions revoke <document-id> <user-id>
20
+ $ primitive documents records models <document-id>
21
+ $ primitive documents records describe <document-id> <model-name>
22
+ $ primitive documents records query <document-id> <model-name> --filter '{"status":"open"}'
23
+ $ primitive documents records count <document-id> <model-name>
24
+ $ primitive documents records save <document-id> <model-name> --data '{"qty":10}'
25
+ $ primitive documents records patch <document-id> <model-name> <record-id> --data '{"qty":11}'
26
+ $ primitive documents records delete <document-id> <model-name> <record-id> -y
27
+ $ primitive documents records bulk <document-id> --data-file ops.json -y
28
+ $ primitive documents dump <document-id>
29
+ $ primitive documents stats <document-id>
13
30
  $ primitive documents transfer-owner <document-id> <new-owner-id>
14
31
  $ primitive documents group-permissions list <document-id>
15
32
  `);
33
+ // ---- List a user's documents ----
34
+ // No app-wide enumeration: `--user-id` is required (the admin token has no
35
+ // "self"). Reads the per-user permission partition via the admin endpoint.
36
+ documents
37
+ .command("list")
38
+ .description("List a user's documents")
39
+ .requiredOption("--user-id <id>", "User ID whose documents to list")
40
+ .option("--app <app-id>", "App ID")
41
+ .option("--json", "Output as JSON")
42
+ .action(async (options) => {
43
+ const resolvedAppId = resolveAppId(undefined, options);
44
+ const client = new ApiClient();
45
+ try {
46
+ const list = await client.listAdminDocuments(resolvedAppId, options.userId);
47
+ if (options.json) {
48
+ json(list);
49
+ return;
50
+ }
51
+ if (list.length === 0) {
52
+ info("No documents found.");
53
+ return;
54
+ }
55
+ console.log(formatTable(list, [
56
+ { header: "DOCUMENT_ID", key: "documentId", format: formatId },
57
+ { header: "TITLE", key: "title", flex: true },
58
+ { header: "PERMISSION", key: "permission" },
59
+ { header: "GRANTED", key: "grantedAt", format: formatDate },
60
+ ]));
61
+ }
62
+ catch (err) {
63
+ error(err.message);
64
+ process.exit(1);
65
+ }
66
+ });
67
+ // ---- Get a single document's metadata ----
68
+ documents
69
+ .command("get")
70
+ .description("Show a document's metadata and the caller's access to it")
71
+ .argument("<document-id>", "Document ID")
72
+ .option("--app <app-id>", "App ID")
73
+ .option("--json", "Output as JSON")
74
+ .action(async (documentId, options) => {
75
+ const resolvedAppId = resolveAppId(undefined, options);
76
+ const client = new ApiClient();
77
+ try {
78
+ const doc = await client.getDocument(resolvedAppId, documentId);
79
+ if (options.json) {
80
+ json(doc);
81
+ return;
82
+ }
83
+ keyValue("Document ID", doc.documentId);
84
+ keyValue("Title", doc.title);
85
+ keyValue("Created By", doc.createdBy);
86
+ keyValue("Created", formatDate(doc.createdAt));
87
+ keyValue("Modified", formatDate(doc.modifiedAt ?? doc.lastModified));
88
+ // `GET documents/:id` reports the caller's own access, not the
89
+ // document's grant/alias/collection inventory — printing counts for
90
+ // fields the endpoint never returns read as a definitive "0" for
91
+ // documents that demonstrably have them. Use
92
+ // `documents permissions list` for the grant list.
93
+ if (doc.permission)
94
+ keyValue("Your Permission", doc.permission);
95
+ if (doc.accessSource)
96
+ keyValue("Access Source", doc.accessSource);
97
+ if (doc.linkAccess)
98
+ keyValue("Link Access", doc.linkAccess);
99
+ }
100
+ catch (err) {
101
+ error(err.message);
102
+ process.exit(1);
103
+ }
104
+ });
105
+ // ---- User-level permissions subcommand group ----
106
+ const permissions = documents
107
+ .command("permissions")
108
+ .description("Manage user-level permissions on a document");
109
+ // List user-level permissions
110
+ permissions
111
+ .command("list")
112
+ .description("List user-level permissions for a document")
113
+ .argument("<document-id>", "Document ID")
114
+ .option("--app <app-id>", "App ID")
115
+ .option("--json", "Output as JSON")
116
+ .action(async (documentId, options) => {
117
+ const resolvedAppId = resolveAppId(undefined, options);
118
+ const client = new ApiClient();
119
+ try {
120
+ const result = await client.listDocumentPermissions(resolvedAppId, documentId);
121
+ const list = Array.isArray(result) ? result : result?.permissions ?? [];
122
+ if (options.json) {
123
+ json(list);
124
+ return;
125
+ }
126
+ if (list.length === 0) {
127
+ info("No permissions found.");
128
+ return;
129
+ }
130
+ console.log(formatTable(list, [
131
+ { header: "USER_ID", key: "userId", format: formatId },
132
+ { header: "EMAIL", key: "email", flex: true },
133
+ { header: "PERMISSION", key: "permission" },
134
+ { header: "GRANTED", key: "grantedAt", format: formatDate },
135
+ ]));
136
+ }
137
+ catch (err) {
138
+ error(err.message);
139
+ process.exit(1);
140
+ }
141
+ });
142
+ // Grant (or update) a user-level permission
143
+ permissions
144
+ .command("grant")
145
+ .description("Grant or update a user's permission on a document")
146
+ .argument("<document-id>", "Document ID")
147
+ .option("--user-id <id>", "User ID to grant the permission to")
148
+ .option("--email <email>", "Email of the user to grant the permission to")
149
+ .requiredOption("--permission <permission>", "Permission level: reader or read-write")
150
+ .option("--app <app-id>", "App ID")
151
+ .option("--json", "Output as JSON")
152
+ .action(async (documentId, options) => {
153
+ if (!options.userId && !options.email) {
154
+ error("Either --user-id or --email is required.");
155
+ process.exit(1);
156
+ }
157
+ if (options.userId && options.email) {
158
+ error("Provide only one of --user-id or --email, not both.");
159
+ process.exit(1);
160
+ }
161
+ if (!["reader", "read-write"].includes(options.permission)) {
162
+ error("Invalid --permission. Must be 'reader' or 'read-write'.");
163
+ process.exit(1);
164
+ }
165
+ const resolvedAppId = resolveAppId(undefined, options);
166
+ const client = new ApiClient();
167
+ try {
168
+ const entry = options.userId
169
+ ? { userId: options.userId, permission: options.permission }
170
+ : { email: options.email, permission: options.permission };
171
+ const result = await client.grantDocumentPermission(resolvedAppId, documentId, [entry]);
172
+ if (options.json) {
173
+ json(result);
174
+ return;
175
+ }
176
+ const target = options.userId ? options.userId : options.email;
177
+ success(`Permission '${options.permission}' granted to ${target}.`);
178
+ }
179
+ catch (err) {
180
+ error(err.message);
181
+ process.exit(1);
182
+ }
183
+ });
184
+ // Revoke a user-level permission
185
+ permissions
186
+ .command("revoke")
187
+ .description("Revoke a user's permission on a document")
188
+ .argument("<document-id>", "Document ID")
189
+ .argument("[user-id]", "User ID to revoke (or use --email)")
190
+ .option("--email <email>", "Email of the user to revoke (alternative to <user-id>)")
191
+ .option("--app <app-id>", "App ID")
192
+ .option("-y, --yes", "Skip confirmation prompt")
193
+ .action(async (documentId, userId, options) => {
194
+ if (!userId && !options.email) {
195
+ error("Either a <user-id> argument or --email is required.");
196
+ process.exit(1);
197
+ }
198
+ if (userId && options.email) {
199
+ error("Provide only one of <user-id> or --email, not both.");
200
+ process.exit(1);
201
+ }
202
+ const target = userId ?? options.email;
203
+ const resolvedAppId = resolveAppId(undefined, options);
204
+ if (!options.yes) {
205
+ let confirm;
206
+ try {
207
+ confirm = await confirmPrompt(`Revoke permission for ${target} on document ${documentId}?`);
208
+ }
209
+ catch (err) {
210
+ error(err.message);
211
+ process.exit(1);
212
+ }
213
+ if (!confirm) {
214
+ info("Cancelled.");
215
+ return;
216
+ }
217
+ }
218
+ const client = new ApiClient();
219
+ try {
220
+ await client.revokeDocumentPermission(resolvedAppId, documentId, {
221
+ userId,
222
+ email: options.email,
223
+ });
224
+ success(`Permission revoked for ${target}.`);
225
+ }
226
+ catch (err) {
227
+ error(err.message);
228
+ process.exit(1);
229
+ }
230
+ });
231
+ // ---- Records introspection subcommand group ----
232
+ const records = documents
233
+ .command("records")
234
+ .description("Inspect a document's models and records");
235
+ // List model names
236
+ records
237
+ .command("models")
238
+ .description("List model names in a document")
239
+ .argument("<document-id>", "Document ID")
240
+ .option("--app <app-id>", "App ID")
241
+ .option("--json", "Output as JSON")
242
+ .action(async (documentId, options) => {
243
+ const resolvedAppId = resolveAppId(undefined, options);
244
+ const client = new ApiClient();
245
+ try {
246
+ const result = await client.getDocumentSchema(resolvedAppId, documentId);
247
+ const models = result?.modelNames ?? Object.keys(result?.schema?.models ?? {});
248
+ if (options.json) {
249
+ json(models);
250
+ return;
251
+ }
252
+ if (models.length === 0) {
253
+ info("No models found.");
254
+ return;
255
+ }
256
+ for (const m of models) {
257
+ console.log(` ${m}`);
258
+ }
259
+ }
260
+ catch (err) {
261
+ error(err.message);
262
+ process.exit(1);
263
+ }
264
+ });
265
+ // Describe a model's fields and indexes
266
+ records
267
+ .command("describe")
268
+ .description("Show a model's fields and indexes")
269
+ .argument("<document-id>", "Document ID")
270
+ .argument("<model-name>", "Model name")
271
+ .option("--app <app-id>", "App ID")
272
+ .option("--json", "Output as JSON")
273
+ .action(async (documentId, modelName, options) => {
274
+ const resolvedAppId = resolveAppId(undefined, options);
275
+ const client = new ApiClient();
276
+ try {
277
+ const result = await client.getDocumentSchema(resolvedAppId, documentId);
278
+ const model = result?.schema?.models?.[modelName];
279
+ if (!model) {
280
+ if (options.json) {
281
+ json(null);
282
+ return;
283
+ }
284
+ info(`Model '${modelName}' not found in this document.`);
285
+ return;
286
+ }
287
+ const fieldsObj = model.fields ?? {};
288
+ const rows = Object.entries(fieldsObj).map(([name, meta]) => ({
289
+ field: name,
290
+ type: meta?.type ?? "unknown",
291
+ indexed: meta?.indexed ? "yes" : "",
292
+ unique: meta?.unique ? "yes" : "",
293
+ required: meta?.required ? "yes" : "",
294
+ }));
295
+ if (options.json) {
296
+ json({ fields: fieldsObj, constraints: model.constraints ?? {} });
297
+ return;
298
+ }
299
+ if (rows.length === 0) {
300
+ info("No fields detected for this model.");
301
+ return;
302
+ }
303
+ console.log(formatTable(rows, [
304
+ { header: "FIELD", key: "field", flex: true },
305
+ { header: "TYPE", key: "type" },
306
+ { header: "INDEXED", key: "indexed" },
307
+ { header: "UNIQUE", key: "unique" },
308
+ { header: "REQUIRED", key: "required" },
309
+ ]));
310
+ }
311
+ catch (err) {
312
+ error(err.message);
313
+ process.exit(1);
314
+ }
315
+ });
316
+ // Query records in a document model
317
+ records
318
+ .command("query")
319
+ .description("Query records in a document model")
320
+ .argument("<document-id>", "Document ID")
321
+ .argument("<model-name>", "Model name to query")
322
+ .option("--app <app-id>", "App ID")
323
+ .option("--filter <json>", "Filter as JSON (e.g. '{\"status\":\"open\"}')")
324
+ .option("--limit <n>", "Maximum number of records to return (max 100)", parseInt)
325
+ .option("--cursor <cursor>", "Pagination cursor from a previous query")
326
+ .option("--json", "Output as JSON")
327
+ .action(async (documentId, modelName, options) => {
328
+ const resolvedAppId = resolveAppId(undefined, options);
329
+ let filter;
330
+ if (options.filter) {
331
+ try {
332
+ filter = JSON.parse(options.filter);
333
+ }
334
+ catch {
335
+ error("Invalid --filter JSON.");
336
+ process.exit(1);
337
+ }
338
+ }
339
+ const client = new ApiClient();
340
+ try {
341
+ const result = await client.queryDocumentRecords(resolvedAppId, documentId, modelName, { filter, limit: options.limit, cursor: options.cursor });
342
+ if (options.json) {
343
+ json(result);
344
+ return;
345
+ }
346
+ const items = result.items || [];
347
+ if (items.length === 0) {
348
+ info("No records found.");
349
+ return;
350
+ }
351
+ const allKeys = new Set();
352
+ for (const rec of items) {
353
+ for (const key of Object.keys(rec))
354
+ allKeys.add(key);
355
+ }
356
+ const orderedKeys = [];
357
+ if (allKeys.has("id")) {
358
+ orderedKeys.push("id");
359
+ allKeys.delete("id");
360
+ }
361
+ orderedKeys.push(...[...allKeys].sort());
362
+ const columns = orderedKeys.map((key) => ({
363
+ header: key.toUpperCase(),
364
+ key,
365
+ format: (v) => {
366
+ if (v === null || v === undefined)
367
+ return "—";
368
+ if (typeof v === "object")
369
+ return JSON.stringify(v);
370
+ return String(v);
371
+ },
372
+ }));
373
+ console.log(formatTable(items, columns));
374
+ if (result.nextCursor) {
375
+ info(`More results available. Use --cursor ${result.nextCursor}`);
376
+ }
377
+ }
378
+ catch (err) {
379
+ error(err.message);
380
+ process.exit(1);
381
+ }
382
+ });
383
+ // Count records in a document model
384
+ records
385
+ .command("count")
386
+ .description("Count records in a document model")
387
+ .argument("<document-id>", "Document ID")
388
+ .argument("<model-name>", "Model name to count")
389
+ .option("--app <app-id>", "App ID")
390
+ .option("--filter <json>", "Filter as JSON (e.g. '{\"status\":\"open\"}')")
391
+ .option("--json", "Output as JSON")
392
+ .action(async (documentId, modelName, options) => {
393
+ const resolvedAppId = resolveAppId(undefined, options);
394
+ let filter;
395
+ if (options.filter) {
396
+ try {
397
+ filter = JSON.parse(options.filter);
398
+ }
399
+ catch {
400
+ error("Invalid --filter JSON.");
401
+ process.exit(1);
402
+ }
403
+ }
404
+ const client = new ApiClient();
405
+ try {
406
+ const result = await client.countDocumentRecords(resolvedAppId, documentId, modelName, { filter });
407
+ if (options.json) {
408
+ json(result);
409
+ return;
410
+ }
411
+ console.log(` ${result.count}`);
412
+ }
413
+ catch (err) {
414
+ error(err.message);
415
+ process.exit(1);
416
+ }
417
+ });
418
+ // ---- Record writes (#1964 Phase 4) ----
419
+ // Server-side writes through the document facade: Yjs-first, CRDT-safe,
420
+ // broadcast to connected clients. Requires read-write or higher on the
421
+ // document (or an admin token). `--data` / `--data-file` are validated
422
+ // CLI-side before any request is sent.
423
+ // `--data`/`--data-file` parsing (including the object-shape check and the
424
+ // both-flags rule) is the shared `parseDataOption` helper, so these verbs
425
+ // behave and diagnose identically to `metadata` and `databases records`.
426
+ // Create or replace a record
427
+ records
428
+ .command("save")
429
+ .description("Create or replace a record in a document model")
430
+ .argument("<document-id>", "Document ID")
431
+ .argument("<model-name>", "Model name to write to")
432
+ .option("--app <app-id>", "App ID")
433
+ .option("--id <record-id>", "Record ID (a ULID is generated when omitted)")
434
+ .option("--data <json>", "Record fields as JSON (e.g. '{\"qty\":10}')")
435
+ .option("--data-file <file>", "Read record fields from a JSON file")
436
+ .option("--upsert-on <field>", "Update the record whose <field> matches instead of creating")
437
+ .option("--json", "Output as JSON")
438
+ .action(async (documentId, modelName, options) => {
439
+ const data = parseDataOption(options, "record fields");
440
+ const resolvedAppId = resolveAppId(undefined, options);
441
+ const recordId = options.id || ulid();
442
+ const writeOptions = options.upsertOn ? { upsertOn: options.upsertOn } : undefined;
443
+ const client = new ApiClient();
444
+ try {
445
+ const result = await client.saveDocumentRecord(resolvedAppId, documentId, modelName, { id: recordId, data, ...(writeOptions ? { options: writeOptions } : {}) });
446
+ if (options.json) {
447
+ json(result);
448
+ return;
449
+ }
450
+ success(`Record saved: ${result.record?.id ?? recordId}`);
451
+ }
452
+ catch (err) {
453
+ error(err.message);
454
+ process.exit(1);
455
+ }
456
+ });
457
+ // Merge fields into an existing record
458
+ records
459
+ .command("patch")
460
+ .description("Merge fields into an existing record in a document model")
461
+ .argument("<document-id>", "Document ID")
462
+ .argument("<model-name>", "Model name")
463
+ .argument("<record-id>", "Record ID to patch")
464
+ .option("--app <app-id>", "App ID")
465
+ .option("--data <json>", "Fields to merge as JSON (e.g. '{\"status\":\"closed\"}')")
466
+ .option("--data-file <file>", "Read fields from a JSON file")
467
+ .option("--json", "Output as JSON")
468
+ .action(async (documentId, modelName, recordId, options) => {
469
+ const data = parseDataOption(options, "fields to merge");
470
+ const resolvedAppId = resolveAppId(undefined, options);
471
+ const client = new ApiClient();
472
+ try {
473
+ const result = await client.patchDocumentRecord(resolvedAppId, documentId, modelName, recordId, { data });
474
+ if (options.json) {
475
+ json(result);
476
+ return;
477
+ }
478
+ success(`Record patched: ${recordId}`);
479
+ }
480
+ catch (err) {
481
+ error(err.message);
482
+ process.exit(1);
483
+ }
484
+ });
485
+ // Delete a record
486
+ records
487
+ .command("delete")
488
+ .description("Delete a record from a document model")
489
+ .argument("<document-id>", "Document ID")
490
+ .argument("<model-name>", "Model name")
491
+ .argument("<record-id>", "Record ID to delete")
492
+ .option("--app <app-id>", "App ID")
493
+ .option("-y, --yes", "Skip confirmation prompt")
494
+ .option("--json", "Output as JSON")
495
+ .action(async (documentId, modelName, recordId, options) => {
496
+ const resolvedAppId = resolveAppId(undefined, options);
497
+ if (!options.yes) {
498
+ const confirmed = await confirmPrompt(`Delete record ${recordId} from ${modelName} in document ${documentId}?`);
499
+ if (!confirmed) {
500
+ info("Cancelled.");
501
+ return;
502
+ }
503
+ }
504
+ const client = new ApiClient();
505
+ try {
506
+ const result = await client.deleteDocumentRecord(resolvedAppId, documentId, modelName, recordId);
507
+ if (options.json) {
508
+ json(result);
509
+ return;
510
+ }
511
+ // A delete of a missing record is a silent no-op server-side, so
512
+ // this reports the request outcome, not record existence.
513
+ success(`Record deleted: ${recordId}`);
514
+ }
515
+ catch (err) {
516
+ error(err.message);
517
+ process.exit(1);
518
+ }
519
+ });
520
+ // Apply an atomic multi-model operations blob
521
+ records
522
+ .command("bulk")
523
+ .description("Apply an atomic create/patch/delete operations blob to a document")
524
+ .argument("<document-id>", "Document ID")
525
+ .requiredOption("--data-file <file>", "JSON file with { operations: [...] } or a bare operations array")
526
+ .option("--app <app-id>", "App ID")
527
+ .option("-y, --yes", "Skip confirmation prompt")
528
+ .option("--json", "Output as JSON")
529
+ .action(async (documentId, options) => {
530
+ const parsed = parseDataOption(options, "operations blob", {
531
+ allowArray: true,
532
+ });
533
+ const operations = Array.isArray(parsed) ? parsed : parsed?.operations;
534
+ if (!Array.isArray(operations) || operations.length === 0) {
535
+ error("--data-file must contain { operations: [...] } or a non-empty operations array.");
536
+ process.exit(1);
537
+ }
538
+ const resolvedAppId = resolveAppId(undefined, options);
539
+ if (!options.yes) {
540
+ const confirmed = await confirmPrompt(`Apply ${operations.length} operation(s) atomically to document ${documentId}?`);
541
+ if (!confirmed) {
542
+ info("Cancelled.");
543
+ return;
544
+ }
545
+ }
546
+ const client = new ApiClient();
547
+ try {
548
+ const result = await client.bulkDocumentRecords(resolvedAppId, documentId, operations);
549
+ if (options.json) {
550
+ json(result);
551
+ return;
552
+ }
553
+ success(`Applied ${result.applied} operation(s): ` +
554
+ `${result.added.length} added, ${result.updated.length} updated, ${result.deleted} deleted.`);
555
+ }
556
+ catch (err) {
557
+ error(err.message);
558
+ process.exit(1);
559
+ }
560
+ });
561
+ // Dump a document's records grouped by model (CLI-side composition)
562
+ documents
563
+ .command("dump")
564
+ .description("Dump a document's records grouped by model as JSON")
565
+ .argument("<document-id>", "Document ID")
566
+ .option("--app <app-id>", "App ID")
567
+ .option("--output <file>", "Write the JSON dump to a file instead of stdout")
568
+ .option("--json", "Output as JSON (default)")
569
+ .action(async (documentId, options) => {
570
+ const resolvedAppId = resolveAppId(undefined, options);
571
+ const client = new ApiClient();
572
+ try {
573
+ // Model names come from the schema; records are assembled from paged
574
+ // reads per model (not a single atomic snapshot — the paged path is
575
+ // the contract, same pattern as `databases export`).
576
+ const schema = await client.getDocumentSchema(resolvedAppId, documentId);
577
+ const modelNames = schema?.modelNames ?? Object.keys(schema?.schema?.models ?? {});
578
+ const grouped = {};
579
+ for (const modelName of modelNames) {
580
+ const records = [];
581
+ let cursor;
582
+ do {
583
+ const page = await client.queryDocumentRecords(resolvedAppId, documentId, modelName, { limit: 100, cursor });
584
+ records.push(...(page.items || []));
585
+ cursor = page.nextCursor;
586
+ } while (cursor);
587
+ grouped[modelName] = records;
588
+ }
589
+ const output = JSON.stringify(grouped, null, 2);
590
+ if (options.output) {
591
+ fs.writeFileSync(options.output, output);
592
+ success(`Document dump written to ${options.output}`);
593
+ return;
594
+ }
595
+ console.log(output);
596
+ }
597
+ catch (err) {
598
+ error(err.message);
599
+ process.exit(1);
600
+ }
601
+ });
602
+ // Document statistics (platform-vocabulary projection)
603
+ documents
604
+ .command("stats")
605
+ .description("Show a document's record/model/blob counts and approximate size")
606
+ .argument("<document-id>", "Document ID")
607
+ .option("--app <app-id>", "App ID")
608
+ .option("--json", "Output as JSON")
609
+ .action(async (documentId, options) => {
610
+ const resolvedAppId = resolveAppId(undefined, options);
611
+ const client = new ApiClient();
612
+ try {
613
+ const result = await client.getDocumentStats(resolvedAppId, documentId);
614
+ if (options.json) {
615
+ json(result);
616
+ return;
617
+ }
618
+ keyValue("Document ID", result.documentId);
619
+ keyValue("Records", result.recordCount);
620
+ keyValue("Models", result.modelCount);
621
+ keyValue("Blobs", result.blobCount);
622
+ keyValue("Size (bytes, approx)", result.sizeBytes);
623
+ keyValue("Last modified", result.lastModifiedAt ? formatDate(result.lastModifiedAt) : "—");
624
+ }
625
+ catch (err) {
626
+ error(err.message);
627
+ process.exit(1);
628
+ }
629
+ });
16
630
  // Transfer document ownership
17
631
  documents
18
632
  .command("transfer-owner")