|
| 1 | +/** |
| 2 | + * The gate this package most needed and did not have: run the WHOLE pipeline against a |
| 3 | + * REAL SQLite engine and assert BOTH properties that matter. |
| 4 | + * |
| 5 | + * 1. IDEMPOTENCE — emit → apply → introspect → re-diff must be EMPTY. Catches any |
| 6 | + * asymmetry between what emit writes, what introspect reads back, and what |
| 7 | + * expected-schema models. (That asymmetry is what made a uuid-PK's synthesized |
| 8 | + * DEFAULT rebuild every table on every migrate.) |
| 9 | + * |
| 10 | + * 2. VALUE SEMANTICS — actually INSERT a row that takes the column defaults and ask |
| 11 | + * SQLite what it stored. Idempotence alone is BLIND to *consistently* wrong |
| 12 | + * behavior: emit and introspect can agree on `DEFAULT 'false'` and converge |
| 13 | + * happily, while SQLite stores the TEXT string "false" in a numeric column and |
| 14 | + * `WHERE flag = 0` silently matches nothing. |
| 15 | + * |
| 16 | + * Every prior test in this package asserted strings the emitter produced, or diffed |
| 17 | + * hand-built descriptors against each other — so both bugs were invisible by construction. |
| 18 | + * |
| 19 | + * NOTE the interaction that makes this subtle: for sqlite/d1, `buildExpectedSchema` |
| 20 | + * Pass 3 normalizes a boolean column's *type* to integer, so the emitter never sees |
| 21 | + * `kind: "boolean"`. The default *value* must be normalized with it, or the two layers |
| 22 | + * disagree — emit `0`, expected `"false"`, introspect `"0"` — and `columnDefaultsEqual` |
| 23 | + * (a strict string compare) reports `change-column-default` forever, which on SQLite |
| 24 | + * means a destructive recreate-and-copy of the whole table on every run. |
| 25 | + */ |
| 26 | + |
| 27 | +import { test, expect, beforeEach, afterEach, describe } from "bun:test"; |
| 28 | +import { mkdtempSync, rmSync } from "node:fs"; |
| 29 | +import { tmpdir } from "node:os"; |
| 30 | +import { join } from "node:path"; |
| 31 | +import { Kysely, sql } from "kysely"; |
| 32 | +import { LibsqlDialect } from "@libsql/kysely-libsql"; |
| 33 | +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; |
| 34 | +import { buildExpectedSchema } from "../../src/expected-schema.js"; |
| 35 | +import { introspectSqlite } from "../../src/introspect/sqlite.js"; |
| 36 | +import { diff } from "../../src/diff/index.js"; |
| 37 | +import { emit } from "../../src/emit/index.js"; |
| 38 | + |
| 39 | +const META = JSON.stringify({ |
| 40 | + "metadata.root": { |
| 41 | + package: "acme", |
| 42 | + children: [ |
| 43 | + { |
| 44 | + "object.entity": { |
| 45 | + name: "Photo", |
| 46 | + children: [ |
| 47 | + { "field.long": { name: "id" } }, |
| 48 | + { "field.boolean": { name: "isPrimary", "@default": false, "@required": true } }, |
| 49 | + { "field.boolean": { name: "isPublished", "@default": true, "@required": true } }, |
| 50 | + { "field.int": { name: "sortOrder", "@default": 0, "@required": true } }, |
| 51 | + { "field.string": { name: "label", "@default": "none", "@required": true } }, |
| 52 | + { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } }, |
| 53 | + ], |
| 54 | + }, |
| 55 | + }, |
| 56 | + ], |
| 57 | + }, |
| 58 | +}); |
| 59 | + |
| 60 | +let tmpDir: string; |
| 61 | +let k: Kysely<Record<string, unknown>>; |
| 62 | + |
| 63 | +beforeEach(() => { |
| 64 | + tmpDir = mkdtempSync(join(tmpdir(), "migrate-ts-default-semantics-")); |
| 65 | + k = new Kysely({ dialect: new LibsqlDialect({ url: `file:${join(tmpDir, "t.db")}` }) }); |
| 66 | +}); |
| 67 | +afterEach(async () => { |
| 68 | + await k.destroy(); |
| 69 | + rmSync(tmpDir, { recursive: true, force: true }); |
| 70 | +}); |
| 71 | + |
| 72 | +async function applyRaw(sqlText: string): Promise<void> { |
| 73 | + for (const stmt of sqlText.trim().split(";").map((s) => s.trim()).filter(Boolean)) { |
| 74 | + await sql.raw(stmt).execute(k); |
| 75 | + } |
| 76 | +} |
| 77 | + |
| 78 | +/** Full pipeline: build → diff-from-empty → emit → apply. Returns the emitted up-SQL. */ |
| 79 | +async function migrateFromEmpty(): Promise<{ up: string; expected: ReturnType<typeof buildExpectedSchema> }> { |
| 80 | + const root = (await new MetaDataLoader().load([new InMemoryStringSource(META)])).root; |
| 81 | + const expected = buildExpectedSchema(root, { dialect: "sqlite" }); |
| 82 | + const actual0 = await introspectSqlite(k); |
| 83 | + const initial = await diff(expected, actual0); |
| 84 | + const { up } = emit(initial.changes, { |
| 85 | + dialect: "sqlite", |
| 86 | + expectedSchema: expected, |
| 87 | + ...(actual0.meta !== undefined && { actualMeta: actual0.meta }), |
| 88 | + }); |
| 89 | + await applyRaw(up); |
| 90 | + return { up, expected }; |
| 91 | +} |
| 92 | + |
| 93 | +describe("SQLite defaults — real-engine value semantics + idempotence", () => { |
| 94 | + test("a boolean @default is stored as an INTEGER, and `= 0` / `= 1` actually match", async () => { |
| 95 | + await migrateFromEmpty(); |
| 96 | + // Take the column defaults — the write path a non-Drizzle client (or raw SQL) uses. |
| 97 | + await sql.raw(`INSERT INTO "photos" ("id") VALUES (1)`).execute(k); |
| 98 | + |
| 99 | + const row = (await sql |
| 100 | + .raw( |
| 101 | + `SELECT typeof("is_primary") AS t_primary, typeof("is_published") AS t_published, |
| 102 | + typeof("sort_order") AS t_sort, "is_primary" AS p, "is_published" AS pub |
| 103 | + FROM "photos"`, |
| 104 | + ) |
| 105 | + .execute(k)).rows[0] as Record<string, unknown>; |
| 106 | + |
| 107 | + // The bug: `DEFAULT 'false'` on a numeric-affinity column stores the TEXT "false". |
| 108 | + expect(row["t_primary"]).toBe("integer"); |
| 109 | + expect(row["t_published"]).toBe("integer"); |
| 110 | + expect(row["t_sort"]).toBe("integer"); |
| 111 | + expect(row["p"]).toBe(0); |
| 112 | + expect(row["pub"]).toBe(1); |
| 113 | + |
| 114 | + // …and the consequence that actually bites in production: the filter misses the row. |
| 115 | + const hits = (await sql |
| 116 | + .raw(`SELECT COUNT(*) AS c FROM "photos" WHERE "is_primary" = 0`) |
| 117 | + .execute(k)).rows[0] as { c: number }; |
| 118 | + expect(Number(hits.c)).toBe(1); |
| 119 | + }); |
| 120 | + |
| 121 | + test("IDEMPOTENCE: after applying, a re-diff against the live DB is empty", async () => { |
| 122 | + const { expected } = await migrateFromEmpty(); |
| 123 | + const actual = await introspectSqlite(k); |
| 124 | + const followup = await diff(expected, actual); |
| 125 | + if (followup.changes.length > 0) { |
| 126 | + console.error("ROUND-TRIP FAILURE — a second `meta migrate` would emit:"); |
| 127 | + for (const c of followup.changes) console.error(" -", JSON.stringify(c)); |
| 128 | + } |
| 129 | + // A non-empty re-diff here means every subsequent migrate re-runs this change — |
| 130 | + // and on SQLite a change-column-default recreate-and-copies the WHOLE table. |
| 131 | + expect(followup.changes).toEqual([]); |
| 132 | + }); |
| 133 | + |
| 134 | + test("a text @default still round-trips and is stored as text", async () => { |
| 135 | + await migrateFromEmpty(); |
| 136 | + await sql.raw(`INSERT INTO "photos" ("id") VALUES (2)`).execute(k); |
| 137 | + const row = (await sql |
| 138 | + .raw(`SELECT typeof("label") AS t, "label" AS l FROM "photos" WHERE "id" = 2`) |
| 139 | + .execute(k)).rows[0] as Record<string, unknown>; |
| 140 | + expect(row["t"]).toBe("text"); |
| 141 | + expect(row["l"]).toBe("none"); |
| 142 | + }); |
| 143 | +}); |
0 commit comments