@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.
- package/README.md +2 -1
- package/dist/diff/index.js +73 -15
- package/dist/diff/index.js.map +1 -1
- package/dist/diff/status.d.ts.map +1 -1
- package/dist/diff/status.js +30 -3
- package/dist/diff/status.js.map +1 -1
- package/dist/emit/sqlite.d.ts.map +1 -1
- package/dist/emit/sqlite.js +64 -16
- package/dist/emit/sqlite.js.map +1 -1
- package/dist/expected-schema.d.ts.map +1 -1
- package/dist/expected-schema.js +98 -6
- package/dist/expected-schema.js.map +1 -1
- package/dist/introspect/d1.d.ts.map +1 -1
- package/dist/introspect/d1.js +47 -12
- package/dist/introspect/d1.js.map +1 -1
- package/dist/introspect/postgres.d.ts.map +1 -1
- package/dist/introspect/postgres.js +35 -13
- package/dist/introspect/postgres.js.map +1 -1
- package/dist/introspect/sqlite-shared.d.ts +60 -1
- package/dist/introspect/sqlite-shared.d.ts.map +1 -1
- package/dist/introspect/sqlite-shared.js +205 -3
- package/dist/introspect/sqlite-shared.js.map +1 -1
- package/dist/introspect/sqlite.d.ts.map +1 -1
- package/dist/introspect/sqlite.js +27 -13
- package/dist/introspect/sqlite.js.map +1 -1
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/diff/index.ts +83 -14
- package/src/diff/status.ts +34 -3
- package/src/emit/sqlite.ts +63 -16
- package/src/expected-schema.ts +98 -7
- package/src/introspect/d1.ts +57 -11
- package/src/introspect/postgres.ts +35 -13
- package/src/introspect/sqlite-shared.ts +197 -4
- package/src/introspect/sqlite.ts +37 -12
- package/src/types.ts +7 -0
package/src/emit/sqlite.ts
CHANGED
|
@@ -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)
|
|
276
|
-
// add-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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/expected-schema.ts
CHANGED
|
@@ -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
|
|
838
|
+
* column carrying the JSON array).
|
|
774
839
|
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
*
|
|
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:
|
|
782
|
-
case
|
|
783
|
-
|
|
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
|
|
package/src/introspect/d1.ts
CHANGED
|
@@ -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 {
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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'
|
|
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
|
-
|
|
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:
|
|
243
|
-
//
|
|
244
|
-
//
|
|
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
|
-
//
|
|
247
|
-
//
|
|
248
|
-
// diff can detect view-body drift (not
|
|
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,
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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:
|
|
283
|
+
// pg-mem: pg_class/pg_depend catalog introspection not supported — return empty view list.
|
|
262
284
|
return [];
|
|
263
285
|
}
|
|
264
286
|
}
|