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}: remap_project where predicate is empty

What it means

remap_project refuses to run an UPDATE with no WHERE predicate: after assembling clauses from where.project, where.memory_session_id, and where.merged_into_project_is_null, an empty predicate list would mean remapping the entire corpus, which is treated as never intentional. The op is rejected outright.

Solutions

  1. Supply at least one supported predicate: where.project, where.memory_session_id, or where.merged_into_project_is_null=true
  2. Never send an empty where — iterate the matched sessions and emit one bounded op per group instead
  3. Add producer-side assertion that a remap op always has a non-empty, recognized predicate

Example fix

// before
body: { where: {}, fields: { project: 'proj-b' } }
// after
body: { where: { project: 'proj-a' }, fields: { project: 'proj-b' } }
Defensive patterns

Strategy: validation

Validate before calling

function hasRemapPredicate(where: Record<string, unknown>): boolean {
  return typeof where.project === 'string' ||
         typeof where.memory_session_id === 'string' ||
         where.merged_into_project_is_null === true;
}
if (!hasRemapPredicate(op.body.where)) throw new Error('remap needs a non-empty where predicate');

Type guard

const hasPredicate = (w: unknown): w is { project?: string; memory_session_id?: string; merged_into_project_is_null?: true } =>
  typeof w === 'object' && w !== null && Object.keys(w).length > 0;

Try / catch

try { apply(op); } catch (e) {
  if (e instanceof InvalidOpError && e.reason.includes('where predicate is empty')) {
    // never auto-broaden; require explicit re-emission with a bounded predicate
  } else throw e;
}

Prevention

When it happens

Trigger: A remap_project op with an empty where object ({}), or where whose keys are all unrecognized/non-string so no clause is generated (e.g. { ids: [...] }).

Common situations: A producer that meant to remap everything and tried an empty selector; a bulk-merge tool passing an empty filter after its own query returned no groups; test fixtures with placeholder where clauses.

Understand the failure class

Background: "Must be a positive integer", "Invalid value", "Unsupported": the invalid-argument-value error family, when a library rejects the value you pass — this error's family across 35 libraries.

Related errors


AI-assisted analysis of thedotmack/claude-mem@d8bc9755e7 (2026-09-17). Data as JSON: /api/errors/033028bfce93006e. Report an issue: GitHub.

Appendix: source

Thrown at src/services/sync/SyncApply.ts:1122

    const whereClauses: string[] = [];
    const whereParams: (string | number)[] = [];
    const whereProject = fieldString(op, where, 'project');
    const whereMemorySessionId = fieldString(op, where, 'memory_session_id');
    if (whereProject !== null) {
      whereClauses.push('project = ?');
      whereParams.push(whereProject);
    }
    if (whereMemorySessionId !== null) {
      whereClauses.push('memory_session_id = ?');
      whereParams.push(whereMemorySessionId);
    }
    if (where.merged_into_project_is_null === true) {
      whereClauses.push('merged_into_project IS NULL');
    }
    if (whereClauses.length === 0) {
      // Refuse an unbounded remap outright — a predicate-free UPDATE across
      // the whole corpus is never intentional.
      throw invalidOp(op, 'remap_project where predicate is empty');
    }

    // Rev guard per matched row: apply only where op.rev >= sync_rev, and
    // stamp sync_rev = op.rev on the rows actually remapped (op.rev is the
    // max by the guard). synced_at untouched — the remap travels via the log.
    //
    // The applied/stale outcome comes from COUNT-then-UPDATE, never from
    // `.run().changes` — see the note in applySetTitle (bun:sqlite reports
    // unreliable `changes` after RETURNING statements in the same
    // transaction). Both statements see identical in-transaction state.
    let matched = 0;
    for (const table of ['observations', 'session_summaries']) {
      const rows = this.db.prepare(`
        SELECT CAST(id AS TEXT) AS id, CAST(sync_rev AS TEXT) AS sync_rev
        FROM ${table} WHERE ${whereClauses.join(' AND ')}
      `).all(...whereParams) as Array<{ id: string; sync_rev: string }>;
      for (const row of rows) {
        if (compareCanonicalDecimals(op.rev, row.sync_rev) < 0) continue;

View on GitHub (pinned to d8bc9755e7)