@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
package/src/boot.ts ADDED
@@ -0,0 +1,206 @@
1
+ import { Hono } from "hono";
2
+ import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
3
+ import type Database from "better-sqlite3";
4
+ import type { ActionInstance } from "./actions/action.js";
5
+ import type { ReportDefinition } from "./reports/report.js";
6
+ import type { ViewMeta } from "./views/view.js";
7
+ import type { ProjectDbConnection } from "./db/sqlite-connection.js";
8
+ import { loadSchemas } from "./schema/loader.js";
9
+ import { migrateSchemas } from "./schema/migrate.js";
10
+ import { loadActions } from "./actions/loader.js";
11
+ import { actionApi } from "./api/actions.js";
12
+ import { loadReports } from "./reports/loader.js";
13
+ import { reportApi } from "./api/reports.js";
14
+ import { loadViewMeta } from "./views/loader.js";
15
+ import { viewApi } from "./api/views.js";
16
+ import { SchemaRegistry } from "./schema/registry.js";
17
+ import { tablesApi } from "./api/tables.js";
18
+ import { ensureMetadataTables, loadUISchemas, validateUISchemas } from "./schema/metadata-tables.js";
19
+ import { metaApi } from "./api/meta.js";
20
+ import { logger } from "./db/logger.js";
21
+
22
+ const log = logger.child({ module: "boot" });
23
+
24
+ export interface ProjectConfig {
25
+ slug: string;
26
+ /**
27
+ * Absolute path to the SQLite database file. Persisted in the registry at
28
+ * creation time and passed through unchanged at boot time. The storage
29
+ * layer derives this path once (during create), then it's stored forever.
30
+ */
31
+ databasePath: string;
32
+ /**
33
+ * Absolute path to the project's filesystem directory (containing schema/,
34
+ * actions/, reports/, views/ subdirectories). null for API-created projects
35
+ * that have no filesystem presence — they rely entirely on UI-managed schemas.
36
+ */
37
+ dir: string | null;
38
+ }
39
+
40
+ /** Runtime context for a single booted project. */
41
+ export interface InProcessProject {
42
+ kind: "in-process";
43
+ slug: string;
44
+ dir: string | null;
45
+ db: BetterSQLite3Database;
46
+ sqlite: Database.Database;
47
+ /** Source of truth for table schemas. Use registry.all() for the live list. */
48
+ registry: SchemaRegistry;
49
+ actions: ActionInstance<any, any>[];
50
+ reports: ReportDefinition[];
51
+ views: ViewMeta[];
52
+ /**
53
+ * Per-project Hono sub-app containing all of this project's routes.
54
+ *
55
+ * The root app does NOT mount project routes directly (via app.route).
56
+ * Instead, a single /p/:slug/* catch-all dispatches to the appropriate
57
+ * project's subApp by looking up projectMap. This is necessary because
58
+ * Hono compiles its route tree on the first request — any app.route()
59
+ * call after that throws "Can not add a route since the matcher is
60
+ * already built". The sub-app pattern sidesteps this: the catch-all is
61
+ * registered once at startup, and new projects just need to be added
62
+ * to the Map to become routable.
63
+ */
64
+ subApp: Hono;
65
+ }
66
+
67
+ // ── Boot ──────────────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Boot a single project: connect DB, load schemas, migrate, build sub-app.
71
+ *
72
+ * The boot sequence has ordering constraints — steps must run in this order:
73
+ *
74
+ * 1. ensureMetadataTables — creates _sapporta_tables/_sapporta_columns if
75
+ * they don't exist. MUST run before loadUISchemas (step 5) which reads
76
+ * from these tables.
77
+ *
78
+ * 2. Load file schemas from disk (if dir is set). File schemas are registered
79
+ * FIRST so they take precedence over UI schemas.
80
+ *
81
+ * 3. loadUISchemas — reads UI-managed table definitions from the metadata
82
+ * tables. Needs file schemas already registered so that FK references
83
+ * from UI tables to file tables resolve correctly.
84
+ *
85
+ * 4. migrateSchemas — applies Drizzle migrations for FILE-managed tables
86
+ * only. UI tables use direct DDL from the mutation API. Running
87
+ * pushSchema on UI tables risks destructive ALTER statements if
88
+ * metadata drifts from actual DB state.
89
+ *
90
+ * 5. validateUISchemas — compares UI metadata against actual DB state.
91
+ * Runs AFTER migration so file tables exist. Warns on drift but
92
+ * doesn't auto-fix.
93
+ *
94
+ * 6. Build the per-project Hono sub-app with all five namespaces mounted
95
+ * unconditionally: /meta, /tables, /actions, /reports, /views. Routes
96
+ * are always present even when there are no resources — this avoids
97
+ * the Hono "can't add routes after compilation" constraint.
98
+ *
99
+ * Returns a InProcessProject that includes the sub-app. The caller adds
100
+ * this to projectMap, which makes the project's routes immediately accessible
101
+ * via the /p/:slug/* dynamic dispatch in runtime().
102
+ */
103
+ export async function bootProject(
104
+ project: ProjectConfig,
105
+ conn: ProjectDbConnection,
106
+ ): Promise<InProcessProject> {
107
+ log.info(`Booting project "${project.slug}"`, { project: project.slug, dir: project.dir });
108
+
109
+ const { sqlite, db } = conn;
110
+ log.info("Database connected", { project: project.slug });
111
+
112
+ // Step 1: Create metadata tables. Must happen before loadUISchemas.
113
+ ensureMetadataTables(sqlite);
114
+
115
+ // Steps 2-3: Load schemas. File schemas first (take precedence), then UI schemas.
116
+ // API-created projects (dir === null) skip file schema loading entirely — they
117
+ // have no filesystem directory and rely on UI-managed schemas exclusively.
118
+ const registry = new SchemaRegistry();
119
+ if (project.dir) {
120
+ const schemaDir = `${project.dir}/schema`;
121
+ const { tables } = await loadSchemas(schemaDir);
122
+ log.info("Loaded schemas", { project: project.slug, tables: tables.length });
123
+
124
+ // File schemas registered first — if a UI table has the same name,
125
+ // it becomes "shadowed" (tracked but not active).
126
+ for (const def of tables) {
127
+ registry.register(def, "file");
128
+ }
129
+ }
130
+
131
+ // UI schemas loaded second. The registry already has file schemas, so FK
132
+ // resolution from UI tables to file tables works immediately.
133
+ const uiDefs = loadUISchemas(sqlite, registry);
134
+ for (const def of uiDefs) {
135
+ registry.register(def, "ui");
136
+ }
137
+ if (uiDefs.length > 0) {
138
+ const activeUI = registry.uiManaged().length;
139
+ const shadowed = uiDefs.length - activeUI;
140
+ log.info("Loaded UI-managed tables", { project: project.slug, total: uiDefs.length, active: activeUI, shadowed });
141
+ }
142
+
143
+ // Step 4: Migrate file-managed tables only.
144
+ await migrateSchemas(registry.fileManaged(), db, sqlite);
145
+
146
+ // Step 5: Validate UI schemas against actual DB state. Warns on drift.
147
+ validateUISchemas(sqlite, registry, project.slug);
148
+
149
+ // Step 6: Load resources and build the per-project Hono sub-app.
150
+ //
151
+ // All five namespaces are mounted UNCONDITIONALLY — even when there are
152
+ // no actions/reports/views. This avoids the Hono route-tree-compilation
153
+ // constraint: Hono compiles its route tree on first request, so all
154
+ // routes must exist before then.
155
+
156
+ let actions: ActionInstance<any, any>[] = [];
157
+ let reports: ReportDefinition[] = [];
158
+ let views: ViewMeta[] = [];
159
+
160
+ // File-based resources (actions, reports, views) only exist for filesystem
161
+ // projects. API-created projects have dir === null and skip this entirely.
162
+ if (project.dir) {
163
+ const actionsDir = `${project.dir}/actions`;
164
+ try {
165
+ actions = await loadActions(actionsDir);
166
+ if (actions.length > 0) {
167
+ log.info("Loaded actions", { project: project.slug, count: actions.length });
168
+ }
169
+ } catch {
170
+ // Actions dir may not exist yet — not an error
171
+ }
172
+
173
+ const reportsDir = `${project.dir}/reports`;
174
+ try {
175
+ reports = await loadReports(reportsDir);
176
+ if (reports.length > 0) {
177
+ log.info("Loaded reports", { project: project.slug, count: reports.length });
178
+ }
179
+ } catch {
180
+ // Reports dir may not exist yet
181
+ }
182
+
183
+ const viewsDir = `${project.dir}/views`;
184
+ try {
185
+ views = await loadViewMeta(viewsDir);
186
+ if (views.length > 0) {
187
+ log.info("Loaded views", { project: project.slug, count: views.length });
188
+ }
189
+ } catch {
190
+ // Views dir may not exist yet
191
+ }
192
+ }
193
+
194
+ // Routes are relative (e.g. /meta, /tables) — the /p/{slug} prefix is
195
+ // stripped by the dynamic dispatch in runtime() before reaching this sub-app.
196
+ const subApp = new Hono();
197
+ subApp.route("/meta", metaApi(registry, sqlite, db, project));
198
+ subApp.route("/tables", tablesApi(registry, db));
199
+ subApp.route("/actions", actionApi(actions, db));
200
+ subApp.route("/reports", reportApi(reports, sqlite));
201
+ subApp.route("/views", viewApi(views));
202
+
203
+ log.info("Project routes mounted", { project: project.slug, tables: registry.all().length });
204
+
205
+ return { kind: "in-process" as const, slug: project.slug, dir: project.dir, db, sqlite, registry, actions, reports, views, subApp };
206
+ }
@@ -0,0 +1,220 @@
1
+ import { z } from "zod";
2
+ import { dbListTables } from "../introspect/list-tables.js";
3
+ import { dbDescribe } from "../introspect/describe.js";
4
+ import { dbDescribeAll } from "../introspect/describe-all.js";
5
+ // SQLite has no native enum type — enums command removed.
6
+ import { dbQuery } from "../introspect/query.js";
7
+ import { dbExec } from "../introspect/exec.js";
8
+ import { dbSample } from "../introspect/sample.js";
9
+ import { dbIndexes } from "../introspect/indexes.js";
10
+ import { rowsInsert } from "./rows-insert.js";
11
+ import { rowsInsertInput } from "./rows-insert.js";
12
+ import { rowsInsertMasterDetail } from "./rows-insert-master-detail.js";
13
+ import { rowsInsertMasterDetailInput } from "./rows-insert-master-detail.js";
14
+ import { tableRename } from "../introspect/table-rename.js";
15
+ import { tableRenameInput } from "../introspect/table-rename.js";
16
+ type AiCommandMeta = {
17
+ command: string;
18
+ description: string;
19
+ mutating?: boolean;
20
+ inputSchema?: z.ZodType;
21
+ examples?: string[];
22
+ };
23
+ import type { SqlClient, OperationResult } from "../introspect/types.js";
24
+ import { OperationError, ErrorCode } from "../introspect/types.js";
25
+ import { resolveOutputFormat } from "./format.js";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // AiCommand type
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export type AiCommand = {
32
+ description: string;
33
+ inputSchema: z.ZodType;
34
+ mutating?: boolean;
35
+ examples?: string[];
36
+ /** Convert positional args + flags into the input object shape. */
37
+ parseArgs: (positional: string[], flags: Record<string, string>) => unknown;
38
+ /** Number of leading positional args to consume before flag parsing. */
39
+ positionalCount: number;
40
+ /** Usage string shown when required positional arg is missing (flag mode). */
41
+ usage?: string;
42
+ // TODO: PLAN-3 — Change SqlClient to Database.Database once CLI is fully migrated.
43
+ // For now, the CLI runner casts the connection to the expected type.
44
+ run: (sql: SqlClient, input: any) => Promise<OperationResult>;
45
+ };
46
+
47
+ // Zod schemas for introspection commands (these were previously exported from
48
+ // the Postgres introspection modules but are now defined inline since the SQLite
49
+ // versions don't need them for validation — they're only used by the CLI parser).
50
+ const dbListTablesInput = z.object({});
51
+ const dbDescribeInput = z.object({ table: z.string() });
52
+ const dbDescribeAllInput = z.object({});
53
+ const dbQueryInput = z.object({ sql: z.string(), limit: z.number().optional() });
54
+ const dbExecInput = z.object({ sql: z.string(), dryRun: z.boolean().optional() });
55
+ const dbSampleInput = z.object({
56
+ table: z.string(),
57
+ limit: z.number().optional(),
58
+ fields: z.string().optional(),
59
+ });
60
+ const dbIndexesInput = z.object({ table: z.string() });
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Command definitions
64
+ // ---------------------------------------------------------------------------
65
+ // Each command uses the SQLite introspection functions which are synchronous.
66
+ // The run() signature expects Promise<OperationResult>, so we wrap sync
67
+ // results with Promise.resolve(). The SqlClient → Database cast is handled
68
+ // by the CLI runner which passes the raw better-sqlite3 handle.
69
+
70
+ export const AI_COMMANDS: Record<string, AiCommand> = {
71
+ "meta tables": {
72
+ description: "List all tables with schema metadata and row counts",
73
+ inputSchema: dbListTablesInput,
74
+ positionalCount: 0,
75
+ parseArgs: () => ({}),
76
+ run: (sql) => Promise.resolve(dbListTables(sql as any)),
77
+ },
78
+ "meta tables describe": {
79
+ description: "Show table structure (columns, types, constraints, foreign keys)",
80
+ inputSchema: dbDescribeInput,
81
+ positionalCount: 1,
82
+ parseArgs: ([table]) => ({ table }),
83
+ run: (sql, { table }) =>
84
+ Promise.resolve(dbDescribe(sql as any, table)),
85
+ },
86
+ "meta tables describe-all": {
87
+ description: "Dump full database schema (all tables, columns, FKs) in one call",
88
+ inputSchema: dbDescribeAllInput,
89
+ positionalCount: 0,
90
+ parseArgs: () => ({}),
91
+ run: (sql) => Promise.resolve(dbDescribeAll(sql as any)),
92
+ },
93
+ // SQLite has no native enum type — enum values are in column definitions.
94
+ "meta sql query": {
95
+ description: "Run a read-only SQL query (SELECT/WITH only)",
96
+ inputSchema: dbQueryInput,
97
+ positionalCount: 1,
98
+ usage: 'sapporta meta sql query "<sql>"',
99
+ examples: ['sapporta meta sql query --json \'{"sql": "SELECT * FROM accounts", "limit": 50}\''],
100
+ parseArgs: ([sql], flags) => ({
101
+ sql,
102
+ limit: flags.limit ? parseInt(flags.limit, 10) : undefined,
103
+ }),
104
+ run: (sql, { sql: query, limit }) =>
105
+ Promise.resolve(dbQuery(sql as any, query, limit)),
106
+ },
107
+ "meta sql exec": {
108
+ description: "Execute a mutating SQL statement (INSERT, UPDATE, DELETE, DDL)",
109
+ inputSchema: dbExecInput,
110
+ mutating: true,
111
+ positionalCount: 1,
112
+ usage: 'sapporta meta sql exec "<sql>"',
113
+ parseArgs: ([sql], flags) => ({
114
+ sql,
115
+ dryRun: flags.dryRun === "true",
116
+ }),
117
+ run: (sql, { sql: query, dryRun }) =>
118
+ Promise.resolve(dbExec(sql as any, query, dryRun)),
119
+ },
120
+ "meta tables sample": {
121
+ description: "Show sample rows from a table",
122
+ inputSchema: dbSampleInput,
123
+ positionalCount: 1,
124
+ parseArgs: ([table], flags) => ({
125
+ table,
126
+ limit: flags.limit ? parseInt(flags.limit, 10) : undefined,
127
+ fields: flags.fields,
128
+ }),
129
+ run: (sql, { table, limit, fields }) =>
130
+ Promise.resolve(dbSample(sql as any, table, limit, fields?.split(","))),
131
+ },
132
+ "meta tables indexes": {
133
+ description: "Show indexes on a table",
134
+ inputSchema: dbIndexesInput,
135
+ positionalCount: 1,
136
+ parseArgs: ([table]) => ({ table }),
137
+ run: (sql, { table }) =>
138
+ Promise.resolve(dbIndexes(sql as any, table)),
139
+ },
140
+ "tables insert": {
141
+ description: "Insert one or more rows into a table",
142
+ inputSchema: rowsInsertInput,
143
+ mutating: true,
144
+ positionalCount: 1,
145
+ parseArgs: ([table], flags) => ({
146
+ table,
147
+ data: flags.data,
148
+ }),
149
+ run: (sql, input) => rowsInsert(sql, input.table, input.data, input.dryRun),
150
+ },
151
+ "tables insert-master-detail": {
152
+ description: "Insert master record + detail rows atomically",
153
+ inputSchema: rowsInsertMasterDetailInput,
154
+ mutating: true,
155
+ positionalCount: 1,
156
+ parseArgs: ([table], flags) => ({
157
+ table,
158
+ data: flags.data,
159
+ }),
160
+ run: (sql, input) => rowsInsertMasterDetail(sql, input),
161
+ },
162
+ "meta tables rename": {
163
+ description: "Rename a table (updates DDL, metadata, and FK references)",
164
+ inputSchema: tableRenameInput,
165
+ mutating: true,
166
+ positionalCount: 2,
167
+ parseArgs: ([table, newName]) => ({ table, newName }),
168
+ run: (sql, input) => tableRename(sql, input.table, input.newName, input.newLabel),
169
+ },
170
+ };
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // CLI helper — parse command and dispatch
174
+ // ---------------------------------------------------------------------------
175
+
176
+ export function parseCommand(args: string[]): { command: string; positional: string[]; flags: Record<string, string> } | null {
177
+ // Try to match multi-word commands (longest match first)
178
+ const commandKeys = Object.keys(AI_COMMANDS).sort((a, b) => b.length - a.length);
179
+ for (const cmd of commandKeys) {
180
+ const parts = cmd.split(" ");
181
+ if (parts.every((p, i) => args[i] === p)) {
182
+ const rest = args.slice(parts.length);
183
+ const positional: string[] = [];
184
+ const flags: Record<string, string> = {};
185
+
186
+ const cmdDef = AI_COMMANDS[cmd];
187
+ let i = 0;
188
+ while (i < rest.length && positional.length < cmdDef.positionalCount) {
189
+ if (rest[i].startsWith("--")) break;
190
+ positional.push(rest[i]);
191
+ i++;
192
+ }
193
+ while (i < rest.length) {
194
+ const arg = rest[i];
195
+ if (arg.startsWith("--")) {
196
+ const key = arg.replace(/^--/, "");
197
+ flags[key] = rest[i + 1] ?? "true";
198
+ i += 2;
199
+ } else {
200
+ i++;
201
+ }
202
+ }
203
+ return { command: cmd, positional, flags };
204
+ }
205
+ }
206
+ return null;
207
+ }
208
+
209
+ /**
210
+ * List of commands formatted for `sapporta describe`.
211
+ */
212
+ export function describeCommands(): AiCommandMeta[] {
213
+ return Object.entries(AI_COMMANDS).map(([command, def]) => ({
214
+ command: `sapporta ${command}`,
215
+ description: def.description,
216
+ mutating: def.mutating,
217
+ inputSchema: def.inputSchema,
218
+ examples: def.examples,
219
+ }));
220
+ }
@@ -0,0 +1,169 @@
1
+ import { resolve, join } from "node:path";
2
+ import { loadReports } from "../reports/loader.js";
3
+ import { loadSchemas } from "../schema/loader.js";
4
+ import { checkReportDefinition, checkReportSqlColumns, type CheckSql } from "../reports/check.js";
5
+ import { checkSchemaDefinitions } from "../schema/check.js";
6
+ import type { OperationResult } from "../introspect/types.js";
7
+ import { parseFlags } from "./format.js";
8
+ import { connectProject } from "../db/sqlite-connection.js";
9
+ import { resolveProjectContext } from "./project-context.js";
10
+
11
+ /**
12
+ * Validate project schemas and reports statically (and optionally with DB).
13
+ *
14
+ * Returns structured validation results. The router is responsible for
15
+ * setting the exit code based on result.meta.hasIssues.
16
+ *
17
+ * Note: This command still renders its own text output via meta.message
18
+ * because the checkmark/cross-mark format doesn't map to a simple table.
19
+ */
20
+ export async function check(args: string[]): Promise<OperationResult> {
21
+ const flags = parseFlags(args);
22
+ const ctx = await resolveProjectContext(flags);
23
+
24
+ if (!ctx.dir) {
25
+ return {
26
+ ok: false,
27
+ error: "Cannot check an API-created project — it has no schema directory",
28
+ code: "VALIDATION_FAILED",
29
+ };
30
+ }
31
+ const projectDir = resolve(ctx.dir);
32
+ const schemaDir = join(projectDir, "schema");
33
+ const reportsDir = join(projectDir, "reports");
34
+
35
+ let hasIssues = false;
36
+ const allIssues: Record<string, unknown>[] = [];
37
+ let textOutput = "";
38
+
39
+ // -- Schema checks --
40
+ let schemas;
41
+ try {
42
+ schemas = await loadSchemas(schemaDir);
43
+ } catch (err: any) {
44
+ if (err.code !== "ENOENT") {
45
+ return {
46
+ ok: false,
47
+ error: `Error loading schemas from ${schemaDir}: ${err.message}`,
48
+ code: "INTERNAL",
49
+ };
50
+ }
51
+ }
52
+
53
+ if (schemas && schemas.tables.length > 0) {
54
+ const schemaIssues = checkSchemaDefinitions(schemas.tables);
55
+
56
+ textOutput += `\nChecking schemas: ${schemas.tables.length} table(s)\n`;
57
+ if (schemaIssues.length === 0) {
58
+ textOutput += " \u2713 No issues found\n";
59
+ } else {
60
+ hasIssues = true;
61
+ for (const issue of schemaIssues) {
62
+ textOutput += ` \u2717 ${issue.table}.${issue.column}: ${issue.message}\n`;
63
+ allIssues.push({
64
+ type: "schema",
65
+ table: issue.table,
66
+ column: issue.column,
67
+ message: issue.message,
68
+ });
69
+ }
70
+ }
71
+ }
72
+
73
+ // -- Report checks --
74
+ let reports: Awaited<ReturnType<typeof loadReports>> | undefined = undefined;
75
+ try {
76
+ reports = await loadReports(reportsDir);
77
+ } catch (err: any) {
78
+ if (err.code !== "ENOENT") {
79
+ return {
80
+ ok: false,
81
+ error: `Error loading reports from ${reportsDir}: ${err.message}`,
82
+ code: "INTERNAL",
83
+ };
84
+ }
85
+ }
86
+
87
+ if (reports && reports.length > 0) {
88
+ const issuesByReport = new Map<string, { name: string; issues: { path: string; message: string }[] }>();
89
+ for (const report of reports) {
90
+ issuesByReport.set(report.name, {
91
+ name: report.name,
92
+ issues: checkReportDefinition(report),
93
+ });
94
+ }
95
+
96
+ // DB-aware checks: discover SQL column metadata via the query planner.
97
+ // Open a SQLite connection and adapt it to the CheckSql interface that
98
+ // checkReportSqlColumns expects.
99
+ let conn: ReturnType<typeof connectProject> | null = null;
100
+ try {
101
+ conn = connectProject(ctx.databasePath);
102
+ } catch {
103
+ // Database may not exist yet (e.g. `check` run before first serve)
104
+ }
105
+
106
+ if (conn) {
107
+ try {
108
+ const checkSql: CheckSql = {
109
+ async unsafe(query: string, params?: unknown[]) {
110
+ const stmt = conn!.sqlite.prepare(query);
111
+ const columns = stmt.columns().map((c: any) => ({ name: c.name }));
112
+ const rows = params && params.length > 0 ? stmt.all(...params) : stmt.all();
113
+ (rows as any).columns = columns;
114
+ return rows as any;
115
+ },
116
+ };
117
+ for (const report of reports) {
118
+ const dbIssues = await checkReportSqlColumns(checkSql, report);
119
+ const entry = issuesByReport.get(report.name)!;
120
+ entry.issues.push(...dbIssues);
121
+ }
122
+ } finally {
123
+ conn.sqlite.close();
124
+ }
125
+ }
126
+
127
+ let reportsWithIssues = 0;
128
+
129
+ for (const report of reports) {
130
+ textOutput += `\nChecking report: ${report.name}\n`;
131
+ const { issues } = issuesByReport.get(report.name)!;
132
+
133
+ if (issues.length === 0) {
134
+ textOutput += " \u2713 No issues found\n";
135
+ } else {
136
+ reportsWithIssues++;
137
+ for (const issue of issues) {
138
+ textOutput += ` \u2717 ${issue.path}: ${issue.message}\n`;
139
+ allIssues.push({
140
+ type: "report",
141
+ report: report.name,
142
+ path: issue.path,
143
+ message: issue.message,
144
+ });
145
+ }
146
+ }
147
+ }
148
+
149
+ textOutput += `\n${reports.length} report(s) checked, ${reportsWithIssues} with issues\n`;
150
+
151
+ if (reportsWithIssues > 0) {
152
+ hasIssues = true;
153
+ }
154
+ }
155
+
156
+ if (!schemas?.tables.length && !reports?.length) {
157
+ textOutput += `No schemas or reports found in ${projectDir}\n`;
158
+ }
159
+
160
+ return {
161
+ ok: true,
162
+ data: allIssues,
163
+ meta: {
164
+ message: textOutput.trimEnd(),
165
+ hasIssues,
166
+ tableOutputHandled: true,
167
+ },
168
+ };
169
+ }