@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,297 @@
1
+ /**
2
+ * Dynamic Table Builder — constructs real Drizzle SQLite table objects from metadata rows.
3
+ *
4
+ * This is the core bridge between UI-managed table definitions (stored in
5
+ * _sapporta_tables / _sapporta_columns) and the runtime type system. Every
6
+ * Drizzle API surface — query builder, getTableConfig, FK references,
7
+ * extractSchemas, CRUD, validation — works identically with these runtime-
8
+ * constructed tables as with compile-time ones. The only caveat is compile-time
9
+ * type loss (SQLiteTableWithColumns<any>), which is harmless at runtime and
10
+ * matches the existing `as any` cast pattern used throughout the codebase.
11
+ */
12
+ import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
13
+ import { sql } from "drizzle-orm";
14
+ import type { SchemaRegistry } from "./registry.js";
15
+ import type { TableDef, SapportaMeta, SelectMeta, ColumnMeta as SapportaColumnMeta } from "./table.js";
16
+ import { logger } from "../db/logger.js";
17
+
18
+ // DDL generation is delegated to ddl.ts.
19
+ // Re-export the DDL functions so callers that imported from here still work.
20
+ export {
21
+ SQLITE_TYPE_MAP,
22
+ sqliteTypeForColumnType,
23
+ generateCreateTableDDL,
24
+ generateAddColumnDDL,
25
+ generateCheckConstraintDDL,
26
+ } from "./ddl.js";
27
+ import { SQLITE_TYPE_MAP } from "./ddl.js";
28
+
29
+ const log = logger.child({ module: "schema" });
30
+
31
+ // ─── Metadata Row Types ─────────────────────────────────────────────────────
32
+ // These describe the rows stored in _sapporta_tables and _sapporta_columns.
33
+ // They are the "source of truth" for UI-managed table definitions.
34
+
35
+ export interface TableMeta {
36
+ name: string;
37
+ label?: string;
38
+ display_column?: string;
39
+ immutable?: boolean;
40
+ inferred?: boolean;
41
+ position?: number;
42
+ }
43
+
44
+ export interface ColumnMetaRow {
45
+ column_name: string;
46
+ /** Logical column type that determines both the Drizzle builder and SQLite DDL type.
47
+ * Some types map to the same SQLite type but carry different UI semantics:
48
+ * - "currency" / "percentage" → TEXT (exact decimal, not REAL)
49
+ * - "email" / "url" → TEXT (UI-only distinction for validation)
50
+ * - "select" → TEXT (validation via select_options, not a DB constraint)
51
+ * - "link" → INTEGER + FK constraint (references another table's PK) */
52
+ column_type: string;
53
+ position: number;
54
+ not_null?: boolean;
55
+ is_unique?: boolean;
56
+ default_value?: string;
57
+ /** For "link" columns: the SQL name of the referenced table. */
58
+ references_table?: string;
59
+ /** For "select" columns: the allowed option values. */
60
+ select_options?: string[];
61
+ /** Arbitrary UI hints (display width, formatting, etc.) stored as JSON text. */
62
+ ui_hints?: Record<string, unknown>;
63
+ }
64
+
65
+ // ─── Column Type Validation ─────────────────────────────────────────────────
66
+ // The set of valid column_type values accepted by the mutation API.
67
+ // Derived from the SQLite type map so DDL generation and validation agree.
68
+
69
+ /** The set of valid column_type values accepted by the mutation API. */
70
+ export const VALID_COLUMN_TYPES = new Set(Object.keys(SQLITE_TYPE_MAP));
71
+
72
+ // ─── Drizzle Column Builders ────────────────────────────────────────────────
73
+ // Maps each logical type to the Drizzle builder function that constructs
74
+ // the in-memory column definition used by the query builder and ORM.
75
+ //
76
+ // Critical SQLite-specific mappings:
77
+ // - boolean → integer({ mode: "boolean" }): Drizzle handles 0/1 ↔ true/false
78
+ // - date/timestamp → text(): ISO 8601 strings, no Date object conversion
79
+ // - currency/percentage → text(): exact decimal as text, not REAL (precision loss)
80
+ // - numeric → real(): only for user-defined "numeric" columns (aggregation use)
81
+
82
+ type ColumnBuilder = (name: string) => any;
83
+
84
+ const DRIZZLE_BUILDER_MAP: Record<string, ColumnBuilder> = {
85
+ text: (name) => text(name),
86
+ integer: (name) => integer(name),
87
+ numeric: (name) => real(name),
88
+ boolean: (name) => integer(name, { mode: "boolean" }),
89
+ date: (name) => text(name), // ISO 8601 string
90
+ timestamp: (name) => text(name), // ISO 8601 string
91
+ select: (name) => text(name),
92
+ link: (name) => integer(name),
93
+ currency: (name) => text(name), // Exact decimal as text (see sqlite-ddl.ts C4)
94
+ percentage: (name) => text(name), // Exact decimal as text (see sqlite-ddl.ts C4)
95
+ email: (name) => text(name),
96
+ url: (name) => text(name),
97
+ };
98
+
99
+ // ─── Intermediate Representation ──────────────────────────────────────────
100
+ // A pure, self-contained description of a table — no Drizzle objects, no
101
+ // registry lookups. Produced by planTable(), consumed by realizeTable().
102
+
103
+ export interface ColumnSpec {
104
+ name: string;
105
+ /** Key into DRIZZLE_BUILDER_MAP (e.g. "text", "link", "currency") */
106
+ builderType: string;
107
+ notNull: boolean;
108
+ isUnique: boolean;
109
+ defaultValue: string | undefined;
110
+ /** For "link" columns: SQL name of FK target table. null otherwise. */
111
+ fkTarget: string | null;
112
+ }
113
+
114
+ export interface TableSpec {
115
+ sqlName: string;
116
+ /** User-defined only; system cols (id, created_at, updated_at) are implicit */
117
+ columns: ColumnSpec[];
118
+ meta: SapportaMeta;
119
+ }
120
+
121
+ // ─── Pure Layer ───────────────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Compute a TableSpec from raw metadata — pure function, no side effects.
125
+ *
126
+ * Unknown column types are skipped with a log.warn (the only impurity,
127
+ * and only in the degenerate case that should never occur after mutation
128
+ * API validation).
129
+ */
130
+ export function planTable(
131
+ tableMeta: TableMeta,
132
+ columns: ColumnMetaRow[],
133
+ ): TableSpec {
134
+ const specs: ColumnSpec[] = [];
135
+
136
+ for (const col of columns) {
137
+ if (!DRIZZLE_BUILDER_MAP[col.column_type]) {
138
+ log.warn("Unknown column type, skipping", { table: tableMeta.name, column: col.column_name, type: col.column_type });
139
+ continue;
140
+ }
141
+
142
+ specs.push({
143
+ name: col.column_name,
144
+ builderType: col.column_type,
145
+ notNull: col.not_null ?? false,
146
+ isUnique: col.is_unique ?? false,
147
+ defaultValue: col.default_value !== undefined && col.default_value !== null
148
+ ? col.default_value
149
+ : undefined,
150
+ fkTarget: col.column_type === "link" && col.references_table
151
+ ? col.references_table
152
+ : null,
153
+ });
154
+ }
155
+
156
+ return {
157
+ sqlName: tableMeta.name,
158
+ columns: specs,
159
+ meta: buildSapportaMeta(tableMeta, columns),
160
+ };
161
+ }
162
+
163
+ // ─── Effectful Layer ──────────────────────────────────────────────────────
164
+
165
+ /**
166
+ * Translate a TableSpec into a live Drizzle SQLite table + SapportaMeta.
167
+ *
168
+ * This is the only function that touches Drizzle builders and the registry.
169
+ *
170
+ * FK resolution strategy:
171
+ * - Uses Drizzle's lazy reference pattern: .references(() => target.id).
172
+ * The callback is evaluated at query time, not definition time, so the
173
+ * target just needs to exist in the registry by the time the first query runs.
174
+ * - If the target isn't in the registry, we skip .references() and log a
175
+ * warning. The column is still created as integer() without the FK
176
+ * constraint — boot validation will flag dangling links.
177
+ */
178
+ export function realizeTable(
179
+ spec: TableSpec,
180
+ registry: SchemaRegistry,
181
+ ): TableDef {
182
+ // System columns — always present, never in the spec.
183
+ // SQLite uses INTEGER PRIMARY KEY AUTOINCREMENT instead of SERIAL.
184
+ // Timestamps are TEXT columns with ISO 8601 strings (no native timestamp type).
185
+ const columnDefs: Record<string, any> = {
186
+ id: integer("id").primaryKey({ autoIncrement: true }),
187
+ created_at: text("created_at").notNull().default(sql`(datetime('now'))`),
188
+ updated_at: text("updated_at").notNull().default(sql`(datetime('now'))`),
189
+ };
190
+
191
+ for (const col of spec.columns) {
192
+ let drizzleCol = DRIZZLE_BUILDER_MAP[col.builderType](col.name);
193
+
194
+ if (col.notNull) drizzleCol = drizzleCol.notNull();
195
+ if (col.isUnique) drizzleCol = drizzleCol.unique();
196
+ if (col.defaultValue !== undefined) drizzleCol = drizzleCol.default(col.defaultValue);
197
+
198
+ if (col.fkTarget) {
199
+ if (registry.has(col.fkTarget)) {
200
+ const targetName = col.fkTarget;
201
+ drizzleCol = drizzleCol.references(() => {
202
+ const entry = registry.get(targetName);
203
+ if (!entry) {
204
+ throw new Error(`FK target "${targetName}" not found in registry`);
205
+ }
206
+ return (entry.def.drizzle as any).id;
207
+ });
208
+ } else {
209
+ log.warn("FK target not found, creating as plain integer without FK constraint", {
210
+ table: spec.sqlName, column: col.name, target: col.fkTarget,
211
+ });
212
+ }
213
+ }
214
+
215
+ columnDefs[col.name] = drizzleCol;
216
+ }
217
+
218
+ return {
219
+ drizzle: sqliteTable(spec.sqlName, columnDefs),
220
+ sqlName: spec.sqlName,
221
+ meta: spec.meta,
222
+ };
223
+ }
224
+
225
+ // ─── Composed ─────────────────────────────────────────────────────────────
226
+
227
+ /**
228
+ * Construct a real Drizzle SQLite table object from metadata rows.
229
+ * Composes planTable (pure) → realizeTable (effectful).
230
+ */
231
+ export function buildDrizzleTable(
232
+ tableMeta: TableMeta,
233
+ columns: ColumnMetaRow[],
234
+ registry: SchemaRegistry,
235
+ ): TableDef {
236
+ const spec = planTable(tableMeta, columns);
237
+ return realizeTable(spec, registry);
238
+ }
239
+
240
+ // ─── SapportaMeta Construction ──────────────────────────────────────────────
241
+
242
+ /**
243
+ * Build the SapportaMeta object from table + column metadata.
244
+ *
245
+ * The goal is to produce the same metadata structure that file-managed tables
246
+ * define statically in their table() calls, so the rest of Sapporta (CRUD,
247
+ * validation, schema API, etc.) treats both origins identically.
248
+ */
249
+ function buildSapportaMeta(
250
+ tableMeta: TableMeta,
251
+ columns: ColumnMetaRow[],
252
+ ): SapportaMeta {
253
+ // Collect select column definitions for Zod validation
254
+ const selects: SelectMeta[] = columns
255
+ .filter((c) => c.column_type === "select" && c.select_options?.length)
256
+ .map((c) => ({
257
+ type: "select" as const,
258
+ column: c.column_name,
259
+ options: c.select_options!,
260
+ }));
261
+
262
+ // Build per-column metadata from ui_hints and type-specific info
263
+ const columnsMeta: Record<string, SapportaColumnMeta> = {};
264
+ for (const col of columns) {
265
+ const meta: SapportaColumnMeta = {};
266
+
267
+ // Always store the logical column type for UI-managed columns
268
+ meta.columnType = col.column_type;
269
+
270
+ // Currency columns get "money" type for right-aligned decimal formatting
271
+ if (col.column_type === "currency") {
272
+ meta.type = "money";
273
+ }
274
+
275
+ // Merge any explicit UI hints (width, header, hidden, etc.)
276
+ if (col.ui_hints) {
277
+ if (typeof col.ui_hints.header === "string") meta.header = col.ui_hints.header;
278
+ if (typeof col.ui_hints.hidden === "boolean") meta.hidden = col.ui_hints.hidden;
279
+ if (typeof col.ui_hints.width === "number") meta.width = col.ui_hints.width;
280
+ if (typeof col.ui_hints.minWidth === "number") meta.minWidth = col.ui_hints.minWidth;
281
+ if (typeof col.ui_hints.maxWidth === "number") meta.maxWidth = col.ui_hints.maxWidth;
282
+ if (typeof col.ui_hints.widthPx === "number") meta.widthPx = col.ui_hints.widthPx;
283
+ if (typeof col.ui_hints.notes === "string") meta.notes = col.ui_hints.notes;
284
+ }
285
+
286
+ columnsMeta[col.column_name] = meta;
287
+ }
288
+
289
+ return {
290
+ label: tableMeta.label,
291
+ displayColumn: tableMeta.display_column,
292
+ immutable: tableMeta.immutable ?? false,
293
+ inferred: tableMeta.inferred ?? false,
294
+ ...(selects.length > 0 ? { selects } : {}),
295
+ ...(Object.keys(columnsMeta).length > 0 ? { columns: columnsMeta } : {}),
296
+ };
297
+ }
@@ -0,0 +1,261 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { Hono } from "hono";
3
+ import {
4
+ sqliteTable,
5
+ text,
6
+ integer,
7
+ real,
8
+ } from "drizzle-orm/sqlite-core";
9
+ import { timestamp } from "./table.js";
10
+ import { table } from "./table.js";
11
+ import { schemaApi, extractSchemas, extractSchema } from "./extract.js";
12
+
13
+ const accountsTable = sqliteTable("accounts", {
14
+ id: integer("id").primaryKey({ autoIncrement: true }),
15
+ name: text("name").notNull(),
16
+ type: text("type").notNull(),
17
+ balance: integer("balance"),
18
+ active: integer("active", { mode: "boolean" }).default(true),
19
+ created_at: timestamp("created_at").notNull().$defaultFn(() => new Date().toISOString()),
20
+ });
21
+
22
+ const accounts = table({
23
+ drizzle: accountsTable,
24
+ meta: {
25
+ label: "Accounts",
26
+ selects: [
27
+ {
28
+ type: "select",
29
+ column: "type",
30
+ options: ["asset", "liability", "equity", "revenue", "expense"],
31
+ },
32
+ ],
33
+ },
34
+ });
35
+
36
+ // Table with FK
37
+ const invoicesTable = sqliteTable("invoices", {
38
+ id: integer("id").primaryKey({ autoIncrement: true }),
39
+ account_id: integer("account_id")
40
+ .notNull()
41
+ .references(() => accountsTable.id),
42
+ amount: integer("amount").notNull(),
43
+ });
44
+
45
+ const invoices = table({
46
+ drizzle: invoicesTable,
47
+ meta: { label: "Invoices" },
48
+ });
49
+
50
+ // Immutable table
51
+ const ledgerTable = sqliteTable("ledger", {
52
+ id: integer("id").primaryKey({ autoIncrement: true }),
53
+ description: text("description").notNull(),
54
+ });
55
+
56
+ const ledger = table({
57
+ drizzle: ledgerTable,
58
+ meta: { immutable: true },
59
+ });
60
+
61
+ // Table with money columns
62
+ const transactionsTable = sqliteTable("transactions", {
63
+ id: integer("id").primaryKey({ autoIncrement: true }),
64
+ description: text("description").notNull(),
65
+ debit: real("debit"),
66
+ credit: real("credit"),
67
+ });
68
+
69
+ const transactions = table({
70
+ drizzle: transactionsTable,
71
+ meta: {
72
+ label: "Transactions",
73
+ columns: {
74
+ debit: { type: "money" },
75
+ credit: { type: "money" },
76
+ },
77
+ },
78
+ });
79
+
80
+ describe("extractSchemas", () => {
81
+ it("extracts table metadata correctly", () => {
82
+ const result = extractSchemas([accounts]);
83
+ expect(result).toHaveLength(1);
84
+ expect(result[0].name).toBe("accounts");
85
+ expect(result[0].label).toBe("Accounts");
86
+ expect(result[0].immutable).toBe(false);
87
+ });
88
+
89
+ it("extracts column metadata", () => {
90
+ const result = extractSchemas([accounts]);
91
+ const cols = result[0].columns;
92
+
93
+ const idCol = cols.find((c) => c.name === "id")!;
94
+ expect(idCol.primary).toBe(true);
95
+ expect(idCol.hasDefault).toBe(true);
96
+ expect(idCol.dataType).toBe("number");
97
+
98
+ const nameCol = cols.find((c) => c.name === "name")!;
99
+ expect(nameCol.notNull).toBe(true);
100
+ expect(nameCol.dataType).toBe("string");
101
+ expect(nameCol.primary).toBe(false);
102
+
103
+ const balanceCol = cols.find((c) => c.name === "balance")!;
104
+ expect(balanceCol.notNull).toBe(false);
105
+ expect(balanceCol.dataType).toBe("number");
106
+ });
107
+
108
+ it("includes select metadata", () => {
109
+ const result = extractSchemas([accounts]);
110
+ const typeCol = result[0].columns.find((c) => c.name === "type")!;
111
+ expect(typeCol.select).toEqual({
112
+ options: ["asset", "liability", "equity", "revenue", "expense"],
113
+ });
114
+ });
115
+
116
+ it("detects foreign keys", () => {
117
+ const result = extractSchemas([invoices]);
118
+ const fkCol = result[0].columns.find((c) => c.name === "account_id")!;
119
+ expect(fkCol.foreignKey).toEqual({ table: "accounts", column: "id" });
120
+ });
121
+
122
+ it("marks immutable tables", () => {
123
+ const result = extractSchemas([ledger]);
124
+ expect(result[0].immutable).toBe(true);
125
+ });
126
+
127
+ it("defaults label to sqlName", () => {
128
+ const result = extractSchemas([ledger]);
129
+ expect(result[0].label).toBe("ledger");
130
+ });
131
+
132
+ it("marks money columns from column metadata", () => {
133
+ const result = extractSchemas([transactions]);
134
+ const cols = result[0].columns;
135
+
136
+ const debitCol = cols.find((c) => c.name === "debit")!;
137
+ expect(debitCol.money).toBe(true);
138
+
139
+ const creditCol = cols.find((c) => c.name === "credit")!;
140
+ expect(creditCol.money).toBe(true);
141
+
142
+ const descCol = cols.find((c) => c.name === "description")!;
143
+ expect(descCol.money).toBe(false);
144
+ });
145
+
146
+ it("defaults money to false when no column metadata", () => {
147
+ const result = extractSchemas([accounts]);
148
+ const cols = result[0].columns;
149
+ for (const col of cols) {
150
+ expect(col.money).toBe(false);
151
+ }
152
+ });
153
+
154
+ it("auto-hides created_at and updated_at columns", () => {
155
+ // Hidden by default so the UI table views don't show noisy timestamps.
156
+ const result = extractSchemas([accounts]);
157
+ const cols = result[0].columns;
158
+
159
+ const createdAt = cols.find((c) => c.name === "created_at")!;
160
+ expect(createdAt.hidden).toBe(true);
161
+
162
+ // Columns that aren't created_at/updated_at should not be auto-hidden
163
+ const nameCol = cols.find((c) => c.name === "name")!;
164
+ expect(nameCol.hidden).toBeUndefined();
165
+ });
166
+
167
+ it("allows meta.columns to override auto-hidden timestamps", () => {
168
+ const customTable = sqliteTable("audit", {
169
+ id: integer("id").primaryKey({ autoIncrement: true }),
170
+ created_at: timestamp("created_at").notNull().$defaultFn(() => new Date().toISOString()),
171
+ });
172
+ const audit = table({
173
+ drizzle: customTable,
174
+ meta: {
175
+ columns: {
176
+ created_at: { hidden: false },
177
+ },
178
+ },
179
+ });
180
+
181
+ const result = extractSchemas([audit]);
182
+ const createdAt = result[0].columns.find((c) => c.name === "created_at")!;
183
+ expect(createdAt.hidden).toBe(false);
184
+ });
185
+
186
+ it("resolves children from parent table's meta.children", () => {
187
+ const ordersTable = sqliteTable("orders", {
188
+ id: integer("id").primaryKey({ autoIncrement: true }),
189
+ customer: text("customer").notNull(),
190
+ created_at: timestamp("created_at"),
191
+ updated_at: timestamp("updated_at"),
192
+ });
193
+ const lineItemsTable = sqliteTable("line_items", {
194
+ id: integer("id").primaryKey({ autoIncrement: true }),
195
+ order_id: integer("order_id").notNull().references(() => ordersTable.id),
196
+ product: text("product").notNull(),
197
+ quantity: integer("quantity").notNull(),
198
+ created_at: timestamp("created_at"),
199
+ updated_at: timestamp("updated_at"),
200
+ });
201
+
202
+ const orders = table({
203
+ drizzle: ordersTable,
204
+ meta: {
205
+ label: "Orders",
206
+ children: [
207
+ { table: "line_items", foreignKey: "order_id" },
208
+ ],
209
+ },
210
+ });
211
+ const lineItems = table({
212
+ drizzle: lineItemsTable,
213
+ meta: { label: "Line Items" },
214
+ });
215
+
216
+ const result = extractSchemas([orders, lineItems]);
217
+ const orderSchema = result.find((s) => s.name === "orders")!;
218
+
219
+ expect(orderSchema.children).toHaveLength(1);
220
+ const child = orderSchema.children[0];
221
+ expect(child.table).toBe("line_items");
222
+ expect(child.foreignKey).toBe("order_id");
223
+ expect(child.label).toBe("Line Items");
224
+
225
+ // Auto-resolved columns should exclude: id, order_id, created_at, updated_at
226
+ expect(child.columns).toContain("product");
227
+ expect(child.columns).toContain("quantity");
228
+ expect(child.columns).not.toContain("id");
229
+ expect(child.columns).not.toContain("order_id");
230
+ expect(child.columns).not.toContain("created_at");
231
+ expect(child.columns).not.toContain("updated_at");
232
+ });
233
+ });
234
+
235
+ describe("extractSchema (single-table lookup)", () => {
236
+ it("returns schema for a known table", () => {
237
+ const result = extractSchema([accounts, invoices], "accounts");
238
+ expect(result).toBeDefined();
239
+ expect(result!.name).toBe("accounts");
240
+ expect(result!.label).toBe("Accounts");
241
+ });
242
+
243
+ it("returns undefined for unknown table", () => {
244
+ const result = extractSchema([accounts], "nonexistent");
245
+ expect(result).toBeUndefined();
246
+ });
247
+ });
248
+
249
+ describe("GET /meta/tables", () => {
250
+ it("returns schema metadata via HTTP", async () => {
251
+ const app = new Hono();
252
+ app.route("/meta/tables", schemaApi([accounts, invoices]));
253
+
254
+ const res = await app.request("/meta/tables");
255
+ expect(res.status).toBe(200);
256
+ const json = await res.json();
257
+ expect(json.tables).toHaveLength(2);
258
+ expect(json.tables[0].name).toBe("accounts");
259
+ expect(json.tables[1].name).toBe("invoices");
260
+ });
261
+ });