@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,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata Tables — internal tables that store UI-managed table definitions.
|
|
3
|
+
*
|
|
4
|
+
* UI-managed tables are defined by non-programmers through the browser UI.
|
|
5
|
+
* Their definitions are stored in two internal tables:
|
|
6
|
+
*
|
|
7
|
+
* _sapporta_tables — one row per UI-managed table (name, label, options)
|
|
8
|
+
* _sapporta_columns — one row per column in a UI-managed table
|
|
9
|
+
*
|
|
10
|
+
* These are separate from the file-managed schemas (TypeScript files loaded
|
|
11
|
+
* at boot). The runtime merges both sources: file schemas always win on
|
|
12
|
+
* collision, UI schemas fill in the rest. This split is why:
|
|
13
|
+
* - File schemas use pushSQLiteSchema() (Drizzle Kit diff) for migration
|
|
14
|
+
* - UI schemas use direct DDL from the mutation API
|
|
15
|
+
* - At boot we only *validate* UI schemas, never auto-migrate them
|
|
16
|
+
*
|
|
17
|
+
* The bootstrap function creates these tables idempotently, and the load
|
|
18
|
+
* function reads them to construct TableDef objects via buildDrizzleTable.
|
|
19
|
+
*
|
|
20
|
+
* SQLite differences from the old Postgres version:
|
|
21
|
+
* - BOOLEAN → INTEGER (0/1) — SQLite has no native boolean type
|
|
22
|
+
* - TIMESTAMPTZ DEFAULT now() → TEXT DEFAULT (datetime('now'))
|
|
23
|
+
* - TEXT[] → TEXT (JSON-serialized array, deserialized on read)
|
|
24
|
+
* - JSONB → TEXT (JSON string, parsed on read)
|
|
25
|
+
* - information_schema → sqlite_master + PRAGMA table_info
|
|
26
|
+
* - All operations are synchronous (better-sqlite3)
|
|
27
|
+
*
|
|
28
|
+
* Boot sequence invariant: ensureMetadataTables() runs after DB connection
|
|
29
|
+
* but before schema loading. loadUISchemas() runs after file schemas are
|
|
30
|
+
* registered in the registry (so FK targets can be resolved).
|
|
31
|
+
*/
|
|
32
|
+
import type Database from "better-sqlite3";
|
|
33
|
+
import type { SchemaRegistry } from "./registry.js";
|
|
34
|
+
import type { TableDef } from "./table.js";
|
|
35
|
+
import {
|
|
36
|
+
buildDrizzleTable,
|
|
37
|
+
type TableMeta,
|
|
38
|
+
type ColumnMetaRow,
|
|
39
|
+
} from "./dynamic-builder.js";
|
|
40
|
+
import {
|
|
41
|
+
columnExists,
|
|
42
|
+
} from "../introspect/db-helpers.js";
|
|
43
|
+
import { logger } from "../db/logger.js";
|
|
44
|
+
|
|
45
|
+
const log = logger.child({ module: "metadata" });
|
|
46
|
+
|
|
47
|
+
// ─── Bootstrap ──────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create the internal metadata tables if they don't exist.
|
|
51
|
+
*
|
|
52
|
+
* Called during bootProject(), after DB connection but before schema loading.
|
|
53
|
+
* Uses sqlite_master to check existence (no information_schema in SQLite).
|
|
54
|
+
*
|
|
55
|
+
* All operations are synchronous — better-sqlite3 returns immediately.
|
|
56
|
+
*/
|
|
57
|
+
export function ensureMetadataTables(sqlite: Database.Database): void {
|
|
58
|
+
const existing = sqlite
|
|
59
|
+
.prepare(
|
|
60
|
+
`SELECT name FROM sqlite_master
|
|
61
|
+
WHERE type = 'table'
|
|
62
|
+
AND name IN ('_sapporta_tables', '_sapporta_columns')`,
|
|
63
|
+
)
|
|
64
|
+
.all() as { name: string }[];
|
|
65
|
+
|
|
66
|
+
const existingNames = new Set(existing.map((r) => r.name));
|
|
67
|
+
|
|
68
|
+
if (!existingNames.has("_sapporta_tables")) {
|
|
69
|
+
sqlite.exec(`
|
|
70
|
+
CREATE TABLE _sapporta_tables (
|
|
71
|
+
name TEXT PRIMARY KEY,
|
|
72
|
+
label TEXT,
|
|
73
|
+
display_column TEXT,
|
|
74
|
+
immutable INTEGER NOT NULL DEFAULT 0,
|
|
75
|
+
inferred INTEGER NOT NULL DEFAULT 0,
|
|
76
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
77
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
78
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
79
|
+
)
|
|
80
|
+
`);
|
|
81
|
+
} else {
|
|
82
|
+
// Migration: add inferred column if it doesn't exist yet.
|
|
83
|
+
// Same migration path as the old Postgres version for schema compatibility.
|
|
84
|
+
if (!columnExists(sqlite, "_sapporta_tables", "inferred")) {
|
|
85
|
+
sqlite.exec(
|
|
86
|
+
`ALTER TABLE _sapporta_tables ADD COLUMN inferred INTEGER NOT NULL DEFAULT 0`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!existingNames.has("_sapporta_columns")) {
|
|
92
|
+
// select_options is TEXT (JSON-serialized array) — no native TEXT[] in SQLite
|
|
93
|
+
// ui_hints is TEXT (JSON string) — no native JSONB in SQLite
|
|
94
|
+
sqlite.exec(`
|
|
95
|
+
CREATE TABLE _sapporta_columns (
|
|
96
|
+
table_name TEXT NOT NULL REFERENCES _sapporta_tables(name) ON DELETE CASCADE,
|
|
97
|
+
column_name TEXT NOT NULL,
|
|
98
|
+
column_type TEXT NOT NULL DEFAULT 'text',
|
|
99
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
100
|
+
not_null INTEGER NOT NULL DEFAULT 0,
|
|
101
|
+
is_unique INTEGER NOT NULL DEFAULT 0,
|
|
102
|
+
default_value TEXT,
|
|
103
|
+
references_table TEXT,
|
|
104
|
+
select_options TEXT,
|
|
105
|
+
ui_hints TEXT NOT NULL DEFAULT '{}',
|
|
106
|
+
PRIMARY KEY (table_name, column_name)
|
|
107
|
+
)
|
|
108
|
+
`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Load UI Schemas ────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Load UI-managed table definitions from the metadata tables.
|
|
116
|
+
*
|
|
117
|
+
* Reads _sapporta_tables + _sapporta_columns, deserializes JSON fields
|
|
118
|
+
* (select_options, ui_hints), then calls buildDrizzleTable() for each
|
|
119
|
+
* table to produce runtime TableDef objects.
|
|
120
|
+
*
|
|
121
|
+
* The registry is passed through for FK target resolution — by the time
|
|
122
|
+
* this runs, file-managed schemas are already registered (see boot
|
|
123
|
+
* sequence in runtime.ts).
|
|
124
|
+
*
|
|
125
|
+
* Critical JSON deserialization: select_options is stored as a JSON string
|
|
126
|
+
* in SQLite (not a native array like Postgres TEXT[]). Similarly, ui_hints
|
|
127
|
+
* is a JSON string, not native JSONB. Both are deserialized on read.
|
|
128
|
+
*/
|
|
129
|
+
export function loadUISchemas(
|
|
130
|
+
sqlite: Database.Database,
|
|
131
|
+
registry: SchemaRegistry,
|
|
132
|
+
): TableDef[] {
|
|
133
|
+
const tableRows = sqlite
|
|
134
|
+
.prepare(
|
|
135
|
+
`SELECT name, label, display_column, immutable, inferred, position
|
|
136
|
+
FROM _sapporta_tables
|
|
137
|
+
ORDER BY position, created_at`,
|
|
138
|
+
)
|
|
139
|
+
.all() as {
|
|
140
|
+
name: string;
|
|
141
|
+
label: string | null;
|
|
142
|
+
display_column: string | null;
|
|
143
|
+
immutable: number;
|
|
144
|
+
inferred: number;
|
|
145
|
+
position: number;
|
|
146
|
+
}[];
|
|
147
|
+
|
|
148
|
+
if (tableRows.length === 0) return [];
|
|
149
|
+
|
|
150
|
+
// Load all columns in one query (N+1 prevention), then group by table.
|
|
151
|
+
const columnRows = sqlite
|
|
152
|
+
.prepare(
|
|
153
|
+
`SELECT table_name, column_name, column_type, position,
|
|
154
|
+
not_null, is_unique, default_value, references_table,
|
|
155
|
+
select_options, ui_hints
|
|
156
|
+
FROM _sapporta_columns
|
|
157
|
+
ORDER BY table_name, position`,
|
|
158
|
+
)
|
|
159
|
+
.all() as {
|
|
160
|
+
table_name: string;
|
|
161
|
+
column_name: string;
|
|
162
|
+
column_type: string;
|
|
163
|
+
position: number;
|
|
164
|
+
not_null: number;
|
|
165
|
+
is_unique: number;
|
|
166
|
+
default_value: string | null;
|
|
167
|
+
references_table: string | null;
|
|
168
|
+
select_options: string | null; // JSON string, not native array
|
|
169
|
+
ui_hints: string; // JSON string, not native JSONB
|
|
170
|
+
}[];
|
|
171
|
+
|
|
172
|
+
// Group columns by table name
|
|
173
|
+
const columnsByTable = new Map<string, ColumnMetaRow[]>();
|
|
174
|
+
for (const row of columnRows) {
|
|
175
|
+
if (!columnsByTable.has(row.table_name)) {
|
|
176
|
+
columnsByTable.set(row.table_name, []);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Deserialize JSON fields: SQLite stores arrays and objects as TEXT,
|
|
180
|
+
// unlike Postgres which natively handles TEXT[] and JSONB.
|
|
181
|
+
columnsByTable.get(row.table_name)!.push({
|
|
182
|
+
column_name: row.column_name,
|
|
183
|
+
column_type: row.column_type,
|
|
184
|
+
position: row.position,
|
|
185
|
+
not_null: row.not_null === 1, // INTEGER 0/1 → boolean
|
|
186
|
+
is_unique: row.is_unique === 1, // INTEGER 0/1 → boolean
|
|
187
|
+
default_value: row.default_value ?? undefined,
|
|
188
|
+
references_table: row.references_table ?? undefined,
|
|
189
|
+
select_options: row.select_options
|
|
190
|
+
? JSON.parse(row.select_options) // TEXT → string[]
|
|
191
|
+
: undefined,
|
|
192
|
+
ui_hints: row.ui_hints ? JSON.parse(row.ui_hints) : {}, // TEXT → Record
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Build a TableDef for each metadata table
|
|
197
|
+
const defs: TableDef[] = [];
|
|
198
|
+
for (const row of tableRows) {
|
|
199
|
+
const tableMeta: TableMeta = {
|
|
200
|
+
name: row.name,
|
|
201
|
+
label: row.label ?? undefined,
|
|
202
|
+
display_column: row.display_column ?? undefined,
|
|
203
|
+
immutable: row.immutable === 1, // INTEGER 0/1 → boolean
|
|
204
|
+
inferred: row.inferred === 1, // INTEGER 0/1 → boolean
|
|
205
|
+
position: row.position,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const columns = columnsByTable.get(tableMeta.name) ?? [];
|
|
209
|
+
const def = buildDrizzleTable(tableMeta, columns, registry);
|
|
210
|
+
defs.push(def);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return defs;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── Boot Validation ────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Validate UI-managed tables by comparing metadata against actual DB state.
|
|
220
|
+
*
|
|
221
|
+
* Uses sqlite_master instead of information_schema.tables, and PRAGMA
|
|
222
|
+
* table_info instead of information_schema.columns.
|
|
223
|
+
*
|
|
224
|
+
* Why validate instead of auto-migrate? File-managed tables use
|
|
225
|
+
* pushSQLiteSchema() (Drizzle Kit diff) because the developer controls
|
|
226
|
+
* the schema. UI-managed tables were created at runtime via direct DDL.
|
|
227
|
+
* Running pushSQLiteSchema() on them risks destructive ALTER statements
|
|
228
|
+
* if metadata drifts. Instead, we compare and warn — the user or admin
|
|
229
|
+
* can fix drift manually.
|
|
230
|
+
*
|
|
231
|
+
* Also checks for dangling FK references (link columns pointing to tables
|
|
232
|
+
* that don't exist in the registry).
|
|
233
|
+
*/
|
|
234
|
+
export function validateUISchemas(
|
|
235
|
+
sqlite: Database.Database,
|
|
236
|
+
registry: SchemaRegistry,
|
|
237
|
+
slug: string,
|
|
238
|
+
): void {
|
|
239
|
+
const uiDefs = registry.uiManaged();
|
|
240
|
+
if (uiDefs.length === 0) return;
|
|
241
|
+
|
|
242
|
+
for (const def of uiDefs) {
|
|
243
|
+
// Check that the actual DB table exists via sqlite_master
|
|
244
|
+
const tableCheck = sqlite
|
|
245
|
+
.prepare(
|
|
246
|
+
`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
|
247
|
+
)
|
|
248
|
+
.get(def.sqlName);
|
|
249
|
+
|
|
250
|
+
if (!tableCheck) {
|
|
251
|
+
log.warn(
|
|
252
|
+
`UI-managed table "${def.sqlName}" exists in metadata but not in database. ` +
|
|
253
|
+
`Removing from registry. Delete from _sapporta_tables to clean up metadata.`,
|
|
254
|
+
{ project: slug, table: def.sqlName },
|
|
255
|
+
);
|
|
256
|
+
registry.unregister(def.sqlName);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Compare expected columns (metadata) against actual columns (PRAGMA table_info)
|
|
261
|
+
const actualColumns = sqlite.pragma(
|
|
262
|
+
`table_info("${def.sqlName}")`,
|
|
263
|
+
) as { name: string; type: string; notnull: number }[];
|
|
264
|
+
|
|
265
|
+
const actualColMap = new Map<
|
|
266
|
+
string,
|
|
267
|
+
{ type: string; notnull: number }
|
|
268
|
+
>();
|
|
269
|
+
for (const col of actualColumns) {
|
|
270
|
+
actualColMap.set(col.name, {
|
|
271
|
+
type: col.type,
|
|
272
|
+
notnull: col.notnull,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Load expected columns from metadata
|
|
277
|
+
const expectedColumns = sqlite
|
|
278
|
+
.prepare(
|
|
279
|
+
`SELECT column_name, column_type, not_null
|
|
280
|
+
FROM _sapporta_columns
|
|
281
|
+
WHERE table_name = ?`,
|
|
282
|
+
)
|
|
283
|
+
.all(def.sqlName) as {
|
|
284
|
+
column_name: string;
|
|
285
|
+
column_type: string;
|
|
286
|
+
not_null: number;
|
|
287
|
+
}[];
|
|
288
|
+
|
|
289
|
+
for (const expected of expectedColumns) {
|
|
290
|
+
const actual = actualColMap.get(expected.column_name);
|
|
291
|
+
if (!actual) {
|
|
292
|
+
log.warn("Column drift: exists in metadata but not in database", {
|
|
293
|
+
project: slug,
|
|
294
|
+
table: def.sqlName,
|
|
295
|
+
column: expected.column_name,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Check actual columns not in metadata (excluding system columns)
|
|
301
|
+
const systemCols = new Set(["id", "created_at", "updated_at"]);
|
|
302
|
+
const expectedNames = new Set(
|
|
303
|
+
expectedColumns.map((c) => c.column_name),
|
|
304
|
+
);
|
|
305
|
+
for (const [colName] of actualColMap) {
|
|
306
|
+
if (!systemCols.has(colName) && !expectedNames.has(colName)) {
|
|
307
|
+
log.warn("Column drift: exists in database but not in metadata", {
|
|
308
|
+
project: slug,
|
|
309
|
+
table: def.sqlName,
|
|
310
|
+
column: colName,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Check for dangling FK references across all UI-managed tables.
|
|
317
|
+
// A dangling FK is a "link" column whose references_table doesn't exist
|
|
318
|
+
// in the registry (neither as a file-managed nor UI-managed table).
|
|
319
|
+
const linkColumns = sqlite
|
|
320
|
+
.prepare(
|
|
321
|
+
`SELECT table_name, column_name, references_table
|
|
322
|
+
FROM _sapporta_columns
|
|
323
|
+
WHERE column_type = 'link' AND references_table IS NOT NULL`,
|
|
324
|
+
)
|
|
325
|
+
.all() as {
|
|
326
|
+
table_name: string;
|
|
327
|
+
column_name: string;
|
|
328
|
+
references_table: string;
|
|
329
|
+
}[];
|
|
330
|
+
|
|
331
|
+
for (const row of linkColumns) {
|
|
332
|
+
if (!registry.has(row.references_table)) {
|
|
333
|
+
log.warn("Dangling FK reference", {
|
|
334
|
+
project: slug,
|
|
335
|
+
table: row.table_name,
|
|
336
|
+
column: row.column_name,
|
|
337
|
+
referencesTable: row.references_table,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite Schema Migration — push schema changes via drizzle-kit/api
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// drizzle-kit/api verification results (Step 1.2):
|
|
6
|
+
// - Export: pushSQLiteSchema (separate function from pushSchema)
|
|
7
|
+
// - Signature: pushSQLiteSchema(imports, drizzleInstance)
|
|
8
|
+
// - imports: Record<string, SQLiteTable> (flat object of table names → Drizzle objects)
|
|
9
|
+
// - drizzleInstance: BetterSQLite3Database (uses .all() and .run() internally)
|
|
10
|
+
// - Returns: { hasDataLoss, warnings, statementsToExecute: string[], apply() }
|
|
11
|
+
// - NO tablesFilter parameter (unlike Postgres pushSchema which has one)
|
|
12
|
+
// - apply() executes ALL statementsToExecute — we don't call it, we execute
|
|
13
|
+
// filtered statements manually to skip destructive DROPs
|
|
14
|
+
//
|
|
15
|
+
// Differences from the old Postgres migrate.ts:
|
|
16
|
+
// - No PgEnum parameter (SQLite has no native enums)
|
|
17
|
+
// - No db.execute(sql.raw()) proxy wrapper (SQLite Drizzle doesn't have
|
|
18
|
+
// the Postgres res.rows compatibility issue)
|
|
19
|
+
// - sqlite.exec(stmt) for direct DDL execution instead of db.execute()
|
|
20
|
+
// - DROP SEQUENCE/DROP TYPE patterns removed (SQLite doesn't have these)
|
|
21
|
+
// - No tablesFilter — pushSQLiteSchema introspects ALL tables in the DB,
|
|
22
|
+
// so the destructive DROP filter is the only defense against dropping
|
|
23
|
+
// UI-managed tables. This is adequate because:
|
|
24
|
+
// 1. The statement filter catches DROP TABLE for UI tables
|
|
25
|
+
// 2. SQLite has no sequences or custom types to produce spurious DROPs
|
|
26
|
+
//
|
|
27
|
+
// Same two-layer safety philosophy as the old Postgres version, but Layer 1
|
|
28
|
+
// (tablesFilter) is unavailable, so Layer 2 (statement filter) is the
|
|
29
|
+
// primary defense.
|
|
30
|
+
|
|
31
|
+
import type Database from "better-sqlite3";
|
|
32
|
+
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
|
33
|
+
import { pushSQLiteSchema } from "drizzle-kit/api";
|
|
34
|
+
import type { TableDef } from "./table.js";
|
|
35
|
+
import { logger } from "../db/logger.js";
|
|
36
|
+
|
|
37
|
+
const log = logger.child({ module: "schema" });
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Matches destructive DROP statements that should never be auto-executed.
|
|
41
|
+
*
|
|
42
|
+
* Only top-level DROP TABLE and DROP INDEX are relevant for SQLite.
|
|
43
|
+
* ALTER TABLE ... DROP COLUMN is intentionally NOT matched — dropping
|
|
44
|
+
* a column is a legitimate schema change when a developer removes it
|
|
45
|
+
* from a file-managed table definition.
|
|
46
|
+
*/
|
|
47
|
+
const DESTRUCTIVE_DROP = [/^DROP\s+TABLE\b/i, /^DROP\s+INDEX\b/i];
|
|
48
|
+
|
|
49
|
+
function isDestructiveDrop(stmt: string): boolean {
|
|
50
|
+
return DESTRUCTIVE_DROP.some((re) => re.test(stmt.trimStart()));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Push file-managed schema changes to the SQLite database.
|
|
55
|
+
*
|
|
56
|
+
* ## Safety: no tablesFilter available
|
|
57
|
+
*
|
|
58
|
+
* Unlike Postgres's pushSchema which accepts a tablesFilter for positive
|
|
59
|
+
* inclusion, pushSQLiteSchema introspects ALL tables in the database.
|
|
60
|
+
* This means UI-managed tables appear as "extra" and generate DROP TABLE
|
|
61
|
+
* statements. The destructive statement filter is the sole defense:
|
|
62
|
+
* it strips all DROP TABLE/DROP INDEX before execution.
|
|
63
|
+
*
|
|
64
|
+
* This is safe because:
|
|
65
|
+
* 1. UI-managed tables are created via direct DDL, not schema push
|
|
66
|
+
* 2. The only way a DROP TABLE appears is if a table exists in DB but
|
|
67
|
+
* not in the schema objects — which is exactly the UI-managed case
|
|
68
|
+
* 3. SQLite has no sequences or custom types that could cause spurious DROPs
|
|
69
|
+
*
|
|
70
|
+
* ## Caller invariant
|
|
71
|
+
*
|
|
72
|
+
* The `schemas` parameter must contain only file-managed tables.
|
|
73
|
+
* The caller passes registry.fileManaged(), not registry.all().
|
|
74
|
+
*
|
|
75
|
+
* @returns Object with applied and skipped statement lists for logging.
|
|
76
|
+
*/
|
|
77
|
+
export async function migrateSchemas(
|
|
78
|
+
schemas: TableDef[],
|
|
79
|
+
db: BetterSQLite3Database,
|
|
80
|
+
sqlite: Database.Database,
|
|
81
|
+
): Promise<{ applied: string[]; skipped: string[] }> {
|
|
82
|
+
if (schemas.length === 0) {
|
|
83
|
+
log.info("Schema is up to date");
|
|
84
|
+
return { applied: [], skipped: [] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Build the imports record: flat object of { tableName: DrizzleTable }
|
|
88
|
+
const imports: Record<string, unknown> = {};
|
|
89
|
+
for (const schema of schemas) {
|
|
90
|
+
imports[schema.sqlName] = schema.drizzle;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// IMPORTANT: Temporarily remove non-schema tables from the database
|
|
94
|
+
// before calling pushSQLiteSchema.
|
|
95
|
+
//
|
|
96
|
+
// pushSQLiteSchema introspects ALL tables in the database. If it finds
|
|
97
|
+
// tables that aren't in the schema definition (like _sapporta_tables,
|
|
98
|
+
// _sapporta_columns, or UI-managed tables), it triggers an interactive
|
|
99
|
+
// prompt asking whether each new schema table should be created fresh or
|
|
100
|
+
// renamed from an existing table. This prompt hangs in non-interactive
|
|
101
|
+
// environments (tests, CI, automated deploys).
|
|
102
|
+
//
|
|
103
|
+
// The workaround: save the DDL + data for non-schema tables, drop them,
|
|
104
|
+
// run pushSQLiteSchema on a clean DB, then restore. SQLite makes this
|
|
105
|
+
// safe because all operations are synchronous and single-threaded — no
|
|
106
|
+
// concurrent access can observe the temporary state.
|
|
107
|
+
const schemaTableNames = new Set(Object.keys(imports));
|
|
108
|
+
const allTables = sqlite.prepare(
|
|
109
|
+
`SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`
|
|
110
|
+
).all() as { name: string; sql: string }[];
|
|
111
|
+
|
|
112
|
+
// Save state of tables that aren't part of the file-managed schema
|
|
113
|
+
const hiddenTables: { name: string; ddl: string; rows: unknown[] }[] = [];
|
|
114
|
+
for (const { name, sql: ddl } of allTables) {
|
|
115
|
+
if (!schemaTableNames.has(name)) {
|
|
116
|
+
// Save the CREATE TABLE DDL and all rows
|
|
117
|
+
const rows = sqlite.prepare(`SELECT * FROM "${name}"`).all();
|
|
118
|
+
hiddenTables.push({ name, ddl, rows });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Drop non-schema tables so pushSQLiteSchema doesn't see them.
|
|
123
|
+
// Order matters: drop child tables (with FK references) before parents.
|
|
124
|
+
// Temporarily disable FK checks to avoid constraint violations during drop.
|
|
125
|
+
sqlite.pragma("foreign_keys = OFF");
|
|
126
|
+
for (const { name } of hiddenTables) {
|
|
127
|
+
sqlite.exec(`DROP TABLE "${name}"`);
|
|
128
|
+
}
|
|
129
|
+
sqlite.pragma("foreign_keys = ON");
|
|
130
|
+
|
|
131
|
+
let result;
|
|
132
|
+
try {
|
|
133
|
+
// Cast to any: drizzle-kit's type declarations expect LibSQLDatabase
|
|
134
|
+
// but the runtime implementation only uses .all() and .run() which
|
|
135
|
+
// BetterSQLite3Database provides. Verified working in Step 1.2 spike.
|
|
136
|
+
result = await pushSQLiteSchema(imports, db as any);
|
|
137
|
+
} finally {
|
|
138
|
+
// Restore hidden tables regardless of success/failure.
|
|
139
|
+
// Disable FK checks during restore to handle any FK ordering issues.
|
|
140
|
+
sqlite.pragma("foreign_keys = OFF");
|
|
141
|
+
for (const { name, ddl, rows } of hiddenTables) {
|
|
142
|
+
sqlite.exec(ddl);
|
|
143
|
+
if (rows.length > 0) {
|
|
144
|
+
// Rebuild INSERT from row data. Use the column names from the first row.
|
|
145
|
+
const cols = Object.keys(rows[0] as Record<string, unknown>);
|
|
146
|
+
const placeholders = cols.map(() => "?").join(", ");
|
|
147
|
+
const insertStmt = sqlite.prepare(
|
|
148
|
+
`INSERT INTO "${name}" (${cols.map(c => `"${c}"`).join(", ")}) VALUES (${placeholders})`
|
|
149
|
+
);
|
|
150
|
+
const insertAll = sqlite.transaction((data: unknown[]) => {
|
|
151
|
+
for (const row of data) {
|
|
152
|
+
const values = cols.map(c => (row as Record<string, unknown>)[c]);
|
|
153
|
+
insertStmt.run(...values);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
insertAll(rows);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
sqlite.pragma("foreign_keys = ON");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (result.warnings.length > 0) {
|
|
163
|
+
log.warn("Schema migration warnings", { warnings: result.warnings });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Filter destructive statements — primary defense since no tablesFilter
|
|
167
|
+
const safe: string[] = [];
|
|
168
|
+
const skipped: string[] = [];
|
|
169
|
+
for (const stmt of result.statementsToExecute) {
|
|
170
|
+
if (isDestructiveDrop(stmt)) {
|
|
171
|
+
skipped.push(stmt);
|
|
172
|
+
} else {
|
|
173
|
+
safe.push(stmt);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (skipped.length > 0) {
|
|
178
|
+
log.warn("Skipped destructive statements", { statements: skipped });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (safe.length > 0) {
|
|
182
|
+
log.info(`Applying ${safe.length} schema change(s)`);
|
|
183
|
+
// Execute each statement via better-sqlite3's synchronous exec().
|
|
184
|
+
// Unlike the old Postgres version which used db.execute(sql.raw()) for
|
|
185
|
+
// PGlite compatibility, SQLite always has exec() available.
|
|
186
|
+
for (const stmt of safe) {
|
|
187
|
+
sqlite.exec(stmt);
|
|
188
|
+
}
|
|
189
|
+
log.info("Schema migration complete");
|
|
190
|
+
} else {
|
|
191
|
+
log.info("Schema is up to date");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { applied: safe, skipped };
|
|
195
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { normalizeDataType, isDateObjectMode } from "./normalize-datatype.js";
|
|
3
|
+
|
|
4
|
+
describe("normalizeDataType", () => {
|
|
5
|
+
// Drizzle reports date/timestamp columns as dataType:"string", but
|
|
6
|
+
// the UI needs "date" for date pickers and formatters. These tests
|
|
7
|
+
// cover the override path; non-date types pass through unchanged.
|
|
8
|
+
|
|
9
|
+
it("Pg PgDateString → date (overrides Drizzle's 'string')", () => {
|
|
10
|
+
expect(normalizeDataType({ columnType: "PgDateString", dataType: "string" })).toBe("date");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("Pg PgTimestampString → date (overrides Drizzle's 'string')", () => {
|
|
14
|
+
expect(normalizeDataType({ columnType: "PgTimestampString", dataType: "string" })).toBe("date");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("Pg PgTimestamp (Date mode) → date", () => {
|
|
18
|
+
expect(normalizeDataType({ columnType: "PgTimestamp", dataType: "date" })).toBe("date");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("Pg PgDate → date (overrides Drizzle's 'string')", () => {
|
|
22
|
+
expect(normalizeDataType({ columnType: "PgDate", dataType: "string" })).toBe("date");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("SQLite timestamp → date (matched by columnType, not dataType)", () => {
|
|
26
|
+
expect(normalizeDataType({ columnType: "SQLiteTimestamp", dataType: "date" })).toBe("date");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Non-date types fall through to Drizzle's dataType unchanged.
|
|
30
|
+
|
|
31
|
+
it("unknown columnType falls through to dataType", () => {
|
|
32
|
+
expect(normalizeDataType({ columnType: "SomeFutureType", dataType: "string" })).toBe("string");
|
|
33
|
+
expect(normalizeDataType({ columnType: "SomeFutureType", dataType: "number" })).toBe("number");
|
|
34
|
+
expect(normalizeDataType({ columnType: "SomeFutureType", dataType: "boolean" })).toBe("boolean");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("isDateObjectMode", () => {
|
|
39
|
+
it("PgTimestamp (default mode) returns Date objects", () => {
|
|
40
|
+
expect(isDateObjectMode({ columnType: "PgTimestamp" })).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("PgTimestampString (string mode) does NOT return Date objects", () => {
|
|
44
|
+
expect(isDateObjectMode({ columnType: "PgTimestampString" })).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("SQLiteTimestamp (mode: timestamp) returns Date objects", () => {
|
|
48
|
+
expect(isDateObjectMode({ columnType: "SQLiteTimestamp" })).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("SQLiteText does NOT return Date objects", () => {
|
|
52
|
+
expect(isDateObjectMode({ columnType: "SQLiteText" })).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("SQLiteInteger does NOT return Date objects", () => {
|
|
56
|
+
expect(isDateObjectMode({ columnType: "SQLiteInteger" })).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Database-agnostic column type normalization
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Drizzle's internal columnType and dataType strings differ between
|
|
6
|
+
// Postgres and SQLite:
|
|
7
|
+
//
|
|
8
|
+
// Postgres:
|
|
9
|
+
// PgTimestampString → dataType "string" (mode: "string")
|
|
10
|
+
// PgTimestamp → dataType "date" (default mode, returns Date objects)
|
|
11
|
+
// PgDateString → dataType "string"
|
|
12
|
+
// PgDate → dataType "string"
|
|
13
|
+
// PgNumeric → dataType "string" (Postgres NUMERICs are strings in JS)
|
|
14
|
+
// PgBoolean → dataType "boolean"
|
|
15
|
+
//
|
|
16
|
+
// SQLite:
|
|
17
|
+
// SQLiteText → dataType "string"
|
|
18
|
+
// SQLiteInteger → dataType "number"
|
|
19
|
+
// SQLiteReal → dataType "number"
|
|
20
|
+
// SQLiteBoolean → dataType "boolean" (mode: "boolean")
|
|
21
|
+
// SQLiteTimestamp → dataType "date" (mode: "timestamp", returns Date objects)
|
|
22
|
+
//
|
|
23
|
+
// The UI needs stable, dialect-agnostic dataType values for formatting.
|
|
24
|
+
// This module provides the single point of truth for that normalization.
|
|
25
|
+
//
|
|
26
|
+
// The current callers that do ad-hoc columnType checks:
|
|
27
|
+
// - extract.ts: PgDateString, PgDate, PgTimestampString → "date"
|
|
28
|
+
// - check.ts: PgTimestamp for Date-object-mode warning
|
|
29
|
+
// - validate.ts: PgTimestampString recognition
|
|
30
|
+
//
|
|
31
|
+
// This module centralizes those checks and extends them to SQLite types.
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Drizzle columnType values that represent date/timestamp columns.
|
|
35
|
+
*
|
|
36
|
+
* These are the columnType strings where the UI should use "date" formatting
|
|
37
|
+
* regardless of what Drizzle reports as dataType. The set covers:
|
|
38
|
+
* - Postgres timestamp/date columns in string mode (dataType = "string")
|
|
39
|
+
* - Postgres timestamp in Date mode (dataType = "date", but we normalize anyway)
|
|
40
|
+
* - SQLite timestamp (already dataType = "date" via mode: "timestamp")
|
|
41
|
+
*/
|
|
42
|
+
const DATE_COLUMN_TYPES = new Set([
|
|
43
|
+
// Postgres — string-mode timestamps report dataType "string" in Drizzle,
|
|
44
|
+
// but the UI needs "date" for display formatting. See timestamp() in table.ts.
|
|
45
|
+
"PgDateString",
|
|
46
|
+
"PgDate",
|
|
47
|
+
"PgTimestampString",
|
|
48
|
+
"PgTimestamp",
|
|
49
|
+
// SQLite — already returns dataType "date", but included for completeness
|
|
50
|
+
"SQLiteTimestamp",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Derive a stable, UI-facing dataType from Drizzle's column metadata.
|
|
55
|
+
*
|
|
56
|
+
* Priority order:
|
|
57
|
+
* 1. Known date/timestamp columnTypes (dialect-specific but harmless to check both)
|
|
58
|
+
* 2. Fall through to Drizzle's own dataType (works for most types)
|
|
59
|
+
*
|
|
60
|
+
* The returned string is one of: "string", "number", "boolean", "date"
|
|
61
|
+
* These map directly to UI formatting strategies.
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeDataType(col: {
|
|
64
|
+
columnType: string;
|
|
65
|
+
dataType: string;
|
|
66
|
+
}): string {
|
|
67
|
+
// Known date/timestamp types — overrides Drizzle's dataType which may be
|
|
68
|
+
// "string" for string-mode Pg timestamps, or already "date" for others
|
|
69
|
+
if (DATE_COLUMN_TYPES.has(col.columnType)) return "date";
|
|
70
|
+
|
|
71
|
+
// Fall through to Drizzle's reported dataType.
|
|
72
|
+
// This handles: "string", "number", "boolean", "date" (SQLiteTimestamp, PgTimestamp)
|
|
73
|
+
return col.dataType;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Detect if a column uses Date-object mode (not string mode).
|
|
78
|
+
*
|
|
79
|
+
* Used by schema checking to warn about non-JSON-safe timestamp modes.
|
|
80
|
+
* Sapporta is a JSON-over-HTTP framework — Date objects don't serialize
|
|
81
|
+
* cleanly across HTTP boundaries. The mode: "string" timestamp helper
|
|
82
|
+
* (table.ts) exists to avoid this, so finding Date-mode columns is a
|
|
83
|
+
* schema error that should be flagged.
|
|
84
|
+
*
|
|
85
|
+
* Date-object mode columns:
|
|
86
|
+
* Postgres: PgTimestamp (default mode) returns Date objects
|
|
87
|
+
* SQLite: SQLiteTimestamp (mode: "timestamp") returns Date objects
|
|
88
|
+
*/
|
|
89
|
+
export function isDateObjectMode(col: {
|
|
90
|
+
columnType: string;
|
|
91
|
+
}): boolean {
|
|
92
|
+
// PgTimestamp is the default-mode Postgres timestamp — returns Date objects.
|
|
93
|
+
// PgTimestampString is the string-mode variant — returns ISO strings (safe).
|
|
94
|
+
if (col.columnType === "PgTimestamp") return true;
|
|
95
|
+
// SQLiteTimestamp with mode: "timestamp" returns Date objects.
|
|
96
|
+
// (SQLite stores as integer epoch, Drizzle converts to Date on read.)
|
|
97
|
+
if (col.columnType === "SQLiteTimestamp") return true;
|
|
98
|
+
return false;
|
|
99
|
+
}
|