@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
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
|
+
}
|
package/src/cli/check.ts
ADDED
|
@@ -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
|
+
}
|