thedotmack/claude-mem · error

partial canonical mutation snapshot

Error message

partial canonical mutation snapshot

What it means

drainMutations() requires each sync_outbox row's canonical_body and operation_sha256 to be either both NULL (legacy row, not yet frozen) or both set (frozen canonical wrapper). Exactly one being NULL means the freeze write was torn — the serialized body was stored without its hash or vice versa — so the pair cannot be trusted as an atomic snapshot. The row is moved to sync_dead_letter with this reason and DELETEd from sync_outbox inside one transaction; the drain continues with later rows.

Source

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

      ).all() as MutationOutboxRow[];
      if (rows.length === 0) break;

      // Same size-bounded packing as drainKind: mutation bodies are usually
      // tiny, but every page still stays within the request budget.
      let buf: WireOp[] = [];
      let bufBytes = 0;
      const send = async (): Promise<void> => {
        if (this.stopped || buf.length === 0) return;
        await this.sendOps(buf);
        buf = [];
        bufBytes = 0;
      };
      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;
            }

View on GitHub (pinned to e2d1df569a)

Solutions

  1. Inspect the quarantined payload: SELECT * FROM sync_dead_letter WHERE reason LIKE 'partial canonical%'
  2. If the mutation still matters, re-issue it from the app — it re-enqueues cleanly
  3. Or repair the row by resetting both columns to NULL so the next drain re-freezes it from the raw body: UPDATE sync_outbox SET canonical_body = NULL, operation_sha256 = NULL WHERE id = <id>
  4. Scan for other torn rows: SELECT id FROM sync_outbox WHERE (canonical_body IS NULL) != (operation_sha256 IS NULL)

Example fix

-- before: torn row (one column set)
-- id=7 canonical_body='{...}' operation_sha256=NULL

-- after: let the drain re-derive both from body
UPDATE sync_outbox SET canonical_body = NULL, operation_sha256 = NULL WHERE id = 7;
Defensive patterns

Strategy: validation

Validate before calling

// Detect torn freeze pairs before a flush drains them:
const torn = db.prepare(`
  SELECT id, op_uuid FROM sync_outbox
  WHERE (canonical_body IS NULL) != (operation_sha256 IS NULL)
`).all();
if (torn.length > 0) {
  // repair by re-freezing: reset both so the drain rebuilds from body
  db.prepare(`UPDATE sync_outbox SET canonical_body = NULL, operation_sha256 = NULL
              WHERE (canonical_body IS NULL) != (operation_sha256 IS NULL)`).run();
}

Try / catch

// Per-row drain failures are quarantined, never thrown. Watch the dead-letter table:
const q = db.prepare(`SELECT * FROM sync_dead_letter WHERE reason LIKE 'partial canonical%'`).all();
// each entry: decide re-issue vs discard, then DELETE the dead-letter row

Prevention

When it happens

Trigger: A crash between two separate UPDATE statements in an older non-atomic freeze path; manual SQLite edits to sync_outbox; a migration populating only one of the two columns; disk-level corruption of the DB file.

Common situations: Database restored from a backup taken mid-write; a user hand-editing the SQLite file; upgrading from a pre-v42 schema that only had the body column; older app versions partially applying the freeze.

Related errors


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