fix(forms): surface validation and recover schema drift
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.8.13",
|
||||
"version": "0.8.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -86,6 +86,40 @@ function hasExecutableSql(sql: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function additiveColumnTarget(sql: string): { table: string; column: string } | undefined {
|
||||
const executable = sql
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||
.replace(/--[^\r\n]*/g, " ")
|
||||
.trim();
|
||||
const match = /^ALTER\s+TABLE\s+([A-Za-z_][A-Za-z0-9_]*)\s+ADD\s+COLUMN\s+([A-Za-z_][A-Za-z0-9_]*)\b[\s\S]*;?\s*$/i.exec(
|
||||
executable,
|
||||
);
|
||||
return match ? { table: match[1]!, column: match[2]! } : undefined;
|
||||
}
|
||||
|
||||
async function additiveColumnAlreadyExists(db: Db, sql: string): Promise<boolean> {
|
||||
const target = additiveColumnTarget(sql);
|
||||
if (!target) return false;
|
||||
if (db.driver.dialect === "sqlite") {
|
||||
const columns = await db.all<{ name: string }>(`PRAGMA table_info(${target.table})`);
|
||||
return columns.some(({ name }) => name.toLowerCase() === target.column.toLowerCase());
|
||||
}
|
||||
if (db.driver.dialect === "postgres") {
|
||||
return Boolean(
|
||||
await db.one(
|
||||
"SELECT 1 AS present FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?",
|
||||
[target.table, target.column],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Boolean(
|
||||
await db.one(
|
||||
"SELECT 1 AS present FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?",
|
||||
[target.table, target.column],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Load and parse all migration files in a directory, sorted by filename. */
|
||||
export function loadMigrations(dir: string): Migration[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
@@ -171,7 +205,12 @@ export async function applyMigrations(
|
||||
for (const migration of pending.filter(({ name }) => !current.has(name))) {
|
||||
throwIfAborted(options.signal);
|
||||
await db.tx(async (tx) => {
|
||||
if (hasExecutableSql(migration.up)) await tx.exec(migration.up);
|
||||
if (
|
||||
hasExecutableSql(migration.up) &&
|
||||
!(await additiveColumnAlreadyExists(tx, migration.up))
|
||||
) {
|
||||
await tx.exec(migration.up);
|
||||
}
|
||||
await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [migration.name]);
|
||||
});
|
||||
done.push(migration.name);
|
||||
|
||||
@@ -161,6 +161,24 @@ test("comment-only migrations are recorded without executing empty SQL", async (
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test("add-column migrations recover when the column exists but the migration record does not", async () => {
|
||||
const db = createDb(sqlite());
|
||||
await db.exec("CREATE TABLE otp_challenges (id TEXT PRIMARY KEY, purpose TEXT NOT NULL)");
|
||||
const migrations = [
|
||||
{
|
||||
name: "0002_otp_purpose",
|
||||
up: "ALTER TABLE otp_challenges ADD COLUMN purpose TEXT NOT NULL DEFAULT 'verification';",
|
||||
down: "ALTER TABLE otp_challenges DROP COLUMN purpose;",
|
||||
},
|
||||
];
|
||||
|
||||
expect(await applyMigrations(db, migrations)).toEqual(["0002_otp_purpose"]);
|
||||
expect(await appliedMigrations(db)).toContain("0002_otp_purpose");
|
||||
const columns = await db.all<{ name: string }>("PRAGMA table_info(otp_challenges)");
|
||||
expect(columns.filter(({ name }) => name === "purpose")).toHaveLength(1);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test("migration dry-run plans changes without applying schema and honors cancellation", async () => {
|
||||
const db = createDb(sqlite());
|
||||
const migrations = [
|
||||
|
||||
Reference in New Issue
Block a user