{"record":{"id":"20a4120e8b007ac6","repo":"thedotmack/claude-mem","slug":"syncapply-sequence-gap-expected-incrementcanon","errorCode":null,"errorMessage":"SyncApply: sequence gap (expected ${incrementCanonicalDecimal(lastSeq)}, got ${seq})","messagePattern":"SyncApply: sequence gap \\(expected (.+?), got (.+?)\\)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/services/sync/SyncApply.ts","lineNumber":462,"sourceCode":"      epochReset: false,\n    };\n    if (ops.length === 0) return result;\n\n    const chromaJobs: ChromaJob[] = [];\n\n    const tx = this.db.transaction(() => {\n      const cursor = this.getCursor();\n      let lastSeq = cursor;\n\n      for (const op of ops) {\n        const seq = assertCanonicalDecimal(op.seq, { positive: true });\n        assertCanonicalDecimal(op.rev, { positive: true });\n        // Strict HTTP pages describe the exact raw suffix after our cursor.\n        // Validate every supplied sequence before the ordinary replay skip;\n        // otherwise a stale prefix (even an out-of-order one) is silently\n        // discarded and a malformed page can look contiguous.\n        if (options.requireContiguous === true && seq !== incrementCanonicalDecimal(lastSeq)) {\n          throw new Error(`SyncApply: sequence gap (expected ${incrementCanonicalDecimal(lastSeq)}, got ${seq})`);\n        }\n        if (compareCanonicalDecimals(seq, cursor) <= 0) {\n          result.skippedCursor++;\n          continue;\n        }\n        if (compareCanonicalDecimals(seq, lastSeq) <= 0) {\n          throw new Error(`SyncApply: ops out of order (seq ${op.seq} after ${lastSeq})`);\n        }\n        lastSeq = seq;\n\n        if (op.origin_device === this.deviceId) {\n          result.skippedOwn++;\n          continue;\n        }\n\n        let outcome: 'applied' | 'stale';\n        if (op.kind === 'mutation') {\n          outcome = this.applyMutation(op);","sourceCodeStart":444,"sourceCodeEnd":480,"githubUrl":"https://github.com/thedotmack/claude-mem/blob/e2d1df569a8f04075d40e92461128ece7cf04c82/src/services/sync/SyncApply.ts#L444-L480","documentation":"Thrown by SyncApply.applyOps during a strict pull (options.requireContiguous === true, which the SyncClient HTTP path always sets) when an incoming op's seq is not exactly incrementCanonicalDecimal(lastSeq). Strict pages must describe the exact raw suffix after the cursor, so the gap check runs before the stale-prefix skip: even an out-of-order stale op fails it, preventing a malformed page from looking contiguous. Halt reason: ops could have been lost between the cursor and this page, so replaying silently would diverge local state from the hub.","triggerScenarios":"Calling applyOps(ops, { requireContiguous: true }) where the first op is not cursor+1, or any later op skips a seq: hub returned a truncated page (server data loss), local cursor is ahead of the server head after a server restore/reset, ops re-ordered in transit, or server restarted its sequence without bumping epoch (so the epochReset re-pull path never triggers).","commonSituations":"Sync hub redeployed from a backup so its op log no longer contains the seqs after your cursor; two hub instances behind a load balancer with divergent heads serving alternating pages; a server-side bug pruning ops; client DB restored from an older snapshot so its cursor points past the server head.","solutions":["Trigger a full resync: reset the local cursor to 0 (apply is idempotent by design) so the next pull re-reads the whole suffix and re-establishes contiguity.","Verify the hub op log around the expected seq to confirm whether ops were actually lost server-side or only reordered.","Confirm you are hitting a single hub head (one instance or a shared log), not alternating servers with different heads.","If the server legitimately reset sequences, make sure it also bumped epoch so clients take the epochReset re-pull path instead of gap errors."],"exampleFix":"// before\ntry {\n  const result = syncApply.applyOps(page.ops, { epoch: page.epoch, requireContiguous: true });\n} catch (e) {\n  throw e; // gap aborts sync forever\n}\n\n// after\ntry {\n  const result = syncApply.applyOps(page.ops, { epoch: page.epoch, requireContiguous: true });\n} catch (e) {\n  if (e instanceof Error && e.message.includes('sequence gap')) {\n    syncApply.resetCursorToZero(); // re-pull from start; apply is idempotent\n    return;\n  }\n  throw e;\n}","handlingStrategy":"retry","validationCode":"// Before applying a strict page, verify its first op continues the cursor exactly.\nimport { incrementCanonicalDecimal, compareCanonicalDecimals } from './canonical-decimal.js';\nfunction pageIsContiguousSuffix(cursor: string, ops: Array<{ seq: string }>): boolean {\n  let last = cursor;\n  for (const op of ops) {\n    if (op.seq !== incrementCanonicalDecimal(last)) return false;\n    last = op.seq;\n  }\n  return true;\n}","typeGuard":null,"tryCatchPattern":"try {\n  result = syncApply.applyOps(ops, { epoch, requireContiguous: true });\n} catch (e) {\n  if (e instanceof Error && e.message.startsWith('SyncApply: sequence gap')) {\n    // Ops may be missing server-side: do NOT skip ahead. Reset and re-pull.\n    syncApply.resetCursor();\n    return pullAgain();\n  }\n  throw e;\n}","preventionTips":["Never pass requireContiguous for delivery layers that may reorder or dedupe; reserve it for pull-from-cursor pages that guarantee the exact raw suffix.","Keep hub epoch bumps paired with any server-side sequence reset so clients take the epochReset path instead of hitting gaps.","Monitor the first-op seq of each page against your stored cursor during development of transport changes."],"tags":["sync","data-integrity","replication","sequence-validation"],"backgroundTag":"replication-sequence-gap","analyzedSha":"e2d1df569a8f04075d40e92461128ece7cf04c82","analyzedAt":"2026-08-20T23:58:13.836Z","contentChangedAt":"2026-08-20T23:58:13.836Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}