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

  1. 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.
  2. Check for duplicate `id` values or NOT NULL violations in the legacy JSON and correct them before re-running migration.
  3. 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.
  4. 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

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


AI-assisted analysis of decolua/9router@90b52e06ff (2026-08-30). Data as JSON: /api/errors/9d64934b4f084480. Report an issue: GitHub.