@sapporta/server 0.0.1
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/package.json +40 -0
- package/src/actions/action.test.ts +108 -0
- package/src/actions/action.ts +60 -0
- package/src/actions/loader.ts +47 -0
- package/src/api/actions.ts +124 -0
- package/src/api/meta-mutations.ts +922 -0
- package/src/api/meta.ts +222 -0
- package/src/api/reports.ts +98 -0
- package/src/api/server.ts +24 -0
- package/src/api/tables.ts +108 -0
- package/src/api/views.ts +44 -0
- package/src/boot.ts +206 -0
- package/src/cli/ai-commands.ts +220 -0
- package/src/cli/check.ts +169 -0
- package/src/cli/cli-utils.test.ts +313 -0
- package/src/cli/describe.test.ts +151 -0
- package/src/cli/describe.ts +88 -0
- package/src/cli/emit-result.test.ts +160 -0
- package/src/cli/format.ts +150 -0
- package/src/cli/http-client.ts +55 -0
- package/src/cli/index.ts +162 -0
- package/src/cli/init.ts +35 -0
- package/src/cli/project-context.ts +38 -0
- package/src/cli/request.ts +146 -0
- package/src/cli/routes.ts +418 -0
- package/src/cli/rows-insert-master-detail.test.ts +124 -0
- package/src/cli/rows-insert-master-detail.ts +186 -0
- package/src/cli/rows-insert.test.ts +137 -0
- package/src/cli/rows-insert.ts +97 -0
- package/src/cli/serve-single.ts +49 -0
- package/src/create-project.ts +81 -0
- package/src/data/count.ts +62 -0
- package/src/data/crud.test.ts +188 -0
- package/src/data/crud.ts +242 -0
- package/src/data/lookup.test.ts +96 -0
- package/src/data/lookup.ts +104 -0
- package/src/data/query-parser.test.ts +67 -0
- package/src/data/query-parser.ts +106 -0
- package/src/data/sanitize.test.ts +57 -0
- package/src/data/sanitize.ts +25 -0
- package/src/data/save-pipeline.test.ts +115 -0
- package/src/data/save-pipeline.ts +93 -0
- package/src/data/validate.test.ts +110 -0
- package/src/data/validate.ts +98 -0
- package/src/db/errors.ts +20 -0
- package/src/db/logger.ts +63 -0
- package/src/db/sqlite-connection.test.ts +59 -0
- package/src/db/sqlite-connection.ts +79 -0
- package/src/index.ts +111 -0
- package/src/integration/api-actions.test.ts +60 -0
- package/src/integration/api-global.test.ts +21 -0
- package/src/integration/api-meta.test.ts +252 -0
- package/src/integration/api-reports.test.ts +77 -0
- package/src/integration/api-tables.test.ts +238 -0
- package/src/integration/api-views.test.ts +39 -0
- package/src/integration/cli-routes.test.ts +167 -0
- package/src/integration/fixtures/actions/create-account.ts +23 -0
- package/src/integration/fixtures/reports/account-list.ts +25 -0
- package/src/integration/fixtures/schema/accounts.ts +21 -0
- package/src/integration/fixtures/schema/audit-log.ts +19 -0
- package/src/integration/fixtures/schema/journal-entries.ts +20 -0
- package/src/integration/fixtures/views/dashboard.tsx +4 -0
- package/src/integration/fixtures/views/settings.tsx +3 -0
- package/src/integration/setup.ts +72 -0
- package/src/introspect/db-helpers.ts +109 -0
- package/src/introspect/describe-all.test.ts +73 -0
- package/src/introspect/describe-all.ts +80 -0
- package/src/introspect/describe.test.ts +65 -0
- package/src/introspect/describe.ts +184 -0
- package/src/introspect/exec.test.ts +103 -0
- package/src/introspect/exec.ts +57 -0
- package/src/introspect/indexes.test.ts +41 -0
- package/src/introspect/indexes.ts +95 -0
- package/src/introspect/inference.ts +98 -0
- package/src/introspect/list-tables.test.ts +40 -0
- package/src/introspect/list-tables.ts +62 -0
- package/src/introspect/query.test.ts +77 -0
- package/src/introspect/query.ts +47 -0
- package/src/introspect/sample.test.ts +67 -0
- package/src/introspect/sample.ts +50 -0
- package/src/introspect/sql-safety.ts +76 -0
- package/src/introspect/sqlite/db-helpers.test.ts +79 -0
- package/src/introspect/sqlite/db-helpers.ts +56 -0
- package/src/introspect/sqlite/describe-all.ts +21 -0
- package/src/introspect/sqlite/describe.test.ts +160 -0
- package/src/introspect/sqlite/describe.ts +185 -0
- package/src/introspect/sqlite/exec.ts +57 -0
- package/src/introspect/sqlite/indexes.test.ts +60 -0
- package/src/introspect/sqlite/indexes.ts +96 -0
- package/src/introspect/sqlite/list-tables.test.ts +100 -0
- package/src/introspect/sqlite/list-tables.ts +67 -0
- package/src/introspect/sqlite/query.ts +49 -0
- package/src/introspect/sqlite/sample.ts +50 -0
- package/src/introspect/table-rename.test.ts +235 -0
- package/src/introspect/table-rename.ts +115 -0
- package/src/introspect/types.ts +95 -0
- package/src/reports/check.test.ts +499 -0
- package/src/reports/check.ts +208 -0
- package/src/reports/engine.test.ts +1465 -0
- package/src/reports/engine.ts +678 -0
- package/src/reports/loader.ts +55 -0
- package/src/reports/report.ts +308 -0
- package/src/reports/sql-bind.ts +161 -0
- package/src/reports/sqlite-bind.test.ts +98 -0
- package/src/reports/sqlite-bind.ts +58 -0
- package/src/reports/sqlite-sql-client.ts +42 -0
- package/src/runtime.ts +3 -0
- package/src/schema/check.ts +90 -0
- package/src/schema/ddl.test.ts +210 -0
- package/src/schema/ddl.ts +180 -0
- package/src/schema/dynamic-builder.ts +297 -0
- package/src/schema/extract.test.ts +261 -0
- package/src/schema/extract.ts +285 -0
- package/src/schema/loader.test.ts +31 -0
- package/src/schema/loader.ts +60 -0
- package/src/schema/metadata-io.test.ts +261 -0
- package/src/schema/metadata-io.ts +161 -0
- package/src/schema/metadata-tables.test.ts +737 -0
- package/src/schema/metadata-tables.ts +341 -0
- package/src/schema/migrate.ts +195 -0
- package/src/schema/normalize-datatype.test.ts +58 -0
- package/src/schema/normalize-datatype.ts +99 -0
- package/src/schema/registry.test.ts +174 -0
- package/src/schema/registry.ts +139 -0
- package/src/schema/reserved.ts +227 -0
- package/src/schema/table.ts +135 -0
- package/src/test-fixtures/schema/accounts.ts +24 -0
- package/src/test-fixtures/schema/not-a-table.ts +6 -0
- package/src/testing/test-utils.ts +44 -0
- package/src/views/loader.test.ts +70 -0
- package/src/views/loader.ts +38 -0
- package/src/views/view.test.ts +121 -0
- package/src/views/view.ts +16 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request building and response rendering for CLI → API bridge.
|
|
3
|
+
*
|
|
4
|
+
* Side-effect-free — safe to import from other packages (e.g. control-plane CLI).
|
|
5
|
+
*/
|
|
6
|
+
import type { CliRoute } from "./routes.js";
|
|
7
|
+
import { formatTable, type OutputFormat } from "./format.js";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// System flags — excluded from body/query param building
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/** Flags consumed by the CLI framework, never forwarded to the API. */
|
|
14
|
+
const SYSTEM_FLAGS = new Set(["_", "output", "project", "json", "api-url", "token"]);
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Request building (pure, testable)
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export interface RequestSpec {
|
|
21
|
+
method: string;
|
|
22
|
+
urlPath: string;
|
|
23
|
+
body?: unknown;
|
|
24
|
+
queryParams?: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build an HTTP request spec from a matched route and parsed flags.
|
|
29
|
+
* Pure function — no I/O, fully testable.
|
|
30
|
+
*/
|
|
31
|
+
export function buildRequest(
|
|
32
|
+
route: CliRoute,
|
|
33
|
+
params: Record<string, string>,
|
|
34
|
+
allFlags: Record<string, any>,
|
|
35
|
+
): RequestSpec {
|
|
36
|
+
let urlPath = route.path;
|
|
37
|
+
for (const [key, value] of Object.entries(params)) {
|
|
38
|
+
urlPath = urlPath.replace(`:${key}`, encodeURIComponent(value));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let body: unknown | undefined;
|
|
42
|
+
if (route.method === "POST" || route.method === "PUT" || route.method === "PATCH") {
|
|
43
|
+
if (allFlags.json) {
|
|
44
|
+
body = JSON.parse(allFlags.json);
|
|
45
|
+
if (route.inputSchema) {
|
|
46
|
+
body = route.inputSchema.parse(body);
|
|
47
|
+
}
|
|
48
|
+
} else if (route.bodyField && allFlags[route.bodyField]) {
|
|
49
|
+
body = JSON.parse(allFlags[route.bodyField]);
|
|
50
|
+
} else if (route.inputSchema) {
|
|
51
|
+
const schemaBody: Record<string, unknown> = {};
|
|
52
|
+
if (route.positionalArgs) {
|
|
53
|
+
const positionals = allFlags._ as unknown as string[];
|
|
54
|
+
if (positionals) {
|
|
55
|
+
for (let i = 0; i < route.positionalArgs.length && i < positionals.length; i++) {
|
|
56
|
+
schemaBody[route.positionalArgs[i].field] = positionals[i];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const [key, value] of Object.entries(allFlags)) {
|
|
61
|
+
if (SYSTEM_FLAGS.has(key) || key === "dry-run") continue;
|
|
62
|
+
schemaBody[key] = value;
|
|
63
|
+
}
|
|
64
|
+
if (allFlags["dry-run"]) {
|
|
65
|
+
schemaBody.dryRun = true;
|
|
66
|
+
}
|
|
67
|
+
if (route.flagMap) {
|
|
68
|
+
for (const [cliKey, bodyKey] of Object.entries(route.flagMap)) {
|
|
69
|
+
if (cliKey in schemaBody) {
|
|
70
|
+
schemaBody[bodyKey] = schemaBody[cliKey];
|
|
71
|
+
delete schemaBody[cliKey];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (Object.keys(schemaBody).length > 0) {
|
|
76
|
+
body = route.inputSchema.parse(schemaBody);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const queryParams: Record<string, string> = {};
|
|
82
|
+
if (route.queryFlags) {
|
|
83
|
+
if (route.queryFlags.includes("*")) {
|
|
84
|
+
for (const [key, value] of Object.entries(allFlags)) {
|
|
85
|
+
if (SYSTEM_FLAGS.has(key)) continue;
|
|
86
|
+
queryParams[key] = value;
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
for (const flag of route.queryFlags) {
|
|
90
|
+
if (allFlags[flag]) queryParams[flag] = allFlags[flag];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
method: route.method,
|
|
97
|
+
urlPath,
|
|
98
|
+
body,
|
|
99
|
+
queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Response rendering (I/O shell)
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Render an HTTP result to stdout/stderr.
|
|
109
|
+
* Returns the exit code (0 for success, 1 for error).
|
|
110
|
+
*/
|
|
111
|
+
export function renderResult(
|
|
112
|
+
route: CliRoute,
|
|
113
|
+
params: Record<string, string>,
|
|
114
|
+
result: { status: number; data: any },
|
|
115
|
+
format: OutputFormat,
|
|
116
|
+
): number {
|
|
117
|
+
if (result.status >= 400) {
|
|
118
|
+
const errMsg = result.data?.error ?? `HTTP ${result.status}`;
|
|
119
|
+
if (format === "json") {
|
|
120
|
+
console.log(JSON.stringify(result.data));
|
|
121
|
+
} else {
|
|
122
|
+
console.error(`Error: ${errMsg}`);
|
|
123
|
+
if (result.data?.details) {
|
|
124
|
+
console.error(JSON.stringify(result.data.details, null, 2));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (format === "json") {
|
|
131
|
+
console.log(JSON.stringify(result.data));
|
|
132
|
+
} else {
|
|
133
|
+
if (route.formatHeader) {
|
|
134
|
+
const header = route.formatHeader(result.data, params);
|
|
135
|
+
if (header) console.log(header);
|
|
136
|
+
}
|
|
137
|
+
const rows = route.extractData(result.data);
|
|
138
|
+
if (rows.length > 0) {
|
|
139
|
+
console.log(formatTable(rows));
|
|
140
|
+
} else {
|
|
141
|
+
console.log("(empty)");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Route Table — maps CLI command patterns to HTTP API endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Each route defines:
|
|
5
|
+
* - The CLI segments users type (verb-first: fixed keywords before positional params)
|
|
6
|
+
* - The HTTP method + path template
|
|
7
|
+
* - How to build the request body / query params from CLI flags
|
|
8
|
+
* - How to extract tabular data from the API response for --output table
|
|
9
|
+
*
|
|
10
|
+
* Commander.js registers these routes as a command hierarchy.
|
|
11
|
+
* The action handler substitutes positional params into the URL and calls httpRequest().
|
|
12
|
+
*/
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { Command } from "commander";
|
|
15
|
+
|
|
16
|
+
// ── Route Definition ────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface CliRoute {
|
|
19
|
+
/** CLI segments in verb-first order.
|
|
20
|
+
* Fixed keywords come before positional params (tokens starting with ":"). */
|
|
21
|
+
pattern: string[];
|
|
22
|
+
description: string;
|
|
23
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
24
|
+
/** URL path template, e.g. "/meta/tables/:name/indexes" */
|
|
25
|
+
path: string;
|
|
26
|
+
/** Names of positional params in pattern order */
|
|
27
|
+
params: string[];
|
|
28
|
+
/** Zod schema for --json input validation (optional) */
|
|
29
|
+
inputSchema?: z.ZodType;
|
|
30
|
+
/** Flag name whose value is parsed as JSON and used as POST body */
|
|
31
|
+
bodyField?: string;
|
|
32
|
+
/** Flags that become URL query params (for GET requests) */
|
|
33
|
+
queryFlags?: string[];
|
|
34
|
+
/** Map positional args (after the pattern) to body fields. */
|
|
35
|
+
positionalArgs?: { field: string }[];
|
|
36
|
+
/** Whether this command mutates data */
|
|
37
|
+
mutating?: boolean;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Maps CLI flag names to request body field names.
|
|
41
|
+
*
|
|
42
|
+
* CLI users type --db <url>, but the API schema may expect
|
|
43
|
+
* database_path. The flagMap bridges this: { db: "database_path" } renames
|
|
44
|
+
* the flag before the body is assembled.
|
|
45
|
+
*/
|
|
46
|
+
flagMap?: Record<string, string>;
|
|
47
|
+
|
|
48
|
+
/** Extract rows from the API response for --output table rendering */
|
|
49
|
+
extractData: (res: any) => Record<string, unknown>[];
|
|
50
|
+
/** Optional header text printed before the table in --output table mode */
|
|
51
|
+
formatHeader?: (res: any, params: Record<string, string>) => string | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/** Flatten a TableSchema into a summary row for the table listing. */
|
|
57
|
+
function tableListRow(t: any) {
|
|
58
|
+
return {
|
|
59
|
+
name: t.name,
|
|
60
|
+
label: t.label,
|
|
61
|
+
columns: t.columns?.length ?? 0,
|
|
62
|
+
source: t.source,
|
|
63
|
+
rowCount: t.rowCount ?? "",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Flatten columns for single-table describe output. */
|
|
68
|
+
function tableDescribeRows(res: any) {
|
|
69
|
+
if (!res.columns) return [res];
|
|
70
|
+
return res.columns.map((col: any) => ({
|
|
71
|
+
column: col.name,
|
|
72
|
+
type: col.dataType ?? "",
|
|
73
|
+
notNull: col.notNull ? "YES" : "",
|
|
74
|
+
pk: col.primary ? "YES" : "",
|
|
75
|
+
fk: col.foreignKey ? `→ ${col.foreignKey.table}.${col.foreignKey.column}` : "",
|
|
76
|
+
default: col.hasDefault ? "YES" : "",
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Route Table ─────────────────────────────────────────────────────────────
|
|
81
|
+
//
|
|
82
|
+
// Patterns are verb-first: all fixed keywords precede positional params.
|
|
83
|
+
// This lets Commander.js build a static command hierarchy for routing,
|
|
84
|
+
// help generation, and missing-argument validation.
|
|
85
|
+
|
|
86
|
+
export const ROUTES: CliRoute[] = [
|
|
87
|
+
// ── /meta ──────────────────────────────────────────────────────────────
|
|
88
|
+
{
|
|
89
|
+
pattern: ["meta", "tables"],
|
|
90
|
+
description: "List all tables with schema metadata and row counts",
|
|
91
|
+
method: "GET",
|
|
92
|
+
path: "/meta/tables",
|
|
93
|
+
params: [],
|
|
94
|
+
extractData: (res) => (res.tables ?? []).map(tableListRow),
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
pattern: ["meta", "tables", "show", ":name"],
|
|
98
|
+
description: "Describe single table schema",
|
|
99
|
+
method: "GET",
|
|
100
|
+
path: "/meta/tables/:name",
|
|
101
|
+
params: ["name"],
|
|
102
|
+
extractData: tableDescribeRows,
|
|
103
|
+
formatHeader: (res) => res.name ? `Table: ${res.name} (${res.label})` : undefined,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
pattern: ["meta", "tables", "indexes", ":name"],
|
|
107
|
+
description: "Show indexes on a table",
|
|
108
|
+
method: "GET",
|
|
109
|
+
path: "/meta/tables/:name/indexes",
|
|
110
|
+
params: ["name"],
|
|
111
|
+
extractData: (res) => res.data ?? res ?? [],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
pattern: ["meta", "tables", "sample", ":name"],
|
|
115
|
+
description: "Show sample rows from a table",
|
|
116
|
+
method: "GET",
|
|
117
|
+
path: "/meta/tables/:name/sample",
|
|
118
|
+
params: ["name"],
|
|
119
|
+
queryFlags: ["limit", "fields"],
|
|
120
|
+
extractData: (res) => res.data ?? res ?? [],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
pattern: ["meta", "tables", "update", ":name"],
|
|
124
|
+
description: "Update table properties (label, display_column, immutable, name)",
|
|
125
|
+
method: "PATCH",
|
|
126
|
+
path: "/meta/tables/:name",
|
|
127
|
+
params: ["name"],
|
|
128
|
+
bodyField: "data",
|
|
129
|
+
mutating: true,
|
|
130
|
+
inputSchema: z.object({
|
|
131
|
+
name: z.string().optional().describe("New table name (rename)"),
|
|
132
|
+
label: z.string().optional().describe("Display label"),
|
|
133
|
+
display_column: z.string().optional().describe("Column used for FK display values"),
|
|
134
|
+
immutable: z.boolean().optional().describe("Prevent updates and deletes"),
|
|
135
|
+
position: z.number().optional().describe("Sort position in sidebar"),
|
|
136
|
+
}),
|
|
137
|
+
extractData: (res) => [res],
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
pattern: ["meta", "tables", "drop", ":name"],
|
|
141
|
+
description: "Drop a UI-managed table",
|
|
142
|
+
method: "DELETE",
|
|
143
|
+
path: "/meta/tables/:name",
|
|
144
|
+
params: ["name"],
|
|
145
|
+
queryFlags: ["confirm"],
|
|
146
|
+
mutating: true,
|
|
147
|
+
extractData: (res) => [res],
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
pattern: ["meta", "enums"],
|
|
151
|
+
description: "List all Postgres enums and their allowed values",
|
|
152
|
+
method: "GET",
|
|
153
|
+
path: "/meta/enums",
|
|
154
|
+
params: [],
|
|
155
|
+
extractData: (res) => res.data ?? res ?? [],
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
pattern: ["meta", "sql", "query"],
|
|
159
|
+
description: "Run a read-only SQL query (SELECT/WITH only)",
|
|
160
|
+
method: "POST",
|
|
161
|
+
path: "/meta/sql/query",
|
|
162
|
+
params: [],
|
|
163
|
+
positionalArgs: [{ field: "sql" }],
|
|
164
|
+
inputSchema: z.object({
|
|
165
|
+
sql: z.string().describe("SQL query to run"),
|
|
166
|
+
limit: z.number().optional().describe("Max rows to return"),
|
|
167
|
+
}),
|
|
168
|
+
extractData: (res) => Array.isArray(res) ? res : (res.data ?? []),
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
pattern: ["meta", "sql", "exec"],
|
|
172
|
+
description: "Run any SQL statement (INSERT, UPDATE, DELETE, etc.)",
|
|
173
|
+
method: "POST",
|
|
174
|
+
path: "/meta/sql/exec",
|
|
175
|
+
params: [],
|
|
176
|
+
positionalArgs: [{ field: "sql" }],
|
|
177
|
+
mutating: true,
|
|
178
|
+
inputSchema: z.object({
|
|
179
|
+
sql: z.string().describe("SQL statement to execute"),
|
|
180
|
+
dryRun: z.boolean().optional().describe("Validate without executing"),
|
|
181
|
+
}),
|
|
182
|
+
extractData: (res) => Array.isArray(res) ? res : (res.data ?? [res]),
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
pattern: ["meta", "schema", "sync"],
|
|
186
|
+
description: "Sync schema files to database (apply migrations)",
|
|
187
|
+
method: "POST",
|
|
188
|
+
path: "/meta/schema/sync",
|
|
189
|
+
params: [],
|
|
190
|
+
mutating: true,
|
|
191
|
+
extractData: (res) => [res],
|
|
192
|
+
},
|
|
193
|
+
// ── /tables ────────────────────────────────────────────────────────────
|
|
194
|
+
{
|
|
195
|
+
pattern: ["tables", "list", ":table"],
|
|
196
|
+
description: "List rows from a table (with filters, sort, pagination)",
|
|
197
|
+
method: "GET",
|
|
198
|
+
path: "/tables/:table",
|
|
199
|
+
params: ["table"],
|
|
200
|
+
queryFlags: ["limit", "page", "sort", "order"],
|
|
201
|
+
extractData: (res) => res.data ?? [],
|
|
202
|
+
formatHeader: (res) => {
|
|
203
|
+
const m = res.meta;
|
|
204
|
+
return m ? `Page ${m.page}/${m.pages} (${m.total} total rows)` : undefined;
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
pattern: ["tables", "get", ":table", ":id"],
|
|
209
|
+
description: "Get a single row by ID",
|
|
210
|
+
method: "GET",
|
|
211
|
+
path: "/tables/:table/:id",
|
|
212
|
+
params: ["table", "id"],
|
|
213
|
+
extractData: (res) => res.data ? [res.data] : [],
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
pattern: ["tables", "create", ":table"],
|
|
217
|
+
description: "Insert one or more rows into a table",
|
|
218
|
+
method: "POST",
|
|
219
|
+
path: "/tables/:table",
|
|
220
|
+
params: ["table"],
|
|
221
|
+
bodyField: "data",
|
|
222
|
+
mutating: true,
|
|
223
|
+
extractData: (res) => {
|
|
224
|
+
const d = res.data;
|
|
225
|
+
return Array.isArray(d) ? d : d ? [d] : [];
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
pattern: ["tables", "update", ":table", ":id"],
|
|
230
|
+
description: "Update a row by ID",
|
|
231
|
+
method: "PUT",
|
|
232
|
+
path: "/tables/:table/:id",
|
|
233
|
+
params: ["table", "id"],
|
|
234
|
+
bodyField: "data",
|
|
235
|
+
mutating: true,
|
|
236
|
+
extractData: (res) => res.data ? [res.data] : [],
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
pattern: ["tables", "delete", ":table", ":id"],
|
|
240
|
+
description: "Delete a row by ID",
|
|
241
|
+
method: "DELETE",
|
|
242
|
+
path: "/tables/:table/:id",
|
|
243
|
+
params: ["table", "id"],
|
|
244
|
+
mutating: true,
|
|
245
|
+
extractData: (res) => res.data ? [res.data] : [],
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
// ── /reports ───────────────────────────────────────────────────────────
|
|
249
|
+
{
|
|
250
|
+
pattern: ["reports"],
|
|
251
|
+
description: "List all report definitions",
|
|
252
|
+
method: "GET",
|
|
253
|
+
path: "/reports",
|
|
254
|
+
params: [],
|
|
255
|
+
extractData: (res) =>
|
|
256
|
+
(res.reports ?? []).map((r: any) => ({
|
|
257
|
+
name: r.name,
|
|
258
|
+
label: r.label ?? r.name,
|
|
259
|
+
params: (r.params ?? []).map((p: any) => p.name).join(", "),
|
|
260
|
+
})),
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
pattern: ["reports", "show", ":name"],
|
|
264
|
+
description: "Get report metadata and parameters",
|
|
265
|
+
method: "GET",
|
|
266
|
+
path: "/reports/:name",
|
|
267
|
+
params: ["name"],
|
|
268
|
+
extractData: (res) => [res],
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
pattern: ["reports", "run", ":name"],
|
|
272
|
+
description: "Execute a report with parameters",
|
|
273
|
+
method: "GET",
|
|
274
|
+
path: "/reports/:name/results",
|
|
275
|
+
params: ["name"],
|
|
276
|
+
queryFlags: ["*"],
|
|
277
|
+
extractData: (res) => {
|
|
278
|
+
if (res.nodes) return res.nodes;
|
|
279
|
+
if (Array.isArray(res)) return res;
|
|
280
|
+
return [res];
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
// ── /actions ───────────────────────────────────────────────────────────
|
|
285
|
+
{
|
|
286
|
+
pattern: ["actions"],
|
|
287
|
+
description: "List all available actions with input schemas",
|
|
288
|
+
method: "GET",
|
|
289
|
+
path: "/actions",
|
|
290
|
+
params: [],
|
|
291
|
+
extractData: (res) =>
|
|
292
|
+
(res.actions ?? res ?? []).map((a: any) => ({
|
|
293
|
+
name: a.name,
|
|
294
|
+
label: a.label ?? a.name,
|
|
295
|
+
})),
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
pattern: ["actions", "run", ":name"],
|
|
299
|
+
description: "Execute an action",
|
|
300
|
+
method: "POST",
|
|
301
|
+
path: "/actions/:name",
|
|
302
|
+
params: ["name"],
|
|
303
|
+
bodyField: "data",
|
|
304
|
+
mutating: true,
|
|
305
|
+
extractData: (res) => {
|
|
306
|
+
const d = res.data;
|
|
307
|
+
return Array.isArray(d) ? d : d ? [d] : [res];
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
// ── /views ─────────────────────────────────────────────────────────────
|
|
312
|
+
{
|
|
313
|
+
pattern: ["views"],
|
|
314
|
+
description: "List all custom views with metadata",
|
|
315
|
+
method: "GET",
|
|
316
|
+
path: "/views",
|
|
317
|
+
params: [],
|
|
318
|
+
extractData: (res) =>
|
|
319
|
+
(res.views ?? res ?? []).map((v: any) => ({
|
|
320
|
+
name: v.name,
|
|
321
|
+
label: v.label ?? v.name,
|
|
322
|
+
icon: v.icon ?? "",
|
|
323
|
+
})),
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
// ── Commander registration ──────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
export type RouteActionHandler = (
|
|
330
|
+
route: CliRoute,
|
|
331
|
+
params: Record<string, string>,
|
|
332
|
+
extraPositionals: string[],
|
|
333
|
+
) => Promise<void>;
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Register all CLI routes as Commander subcommands.
|
|
337
|
+
*
|
|
338
|
+
* Builds a command hierarchy from each route's fixed segments, adds
|
|
339
|
+
* positional arguments for URL params and positionalArgs, then wires
|
|
340
|
+
* the action callback through the provided handler.
|
|
341
|
+
*
|
|
342
|
+
* Commander handles routing, required-argument validation, and help
|
|
343
|
+
* generation. The handler receives the matched route, extracted URL
|
|
344
|
+
* params, and any extra positional values (for positionalArgs fields).
|
|
345
|
+
*/
|
|
346
|
+
export function registerRoutes(
|
|
347
|
+
program: Command,
|
|
348
|
+
routes: CliRoute[],
|
|
349
|
+
handler: RouteActionHandler,
|
|
350
|
+
): void {
|
|
351
|
+
for (const route of routes) {
|
|
352
|
+
const fixed: string[] = [];
|
|
353
|
+
const params: string[] = [];
|
|
354
|
+
for (const token of route.pattern) {
|
|
355
|
+
if (token.startsWith(":")) params.push(token.slice(1));
|
|
356
|
+
else fixed.push(token);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (fixed.length === 0) continue;
|
|
360
|
+
|
|
361
|
+
// Navigate/create command hierarchy for fixed[0..len-2]
|
|
362
|
+
let parent: Command = program;
|
|
363
|
+
for (let i = 0; i < fixed.length - 1; i++) {
|
|
364
|
+
let existing = parent.commands.find((c) => c.name() === fixed[i]);
|
|
365
|
+
if (!existing) {
|
|
366
|
+
existing = parent.command(fixed[i]);
|
|
367
|
+
existing.allowUnknownOption();
|
|
368
|
+
existing.allowExcessArguments(true);
|
|
369
|
+
}
|
|
370
|
+
parent = existing;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Create or reuse the leaf command
|
|
374
|
+
const leafName = fixed[fixed.length - 1];
|
|
375
|
+
let cmd = parent.commands.find((c) => c.name() === leafName);
|
|
376
|
+
if (!cmd) {
|
|
377
|
+
cmd = parent.command(leafName);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
cmd.description(route.description);
|
|
381
|
+
|
|
382
|
+
// URL params become required arguments
|
|
383
|
+
for (const p of params) {
|
|
384
|
+
cmd.argument(`<${p}>`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// positionalArgs become optional arguments (e.g. [sql] for "meta sql query")
|
|
388
|
+
if (route.positionalArgs) {
|
|
389
|
+
for (const pa of route.positionalArgs) {
|
|
390
|
+
cmd.argument(`[${pa.field}]`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
cmd.allowUnknownOption();
|
|
395
|
+
cmd.allowExcessArguments(true);
|
|
396
|
+
|
|
397
|
+
// Capture route and params list in closure
|
|
398
|
+
const routeRef = route;
|
|
399
|
+
const paramNames = params;
|
|
400
|
+
|
|
401
|
+
cmd.action(async (...actionArgs: any[]) => {
|
|
402
|
+
actionArgs.pop(); // Command instance
|
|
403
|
+
actionArgs.pop(); // Commander-parsed options (unused — we parse flags ourselves)
|
|
404
|
+
const positionalValues = actionArgs as string[];
|
|
405
|
+
|
|
406
|
+
// Map declared positional values to route URL params
|
|
407
|
+
const routeParams: Record<string, string> = {};
|
|
408
|
+
for (let i = 0; i < paramNames.length; i++) {
|
|
409
|
+
routeParams[paramNames[i]] = positionalValues[i];
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Remaining positionals go to positionalArgs mapping in buildRequest
|
|
413
|
+
const extraPositionals = positionalValues.slice(paramNames.length);
|
|
414
|
+
|
|
415
|
+
await handler(routeRef, routeParams, extraPositionals);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { rowsInsertMasterDetail } from "./rows-insert-master-detail.js";
|
|
4
|
+
import type { SqlClient } from "../introspect/types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a SqlClient adapter from a better-sqlite3 Database.
|
|
8
|
+
*
|
|
9
|
+
* The returned object is both a SqlClient (for rowsInsertMasterDetail's type
|
|
10
|
+
* signature) and a Database.Database proxy (for the `sql as any` casts inside
|
|
11
|
+
* rowsInsertMasterDetail that pass it to synchronous db-helpers functions).
|
|
12
|
+
*
|
|
13
|
+
* begin() wraps the callback in a SQLite transaction using SAVEPOINT for
|
|
14
|
+
* nested transaction semantics. The transaction SqlClient passed to the
|
|
15
|
+
* callback also carries the sqlite handle's native methods.
|
|
16
|
+
*/
|
|
17
|
+
function createTestSql(sqlite: Database.Database): SqlClient & Database.Database {
|
|
18
|
+
const extended = sqlite as any;
|
|
19
|
+
extended.unsafe = (query: string, params?: any[]) => {
|
|
20
|
+
return Promise.resolve(sqlite.prepare(query).all(...(params ?? [])));
|
|
21
|
+
};
|
|
22
|
+
extended.begin = async (fn: (tx: SqlClient) => Promise<any>) => {
|
|
23
|
+
sqlite.exec("BEGIN");
|
|
24
|
+
try {
|
|
25
|
+
const txExtended = sqlite as any;
|
|
26
|
+
// tx.unsafe is already on the sqlite handle from the outer assignment
|
|
27
|
+
const result = await fn(txExtended);
|
|
28
|
+
sqlite.exec("COMMIT");
|
|
29
|
+
return result;
|
|
30
|
+
} catch (err) {
|
|
31
|
+
sqlite.exec("ROLLBACK");
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
extended.end = async () => {};
|
|
36
|
+
return extended;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("rows insert-master-detail", () => {
|
|
40
|
+
let sqlite: Database.Database;
|
|
41
|
+
let sql: SqlClient;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
sqlite = new Database(":memory:");
|
|
45
|
+
sqlite.pragma("foreign_keys = ON");
|
|
46
|
+
sql = createTestSql(sqlite);
|
|
47
|
+
sqlite.exec(`
|
|
48
|
+
CREATE TABLE orders (
|
|
49
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
50
|
+
customer TEXT NOT NULL
|
|
51
|
+
);
|
|
52
|
+
CREATE TABLE order_items (
|
|
53
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
54
|
+
order_id INTEGER NOT NULL REFERENCES orders(id),
|
|
55
|
+
product TEXT NOT NULL,
|
|
56
|
+
quantity INTEGER NOT NULL
|
|
57
|
+
);
|
|
58
|
+
`);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
sqlite.close();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("inserts master and detail rows atomically", async () => {
|
|
66
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
67
|
+
|
|
68
|
+
await rowsInsertMasterDetail(sql, {
|
|
69
|
+
"master-table": "orders",
|
|
70
|
+
"master-data": '{"customer":"Alice"}',
|
|
71
|
+
"detail-table": "order_items",
|
|
72
|
+
"detail-data": '[{"product":"Widget","quantity":3},{"product":"Gadget","quantity":1}]',
|
|
73
|
+
"detail-fk": "order_id",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const orders = sqlite.prepare("SELECT * FROM orders").all() as any[];
|
|
77
|
+
expect(orders).toHaveLength(1);
|
|
78
|
+
expect(orders[0].customer).toBe("Alice");
|
|
79
|
+
|
|
80
|
+
const items = sqlite.prepare("SELECT * FROM order_items ORDER BY id").all() as any[];
|
|
81
|
+
expect(items).toHaveLength(2);
|
|
82
|
+
expect(items[0].order_id).toBe(1);
|
|
83
|
+
expect(items[0].product).toBe("Widget");
|
|
84
|
+
expect(items[1].order_id).toBe(1);
|
|
85
|
+
expect(items[1].product).toBe("Gadget");
|
|
86
|
+
|
|
87
|
+
log.mockRestore();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("backfills FK column from master ID", async () => {
|
|
91
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
92
|
+
|
|
93
|
+
await rowsInsertMasterDetail(sql, {
|
|
94
|
+
"master-table": "orders",
|
|
95
|
+
"master-data": '{"customer":"Bob"}',
|
|
96
|
+
"detail-table": "order_items",
|
|
97
|
+
"detail-data": '[{"product":"Thing","quantity":5}]',
|
|
98
|
+
"detail-fk": "order_id",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const items = sqlite.prepare("SELECT * FROM order_items").all() as any[];
|
|
102
|
+
expect(items[0].order_id).toBe(1);
|
|
103
|
+
|
|
104
|
+
log.mockRestore();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("throws when required flags are missing", async () => {
|
|
108
|
+
await expect(
|
|
109
|
+
rowsInsertMasterDetail(sql, { "master-table": "orders" }),
|
|
110
|
+
).rejects.toThrow("Usage:");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("rejects invalid table names", async () => {
|
|
114
|
+
await expect(
|
|
115
|
+
rowsInsertMasterDetail(sql, {
|
|
116
|
+
"master-table": "orders; DROP TABLE orders;--",
|
|
117
|
+
"master-data": '{"customer":"x"}',
|
|
118
|
+
"detail-table": "order_items",
|
|
119
|
+
"detail-data": "[{}]",
|
|
120
|
+
"detail-fk": "order_id",
|
|
121
|
+
}),
|
|
122
|
+
).rejects.toThrow("Invalid table name");
|
|
123
|
+
});
|
|
124
|
+
});
|