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
- Update to the latest Actual version so the local schema matches the messages being applied
- Back up the budget file, then check the wrapped error.query in the log to identify the missing table/column and repair the schema
- Restore the budget from a backup made with a matching app version
- 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
- Never downgrade the app below the version that wrote the budget
- Take budget backups before upgrades
- Let migrations finish before syncing
- Inspect e.reason.query in logs to pinpoint the schema drift
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
- getSyncError(result.error.reason, localBudget.id, result.err
- Sync ID is required for sync ${flag}. Set --sync-id or ACTUA
- Could not resolve on-disk budget id for syncId ${syncId} aft
- TrieNode for key ${k} could not be found
- Timestamp.InvalidError: ${data.timestamp}
AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29).
Data as JSON: /api/errors/06515c3e1172d98e.
Report an issue: GitHub.