decolua/9router · critical · MigrationAborted
${tableName} row-count mismatch: expected ${rows.length}, go
Error message
${tableName} row-count mismatch: expected ${rows.length}, got ${inserted} What it means
During legacy db.json → SQLite migration, importWithAssertion copies every row from the legacy JSON into the new table via insertFn, collecting insert failures in `dropped` instead of throwing. It then compares the table's actual COUNT(*) against the number of source rows; a mismatch means one or more rows failed to insert, so it aborts the whole migration with MigrationAborted rather than silently losing data.
Source
Thrown at src/lib/db/migrate.js:37
export class MigrationAborted extends Error {
constructor(message, droppedRows) {
super(message);
this.name = "MigrationAborted";
this.droppedRows = droppedRows;
}
}
// Insert rows one-by-one, collect failures, then assert COUNT(*) matches input length.
function importWithAssertion(adapter, tableName, rows, insertFn, rowMeta) {
const dropped = [];
for (const row of rows) {
try { insertFn(row); }
catch (err) { dropped.push({ ...rowMeta(row), reason: err.message }); }
}
const inserted = adapter.get(`SELECT COUNT(*) as c FROM ${tableName}`)?.c ?? 0;
if (inserted !== rows.length) {
console.warn(`[DB][migrate] ${tableName} row-count mismatch: expected ${rows.length}, got ${inserted}. Dropped:`, dropped);
throw new MigrationAborted(`${tableName} row-count mismatch: expected ${rows.length}, got ${inserted}`, dropped);
}
}
function readJsonSafe(file) {
if (!fs.existsSync(file)) return null;
try { return JSON.parse(fs.readFileSync(file, "utf-8")); } catch { return null; }
}
function isFreshDb(adapter) {
// Table _meta may not exist yet on truly fresh DB
try {
const row = adapter.get(`SELECT COUNT(*) as c FROM _meta`);
return !row || row.c === 0;
} catch {
return true;
}
}
View on GitHub (pinned to 90b52e06ff)
Solutions
- Read the console.warn output '[DB][migrate] ... Dropped:' — each dropped row lists rowMeta and the reason; fix or remove the offending rows in the legacy db.json (default ~/.9router/db.json) and retry.
- Check for duplicate `id` values or NOT NULL violations in the legacy JSON and correct them before re-running migration.
- If the target table already contains rows from a failed prior migration, delete/reset the SQLite database (or the specific table) so the import starts clean.
- Update the app: newer versions may have relaxed the schema or added row sanitization during import.
Example fix
// before: legacy row with duplicate id causes silent drop then abort
{ "apiKeys": [ { "id": "k1", "name": "a" }, { "id": "k1", "name": "b" } ] }
// after: give every row a unique id
{ "apiKeys": [ { "id": "k1", "name": "a" }, { "id": "k2", "name": "b" } ] } Defensive patterns
Strategy: try-catch
Validate before calling
// sanity-check legacy rows before migration
function assertRowsMigratable(tableName, rows) {
const ids = new Set();
for (const r of rows) {
if (r.id == null) throw new Error(`${tableName}: row without id`);
if (ids.has(r.id)) throw new Error(`${tableName}: duplicate id ${r.id}`);
ids.add(r.id);
}
} Try / catch
try {
await migrateLegacyDb();
} catch (err) {
if (err instanceof MigrationAborted) {
console.error("Migration aborted; dropped rows:", err.dropped ?? err.details);
// keep legacy db.json untouched and surface `dropped` to the user
} else throw err;
} Prevention
- Never hand-edit ~/.9router/db.json; back it up before upgrades.
- Ensure every legacy row has a unique, non-null id matching the new schema.
- Watch the '[DB][migrate] ... Dropped:' warn lines — they name each failing row and reason.
- If a migration fails, don't retry blindly; fix the listed rows or start from a clean SQLite db.
When it happens
Trigger: Any insertFn(row) throwing inside the loop (constraint violations, NOT NULL columns, invalid JSON for a JSON column, duplicate primary key) so that COUNT(*) < rows.length. Also if rows.length is miscounted or the table had pre-existing rows making COUNT(*) > rows.length.
Common situations: Users with hand-edited or corrupted legacy db.json files (nulls where non-null is required, ids that clash, fields of the wrong type); schema drift after an app upgrade where the new SQLite schema is stricter than what the legacy JSON contained; a partially-completed previous migration leaving rows in the table.
Related errors
- [DB][migrate] ${tableName} row-count mismatch: expected ${ro
- [DB][migrate] pre-schema backup failed (continuing): ${e.mes
- Failed to export database
- Failed to import database
- [DB] No SQLite driver available (bun/better/node/sql.js all
AI-assisted analysis of decolua/9router@90b52e06ff (2026-08-30).
Data as JSON: /api/errors/9d64934b4f084480.
Report an issue: GitHub.