@metaobjectsdev/migrate-ts 0.15.20 → 0.15.21

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.
@@ -27,6 +27,10 @@ const STAGE_ORDER: Record<Change["kind"], number> = {
27
27
  const RECREATE_TRIGGERING_KINDS = new Set<Change["kind"]>([
28
28
  "change-column-type", "change-column-nullable", "change-column-default",
29
29
  "add-fk", "drop-fk",
30
+ // CHECK constraints are create-time-only inline on SQLite (no ALTER … ADD/DROP
31
+ // CONSTRAINT), so any check change — e.g. an evolved `field.enum @values`
32
+ // membership — rebuilds the table with the new inline CHECK.
33
+ "add-check", "drop-check",
30
34
  ]);
31
35
 
32
36
  export function renderSqlite(
@@ -199,16 +203,14 @@ function renderUpNative(c: Change): string {
199
203
  case "drop-index": return `DROP INDEX ${quote(c.index)};`;
200
204
  case "add-check":
201
205
  case "drop-check":
202
- // Declared for future existing-table support; the diff does not yet produce
203
- // these (checks are create-time-only, inlined in CREATE TABLE). Unreachable
204
- // today — throw rather than silently mis-emit if one ever arrives here.
205
- throw new Error("CHECK migration not implemented for sqlite (recreate path pending)");
206
206
  case "change-column-type":
207
207
  case "change-column-nullable":
208
208
  case "change-column-default":
209
209
  case "add-fk":
210
210
  case "drop-fk":
211
- // These are handled by renderRecreate before reaching renderUpNative.
211
+ // These are handled by renderRecreate before reaching renderUpNative
212
+ // (checks are create-time-only inline on SQLite, so a check change is a
213
+ // recreate-triggering kind like the others).
212
214
  throw new Error(`renderUpNative: ${c.kind} should have been handled by recreate bundler`);
213
215
  // SQLite has no schema namespacing for views and no CREATE OR REPLACE VIEW;
214
216
  // a replace is DROP + CREATE. The view body lives in ViewDescriptor.sql.
@@ -237,16 +239,14 @@ function renderDownNative(c: Change): string {
237
239
  case "drop-index": return `-- WARNING: down migration cannot restore the original index definition`;
238
240
  case "add-check":
239
241
  case "drop-check":
240
- // Declared for future existing-table support; the diff does not yet produce
241
- // these (checks are create-time-only, inlined in CREATE TABLE). Unreachable
242
- // today — throw rather than silently mis-emit if one ever arrives here.
243
- throw new Error("CHECK migration not implemented for sqlite (recreate path pending)");
244
242
  case "change-column-type":
245
243
  case "change-column-nullable":
246
244
  case "change-column-default":
247
245
  case "add-fk":
248
246
  case "drop-fk":
249
- // These are handled by renderRecreate before reaching renderDownNative.
247
+ // These are handled by renderRecreate before reaching renderDownNative
248
+ // (checks are create-time-only inline on SQLite, so a check change is a
249
+ // recreate-triggering kind like the others).
250
250
  throw new Error(`renderDownNative: ${c.kind} should have been handled by recreate bundler`);
251
251
  case "create-view": return `DROP VIEW IF EXISTS ${quote(c.view.name)};`;
252
252
  case "drop-view": return `-- WARNING: down migration cannot restore the original view definition`;
@@ -272,8 +272,9 @@ function renderCreateTable(t: TableDescriptor): string {
272
272
  colDefs.push(clause);
273
273
  }
274
274
  // CHECK constraints are inlined into the CREATE TABLE DDL (SQLite supports
275
- // inline named CHECK). Checks are create-time-only; the diff never produces
276
- // add-check / drop-check, so this is the sole place SQLite emits a CHECK.
275
+ // inline named CHECK) the sole place SQLite emits a CHECK. A check CHANGE
276
+ // on an existing table (add-check/drop-check from the diff) triggers
277
+ // recreate-and-copy, which lands back here with the updated check list.
277
278
  for (const chk of t.checks ?? []) {
278
279
  colDefs.push(` CONSTRAINT ${quote(chk.name)} CHECK (${chk.expression})`);
279
280
  }
@@ -295,7 +296,7 @@ function renderColumnInline(c: ColumnDescriptor, isSinglePk = false): string {
295
296
  if (c.identity === "increment" && isSinglePk) s += " AUTOINCREMENT";
296
297
  s += c.nullable ? "" : " NOT NULL";
297
298
  if (c.default !== undefined) {
298
- s += ` DEFAULT ${renderDefault(c.default)}`;
299
+ s += ` DEFAULT ${renderDefault(c.default, c.sqlType)}`;
299
300
  } else if (c.identity === "uuid") {
300
301
  // SQLite has no native uuid(); approximate via lower(hex(randomblob(16))).
301
302
  s += " DEFAULT (lower(hex(randomblob(16))))";
@@ -329,14 +330,60 @@ function sqliteType(t: SqlType, identity: ColumnDescriptor["identity"]): string
329
330
  }
330
331
  }
331
332
 
332
- function renderDefault(d: ColumnDefault): string {
333
+ /** A literal safely emittable unquoted on a numeric-affinity column. */
334
+ const NUMERIC_LITERAL = /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?$/;
335
+
336
+ /**
337
+ * Render a literal default according to the column's declared SQL type.
338
+ *
339
+ * Quoting must NOT be unconditional. SQLite applies the column's affinity when
340
+ * storing a default: a quoted literal that does not *look* numeric (e.g. `'false'`)
341
+ * cannot be coerced under NUMERIC/INTEGER affinity, so it is stored verbatim as
342
+ * TEXT — a mistyped value that a later `col = 0` comparison silently misses.
343
+ * (A numeric-looking `'0'` *is* coerced, which is exactly why this stayed hidden.)
344
+ *
345
+ * SQLite has no boolean literal, so the canonical "true"/"false" become 1/0.
346
+ * Non-numeric junk on a numeric column still falls back to quoting rather than
347
+ * emitting bare invalid SQL — the loader should reject it long before here.
348
+ */
349
+ function renderDefault(d: ColumnDefault, t: SqlType): string {
333
350
  if (d.kind === "expr") return d.value;
334
- return `'${d.value.replace(/'/g, "''")}'`;
351
+ const quoted = `'${d.value.replace(/'/g, "''")}'`;
352
+ switch (t.kind) {
353
+ case "boolean":
354
+ if (d.value === "true") return "1";
355
+ if (d.value === "false") return "0";
356
+ return NUMERIC_LITERAL.test(d.value) ? d.value : quoted;
357
+ case "integer":
358
+ case "real":
359
+ case "real4":
360
+ case "numeric":
361
+ return NUMERIC_LITERAL.test(d.value) ? d.value : quoted;
362
+ default:
363
+ return quoted;
364
+ }
335
365
  }
336
366
 
337
367
  function renderCreateIndex(table: string, ix: IndexDescriptor): string {
338
368
  const u = ix.unique ? "UNIQUE " : "";
339
- return `CREATE ${u}INDEX ${quote(ix.name)} ON ${quote(table)} (${ix.columns.map(quote).join(", ")});`;
369
+ // SQLite natively supports expression indexes, per-column DESC, and partial
370
+ // (WHERE) indexes — render all three. Dropping them is not an option:
371
+ // - a dropped @expr leaves `();` (invalid SQL — the apply fails outright);
372
+ // - a dropped @where turns a partial UNIQUE into a FULL unique constraint,
373
+ // silently rejecting inserts the model says are valid;
374
+ // - a dropped DESC churns drop/add on every diff once introspection reads
375
+ // the real ordering back.
376
+ // @using is deliberately NOT rendered: SQLite has exactly one index access
377
+ // method (b-tree) and no USING clause — a plain index is the closest physical
378
+ // realization. The expected snapshot strips `using` for sqlite (Pass 3 in
379
+ // buildExpectedSchema) so the diff stays convergent.
380
+ const keys = ix.expr
381
+ ? ix.expr
382
+ : ix.columns
383
+ .map((c, i) => (ix.orders?.[i] === "desc" ? `${quote(c)} DESC` : quote(c)))
384
+ .join(", ");
385
+ const where = ix.where ? ` WHERE (${ix.where})` : "";
386
+ return `CREATE ${u}INDEX ${quote(ix.name)} ON ${quote(table)} (${keys})${where};`;
340
387
  }
341
388
 
342
389
  function quote(ident: string): string {
@@ -152,7 +152,45 @@ export function buildExpectedSchema(
152
152
  if (dialect === "sqlite") {
153
153
  for (const table of tables) {
154
154
  for (const col of table.columns) {
155
+ const kindBefore = col.sqlType.kind;
155
156
  col.sqlType = normalizeForSqlite(col.sqlType);
157
+ // The default VALUE must be normalized alongside the TYPE, or the three layers
158
+ // disagree. A boolean column becomes an integer column here, so its canonical
159
+ // "true"/"false" literal must become "1"/"0" too:
160
+ // - leave it as "false" → the emitter sees an integer column with a
161
+ // non-numeric literal and quotes it (`DEFAULT 'false'`), which SQLite
162
+ // stores as TEXT in a numeric-affinity column, so `WHERE col = 0` silently
163
+ // matches nothing;
164
+ // - emit `0` while the expected side still says "false" → introspection reads
165
+ // back "0", `columnDefaultsEqual` is a strict string compare, and the diff
166
+ // reports `change-column-default` forever — which on SQLite means a
167
+ // destructive recreate-and-copy of the whole table on EVERY migrate.
168
+ // Normalizing here keeps expected == emitted == introspected.
169
+ if (kindBefore === "boolean" && col.default?.kind === "literal") {
170
+ const normalized = normalizeBooleanLiteralForSqlite(col.default.value);
171
+ if (normalized !== undefined) col.default = { kind: "literal", value: normalized };
172
+ }
173
+ // `now()` is Postgres-only; SQLite has no such function, so a
174
+ // `DEFAULT now()` (the @autoSet insert-time default, and any authored
175
+ // @default "now()") makes the emitted CREATE TABLE un-appliable
176
+ // (`near "(": syntax error`). Normalize to the SQL-standard
177
+ // CURRENT_TIMESTAMP *here* — not at emit time — so all three layers
178
+ // agree: emit renders `DEFAULT CURRENT_TIMESTAMP`, sqlite stores that
179
+ // token verbatim, and introspection reads back the identical expr,
180
+ // keeping the re-diff empty (an emit-only mapping would report
181
+ // change-column-default forever → recreate-and-copy on every run).
182
+ if (col.default?.kind === "expr" && /^now\(\)$/i.test(col.default.value.trim())) {
183
+ col.default = { kind: "expr", value: "CURRENT_TIMESTAMP" };
184
+ }
185
+ }
186
+ // `@using` names a Postgres index access method (gin/gist/hash/…).
187
+ // SQLite has exactly ONE access method (b-tree) and no USING clause, so
188
+ // the attr is physically meaningless there: the emitter cannot render it
189
+ // and introspection can never read it back. Strip it from the expected
190
+ // snapshot — the closest physical realization is a plain index — so the
191
+ // diff converges instead of proposing drop/add on every run.
192
+ for (const index of table.indexes) {
193
+ delete index.using;
156
194
  }
157
195
  }
158
196
  }
@@ -181,6 +219,25 @@ export function buildExpectedSchema(
181
219
  * sqlite stores all integers (including booleans) as INTEGER, and uses TEXT for
182
220
  * date/time/timestamp affinities by default.
183
221
  */
222
+ /**
223
+ * SQLite has no boolean literal: the canonical "true"/"false" become 1/0 so the value
224
+ * matches the integer column the type is normalized to. Returns undefined for anything
225
+ * unrecognized (the loader validates coercibility long before here) so we never silently
226
+ * rewrite a value we don't understand.
227
+ */
228
+ function normalizeBooleanLiteralForSqlite(value: string): string | undefined {
229
+ switch (value.trim().toLowerCase()) {
230
+ case "true":
231
+ case "1":
232
+ return "1";
233
+ case "false":
234
+ case "0":
235
+ return "0";
236
+ default:
237
+ return undefined;
238
+ }
239
+ }
240
+
184
241
  function normalizeForSqlite(sqlType: SqlType): SqlType {
185
242
  switch (sqlType.kind) {
186
243
  case "boolean":
@@ -511,6 +568,14 @@ function buildChecks(
511
568
  ): CheckDescriptor[] {
512
569
  const checks: CheckDescriptor[] = [];
513
570
  for (const field of entity.fields()) {
571
+ // No field-level CHECKs on array columns: the derived expressions assume a
572
+ // SCALAR column. `"labels" IN ('A','B')` against a text[] is a type error at
573
+ // CREATE TABLE; `length(col)`/range checks are equally scalar-shaped. Array
574
+ // element validation (enum membership, ranges) is enforced app-side (Zod /
575
+ // per-port validators), matching codegen-ts's column-mapper which also skips
576
+ // the enum literal-union for isArray columns.
577
+ // ADR-0039: resolving — array-ness may be inherited via extends.
578
+ if (field.resolvedIsArray()) continue;
514
579
  const col = resolveColumnName(field, strategy);
515
580
  const qcol = quoteCheckCol(col);
516
581
  // Enum membership check.
@@ -770,17 +835,43 @@ function buildColumn(
770
835
  /**
771
836
  * The native Postgres array ELEMENT SqlType for an `isArray` scalar field, or
772
837
  * undefined when the subtype has no native-array form (object/map → single jsonb
773
- * column; everything else falls through to the scalar subtype default).
838
+ * column carrying the JSON array).
774
839
  *
775
- * dbColumnType slim-and-derive Phase 1 wires the two derived cases the design calls
776
- * out: `field.string` `text[]`, `field.uuid` `uuid[]`. This mirrors codegen-ts's
777
- * column-mapper, which emits native `.array()` for the same scalar subtypes.
840
+ * MUST agree with codegen-ts's column-mapper, which emits Drizzle `.array()` for
841
+ * EVERY scalar `@isArray` field on postgres (everything except object/map). When
842
+ * this mapped only string/uuid, `field.int @isArray` got a SCALAR integer DB
843
+ * column under an `integer("x").array()` Drizzle column — the first insert
844
+ * failed (`column "x" is of type integer but expression is of type integer[]`)
845
+ * with no drift signal, because both diff sides carried the same wrong scalar.
846
+ *
847
+ * Elements are deliberately UNQUALIFIED — bare `text` (no maxLength), bare
848
+ * `numeric` (no precision/scale): information_schema reports NO qualifiers for
849
+ * array elements (character_maximum_length / numeric_precision are NULL when
850
+ * data_type = 'ARRAY'; verified on live PG 16), so a qualified expected element
851
+ * could never converge with introspection and would churn change-column-type on
852
+ * every run. The element qualifier is a codegen/validation concern, not a
853
+ * migratable physical property.
778
854
  */
779
855
  function arrayElementSqlType(field: MetaData): SqlType | undefined {
780
856
  switch (field.subType) {
781
- case FIELD_SUBTYPE_STRING: return { kind: "text" };
782
- case FIELD_SUBTYPE_UUID: return { kind: "uuid" };
783
- default: return undefined;
857
+ case FIELD_SUBTYPE_STRING:
858
+ case FIELD_SUBTYPE_ENUM: // enum[] stores as text[]; membership is app-level (no CHECK — see buildChecks)
859
+ case FIELD_SUBTYPE_URI: return { kind: "text" };
860
+ case FIELD_SUBTYPE_UUID: return { kind: "uuid" };
861
+ case FIELD_SUBTYPE_INT: return { kind: "integer", bits: 32 };
862
+ case FIELD_SUBTYPE_LONG:
863
+ case FIELD_SUBTYPE_CURRENCY: return { kind: "integer", bits: 64 };
864
+ case FIELD_SUBTYPE_DOUBLE: return { kind: "real" };
865
+ case FIELD_SUBTYPE_FLOAT: return { kind: "real4" };
866
+ case FIELD_SUBTYPE_DECIMAL: return { kind: "numeric" };
867
+ case FIELD_SUBTYPE_BOOLEAN: return { kind: "boolean" };
868
+ case FIELD_SUBTYPE_DATE: return { kind: "date" };
869
+ case FIELD_SUBTYPE_TIME: return { kind: "time" };
870
+ case FIELD_SUBTYPE_TIMESTAMP:
871
+ // ADR-0039: resolving — @localTime may be inherited via extends.
872
+ return { kind: "timestamp", withTimezone: field.attr(FIELD_ATTR_LOCAL_TIME) !== true };
873
+ case FIELD_SUBTYPE_INET: return { kind: "inet" };
874
+ default: return undefined; // object/map → single jsonb column
784
875
  }
785
876
  }
786
877
 
@@ -2,7 +2,10 @@ import type {
2
2
  SchemaSnapshot, TableDescriptor, ColumnDescriptor, SnapshotMeta,
3
3
  IndexDescriptor, FkDescriptor, FkAction, ViewDescriptor,
4
4
  } from "../types.js";
5
- import { parseSqliteDefault, sqliteTypeToSqlType, sqliteRuleToAction } from "./sqlite-shared.js";
5
+ import {
6
+ parseSqliteDefault, sqliteTypeToSqlType, sqliteRuleToAction, parseSqliteChecks,
7
+ buildSqliteIndexDescriptor,
8
+ } from "./sqlite-shared.js";
6
9
 
7
10
  /**
8
11
  * Runner contract: takes a SQL command string and returns wrangler's raw
@@ -48,8 +51,30 @@ export async function introspectD1(opts: IntrospectD1Options): Promise<SchemaSna
48
51
  }
49
52
  const meta: SnapshotMeta = { sqliteVersion };
50
53
 
54
+ // Beyond SQLite's own `sqlite_%` and our rename-shadow `__new_%` tables, D1 carries
55
+ // two infrastructure tables that were never part of the declared schema and must
56
+ // never reach the diff:
57
+ //
58
+ // `_cf_%` Cloudflare/miniflare reserved bookkeeping (e.g. `_cf_METADATA`).
59
+ // It appears the moment ANY write touches a local D1, and D1's
60
+ // authorizer then denies even a bare `pragma_table_info` against it
61
+ // (SQLITE_AUTH). Since we call readTableInfo once per enumerated
62
+ // table, leaving it in the list aborts introspection outright — which
63
+ // breaks every second-and-later `meta migrate --dialect d1` (the first
64
+ // migration is the very write that creates it, so it is invisible until
65
+ // the first incremental run).
66
+ //
67
+ // `d1_migrations` wrangler's own migration-tracking table. Queryable, so it doesn't
68
+ // crash — it just reads as an undeclared "extra" table, and the diff
69
+ // then proposes DROP TABLE on wrangler's own bookkeeping.
70
+ //
71
+ // Filter in the query, not after: `_cf_METADATA` must never even be fetched.
51
72
  const tableRows = await exec(
52
- "SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__new_%' ORDER BY name",
73
+ "SELECT name, sql FROM sqlite_master WHERE type='table'"
74
+ + " AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'"
75
+ + " AND name NOT LIKE '\\_\\_new\\_%' ESCAPE '\\'"
76
+ + " AND name NOT LIKE '\\_cf\\_%' ESCAPE '\\'"
77
+ + " AND name != 'd1_migrations' ORDER BY name",
53
78
  );
54
79
 
55
80
  const tables: TableDescriptor[] = [];
@@ -71,7 +96,9 @@ export async function introspectD1(opts: IntrospectD1Options): Promise<SchemaSna
71
96
  columns: cols,
72
97
  indexes: await readIndexes(exec, name),
73
98
  foreignKeys: await readForeignKeys(exec, name),
74
- checks: [], // CHECK introspection is out of scope; expected-side derives them
99
+ // Named CHECKs parsed from the stored CREATE TABLE DDL required for
100
+ // check evolution (enum @values changes) to converge on sqlite/D1.
101
+ checks: parseSqliteChecks(createSql),
75
102
  primaryKey: pk,
76
103
  });
77
104
  }
@@ -129,17 +156,26 @@ function extractPrimaryKey(rows: Record<string, unknown>[]): string[] {
129
156
 
130
157
  async function readIndexes(exec: Exec, table: string): Promise<IndexDescriptor[]> {
131
158
  const list = await exec(`SELECT * FROM pragma_index_list(${sqliteIdent(table)})`);
159
+ // Stored CREATE INDEX DDL per index — the only catalog for expression keys and
160
+ // partial-index predicates (mirrors introspectSqlite's readSqliteIndexes).
161
+ const ddlRows = await exec(
162
+ `SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name = ${sqliteLiteral(table)}`,
163
+ );
164
+ const ddlByName = new Map(ddlRows.map((r) => [String(r.name), r.sql === null ? null : String(r.sql)] as const));
132
165
  const indexes: IndexDescriptor[] = [];
133
166
  for (const ix of list) {
134
167
  if (String(ix.origin) === "pk") continue;
135
- if (Number(ix.partial) === 1) continue;
136
168
  const ixName = String(ix.name);
137
- const cols = await exec(`SELECT seqno, cid, name FROM pragma_index_info(${sqliteIdent(ixName)}) ORDER BY seqno`);
138
- indexes.push({
139
- name: ixName,
140
- columns: cols.map((c) => String(c.name)),
141
- unique: Number(ix.unique) === 1,
142
- });
169
+ // pragma_index_xinfo: DESC bit + key-vs-auxiliary flag; expression keys have
170
+ // name NULL (cid -2).
171
+ const keyRows = await exec(`SELECT * FROM pragma_index_xinfo(${sqliteIdent(ixName)}) ORDER BY seqno`);
172
+ indexes.push(buildSqliteIndexDescriptor(
173
+ { name: ixName, unique: Number(ix.unique) === 1, partial: Number(ix.partial) === 1 },
174
+ keyRows
175
+ .filter((c) => Number(c.key) === 1)
176
+ .map((c) => ({ name: c.name === null ? null : String(c.name), desc: Number(c.desc) === 1 })),
177
+ ddlByName.get(ixName) ?? null,
178
+ ));
143
179
  }
144
180
  return indexes;
145
181
  }
@@ -181,7 +217,9 @@ async function readViews(exec: Exec): Promise<ViewDescriptor[]> {
181
217
  // D1 gets the same view-body drift detection as the kysely sqlite path — the
182
218
  // diff's comparator strips the leading CREATE VIEW before comparing bodies.
183
219
  const rows = await exec(
184
- "SELECT name, sql FROM sqlite_master WHERE type='view' AND name NOT LIKE 'sqlite_%' ORDER BY name",
220
+ "SELECT name, sql FROM sqlite_master WHERE type='view'"
221
+ + " AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'"
222
+ + " AND name NOT LIKE '\\_cf\\_%' ESCAPE '\\' ORDER BY name",
185
223
  );
186
224
  return rows.map((r) => {
187
225
  const view: ViewDescriptor = { name: String(r.name) };
@@ -201,3 +239,11 @@ function sqliteIdent(name: string): string {
201
239
  return `"${name.replace(/"/g, '""')}"`;
202
240
  }
203
241
 
242
+ /**
243
+ * Quote a VALUE as a SQL string literal (single quotes, '' escaping) for the
244
+ * same no-bind-params wrangler constraint sqliteIdent works around.
245
+ */
246
+ function sqliteLiteral(value: string): string {
247
+ return `'${value.replace(/'/g, "''")}'`;
248
+ }
249
+
@@ -200,8 +200,12 @@ export function parsePgDefault(raw: string | null | undefined): ColumnDefault |
200
200
  if (raw.startsWith("'")) {
201
201
  // Strip the cast suffix (e.g. `::boolean`, `::text`, `::integer`)
202
202
  const withoutCast = raw.replace(/::[^']+$/, "");
203
- // Strip the surrounding single-quotes
204
- const cleaned = withoutCast.replace(/^'(.*)'$/, "$1");
203
+ // Strip the surrounding single-quotes, then un-double the embedded `''`
204
+ // escapes PG stores verbatim (e.g. `'don''t'::text`) — without this the
205
+ // introspected value never equals the expected literal and the diff issues
206
+ // a bogus SET DEFAULT on every run.
207
+ const m = /^'(.*)'$/.exec(withoutCast);
208
+ const cleaned = m !== null ? m[1]!.replace(/''/g, "'") : withoutCast;
205
209
  return { kind: "literal", value: cleaned };
206
210
  }
207
211
 
@@ -239,18 +243,36 @@ async function readTableNames(k: Kysely<any>): Promise<SchemaTableRef[]> {
239
243
  }
240
244
 
241
245
  async function readPgViews(k: RawKysely): Promise<ViewDescriptor[]> {
242
- // pg-mem gap: information_schema.views is not supported — the query throws
243
- // "relation views does not exist". We catch and return [] so other tests
244
- // still pass on pg-mem. Real PG (Postgres 16) handles this correctly.
246
+ // pg-mem gap: the catalog joins below are not supported — the query throws.
247
+ // We catch and return [] so other tests still pass on pg-mem. Real PG
248
+ // (Postgres 16) handles this correctly.
245
249
  try {
246
- // information_schema.views.view_definition is the SELECT body (not the
247
- // full CREATE VIEW statement). We carry it through on the descriptor so the
248
- // diff can detect view-body drift (not just name presence).
250
+ // pg_get_viewdef returns the SELECT body (not the full CREATE VIEW
251
+ // statement same shape information_schema.view_definition had). We carry
252
+ // it through on the descriptor so the diff can detect view-body drift (not
253
+ // just name presence).
254
+ //
255
+ // EXTENSION-OWNED views are excluded (pg_depend deptype 'e'): a view that
256
+ // `CREATE EXTENSION` installed (e.g. pg_stat_statements drops one into
257
+ // `public`) belongs to the extension, not the model — reporting it makes
258
+ // the very next migrate propose `DROP VIEW "pg_stat_statements";`, and
259
+ // dropping it would break the extension.
249
260
  const rows = await sql<{ table_name: string; table_schema: string; view_definition: string | null }>`
250
- SELECT table_name, table_schema, view_definition FROM information_schema.views
251
- WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
252
- AND table_schema NOT LIKE 'pg_%'
253
- ORDER BY table_schema, table_name
261
+ SELECT c.relname AS table_name,
262
+ n.nspname AS table_schema,
263
+ pg_get_viewdef(c.oid) AS view_definition
264
+ FROM pg_class c
265
+ JOIN pg_namespace n ON n.oid = c.relnamespace
266
+ WHERE c.relkind = 'v'
267
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
268
+ AND n.nspname NOT LIKE 'pg_%'
269
+ AND NOT EXISTS (
270
+ SELECT 1 FROM pg_depend d
271
+ WHERE d.classid = 'pg_class'::regclass
272
+ AND d.objid = c.oid
273
+ AND d.deptype = 'e'
274
+ )
275
+ ORDER BY n.nspname, c.relname
254
276
  `.execute(k);
255
277
  return rows.rows.map((r) => {
256
278
  const view: ViewDescriptor = { name: r.table_name, schema: r.table_schema };
@@ -258,7 +280,7 @@ async function readPgViews(k: RawKysely): Promise<ViewDescriptor[]> {
258
280
  return view;
259
281
  });
260
282
  } catch {
261
- // pg-mem: information_schema.views not supported — return empty view list.
283
+ // pg-mem: pg_class/pg_depend catalog introspection not supported — return empty view list.
262
284
  return [];
263
285
  }
264
286
  }