@metaobjectsdev/migrate-ts 0.15.20 → 0.15.21-rc.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.
@@ -5,7 +5,7 @@
5
5
  * All three routines are pure mappings of SQLite's declared type / pragma
6
6
  * values to canonical migrate-ts types and carry no I/O dependencies.
7
7
  */
8
- import type { ColumnDefault, FkAction } from "../types.js";
8
+ import type { CheckDescriptor, ColumnDefault, FkAction, IndexDescriptor } from "../types.js";
9
9
  import type { SqlType } from "../sql-type.js";
10
10
 
11
11
  export const SQLITE_EXPR_DEFAULT_PATTERNS = [
@@ -17,11 +17,27 @@ export const SQLITE_EXPR_DEFAULT_PATTERNS = [
17
17
 
18
18
  export function parseSqliteDefault(raw: string | null): ColumnDefault | undefined {
19
19
  if (raw === null || raw === undefined || raw === "") return undefined;
20
+
21
+ // A QUOTE-WRAPPED value is a string literal BY CONSTRUCTION — SQLite always quotes a
22
+ // literal string default. This test MUST come before the expr patterns: one of those
23
+ // patterns is a bare /\(.*\)/, so a perfectly ordinary literal containing parentheses
24
+ // (`@default "n/a (unknown)"` → stored as `'n/a (unknown)'`) would otherwise be
25
+ // classified an EXPR with its quotes still attached. It could then never string-equal
26
+ // the expected literal, so the diff would report change-column-default on EVERY run —
27
+ // and on SQLite (no ALTER COLUMN) that recreate-and-copies the whole table, forever,
28
+ // un-gated, with `verify --db` permanently red. Exactly the failure this un-escaping
29
+ // was added to prevent.
30
+ //
31
+ // Then un-double the emitter's `''` escaping (`@default "don't"` → `'don''t'`), or the
32
+ // introspected `don''t` never equals the expected `don't` — same perpetual rebuild.
33
+ const quoted = /^'([\s\S]*)'$/.exec(raw);
34
+ if (quoted !== null) {
35
+ return { kind: "literal", value: quoted[1]!.replace(/''/g, "'") };
36
+ }
37
+
20
38
  const isExpr = SQLITE_EXPR_DEFAULT_PATTERNS.some((re) => re.test(raw));
21
39
  if (isExpr) return { kind: "expr", value: raw };
22
- // SQLite stores literal string defaults with surrounding quotes.
23
- const cleaned = raw.replace(/^'(.*)'$/, "$1");
24
- return { kind: "literal", value: cleaned };
40
+ return { kind: "literal", value: raw };
25
41
  }
26
42
 
27
43
  export function sqliteTypeToSqlType(declaredType: string): SqlType {
@@ -68,6 +84,183 @@ export function sqliteTypeToSqlType(declaredType: string): SqlType {
68
84
  return { kind: "text" };
69
85
  }
70
86
 
87
+ /**
88
+ * Parse NAMED CHECK constraints (`CONSTRAINT <name> CHECK (<expr>)`) out of a
89
+ * table's CREATE TABLE statement (sqlite_master.sql). SQLite exposes no pragma
90
+ * for CHECK constraints, so the stored DDL text is the only catalog.
91
+ *
92
+ * This is what makes CHECK evolution CONVERGE on sqlite: the diff proposes a
93
+ * check change → the emitter recreate-and-copies the table with the new inline
94
+ * CHECK → this reads the very DDL that recreate wrote, so the re-diff is empty.
95
+ * Without it the actual side always reported `checks: []` and every expected
96
+ * check re-surfaced as add-check on every single run.
97
+ *
98
+ * Unnamed checks (`CHECK (…)` with no CONSTRAINT clause — hand-written DDL) are
99
+ * NOT parsed: they have no identity to match on. A modeled check over such a
100
+ * table converges after one recreate (which rewrites it in named form).
101
+ *
102
+ * The expression is scanned with balanced parens and string-literal awareness
103
+ * (an enum member may contain `(`/`)`), and returned verbatim — the diff's
104
+ * checkExprEquals normalizes both sides before comparing.
105
+ */
106
+ export function parseSqliteChecks(createSql: string | null | undefined): CheckDescriptor[] {
107
+ if (createSql === null || createSql === undefined || createSql === "") return [];
108
+ const out: CheckDescriptor[] = [];
109
+ const re = /\bCONSTRAINT\s+(?:"((?:[^"]|"")+)"|([A-Za-z_][A-Za-z0-9_$]*))\s+CHECK\s*\(/gi;
110
+ let m: RegExpExecArray | null;
111
+ while ((m = re.exec(createSql)) !== null) {
112
+ const name = m[1] !== undefined ? m[1].replace(/""/g, '"') : m[2]!;
113
+ const open = re.lastIndex - 1; // position of the "(" the regex just consumed
114
+ let depth = 0;
115
+ let inString = false;
116
+ let close = -1;
117
+ for (let i = open; i < createSql.length; i++) {
118
+ const ch = createSql[i];
119
+ if (inString) {
120
+ if (ch === "'") {
121
+ if (createSql[i + 1] === "'") i++; // '' escape inside the literal
122
+ else inString = false;
123
+ }
124
+ continue;
125
+ }
126
+ if (ch === "'") inString = true;
127
+ else if (ch === "(") depth++;
128
+ else if (ch === ")") {
129
+ depth--;
130
+ if (depth === 0) { close = i; break; }
131
+ }
132
+ }
133
+ if (close === -1) break; // malformed tail — stop rather than mis-slice
134
+ out.push({ name, expression: createSql.slice(open + 1, close).trim() });
135
+ re.lastIndex = close + 1;
136
+ }
137
+ return out;
138
+ }
139
+
140
+ /** Skip a single-quoted SQL string starting at `i` (position of the opening
141
+ * quote); returns the position just past the closing quote ('' escapes honored). */
142
+ function skipSingleQuoted(s: string, i: number): number {
143
+ i++;
144
+ while (i < s.length) {
145
+ if (s[i] === "'") {
146
+ if (s[i + 1] === "'") { i += 2; continue; }
147
+ return i + 1;
148
+ }
149
+ i++;
150
+ }
151
+ return i;
152
+ }
153
+
154
+ /** Skip a double-quoted SQL identifier starting at `i`; "" escapes honored. */
155
+ function skipDoubleQuoted(s: string, i: number): number {
156
+ i++;
157
+ while (i < s.length) {
158
+ if (s[i] === '"') {
159
+ if (s[i + 1] === '"') { i += 2; continue; }
160
+ return i + 1;
161
+ }
162
+ i++;
163
+ }
164
+ return i;
165
+ }
166
+
167
+ export interface ParsedSqliteIndexDef {
168
+ /** Raw key-list text between the balanced parens after `ON <table>`. */
169
+ keyList: string;
170
+ /** Raw partial-index predicate after WHERE; undefined for a full index. */
171
+ where?: string;
172
+ }
173
+
174
+ /**
175
+ * Parse a stored `CREATE [UNIQUE] INDEX … ON <table> (<keys>) [WHERE <pred>]`
176
+ * statement (sqlite_master.sql). SQLite has no pragma exposing an index's key
177
+ * EXPRESSIONS or its partial-index predicate — the stored DDL is the only
178
+ * catalog — so this powers reading `@expr` / `@where` indexes back for the diff
179
+ * to converge. Quote-aware: parens inside string literals or quoted identifiers
180
+ * never confuse the balanced scan.
181
+ */
182
+ export function parseSqliteIndexDef(createSql: string | null | undefined): ParsedSqliteIndexDef | undefined {
183
+ if (createSql === null || createSql === undefined || createSql === "") return undefined;
184
+ const n = createSql.length;
185
+ // First "(" outside any quoted region = start of the key list.
186
+ let open = -1;
187
+ for (let i = 0; i < n; ) {
188
+ const ch = createSql[i];
189
+ if (ch === "'") { i = skipSingleQuoted(createSql, i); continue; }
190
+ if (ch === '"') { i = skipDoubleQuoted(createSql, i); continue; }
191
+ if (ch === "(") { open = i; break; }
192
+ i++;
193
+ }
194
+ if (open === -1) return undefined;
195
+ // Balanced scan (quote-aware) to the matching ")".
196
+ let close = -1;
197
+ for (let i = open, depth = 0; i < n; ) {
198
+ const ch = createSql[i];
199
+ if (ch === "'") { i = skipSingleQuoted(createSql, i); continue; }
200
+ if (ch === '"') { i = skipDoubleQuoted(createSql, i); continue; }
201
+ if (ch === "(") depth++;
202
+ else if (ch === ")") {
203
+ depth--;
204
+ if (depth === 0) { close = i; break; }
205
+ }
206
+ i++;
207
+ }
208
+ if (close === -1) return undefined;
209
+ const out: ParsedSqliteIndexDef = { keyList: createSql.slice(open + 1, close).trim() };
210
+ const m = /^\s*WHERE\s+([\s\S]+)$/i.exec(createSql.slice(close + 1));
211
+ if (m) out.where = m[1]!.trim().replace(/;\s*$/, "");
212
+ return out;
213
+ }
214
+
215
+ /** pragma_index_list row, dialect-neutrally coerced by the caller. */
216
+ export interface SqliteIndexListEntry {
217
+ name: string;
218
+ unique: boolean;
219
+ partial: boolean;
220
+ }
221
+
222
+ /** A KEY column row from pragma_index_xinfo (key=1 rows only, seqno order). */
223
+ export interface SqliteIndexKeyColumn {
224
+ /** Column name; null for an expression key (cid = -2). */
225
+ name: string | null;
226
+ /** true when the key is sorted DESC. */
227
+ desc: boolean;
228
+ }
229
+
230
+ /**
231
+ * Assemble an IndexDescriptor from the SQLite catalog pieces: pragma_index_list
232
+ * (unique/partial), pragma_index_xinfo key columns (names + DESC bits; name null
233
+ * for an expression key), and the stored CREATE INDEX DDL (expression key list +
234
+ * partial predicate — neither is exposed by any pragma).
235
+ *
236
+ * Mirrors the expected side's shape rules: an expression index carries the whole
237
+ * key list in `expr` with `columns: []`; `orders` is attached only when some key
238
+ * is DESC (all-ascending serializes as absent, like buildExpectedSchema).
239
+ */
240
+ export function buildSqliteIndexDescriptor(
241
+ entry: SqliteIndexListEntry,
242
+ keyColumns: readonly SqliteIndexKeyColumn[],
243
+ createSql: string | null | undefined,
244
+ ): IndexDescriptor {
245
+ const parsed = parseSqliteIndexDef(createSql);
246
+ const descriptor: IndexDescriptor = {
247
+ name: entry.name,
248
+ columns: [],
249
+ unique: entry.unique,
250
+ };
251
+ if (keyColumns.some((c) => c.name === null)) {
252
+ // Expression key somewhere in the list → the whole key list is the expr
253
+ // (same convention as the Postgres introspector + buildExpectedSchema).
254
+ if (parsed !== undefined) descriptor.expr = parsed.keyList;
255
+ } else {
256
+ descriptor.columns = keyColumns.map((c) => c.name!);
257
+ const orders = keyColumns.map((c): "asc" | "desc" => (c.desc ? "desc" : "asc"));
258
+ if (orders.some((o) => o === "desc")) descriptor.orders = orders;
259
+ }
260
+ if (entry.partial && parsed?.where !== undefined) descriptor.where = parsed.where;
261
+ return descriptor;
262
+ }
263
+
71
264
  export function sqliteRuleToAction(rule: string): FkAction {
72
265
  const r = rule.toUpperCase();
73
266
  if (r === "CASCADE") return "cascade";
@@ -4,7 +4,10 @@ import type {
4
4
  SchemaSnapshot, TableDescriptor, ColumnDescriptor, SnapshotMeta,
5
5
  IndexDescriptor, FkDescriptor, FkAction, ViewDescriptor,
6
6
  } from "../types.js";
7
- import { parseSqliteDefault, sqliteTypeToSqlType, sqliteRuleToAction } from "./sqlite-shared.js";
7
+ import {
8
+ parseSqliteDefault, sqliteTypeToSqlType, sqliteRuleToAction, parseSqliteChecks,
9
+ buildSqliteIndexDescriptor,
10
+ } from "./sqlite-shared.js";
8
11
  import { MIGRATIONS_TABLE } from "../apply/ledger.js";
9
12
 
10
13
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -16,9 +19,14 @@ export async function introspectSqlite(db: Kysely<Record<string, unknown>>): Pro
16
19
  const versionRow = await sql<{ v: string }>`SELECT sqlite_version() AS v`.execute(k);
17
20
  const meta: SnapshotMeta = { sqliteVersion: versionRow.rows[0]?.v ?? "0.0.0" };
18
21
 
22
+ // NOTE: "_" is a single-character WILDCARD in SQL LIKE. Unescaped, '__new_%' also
23
+ // matches an ordinary table named "renewals" (verified against real SQLite), silently
24
+ // hiding it from introspection — so the diff re-proposes CREATE TABLE on every run and
25
+ // the next apply dies with "already exists". Escape the underscores.
19
26
  const tableNamesRows = await sql<{ name: string; sql: string | null }>`
20
27
  SELECT name, sql FROM sqlite_master
21
- WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__new_%'
28
+ WHERE type='table' AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'
29
+ AND name NOT LIKE '\\_\\_new\\_%' ESCAPE '\\'
22
30
  AND name <> ${MIGRATIONS_TABLE}
23
31
  ORDER BY name
24
32
  `.execute(k);
@@ -38,7 +46,9 @@ export async function introspectSqlite(db: Kysely<Record<string, unknown>>): Pro
38
46
  columns: cols,
39
47
  indexes: await readSqliteIndexes(k, t.name),
40
48
  foreignKeys: await readSqliteForeignKeys(k, t.name),
41
- checks: [], // CHECK introspection is out of scope; expected-side derives them
49
+ // Named CHECKs parsed from the stored CREATE TABLE DDL required for
50
+ // check evolution (enum @values changes) to converge on sqlite.
51
+ checks: parseSqliteChecks(t.sql),
42
52
  primaryKey: pk,
43
53
  });
