@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,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
+ }