@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,737 @@
1
+ /**
2
+ * Integration tests for the UI-managed tables system (Plan 1b).
3
+ *
4
+ * Tests the full lifecycle: dynamic table builder, metadata tables bootstrap,
5
+ * schema mutation API, schema API extensions, and boot sequence integration.
6
+ *
7
+ * Uses in-memory SQLite — no external DB needed.
8
+ * We use the raw better-sqlite3 handle for DDL; ORM queries go through
9
+ * Drizzle's BetterSQLite3Database.
10
+ */
11
+ import { describe, it, expect, beforeEach } from "vitest";
12
+ import { Hono } from "hono";
13
+ import {
14
+ sqliteTable,
15
+ text,
16
+ integer,
17
+ getTableConfig,
18
+ } from "drizzle-orm/sqlite-core";
19
+ import { table } from "./table.js";
20
+ import { SchemaRegistry } from "./registry.js";
21
+ import { extractSchemas } from "./extract.js";
22
+ import { crud } from "../data/crud.js";
23
+ import {
24
+ buildDrizzleTable,
25
+ planTable,
26
+ generateCreateTableDDL,
27
+ generateAddColumnDDL,
28
+ type ColumnMetaRow,
29
+ } from "./dynamic-builder.js";
30
+ import { validateTableName, validateColumnName } from "./reserved.js";
31
+ import { createTestDb } from "../testing/test-utils.js";
32
+ import { ensureMetadataTables, loadUISchemas } from "./metadata-tables.js";
33
+ import Database from "better-sqlite3";
34
+
35
+ // ═══════════════════════════════════════════════════════════════════════════════
36
+ // 1. Dynamic Table Builder
37
+ // ═══════════════════════════════════════════════════════════════════════════════
38
+
39
+ describe("buildDrizzleTable", () => {
40
+ let registry: SchemaRegistry;
41
+
42
+ beforeEach(() => {
43
+ registry = new SchemaRegistry();
44
+ });
45
+
46
+ it("creates a table with system columns (id, created_at, updated_at)", () => {
47
+ const def = buildDrizzleTable(
48
+ { name: "expenses" },
49
+ [],
50
+ registry,
51
+ );
52
+
53
+ expect(def.sqlName).toBe("expenses");
54
+ const config = getTableConfig(def.drizzle);
55
+ expect(config.name).toBe("expenses");
56
+
57
+ // System columns are always present
58
+ const colNames = config.columns.map((c) => c.name);
59
+ expect(colNames).toContain("id");
60
+ expect(colNames).toContain("created_at");
61
+ expect(colNames).toContain("updated_at");
62
+
63
+ // id is primary key
64
+ const idCol = config.columns.find((c) => c.name === "id")!;
65
+ expect(idCol.primary).toBe(true);
66
+ expect(idCol.hasDefault).toBe(true);
67
+ });
68
+
69
+ it("adds user-defined columns from metadata", () => {
70
+ const columns: ColumnMetaRow[] = [
71
+ { column_name: "description", column_type: "text", position: 0, not_null: true },
72
+ { column_name: "amount", column_type: "numeric", position: 1 },
73
+ { column_name: "is_active", column_type: "boolean", position: 2, default_value: "true" },
74
+ ];
75
+
76
+ const def = buildDrizzleTable({ name: "expenses" }, columns, registry);
77
+ const config = getTableConfig(def.drizzle);
78
+
79
+ const descCol = config.columns.find((c) => c.name === "description")!;
80
+ expect(descCol.notNull).toBe(true);
81
+ expect(descCol.dataType).toBe("string"); // text → string in Drizzle
82
+
83
+ const amountCol = config.columns.find((c) => c.name === "amount")!;
84
+ expect(amountCol.dataType).toBe("number"); // numeric → real() → number in Drizzle SQLite
85
+
86
+ const activeCol = config.columns.find((c) => c.name === "is_active")!;
87
+ expect(activeCol.dataType).toBe("boolean");
88
+ expect(activeCol.hasDefault).toBe(true);
89
+ });
90
+
91
+ it("maps all column types to correct Drizzle builders", () => {
92
+ const columns: ColumnMetaRow[] = [
93
+ { column_name: "col_text", column_type: "text", position: 0 },
94
+ { column_name: "col_integer", column_type: "integer", position: 1 },
95
+ { column_name: "col_numeric", column_type: "numeric", position: 2 },
96
+ { column_name: "col_boolean", column_type: "boolean", position: 3 },
97
+ { column_name: "col_date", column_type: "date", position: 4 },
98
+ { column_name: "col_timestamp", column_type: "timestamp", position: 5 },
99
+ { column_name: "col_select", column_type: "select", position: 6, select_options: ["a", "b"] },
100
+ { column_name: "col_currency", column_type: "currency", position: 7 },
101
+ { column_name: "col_percentage", column_type: "percentage", position: 8 },
102
+ { column_name: "col_email", column_type: "email", position: 9 },
103
+ { column_name: "col_url", column_type: "url", position: 10 },
104
+ ];
105
+
106
+ const def = buildDrizzleTable({ name: "test_types" }, columns, registry);
107
+ const config = getTableConfig(def.drizzle);
108
+
109
+ // All columns should exist (11 user + 3 system)
110
+ expect(config.columns).toHaveLength(14);
111
+ });
112
+
113
+ it("applies UNIQUE constraint", () => {
114
+ const columns: ColumnMetaRow[] = [
115
+ { column_name: "email", column_type: "email", position: 0, is_unique: true },
116
+ ];
117
+
118
+ const def = buildDrizzleTable({ name: "users" }, columns, registry);
119
+ const config = getTableConfig(def.drizzle);
120
+ const emailCol = config.columns.find((c) => c.name === "email")!;
121
+ expect(emailCol.isUnique).toBe(true);
122
+ });
123
+
124
+ it("creates FK references for link columns when target exists in registry", () => {
125
+ // Register a target table first
126
+ const categoriesTable = sqliteTable("categories", {
127
+ id: integer("id").primaryKey({ autoIncrement: true }),
128
+ name: text("name").notNull(),
129
+ });
130
+ const catDef = table({ drizzle: categoriesTable, meta: { label: "Categories" } });
131
+ registry.register(catDef, "file");
132
+
133
+ // Build a table with a link column to categories
134
+ const columns: ColumnMetaRow[] = [
135
+ { column_name: "category_id", column_type: "link", position: 0, references_table: "categories" },
136
+ ];
137
+
138
+ const def = buildDrizzleTable({ name: "expenses" }, columns, registry);
139
+ const config = getTableConfig(def.drizzle);
140
+
141
+ // Should have FK metadata
142
+ expect(config.foreignKeys).toHaveLength(1);
143
+ const fkRef = config.foreignKeys[0].reference();
144
+ const targetConfig = getTableConfig(fkRef.foreignColumns[0].table);
145
+ expect(targetConfig.name).toBe("categories");
146
+ });
147
+
148
+ it("skips FK reference when target is not in registry (dangling link)", () => {
149
+ const columns: ColumnMetaRow[] = [
150
+ { column_name: "category_id", column_type: "link", position: 0, references_table: "nonexistent" },
151
+ ];
152
+
153
+ const def = buildDrizzleTable({ name: "expenses" }, columns, registry);
154
+ const config = getTableConfig(def.drizzle);
155
+
156
+ // Column exists as integer but without FK
157
+ const col = config.columns.find((c) => c.name === "category_id")!;
158
+ expect(col.dataType).toBe("number");
159
+ expect(config.foreignKeys).toHaveLength(0);
160
+ });
161
+
162
+ it("builds SapportaMeta with label, displayColumn, and selects", () => {
163
+ const columns: ColumnMetaRow[] = [
164
+ { column_name: "status", column_type: "select", position: 0, select_options: ["open", "closed"] },
165
+ { column_name: "total", column_type: "currency", position: 1 },
166
+ ];
167
+
168
+ const def = buildDrizzleTable(
169
+ { name: "orders", label: "Orders", display_column: "status" },
170
+ columns,
171
+ registry,
172
+ );
173
+
174
+ expect(def.meta.label).toBe("Orders");
175
+ expect(def.meta.displayColumn).toBe("status");
176
+ expect(def.meta.selects).toHaveLength(1);
177
+ expect(def.meta.selects![0].column).toBe("status");
178
+ expect(def.meta.selects![0].options).toEqual(["open", "closed"]);
179
+
180
+ // Currency columns get "money" type for formatting
181
+ expect(def.meta.columns?.total?.type).toBe("money");
182
+ });
183
+
184
+ it("builds SapportaMeta with immutable flag", () => {
185
+ const def = buildDrizzleTable(
186
+ { name: "audit_log", immutable: true },
187
+ [],
188
+ registry,
189
+ );
190
+
191
+ expect(def.meta.immutable).toBe(true);
192
+ });
193
+ });
194
+
195
+ // ═══════════════════════════════════════════════════════════════════════════════
196
+ // 1b. planTable (pure layer)
197
+ // ═══════════════════════════════════════════════════════════════════════════════
198
+
199
+ describe("planTable", () => {
200
+ it("empty columns → empty spec", () => {
201
+ const spec = planTable({ name: "empty" }, []);
202
+ expect(spec.sqlName).toBe("empty");
203
+ expect(spec.columns).toEqual([]);
204
+ });
205
+
206
+ it("maps column metadata to correct ColumnSpec fields", () => {
207
+ const columns: ColumnMetaRow[] = [
208
+ { column_name: "title", column_type: "text", position: 0, not_null: true, is_unique: true, default_value: "untitled" },
209
+ { column_name: "amount", column_type: "numeric", position: 1 },
210
+ ];
211
+ const spec = planTable({ name: "items" }, columns);
212
+
213
+ expect(spec.columns).toHaveLength(2);
214
+
215
+ const title = spec.columns[0];
216
+ expect(title.name).toBe("title");
217
+ expect(title.builderType).toBe("text");
218
+ expect(title.notNull).toBe(true);
219
+ expect(title.isUnique).toBe(true);
220
+ expect(title.defaultValue).toBe("untitled");
221
+ expect(title.fkTarget).toBeNull();
222
+
223
+ const amount = spec.columns[1];
224
+ expect(amount.name).toBe("amount");
225
+ expect(amount.builderType).toBe("numeric");
226
+ expect(amount.notNull).toBe(false);
227
+ expect(amount.isUnique).toBe(false);
228
+ expect(amount.defaultValue).toBeUndefined();
229
+ expect(amount.fkTarget).toBeNull();
230
+ });
231
+
232
+ it("link column → fkTarget set to table name", () => {
233
+ const columns: ColumnMetaRow[] = [
234
+ { column_name: "category_id", column_type: "link", position: 0, references_table: "categories" },
235
+ ];
236
+ const spec = planTable({ name: "expenses" }, columns);
237
+
238
+ expect(spec.columns[0].builderType).toBe("link");
239
+ expect(spec.columns[0].fkTarget).toBe("categories");
240
+ });
241
+
242
+ it("unknown column type → skipped", () => {
243
+ const columns: ColumnMetaRow[] = [
244
+ { column_name: "good", column_type: "text", position: 0 },
245
+ { column_name: "bad", column_type: "imaginary_type", position: 1 },
246
+ ];
247
+ const spec = planTable({ name: "test" }, columns);
248
+
249
+ expect(spec.columns).toHaveLength(1);
250
+ expect(spec.columns[0].name).toBe("good");
251
+ });
252
+
253
+ it("builds SapportaMeta with selects, currency→money, label, displayColumn, immutable, inferred", () => {
254
+ const columns: ColumnMetaRow[] = [
255
+ { column_name: "status", column_type: "select", position: 0, select_options: ["active", "inactive"] },
256
+ { column_name: "total", column_type: "currency", position: 1 },
257
+ ];
258
+ const spec = planTable(
259
+ { name: "orders", label: "Orders", display_column: "status", immutable: true, inferred: true },
260
+ columns,
261
+ );
262
+
263
+ expect(spec.meta.label).toBe("Orders");
264
+ expect(spec.meta.displayColumn).toBe("status");
265
+ expect(spec.meta.immutable).toBe(true);
266
+ expect(spec.meta.inferred).toBe(true);
267
+ expect(spec.meta.selects).toHaveLength(1);
268
+ expect(spec.meta.selects![0]).toEqual({ type: "select", column: "status", options: ["active", "inactive"] });
269
+ expect(spec.meta.columns?.total?.type).toBe("money");
270
+ });
271
+
272
+ it("includes ui_hints in column metadata", () => {
273
+ const columns: ColumnMetaRow[] = [
274
+ { column_name: "notes", column_type: "text", position: 0, ui_hints: { header: "Notes", hidden: true, width: 200 } },
275
+ ];
276
+ const spec = planTable({ name: "tasks" }, columns);
277
+
278
+ expect(spec.meta.columns?.notes?.header).toBe("Notes");
279
+ expect(spec.meta.columns?.notes?.hidden).toBe(true);
280
+ expect(spec.meta.columns?.notes?.width).toBe(200);
281
+ });
282
+ });
283
+
284
+ // ═══════════════════════════════════════════════════════════════════════════════
285
+ // 3. Dynamic Table Builder + SQLite Integration
286
+ // ═══════════════════════════════════════════════════════════════════════════════
287
+
288
+ describe("buildDrizzleTable + SQLite integration", () => {
289
+ let db: any;
290
+ let sqlite: any;
291
+ let registry: SchemaRegistry;
292
+
293
+ beforeEach(() => {
294
+ ({ db, sqlite } = createTestDb());
295
+ registry = new SchemaRegistry();
296
+ });
297
+
298
+ it("creates table via DDL, inserts data via Drizzle, queries back", async () => {
299
+ // Create the table using generated DDL
300
+ const ddl = generateCreateTableDDL("expenses");
301
+ sqlite.exec(ddl);
302
+
303
+ for (const stmt of generateAddColumnDDL("expenses", {
304
+ column_name: "description",
305
+ column_type: "text",
306
+ position: 0,
307
+ not_null: true,
308
+ })) sqlite.exec(stmt);
309
+
310
+ for (const stmt of generateAddColumnDDL("expenses", {
311
+ column_name: "amount",
312
+ column_type: "numeric",
313
+ position: 1,
314
+ })) sqlite.exec(stmt);
315
+
316
+ // Build the dynamic table
317
+ const def = buildDrizzleTable(
318
+ { name: "expenses", label: "Expenses" },
319
+ [
320
+ { column_name: "description", column_type: "text", position: 0, not_null: true },
321
+ { column_name: "amount", column_type: "numeric", position: 1 },
322
+ ],
323
+ registry,
324
+ );
325
+
326
+ // Insert via Drizzle
327
+ await db.insert(def.drizzle as any).values({ description: "Lunch", amount: "15.50" });
328
+ const rows = await db.select().from(def.drizzle as any);
329
+
330
+ expect(rows).toHaveLength(1);
331
+ expect(rows[0].description).toBe("Lunch");
332
+ // SQLite REAL stores as a float — "15.50" becomes 15.5
333
+ expect(rows[0].amount).toBe(15.5);
334
+ expect(rows[0].id).toBe(1);
335
+ expect(typeof rows[0].created_at).toBe("string");
336
+ });
337
+
338
+ it("CRUD sub-app works with dynamically built table", async () => {
339
+ sqlite.exec(generateCreateTableDDL("tasks"));
340
+ for (const stmt of generateAddColumnDDL("tasks", {
341
+ column_name: "title",
342
+ column_type: "text",
343
+ position: 0,
344
+ not_null: true,
345
+ })) sqlite.exec(stmt);
346
+
347
+ const def = buildDrizzleTable(
348
+ { name: "tasks" },
349
+ [{ column_name: "title", column_type: "text", position: 0, not_null: true }],
350
+ registry,
351
+ );
352
+
353
+ const app = new Hono();
354
+ app.route("/tasks", crud(def, db));
355
+
356
+ // POST
357
+ const createRes = await app.request("/tasks", {
358
+ method: "POST",
359
+ headers: { "Content-Type": "application/json" },
360
+ body: JSON.stringify({ title: "Buy groceries" }),
361
+ });
362
+ expect(createRes.status).toBe(201);
363
+ const created = await createRes.json();
364
+ expect(created.data.title).toBe("Buy groceries");
365
+
366
+ // GET
367
+ const listRes = await app.request("/tasks");
368
+ const list = await listRes.json();
369
+ expect(list.data).toHaveLength(1);
370
+
371
+ // GET /:id
372
+ const getRes = await app.request("/tasks/1");
373
+ const record = await getRes.json();
374
+ expect(record.data.title).toBe("Buy groceries");
375
+
376
+ // PUT
377
+ const updateRes = await app.request("/tasks/1", {
378
+ method: "PUT",
379
+ headers: { "Content-Type": "application/json" },
380
+ body: JSON.stringify({ title: "Updated title" }),
381
+ });
382
+ expect(updateRes.status).toBe(200);
383
+ const updated = await updateRes.json();
384
+ expect(updated.data.title).toBe("Updated title");
385
+
386
+ // DELETE
387
+ const deleteRes = await app.request("/tasks/1", { method: "DELETE" });
388
+ expect(deleteRes.status).toBe(200);
389
+
390
+ const check = await app.request("/tasks/1");
391
+ expect(check.status).toBe(404);
392
+ });
393
+
394
+ it("extractSchemas works with dynamically built tables", () => {
395
+ const def = buildDrizzleTable(
396
+ { name: "expenses", label: "Expenses" },
397
+ [
398
+ { column_name: "description", column_type: "text", position: 0, not_null: true },
399
+ { column_name: "status", column_type: "select", position: 1, select_options: ["open", "closed"] },
400
+ { column_name: "amount", column_type: "currency", position: 2 },
401
+ ],
402
+ registry,
403
+ );
404
+
405
+ const schemas = extractSchemas([def]);
406
+ expect(schemas).toHaveLength(1);
407
+
408
+ const s = schemas[0];
409
+ expect(s.name).toBe("expenses");
410
+ expect(s.label).toBe("Expenses");
411
+ expect(s.source).toBe("file"); // Default when using plain array
412
+
413
+ // Select column
414
+ const statusCol = s.columns.find((c) => c.name === "status")!;
415
+ expect(statusCol.select).toEqual({ options: ["open", "closed"] });
416
+ expect(statusCol.selectOptions).toEqual(["open", "closed"]);
417
+
418
+ // Currency column
419
+ const amountCol = s.columns.find((c) => c.name === "amount")!;
420
+ expect(amountCol.money).toBe(true);
421
+ });
422
+ });
423
+
424
+ // ═══════════════════════════════════════════════════════════════════════════════
425
+ // 4. Schema API Extensions (source, shadowedByFile, displayColumn)
426
+ // ═══════════════════════════════════════════════════════════════════════════════
427
+
428
+ describe("extractSchemas with registry (source + shadow tracking)", () => {
429
+ it("includes source: 'file' for file-managed tables", () => {
430
+ const registry = new SchemaRegistry();
431
+ const drizzleTable = sqliteTable("accounts", {
432
+ id: integer("id").primaryKey({ autoIncrement: true }),
433
+ name: text("name").notNull(),
434
+ });
435
+ const def = table({ drizzle: drizzleTable, meta: { label: "Accounts" } });
436
+ registry.register(def, "file");
437
+
438
+ const schemas = extractSchemas(registry);
439
+ expect(schemas[0].source).toBe("file");
440
+ expect(schemas[0].shadowedByFile).toBeUndefined();
441
+ });
442
+
443
+ it("includes source: 'ui' for UI-managed tables", () => {
444
+ const registry = new SchemaRegistry();
445
+ const def = buildDrizzleTable(
446
+ { name: "expenses", label: "Expenses" },
447
+ [],
448
+ registry,
449
+ );
450
+ registry.register(def, "ui");
451
+
452
+ const schemas = extractSchemas(registry);
453
+ expect(schemas[0].source).toBe("ui");
454
+ });
455
+
456
+ it("marks shadowed tables", () => {
457
+ const registry = new SchemaRegistry();
458
+
459
+ // Register a file-managed table
460
+ const drizzleTable = sqliteTable("accounts", {
461
+ id: integer("id").primaryKey({ autoIncrement: true }),
462
+ name: text("name").notNull(),
463
+ });
464
+ const fileDef = table({ drizzle: drizzleTable, meta: { label: "Accounts" } });
465
+ registry.register(fileDef, "file");
466
+
467
+ // Try to register a UI-managed table with the same name — it gets shadowed
468
+ const uiDef = buildDrizzleTable({ name: "accounts", label: "Accounts UI" }, [], registry);
469
+ registry.register(uiDef, "ui");
470
+
471
+ // The file version should be active, but isShadowed returns true
472
+ // because a UI definition exists for this name
473
+ expect(registry.isShadowed("accounts")).toBe(true);
474
+
475
+ const schemas = extractSchemas(registry);
476
+ expect(schemas).toHaveLength(1); // Only one active entry
477
+ expect(schemas[0].source).toBe("file"); // File wins
478
+ expect(schemas[0].shadowedByFile).toBe(true);
479
+ });
480
+
481
+ it("includes displayColumn from meta", () => {
482
+ const registry = new SchemaRegistry();
483
+ const drizzleTable = sqliteTable("accounts", {
484
+ id: integer("id").primaryKey({ autoIncrement: true }),
485
+ name: text("name").notNull(),
486
+ });
487
+ const def = table({
488
+ drizzle: drizzleTable,
489
+ meta: { label: "Accounts", displayColumn: "name" },
490
+ });
491
+ registry.register(def, "file");
492
+
493
+ const schemas = extractSchemas(registry);
494
+ expect(schemas[0].displayColumn).toBe("name");
495
+ });
496
+ });
497
+
498
+ // ═══════════════════════════════════════════════════════════════════════════════
499
+ // 5. ensureMetadataTables — DDL bootstrap and migration
500
+ // ═══════════════════════════════════════════════════════════════════════════════
501
+
502
+ describe("ensureMetadataTables", () => {
503
+ it("creates both metadata tables from scratch", () => {
504
+ const sqlite = new Database(":memory:");
505
+ sqlite.pragma("foreign_keys = ON");
506
+
507
+ ensureMetadataTables(sqlite);
508
+
509
+ const tables = sqlite
510
+ .prepare(
511
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '_sapporta_%'`,
512
+ )
513
+ .all() as { name: string }[];
514
+
515
+ const names = tables.map((t) => t.name).sort();
516
+ expect(names).toEqual(["_sapporta_columns", "_sapporta_tables"]);
517
+ sqlite.close();
518
+ });
519
+
520
+ it("is idempotent — calling twice does not throw", () => {
521
+ const sqlite = new Database(":memory:");
522
+ sqlite.pragma("foreign_keys = ON");
523
+
524
+ ensureMetadataTables(sqlite);
525
+ expect(() => ensureMetadataTables(sqlite)).not.toThrow();
526
+ sqlite.close();
527
+ });
528
+
529
+ it("migrates old schema: adds inferred column if missing", () => {
530
+ // Simulate a database from before the inferred column was added.
531
+ const sqlite = new Database(":memory:");
532
+ sqlite.pragma("foreign_keys = ON");
533
+
534
+ // Create _sapporta_tables WITHOUT the inferred column (old schema)
535
+ sqlite.exec(`
536
+ CREATE TABLE _sapporta_tables (
537
+ name TEXT PRIMARY KEY,
538
+ label TEXT,
539
+ display_column TEXT,
540
+ immutable INTEGER NOT NULL DEFAULT 0,
541
+ position INTEGER NOT NULL DEFAULT 0,
542
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
543
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
544
+ )
545
+ `);
546
+ sqlite.exec(`
547
+ CREATE TABLE _sapporta_columns (
548
+ table_name TEXT NOT NULL REFERENCES _sapporta_tables(name) ON DELETE CASCADE,
549
+ column_name TEXT NOT NULL,
550
+ column_type TEXT NOT NULL DEFAULT 'text',
551
+ position INTEGER NOT NULL DEFAULT 0,
552
+ not_null INTEGER NOT NULL DEFAULT 0,
553
+ is_unique INTEGER NOT NULL DEFAULT 0,
554
+ default_value TEXT,
555
+ references_table TEXT,
556
+ select_options TEXT,
557
+ ui_hints TEXT NOT NULL DEFAULT '{}',
558
+ PRIMARY KEY (table_name, column_name)
559
+ )
560
+ `);
561
+
562
+ // Verify inferred column does NOT exist yet
563
+ const colsBefore = sqlite.pragma('table_info("_sapporta_tables")') as { name: string }[];
564
+ expect(colsBefore.map((c) => c.name)).not.toContain("inferred");
565
+
566
+ // Run ensureMetadataTables — should migrate without error
567
+ ensureMetadataTables(sqlite);
568
+
569
+ // Verify inferred column now exists
570
+ const colsAfter = sqlite.pragma('table_info("_sapporta_tables")') as { name: string }[];
571
+ expect(colsAfter.map((c) => c.name)).toContain("inferred");
572
+
573
+ // Verify default value works: inserting a row without inferred should default to 0
574
+ sqlite.prepare(`INSERT INTO _sapporta_tables (name) VALUES (?)`).run("test");
575
+ const row = sqlite.prepare(`SELECT inferred FROM _sapporta_tables WHERE name = ?`).get("test") as { inferred: number };
576
+ expect(row.inferred).toBe(0);
577
+
578
+ sqlite.close();
579
+ });
580
+
581
+ it("cascades column deletes when table row is deleted", () => {
582
+ const sqlite = new Database(":memory:");
583
+ sqlite.pragma("foreign_keys = ON");
584
+ ensureMetadataTables(sqlite);
585
+
586
+ sqlite.prepare(`INSERT INTO _sapporta_tables (name, label) VALUES (?, ?)`).run("test_table", "Test");
587
+ sqlite.prepare(`INSERT INTO _sapporta_columns (table_name, column_name, column_type) VALUES (?, ?, ?)`).run("test_table", "col1", "text");
588
+
589
+ sqlite.prepare(`DELETE FROM _sapporta_tables WHERE name = ?`).run("test_table");
590
+
591
+ const cols = sqlite.prepare(`SELECT * FROM _sapporta_columns WHERE table_name = ?`).all("test_table");
592
+ expect(cols).toHaveLength(0);
593
+
594
+ sqlite.close();
595
+ });
596
+ });
597
+
598
+ // ═══════════════════════════════════════════════════════════════════════════════
599
+ // 6. loadUISchemas — reading metadata into live TableDefs
600
+ // ═══════════════════════════════════════════════════════════════════════════════
601
+
602
+ describe("loadUISchemas", () => {
603
+ it("returns empty array for fresh database", () => {
604
+ const sqlite = new Database(":memory:");
605
+ sqlite.pragma("foreign_keys = ON");
606
+ ensureMetadataTables(sqlite);
607
+
608
+ const registry = new SchemaRegistry();
609
+ const defs = loadUISchemas(sqlite, registry);
610
+ expect(defs).toEqual([]);
611
+
612
+ sqlite.close();
613
+ });
614
+
615
+ it("loads a table with columns into live TableDefs", () => {
616
+ const sqlite = new Database(":memory:");
617
+ sqlite.pragma("foreign_keys = ON");
618
+ ensureMetadataTables(sqlite);
619
+
620
+ sqlite.prepare(`INSERT INTO _sapporta_tables (name, label, position) VALUES (?, ?, ?)`).run("contacts", "Contacts", 0);
621
+ sqlite.prepare(`INSERT INTO _sapporta_columns (table_name, column_name, column_type, position, not_null) VALUES (?, ?, ?, ?, ?)`).run("contacts", "name", "text", 0, 1);
622
+ sqlite.prepare(`INSERT INTO _sapporta_columns (table_name, column_name, column_type, position) VALUES (?, ?, ?, ?)`).run("contacts", "email", "email", 1);
623
+
624
+ const registry = new SchemaRegistry();
625
+ const defs = loadUISchemas(sqlite, registry);
626
+
627
+ expect(defs).toHaveLength(1);
628
+ expect(defs[0].sqlName).toBe("contacts");
629
+ expect(defs[0].meta.label).toBe("Contacts");
630
+
631
+ sqlite.close();
632
+ });
633
+
634
+ it("deserializes JSON select_options and ui_hints", () => {
635
+ const sqlite = new Database(":memory:");
636
+ sqlite.pragma("foreign_keys = ON");
637
+ ensureMetadataTables(sqlite);
638
+
639
+ sqlite.prepare(`INSERT INTO _sapporta_tables (name) VALUES (?)`).run("items");
640
+ sqlite.prepare(
641
+ `INSERT INTO _sapporta_columns (table_name, column_name, column_type, select_options, ui_hints) VALUES (?, ?, ?, ?, ?)`,
642
+ ).run("items", "status", "select", JSON.stringify(["active", "inactive"]), JSON.stringify({ width: 40 }));
643
+
644
+ const registry = new SchemaRegistry();
645
+ const defs = loadUISchemas(sqlite, registry);
646
+
647
+ expect(defs[0].meta.selects).toBeDefined();
648
+ expect(defs[0].meta.selects![0].options).toEqual(["active", "inactive"]);
649
+ expect(defs[0].meta.columns?.status?.width).toBe(40);
650
+
651
+ sqlite.close();
652
+ });
653
+
654
+ it("converts immutable and inferred flags (SQLite 0/1 → boolean)", () => {
655
+ const sqlite = new Database(":memory:");
656
+ sqlite.pragma("foreign_keys = ON");
657
+ ensureMetadataTables(sqlite);
658
+
659
+ sqlite.prepare(`INSERT INTO _sapporta_tables (name, immutable, inferred) VALUES (?, ?, ?)`).run("audit_log", 1, 1);
660
+
661
+ const registry = new SchemaRegistry();
662
+ const defs = loadUISchemas(sqlite, registry);
663
+
664
+ expect(defs[0].meta.immutable).toBe(true);
665
+ expect(defs[0].meta.inferred).toBe(true);
666
+
667
+ sqlite.close();
668
+ });
669
+ });
670
+
671
+ describe("file vs UI table shadowing", () => {
672
+ it("file-managed tables shadow UI-managed tables", async () => {
673
+ const registry = new SchemaRegistry();
674
+
675
+ // Register a file-managed table first
676
+ const drizzleTable = sqliteTable("accounts", {
677
+ id: integer("id").primaryKey({ autoIncrement: true }),
678
+ name: text("name").notNull(),
679
+ balance: integer("balance"),
680
+ });
681
+ const fileDef = table({ drizzle: drizzleTable, meta: { label: "Accounts (File)" } });
682
+ registry.register(fileDef, "file");
683
+
684
+ // Now register a UI-managed table with the same name
685
+ const uiDef = buildDrizzleTable(
686
+ { name: "accounts", label: "Accounts (UI)" },
687
+ [{ column_name: "description", column_type: "text", position: 0 }],
688
+ registry,
689
+ );
690
+ registry.register(uiDef, "ui");
691
+
692
+ // File should win
693
+ expect(registry.all()).toHaveLength(1);
694
+ expect(registry.get("accounts")?.source).toBe("file");
695
+ expect(registry.get("accounts")?.def.meta.label).toBe("Accounts (File)");
696
+
697
+ // UI def should be in the shadow map
698
+ expect(registry.isShadowed("accounts")).toBe(true);
699
+ expect(registry.shadowedEntries().get("accounts")?.meta.label).toBe("Accounts (UI)");
700
+ });
701
+ });
702
+
703
+ // ═══════════════════════════════════════════════════════════════════════════════
704
+ // 6. Validation Helpers
705
+ // ═══════════════════════════════════════════════════════════════════════════════
706
+
707
+ describe("validateTableName / validateColumnName", () => {
708
+
709
+ it("accepts valid table names", () => {
710
+ expect(validateTableName("expenses").valid).toBe(true);
711
+ expect(validateTableName("order_items").valid).toBe(true);
712
+ expect(validateTableName("a123").valid).toBe(true);
713
+ });
714
+
715
+ it("rejects invalid table names", () => {
716
+ expect(validateTableName("123abc").valid).toBe(false); // starts with number
717
+ expect(validateTableName("My Table").valid).toBe(false); // spaces
718
+ expect(validateTableName("UPPER").valid).toBe(false); // uppercase
719
+ expect(validateTableName("").valid).toBe(false); // empty
720
+ expect(validateTableName("_sapporta_meta").valid).toBe(false); // reserved prefix
721
+ expect(validateTableName("_schema").valid).toBe(false); // reserved name
722
+ expect(validateTableName("_meta").valid).toBe(false); // reserved name
723
+ expect(validateTableName("select").valid).toBe(false); // PG reserved word
724
+ expect(validateTableName("table").valid).toBe(false); // PG reserved word
725
+ });
726
+
727
+ it("accepts valid column names", () => {
728
+ expect(validateColumnName("amount").valid).toBe(true);
729
+ expect(validateColumnName("first_name").valid).toBe(true);
730
+ });
731
+
732
+ it("rejects auto-managed column names", () => {
733
+ expect(validateColumnName("id").valid).toBe(false);
734
+ expect(validateColumnName("created_at").valid).toBe(false);
735
+ expect(validateColumnName("updated_at").valid).toBe(false);
736
+ });
737
+ });