@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/api/meta.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified /meta namespace — a thin composition layer.
|
|
3
|
+
*
|
|
4
|
+
* Mounted at `/p/{slug}/meta` by the runtime. Composes focused modules
|
|
5
|
+
* into a single Hono sub-app rather than absorbing their code. Each
|
|
6
|
+
* concern lives in its own module; this file only wires routes to handlers.
|
|
7
|
+
*
|
|
8
|
+
* ## Composed modules
|
|
9
|
+
*
|
|
10
|
+
* - Schema introspection (GET /tables, GET /tables/:name) — pure reads
|
|
11
|
+
* from the SchemaRegistry, no DB I/O. Uses extractSchemas/extractSchema
|
|
12
|
+
* from schema-api.ts.
|
|
13
|
+
*
|
|
14
|
+
* - DB introspection (GET /enums, GET /tables/:name/indexes, /sample) —
|
|
15
|
+
* thin wrappers over operation functions that query sqlite_master / PRAGMA.
|
|
16
|
+
*
|
|
17
|
+
* - Schema mutations (POST/PATCH/DELETE on /tables, /columns, /infer) —
|
|
18
|
+
* delegated to metadataApi() which owns transactional DDL, registry sync,
|
|
19
|
+
* and LLM inference. ~850 lines of internal cohesion, kept as-is.
|
|
20
|
+
*
|
|
21
|
+
* - SQL proxy (POST /sql/query, /sql/exec) — passthrough to db-query/db-exec.
|
|
22
|
+
*
|
|
23
|
+
* - Schema sync (POST /schema/sync) — reloads file schemas from disk and
|
|
24
|
+
* applies Drizzle migrations.
|
|
25
|
+
*
|
|
26
|
+
* ## Route method separation
|
|
27
|
+
*
|
|
28
|
+
* The schema introspection GETs and the metadataApi mutations (POST/PATCH/DELETE)
|
|
29
|
+
* both register routes under `/tables/:name`. This is safe because Hono routes
|
|
30
|
+
* by HTTP method — GET /tables/:name (introspection) and PATCH /tables/:name
|
|
31
|
+
* (mutation) don't conflict.
|
|
32
|
+
*/
|
|
33
|
+
import { Hono } from "hono";
|
|
34
|
+
import type Database from "better-sqlite3";
|
|
35
|
+
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
|
36
|
+
import type { SchemaRegistry } from "../schema/registry.js";
|
|
37
|
+
import { extractSchemas, extractSchema } from "../schema/extract.js";
|
|
38
|
+
import { metadataApi } from "./meta-mutations.js";
|
|
39
|
+
import { dbQuery } from "../introspect/query.js";
|
|
40
|
+
import { dbExec } from "../introspect/exec.js";
|
|
41
|
+
// SQLite has no native enum type — the /enums endpoint is removed.
|
|
42
|
+
import { dbIndexes } from "../introspect/indexes.js";
|
|
43
|
+
import { dbSample } from "../introspect/sample.js";
|
|
44
|
+
import { dbDescribeAll } from "../introspect/describe-all.js";
|
|
45
|
+
import { loadSchemas } from "../schema/loader.js";
|
|
46
|
+
import { migrateSchemas } from "../schema/migrate.js";
|
|
47
|
+
import { OperationError } from "../introspect/types.js";
|
|
48
|
+
|
|
49
|
+
// ── Operation → HTTP error mapping ───────────────────────────────────────────
|
|
50
|
+
//
|
|
51
|
+
// Operation functions (db-query, db-exec, db-enums, etc.) return an OperationResult:
|
|
52
|
+
// { ok: true, data: ... } | { ok: false, error: string, code: string }
|
|
53
|
+
// or throw `OperationError` with a `.code` property.
|
|
54
|
+
//
|
|
55
|
+
// HTTP endpoints need proper status codes, not just 200/500. This map converts
|
|
56
|
+
// structured error codes to the closest HTTP semantics. Unmapped codes
|
|
57
|
+
// default to 500 (internal server error).
|
|
58
|
+
|
|
59
|
+
const ERROR_CODE_STATUS: Record<string, number> = {
|
|
60
|
+
TABLE_NOT_FOUND: 404,
|
|
61
|
+
INVALID_TABLE_NAME: 400,
|
|
62
|
+
INVALID_COLUMN_NAME: 400,
|
|
63
|
+
INVALID_JSON: 400,
|
|
64
|
+
DANGEROUS_SQL: 400,
|
|
65
|
+
SELECT_ONLY: 400,
|
|
66
|
+
PROJECT_NOT_FOUND: 404,
|
|
67
|
+
REPORT_NOT_FOUND: 404,
|
|
68
|
+
VALIDATION_FAILED: 422,
|
|
69
|
+
MISSING_ARGUMENT: 400,
|
|
70
|
+
INTERNAL: 500,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Convert an OperationResult ({ok, data} | {ok: false, error, code}) to an HTTP response. */
|
|
74
|
+
function resultToResponse(c: any, result: any) {
|
|
75
|
+
if (result.ok) return c.json(result.data);
|
|
76
|
+
const status = ERROR_CODE_STATUS[result.code] ?? 500;
|
|
77
|
+
return c.json({ error: result.error }, status);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Run an operation function and convert its result to an HTTP response.
|
|
82
|
+
* Handles both the OperationResult return pattern and the OperationError throw
|
|
83
|
+
* pattern — some functions use one, some use the other. This normalizes both
|
|
84
|
+
* into proper HTTP responses so callers don't need to care.
|
|
85
|
+
*/
|
|
86
|
+
// Accepts both sync (SQLite introspection) and async (report execution) operations.
|
|
87
|
+
function withOperationError(c: any, fn: () => any) {
|
|
88
|
+
try {
|
|
89
|
+
const result = fn();
|
|
90
|
+
return resultToResponse(c, result);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (err instanceof OperationError) {
|
|
93
|
+
const status = ERROR_CODE_STATUS[err.code] ?? 500;
|
|
94
|
+
return c.json({ error: err.message, code: err.code }, status);
|
|
95
|
+
}
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── API Factory ──────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
export function metaApi(
|
|
103
|
+
registry: SchemaRegistry,
|
|
104
|
+
sqlite: Database.Database,
|
|
105
|
+
db: BetterSQLite3Database,
|
|
106
|
+
project: { dir: string | null },
|
|
107
|
+
): Hono {
|
|
108
|
+
const app = new Hono();
|
|
109
|
+
|
|
110
|
+
// ── Schema introspection (pure reads) ──────────────────────────────────
|
|
111
|
+
|
|
112
|
+
// GET /tables — list all tables (structure + UI metadata)
|
|
113
|
+
app.get("/tables", async (c) => {
|
|
114
|
+
const detail = c.req.query("detail");
|
|
115
|
+
if (detail === "full") {
|
|
116
|
+
return withOperationError(c, () => dbDescribeAll(sqlite));
|
|
117
|
+
}
|
|
118
|
+
const data = extractSchemas(registry);
|
|
119
|
+
|
|
120
|
+
// Merge exact row counts from SQLite.
|
|
121
|
+
// One COUNT(*) per table — acceptable for SQLite's dataset sizes.
|
|
122
|
+
for (const table of data) {
|
|
123
|
+
try {
|
|
124
|
+
const row = sqlite.prepare(`SELECT COUNT(*) AS cnt FROM "${table.name}"`).get() as { cnt: number } | undefined;
|
|
125
|
+
table.rowCount = row?.cnt ?? 0;
|
|
126
|
+
} catch {
|
|
127
|
+
table.rowCount = 0;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return c.json({ tables: data });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// GET /tables/:name — describe single table
|
|
135
|
+
app.get("/tables/:name", (c) => {
|
|
136
|
+
const schema = extractSchema(registry, c.req.param("name"));
|
|
137
|
+
if (!schema) return c.notFound();
|
|
138
|
+
return c.json(schema);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ── DB introspection ───────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
// GET /tables/:name/indexes — table indexes
|
|
144
|
+
app.get("/tables/:name/indexes", (c) => {
|
|
145
|
+
return withOperationError(c, () =>
|
|
146
|
+
dbIndexes(sqlite, c.req.param("name")),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// GET /tables/:name/sample — sample rows
|
|
151
|
+
app.get("/tables/:name/sample", (c) => {
|
|
152
|
+
const limit = c.req.query("limit") ? parseInt(c.req.query("limit")!) : undefined;
|
|
153
|
+
const fields = c.req.query("fields")?.split(",");
|
|
154
|
+
return withOperationError(c, () =>
|
|
155
|
+
dbSample(sqlite, c.req.param("name"), limit, fields),
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// SQLite has no native enum type — enum values are expressed as
|
|
160
|
+
// text({ enum: [...] }) in column definitions. No /enums endpoint needed.
|
|
161
|
+
|
|
162
|
+
// ── Schema mutations ───────────────────────────────────────────────────
|
|
163
|
+
// Mount the existing metadataApi for POST/PATCH/DELETE on tables & columns,
|
|
164
|
+
// plus POST /tables/:name/infer (LLM inference). The GET routes were removed
|
|
165
|
+
// from metadataApi — they're replaced by the introspection routes above.
|
|
166
|
+
//
|
|
167
|
+
// Mounting at "/" means metadataApi's routes are relative to /meta.
|
|
168
|
+
// For example, metadataApi's `POST /tables` becomes `POST /meta/tables`.
|
|
169
|
+
// This works alongside the GET /tables route above because Hono dispatches
|
|
170
|
+
// by HTTP method — they share the same path but different methods.
|
|
171
|
+
app.route("/", metadataApi(sqlite, registry));
|
|
172
|
+
|
|
173
|
+
// ── SQL proxy (admin) ──────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
// POST /sql/query — read-only SQL
|
|
176
|
+
app.post("/sql/query", async (c) => {
|
|
177
|
+
const body = await c.req.json<{ sql: string; limit?: number }>();
|
|
178
|
+
return withOperationError(c, () => dbQuery(sqlite, body.sql, body.limit));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// POST /sql/exec — mutating SQL
|
|
182
|
+
app.post("/sql/exec", async (c) => {
|
|
183
|
+
const body = await c.req.json<{ sql: string; dryRun?: boolean }>();
|
|
184
|
+
return withOperationError(c, () => dbExec(sqlite, body.sql, body.dryRun));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ── Schema sync ────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
// POST /schema/sync — reload file schemas from disk and apply Drizzle migrations.
|
|
190
|
+
//
|
|
191
|
+
// This is the HTTP equivalent of `sapporta schema sync`. The sequence matters:
|
|
192
|
+
// 1. Load schemas from disk (picks up new/changed .ts files)
|
|
193
|
+
// 2. Migrate ONLY file-managed tables (UI tables use direct DDL, not pushSchema)
|
|
194
|
+
// 3. Re-register in the registry so the new tables appear immediately
|
|
195
|
+
// in the dynamic router without a server restart.
|
|
196
|
+
//
|
|
197
|
+
// Only file-managed tables are migrated here. UI-managed tables were created
|
|
198
|
+
// via the mutation API (metadataApi) which applies DDL directly. Running
|
|
199
|
+
// pushSchema on them risks destructive ALTER statements if metadata drifts
|
|
200
|
+
// from actual DB state.
|
|
201
|
+
app.post("/schema/sync", async (c) => {
|
|
202
|
+
if (!project.dir) {
|
|
203
|
+
return c.json({ error: "Schema sync requires a project directory", code: "NO_PROJECT_DIR" }, 400);
|
|
204
|
+
}
|
|
205
|
+
const schemaDir = `${project.dir}/schema`;
|
|
206
|
+
const { tables } = await loadSchemas(schemaDir);
|
|
207
|
+
await migrateSchemas(registry.fileManaged(), db, sqlite);
|
|
208
|
+
|
|
209
|
+
// Re-register so new tables appear in the router immediately.
|
|
210
|
+
// SchemaRegistry.register() is idempotent for same-name same-source entries.
|
|
211
|
+
for (const def of tables) {
|
|
212
|
+
registry.register(def, "file");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return c.json({
|
|
216
|
+
ok: true,
|
|
217
|
+
tables: tables.length,
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return app;
|
|
222
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
import type { ReportDefinition } from "../reports/report.js";
|
|
4
|
+
import type { ReportSqlClient } from "../reports/engine.js";
|
|
5
|
+
import { executeReport } from "../reports/engine.js";
|
|
6
|
+
import { createReportSqlClient } from "../reports/sqlite-sql-client.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create the /reports API sub-app. Mounted at `/p/{slug}/reports` by the runtime.
|
|
10
|
+
*
|
|
11
|
+
* Reports are read-only queries — they never mutate data. This makes GET the
|
|
12
|
+
* semantically correct method for execution (CQS: queries via GET, commands
|
|
13
|
+
* via POST). The frontend uses GET /:name/results with params in the query string.
|
|
14
|
+
*
|
|
15
|
+
* POST /:name/execute is kept as a fallback for cases where query parameters
|
|
16
|
+
* exceed URL length limits (~2000 chars in some browser/proxy combinations).
|
|
17
|
+
* Current reports have a few date/filter params and won't hit this limit,
|
|
18
|
+
* but it future-proofs against complex filter expressions.
|
|
19
|
+
*
|
|
20
|
+
* Both endpoints call the same executeReport() engine — they differ only in
|
|
21
|
+
* where they read the parameters from (query string vs request body).
|
|
22
|
+
*
|
|
23
|
+
* The sqlite handle is wrapped via createReportSqlClient() into the
|
|
24
|
+
* ReportSqlClient interface that executeReport() expects. This adapter
|
|
25
|
+
* maps better-sqlite3's synchronous .prepare().all() to the async
|
|
26
|
+
* ReportSqlClient.unsafe() interface.
|
|
27
|
+
*/
|
|
28
|
+
export function reportApi(
|
|
29
|
+
reports: ReportDefinition[],
|
|
30
|
+
sqlite: Database.Database,
|
|
31
|
+
): Hono {
|
|
32
|
+
// Wrap the SQLite handle once at mount time. The adapter is stateless —
|
|
33
|
+
// it just forwards queries to sqlite.prepare().all() and wraps results
|
|
34
|
+
// in Promise.resolve(). One adapter per project, not per request.
|
|
35
|
+
const sql: ReportSqlClient = createReportSqlClient(sqlite);
|
|
36
|
+
const reportMap = new Map(reports.map((r) => [r.name, r]));
|
|
37
|
+
|
|
38
|
+
const app = new Hono();
|
|
39
|
+
|
|
40
|
+
// List available reports — consumed by the sidebar to build navigation
|
|
41
|
+
app.get("/", (c) => {
|
|
42
|
+
return c.json({
|
|
43
|
+
reports: reports.map((r) => ({
|
|
44
|
+
name: r.name,
|
|
45
|
+
label: r.label,
|
|
46
|
+
params: r.params,
|
|
47
|
+
})),
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Get report metadata
|
|
52
|
+
app.get("/:name", (c) => {
|
|
53
|
+
const report = reportMap.get(c.req.param("name"));
|
|
54
|
+
if (!report) return c.json({ error: "Report not found" }, 404);
|
|
55
|
+
return c.json({
|
|
56
|
+
name: report.name,
|
|
57
|
+
label: report.label,
|
|
58
|
+
params: report.params,
|
|
59
|
+
columns: report.tree.columns,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Execute report via GET — primary method. Params come from the query string.
|
|
64
|
+
// The frontend serializes each param as a simple key=value pair.
|
|
65
|
+
app.get("/:name/results", async (c) => {
|
|
66
|
+
const report = reportMap.get(c.req.param("name"));
|
|
67
|
+
if (!report) return c.json({ error: "Report not found" }, 404);
|
|
68
|
+
|
|
69
|
+
const params: Record<string, string> = {};
|
|
70
|
+
for (const [key, value] of Object.entries(c.req.query())) {
|
|
71
|
+
params[key] = value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const result = await executeReport(sql, report, params);
|
|
75
|
+
return c.json(result);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Execute report via POST — fallback for long parameter lists that would
|
|
79
|
+
// exceed query string length limits. Same engine, params from request body.
|
|
80
|
+
app.post("/:name/execute", async (c) => {
|
|
81
|
+
const report = reportMap.get(c.req.param("name"));
|
|
82
|
+
if (!report) return c.json({ error: "Report not found" }, 404);
|
|
83
|
+
|
|
84
|
+
const body = await c.req.json();
|
|
85
|
+
const result = await executeReport(sql, report, body.params ?? {});
|
|
86
|
+
return c.json(result);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Catch parameter validation errors and return 400 instead of 500
|
|
90
|
+
app.onError((err, c) => {
|
|
91
|
+
if (err.message.startsWith("Required parameter")) {
|
|
92
|
+
return c.json({ error: err.message }, 400);
|
|
93
|
+
}
|
|
94
|
+
throw err;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return app;
|
|
98
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { cors } from "hono/cors";
|
|
3
|
+
import { requestLogger } from "../db/logger.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create the root Hono app with global middleware and the health endpoint.
|
|
7
|
+
*
|
|
8
|
+
* This is the bare app before any project-scoped routes are mounted.
|
|
9
|
+
* The runtime calls createApp() once, then mounts project namespaces
|
|
10
|
+
* under /p/{slug}/... and the global /_projects endpoint.
|
|
11
|
+
*
|
|
12
|
+
* The /health endpoint is outside all project scopes — it's used by
|
|
13
|
+
* load balancers and monitoring to check if the server is alive.
|
|
14
|
+
*/
|
|
15
|
+
export function createApp() {
|
|
16
|
+
const app = new Hono();
|
|
17
|
+
|
|
18
|
+
app.use("*", requestLogger());
|
|
19
|
+
app.use("*", cors());
|
|
20
|
+
|
|
21
|
+
app.get("/health", (c) => c.json({ status: "ok" }));
|
|
22
|
+
|
|
23
|
+
return app;
|
|
24
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Hono, type Context } from "hono";
|
|
2
|
+
import type { SchemaRegistry } from "../schema/registry.js";
|
|
3
|
+
import type { TableDef } from "../schema/table.js";
|
|
4
|
+
import { handleList, handleGet, handleCreate, handleUpdate, handleDelete } from "../data/crud.js";
|
|
5
|
+
import { handleLookup } from "../data/lookup.js";
|
|
6
|
+
import { handleCount } from "../data/count.js";
|
|
7
|
+
import { logger } from "../db/logger.js";
|
|
8
|
+
|
|
9
|
+
const log = logger.child({ module: "crud" });
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Dynamic parametric routing for table CRUD, lookup, and count endpoints.
|
|
13
|
+
//
|
|
14
|
+
// Mounted at `/p/{slug}/tables` by the runtime.
|
|
15
|
+
//
|
|
16
|
+
// Returns a Hono sub-app with parametric /:tableName/* routes. On each request
|
|
17
|
+
// the handler resolves the table name from the SchemaRegistry — a live,
|
|
18
|
+
// mutable Map. This means tables added at runtime (via the mutation API or
|
|
19
|
+
// schema sync) are immediately accessible without re-mounting routes or
|
|
20
|
+
// restarting the server.
|
|
21
|
+
//
|
|
22
|
+
// Key invariant: the SchemaRegistry is the single source of truth for which
|
|
23
|
+
// tables exist. If a table is in the registry, it's routable. If it's been
|
|
24
|
+
// unregistered (e.g., dropped via DDL), requests return 404/410.
|
|
25
|
+
//
|
|
26
|
+
// No per-table sub-apps, no URL rewriting, no schema caching. Resolution is
|
|
27
|
+
// a single synchronous Map.get() per request.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create the /tables API sub-app for dynamic table routing.
|
|
32
|
+
*
|
|
33
|
+
* Handles:
|
|
34
|
+
* GET /:table list rows (filtered, sorted, paginated)
|
|
35
|
+
* GET /:table/:id get single row
|
|
36
|
+
* POST /:table create row(s), master-detail
|
|
37
|
+
* PUT /:table/:id update row
|
|
38
|
+
* DELETE /:table/:id delete row
|
|
39
|
+
* GET /:table/_lookup FK display values
|
|
40
|
+
* GET /:table/_count grouped child counts
|
|
41
|
+
*
|
|
42
|
+
* Tables added at runtime via the registry are immediately accessible —
|
|
43
|
+
* the router reads from the registry on each request.
|
|
44
|
+
*/
|
|
45
|
+
export function tablesApi(registry: SchemaRegistry, db: any): Hono {
|
|
46
|
+
const app = new Hono();
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Middleware that resolves the :tableName param against the registry.
|
|
50
|
+
*
|
|
51
|
+
* Returns 404 if the table isn't registered. If the table IS registered but
|
|
52
|
+
* the underlying DB table has been dropped externally (e.g., by direct DDL
|
|
53
|
+
* or a failed migration), SQLite will throw a SqliteError with a message
|
|
54
|
+
* containing "no such table". In that case we unregister the stale entry
|
|
55
|
+
* and return 410 Gone — this keeps the registry consistent and prevents
|
|
56
|
+
* repeated 500s on a dead table.
|
|
57
|
+
*/
|
|
58
|
+
function withSchema(
|
|
59
|
+
handler: (schema: TableDef, db: any, c: Context) => Promise<Response>,
|
|
60
|
+
) {
|
|
61
|
+
return async (c: Context) => {
|
|
62
|
+
const tableName = c.req.param("tableName");
|
|
63
|
+
const entry = registry.get(tableName);
|
|
64
|
+
if (!entry) return c.notFound();
|
|
65
|
+
try {
|
|
66
|
+
return await handler(entry.def, db, c);
|
|
67
|
+
} catch (err: any) {
|
|
68
|
+
// better-sqlite3 throws SqliteError whose message includes "no such table"
|
|
69
|
+
// when a table has been dropped externally. This replaces the Postgres
|
|
70
|
+
// error code check (42P01) from the pre-SQLite migration.
|
|
71
|
+
if (err?.message?.includes("no such table")) {
|
|
72
|
+
log.warn("Table no longer exists", { table: tableName });
|
|
73
|
+
registry.unregister(tableName);
|
|
74
|
+
return c.json(
|
|
75
|
+
{ error: `Table "${tableName}" no longer exists in the database` },
|
|
76
|
+
{ status: 410 },
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Route ordering within this sub-app ───────────────────────────────
|
|
85
|
+
//
|
|
86
|
+
// _lookup and _count MUST be registered before the /:tableName/:id route.
|
|
87
|
+
// Hono matches routes top-to-bottom; /:tableName/:id would capture
|
|
88
|
+
// "/_lookup" as the :id param if it came first. The underscore prefix is
|
|
89
|
+
// a naming convention only — it doesn't affect routing priority.
|
|
90
|
+
//
|
|
91
|
+
// This ordering constraint is LOCAL to this sub-app. It does NOT affect
|
|
92
|
+
// the five namespace mounts in runtime.ts (which use distinct prefixes
|
|
93
|
+
// and are order-independent).
|
|
94
|
+
app.get("/:tableName/_lookup", withSchema((s, d, c) => handleLookup(s, d, c)));
|
|
95
|
+
app.get("/:tableName/_count", withSchema((s, d, c) => handleCount(s, d, c)));
|
|
96
|
+
|
|
97
|
+
// CRUD — the generic /:tableName/:id comes after the specific sub-paths above.
|
|
98
|
+
app.get("/:tableName", withSchema((s, d, c) => handleList(s, d, c)));
|
|
99
|
+
app.get("/:tableName/:id", withSchema((s, d, c) =>
|
|
100
|
+
handleGet(s, d, c, c.req.param("id"))));
|
|
101
|
+
app.post("/:tableName", withSchema((s, d, c) => handleCreate(s, d, c)));
|
|
102
|
+
app.put("/:tableName/:id", withSchema((s, d, c) =>
|
|
103
|
+
handleUpdate(s, d, c, c.req.param("id"))));
|
|
104
|
+
app.delete("/:tableName/:id", withSchema((s, d, c) =>
|
|
105
|
+
handleDelete(s, d, c, c.req.param("id"))));
|
|
106
|
+
|
|
107
|
+
return app;
|
|
108
|
+
}
|
package/src/api/views.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { ViewMeta } from "../views/view.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hono sub-app for view metadata.
|
|
6
|
+
* Mounted at `/p/{slug}/views` by the runtime.
|
|
7
|
+
*
|
|
8
|
+
* These endpoints serve metadata only (name, label, icon). The actual
|
|
9
|
+
* React component code is NOT served through this API — it's served by
|
|
10
|
+
* Vite directly from the project's views/ directory via the sapportaViews
|
|
11
|
+
* plugin (see packages/ui/vite-plugin-sapporta-views.ts).
|
|
12
|
+
*
|
|
13
|
+
* The split is intentional: the backend loads .tsx files with `tsx` to
|
|
14
|
+
* extract only the `meta` export (lightweight, no React needed on the server).
|
|
15
|
+
* The frontend uses Vite's virtual module system to lazy-load the full
|
|
16
|
+
* component with HMR support. This means:
|
|
17
|
+
* - Backend: reads meta from .tsx files → serves JSON via this API
|
|
18
|
+
* - Frontend: Vite plugin discovers view files → generates dynamic imports
|
|
19
|
+
* - The sidebar populates from this API; the component loads from Vite
|
|
20
|
+
*/
|
|
21
|
+
export function viewApi(views: ViewMeta[]): Hono {
|
|
22
|
+
const viewMap = new Map(views.map((v) => [v.name, v]));
|
|
23
|
+
const app = new Hono();
|
|
24
|
+
|
|
25
|
+
// List available views — consumed by the UI to populate the sidebar
|
|
26
|
+
app.get("/", (c) => {
|
|
27
|
+
return c.json({
|
|
28
|
+
views: views.map((v) => ({
|
|
29
|
+
name: v.name,
|
|
30
|
+
label: v.label,
|
|
31
|
+
icon: v.icon,
|
|
32
|
+
})),
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Get single view's metadata
|
|
37
|
+
app.get("/:name", (c) => {
|
|
38
|
+
const view = viewMap.get(c.req.param("name"));
|
|
39
|
+
if (!view) return c.json({ error: "View not found" }, 404);
|
|
40
|
+
return c.json(view);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return app;
|
|
44
|
+
}
|