thedotmack/claude-mem · error · Error
SyncApply: invalid op seq=
Error message
SyncApply: invalid op seq=${op.seq} kind=${op.kind} origin=${op.origin_device}/${op.origin_id}: remap_project where predicate is empty What it means
remap_project refuses to run an UPDATE with no WHERE predicate: after assembling clauses from where.project, where.memory_session_id, and where.merged_into_project_is_null, an empty predicate list would mean remapping the entire corpus, which is treated as never intentional. The op is rejected outright.
Solutions
- Supply at least one supported predicate: where.project, where.memory_session_id, or where.merged_into_project_is_null=true
- Never send an empty where — iterate the matched sessions and emit one bounded op per group instead
- Add producer-side assertion that a remap op always has a non-empty, recognized predicate
Example fix
// before
body: { where: {}, fields: { project: 'proj-b' } }
// after
body: { where: { project: 'proj-a' }, fields: { project: 'proj-b' } } Defensive patterns
Strategy: validation
Validate before calling
function hasRemapPredicate(where: Record<string, unknown>): boolean {
return typeof where.project === 'string' ||
typeof where.memory_session_id === 'string' ||
where.merged_into_project_is_null === true;
}
if (!hasRemapPredicate(op.body.where)) throw new Error('remap needs a non-empty where predicate'); Type guard
const hasPredicate = (w: unknown): w is { project?: string; memory_session_id?: string; merged_into_project_is_null?: true } =>
typeof w === 'object' && w !== null && Object.keys(w).length > 0; Try / catch
try { apply(op); } catch (e) {
if (e instanceof InvalidOpError && e.reason.includes('where predicate is empty')) {
// never auto-broaden; require explicit re-emission with a bounded predicate
} else throw e;
} Prevention
- Never emit an empty or unrecognized where object
- Build remaps from concrete query results (per-project, per-session groups)
- Add a producer-side guard that rejects predicate-free remaps
- Only use supported predicate keys: project, memory_session_id, merged_into_project_is_null
When it happens
Trigger: A remap_project op with an empty where object ({}), or where whose keys are all unrecognized/non-string so no clause is generated (e.g. { ids: [...] }).
Common situations: A producer that meant to remap everything and tried an empty selector; a bulk-merge tool passing an empty filter after its own query returned no groups; test fixtures with placeholder where clauses.
Understand the failure class
Background: "Must be a positive integer", "Invalid value", "Unsupported": the invalid-argument-value error family, when a library rejects the value you pass — this error's family across 35 libraries.
Related errors
- canonical content
- device_id must be 1-128 characters
- deviceId must be non-empty
- name must be 1-80 characters
- ops[ ]
AI-assisted analysis of thedotmack/claude-mem@d8bc9755e7 (2026-09-17).
Data as JSON: /api/errors/033028bfce93006e.
Report an issue: GitHub.
Appendix: source
Thrown at src/services/sync/SyncApply.ts:1122
const whereClauses: string[] = [];
const whereParams: (string | number)[] = [];
const whereProject = fieldString(op, where, 'project');
const whereMemorySessionId = fieldString(op, where, 'memory_session_id');
if (whereProject !== null) {
whereClauses.push('project = ?');
whereParams.push(whereProject);
}
if (whereMemorySessionId !== null) {
whereClauses.push('memory_session_id = ?');
whereParams.push(whereMemorySessionId);
}
if (where.merged_into_project_is_null === true) {
whereClauses.push('merged_into_project IS NULL');
}
if (whereClauses.length === 0) {
// Refuse an unbounded remap outright — a predicate-free UPDATE across
// the whole corpus is never intentional.
throw invalidOp(op, 'remap_project where predicate is empty');
}
// Rev guard per matched row: apply only where op.rev >= sync_rev, and
// stamp sync_rev = op.rev on the rows actually remapped (op.rev is the
// max by the guard). synced_at untouched — the remap travels via the log.
//
// The applied/stale outcome comes from COUNT-then-UPDATE, never from
// `.run().changes` — see the note in applySetTitle (bun:sqlite reports
// unreliable `changes` after RETURNING statements in the same
// transaction). Both statements see identical in-transaction state.
let matched = 0;
for (const table of ['observations', 'session_summaries']) {
const rows = this.db.prepare(`
SELECT CAST(id AS TEXT) AS id, CAST(sync_rev AS TEXT) AS sync_rev
FROM ${table} WHERE ${whereClauses.join(' AND ')}
`).all(...whereParams) as Array<{ id: string; sync_rev: string }>;
for (const row of rows) {
if (compareCanonicalDecimals(op.rev, row.sync_rev) < 0) continue;View on GitHub (pinned to d8bc9755e7)