Skip to content
Draft
6 changes: 6 additions & 0 deletions packages/appkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
"development": "./src/beta.ts",
"default": "./dist/beta.js"
},
"./database/introspector": {
"types": "./dist/database/introspector/index.d.ts",
"development": "./src/database/introspector/index.ts",
"default": "./dist/database/introspector/index.js"
},
"./type-generator": {
"types": "./dist/type-generator/index.d.ts",
"development": "./src/type-generator/index.ts",
Expand Down Expand Up @@ -103,6 +108,7 @@
"exports": {
".": "./dist/index.js",
"./beta": "./dist/beta.js",
"./database/introspector": "./dist/database/introspector/index.js",
"./dist/shared/src/plugin": "./dist/shared/src/plugin.d.ts",
"./type-generator": "./dist/type-generator/index.js",
"./package.json": "./package.json"
Expand Down
254 changes: 254 additions & 0 deletions packages/appkit/src/database/introspector/diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import type { IntrospectedTable, IntrospectionResult } from "./types";

/** Severity of a drift entry. */
export type DriftSeverity = "info" | "warn" | "error";

/** A single drift entry. */
export interface DriftEntry {
/** The severity of the drift entry. */
severity: DriftSeverity;
/** The kind of drift entry. */
kind: "live-only" | "schema-only" | "type-mismatch";
/** The message of the drift entry. */
message: string;
}

/** A report of drift entries. */
export interface DriftReport {
/** Whether there is any drift. */
hasDrift: boolean;
/** The entries of the drift report. */
entries: DriftEntry[];
}

/** Diff two introspections and return a report of drift entries. */
export function diffIntrospections(
live: IntrospectionResult,
declared: IntrospectionResult,
): DriftReport {
const entries: DriftEntry[] = [];
const liveByKey = new Map(live.tables.map((t) => [tableKey(t), t]));
const declaredByKey = new Map(declared.tables.map((t) => [tableKey(t), t]));

for (const [key, liveTable] of liveByKey) {
const declaredTable = declaredByKey.get(key);
if (!declaredTable) {
entries.push({
severity: "warn",
kind: "live-only",
message: `table ${key} (exists in db, missing in schema.ts)`,
});
continue;
}
diffColumns(key, liveTable, declaredTable, entries);
}

for (const [key] of declaredByKey) {
if (!liveByKey.has(key)) {
entries.push({
severity: "warn",
kind: "schema-only",
message: `table ${key} (in schema.ts, missing in db)`,
});
}
}

return { hasDrift: entries.length > 0, entries };
}

/** Diff two tables and return a report of drift entries. */
function diffColumns(
key: string,
live: IntrospectedTable,
declared: IntrospectedTable,
entries: DriftEntry[],
): void {
const liveCols = new Map(live.columns.map((c) => [c.name, c]));
const declaredCols = new Map(declared.columns.map((c) => [c.name, c]));

for (const [name, liveCol] of liveCols) {
const declaredCol = declaredCols.get(name);
if (!declaredCol) {
entries.push({
severity: "warn",
kind: "live-only",
message: `column ${key}.${name} (in db, missing in schema.ts)`,
});
continue;
}

if (liveCol.pgType !== declaredCol.pgType) {
entries.push({
severity: "warn",
kind: "type-mismatch",
message: `column ${key}.${name} (${declaredCol.pgType} declared, ${liveCol.pgType} in db)`,
});
}
diffColumnMetadata(key, name, liveCol, declaredCol, entries);
}

for (const [name] of declaredCols) {
if (!liveCols.has(name)) {
entries.push({
severity: "warn",
kind: "schema-only",
message: `column ${key}.${name} (in schema.ts, missing in db)`,
});
}
}
}

/** Get the key of a table. */
function tableKey(table: Pick<IntrospectedTable, "schema" | "name">): string {
return `${table.schema}.${table.name}`;
}

/**
* Compares the column contract beyond the raw Postgres type.
*
* Runtime writes and migrations depend on nullability, defaults, keys,
* generated columns, and FK actions, so drift detection must compare the
* metadata captured by introspection instead of stopping at `pgType`.
*
* Server-generated columns get special treatment: when both sides agree the
* column is server-generated, we skip `hasDefault` and `defaultExpression`
* comparisons because the live DB stores the literal `nextval(...)` /
* `GENERATED AS IDENTITY` expression while the schema models the same fact
* as `serverGenerated: true` metadata. Comparing them would produce noise on
* every introspect β†’ verify roundtrip for serial / bigserial / identity PKs.
*/
function diffColumnMetadata(
table: string,
column: string,
live: IntrospectedTable["columns"][number],
declared: IntrospectedTable["columns"][number],
entries: DriftEntry[],
): void {
compareField(
table,
column,
"nullable",
live.nullable,
declared.nullable,
entries,
);

const bothServerGenerated =
Boolean(live.serverGenerated) && Boolean(declared.serverGenerated);
if (!bothServerGenerated) {
compareField(
table,
column,
"hasDefault",
live.hasDefault,
declared.hasDefault,
entries,
);
compareField(
table,
column,
"defaultExpression",
normalizeDefaultExpression(live.defaultExpression),
normalizeDefaultExpression(declared.defaultExpression),
entries,
);
}

compareField(
table,
column,
"isPrimaryKey",
Boolean(live.isPrimaryKey),
Boolean(declared.isPrimaryKey),
entries,
);
if (live.isPrimaryKey || declared.isPrimaryKey) {
compareField(
table,
column,
"serverGenerated",
Boolean(live.serverGenerated),
Boolean(declared.serverGenerated),
entries,
);
}

const liveRef = normalizeReference(live.references);
const declaredRef = normalizeReference(declared.references);
if (liveRef !== declaredRef) {
entries.push({
severity: "warn",
kind: "type-mismatch",
message: `column ${table}.${column} foreign key (${declaredRef} declared, ${liveRef} in db)`,
});
}
}

/** Compare a field of a column and return a report of drift entries. */
function compareField(
table: string,
column: string,
field: string,
live: unknown,
declared: unknown,
entries: DriftEntry[],
): void {
if (live === declared) return;
entries.push({
severity: "warn",
kind: "type-mismatch",
message: `column ${table}.${column} ${field} (${formatValue(
declared,
)} declared, ${formatValue(live)} in db)`,
});
}

/**
* Normalizes FK metadata into one comparable value so missing references and
* action changes produce a single readable drift entry.
*/
function normalizeReference(
reference: IntrospectedTable["columns"][number]["references"],
): string {
if (!reference) return "none";
return [
`${reference.schema}.${reference.table}.${reference.column}`,
`onDelete=${reference.onDelete ?? "no action"}`,
`onUpdate=${reference.onUpdate ?? "no action"}`,
].join(" ");
}

function formatValue(value: unknown): string {
return value === undefined ? "undefined" : JSON.stringify(value);
}

/**
* Strip the trivial `'literal'::type` cast Postgres emits around quoted
* string defaults so that `'member'::text` (live) compares equal to `member`
* (declared). Also unescapes `''` -> `'` inside the literal.
*
* Deliberately conservative:
* - Matches a SINGLE quoted literal followed by a single `::type` cast.
* - Does NOT touch expressions that contain `||`, function calls, or
* additional casts β€” those are kept verbatim and compared as-is so we
* don't claim equality between two non-trivially-different expressions
* and silently miss real drift. Example: `'foo'::text || 'bar'::text`
* and `'foobar'` stay distinct.
*/
function normalizeDefaultExpression(
value: string | undefined,
): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
const castedString = SIMPLE_CAST_LITERAL.exec(trimmed);
if (castedString) return castedString[1].replaceAll("''", "'");
return trimmed;
}

/**
* Matches `'literal'::type` where the literal is a single quoted string with
* `''` escaping and the type is a simple identifier (no parens, no `||`,
* no further casts).
*/
const SIMPLE_CAST_LITERAL =
/^'((?:[^']|'')*)'::[a-zA-Z_][\w]*(?:\s*\(\s*\d+\s*\))?$/;
16 changes: 16 additions & 0 deletions packages/appkit/src/database/introspector/drift-help.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Shared resolution hint for drift output. The plugin's boot warning, the
* `appkit db verify` CLI, and any future drift surfaces all read from this
* one place so the recommended commands stay in lock-step.
*/
export function formatDriftResolution(
opts: { includeVerify?: boolean } = {},
): string {
const lines = [
"Resolve with one of:",
" npx appkit db migrate up",
" npx appkit db introspect --merge",
];
if (opts.includeVerify) lines.push(" npx appkit db verify --explain");
return lines.join("\n");
}
Loading