thedotmack/claude-mem · error · Error
ops must be an array
Error message
ops must be an array
What it means
Validation guard in SyncHubDO.pushOps: the ops parameter must be an array of canonical operations. Non-array payloads (objects, strings, null) cannot be parsed per-op and are refused up front with this invalid outcome.
Solutions
- Always wrap operations in an array: { ops: [op1, op2] }
- Check the client SDK version matches the hub's push schema
- Serialize single ops as [op] before calling pushOps
Example fix
// before
body: JSON.stringify({ ops: singleOp })
// after
body: JSON.stringify({ ops: Array.isArray(singleOp) ? singleOp : [singleOp] }) Defensive patterns
Strategy: validation
Validate before calling
function assertOps(ops: unknown): PushOp[] {
if (!Array.isArray(ops)) throw new Error('ops must be an array');
return ops;
} Type guard
const isPushOpArray = (v: unknown): v is PushOp[] => Array.isArray(v);
Try / catch
try {
return await hub.push(deviceId, ops);
} catch (e) {
if (String(e).includes('ops must be an array')) return pushNormalized(Array.isArray(ops) ? ops : [ops]);
throw e;
} Prevention
- Normalize payloads to { ops: [...] } in a single client-side wrapper
- Keep client and hub push schemas versioned together
- Add a request-shape unit test against the hub contract
When it happens
Trigger: POST /ops with a JSON body where ops is an object, a single op object not wrapped in an array, or missing entirely so null/undefined is passed.
Common situations: Client sends { op: {...} } instead of { ops: [...] }, older client version with a different payload schema, hand-rolled test harness posting the wrong shape.
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
- deviceId must be non-empty
- name must be 1-80 characters
- canonical content
- device_id must be 1-128 characters
- ops[ ]
AI-assisted analysis of thedotmack/claude-mem@d8bc9755e7 (2026-09-17).
Data as JSON: /api/errors/5d120c925e978d50.
Report an issue: GitHub.
Appendix: source
Thrown at workers/sync-hub/src/do/SyncHub.ts:377
attachedDevice = typeof attachment?.device_id === "string" ? attachment.device_id : null;
} catch {}
if (attachedDevice === originDeviceId) continue;
try { ws.send(frame); } catch {}
}
} catch (error) {
console.error("sync-hub fan-out failed (advisory; push unaffected):", error);
}
}
// ---------------------------------------------------------------------
// Canonical append path and client cursor reads.
// ---------------------------------------------------------------------
async pushOps(deviceId: string, ops: PushOp[], deviceName: string | null = null): Promise<PushOutcome> {
let rows: ValidatedOp[];
try {
if (typeof deviceId !== "string" || deviceId.length === 0) throw invalid("deviceId must be non-empty");
if (!Array.isArray(ops)) throw invalid("ops must be an array");
rows = await Promise.all(ops.map(async (op, index) => {
try {
const parsed = await parseCanonicalOperation(op);
if (parsed.body.origin_device_id !== deviceId) {
throw new Error("origin_device_id does not match authenticated X-Device-Id");
}
return parsed;
} catch (error) {
throw invalid(`ops[${index}] ${error instanceof Error ? error.message : String(error)}`);
}
}));
} catch (error) {
if (error instanceof Error && error.message.startsWith(INVALID_OPS_PREFIX)) {
return { refused: true, error: error.message };
}
if (isDeviceLimitError(error)) return { refused: true, error: DEVICE_LIMIT_ERROR };
throw error;
}View on GitHub (pinned to d8bc9755e7)