@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,922 @@
1
+ /**
2
+ * Schema Mutation API — HTTP endpoints for creating/modifying/deleting
3
+ * UI-managed tables and columns from the browser UI.
4
+ *
5
+ * Mounted as a Hono sub-app at ${prefix}/_meta. All endpoints enforce:
6
+ *
7
+ * 1. **File-managed tables are protected.** Any mutation attempt on a table
8
+ * whose source is "file" is rejected with 409 Conflict and a message
9
+ * directing the user to modify the TypeScript schema file instead.
10
+ *
11
+ * 2. **Transactional DDL.** SQLite supports DDL inside transactions. Every
12
+ * mutation wraps metadata writes + DDL in a single sqlite.transaction()
13
+ * call. If the DDL fails, the metadata change is rolled back — no partial
14
+ * state in the in-memory registry.
15
+ *
16
+ * 3. **No CASCADE on drop.** Dropping a table that contains business data is
17
+ * destructive, and CASCADE could silently destroy FK columns in other tables.
18
+ * Instead, we check for FK dependents and require explicit confirmation.
19
+ *
20
+ * 4. **Literal defaults only.** default_value from user input is never
21
+ * interpolated as raw SQL. Only literal values (strings, numbers, booleans,
22
+ * null) are accepted. This prevents SQL injection via crafted default
23
+ * expressions like "'); DROP TABLE users; --".
24
+ *
25
+ * 5. **Synchronous execution.** All SQLite operations via better-sqlite3 are
26
+ * synchronous. Hono route handlers are still async (for body parsing), but
27
+ * database operations within them are synchronous — no await needed for
28
+ * sqlite.prepare(), .run(), .get(), .all(), .exec(), or .transaction().
29
+ */
30
+ import { Hono } from "hono";
31
+ import type Database from "better-sqlite3";
32
+ import type { SchemaRegistry } from "../schema/registry.js";
33
+ import { validateTableName, validateColumnName } from "../schema/reserved.js";
34
+ import { buildSetClause, mergeUiHints, deserializeColumnRow, type RawMetadataColumnRow } from "../schema/metadata-io.js";
35
+
36
+ import {
37
+ buildDrizzleTable,
38
+ generateCreateTableDDL,
39
+ generateAddColumnDDL,
40
+ VALID_COLUMN_TYPES,
41
+ type TableMeta,
42
+ type ColumnMetaRow,
43
+ } from "../schema/dynamic-builder.js";
44
+ import { inferSchema } from "../introspect/inference.js";
45
+
46
+ // ─── Safe Type Transitions ──────────────────────────────────────────────────
47
+ // Only type changes that can't lose data are allowed. For example, text → email
48
+ // is safe (both are TEXT in SQLite), but text → integer would require a CAST that
49
+ // could fail on non-numeric strings.
50
+
51
+ const SAFE_TYPE_TRANSITIONS: Record<string, Set<string>> = {
52
+ // Text family — all map to TEXT in SQLite, only UI semantics change
53
+ text: new Set(["email", "url", "select"]),
54
+ email: new Set(["text", "url"]),
55
+ url: new Set(["text", "email"]),
56
+ select: new Set(["text"]),
57
+
58
+ // Numeric family — all map to TEXT or REAL in SQLite
59
+ numeric: new Set(["currency", "percentage"]),
60
+ currency: new Set(["numeric", "percentage"]),
61
+ percentage: new Set(["numeric", "currency"]),
62
+
63
+ // integer → numeric is widening (safe), but numeric → integer is narrowing (unsafe)
64
+ integer: new Set(["numeric", "currency", "percentage"]),
65
+ };
66
+
67
+ function isTypeSafe(fromType: string, toType: string): boolean {
68
+ return SAFE_TYPE_TRANSITIONS[fromType]?.has(toType) ?? false;
69
+ }
70
+
71
+ // ─── API Factory ────────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * Create the metadata mutation API sub-app.
75
+ *
76
+ * The caller (runtime.ts → meta.ts) passes in the raw better-sqlite3 handle
77
+ * (for transactional DDL) and the SchemaRegistry (for in-memory updates).
78
+ *
79
+ * All SQL operations use sqlite.prepare().run/get/all() with parameterized
80
+ * queries, and sqlite.exec() for DDL statements. Transactions use
81
+ * sqlite.transaction() which returns a synchronous callable.
82
+ */
83
+ export function metadataApi(
84
+ sqlite: Database.Database,
85
+ registry: SchemaRegistry,
86
+ ): Hono {
87
+ const app = new Hono();
88
+
89
+ // ── Guard: reject mutations on file-managed tables ──────────────────────
90
+ function assertUIManaged(tableName: string): Response | null {
91
+ const entry = registry.get(tableName);
92
+ if (!entry) return null; // Will be handled as 404 by the caller
93
+ if (entry.source === "file") {
94
+ return Response.json(
95
+ { error: `Table "${tableName}" is code-managed. Modify the TypeScript schema file instead.` },
96
+ { status: 409 },
97
+ );
98
+ }
99
+ return null;
100
+ }
101
+
102
+ // ═══════════════════════════════════════════════════════════════════════════
103
+ // Tables
104
+ // ═══════════════════════════════════════════════════════════════════════════
105
+
106
+ // ── POST /tables — Create a new UI-managed table ────────────────────────
107
+ // Accepts an optional `columns` array for batch creation in a single transaction.
108
+ // This avoids N+1 API calls when creating a table with pre-defined columns
109
+ // (e.g., the "+" button creates a table with 10 text columns A-J).
110
+ app.post("/tables", async (c) => {
111
+ const body = await c.req.json<{
112
+ name: string;
113
+ label?: string;
114
+ columns?: Array<{
115
+ column_name: string;
116
+ column_type?: string;
117
+ not_null?: boolean;
118
+ is_unique?: boolean;
119
+ default_value?: string;
120
+ references_table?: string;
121
+ select_options?: string[];
122
+ ui_hints?: Record<string, unknown>;
123
+ position?: number;
124
+ }>;
125
+ }>();
126
+ const { name, label, columns: colDefs } = body;
127
+
128
+ // 1. Validate the table name
129
+ const validation = validateTableName(name);
130
+ if (!validation.valid) {
131
+ return c.json({ error: validation.reason }, 400);
132
+ }
133
+
134
+ // 2. Check collision with existing tables (file-managed or UI-managed)
135
+ if (registry.has(name)) {
136
+ return c.json(
137
+ { error: `Table "${name}" already exists` },
138
+ 409,
139
+ );
140
+ }
141
+
142
+ // 3. Validate columns (if provided)
143
+ const columnMetas: ColumnMetaRow[] = [];
144
+ if (colDefs && colDefs.length > 0) {
145
+ for (let i = 0; i < colDefs.length; i++) {
146
+ const col = colDefs[i];
147
+ const nameCheck = validateColumnName(col.column_name);
148
+ if (!nameCheck.valid) {
149
+ return c.json({ error: `Column ${i}: ${nameCheck.reason}` }, 400);
150
+ }
151
+ const colType = col.column_type ?? "text";
152
+ if (!VALID_COLUMN_TYPES.has(colType)) {
153
+ return c.json({ error: `Column "${col.column_name}": invalid type "${colType}"` }, 400);
154
+ }
155
+ if (colType === "link" && !col.references_table) {
156
+ return c.json({ error: `Column "${col.column_name}": link columns require a references_table` }, 400);
157
+ }
158
+ if (colType === "link" && col.references_table && !registry.has(col.references_table)) {
159
+ return c.json({ error: `Column "${col.column_name}": FK target "${col.references_table}" does not exist` }, 400);
160
+ }
161
+ columnMetas.push({
162
+ column_name: col.column_name,
163
+ column_type: colType,
164
+ position: col.position ?? i,
165
+ not_null: col.not_null ?? false,
166
+ is_unique: col.is_unique ?? false,
167
+ default_value: col.default_value,
168
+ references_table: col.references_table,
169
+ select_options: col.select_options,
170
+ ui_hints: col.ui_hints ?? {},
171
+ });
172
+ }
173
+ }
174
+
175
+ // 4. Transactional: insert metadata + execute CREATE TABLE DDL + add columns.
176
+ // If any DDL fails, everything is rolled back — no partial state.
177
+ // SQLite transactions are synchronous — the callback is NOT async.
178
+ const ddl = generateCreateTableDDL(name);
179
+ const createTableTxn = sqlite.transaction(() => {
180
+ sqlite.prepare(
181
+ `INSERT INTO _sapporta_tables (name, label) VALUES (?, ?)`
182
+ ).run(name, label ?? name);
183
+
184
+ sqlite.exec(ddl);
185
+
186
+ for (const col of columnMetas) {
187
+ insertColumnMeta(sqlite, name, col);
188
+ for (const stmt of generateAddColumnDDL(name, col)) {
189
+ sqlite.exec(stmt);
190
+ }
191
+ }
192
+ });
193
+ createTableTxn();
194
+
195
+ // 5. Build the TableDef and register it.
196
+ // Registry update happens AFTER the transaction commits — if we crash
197
+ // between commit and register, the next boot will pick it up from metadata.
198
+ const tableMeta: TableMeta = { name, label: label ?? name };
199
+ const def = buildDrizzleTable(tableMeta, columnMetas, registry);
200
+ registry.register(def, "ui");
201
+
202
+ // 6. Return the new table's info
203
+ return c.json({ name, label: label ?? name, columns: columnMetas }, 201);
204
+ });
205
+
206
+ // ── PATCH /tables/:name — Update table properties ──────────────────────
207
+ // Supports renaming via optional `name` field in body.
208
+ app.patch("/tables/:name", async (c) => {
209
+ const name = c.req.param("name");
210
+
211
+ // Guard: reject mutations on file-managed tables
212
+ const guard = assertUIManaged(name);
213
+ if (guard) return guard;
214
+
215
+ if (!registry.has(name)) {
216
+ return c.json({ error: `Table "${name}" not found` }, 404);
217
+ }
218
+
219
+ const body = await c.req.json<{
220
+ name?: string;
221
+ label?: string;
222
+ display_column?: string;
223
+ immutable?: boolean;
224
+ position?: number;
225
+ }>();
226
+
227
+ // Handle rename: if `name` is present and differs, do a full rename
228
+ if (body.name !== undefined && body.name !== name) {
229
+ const newName = body.name;
230
+
231
+ // Validate new name
232
+ const validation = validateTableName(newName);
233
+ if (!validation.valid) {
234
+ return c.json({ error: validation.reason }, 400);
235
+ }
236
+
237
+ // Check collision
238
+ if (registry.has(newName)) {
239
+ return c.json({ error: `Table "${newName}" already exists` }, 409);
240
+ }
241
+
242
+ renameTable(sqlite, registry, name, newName, {
243
+ label: body.label,
244
+ display_column: body.display_column,
245
+ immutable: body.immutable,
246
+ position: body.position,
247
+ });
248
+
249
+ return c.json({ ok: true, renamed: { from: name, to: newName } });
250
+ }
251
+
252
+ // Non-rename update path (existing behavior)
253
+ const updates: Record<string, any> = {};
254
+ if (body.label !== undefined) updates.label = body.label;
255
+ if (body.display_column !== undefined) updates.display_column = body.display_column;
256
+ if (body.immutable !== undefined) updates.immutable = body.immutable;
257
+ if (body.position !== undefined) updates.position = body.position;
258
+
259
+ if (Object.keys(updates).length === 0) {
260
+ return c.json({ error: "No fields to update" }, 400);
261
+ }
262
+
263
+ // Update metadata using buildSetClause to construct the parameterized SET clause.
264
+ // This replaces the postgres.js `tx(updates)` dynamic SET pattern.
265
+ const { clause, values } = buildSetClause(updates);
266
+ sqlite.prepare(
267
+ `UPDATE _sapporta_tables SET ${clause}, updated_at = datetime('now') WHERE name = ?`
268
+ ).run(...values, name);
269
+
270
+ // Rebuild TableDef to pick up new metadata (label, displayColumn, etc.)
271
+ const tableDef = rebuildTableDef(sqlite, name, registry);
272
+ if (tableDef) {
273
+ registry.register(tableDef, "ui");
274
+ }
275
+
276
+ return c.json({ ok: true });
277
+ });
278
+
279
+ // ── DELETE /tables/:name — Drop a UI-managed table ─────────────────────
280
+ // No CASCADE. Dropping a table with business data is destructive and
281
+ // CASCADE could silently destroy FK columns in other tables.
282
+ app.delete("/tables/:name", async (c) => {
283
+ const name = c.req.param("name");
284
+
285
+ // Guard: reject mutations on file-managed tables
286
+ const guard = assertUIManaged(name);
287
+ if (guard) return guard;
288
+
289
+ if (!registry.has(name)) {
290
+ return c.json({ error: `Table "${name}" not found` }, 404);
291
+ }
292
+
293
+ // 1. Check FK dependents: reject if other tables have link columns to this one.
294
+ // The user must remove the FK columns first — no silent cascade.
295
+ const dependents = sqlite.prepare(
296
+ `SELECT table_name, column_name FROM _sapporta_columns WHERE references_table = ?`
297
+ ).all(name) as { table_name: string; column_name: string }[];
298
+
299
+ if (dependents.length > 0) {
300
+ const refs = dependents.map((d) => `${d.table_name}.${d.column_name}`);
301
+ return c.json(
302
+ {
303
+ error: `Cannot drop "${name}": referenced by ${refs.join(", ")}. Remove the FK columns first.`,
304
+ dependents: refs,
305
+ },
306
+ 409,
307
+ );
308
+ }
309
+
310
+ // 2. Check row count: require explicit confirmation for tables with data.
311
+ // Without ?confirm=true, return 409 with the count and a message.
312
+ const countRow = sqlite.prepare(`SELECT COUNT(*) AS cnt FROM "${name}"`).get() as { cnt: number };
313
+ const rowCount = countRow?.cnt ?? 0;
314
+
315
+ if (rowCount > 0 && c.req.query("confirm") !== "true") {
316
+ return c.json(
317
+ {
318
+ error: `Table "${name}" has ${rowCount} row(s). Pass ?confirm=true to confirm deletion.`,
319
+ rowCount,
320
+ },
321
+ 409,
322
+ );
323
+ }
324
+
325
+ // 3. Transactional: DROP TABLE + delete metadata.
326
+ // _sapporta_columns rows cascade from the metadata FK (ON DELETE CASCADE).
327
+ const dropTxn = sqlite.transaction(() => {
328
+ sqlite.exec(`DROP TABLE "${name}"`);
329
+ sqlite.prepare(`DELETE FROM _sapporta_tables WHERE name = ?`).run(name);
330
+ });
331
+ dropTxn();
332
+
333
+ // 4. Remove from in-memory registry
334
+ registry.unregister(name);
335
+
336
+ return c.json({ ok: true, dropped: name });
337
+ });
338
+
339
+ // ═══════════════════════════════════════════════════════════════════════════
340
+ // Columns
341
+ // ═══════════════════════════════════════════════════════════════════════════
342
+
343
+ // ── POST /tables/:name/columns — Add a column ──────────────────────────
344
+ app.post("/tables/:name/columns", async (c) => {
345
+ const tableName = c.req.param("name");
346
+
347
+ // Guard: reject mutations on file-managed tables
348
+ const guard = assertUIManaged(tableName);
349
+ if (guard) return guard;
350
+
351
+ if (!registry.has(tableName)) {
352
+ return c.json({ error: `Table "${tableName}" not found` }, 404);
353
+ }
354
+
355
+ const body = await c.req.json<{
356
+ column_name: string;
357
+ column_type?: string;
358
+ not_null?: boolean;
359
+ is_unique?: boolean;
360
+ default_value?: string;
361
+ references_table?: string;
362
+ select_options?: string[];
363
+ ui_hints?: Record<string, unknown>;
364
+ position?: number;
365
+ }>();
366
+
367
+ // 1. Validate column name
368
+ const nameCheck = validateColumnName(body.column_name);
369
+ if (!nameCheck.valid) {
370
+ return c.json({ error: nameCheck.reason }, 400);
371
+ }
372
+
373
+ // 2. Validate column type
374
+ const colType = body.column_type ?? "text";
375
+ if (!VALID_COLUMN_TYPES.has(colType)) {
376
+ return c.json(
377
+ { error: `Invalid column type "${colType}". Valid types: ${[...VALID_COLUMN_TYPES].join(", ")}` },
378
+ 400,
379
+ );
380
+ }
381
+
382
+ // 3. Validate link columns have a references_table
383
+ if (colType === "link" && !body.references_table) {
384
+ return c.json({ error: "Link columns require a references_table" }, 400);
385
+ }
386
+
387
+ // 4. Validate the FK target exists
388
+ if (colType === "link" && body.references_table && !registry.has(body.references_table)) {
389
+ return c.json(
390
+ { error: `FK target "${body.references_table}" does not exist` },
391
+ 400,
392
+ );
393
+ }
394
+
395
+ // 5. If NOT NULL and the table has existing rows, require a default_value.
396
+ // We don't guess type-appropriate zero values — the user must be explicit.
397
+ if (body.not_null) {
398
+ const countRow = sqlite.prepare(`SELECT COUNT(*) AS cnt FROM "${tableName}"`).get() as { cnt: number };
399
+ const rowCount = countRow?.cnt ?? 0;
400
+ if (rowCount > 0 && body.default_value === undefined) {
401
+ return c.json(
402
+ { error: `Cannot add NOT NULL column without a default_value — table has ${rowCount} existing row(s)` },
403
+ 400,
404
+ );
405
+ }
406
+ }
407
+
408
+ // 6. Check for duplicate column name
409
+ const existing = sqlite.prepare(
410
+ `SELECT 1 FROM _sapporta_columns WHERE table_name = ? AND column_name = ?`
411
+ ).get(tableName, body.column_name);
412
+ if (existing) {
413
+ return c.json({ error: `Column "${body.column_name}" already exists on "${tableName}"` }, 409);
414
+ }
415
+
416
+ // 7. Determine position: default to max + 1
417
+ const maxPosRow = sqlite.prepare(
418
+ `SELECT COALESCE(MAX(position), -1) AS max_pos FROM _sapporta_columns WHERE table_name = ?`
419
+ ).get(tableName) as { max_pos: number };
420
+ const position = body.position ?? (maxPosRow.max_pos + 1);
421
+
422
+ // Build the column metadata row
423
+ const colMeta: ColumnMetaRow = {
424
+ column_name: body.column_name,
425
+ column_type: colType,
426
+ position,
427
+ not_null: body.not_null ?? false,
428
+ is_unique: body.is_unique ?? false,
429
+ default_value: body.default_value,
430
+ references_table: body.references_table,
431
+ select_options: body.select_options,
432
+ ui_hints: body.ui_hints ?? {},
433
+ };
434
+
435
+ // 8. Transactional: insert metadata + ALTER TABLE ADD COLUMN.
436
+ // SQLite transactions are synchronous — the callback is NOT async.
437
+ const ddlStatements = generateAddColumnDDL(tableName, colMeta);
438
+ const addColTxn = sqlite.transaction(() => {
439
+ insertColumnMeta(sqlite, tableName, colMeta);
440
+ for (const stmt of ddlStatements) {
441
+ sqlite.exec(stmt);
442
+ }
443
+ });
444
+ addColTxn();
445
+
446
+ // 9. Rebuild TableDef with updated columns and re-register
447
+ const tableDef = rebuildTableDef(sqlite, tableName, registry);
448
+ if (tableDef) {
449
+ registry.register(tableDef, "ui");
450
+ }
451
+
452
+ return c.json({ ok: true, column: body.column_name }, 201);
453
+ });
454
+
455
+ // ── PATCH /tables/:name/columns/:col — Modify a column ─────────────────
456
+ //
457
+ // Allowed modifications:
458
+ // - label / ui_hints (no DDL needed)
459
+ // - not_null (DDL: ALTER COLUMN SET/DROP NOT NULL — not supported in SQLite, metadata-only)
460
+ // - default_value (DDL: ALTER COLUMN SET DEFAULT / DROP DEFAULT — not supported in SQLite, metadata-only)
461
+ // - select_options (no DDL — metadata only)
462
+ // - column_type — restricted to safe transitions (see SAFE_TYPE_TRANSITIONS)
463
+ //
464
+ // NOTE: SQLite's ALTER TABLE is limited — it cannot ALTER COLUMN constraints.
465
+ // For safe type transitions within the same SQLite storage type (e.g. text → email),
466
+ // only metadata changes. For transitions that change the underlying type, we
467
+ // update metadata only since SQLite is dynamically typed and the actual data
468
+ // is not affected by column type declarations.
469
+ app.patch("/tables/:name/columns/:col", async (c) => {
470
+ const tableName = c.req.param("name");
471
+ const colName = c.req.param("col");
472
+
473
+ // Guard
474
+ const guard = assertUIManaged(tableName);
475
+ if (guard) return guard;
476
+
477
+ if (!registry.has(tableName)) {
478
+ return c.json({ error: `Table "${tableName}" not found` }, 404);
479
+ }
480
+
481
+ // Verify column exists
482
+ const colRow = sqlite.prepare(
483
+ `SELECT column_type, not_null, default_value FROM _sapporta_columns
484
+ WHERE table_name = ? AND column_name = ?`
485
+ ).get(tableName, colName) as { column_type: string; not_null: number; default_value: string | null } | undefined;
486
+
487
+ if (!colRow) {
488
+ return c.json({ error: `Column "${colName}" not found on "${tableName}"` }, 404);
489
+ }
490
+
491
+ const body = await c.req.json<{
492
+ column_name?: string; // rename column
493
+ column_type?: string;
494
+ not_null?: boolean;
495
+ default_value?: string | null;
496
+ select_options?: string[];
497
+ ui_hints?: Record<string, unknown>;
498
+ position?: number;
499
+ }>();
500
+
501
+ // Collect DDL statements needed (may be zero, one, or multiple)
502
+ const ddlStatements: string[] = [];
503
+
504
+ // Column rename validation
505
+ if (body.column_name !== undefined && body.column_name !== colName) {
506
+ const nameCheck = validateColumnName(body.column_name);
507
+ if (!nameCheck.valid) {
508
+ return c.json({ error: nameCheck.reason }, 400);
509
+ }
510
+ // Check for duplicate
511
+ const dup = sqlite.prepare(
512
+ `SELECT 1 FROM _sapporta_columns WHERE table_name = ? AND column_name = ?`
513
+ ).get(tableName, body.column_name);
514
+ if (dup) {
515
+ return c.json({ error: `Column "${body.column_name}" already exists on "${tableName}"` }, 409);
516
+ }
517
+ // SQLite supports ALTER TABLE RENAME COLUMN (since 3.25.0, better-sqlite3 ships 3.40+)
518
+ ddlStatements.push(
519
+ `ALTER TABLE "${tableName}" RENAME COLUMN "${colName}" TO "${body.column_name}"`
520
+ );
521
+ }
522
+
523
+ // Type change validation
524
+ if (body.column_type !== undefined && body.column_type !== colRow.column_type) {
525
+ if (!VALID_COLUMN_TYPES.has(body.column_type)) {
526
+ return c.json({ error: `Invalid column type "${body.column_type}"` }, 400);
527
+ }
528
+ if (!isTypeSafe(colRow.column_type, body.column_type)) {
529
+ return c.json(
530
+ {
531
+ error: `Cannot change type from "${colRow.column_type}" to "${body.column_type}". ` +
532
+ `Only safe transitions are allowed (no data loss).`,
533
+ },
534
+ 400,
535
+ );
536
+ }
537
+
538
+ // SQLite is dynamically typed — changing the column type declaration
539
+ // does not require DDL. The actual stored data is unaffected.
540
+ // We only update the metadata to reflect the new logical type.
541
+ // No DDL statement needed for type changes in SQLite.
542
+ }
543
+
544
+ // NOT NULL and DEFAULT changes in SQLite:
545
+ // SQLite's ALTER TABLE cannot modify column constraints (SET NOT NULL,
546
+ // DROP NOT NULL, SET DEFAULT, DROP DEFAULT). These are metadata-only
547
+ // changes — the logical constraint is tracked in _sapporta_columns and
548
+ // enforced by the application layer, not by SQLite DDL.
549
+
550
+ // Build metadata updates
551
+ const metaUpdates: Record<string, any> = {};
552
+ if (body.column_name !== undefined && body.column_name !== colName) {
553
+ metaUpdates.column_name = body.column_name;
554
+ }
555
+ if (body.column_type !== undefined) metaUpdates.column_type = body.column_type;
556
+ if (body.not_null !== undefined) metaUpdates.not_null = body.not_null ? 1 : 0;
557
+ if (body.default_value !== undefined) metaUpdates.default_value = body.default_value;
558
+ if (body.select_options !== undefined) {
559
+ metaUpdates.select_options = body.select_options ? JSON.stringify(body.select_options) : null;
560
+ }
561
+ if (body.position !== undefined) metaUpdates.position = body.position;
562
+
563
+ const uiHintsMerge = body.ui_hints !== undefined ? body.ui_hints : null;
564
+
565
+ if (Object.keys(metaUpdates).length === 0 && ddlStatements.length === 0 && uiHintsMerge === null) {
566
+ return c.json({ error: "No fields to update" }, 400);
567
+ }
568
+
569
+ // Transactional: metadata update + DDL (if any).
570
+ // ui_hints merge runs BEFORE column_name rename so the WHERE clause
571
+ // still matches the original column name.
572
+ const patchColTxn = sqlite.transaction(() => {
573
+ if (uiHintsMerge !== null) {
574
+ mergeUiHints(sqlite, tableName, colName, uiHintsMerge);
575
+ }
576
+ if (Object.keys(metaUpdates).length > 0) {
577
+ const { clause, values } = buildSetClause(metaUpdates);
578
+ sqlite.prepare(
579
+ `UPDATE _sapporta_columns SET ${clause} WHERE table_name = ? AND column_name = ?`
580
+ ).run(...values, tableName, colName);
581
+ }
582
+ for (const ddl of ddlStatements) {
583
+ sqlite.exec(ddl);
584
+ }
585
+ });
586
+ patchColTxn();
587
+
588
+ // Rebuild + re-register
589
+ const tableDef = rebuildTableDef(sqlite, tableName, registry);
590
+ if (tableDef) {
591
+ registry.register(tableDef, "ui");
592
+ }
593
+
594
+ return c.json({ ok: true });
595
+ });
596
+
597
+ // ── DELETE /tables/:name/columns/:col — Drop a column ──────────────────
598
+ app.delete("/tables/:name/columns/:col", async (c) => {
599
+ const tableName = c.req.param("name");
600
+ const colName = c.req.param("col");
601
+
602
+ // Guard
603
+ const guard = assertUIManaged(tableName);
604
+ if (guard) return guard;
605
+
606
+ if (!registry.has(tableName)) {
607
+ return c.json({ error: `Table "${tableName}" not found` }, 404);
608
+ }
609
+
610
+ // Verify column exists
611
+ const colRow = sqlite.prepare(
612
+ `SELECT 1 FROM _sapporta_columns WHERE table_name = ? AND column_name = ?`
613
+ ).get(tableName, colName);
614
+ if (!colRow) {
615
+ return c.json({ error: `Column "${colName}" not found on "${tableName}"` }, 404);
616
+ }
617
+
618
+ // Transactional: ALTER TABLE DROP COLUMN + delete metadata.
619
+ // SQLite 3.35.0+ supports ALTER TABLE DROP COLUMN natively.
620
+ // better-sqlite3 ships with SQLite 3.40+ so this is safe.
621
+ const dropColTxn = sqlite.transaction(() => {
622
+ sqlite.exec(`ALTER TABLE "${tableName}" DROP COLUMN "${colName}"`);
623
+ sqlite.prepare(
624
+ `DELETE FROM _sapporta_columns WHERE table_name = ? AND column_name = ?`
625
+ ).run(tableName, colName);
626
+ });
627
+ dropColTxn();
628
+
629
+ // Rebuild + re-register
630
+ const tableDef = rebuildTableDef(sqlite, tableName, registry);
631
+ if (tableDef) {
632
+ registry.register(tableDef, "ui");
633
+ }
634
+
635
+ return c.json({ ok: true, dropped: colName });
636
+ });
637
+
638
+ // ═══════════════════════════════════════════════════════════════════════════
639
+ // LLM Inference
640
+ // ═══════════════════════════════════════════════════════════════════════════
641
+
642
+ // ── POST /tables/:name/infer — Infer column names/types from sample data ─
643
+ app.post("/tables/:name/infer", async (c) => {
644
+ const tableName = c.req.param("name");
645
+
646
+ // Guard
647
+ const guard = assertUIManaged(tableName);
648
+ if (guard) return guard;
649
+
650
+ if (!registry.has(tableName)) {
651
+ return c.json({ error: `Table "${tableName}" not found` }, 404);
652
+ }
653
+
654
+ // Check if already inferred
655
+ const tableRow = sqlite.prepare(
656
+ `SELECT inferred FROM _sapporta_tables WHERE name = ?`
657
+ ).get(tableName) as { inferred: number } | undefined;
658
+ if (tableRow && tableRow.inferred) {
659
+ return c.json({ applied: false, reason: "Already inferred" });
660
+ }
661
+
662
+ const body = await c.req.json<{
663
+ rows: Record<string, unknown>[];
664
+ }>();
665
+
666
+ if (!body.rows || body.rows.length === 0) {
667
+ return c.json({ error: "At least one row is required for inference" }, 400);
668
+ }
669
+
670
+ // Get current column metadata
671
+ const colRows = sqlite.prepare(
672
+ `SELECT column_name, column_type, ui_hints
673
+ FROM _sapporta_columns WHERE table_name = ? ORDER BY position`
674
+ ).all(tableName) as { column_name: string; column_type: string; ui_hints: string }[];
675
+
676
+ const columnNames = colRows.map((r) => {
677
+ const hints = r.ui_hints ? JSON.parse(r.ui_hints) as Record<string, unknown> : null;
678
+ return hints?.header as string ?? r.column_name;
679
+ });
680
+
681
+ // Call LLM
682
+ const result = await inferSchema({
683
+ columns: columnNames,
684
+ rows: body.rows,
685
+ });
686
+
687
+ if (!result) {
688
+ return c.json({ applied: false, reason: "Inference returned no results (API key may not be set)" });
689
+ }
690
+
691
+ // Apply changes transactionally.
692
+ // The inference result contains column renames and type changes.
693
+ // We apply DDL (RENAME COLUMN) and metadata updates in a single transaction
694
+ // to ensure atomicity.
695
+ const inferTxn = sqlite.transaction(() => {
696
+ // Update table label and mark as inferred
697
+ sqlite.prepare(
698
+ `UPDATE _sapporta_tables SET label = ?, inferred = 1, updated_at = datetime('now') WHERE name = ?`
699
+ ).run(result.table_label, tableName);
700
+
701
+ // Apply column renames and type changes
702
+ for (const [oldName, inferred] of Object.entries(result.columns)) {
703
+ // Find matching column row
704
+ const colRow = colRows.find((r) => r.column_name === oldName);
705
+ if (!colRow) continue;
706
+
707
+ const oldColName = colRow.column_name;
708
+ const newColName = inferred.name;
709
+ const newType = inferred.type;
710
+ const newHeader = inferred.header;
711
+
712
+ const headerMerge = { header: newHeader };
713
+
714
+ if (newColName !== oldColName) {
715
+ // Check for collision — skip rename if target name already exists
716
+ const exists = colRows.some((r) =>
717
+ r.column_name !== oldColName && r.column_name === newColName
718
+ );
719
+ if (!exists) {
720
+ // Rename the actual column in the table
721
+ sqlite.exec(
722
+ `ALTER TABLE "${tableName}" RENAME COLUMN "${oldColName}" TO "${newColName}"`
723
+ );
724
+ // Update metadata: column_name, column_type, and merge ui_hints
725
+ mergeUiHints(sqlite, tableName, oldColName, headerMerge);
726
+ sqlite.prepare(
727
+ `UPDATE _sapporta_columns SET column_name = ?, column_type = ?
728
+ WHERE table_name = ? AND column_name = ?`
729
+ ).run(newColName, newType, tableName, oldColName);
730
+ }
731
+ } else {
732
+ // Just update type and header — no rename needed
733
+ mergeUiHints(sqlite, tableName, oldColName, headerMerge);
734
+ sqlite.prepare(
735
+ `UPDATE _sapporta_columns SET column_type = ?
736
+ WHERE table_name = ? AND column_name = ?`
737
+ ).run(newType, tableName, oldColName);
738
+ }
739
+
740
+ // SQLite is dynamically typed — changing column_type in metadata
741
+ // doesn't require DDL to alter the actual column type. The stored
742
+ // data is unaffected regardless of the declared type.
743
+ }
744
+ });
745
+ inferTxn();
746
+
747
+ // Rebuild TableDef
748
+ const tableDef = rebuildTableDef(sqlite, tableName, registry);
749
+ if (tableDef) {
750
+ registry.register(tableDef, "ui");
751
+ }
752
+
753
+ return c.json({
754
+ applied: true,
755
+ table_label: result.table_label,
756
+ columns: result.columns,
757
+ });
758
+ });
759
+
760
+ return app;
761
+ }
762
+
763
+ // ─── Domain Functions ────────────────────────────────────────────────────────
764
+
765
+ /**
766
+ * Rename a UI-managed table, updating SQLite, metadata, FK references, and registry.
767
+ *
768
+ * Caller is responsible for validating the new name and checking for collisions
769
+ * before calling this function. All DDL + metadata changes happen in a single
770
+ * transaction. Registry updates happen only after the transaction commits.
771
+ *
772
+ * The rename operation is complex because it must update:
773
+ * 1. The actual SQLite table name (ALTER TABLE RENAME TO)
774
+ * 2. The _sapporta_tables metadata row (insert new, copy columns, delete old)
775
+ * 3. The _sapporta_columns rows (update table_name foreign key)
776
+ * 4. FK references from other tables pointing to the old name
777
+ */
778
+ function renameTable(
779
+ sqlite: Database.Database,
780
+ registry: SchemaRegistry,
781
+ oldName: string,
782
+ newName: string,
783
+ opts?: { label?: string; display_column?: string; immutable?: boolean; position?: number },
784
+ ): void {
785
+ const renameTxn = sqlite.transaction(() => {
786
+ // Rename the actual SQLite table
787
+ sqlite.exec(`ALTER TABLE "${oldName}" RENAME TO "${newName}"`);
788
+
789
+ // Insert new metadata row (copy from old with new name/label)
790
+ if (opts?.label !== undefined) {
791
+ sqlite.prepare(
792
+ `INSERT INTO _sapporta_tables (name, label, display_column, immutable, inferred, position)
793
+ SELECT ?, ?, display_column, immutable, inferred, position
794
+ FROM _sapporta_tables WHERE name = ?`
795
+ ).run(newName, opts.label, oldName);
796
+ } else {
797
+ sqlite.prepare(
798
+ `INSERT INTO _sapporta_tables (name, label, display_column, immutable, inferred, position)
799
+ SELECT ?, label, display_column, immutable, inferred, position
800
+ FROM _sapporta_tables WHERE name = ?`
801
+ ).run(newName, oldName);
802
+ }
803
+
804
+ // Move columns to new table name
805
+ sqlite.prepare(
806
+ `UPDATE _sapporta_columns SET table_name = ? WHERE table_name = ?`
807
+ ).run(newName, oldName);
808
+
809
+ // Update FK references pointing to the old table
810
+ sqlite.prepare(
811
+ `UPDATE _sapporta_columns SET references_table = ? WHERE references_table = ?`
812
+ ).run(newName, oldName);
813
+
814
+ // Delete old metadata row
815
+ sqlite.prepare(
816
+ `DELETE FROM _sapporta_tables WHERE name = ?`
817
+ ).run(oldName);
818
+
819
+ // Apply non-rename updates (display_column, immutable, position) if present
820
+ const extraUpdates: Record<string, any> = {};
821
+ if (opts?.display_column !== undefined) extraUpdates.display_column = opts.display_column;
822
+ if (opts?.immutable !== undefined) extraUpdates.immutable = opts.immutable;
823
+ if (opts?.position !== undefined) extraUpdates.position = opts.position;
824
+ if (Object.keys(extraUpdates).length > 0) {
825
+ const { clause, values } = buildSetClause(extraUpdates);
826
+ sqlite.prepare(
827
+ `UPDATE _sapporta_tables SET ${clause}, updated_at = datetime('now') WHERE name = ?`
828
+ ).run(...values, newName);
829
+ }
830
+ });
831
+ renameTxn();
832
+
833
+ // Unregister old name, rebuild and register under new name.
834
+ // This happens AFTER the transaction commits — no partial registry state.
835
+ registry.unregister(oldName);
836
+ const tableDef = rebuildTableDef(sqlite, newName, registry);
837
+ if (tableDef) {
838
+ registry.register(tableDef, "ui");
839
+ }
840
+ }
841
+
842
+ // ─── Helpers ────────────────────────────────────────────────────────────────
843
+
844
+ /**
845
+ * Insert a column metadata row into _sapporta_columns.
846
+ *
847
+ * Handles the JSON serialization of select_options and ui_hints, and the
848
+ * boolean → integer conversion for not_null and is_unique. Centralizes
849
+ * the INSERT pattern so all callers (create table, add column) use the
850
+ * same serialization logic.
851
+ */
852
+ function insertColumnMeta(
853
+ sqlite: Database.Database,
854
+ tableName: string,
855
+ col: ColumnMetaRow,
856
+ ): void {
857
+ sqlite.prepare(
858
+ `INSERT INTO _sapporta_columns (
859
+ table_name, column_name, column_type, position,
860
+ not_null, is_unique, default_value, references_table,
861
+ select_options, ui_hints
862
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
863
+ ).run(
864
+ tableName,
865
+ col.column_name,
866
+ col.column_type,
867
+ col.position,
868
+ col.not_null ? 1 : 0,
869
+ col.is_unique ? 1 : 0,
870
+ col.default_value ?? null,
871
+ col.references_table ?? null,
872
+ col.select_options ? JSON.stringify(col.select_options) : null,
873
+ JSON.stringify(col.ui_hints ?? {}),
874
+ );
875
+ }
876
+
877
+ /**
878
+ * Rebuild a TableDef from current metadata state.
879
+ *
880
+ * Used after any mutation to ensure the in-memory TableDef matches the
881
+ * committed metadata. Reads fresh from the database so we don't accumulate
882
+ * stale state from incremental in-memory patches.
883
+ *
884
+ * The read uses deserializeColumnRow() from sqlite-metadata-io.ts to
885
+ * correctly parse JSON fields (select_options, ui_hints) and convert
886
+ * SQLite integers (not_null, is_unique) back to booleans.
887
+ */
888
+ function rebuildTableDef(
889
+ sqlite: Database.Database,
890
+ tableName: string,
891
+ registry: SchemaRegistry,
892
+ ): import("../schema/table.js").TableDef | null {
893
+ const tableRow = sqlite.prepare(
894
+ `SELECT name, label, display_column, immutable, inferred, position
895
+ FROM _sapporta_tables WHERE name = ?`
896
+ ).get(tableName) as {
897
+ name: string; label: string; display_column: string | null;
898
+ immutable: number; inferred: number; position: number;
899
+ } | undefined;
900
+
901
+ if (!tableRow) return null;
902
+
903
+ const tableMeta: TableMeta = {
904
+ name: tableRow.name,
905
+ label: tableRow.label,
906
+ display_column: tableRow.display_column ?? undefined,
907
+ immutable: !!tableRow.immutable,
908
+ inferred: !!tableRow.inferred,
909
+ position: tableRow.position,
910
+ };
911
+
912
+ const rawColumns = sqlite.prepare(
913
+ `SELECT column_name, column_type, position, not_null, is_unique,
914
+ default_value, references_table, select_options, ui_hints
915
+ FROM _sapporta_columns WHERE table_name = ? ORDER BY position`
916
+ ).all(tableName) as RawMetadataColumnRow[];
917
+
918
+ // Deserialize each row: JSON parse select_options/ui_hints, convert 0/1 → boolean
919
+ const columns = rawColumns.map((raw) => deserializeColumnRow(raw));
920
+
921
+ return buildDrizzleTable(tableMeta, columns, registry);
922
+ }