actualbudget/actual · critical · SyncError

invalid-schema

invalid-schema

Error message

invalid-schema

What it means

A SyncError thrown by the internal apply() function when the SQL generated from an incoming sync message (INSERT INTO <dataset> or UPDATE <dataset> SET <column>) fails against the local SQLite database. It wraps the underlying SQL error (message/stack) plus the offending query. It almost always means the local schema does not match the schema the remote message was created against.

Source

Thrown at packages/loot-core/src/server/sync/index.ts:102

    // Do nothing, it doesn't exist in the db
  } else {
    let query;
    try {
      if (prev) {
        query = {
          sql: `UPDATE ${dataset} SET ${column} = ? WHERE id = ?`,
          params: [value, row],
        };
      } else {
        query = {
          sql: `INSERT INTO ${dataset} (id, ${column}) VALUES (?, ?)`,
          params: [row, value],
        };
      }

      db.runQuery(db.cache(query.sql), query.params);
    } catch (error) {
      throw new SyncError('invalid-schema', {
        error: { message: error.message, stack: error.stack },
        query,
      });
    }
  }
}

// TODO: convert to `whereIn`
async function fetchAll(table, ids) {
  let results = [];

  // was 500, but that caused a stack overflow in Safari
  const batchSize = 100;

  for (let i = 0; i < ids.length; i += batchSize) {
    const partIds = ids.slice(i, i + batchSize);
    let sql;
    let column = `${table}.id`;

View on GitHub (pinned to d4334cb6e6)

Solutions

  1. Update to the latest Actual version so the local schema matches the messages being applied
  2. Back up the budget file, then check the wrapped error.query in the log to identify the missing table/column and repair the schema
  3. Restore the budget from a backup made with a matching app version
  4. If a migration failed, re-run migrations by reloading/resetting the budget db from the server copy

Example fix

// before: downgraded app applying unknown column
applyMessages(msgs); // invalid-schema: no such column: sort_order
// after: upgrade first
yarn upgrade @actual-app/web@latest // then reopen budget and sync
Defensive patterns

Strategy: try-catch

Validate before calling

const tables = await db.runQuery(
  "SELECT name FROM sqlite_master WHERE type='table'",
);
if (!tables.some(t => t.name === msg.dataset)) {
  throw new Error(`Local schema missing table: ${msg.dataset}`);
}

Type guard

function isInvalidSchema(e: unknown): e is SyncError & { reason: { error: { message: string }, query: { sql: string } } } {
  return e instanceof SyncError && e.reason?.code === 'invalid-schema' && !!e.reason.query;
}

Try / catch

try {
  await applyMessages(messages);
} catch (e) {
  if (isInvalidSchema(e)) {
    logger.error('Schema mismatch at:', e.reason.query.sql, e.reason.error.message);
    // restore from backup or upgrade app
  } else throw e;
}

Prevention

When it happens

Trigger: applyMessages/applyMessagesForImport replay a message whose dataset is a table that no longer exists, whose column was renamed/removed, or whose value violates constraints (e.g. NOT NULL, UNIQUE) in the local schema; typically after a version downgrade or upgrading an old budget file to a newer schema.

Common situations: Opening a budget with a newer app version synced its schema-changing messages, then opening it with an older app; custom/modified database files; migrations partially applied so messages reference tables/columns missing locally.

Related errors


AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29). Data as JSON: /api/errors/06515c3e1172d98e. Report an issue: GitHub.