Skip to content

Commit 536bab7

Browse files
dmealingclaude
andcommitted
fix(migrate): keep an empty-string column @default on sqlite/d1 (#235)
buildExpectedSchema dropped `@default: ""` via a falsy `defaultRaw.length > 0` guard, so a `field.string @default: ""` column read as "no default" — perpetually drifting against a DB that has `DEFAULT ''` (a destructive recreate-and-copy on every sqlite/d1 migrate) and disagreeing with codegen (Drizzle emits `.default("")`). Only `undefined` now means "no default"; `""` is kept as a literal (an empty string is never an expr). Gated by the real-SQLite default-semantics round-trip: the shared Photo META gains a `@default: ""` field, so the IDEMPOTENCE test (emit → apply → introspect → re-diff EMPTY) now covers it, plus a value-semantics assertion (emits `DEFAULT ''`, a seeded row stores the empty string, not NULL). Introspection already parsed `DEFAULT ''` correctly; the bug was only the expected-side falsy check. TS-only (schema is TS-owned, ADR-0015). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014xy8powhHYJ6gfFt9Ut8dL
1 parent 3f33d86 commit 536bab7

3 files changed

Lines changed: 31 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10+
### Fixed — empty-string column `@default: ""` no longer drifts on sqlite/d1 (#235)
11+
12+
**npm-only** (`migrate-ts`). `buildExpectedSchema` dropped an empty-string default with a
13+
falsy `defaultRaw.length > 0` guard, so a `field.string @default: ""` column read as
14+
"no default" — perpetually drifting against a DB that has `DEFAULT ''` (a destructive
15+
recreate-and-copy on every sqlite/d1 migrate) and disagreeing with codegen (Drizzle emits
16+
`.default("")`). Only `undefined` now means "no default"; `""` is kept as a literal. Gated by
17+
the real-SQLite default-semantics round-trip (emit `DEFAULT ''` → apply → introspect → re-diff
18+
empty; a seeded row stores the empty string, not NULL). Introspection already round-tripped
19+
`DEFAULT ''`; the fix was purely the expected-side falsy check.
20+
1021
### Fixed — offline `--allow adopt-view` illegal `CREATE OR REPLACE` for a projection (#240)
1122

1223
**npm-only** (`migrate-ts`). Follow-up to #239: the 0.20.4 fix keyed the legal/illegal

server/typescript/packages/migrate-ts/src/expected-schema.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -869,8 +869,12 @@ function buildColumn(
869869
nullable: !isPk && !fieldIsRequired,
870870
};
871871

872-
if (typeof defaultRaw === "string" && defaultRaw.length > 0) {
873-
const isExpr = EXPR_DEFAULT_PATTERNS.some((re) => re.test(defaultRaw));
872+
if (typeof defaultRaw === "string") {
873+
// #235: an EMPTY-string default (`@default: ""`) is a real literal default —
874+
// codegen emits `.default("")` and the DB gets `DEFAULT ''`, so dropping it here
875+
// (a falsy `.length > 0` check) made the column drift forever on sqlite/d1 and
876+
// disagree with codegen. Keep it as a literal; only `undefined` means "no default".
877+
const isExpr = defaultRaw.length > 0 && EXPR_DEFAULT_PATTERNS.some((re) => re.test(defaultRaw));
874878
col.default = { kind: isExpr ? "expr" : "literal", value: defaultRaw };
875879
} else if (typeof defaultRaw === "boolean" || typeof defaultRaw === "number") {
876880
col.default = { kind: "literal", value: String(defaultRaw) };

server/typescript/packages/migrate-ts/test/integration/sqlite-default-semantics.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ const META = JSON.stringify({
4949
{ "field.boolean": { name: "isPublished", "@default": true, "@required": true } },
5050
{ "field.int": { name: "sortOrder", "@default": 0, "@required": true } },
5151
{ "field.string": { name: "label", "@default": "none", "@required": true } },
52+
// #235 — an EMPTY-string default must round-trip (was dropped as falsy → drift).
53+
{ "field.string": { name: "caption", "@default": "", "@required": true } },
5254
{ "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } },
5355
],
5456
},
@@ -140,4 +142,16 @@ describe("SQLite defaults — real-engine value semantics + idempotence", () =>
140142
expect(row["t"]).toBe("text");
141143
expect(row["l"]).toBe("none");
142144
});
145+
146+
test("#235 an EMPTY-string @default emits DEFAULT '' and stores the empty string (not NULL)", async () => {
147+
const { up } = await migrateFromEmpty();
148+
// The emitter must render the empty-string literal, not drop it.
149+
expect(up).toMatch(/"caption"\s+text\s+not null\s+default\s+''/i);
150+
await sql.raw(`INSERT INTO "photos" ("id") VALUES (3)`).execute(k);
151+
const row = (await sql
152+
.raw(`SELECT typeof("caption") AS t, "caption" AS c FROM "photos" WHERE "id" = 3`)
153+
.execute(k)).rows[0] as Record<string, unknown>;
154+
expect(row["t"]).toBe("text");
155+
expect(row["c"]).toBe(""); // the empty string, applied from DEFAULT '' — NOT NULL
156+
});
143157
});

0 commit comments

Comments
 (0)