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}: body must be a JSON object What it means
parseBody() requires op.body to parse to a JSON object (a plain record). Arrays, strings, numbers, booleans, and null are rejected because the row/mutation appliers index body fields by name. This guarantees the applier can read body.op and entity fields predictably.
Solutions
- Unwrap the array on the producer and enqueue one op per body object.
- Fix the producer so body is always a top-level JSON object with the expected keys (op, entity fields).
- If a migration changed the envelope, transform old bodies into the object shape before replaying.
- Check for double-encoding: JSON.stringify(JSON.stringify(obj)) produces a string body — stringify once.
Example fix
// before
body: JSON.stringify([title, sessionId])
// after
body: JSON.stringify({ op: 'set_title', title, content_session_id: sessionId }) Defensive patterns
Strategy: validation
Validate before calling
function isJsonObjectBody(body) {
try {
const p = JSON.parse(body);
return typeof p === 'object' && p !== null && !Array.isArray(p);
} catch { return false; }
} Type guard
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
Try / catch
try {
applySyncOp(op);
} catch (e) {
if (String(e.message).includes('body must be a JSON object')) {
logger.error({ seq: op.seq }, 'non-object body — check producer envelope');
} else throw e;
} Prevention
- Wrap payloads in a top-level object; never enqueue bare arrays or strings
- Stringify exactly once — avoid double-encoding
- Fix the envelope contract in one shared producer module so all writers agree
When it happens
Trigger: A sync op body was serialized from an array (e.g. a batch wrapped as top-level array), from a bare string, or from null — so JSON.parse succeeds but the result is not a plain object.
Common situations: A producer accidentally wrapped payloads: JSON.stringify([op1, op2]) instead of per-row bodies; a body field set to JSON.stringify(null); a refactor changed the envelope shape from {op:...} to a bare value.
Related errors
- canonical content
- Expected JSON array in observation list field, using plain…
- mutation body is not JSON
- mutation body must be an object
- sync hub pull: malformed /changes response
AI-assisted analysis of thedotmack/claude-mem@d8bc9755e7 (2026-09-17).
Data as JSON: /api/errors/c6ff14f669cca14c.
Report an issue: GitHub.
Appendix: source
Thrown at src/services/sync/SyncApply.ts:523
});
}
return result;
}
// -------------------------------------------------------------------------
// Row ops
// -------------------------------------------------------------------------
private parseBody(op: SyncOp): Record<string, unknown> {
let parsed: unknown;
try {
parsed = JSON.parse(op.body);
} catch {
throw invalidOp(op, 'body is not parseable JSON');
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw invalidOp(op, 'body must be a JSON object');
}
return parsed as Record<string, unknown>;
}
private findByOrigin(table: string, originDevice: string, originId: string): RowIdRev | undefined {
return this.db.prepare(
`SELECT id, CAST(sync_rev AS TEXT) AS sync_rev
FROM ${table} WHERE origin_device_id = ? AND origin_local_id = ?`
).get(originDevice, originId) as RowIdRev | undefined;
}
/**
* Canonical-v2 head ledger. It survives local row deletion and epoch replay,
* so a stale live op cannot resurrect a tombstoned entity.
*/
private applyCanonicalRowOp(op: SyncOp, chromaJobs: ChromaJob[]): 'applied' | 'stale' {
if (!op.entity_id || !op.entity_rev || !op.operation_sha256) {
return this.applyRowOp(op, chromaJobs); // internal legacy fixtures onlyView on GitHub (pinned to d8bc9755e7)