thedotmack/claude-mem · error

mutation body must be an object

Error message

mutation body must be an object

What it means

A sync_outbox row's body parsed as JSON but is not an object — it is an array, string, number, boolean, or null. buildMutationOperation needs an object describing the mutation (op, target, ...), so the row is unusable. Like other per-row drain failures it is quarantined to sync_dead_letter with this reason and removed from the queue; the drain keeps making progress.

Source

Thrown at src/services/sync/CloudSync.ts:798

      };
      for (const row of rows) {
        try {
          let op: WireOp;
          if (row.canonical_body !== null || row.operation_sha256 !== null) {
            if (row.canonical_body === null || row.operation_sha256 === null) {
              throw new Error('partial canonical mutation snapshot');
            }
            op = { body: row.canonical_body, operation_sha256: row.operation_sha256 };
            parseCanonicalOperation(op);
          } else {
            let parsed: unknown;
            try {
              parsed = JSON.parse(row.body);
            } catch {
              throw new Error('mutation body is not JSON');
            }
            if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
              throw new Error('mutation body must be an object');
            }
            const body = parsed as OpBody;
            if (body.op === 'set_prompt_session' && typeof body.target === 'object' && body.target !== null) {
              const target = body.target as Record<string, unknown>;
              if (target.origin_device_id == null) target.origin_device_id = this.deviceId;
            }
            op = buildMutationOperation({
              originDeviceId: this.deviceId,
              mutationId: row.op_uuid,
              entityRev: row.rev,
              mutation: body as unknown as Parameters<typeof buildMutationOperation>[0]['mutation'],
            });
            // First accepted serialization is frozen before its first send.
            this.db.prepare(`
              UPDATE sync_outbox
              SET canonical_body = ?, operation_sha256 = ?
              WHERE id = ? AND canonical_body IS NULL AND operation_sha256 IS NULL
            `).run(op.body, op.operation_sha256, row.id);

View on GitHub (pinned to e2d1df569a)

Solutions

  1. Inspect the dead-letter entry: SELECT raw_body FROM sync_dead_letter WHERE reason = 'mutation body must be an object'
  2. Re-enqueue the correct object shape: { op: '...', target: { ... } } with canonical columns NULL
  3. Fix the producer that queued a non-object (add a shape assertion at enqueue time)
  4. Unwrap double-encoded bodies: JSON.parse twice is the symptom — serialize once at the boundary

Example fix

// before: array envelope — parses to an array, not an object
enqueue(JSON.stringify([{ op: 'set_title', target }]));

// after: single mutation object
enqueue(JSON.stringify({ op: 'set_title', target }));
Defensive patterns

Strategy: type-guard

Validate before calling

// Reject non-object bodies at enqueue time so they never reach the drain:
function enqueueMutation(db: Database, opUuid: string, rev: string, body: unknown): void {
  if (!body || typeof body !== 'object' || Array.isArray(body)) {
    throw new TypeError('mutation body must be a plain object');
  }
  db.prepare('INSERT INTO sync_outbox (op_uuid, rev, body) VALUES (?, ?, ?)')
    .run(opUuid, rev, JSON.stringify(body));
}

Type guard

function isJsonObject(v: unknown): v is Record<string, unknown> {
  return typeof v === 'object' && v !== null && !Array.isArray(v);
}

Try / catch

// Quarantined internally; monitor:
const rows = db.prepare(`SELECT * FROM sync_dead_letter WHERE reason = 'mutation body must be an object'`).all();
// inspect raw_body shape, fix the producer, re-issue the mutation

Prevention

When it happens

Trigger: A writer stored JSON.stringify of a scalar/array (e.g. wrapped the mutation in a list); the body was double-encoded (a JSON string containing JSON text parses to a string); schema drift changed the envelope shape between versions.

Common situations: Calling code passed an array of mutations instead of one; an older build wrapped bodies in [ ... ]; test harness enqueuing malformed fixtures directly into sync_outbox.

Understand the failure class

Background: Schema validation failed / invalid input schema: payload rejected because its shape doesn't match the expected schema — this error's family across 28 libraries.

Related errors


AI-assisted analysis of thedotmack/claude-mem@e2d1df569a (2026-08-20). Data as JSON: /api/errors/2215e08d05e0c924. Report an issue: GitHub.