Skip to content

Commit ee90b8e

Browse files
committed
fix(database): make introspect -> verify roundtrip drift-free
1 parent 7d72a6f commit ee90b8e

11 files changed

Lines changed: 782 additions & 60 deletions

File tree

packages/appkit/src/database/introspector/diff.ts

Lines changed: 77 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export function diffIntrospections(
3636
entries.push({
3737
severity: "warn",
3838
kind: "live-only",
39-
message: `+ table ${key} (exists in db, missing in schema.ts)`,
39+
message: `table ${key} (exists in db, missing in schema.ts)`,
4040
});
4141
continue;
4242
}
@@ -48,7 +48,7 @@ export function diffIntrospections(
4848
entries.push({
4949
severity: "warn",
5050
kind: "schema-only",
51-
message: `- table ${key} (in schema.ts, missing in db)`,
51+
message: `table ${key} (in schema.ts, missing in db)`,
5252
});
5353
}
5454
}
@@ -72,7 +72,7 @@ function diffColumns(
7272
entries.push({
7373
severity: "warn",
7474
kind: "live-only",
75-
message: `+ column ${key}.${name} (in db, missing in schema.ts)`,
75+
message: `column ${key}.${name} (in db, missing in schema.ts)`,
7676
});
7777
continue;
7878
}
@@ -81,7 +81,7 @@ function diffColumns(
8181
entries.push({
8282
severity: "warn",
8383
kind: "type-mismatch",
84-
message: `~ column ${key}.${name} (${declaredCol.pgType} declared, ${liveCol.pgType} in db)`,
84+
message: `column ${key}.${name} (${declaredCol.pgType} declared, ${liveCol.pgType} in db)`,
8585
});
8686
}
8787
diffColumnMetadata(key, name, liveCol, declaredCol, entries);
@@ -92,7 +92,7 @@ function diffColumns(
9292
entries.push({
9393
severity: "warn",
9494
kind: "schema-only",
95-
message: `- column ${key}.${name} (in schema.ts, missing in db)`,
95+
message: `column ${key}.${name} (in schema.ts, missing in db)`,
9696
});
9797
}
9898
}
@@ -109,6 +109,13 @@ function tableKey(table: Pick<IntrospectedTable, "schema" | "name">): string {
109109
* Runtime writes and migrations depend on nullability, defaults, keys,
110110
* generated columns, and FK actions, so drift detection must compare the
111111
* metadata captured by introspection instead of stopping at `pgType`.
112+
*
113+
* Server-generated columns get special treatment: when both sides agree the
114+
* column is server-generated, we skip `hasDefault` and `defaultExpression`
115+
* comparisons because the live DB stores the literal `nextval(...)` /
116+
* `GENERATED AS IDENTITY` expression while the schema models the same fact
117+
* as `serverGenerated: true` metadata. Comparing them would produce noise on
118+
* every introspect → verify roundtrip for serial / bigserial / identity PKs.
112119
*/
113120
function diffColumnMetadata(
114121
table: string,
@@ -125,22 +132,28 @@ function diffColumnMetadata(
125132
declared.nullable,
126133
entries,
127134
);
128-
compareField(
129-
table,
130-
column,
131-
"hasDefault",
132-
live.hasDefault,
133-
declared.hasDefault,
134-
entries,
135-
);
136-
compareField(
137-
table,
138-
column,
139-
"defaultExpression",
140-
live.defaultExpression,
141-
declared.defaultExpression,
142-
entries,
143-
);
135+
136+
const bothServerGenerated =
137+
Boolean(live.serverGenerated) && Boolean(declared.serverGenerated);
138+
if (!bothServerGenerated) {
139+
compareField(
140+
table,
141+
column,
142+
"hasDefault",
143+
live.hasDefault,
144+
declared.hasDefault,
145+
entries,
146+
);
147+
compareField(
148+
table,
149+
column,
150+
"defaultExpression",
151+
normalizeDefaultExpression(live.defaultExpression),
152+
normalizeDefaultExpression(declared.defaultExpression),
153+
entries,
154+
);
155+
}
156+
144157
compareField(
145158
table,
146159
column,
@@ -149,22 +162,24 @@ function diffColumnMetadata(
149162
Boolean(declared.isPrimaryKey),
150163
entries,
151164
);
152-
compareField(
153-
table,
154-
column,
155-
"serverGenerated",
156-
Boolean(live.serverGenerated),
157-
Boolean(declared.serverGenerated),
158-
entries,
159-
);
165+
if (live.isPrimaryKey || declared.isPrimaryKey) {
166+
compareField(
167+
table,
168+
column,
169+
"serverGenerated",
170+
Boolean(live.serverGenerated),
171+
Boolean(declared.serverGenerated),
172+
entries,
173+
);
174+
}
160175

161176
const liveRef = normalizeReference(live.references);
162177
const declaredRef = normalizeReference(declared.references);
163178
if (liveRef !== declaredRef) {
164179
entries.push({
165180
severity: "warn",
166181
kind: "type-mismatch",
167-
message: `~ column ${table}.${column} foreign key (${declaredRef} declared, ${liveRef} in db)`,
182+
message: `column ${table}.${column} foreign key (${declaredRef} declared, ${liveRef} in db)`,
168183
});
169184
}
170185
}
@@ -182,7 +197,7 @@ function compareField(
182197
entries.push({
183198
severity: "warn",
184199
kind: "type-mismatch",
185-
message: `~ column ${table}.${column} ${field} (${formatValue(
200+
message: `column ${table}.${column} ${field} (${formatValue(
186201
declared,
187202
)} declared, ${formatValue(live)} in db)`,
188203
});
@@ -206,3 +221,34 @@ function normalizeReference(
206221
function formatValue(value: unknown): string {
207222
return value === undefined ? "undefined" : JSON.stringify(value);
208223
}
224+
225+
/**
226+
* Strip the trivial `'literal'::type` cast Postgres emits around quoted
227+
* string defaults so that `'member'::text` (live) compares equal to `member`
228+
* (declared). Also unescapes `''` -> `'` inside the literal.
229+
*
230+
* Deliberately conservative:
231+
* - Matches a SINGLE quoted literal followed by a single `::type` cast.
232+
* - Does NOT touch expressions that contain `||`, function calls, or
233+
* additional casts — those are kept verbatim and compared as-is so we
234+
* don't claim equality between two non-trivially-different expressions
235+
* and silently miss real drift. Example: `'foo'::text || 'bar'::text`
236+
* and `'foobar'` stay distinct.
237+
*/
238+
function normalizeDefaultExpression(
239+
value: string | undefined,
240+
): string | undefined {
241+
if (value === undefined) return undefined;
242+
const trimmed = value.trim();
243+
const castedString = SIMPLE_CAST_LITERAL.exec(trimmed);
244+
if (castedString) return castedString[1].replaceAll("''", "'");
245+
return trimmed;
246+
}
247+
248+
/**
249+
* Matches `'literal'::type` where the literal is a single quoted string with
250+
* `''` escaping and the type is a simple identifier (no parens, no `||`,
251+
* no further casts).
252+
*/
253+
const SIMPLE_CAST_LITERAL =
254+
/^'((?:[^']|'')*)'::[a-zA-Z_][\w]*(?:\s*\(\s*\d+\s*\))?$/;

packages/appkit/src/database/introspector/drizzle-adapter.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,16 @@ function adaptColumn(
4949
hasDefault: column.hasDefault,
5050
};
5151

52-
if (column.default !== undefined)
53-
adapted.defaultExpression = String(column.default);
52+
if (column.default !== undefined) {
53+
adapted.defaultExpression = stringifyDefault(column.default);
54+
}
5455
if (column.primary) adapted.isPrimaryKey = true;
5556
if (
5657
meta?.serverGenerated ||
57-
(column.hasDefault && column.columnType === "PgSerial")
58+
(column.hasDefault &&
59+
(column.columnType === "PgSerial" ||
60+
column.columnType === "PgBigSerial53" ||
61+
column.columnType === "PgBigSerial64"))
5862
) {
5963
adapted.serverGenerated = true;
6064
}
@@ -75,14 +79,42 @@ function adaptColumn(
7579
return adapted;
7680
}
7781

78-
/** Convert a Drizzle column type to a Postgres type. */
82+
function stringifyDefault(value: unknown): string {
83+
if (
84+
typeof value === "object" &&
85+
value !== null &&
86+
Array.isArray((value as { queryChunks?: unknown }).queryChunks)
87+
) {
88+
const chunks = (value as { queryChunks: Array<{ value?: unknown }> })
89+
.queryChunks;
90+
return chunks
91+
.map((chunk) => {
92+
if (Array.isArray(chunk.value)) return chunk.value.join("");
93+
return chunk.value === undefined ? String(chunk) : String(chunk.value);
94+
})
95+
.join("");
96+
}
97+
98+
return String(value);
99+
}
100+
101+
/**
102+
* Convert a Drizzle column type to a Postgres `udt_name` value.
103+
*
104+
* Postgres returns `int4` for `serial` and `int8` for `bigserial` from
105+
* `information_schema.columns.udt_name`, so we collapse the auto-incrementing
106+
* and plain-integer Drizzle types to the same wire type. The `serverGenerated`
107+
* flag tracks the sequence-vs-no-sequence distinction separately.
108+
*/
79109
function drizzleTypeToPgType(column: DrizzleColumn): string {
80110
switch (column.columnType) {
81111
case "PgSerial":
82112
case "PgInteger":
83113
return "int4";
84114
case "PgBigInt":
85115
case "PgBigInt53":
116+
case "PgBigSerial53":
117+
case "PgBigSerial64":
86118
return "int8";
87119
case "PgText":
88120
return "text";

packages/appkit/src/database/introspector/render.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
} from "./types";
77

88
const HEADER = `// AUTO-GENERATED by \`appkit db introspect\`. Review before committing.
9-
import { defineSchema, bigint, boolean, fk, id, integer, jsonb, text, timestamp, uuid, varchar } from "@databricks/appkit";
9+
import { defineSchema, bigid, bigint, boolean, fk, id, integer, jsonb, text, timestamp, uuid, varchar } from "@databricks/appkit";
1010
1111
export default defineSchema(({ table }) => {
1212
`;

0 commit comments

Comments
 (0)