@sapporta/server 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +40 -0
- package/src/actions/action.test.ts +108 -0
- package/src/actions/action.ts +60 -0
- package/src/actions/loader.ts +47 -0
- package/src/api/actions.ts +124 -0
- package/src/api/meta-mutations.ts +922 -0
- package/src/api/meta.ts +222 -0
- package/src/api/reports.ts +98 -0
- package/src/api/server.ts +24 -0
- package/src/api/tables.ts +108 -0
- package/src/api/views.ts +44 -0
- package/src/boot.ts +206 -0
- package/src/cli/ai-commands.ts +220 -0
- package/src/cli/check.ts +169 -0
- package/src/cli/cli-utils.test.ts +313 -0
- package/src/cli/describe.test.ts +151 -0
- package/src/cli/describe.ts +88 -0
- package/src/cli/emit-result.test.ts +160 -0
- package/src/cli/format.ts +150 -0
- package/src/cli/http-client.ts +55 -0
- package/src/cli/index.ts +162 -0
- package/src/cli/init.ts +35 -0
- package/src/cli/project-context.ts +38 -0
- package/src/cli/request.ts +146 -0
- package/src/cli/routes.ts +418 -0
- package/src/cli/rows-insert-master-detail.test.ts +124 -0
- package/src/cli/rows-insert-master-detail.ts +186 -0
- package/src/cli/rows-insert.test.ts +137 -0
- package/src/cli/rows-insert.ts +97 -0
- package/src/cli/serve-single.ts +49 -0
- package/src/create-project.ts +81 -0
- package/src/data/count.ts +62 -0
- package/src/data/crud.test.ts +188 -0
- package/src/data/crud.ts +242 -0
- package/src/data/lookup.test.ts +96 -0
- package/src/data/lookup.ts +104 -0
- package/src/data/query-parser.test.ts +67 -0
- package/src/data/query-parser.ts +106 -0
- package/src/data/sanitize.test.ts +57 -0
- package/src/data/sanitize.ts +25 -0
- package/src/data/save-pipeline.test.ts +115 -0
- package/src/data/save-pipeline.ts +93 -0
- package/src/data/validate.test.ts +110 -0
- package/src/data/validate.ts +98 -0
- package/src/db/errors.ts +20 -0
- package/src/db/logger.ts +63 -0
- package/src/db/sqlite-connection.test.ts +59 -0
- package/src/db/sqlite-connection.ts +79 -0
- package/src/index.ts +111 -0
- package/src/integration/api-actions.test.ts +60 -0
- package/src/integration/api-global.test.ts +21 -0
- package/src/integration/api-meta.test.ts +252 -0
- package/src/integration/api-reports.test.ts +77 -0
- package/src/integration/api-tables.test.ts +238 -0
- package/src/integration/api-views.test.ts +39 -0
- package/src/integration/cli-routes.test.ts +167 -0
- package/src/integration/fixtures/actions/create-account.ts +23 -0
- package/src/integration/fixtures/reports/account-list.ts +25 -0
- package/src/integration/fixtures/schema/accounts.ts +21 -0
- package/src/integration/fixtures/schema/audit-log.ts +19 -0
- package/src/integration/fixtures/schema/journal-entries.ts +20 -0
- package/src/integration/fixtures/views/dashboard.tsx +4 -0
- package/src/integration/fixtures/views/settings.tsx +3 -0
- package/src/integration/setup.ts +72 -0
- package/src/introspect/db-helpers.ts +109 -0
- package/src/introspect/describe-all.test.ts +73 -0
- package/src/introspect/describe-all.ts +80 -0
- package/src/introspect/describe.test.ts +65 -0
- package/src/introspect/describe.ts +184 -0
- package/src/introspect/exec.test.ts +103 -0
- package/src/introspect/exec.ts +57 -0
- package/src/introspect/indexes.test.ts +41 -0
- package/src/introspect/indexes.ts +95 -0
- package/src/introspect/inference.ts +98 -0
- package/src/introspect/list-tables.test.ts +40 -0
- package/src/introspect/list-tables.ts +62 -0
- package/src/introspect/query.test.ts +77 -0
- package/src/introspect/query.ts +47 -0
- package/src/introspect/sample.test.ts +67 -0
- package/src/introspect/sample.ts +50 -0
- package/src/introspect/sql-safety.ts +76 -0
- package/src/introspect/sqlite/db-helpers.test.ts +79 -0
- package/src/introspect/sqlite/db-helpers.ts +56 -0
- package/src/introspect/sqlite/describe-all.ts +21 -0
- package/src/introspect/sqlite/describe.test.ts +160 -0
- package/src/introspect/sqlite/describe.ts +185 -0
- package/src/introspect/sqlite/exec.ts +57 -0
- package/src/introspect/sqlite/indexes.test.ts +60 -0
- package/src/introspect/sqlite/indexes.ts +96 -0
- package/src/introspect/sqlite/list-tables.test.ts +100 -0
- package/src/introspect/sqlite/list-tables.ts +67 -0
- package/src/introspect/sqlite/query.ts +49 -0
- package/src/introspect/sqlite/sample.ts +50 -0
- package/src/introspect/table-rename.test.ts +235 -0
- package/src/introspect/table-rename.ts +115 -0
- package/src/introspect/types.ts +95 -0
- package/src/reports/check.test.ts +499 -0
- package/src/reports/check.ts +208 -0
- package/src/reports/engine.test.ts +1465 -0
- package/src/reports/engine.ts +678 -0
- package/src/reports/loader.ts +55 -0
- package/src/reports/report.ts +308 -0
- package/src/reports/sql-bind.ts +161 -0
- package/src/reports/sqlite-bind.test.ts +98 -0
- package/src/reports/sqlite-bind.ts +58 -0
- package/src/reports/sqlite-sql-client.ts +42 -0
- package/src/runtime.ts +3 -0
- package/src/schema/check.ts +90 -0
- package/src/schema/ddl.test.ts +210 -0
- package/src/schema/ddl.ts +180 -0
- package/src/schema/dynamic-builder.ts +297 -0
- package/src/schema/extract.test.ts +261 -0
- package/src/schema/extract.ts +285 -0
- package/src/schema/loader.test.ts +31 -0
- package/src/schema/loader.ts +60 -0
- package/src/schema/metadata-io.test.ts +261 -0
- package/src/schema/metadata-io.ts +161 -0
- package/src/schema/metadata-tables.test.ts +737 -0
- package/src/schema/metadata-tables.ts +341 -0
- package/src/schema/migrate.ts +195 -0
- package/src/schema/normalize-datatype.test.ts +58 -0
- package/src/schema/normalize-datatype.ts +99 -0
- package/src/schema/registry.test.ts +174 -0
- package/src/schema/registry.ts +139 -0
- package/src/schema/reserved.ts +227 -0
- package/src/schema/table.ts +135 -0
- package/src/test-fixtures/schema/accounts.ts +24 -0
- package/src/test-fixtures/schema/not-a-table.ts +6 -0
- package/src/testing/test-utils.ts +44 -0
- package/src/views/loader.test.ts +70 -0
- package/src/views/loader.ts +38 -0
- package/src/views/view.test.ts +121 -0
- package/src/views/view.ts +16 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { getTableConfig } from "drizzle-orm/sqlite-core";
|
|
3
|
+
import { normalizeDataType } from "./normalize-datatype.js";
|
|
4
|
+
import type { TableDef } from "./table.js";
|
|
5
|
+
import type { SchemaRegistry } from "./registry.js";
|
|
6
|
+
import { logger } from "../db/logger.js";
|
|
7
|
+
|
|
8
|
+
const log = logger.child({ module: "schema" });
|
|
9
|
+
|
|
10
|
+
export interface ColumnSchema {
|
|
11
|
+
name: string;
|
|
12
|
+
header?: string;
|
|
13
|
+
format?: "string" | "integer" | "date" | "currency" | "percentage";
|
|
14
|
+
|
|
15
|
+
// Display function: formats a column value for presentation using data from
|
|
16
|
+
// any SQL column (including undeclared ones), declared column values, and
|
|
17
|
+
// rollup values. Receives a merged object: { ...rawSqlRow, ...node.columns, ...node.rollup }.
|
|
18
|
+
// Runs as the LAST step — after transform, rollup, sort, and footer — so
|
|
19
|
+
// rollup/footer functions always see raw numeric values, not display strings.
|
|
20
|
+
// Functions don't serialize to JSON, so this field is transparently absent in API responses.
|
|
21
|
+
display?: (data: Record<string, unknown>) => string | number | null;
|
|
22
|
+
|
|
23
|
+
// DB-derived metadata — all optional, default to inert values
|
|
24
|
+
dataType?: string;
|
|
25
|
+
money?: boolean;
|
|
26
|
+
primary?: boolean;
|
|
27
|
+
isUnique?: boolean;
|
|
28
|
+
notNull?: boolean;
|
|
29
|
+
hasDefault?: boolean;
|
|
30
|
+
foreignKey?: { table: string; column: string } | null;
|
|
31
|
+
select?: { options: string[] } | null;
|
|
32
|
+
hidden?: boolean;
|
|
33
|
+
width?: number;
|
|
34
|
+
minWidth?: number;
|
|
35
|
+
maxWidth?: number;
|
|
36
|
+
widthPx?: number;
|
|
37
|
+
blankIfZero?: boolean;
|
|
38
|
+
|
|
39
|
+
// UI-managed table metadata — present when the table is UI-managed,
|
|
40
|
+
// used by the column editor to know the logical type and options.
|
|
41
|
+
/** The metadata column_type (e.g. "text", "link", "currency", "select").
|
|
42
|
+
* Only set for UI-managed tables — file-managed tables don't have this. */
|
|
43
|
+
columnType?: string;
|
|
44
|
+
/** Flattened select options, convenience duplicate of select.options.
|
|
45
|
+
* Present when column_type is "select". */
|
|
46
|
+
selectOptions?: string[];
|
|
47
|
+
/** Freeform notes describing the column's meaning, conventions, or formula */
|
|
48
|
+
notes?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ChildSchema {
|
|
52
|
+
table: string;
|
|
53
|
+
foreignKey: string;
|
|
54
|
+
label: string;
|
|
55
|
+
columns: string[];
|
|
56
|
+
defaultSort: string;
|
|
57
|
+
width?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface TableSchema {
|
|
61
|
+
name: string;
|
|
62
|
+
label: string;
|
|
63
|
+
immutable: boolean;
|
|
64
|
+
columns: ColumnSchema[];
|
|
65
|
+
children: ChildSchema[];
|
|
66
|
+
/** "file" = defined in TypeScript schema files (code-managed, not editable via UI).
|
|
67
|
+
* "ui" = created at runtime via the metadata tables (editable via UI).
|
|
68
|
+
* The UI uses this to show/hide mutation controls (add column, rename, delete). */
|
|
69
|
+
source: "file" | "ui";
|
|
70
|
+
/** True if a UI definition exists in the metadata tables but is overridden
|
|
71
|
+
* by a TypeScript file definition. The code definition takes precedence.
|
|
72
|
+
* When true, the UI should show a warning. */
|
|
73
|
+
shadowedByFile?: boolean;
|
|
74
|
+
/** The column to use as the display value in lookups and FK references.
|
|
75
|
+
* From the table's meta.displayColumn. */
|
|
76
|
+
displayColumn?: string;
|
|
77
|
+
/** Whether LLM inference has been run on this table (UI-managed tables only).
|
|
78
|
+
* When false for a UI table, the frontend triggers inference after the first row. */
|
|
79
|
+
inferred?: boolean;
|
|
80
|
+
/** Approximate live row count.
|
|
81
|
+
* Only present when the caller merges counts after extractSchemas(). */
|
|
82
|
+
rowCount?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Schema Entry ───────────────────────────────────────────────────────────
|
|
86
|
+
// Internal type that pairs a TableDef with its source. Matches RegistryEntry
|
|
87
|
+
// but also works with the plain TableDef[] overload (defaulting to "file").
|
|
88
|
+
|
|
89
|
+
interface SchemaEntry {
|
|
90
|
+
def: TableDef;
|
|
91
|
+
source: "file" | "ui";
|
|
92
|
+
shadowedByFile?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extract schema metadata from loaded TableDefs with source information.
|
|
97
|
+
*
|
|
98
|
+
* Accepts either:
|
|
99
|
+
* - A SchemaRegistry (production path: reads entries with source tags, checks shadows)
|
|
100
|
+
* - A plain TableDef[] (test path: defaults all to "file" source)
|
|
101
|
+
*
|
|
102
|
+
* Used by GET /api/_schema and available for direct use in tests.
|
|
103
|
+
*/
|
|
104
|
+
export function extractSchemas(source: SchemaRegistry | TableDef[]): TableSchema[] {
|
|
105
|
+
// Normalize input to SchemaEntry[]
|
|
106
|
+
let entries: SchemaEntry[];
|
|
107
|
+
|
|
108
|
+
if (Array.isArray(source)) {
|
|
109
|
+
// Plain TableDef[] — backwards-compatible path for tests.
|
|
110
|
+
// All tables default to "file" source, no shadows.
|
|
111
|
+
entries = source.map((def) => ({ def, source: "file" as const }));
|
|
112
|
+
} else {
|
|
113
|
+
// SchemaRegistry — production path with source tags and shadow detection.
|
|
114
|
+
entries = source.allEntries().map((entry) => ({
|
|
115
|
+
def: entry.def,
|
|
116
|
+
source: entry.source,
|
|
117
|
+
shadowedByFile: source.isShadowed(entry.def.sqlName),
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Build a lookup by sqlName for resolving children
|
|
122
|
+
const byName = new Map<string, TableDef>();
|
|
123
|
+
for (const e of entries) byName.set(e.def.sqlName, e.def);
|
|
124
|
+
|
|
125
|
+
return entries.map((entry) => {
|
|
126
|
+
const schema = entry.def;
|
|
127
|
+
const config = getTableConfig(schema.drizzle);
|
|
128
|
+
|
|
129
|
+
// Build FK lookup: column name → { table, column }
|
|
130
|
+
const fkMap = new Map<string, { table: string; column: string }>();
|
|
131
|
+
for (const fk of config.foreignKeys) {
|
|
132
|
+
const ref = fk.reference();
|
|
133
|
+
// ref.foreignColumns[0] is the target column, ref.columns[0] is the source column
|
|
134
|
+
const sourceCol = ref.columns[0];
|
|
135
|
+
const targetCol = ref.foreignColumns[0];
|
|
136
|
+
if (sourceCol && targetCol) {
|
|
137
|
+
const targetConfig = getTableConfig(targetCol.table);
|
|
138
|
+
fkMap.set(sourceCol.name, {
|
|
139
|
+
table: targetConfig.name,
|
|
140
|
+
column: targetCol.name,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Build select lookup
|
|
146
|
+
const selectMap = new Map<string, string[]>();
|
|
147
|
+
if (schema.meta.selects) {
|
|
148
|
+
for (const s of schema.meta.selects) {
|
|
149
|
+
selectMap.set(s.column, s.options);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const columns: ColumnSchema[] = config.columns.map((col) => {
|
|
154
|
+
const colSchema: ColumnSchema = {
|
|
155
|
+
name: col.name,
|
|
156
|
+
// Drizzle's internal dataType differs between dialects (e.g. Pg string-mode
|
|
157
|
+
// timestamps report "string", not "date"). normalizeDataType() provides a
|
|
158
|
+
// stable, dialect-agnostic value for UI formatting.
|
|
159
|
+
dataType: normalizeDataType(col),
|
|
160
|
+
notNull: col.notNull,
|
|
161
|
+
hasDefault: col.hasDefault,
|
|
162
|
+
primary: col.primary,
|
|
163
|
+
isUnique: col.isUnique,
|
|
164
|
+
foreignKey: fkMap.get(col.name) ?? null,
|
|
165
|
+
select: selectMap.has(col.name)
|
|
166
|
+
? { options: selectMap.get(col.name)! }
|
|
167
|
+
: null,
|
|
168
|
+
money: schema.meta.columns?.[col.name]?.type === "money",
|
|
169
|
+
header: schema.meta.columns?.[col.name]?.header,
|
|
170
|
+
width: schema.meta.columns?.[col.name]?.width,
|
|
171
|
+
minWidth: schema.meta.columns?.[col.name]?.minWidth,
|
|
172
|
+
maxWidth: schema.meta.columns?.[col.name]?.maxWidth,
|
|
173
|
+
widthPx: schema.meta.columns?.[col.name]?.widthPx,
|
|
174
|
+
notes: schema.meta.columns?.[col.name]?.notes,
|
|
175
|
+
hidden: schema.meta.columns?.[col.name]?.hidden ??
|
|
176
|
+
(col.name === "created_at" || col.name === "updated_at" ? true : undefined),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// For select columns, also expose the options as a flat array
|
|
180
|
+
// (convenience for the column editor, avoids digging into select.options)
|
|
181
|
+
if (selectMap.has(col.name)) {
|
|
182
|
+
colSchema.selectOptions = selectMap.get(col.name)!;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// For UI-managed tables, include the logical column type
|
|
186
|
+
if (schema.meta.columns?.[col.name]?.columnType) {
|
|
187
|
+
colSchema.columnType = schema.meta.columns[col.name].columnType;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return colSchema;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Resolve children
|
|
194
|
+
const children: ChildSchema[] = [];
|
|
195
|
+
if (schema.meta.children) {
|
|
196
|
+
for (const childMeta of schema.meta.children) {
|
|
197
|
+
const childDef = byName.get(childMeta.table);
|
|
198
|
+
if (!childDef) {
|
|
199
|
+
log.warn("Child table not found, skipping", { parent: schema.sqlName, child: childMeta.table });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const childConfig = getTableConfig(childDef.drizzle);
|
|
204
|
+
const pkColName = childConfig.columns.find((c) => c.primary)?.name ?? "id";
|
|
205
|
+
|
|
206
|
+
// Default columns: all except PK, the FK column, created_at, updated_at
|
|
207
|
+
const excludeCols = new Set([pkColName, childMeta.foreignKey, "created_at", "updated_at"]);
|
|
208
|
+
const resolvedColumns = childMeta.columns ??
|
|
209
|
+
childConfig.columns
|
|
210
|
+
.map((c) => c.name)
|
|
211
|
+
.filter((name) => !excludeCols.has(name));
|
|
212
|
+
|
|
213
|
+
children.push({
|
|
214
|
+
table: childMeta.table,
|
|
215
|
+
foreignKey: childMeta.foreignKey,
|
|
216
|
+
label: childMeta.label ?? childDef.meta.label ?? childMeta.table,
|
|
217
|
+
columns: resolvedColumns,
|
|
218
|
+
defaultSort: childMeta.defaultSort ?? pkColName,
|
|
219
|
+
width: childMeta.width,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
name: schema.sqlName,
|
|
226
|
+
label: schema.meta.label ?? schema.sqlName,
|
|
227
|
+
immutable: schema.meta.immutable ?? false,
|
|
228
|
+
columns,
|
|
229
|
+
children,
|
|
230
|
+
source: entry.source,
|
|
231
|
+
displayColumn: schema.meta.displayColumn,
|
|
232
|
+
// shadowedByFile is true when the table name has a UI definition in
|
|
233
|
+
// metadata but is overridden by a file schema. The file wins at runtime.
|
|
234
|
+
...(entry.shadowedByFile ? { shadowedByFile: true } : {}),
|
|
235
|
+
// inferred tracks whether LLM inference has run (UI-managed tables only)
|
|
236
|
+
...(entry.source === "ui" ? { inferred: schema.meta.inferred ?? false } : {}),
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Extract schema metadata for a single table by name.
|
|
243
|
+
*
|
|
244
|
+
* Used by GET /meta/tables/:name in metaApi(). Returns undefined if the table
|
|
245
|
+
* is not found. Builds the full list internally (extractSchemas is pure and
|
|
246
|
+
* fast — no I/O), then filters. For a registry with many tables this could
|
|
247
|
+
* be optimized to extract a single entry, but correctness is more important
|
|
248
|
+
* here — the full extraction handles FK resolution and child references that
|
|
249
|
+
* require the complete table set.
|
|
250
|
+
*/
|
|
251
|
+
export function extractSchema(
|
|
252
|
+
source: SchemaRegistry | TableDef[],
|
|
253
|
+
name: string,
|
|
254
|
+
): TableSchema | undefined {
|
|
255
|
+
const all = extractSchemas(source);
|
|
256
|
+
return all.find((s) => s.name === name);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Standalone schema metadata endpoint (GET /).
|
|
261
|
+
*
|
|
262
|
+
* NOTE: In production, schema introspection is served by metaApi() which calls
|
|
263
|
+
* extractSchemas() directly. This schemaApi() function exists as a convenience
|
|
264
|
+
* for tests that want a self-contained Hono app without the full /meta namespace.
|
|
265
|
+
*
|
|
266
|
+
* Accepts a SchemaRegistry (computes per-request so new tables appear
|
|
267
|
+
* immediately) or a plain TableDef[] (static, for tests).
|
|
268
|
+
*/
|
|
269
|
+
export function schemaApi(source: SchemaRegistry | TableDef[]) {
|
|
270
|
+
const app = new Hono();
|
|
271
|
+
|
|
272
|
+
if (Array.isArray(source)) {
|
|
273
|
+
const data = extractSchemas(source);
|
|
274
|
+
app.get("/", (c) => c.json({ tables: data }));
|
|
275
|
+
} else {
|
|
276
|
+
// Registry mode: compute per-request so new tables appear immediately.
|
|
277
|
+
// Pass the registry directly so extractSchemas can read source tags.
|
|
278
|
+
app.get("/", (c) => {
|
|
279
|
+
const data = extractSchemas(source);
|
|
280
|
+
return c.json({ tables: data });
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return app;
|
|
285
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { loadSchemas } from "./loader.js";
|
|
4
|
+
|
|
5
|
+
describe("loadSchemas()", () => {
|
|
6
|
+
const schemaDir = resolve(import.meta.dirname, "../test-fixtures/schema");
|
|
7
|
+
|
|
8
|
+
it("loads TableDef exports from a directory", async () => {
|
|
9
|
+
const { tables } = await loadSchemas(schemaDir);
|
|
10
|
+
const accounts = tables.find((t) => t.sqlName === "accounts");
|
|
11
|
+
expect(accounts).toBeDefined();
|
|
12
|
+
expect(accounts!.meta.label).toBe("Accounts");
|
|
13
|
+
expect(accounts!.meta.selects).toHaveLength(1);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("silently skips non-TableDef exports", async () => {
|
|
17
|
+
const { tables } = await loadSchemas(schemaDir);
|
|
18
|
+
const tableNames = tables.map((t) => t.sqlName);
|
|
19
|
+
expect(tableNames).toContain("accounts");
|
|
20
|
+
expect(tables.every((t) => typeof t.sqlName === "string" && typeof t.drizzle === "object")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("skips .test.ts files", async () => {
|
|
24
|
+
const { tables } = await loadSchemas(schemaDir);
|
|
25
|
+
expect(tables.map((t) => t.sqlName)).toEqual(["accounts"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// SQLite has no native enum type. Enum values are expressed as
|
|
29
|
+
// text({ enum: [...] }) in column definitions, so there's no
|
|
30
|
+
// separate enum object to detect during loading.
|
|
31
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { resolve, join } from "node:path";
|
|
3
|
+
import type { TableDef } from "./table.js";
|
|
4
|
+
|
|
5
|
+
// SQLite has no native enum type. Enum values are expressed as
|
|
6
|
+
// text({ enum: [...] }) directly in column definitions, so there's
|
|
7
|
+
// no separate enum object to collect during schema loading.
|
|
8
|
+
export interface SchemaLoadResult {
|
|
9
|
+
tables: TableDef[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check if a value looks like a TableDef (duck-typing).
|
|
14
|
+
*/
|
|
15
|
+
function isTableDef(val: unknown): val is TableDef {
|
|
16
|
+
if (typeof val !== "object" || val === null) return false;
|
|
17
|
+
const obj = val as Record<string, unknown>;
|
|
18
|
+
return (
|
|
19
|
+
typeof obj.sqlName === "string" &&
|
|
20
|
+
typeof obj.drizzle === "object" &&
|
|
21
|
+
obj.drizzle !== null &&
|
|
22
|
+
typeof obj.meta === "object"
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load all schema files from a directory.
|
|
28
|
+
* Each .ts file is dynamically imported and scanned for TableDef exports.
|
|
29
|
+
* Works under tsx runtime.
|
|
30
|
+
*/
|
|
31
|
+
export async function loadSchemas(dir: string): Promise<SchemaLoadResult> {
|
|
32
|
+
const absDir = resolve(dir);
|
|
33
|
+
let files: string[];
|
|
34
|
+
try {
|
|
35
|
+
files = await readdir(absDir);
|
|
36
|
+
} catch (err: any) {
|
|
37
|
+
if (err.code === "ENOENT") return { tables: [] };
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
const tables: TableDef[] = [];
|
|
41
|
+
|
|
42
|
+
for (const file of files) {
|
|
43
|
+
if (!file.endsWith(".ts") && !file.endsWith(".js")) continue;
|
|
44
|
+
if (file.endsWith(".test.ts") || file.endsWith(".test.js")) continue;
|
|
45
|
+
|
|
46
|
+
const filePath = join(absDir, file);
|
|
47
|
+
const mod = await import(filePath);
|
|
48
|
+
|
|
49
|
+
for (const key of Object.keys(mod)) {
|
|
50
|
+
const val = mod[key];
|
|
51
|
+
if (isTableDef(val)) {
|
|
52
|
+
tables.push(val);
|
|
53
|
+
}
|
|
54
|
+
// No enum detection — SQLite enums are text({ enum }) columns,
|
|
55
|
+
// part of the table definition rather than separate objects.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { tables };
|
|
60
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import {
|
|
4
|
+
deserializeColumnRow,
|
|
5
|
+
serializeColumnValues,
|
|
6
|
+
mergeUiHints,
|
|
7
|
+
buildSetClause,
|
|
8
|
+
type RawMetadataColumnRow,
|
|
9
|
+
} from "./metadata-io.js";
|
|
10
|
+
import { ensureMetadataTables } from "./metadata-tables.js";
|
|
11
|
+
|
|
12
|
+
describe("deserializeColumnRow", () => {
|
|
13
|
+
it("deserializes JSON select_options", () => {
|
|
14
|
+
const raw: RawMetadataColumnRow = {
|
|
15
|
+
table_name: "items",
|
|
16
|
+
column_name: "status",
|
|
17
|
+
column_type: "select",
|
|
18
|
+
position: 0,
|
|
19
|
+
not_null: 1,
|
|
20
|
+
is_unique: 0,
|
|
21
|
+
default_value: null,
|
|
22
|
+
references_table: null,
|
|
23
|
+
select_options: JSON.stringify(["active", "inactive"]),
|
|
24
|
+
ui_hints: "{}",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const result = deserializeColumnRow(raw);
|
|
28
|
+
expect(result.select_options).toEqual(["active", "inactive"]);
|
|
29
|
+
expect(result.not_null).toBe(true);
|
|
30
|
+
expect(result.is_unique).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("handles null select_options", () => {
|
|
34
|
+
const raw: RawMetadataColumnRow = {
|
|
35
|
+
table_name: "items",
|
|
36
|
+
column_name: "name",
|
|
37
|
+
column_type: "text",
|
|
38
|
+
position: 0,
|
|
39
|
+
not_null: 0,
|
|
40
|
+
is_unique: 0,
|
|
41
|
+
default_value: null,
|
|
42
|
+
references_table: null,
|
|
43
|
+
select_options: null,
|
|
44
|
+
ui_hints: "{}",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const result = deserializeColumnRow(raw);
|
|
48
|
+
expect(result.select_options).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("deserializes JSON ui_hints", () => {
|
|
52
|
+
const raw: RawMetadataColumnRow = {
|
|
53
|
+
table_name: "items",
|
|
54
|
+
column_name: "notes",
|
|
55
|
+
column_type: "text",
|
|
56
|
+
position: 0,
|
|
57
|
+
not_null: 0,
|
|
58
|
+
is_unique: 0,
|
|
59
|
+
default_value: null,
|
|
60
|
+
references_table: null,
|
|
61
|
+
select_options: null,
|
|
62
|
+
ui_hints: JSON.stringify({ width: 40, header: "Comments" }),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const result = deserializeColumnRow(raw);
|
|
66
|
+
expect(result.ui_hints).toEqual({ width: 40, header: "Comments" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("converts SQLite integer booleans to JS booleans", () => {
|
|
70
|
+
const raw: RawMetadataColumnRow = {
|
|
71
|
+
table_name: "items",
|
|
72
|
+
column_name: "name",
|
|
73
|
+
column_type: "text",
|
|
74
|
+
position: 0,
|
|
75
|
+
not_null: 1,
|
|
76
|
+
is_unique: 1,
|
|
77
|
+
default_value: null,
|
|
78
|
+
references_table: null,
|
|
79
|
+
select_options: null,
|
|
80
|
+
ui_hints: "{}",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const result = deserializeColumnRow(raw);
|
|
84
|
+
expect(result.not_null).toBe(true);
|
|
85
|
+
expect(result.is_unique).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("serializeColumnValues", () => {
|
|
90
|
+
it("serializes select_options to JSON string", () => {
|
|
91
|
+
const result = serializeColumnValues({
|
|
92
|
+
select_options: ["a", "b", "c"],
|
|
93
|
+
});
|
|
94
|
+
expect(result.select_options).toBe('["a","b","c"]');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("serializes null select_options", () => {
|
|
98
|
+
// When explicitly set to undefined, the field is passed through unchanged
|
|
99
|
+
// (undefined stays undefined — it won't be included in an INSERT).
|
|
100
|
+
// When set to null or empty array, it serializes to null.
|
|
101
|
+
const result = serializeColumnValues({ select_options: undefined });
|
|
102
|
+
expect(result.select_options).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("serializes ui_hints to JSON string", () => {
|
|
106
|
+
const result = serializeColumnValues({
|
|
107
|
+
ui_hints: { width: 30, hidden: true },
|
|
108
|
+
});
|
|
109
|
+
expect(result.ui_hints).toBe('{"width":30,"hidden":true}');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("passes through non-JSON fields unchanged", () => {
|
|
113
|
+
const result = serializeColumnValues({
|
|
114
|
+
column_name: "status",
|
|
115
|
+
column_type: "select",
|
|
116
|
+
not_null: true,
|
|
117
|
+
});
|
|
118
|
+
expect(result.column_name).toBe("status");
|
|
119
|
+
expect(result.column_type).toBe("select");
|
|
120
|
+
expect(result.not_null).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("round-trip", () => {
|
|
125
|
+
it("serialize → deserialize preserves data", () => {
|
|
126
|
+
const original = {
|
|
127
|
+
column_name: "status",
|
|
128
|
+
column_type: "select",
|
|
129
|
+
position: 2,
|
|
130
|
+
not_null: true,
|
|
131
|
+
is_unique: false,
|
|
132
|
+
select_options: ["active", "inactive", "archived"],
|
|
133
|
+
ui_hints: { width: 20, header: "Status" },
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const serialized = serializeColumnValues(original);
|
|
137
|
+
// Simulate what SQLite would return (integers for booleans)
|
|
138
|
+
const rawRow: RawMetadataColumnRow = {
|
|
139
|
+
table_name: "items",
|
|
140
|
+
column_name: serialized.column_name as string,
|
|
141
|
+
column_type: serialized.column_type as string,
|
|
142
|
+
position: serialized.position as number,
|
|
143
|
+
not_null: (serialized.not_null as boolean) ? 1 : 0,
|
|
144
|
+
is_unique: (serialized.is_unique as boolean) ? 1 : 0,
|
|
145
|
+
default_value: null,
|
|
146
|
+
references_table: null,
|
|
147
|
+
select_options: serialized.select_options as string,
|
|
148
|
+
ui_hints: serialized.ui_hints as string,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const deserialized = deserializeColumnRow(rawRow);
|
|
152
|
+
expect(deserialized.select_options).toEqual(original.select_options);
|
|
153
|
+
expect(deserialized.ui_hints).toEqual(original.ui_hints);
|
|
154
|
+
expect(deserialized.not_null).toBe(original.not_null);
|
|
155
|
+
expect(deserialized.is_unique).toBe(original.is_unique);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("mergeUiHints", () => {
|
|
160
|
+
let sqlite: Database.Database;
|
|
161
|
+
|
|
162
|
+
beforeEach(() => {
|
|
163
|
+
sqlite = new Database(":memory:");
|
|
164
|
+
sqlite.pragma("foreign_keys = ON");
|
|
165
|
+
ensureMetadataTables(sqlite);
|
|
166
|
+
|
|
167
|
+
// Insert test data
|
|
168
|
+
sqlite
|
|
169
|
+
.prepare(`INSERT INTO _sapporta_tables (name) VALUES (?)`)
|
|
170
|
+
.run("test_table");
|
|
171
|
+
sqlite
|
|
172
|
+
.prepare(
|
|
173
|
+
`INSERT INTO _sapporta_columns (table_name, column_name, column_type, ui_hints)
|
|
174
|
+
VALUES (?, ?, ?, ?)`,
|
|
175
|
+
)
|
|
176
|
+
.run("test_table", "col1", "text", JSON.stringify({ width: 20 }));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
afterEach(() => {
|
|
180
|
+
sqlite.close();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("merges new keys into existing ui_hints", () => {
|
|
184
|
+
mergeUiHints(sqlite, "test_table", "col1", { header: "Column One" });
|
|
185
|
+
|
|
186
|
+
const row = sqlite
|
|
187
|
+
.prepare(
|
|
188
|
+
`SELECT ui_hints FROM _sapporta_columns
|
|
189
|
+
WHERE table_name = ? AND column_name = ?`,
|
|
190
|
+
)
|
|
191
|
+
.get("test_table", "col1") as { ui_hints: string };
|
|
192
|
+
|
|
193
|
+
const hints = JSON.parse(row.ui_hints);
|
|
194
|
+
expect(hints).toEqual({ width: 20, header: "Column One" });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("overwrites existing keys", () => {
|
|
198
|
+
mergeUiHints(sqlite, "test_table", "col1", { width: 40 });
|
|
199
|
+
|
|
200
|
+
const row = sqlite
|
|
201
|
+
.prepare(
|
|
202
|
+
`SELECT ui_hints FROM _sapporta_columns
|
|
203
|
+
WHERE table_name = ? AND column_name = ?`,
|
|
204
|
+
)
|
|
205
|
+
.get("test_table", "col1") as { ui_hints: string };
|
|
206
|
+
|
|
207
|
+
const hints = JSON.parse(row.ui_hints);
|
|
208
|
+
expect(hints.width).toBe(40);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("handles empty existing ui_hints", () => {
|
|
212
|
+
// Update to empty JSON
|
|
213
|
+
sqlite
|
|
214
|
+
.prepare(
|
|
215
|
+
`UPDATE _sapporta_columns SET ui_hints = '{}' WHERE table_name = ? AND column_name = ?`,
|
|
216
|
+
)
|
|
217
|
+
.run("test_table", "col1");
|
|
218
|
+
|
|
219
|
+
mergeUiHints(sqlite, "test_table", "col1", { notes: "test" });
|
|
220
|
+
|
|
221
|
+
const row = sqlite
|
|
222
|
+
.prepare(
|
|
223
|
+
`SELECT ui_hints FROM _sapporta_columns
|
|
224
|
+
WHERE table_name = ? AND column_name = ?`,
|
|
225
|
+
)
|
|
226
|
+
.get("test_table", "col1") as { ui_hints: string };
|
|
227
|
+
|
|
228
|
+
expect(JSON.parse(row.ui_hints)).toEqual({ notes: "test" });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("handles column with no existing row (nonexistent table/column)", () => {
|
|
232
|
+
// mergeUiHints reads ui_hints and falls back to {} if the row doesn't
|
|
233
|
+
// exist. The UPDATE then silently affects 0 rows. This shouldn't throw.
|
|
234
|
+
expect(() => {
|
|
235
|
+
mergeUiHints(sqlite, "nonexistent_table", "nonexistent_col", { key: "val" });
|
|
236
|
+
}).not.toThrow();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("buildSetClause", () => {
|
|
241
|
+
it("builds parameterized SET clause", () => {
|
|
242
|
+
const { clause, values } = buildSetClause({
|
|
243
|
+
label: "Foo",
|
|
244
|
+
position: 3,
|
|
245
|
+
});
|
|
246
|
+
expect(clause).toBe('"label" = ?, "position" = ?');
|
|
247
|
+
expect(values).toEqual(["Foo", 3]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("handles single field", () => {
|
|
251
|
+
const { clause, values } = buildSetClause({ name: "test" });
|
|
252
|
+
expect(clause).toBe('"name" = ?');
|
|
253
|
+
expect(values).toEqual(["test"]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("handles null values", () => {
|
|
257
|
+
const { clause, values } = buildSetClause({ label: null });
|
|
258
|
+
expect(clause).toBe('"label" = ?');
|
|
259
|
+
expect(values).toEqual([null]);
|
|
260
|
+
});
|
|
261
|
+
});
|