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.
- package/README.md +325 -31
- package/assets/skill/skills/primitive-platform/SKILL.md +281 -0
- package/dist/bin/primitive.d.ts +2 -0
- package/dist/bin/primitive.js +268 -16
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/admins.d.ts +2 -0
- package/dist/src/commands/admins.js +123 -18
- package/dist/src/commands/admins.js.map +1 -1
- package/dist/src/commands/analytics.d.ts +2 -0
- package/dist/src/commands/analytics.js +464 -55
- package/dist/src/commands/analytics.js.map +1 -1
- package/dist/src/commands/apps.d.ts +2 -0
- package/dist/src/commands/apps.js +55 -13
- package/dist/src/commands/apps.js.map +1 -1
- package/dist/src/commands/auth.d.ts +2 -0
- package/dist/src/commands/auth.js +177 -7
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/blob-buckets.d.ts +2 -0
- package/dist/src/commands/blob-buckets.js +422 -0
- package/dist/src/commands/blob-buckets.js.map +1 -0
- package/dist/src/commands/catalog.d.ts +2 -0
- package/dist/src/commands/catalog.js +34 -35
- package/dist/src/commands/catalog.js.map +1 -1
- package/dist/src/commands/collection-type-configs.d.ts +2 -0
- package/dist/src/commands/collection-type-configs.js +178 -0
- package/dist/src/commands/collection-type-configs.js.map +1 -0
- package/dist/src/commands/collections.d.ts +2 -0
- package/dist/src/commands/collections.js +515 -0
- package/dist/src/commands/collections.js.map +1 -0
- package/dist/src/commands/comparisons.d.ts +2 -0
- package/dist/src/commands/comparisons.js +6 -6
- package/dist/src/commands/comparisons.js.map +1 -1
- package/dist/src/commands/cron-triggers.d.ts +2 -0
- package/dist/src/commands/cron-triggers.js +357 -0
- package/dist/src/commands/cron-triggers.js.map +1 -0
- package/dist/src/commands/database-types.d.ts +2 -0
- package/dist/src/commands/database-types.js +471 -0
- package/dist/src/commands/database-types.js.map +1 -0
- package/dist/src/commands/databases.d.ts +2 -0
- package/dist/src/commands/databases.js +1462 -83
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.d.ts +2 -0
- package/dist/src/commands/documents.js +518 -11
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/email-templates.d.ts +2 -0
- package/dist/src/commands/email-templates.js +281 -0
- package/dist/src/commands/email-templates.js.map +1 -0
- package/dist/src/commands/env.d.ts +12 -0
- package/dist/src/commands/env.js +260 -0
- package/dist/src/commands/env.js.map +1 -0
- package/dist/src/commands/group-type-configs.d.ts +2 -0
- package/dist/src/commands/group-type-configs.js +181 -0
- package/dist/src/commands/group-type-configs.js.map +1 -0
- package/dist/src/commands/groups.d.ts +2 -0
- package/dist/src/commands/groups.js +48 -45
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/guides.d.ts +159 -0
- package/dist/src/commands/guides.js +449 -35
- package/dist/src/commands/guides.js.map +1 -1
- package/dist/src/commands/init.d.ts +17 -0
- package/dist/src/commands/init.js +811 -201
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/integrations.d.ts +2 -0
- package/dist/src/commands/integrations.js +533 -46
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/llm.d.ts +2 -0
- package/dist/src/commands/llm.js +4 -2
- package/dist/src/commands/llm.js.map +1 -1
- package/dist/src/commands/metadata.d.ts +2 -0
- package/dist/src/commands/metadata.js +194 -0
- package/dist/src/commands/metadata.js.map +1 -0
- package/dist/src/commands/prompts.d.ts +2 -0
- package/dist/src/commands/prompts.js +50 -52
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/rule-sets.d.ts +3 -0
- package/dist/src/commands/rule-sets.js +388 -0
- package/dist/src/commands/rule-sets.js.map +1 -0
- package/dist/src/commands/scripts.d.ts +2 -0
- package/dist/src/commands/scripts.js +667 -0
- package/dist/src/commands/scripts.js.map +1 -0
- package/dist/src/commands/secrets.d.ts +2 -0
- package/dist/src/commands/secrets.js +108 -0
- package/dist/src/commands/secrets.js.map +1 -0
- package/dist/src/commands/skill.d.ts +2 -0
- package/dist/src/commands/skill.js +29 -0
- package/dist/src/commands/skill.js.map +1 -0
- package/dist/src/commands/sync.d.ts +175 -0
- package/dist/src/commands/sync.js +4412 -233
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/tokens.d.ts +2 -0
- package/dist/src/commands/tokens.js +122 -19
- package/dist/src/commands/tokens.js.map +1 -1
- package/dist/src/commands/users.d.ts +2 -0
- package/dist/src/commands/users.js +440 -22
- package/dist/src/commands/users.js.map +1 -1
- package/dist/src/commands/waitlist.d.ts +2 -0
- package/dist/src/commands/waitlist.js +10 -10
- package/dist/src/commands/waitlist.js.map +1 -1
- package/dist/src/commands/webhooks.d.ts +2 -0
- package/dist/src/commands/webhooks.js +391 -0
- package/dist/src/commands/webhooks.js.map +1 -0
- package/dist/src/commands/workflows.d.ts +49 -0
- package/dist/src/commands/workflows.js +1009 -123
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +1283 -0
- package/dist/src/lib/api-client.js +1129 -83
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/auth-flow.d.ts +8 -0
- package/dist/src/lib/block-layout.d.ts +160 -0
- package/dist/src/lib/block-layout.js +451 -0
- package/dist/src/lib/block-layout.js.map +1 -0
- package/dist/src/lib/cli-manifest.d.ts +60 -0
- package/dist/src/lib/cli-manifest.js +70 -0
- package/dist/src/lib/cli-manifest.js.map +1 -0
- package/dist/src/lib/config.d.ts +53 -0
- package/dist/src/lib/config.js +92 -53
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/confirm-prompt.d.ts +83 -0
- package/dist/src/lib/confirm-prompt.js +110 -0
- package/dist/src/lib/confirm-prompt.js.map +1 -0
- package/dist/src/lib/constants.d.ts +2 -0
- package/dist/src/lib/constants.js +3 -0
- package/dist/src/lib/constants.js.map +1 -0
- package/dist/src/lib/crash-handlers.d.ts +20 -0
- package/dist/src/lib/crash-handlers.js +49 -0
- package/dist/src/lib/crash-handlers.js.map +1 -0
- package/dist/src/lib/credentials-store.d.ts +79 -0
- package/dist/src/lib/credentials-store.js +307 -0
- package/dist/src/lib/credentials-store.js.map +1 -0
- package/dist/src/lib/csv.d.ts +48 -0
- package/dist/src/lib/csv.js +177 -0
- package/dist/src/lib/csv.js.map +1 -0
- package/dist/src/lib/db-codegen/dbFingerprint.d.ts +10 -0
- package/dist/src/lib/db-codegen/dbFingerprint.js +17 -0
- package/dist/src/lib/db-codegen/dbFingerprint.js.map +1 -0
- package/dist/src/lib/db-codegen/dbGenerator.d.ts +111 -0
- package/dist/src/lib/db-codegen/dbGenerator.js +371 -0
- package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -0
- package/dist/src/lib/db-codegen/dbNaming.d.ts +45 -0
- package/dist/src/lib/db-codegen/dbNaming.js +104 -0
- package/dist/src/lib/db-codegen/dbNaming.js.map +1 -0
- package/dist/src/lib/db-codegen/dbTemplates.d.ts +97 -0
- package/dist/src/lib/db-codegen/dbTemplates.js +159 -0
- package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -0
- package/dist/src/lib/db-codegen/dbTsTypes.d.ts +78 -0
- package/dist/src/lib/db-codegen/dbTsTypes.js +112 -0
- package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -0
- package/dist/src/lib/env-resolver.d.ts +62 -0
- package/dist/src/lib/env-resolver.js +121 -0
- package/dist/src/lib/env-resolver.js.map +1 -0
- package/dist/src/lib/fetch.d.ts +5 -0
- package/dist/src/lib/generated-allowlist.d.ts +28 -0
- package/dist/src/lib/generated-allowlist.js +220 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -0
- package/dist/src/lib/init-config.d.ts +59 -0
- package/dist/src/lib/init-config.js +113 -0
- package/dist/src/lib/init-config.js.map +1 -0
- package/dist/src/lib/migration-nag.d.ts +49 -0
- package/dist/src/lib/migration-nag.js +163 -0
- package/dist/src/lib/migration-nag.js.map +1 -0
- package/dist/src/lib/output.d.ts +86 -0
- package/dist/src/lib/output.js +150 -8
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/paginate.d.ts +33 -0
- package/dist/src/lib/paginate.js +42 -0
- package/dist/src/lib/paginate.js.map +1 -0
- package/dist/src/lib/project-config.d.ts +97 -0
- package/dist/src/lib/project-config.js +209 -0
- package/dist/src/lib/project-config.js.map +1 -0
- package/dist/src/lib/query-operators.d.ts +43 -0
- package/dist/src/lib/query-operators.js +80 -0
- package/dist/src/lib/query-operators.js.map +1 -0
- package/dist/src/lib/refresh-admin-credentials.d.ts +65 -0
- package/dist/src/lib/refresh-admin-credentials.js +103 -0
- package/dist/src/lib/refresh-admin-credentials.js.map +1 -0
- package/dist/src/lib/resolve-platform.d.ts +45 -0
- package/dist/src/lib/resolve-platform.js +43 -0
- package/dist/src/lib/resolve-platform.js.map +1 -0
- package/dist/src/lib/skill-installer.d.ts +23 -0
- package/dist/src/lib/skill-installer.js +135 -0
- package/dist/src/lib/skill-installer.js.map +1 -0
- package/dist/src/lib/snapshots.d.ts +99 -0
- package/dist/src/lib/snapshots.js +357 -0
- package/dist/src/lib/snapshots.js.map +1 -0
- package/dist/src/lib/sync-paths.d.ts +72 -0
- package/dist/src/lib/sync-paths.js +130 -0
- package/dist/src/lib/sync-paths.js.map +1 -0
- package/dist/src/lib/template.d.ts +97 -0
- package/dist/src/lib/template.js +336 -62
- package/dist/src/lib/template.js.map +1 -1
- package/dist/src/lib/token-inject.d.ts +56 -0
- package/dist/src/lib/token-inject.js +204 -0
- package/dist/src/lib/token-inject.js.map +1 -0
- package/dist/src/lib/toml-database-config.d.ts +152 -0
- package/dist/src/lib/toml-database-config.js +600 -0
- package/dist/src/lib/toml-database-config.js.map +1 -0
- package/dist/src/lib/toml-metadata-config.d.ts +139 -0
- package/dist/src/lib/toml-metadata-config.js +427 -0
- package/dist/src/lib/toml-metadata-config.js.map +1 -0
- package/dist/src/lib/toml-params-validator.d.ts +129 -0
- package/dist/src/lib/toml-params-validator.js +298 -0
- package/dist/src/lib/toml-params-validator.js.map +1 -0
- package/dist/src/lib/version-check.d.ts +10 -0
- package/dist/src/lib/version-check.js +172 -0
- package/dist/src/lib/version-check.js.map +1 -0
- package/dist/src/lib/workflow-apply.d.ts +66 -0
- package/dist/src/lib/workflow-apply.js +117 -0
- package/dist/src/lib/workflow-apply.js.map +1 -0
- package/dist/src/lib/workflow-fragments.d.ts +41 -0
- package/dist/src/lib/workflow-fragments.js +121 -0
- package/dist/src/lib/workflow-fragments.js.map +1 -0
- package/dist/src/lib/workflow-toml-validator.d.ts +95 -0
- package/dist/src/lib/workflow-toml-validator.js +323 -0
- package/dist/src/lib/workflow-toml-validator.js.map +1 -0
- package/dist/src/types/index.d.ts +520 -0
- package/dist/src/validators.d.ts +64 -0
- package/dist/src/validators.js +63 -0
- package/dist/src/validators.js.map +1 -0
- 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 {
|
|
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
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
//
|
|
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
|
-
.
|
|
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.
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
208
|
-
|
|
209
|
-
.command("
|
|
210
|
-
.description("
|
|
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("<
|
|
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
|
-
.
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
{
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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.
|
|
235
|
-
|
|
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
|
-
//
|
|
243
|
-
|
|
244
|
-
.command("
|
|
245
|
-
.description("
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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.
|
|
258
|
-
const list = Array.isArray(result) ? result :
|
|
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
|
|
770
|
+
info("No operations found.");
|
|
265
771
|
return;
|
|
266
772
|
}
|
|
267
773
|
console.log(formatTable(list, [
|
|
268
|
-
{ header: "
|
|
269
|
-
{ header: "
|
|
270
|
-
{ header: "
|
|
271
|
-
{ header: "
|
|
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
|
-
//
|
|
280
|
-
|
|
281
|
-
.command("
|
|
282
|
-
.description("
|
|
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
|
-
.
|
|
285
|
-
.
|
|
286
|
-
.
|
|
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.
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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(`
|
|
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
|
-
//
|
|
310
|
-
|
|
311
|
-
.command("
|
|
312
|
-
.description("
|
|
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
|
-
.
|
|
315
|
-
.
|
|
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,
|
|
961
|
+
.action(async (databaseId, options) => {
|
|
319
962
|
const resolvedAppId = resolveAppId(undefined, options);
|
|
320
963
|
if (!options.yes) {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
{
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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.
|
|
338
|
-
|
|
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
|