@sapporta/server 0.0.1

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