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

  1. Always wrap operations in an array: { ops: [op1, op2] }
  2. Check the client SDK version matches the hub's push schema
  3. 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

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


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)