44
54
  }
@@ -52,7 +62,7 @@ async function readSqliteViews(k: RawKysely): Promise<ViewDescriptor[]> {
52
62
  // We carry it through on the descriptor so the diff can detect view-body
53
63
  // drift (not just name presence).
54
64
  const rows = await sql<{ name: string; sql: string | null }>`
55
- SELECT name, sql FROM sqlite_master WHERE type='view' AND name NOT LIKE 'sqlite_%'
65
+ SELECT name, sql FROM sqlite_master WHERE type='view' AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'
56
66
  ORDER BY name
57
67
  `.execute(k);
58
68
  return rows.rows.map((r) => {
@@ -99,18 +109,33 @@ async function readSqliteIndexes(k: RawKysely, table: string): Promise<IndexDesc
99
109
  SELECT * FROM pragma_index_list(${table})
100
110
  `.execute(k);
101
111
 
112
+ // Stored CREATE INDEX DDL per index — the ONLY catalog for an index's key
113
+ // EXPRESSIONS and partial-index WHERE predicate (no pragma exposes either).
114
+ // Auto-created indexes (column UNIQUE constraints, origin 'u') have sql NULL,
115
+ // which is fine: they can't be partial or expression-keyed.
116
+ const sqlRows = await sql<{ name: string; sql: string | null }>`
117
+ SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name = ${table}
118
+ `.execute(k);
119
+ const ddlByName = new Map(sqlRows.rows.map((r) => [r.name, r.sql] as const));
120
+
102
121
  const indexes: IndexDescriptor[] = [];
103
122
  for (const ix of listRows.rows) {
104
123
  if (ix.origin === "pk") continue; // PK index — excluded (lives in TableDescriptor.primaryKey)
105
- if (ix.partial === 1) continue; // partial indexes deferred to v0.3
106
- const cols = await sql<{ seqno: number; cid: number; name: string }>`
107
- SELECT seqno, cid, name FROM pragma_index_info(${ix.name}) ORDER BY seqno
124
+ // pragma_index_xinfo (not index_info): includes the DESC bit and marks key
125
+ // columns (key=1) vs auxiliary rowid columns; an expression key has name
126
+ // NULL. SELECT * avoids "desc"/"key" reserved-keyword issues in libsql.
127
+ const xinfo = await sql<{
128
+ seqno: number; cid: number; name: string | null; desc: number; coll: string; key: number;
129
+ }>`
130
+ SELECT * FROM pragma_index_xinfo(${ix.name}) ORDER BY seqno
108
131
  `.execute(k);
109
- indexes.push({
110
- name: ix.name,
111
- columns: cols.rows.map((c) => c.name),
112
- unique: ix.unique === 1,
113
- });
132
+ indexes.push(buildSqliteIndexDescriptor(
133
+ { name: ix.name, unique: ix.unique === 1, partial: ix.partial === 1 },
134
+ xinfo.rows
135
+ .filter((c) => c.key === 1)
136
+ .map((c) => ({ name: c.name, desc: c.desc === 1 })),
137
+ ddlByName.get(ix.name) ?? null,
138
+ ));
114
139
  }
115
140
  return indexes;
116
141
  }
package/src/types.ts CHANGED
@@ -197,6 +197,13 @@ export interface AllowOptions {
197
197
  dropIndex?: boolean;
198
198
  dropFk?: boolean;
199
199
  dropCheck?: boolean;
200
+ /**
201
+ * Gates a REAL view removal (present in the DB, absent from the model) — like
202
+ * every other drop. The internal drop/create recreate pair the diff emits
203
+ * around a column-altering change to a view's source table is NOT gated (the
204
+ * view is re-created in the same migration).
205
+ */
206
+ dropView?: boolean;
200
207
  /** Existing data must satisfy NOT NULL; diff cannot verify this. */
201
208
  nullableToNotNull?: boolean;
202
209
  }