{"record":{"id":"c6ff14f669cca14c","repo":"thedotmack/claude-mem","slug":"syncapply-invalid-op-seq-op-seq-kind-op-kind-origin-op-c6ff14","errorCode":null,"errorMessage":"SyncApply: invalid op seq=${op.seq} kind=${op.kind} origin=${op.origin_device}/${op.origin_id}: body must be a JSON object","messagePattern":"SyncApply: invalid op seq=(.+?) kind=(.+?) origin=(.+?)/(.+?): body must be a JSON object","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"src/services/sync/SyncApply.ts","lineNumber":523,"sourceCode":"      });\n    }\n\n    return result;\n  }\n\n  // -------------------------------------------------------------------------\n  // Row ops\n  // -------------------------------------------------------------------------\n\n  private parseBody(op: SyncOp): Record<string, unknown> {\n    let parsed: unknown;\n    try {\n      parsed = JSON.parse(op.body);\n    } catch {\n      throw invalidOp(op, 'body is not parseable JSON');\n    }\n    if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n      throw invalidOp(op, 'body must be a JSON object');\n    }\n    return parsed as Record<string, unknown>;\n  }\n\n  private findByOrigin(table: string, originDevice: string, originId: string): RowIdRev | undefined {\n    return this.db.prepare(\n      `SELECT id, CAST(sync_rev AS TEXT) AS sync_rev\n       FROM ${table} WHERE origin_device_id = ? AND origin_local_id = ?`\n    ).get(originDevice, originId) as RowIdRev | undefined;\n  }\n\n  /**\n   * Canonical-v2 head ledger. It survives local row deletion and epoch replay,\n   * so a stale live op cannot resurrect a tombstoned entity.\n   */\n  private applyCanonicalRowOp(op: SyncOp, chromaJobs: ChromaJob[]): 'applied' | 'stale' {\n    if (!op.entity_id || !op.entity_rev || !op.operation_sha256) {\n      return this.applyRowOp(op, chromaJobs); // internal legacy fixtures only","sourceCodeStart":505,"sourceCodeEnd":541,"githubUrl":"https://github.com/thedotmack/claude-mem/blob/d8bc9755e74915e5c3b999181e10a67c889bce2a/src/services/sync/SyncApply.ts#L505-L541","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\nbody: JSON.stringify([title, sessionId])\n// after\nbody: JSON.stringify({ op: 'set_title', title, content_session_id: sessionId })","handlingStrategy":"validation","validationCode":"function isJsonObjectBody(body) {\n  try {\n    const p = JSON.parse(body);\n    return typeof p === 'object' && p !== null && !Array.isArray(p);\n  } catch { return false; }\n}","typeGuard":"const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);","tryCatchPattern":"try {\n  applySyncOp(op);\n} catch (e) {\n  if (String(e.message).includes('body must be a JSON object')) {\n    logger.error({ seq: op.seq }, 'non-object body — check producer envelope');\n  } else throw e;\n}","preventionTips":["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"],"tags":["sync","json","shape"],"backgroundTag":"unexpected-response-shape","analyzedSha":"d8bc9755e74915e5c3b999181e10a67c889bce2a","analyzedAt":"2026-09-17T16:40:26.182Z","contentChangedAt":"2026-09-17T16:40:26.182Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}