@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.
Files changed (133) hide show
  1. package/package.json +40 -0
  2. package/src/actions/action.test.ts +108 -0
  3. package/src/actions/action.ts +60 -0
  4. package/src/actions/loader.ts +47 -0
  5. package/src/api/actions.ts +124 -0
  6. package/src/api/meta-mutations.ts +922 -0
  7. package/src/api/meta.ts +222 -0
  8. package/src/api/reports.ts +98 -0
  9. package/src/api/server.ts +24 -0
  10. package/src/api/tables.ts +108 -0
  11. package/src/api/views.ts +44 -0
  12. package/src/boot.ts +206 -0
  13. package/src/cli/ai-commands.ts +220 -0
  14. package/src/cli/check.ts +169 -0
  15. package/src/cli/cli-utils.test.ts +313 -0
  16. package/src/cli/describe.test.ts +151 -0
  17. package/src/cli/describe.ts +88 -0
  18. package/src/cli/emit-result.test.ts +160 -0
  19. package/src/cli/format.ts +150 -0
  20. package/src/cli/http-client.ts +55 -0
  21. package/src/cli/index.ts +162 -0
  22. package/src/cli/init.ts +35 -0
  23. package/src/cli/project-context.ts +38 -0
  24. package/src/cli/request.ts +146 -0
  25. package/src/cli/routes.ts +418 -0
  26. package/src/cli/rows-insert-master-detail.test.ts +124 -0
  27. package/src/cli/rows-insert-master-detail.ts +186 -0
  28. package/src/cli/rows-insert.test.ts +137 -0
  29. package/src/cli/rows-insert.ts +97 -0
  30. package/src/cli/serve-single.ts +49 -0
  31. package/src/create-project.ts +81 -0
  32. package/src/data/count.ts +62 -0
  33. package/src/data/crud.test.ts +188 -0
  34. package/src/data/crud.ts +242 -0
  35. package/src/data/lookup.test.ts +96 -0
  36. package/src/data/lookup.ts +104 -0
  37. package/src/data/query-parser.test.ts +67 -0
  38. package/src/data/query-parser.ts +106 -0
  39. package/src/data/sanitize.test.ts +57 -0
  40. package/src/data/sanitize.ts +25 -0
  41. package/src/data/save-pipeline.test.ts +115 -0
  42. package/src/data/save-pipeline.ts +93 -0
  43. package/src/data/validate.test.ts +110 -0
  44. package/src/data/validate.ts +98 -0
  45. package/src/db/errors.ts +20 -0
  46. package/src/db/logger.ts +63 -0
  47. package/src/db/sqlite-connection.test.ts +59 -0
  48. package/src/db/sqlite-connection.ts +79 -0
  49. package/src/index.ts +111 -0
  50. package/src/integration/api-actions.test.ts +60 -0
  51. package/src/integration/api-global.test.ts +21 -0
  52. package/src/integration/api-meta.test.ts +252 -0
  53. package/src/integration/api-reports.test.ts +77 -0
  54. package/src/integration/api-tables.test.ts +238 -0
  55. package/src/integration/api-views.test.ts +39 -0
  56. package/src/integration/cli-routes.test.ts +167 -0
  57. package/src/integration/fixtures/actions/create-account.ts +23 -0
  58. package/src/integration/fixtures/reports/account-list.ts +25 -0
  59. package/src/integration/fixtures/schema/accounts.ts +21 -0
  60. package/src/integration/fixtures/schema/audit-log.ts +19 -0
  61. package/src/integration/fixtures/schema/journal-entries.ts +20 -0
  62. package/src/integration/fixtures/views/dashboard.tsx +4 -0
  63. package/src/integration/fixtures/views/settings.tsx +3 -0
  64. package/src/integration/setup.ts +72 -0
  65. package/src/introspect/db-helpers.ts +109 -0
  66. package/src/introspect/describe-all.test.ts +73 -0
  67. package/src/introspect/describe-all.ts +80 -0
  68. package/src/introspect/describe.test.ts +65 -0
  69. package/src/introspect/describe.ts +184 -0
  70. package/src/introspect/exec.test.ts +103 -0
  71. package/src/introspect/exec.ts +57 -0
  72. package/src/introspect/indexes.test.ts +41 -0
  73. package/src/introspect/indexes.ts +95 -0
  74. package/src/introspect/inference.ts +98 -0
  75. package/src/introspect/list-tables.test.ts +40 -0
  76. package/src/introspect/list-tables.ts +62 -0
  77. package/src/introspect/query.test.ts +77 -0
  78. package/src/introspect/query.ts +47 -0
  79. package/src/introspect/sample.test.ts +67 -0
  80. package/src/introspect/sample.ts +50 -0
  81. package/src/introspect/sql-safety.ts +76 -0
  82. package/src/introspect/sqlite/db-helpers.test.ts +79 -0
  83. package/src/introspect/sqlite/db-helpers.ts +56 -0
  84. package/src/introspect/sqlite/describe-all.ts +21 -0
  85. package/src/introspect/sqlite/describe.test.ts +160 -0
  86. package/src/introspect/sqlite/describe.ts +185 -0
  87. package/src/introspect/sqlite/exec.ts +57 -0
  88. package/src/introspect/sqlite/indexes.test.ts +60 -0
  89. package/src/introspect/sqlite/indexes.ts +96 -0
  90. package/src/introspect/sqlite/list-tables.test.ts +100 -0
  91. package/src/introspect/sqlite/list-tables.ts +67 -0
  92. package/src/introspect/sqlite/query.ts +49 -0
  93. package/src/introspect/sqlite/sample.ts +50 -0
  94. package/src/introspect/table-rename.test.ts +235 -0
  95. package/src/introspect/table-rename.ts +115 -0
  96. package/src/introspect/types.ts +95 -0
  97. package/src/reports/check.test.ts +499 -0
  98. package/src/reports/check.ts +208 -0
  99. package/src/reports/engine.test.ts +1465 -0
  100. package/src/reports/engine.ts +678 -0
  101. package/src/reports/loader.ts +55 -0
  102. package/src/reports/report.ts +308 -0
  103. package/src/reports/sql-bind.ts +161 -0
  104. package/src/reports/sqlite-bind.test.ts +98 -0
  105. package/src/reports/sqlite-bind.ts +58 -0
  106. package/src/reports/sqlite-sql-client.ts +42 -0
  107. package/src/runtime.ts +3 -0
  108. package/src/schema/check.ts +90 -0
  109. package/src/schema/ddl.test.ts +210 -0
  110. package/src/schema/ddl.ts +180 -0
  111. package/src/schema/dynamic-builder.ts +297 -0
  112. package/src/schema/extract.test.ts +261 -0
  113. package/src/schema/extract.ts +285 -0
  114. package/src/schema/loader.test.ts +31 -0
  115. package/src/schema/loader.ts +60 -0
  116. package/src/schema/metadata-io.test.ts +261 -0
  117. package/src/schema/metadata-io.ts +161 -0
  118. package/src/schema/metadata-tables.test.ts +737 -0
  119. package/src/schema/metadata-tables.ts +341 -0
  120. package/src/schema/migrate.ts +195 -0
  121. package/src/schema/normalize-datatype.test.ts +58 -0
  122. package/src/schema/normalize-datatype.ts +99 -0
  123. package/src/schema/registry.test.ts +174 -0
  124. package/src/schema/registry.ts +139 -0
  125. package/src/schema/reserved.ts +227 -0
  126. package/src/schema/table.ts +135 -0
  127. package/src/test-fixtures/schema/accounts.ts +24 -0
  128. package/src/test-fixtures/schema/not-a-table.ts +6 -0
  129. package/src/testing/test-utils.ts +44 -0
  130. package/src/views/loader.test.ts +70 -0
  131. package/src/views/loader.ts +38 -0
  132. package/src/views/view.test.ts +121 -0
  133. 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
+ });