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

  1. Unwrap the array on the producer and enqueue one op per body object.
  2. Fix the producer so body is always a top-level JSON object with the expected keys (op, entity fields).
  3. If a migration changed the envelope, transform old bodies into the object shape before replaying.
  4. 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

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


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 only

View on GitHub (pinned to d8bc9755e7)