fix(forms): surface validation and recover schema drift
Quality / quality (ubuntu-latest) (push) Failing after 10m23s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-15 13:01:10 +05:30
parent 1d16ef1e82
commit d78707be9f
10 changed files with 128 additions and 19 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.8.13",
"version": "0.8.14",
"private": true,
"type": "module",
"main": "./src/index.ts",
+40 -1
View File
@@ -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);
+18
View File
@@ -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 = [