@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,42 @@
1
+ // ============================================================================
2
+ // SQLite Report SQL Client — synchronous query adapter for the report engine
3
+ // ============================================================================
4
+ //
5
+ // The existing ReportSqlClient interface (engine.ts) is async:
6
+ // { unsafe: (sql, params?) => Promise<any[]> }
7
+ //
8
+ // better-sqlite3 is synchronous. This module wraps a better-sqlite3 Database
9
+ // to match the ReportSqlClient interface by wrapping synchronous results in
10
+ // Promise.resolve(). This allows the report engine to work unchanged until
11
+ // PLAN-2 migrates it to a synchronous execution model.
12
+ //
13
+ // In PLAN-2, the engine will likely switch to a sync interface. At that
14
+ // point this wrapper becomes unnecessary. For now, it bridges the gap.
15
+
16
+ import type Database from "better-sqlite3";
17
+ import type { ReportSqlClient } from "./engine.js";
18
+
19
+ /**
20
+ * Create a ReportSqlClient adapter from a better-sqlite3 Database.
21
+ *
22
+ * Maps the async ReportSqlClient.unsafe() interface to better-sqlite3's
23
+ * synchronous prepare().all() — the result is wrapped in Promise.resolve()
24
+ * for interface compatibility.
25
+ *
26
+ * The params array is spread into .all() which uses SQLite's ? positional
27
+ * parameter binding (not Postgres's $1 positional binding). The caller
28
+ * must use buildSQLitePositionalQuery() from sqlite-bind.ts to convert
29
+ * $name variables to ? placeholders before calling this client.
30
+ */
31
+ export function createReportSqlClient(
32
+ sqlite: Database.Database,
33
+ ): ReportSqlClient {
34
+ return {
35
+ unsafe: (sql: string, params?: any[]): Promise<any[]> => {
36
+ const result = sqlite
37
+ .prepare(sql)
38
+ .all(...(params ?? [])) as Record<string, unknown>[];
39
+ return Promise.resolve(result);
40
+ },
41
+ };
42
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,3 @@
1
+ // Backward-compatibility re-exports. The canonical home is boot.ts.
2
+ export type { ProjectConfig, InProcessProject } from "./boot.js";
3
+ export { bootProject } from "./boot.js";
@@ -0,0 +1,90 @@
1
+ import { getTableConfig } from "drizzle-orm/sqlite-core";
2
+ import { isDateObjectMode } from "./normalize-datatype.js";
3
+ import type { TableDef } from "./table.js";
4
+
5
+ export type SchemaIssue = {
6
+ table: string;
7
+ column: string;
8
+ message: string;
9
+ };
10
+
11
+ /**
12
+ * Check schema definitions for nullable numeric columns that should be NOT NULL.
13
+ *
14
+ * Three categories of nullable numeric columns:
15
+ *
16
+ * 1. **FK columns** (e.g. `parent_id`) — nullable because the relationship is
17
+ * optional. Never aggregated. Auto-detected from Drizzle foreign key metadata.
18
+ *
19
+ * 2. **Additive measures** (e.g. `debit`, `credit`) — values where NULL breaks
20
+ * SUM/AVG. Must be NOT NULL with .default("0").
21
+ *
22
+ * 3. **Non-additive optionals** (e.g. `account_balance_assertion`) — NULL means
23
+ * "no value" and 0 means "value is zero". Legitimately nullable. Marked with
24
+ * `additive: false` in column meta.
25
+ *
26
+ * This checker flags category 2 — nullable numerics that are not FK columns and
27
+ * not explicitly marked as non-additive.
28
+ */
29
+ export function checkSchemaDefinitions(tables: TableDef[]): SchemaIssue[] {
30
+ const issues: SchemaIssue[] = [];
31
+
32
+ for (const table of tables) {
33
+ const config = getTableConfig(table.drizzle);
34
+
35
+ // Build FK column set from Drizzle foreign key metadata
36
+ const fkColumns = new Set<string>();
37
+ for (const fk of config.foreignKeys) {
38
+ const ref = fk.reference();
39
+ const sourceCol = ref.columns[0];
40
+ if (sourceCol) {
41
+ fkColumns.add(sourceCol.name);
42
+ }
43
+ }
44
+
45
+ for (const col of config.columns) {
46
+ // Skip primary keys — never aggregated
47
+ if (col.primary) continue;
48
+
49
+ // Skip foreign key columns — nullable because the relationship is optional
50
+ if (fkColumns.has(col.name)) continue;
51
+
52
+ // Flag Date-object-mode timestamps (Pg default mode or SQLite mode: "timestamp").
53
+ // These break in a JSON-over-HTTP framework because string values hit .toISOString().
54
+ // See timestamp() in table.ts for the string-mode convention.
55
+ if (isDateObjectMode(col)) {
56
+ issues.push({
57
+ table: config.name,
58
+ column: col.name,
59
+ message:
60
+ `Timestamp column using Date mode. ` +
61
+ `Sapporta is JSON-over-HTTP — dates arrive as strings, causing "toISOString is not a function" errors. ` +
62
+ `Use import { timestamp } from "@sapporta/server/table" which returns a text column storing ISO 8601 strings.`,
63
+ });
64
+ }
65
+
66
+ // SQLite integer and real both have dataType "number".
67
+ // Currency/percentage columns stored as TEXT won't match "number",
68
+ // which is correct — they shouldn't warn about nullable numerics
69
+ // since they're text.
70
+ const isNumeric = col.dataType === "number";
71
+
72
+ if (!isNumeric || col.notNull) continue;
73
+
74
+ // Skip columns explicitly marked as non-additive
75
+ if (table.meta.columns?.[col.name]?.additive === false) continue;
76
+
77
+ issues.push({
78
+ table: config.name,
79
+ column: col.name,
80
+ message:
81
+ `Nullable numeric column. ` +
82
+ `A single NULL causes SUM/AVG to silently produce NULL instead of a number. ` +
83
+ `Add .notNull() (with .default("0") if the column is optional). ` +
84
+ `If NULL is semantically distinct from 0, mark it with { additive: false } in column meta.`,
85
+ });
86
+ }
87
+ }
88
+
89
+ return issues;
90
+ }
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import Database from "better-sqlite3";
3
+ import {
4
+ generateCreateTableDDL,
5
+ generateAddColumnDDL,
6
+ generateCheckConstraintDDL,
7
+ } from "./ddl.js";
8
+ import type { ColumnMetaRow } from "./dynamic-builder.js";
9
+
10
+ describe("generateCreateTableDDL", () => {
11
+ let sqlite: Database.Database;
12
+
13
+ beforeEach(() => {
14
+ sqlite = new Database(":memory:");
15
+ });
16
+
17
+ afterEach(() => {
18
+ sqlite.close();
19
+ });
20
+
21
+ it("produces valid SQLite DDL", () => {
22
+ const ddl = generateCreateTableDDL("contacts");
23
+ // Should not throw when executed
24
+ sqlite.exec(ddl);
25
+
26
+ const cols = sqlite.pragma('table_info("contacts")') as {
27
+ name: string;
28
+ type: string;
29
+ }[];
30
+ const colNames = cols.map((c) => c.name);
31
+ expect(colNames).toContain("id");
32
+ expect(colNames).toContain("created_at");
33
+ expect(colNames).toContain("updated_at");
34
+ });
35
+
36
+ it("uses AUTOINCREMENT for id", () => {
37
+ const ddl = generateCreateTableDDL("test");
38
+ expect(ddl).toContain("AUTOINCREMENT");
39
+ });
40
+
41
+ it("uses datetime('now') for timestamps", () => {
42
+ const ddl = generateCreateTableDDL("test");
43
+ expect(ddl).toContain("datetime('now')");
44
+ expect(ddl).not.toContain("now()"); // Postgres-style
45
+ });
46
+ });
47
+
48
+ // generateAddColumnDDL returns string[] — the first element is the ALTER TABLE
49
+ // statement, and an optional second element is a CREATE UNIQUE INDEX statement.
50
+ // Helper to execute all returned statements against a SQLite handle.
51
+ function execAll(sqlite: Database.Database, stmts: string[]) {
52
+ for (const s of stmts) sqlite.exec(s);
53
+ }
54
+
55
+ describe("generateAddColumnDDL", () => {
56
+ let sqlite: Database.Database;
57
+
58
+ beforeEach(() => {
59
+ sqlite = new Database(":memory:");
60
+ sqlite.exec(generateCreateTableDDL("test"));
61
+ });
62
+
63
+ afterEach(() => {
64
+ sqlite.close();
65
+ });
66
+
67
+ function makeCol(overrides: Partial<ColumnMetaRow>): ColumnMetaRow {
68
+ return {
69
+ column_name: "col",
70
+ column_type: "text",
71
+ position: 0,
72
+ not_null: false,
73
+ is_unique: false,
74
+ ...overrides,
75
+ };
76
+ }
77
+
78
+ it("returns a single-element array for non-unique columns", () => {
79
+ const stmts = generateAddColumnDDL("test", makeCol({ column_name: "name", column_type: "text" }));
80
+ expect(stmts).toHaveLength(1);
81
+ execAll(sqlite, stmts);
82
+ const cols = sqlite.pragma('table_info("test")') as { name: string }[];
83
+ expect(cols.map((c) => c.name)).toContain("name");
84
+ });
85
+
86
+ it("generates valid ALTER TABLE for integer column", () => {
87
+ const stmts = generateAddColumnDDL("test", makeCol({ column_name: "count", column_type: "integer" }));
88
+ execAll(sqlite, stmts);
89
+ const cols = sqlite.pragma('table_info("test")') as { name: string; type: string }[];
90
+ const col = cols.find((c) => c.name === "count")!;
91
+ expect(col.type).toBe("INTEGER");
92
+ });
93
+
94
+ it("adds NOT NULL constraint", () => {
95
+ const stmts = generateAddColumnDDL(
96
+ "test",
97
+ makeCol({ column_name: "required", column_type: "text", not_null: true, default_value: "" }),
98
+ );
99
+ expect(stmts[0]).toContain("NOT NULL");
100
+ });
101
+
102
+ it("emits UNIQUE as a separate CREATE UNIQUE INDEX (works on non-empty tables)", () => {
103
+ // SQLite rejects inline UNIQUE in ALTER TABLE ADD COLUMN for non-empty
104
+ // tables. Sapporta uses a separate CREATE UNIQUE INDEX instead, which
105
+ // works regardless of existing rows.
106
+
107
+ // Insert a row so the table is non-empty — this would fail with inline UNIQUE
108
+ sqlite.exec(`INSERT INTO test (created_at, updated_at) VALUES ('2024-01-01', '2024-01-01')`);
109
+
110
+ const stmts = generateAddColumnDDL(
111
+ "test",
112
+ makeCol({ column_name: "email", column_type: "email", is_unique: true }),
113
+ );
114
+
115
+ // Should produce two statements: ALTER TABLE + CREATE UNIQUE INDEX
116
+ expect(stmts).toHaveLength(2);
117
+ expect(stmts[0]).not.toContain("UNIQUE");
118
+ expect(stmts[1]).toContain("CREATE UNIQUE INDEX");
119
+ expect(stmts[1]).toContain('"email"');
120
+
121
+ // Both statements should execute without error on a non-empty table
122
+ execAll(sqlite, stmts);
123
+
124
+ // Verify the unique index was actually created
125
+ const indexes = sqlite
126
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'test'`)
127
+ .all() as { name: string }[];
128
+ expect(indexes.some((i) => i.name.includes("email"))).toBe(true);
129
+ });
130
+
131
+ it("adds FK REFERENCES for link columns", () => {
132
+ sqlite.exec(`CREATE TABLE accounts (id INTEGER PRIMARY KEY AUTOINCREMENT)`);
133
+ const stmts = generateAddColumnDDL(
134
+ "test",
135
+ makeCol({ column_name: "account_id", column_type: "link", references_table: "accounts" }),
136
+ );
137
+ expect(stmts[0]).toContain('REFERENCES "accounts"("id")');
138
+ execAll(sqlite, stmts);
139
+ });
140
+
141
+ it("generates link column without FK when references_table is missing", () => {
142
+ const stmts = generateAddColumnDDL(
143
+ "test",
144
+ makeCol({ column_name: "category_id", column_type: "link" }),
145
+ );
146
+ expect(stmts[0]).toContain("INTEGER");
147
+ expect(stmts[0]).not.toContain("REFERENCES");
148
+ execAll(sqlite, stmts);
149
+ });
150
+
151
+ it("converts boolean defaults: true → 1, false → 0", () => {
152
+ const stmtsTrue = generateAddColumnDDL(
153
+ "test",
154
+ makeCol({ column_name: "active", column_type: "boolean", default_value: "true" }),
155
+ );
156
+ expect(stmtsTrue[0]).toContain("DEFAULT 1");
157
+
158
+ const stmtsFalse = generateAddColumnDDL(
159
+ "test",
160
+ makeCol({ column_name: "deleted", column_type: "boolean", default_value: "false" }),
161
+ );
162
+ expect(stmtsFalse[0]).toContain("DEFAULT 0");
163
+ });
164
+
165
+ it("quotes string defaults", () => {
166
+ const stmts = generateAddColumnDDL(
167
+ "test",
168
+ makeCol({ column_name: "status", column_type: "text", default_value: "active" }),
169
+ );
170
+ expect(stmts[0]).toContain("DEFAULT 'active'");
171
+ });
172
+
173
+ it("escapes single quotes in string defaults", () => {
174
+ const stmts = generateAddColumnDDL(
175
+ "test",
176
+ makeCol({ column_name: "note", column_type: "text", default_value: "it's fine" }),
177
+ );
178
+ expect(stmts[0]).toContain("DEFAULT 'it''s fine'");
179
+ });
180
+
181
+ it("maps currency to TEXT type", () => {
182
+ const stmts = generateAddColumnDDL(
183
+ "test",
184
+ makeCol({ column_name: "amount", column_type: "currency" }),
185
+ );
186
+ expect(stmts[0]).toContain("TEXT");
187
+ expect(stmts[0]).not.toContain("REAL");
188
+ });
189
+ });
190
+
191
+ describe("generateCheckConstraintDDL", () => {
192
+ it("generates valid CHECK clause", () => {
193
+ const check = generateCheckConstraintDDL("status", [
194
+ "active",
195
+ "inactive",
196
+ "archived",
197
+ ]);
198
+ expect(check).toBe(
199
+ `CHECK("status" IN ('active', 'inactive', 'archived'))`,
200
+ );
201
+ });
202
+
203
+ it("escapes single quotes in option values", () => {
204
+ const check = generateCheckConstraintDDL("type", [
205
+ "it's special",
206
+ "normal",
207
+ ]);
208
+ expect(check).toBe(`CHECK("type" IN ('it''s special', 'normal'))`);
209
+ });
210
+ });
@@ -0,0 +1,180 @@
1
+ // ============================================================================
2
+ // SQLite DDL Generation — CREATE TABLE and ALTER TABLE for UI-managed tables
3
+ // ============================================================================
4
+ //
5
+ // These functions generate DDL for runtime table creation and column addition.
6
+ // Used by the mutation API when users create tables/columns via the browser UI.
7
+ //
8
+ // Critical type mapping decisions for SQLite:
9
+ //
10
+ // currency/percentage → TEXT (not REAL)
11
+ // REAL is IEEE 754 double-precision — loses precision for decimal values.
12
+ // TEXT stores exact decimal strings like "1234.56". The application layer
13
+ // handles formatting. This matches Postgres's NUMERIC type semantically.
14
+ //
15
+ // date/timestamp → TEXT
16
+ // SQLite has no native date type. TEXT stores ISO 8601 strings.
17
+ // Matches Sapporta's convention of mode: "string" timestamps that
18
+ // flow as plain strings across JSON boundaries.
19
+ //
20
+ // boolean → INTEGER (0/1)
21
+ // SQLite convention. Matches Drizzle's integer({ mode: "boolean" }).
22
+ //
23
+ // select → TEXT
24
+ // Validated at the application layer via select_options metadata,
25
+ // not via database CHECK constraints (because ALTER TABLE ADD COLUMN
26
+ // doesn't support inline CHECK in SQLite).
27
+
28
+ import type { ColumnMetaRow } from "./dynamic-builder.js";
29
+
30
+ /**
31
+ * SQLite type mapping for UI-managed column types.
32
+ *
33
+ * Each logical column_type maps to a specific SQLite storage type.
34
+ * Types like "currency" and "email" are identical to TEXT at the DB level —
35
+ * the distinction only matters for UI formatting and validation.
36
+ */
37
+ export const SQLITE_TYPE_MAP: Record<string, string> = {
38
+ text: "TEXT",
39
+ integer: "INTEGER",
40
+ numeric: "REAL",
41
+ boolean: "INTEGER",
42
+ date: "TEXT",
43
+ timestamp: "TEXT",
44
+ select: "TEXT",
45
+ link: "INTEGER",
46
+ currency: "TEXT", // NOT REAL — exact decimal representation
47
+ percentage: "TEXT", // NOT REAL — exact decimal representation
48
+ email: "TEXT",
49
+ url: "TEXT",
50
+ };
51
+
52
+ /**
53
+ * Map a logical column_type to its SQLite DDL type string.
54
+ * Returns undefined for unknown types (caller should reject).
55
+ */
56
+ export function sqliteTypeForColumnType(
57
+ columnType: string,
58
+ ): string | undefined {
59
+ return SQLITE_TYPE_MAP[columnType];
60
+ }
61
+
62
+ /**
63
+ * Generate CREATE TABLE DDL for a new UI-managed table.
64
+ *
65
+ * Creates the standard system columns (id, created_at, updated_at).
66
+ * User-defined columns are added later via ALTER TABLE ADD COLUMN.
67
+ *
68
+ * Uses INTEGER PRIMARY KEY AUTOINCREMENT for the id column. In SQLite,
69
+ * this guarantees monotonically increasing IDs (never reuses deleted IDs),
70
+ * matching Postgres SERIAL behavior.
71
+ */
72
+ export function generateCreateTableDDL(tableName: string): string {
73
+ return `CREATE TABLE "${tableName}" (
74
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT,
75
+ "created_at" TEXT NOT NULL DEFAULT (datetime('now')),
76
+ "updated_at" TEXT NOT NULL DEFAULT (datetime('now'))
77
+ )`;
78
+ }
79
+
80
+ /**
81
+ * Generate ALTER TABLE ADD COLUMN DDL for a UI-managed table.
82
+ *
83
+ * Returns an array of DDL statements to execute in order:
84
+ * [0] ALTER TABLE ADD COLUMN — always present
85
+ * [1] CREATE UNIQUE INDEX — only if is_unique is true
86
+ *
87
+ * UNIQUE is handled via a separate CREATE UNIQUE INDEX because SQLite's
88
+ * ALTER TABLE ADD COLUMN rejects inline UNIQUE for non-empty tables.
89
+ * A CREATE UNIQUE INDEX works regardless of existing rows (as long as
90
+ * the data satisfies the constraint).
91
+ *
92
+ * Handles type mapping, NOT NULL, DEFAULT, and REFERENCES constraints.
93
+ * Default values are sanitized via formatLiteralDefault() to prevent injection.
94
+ *
95
+ * Note: SQLite's ALTER TABLE ADD COLUMN does NOT support:
96
+ * - CHECK constraints inline (enforced at application layer instead)
97
+ * - PRIMARY KEY (only one PK allowed, set at CREATE TABLE time)
98
+ * - Columns with NOT NULL and no DEFAULT when the table has existing rows
99
+ * (SQLite requires a default value for NOT NULL columns added to non-empty tables)
100
+ */
101
+ export function generateAddColumnDDL(
102
+ tableName: string,
103
+ col: ColumnMetaRow,
104
+ ): string[] {
105
+ const sqlType = SQLITE_TYPE_MAP[col.column_type] ?? "TEXT";
106
+ let alterDdl = `ALTER TABLE "${tableName}" ADD COLUMN "${col.column_name}" ${sqlType}`;
107
+
108
+ if (col.not_null) {
109
+ alterDdl += " NOT NULL";
110
+ }
111
+ if (col.default_value !== undefined && col.default_value !== null) {
112
+ alterDdl += ` DEFAULT ${formatLiteralDefault(col.default_value, col.column_type)}`;
113
+ }
114
+ if (col.column_type === "link" && col.references_table) {
115
+ alterDdl += ` REFERENCES "${col.references_table}"("id")`;
116
+ }
117
+
118
+ const statements = [alterDdl];
119
+
120
+ // UNIQUE via separate index — SQLite rejects inline UNIQUE in ALTER TABLE
121
+ // ADD COLUMN when the table has existing rows.
122
+ if (col.is_unique) {
123
+ statements.push(
124
+ `CREATE UNIQUE INDEX "idx_${tableName}_${col.column_name}_unique" ON "${tableName}"("${col.column_name}")`,
125
+ );
126
+ }
127
+
128
+ return statements;
129
+ }
130
+
131
+ /**
132
+ * Generate a CHECK constraint clause for select/enum columns.
133
+ *
134
+ * This is only usable in CREATE TABLE statements, not ALTER TABLE ADD COLUMN.
135
+ * For columns added via ALTER TABLE, select validation is enforced at the
136
+ * application layer via select_options metadata.
137
+ *
138
+ * Returns just the CHECK clause (e.g., CHECK("status" IN ('active', 'inactive'))).
139
+ * The caller is responsible for including it in the appropriate DDL context.
140
+ */
141
+ export function generateCheckConstraintDDL(
142
+ columnName: string,
143
+ options: string[],
144
+ ): string {
145
+ const values = options.map((o) => `'${o.replace(/'/g, "''")}'`).join(", ");
146
+ return `CHECK("${columnName}" IN (${values}))`;
147
+ }
148
+
149
+ /**
150
+ * Format a literal default value for SQLite DDL.
151
+ *
152
+ * SECURITY: Only accepts literal values (strings, numbers, booleans).
153
+ * Never interpolates raw SQL expressions. Prevents SQL injection via
154
+ * crafted default_value payloads.
155
+ *
156
+ * SQLite-specific: boolean true/false → 1/0
157
+ */
158
+ function formatLiteralDefault(
159
+ value: string,
160
+ columnType: string,
161
+ ): string {
162
+ // Boolean types: true → 1, false → 0
163
+ if (columnType === "boolean") {
164
+ return value.toLowerCase() === "true" ? "1" : "0";
165
+ }
166
+
167
+ // Numeric types: must parse as a finite number
168
+ if (
169
+ ["integer", "numeric", "currency", "percentage"].includes(columnType)
170
+ ) {
171
+ const num = Number(value);
172
+ if (!Number.isFinite(num)) {
173
+ throw new Error(`Invalid numeric default: ${value}`);
174
+ }
175
+ return String(num);
176
+ }
177
+
178
+ // Text-like types: quote as a string literal with single-quote escaping
179
+ return `'${value.replace(/'/g, "''")}'`;
180
+ }