decolua/9router · error · MigrationAborted

[DB][migrate] ${tableName} row-count mismatch: expected ${ro

Error message

[DB][migrate] ${tableName} row-count mismatch: expected ${rows.length}, got ${inserted}. Dropped:

What it means

Integrity assertion in importWithAssertion() during legacy db.json → SQLite migration. Rows are inserted one-by-one, failures are collected into `dropped`, and afterwards the table's row count is compared to the source array length; any mismatch means rows were dropped (constraint violations, type errors), so the function logs the dropped rows with reasons, throws MigrationAborted, and the migration aborts rather than silently losing data.

Source

Thrown at src/lib/db/migrate.js:36

// legacy db.json kept intact, marker not written → next boot retries.
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 `dropped` array in the warning — each entry has rowMeta and `reason` naming the exact constraint failure.
  2. Fix the offending rows in the legacy db.json (e.g. fill missing NOT NULL fields, dedupe ids) and re-run the migration.
  3. Delete the new SQLite DB file and the migration marker so the migration re-runs cleanly from db.json.
  4. Back up both db.json and the SQLite file before retrying.

Example fix

// before (db.json rows)
{"accounts":[{"id":"a1"},{"id":"a1"}]}
// after (dedupe)
{"accounts":[{"id":"a1"}]}
// then delete the partially-migrated SQLite file and restart to re-run migration
Defensive patterns

Strategy: validation

Validate before calling

// Validate legacy db.json before migration
const db = JSON.parse(fs.readFileSync("db.json", "utf8"));
for (const t of Object.keys(db)) {
  const ids = new Set();
  for (const row of db[t] ?? []) {
    if (row.id != null) {
      if (ids.has(row.id)) throw new Error(`${t}: duplicate id ${row.id}`);
      ids.add(row.id);
    }
  }
}

Try / catch

try { await getAdapter(); /* triggers runMigrationOnce */ }
catch (e) {
  if (e.message.includes("row-count mismatch")) {
    console.error("Migration aborted — fix the dropped rows listed in the warning, then delete the partial SQLite DB and retry.");
  }
}

Prevention

When it happens

Trigger: Running runMigrationOnce/importLegacyMain when a legacy table's data violates the new SQLite schema: NOT NULL / UNIQUE / CHECK / FK constraint violations, malformed values (e.g. non-JSON in a JSON column), duplicate primary keys, or rows corrupted in the legacy db.json.

Common situations: Upgrading from an old 9router version whose db.json predates current constraints; hand-edited db.json; partially corrupted legacy file; legacy rows referencing accounts that no longer exist.

Related errors


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