{"record":{"id":"5d120c925e978d50","repo":"thedotmack/claude-mem","slug":"ops-must-be-an-array","errorCode":null,"errorMessage":"ops must be an array","messagePattern":"ops must be an array","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"workers/sync-hub/src/do/SyncHub.ts","lineNumber":377,"sourceCode":"\t\t\t\t\tattachedDevice = typeof attachment?.device_id === \"string\" ? attachment.device_id : null;\n\t\t\t\t} catch {}\n\t\t\t\tif (attachedDevice === originDeviceId) continue;\n\t\t\t\ttry { ws.send(frame); } catch {}\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconsole.error(\"sync-hub fan-out failed (advisory; push unaffected):\", error);\n\t\t}\n\t}\n\n\t// ---------------------------------------------------------------------\n\t// Canonical append path and client cursor reads.\n\t// ---------------------------------------------------------------------\n\n\tasync pushOps(deviceId: string, ops: PushOp[], deviceName: string | null = null): Promise<PushOutcome> {\n\t\tlet rows: ValidatedOp[];\n\t\ttry {\n\t\t\tif (typeof deviceId !== \"string\" || deviceId.length === 0) throw invalid(\"deviceId must be non-empty\");\n\t\t\tif (!Array.isArray(ops)) throw invalid(\"ops must be an array\");\n\t\t\trows = await Promise.all(ops.map(async (op, index) => {\n\t\t\t\ttry {\n\t\t\t\t\tconst parsed = await parseCanonicalOperation(op);\n\t\t\t\t\tif (parsed.body.origin_device_id !== deviceId) {\n\t\t\t\t\t\tthrow new Error(\"origin_device_id does not match authenticated X-Device-Id\");\n\t\t\t\t\t}\n\t\t\t\t\treturn parsed;\n\t\t\t\t} catch (error) {\n\t\t\t\t\tthrow invalid(`ops[${index}] ${error instanceof Error ? error.message : String(error)}`);\n\t\t\t\t}\n\t\t\t}));\n\t\t} catch (error) {\n\t\t\tif (error instanceof Error && error.message.startsWith(INVALID_OPS_PREFIX)) {\n\t\t\t\treturn { refused: true, error: error.message };\n\t\t\t}\n\t\t\tif (isDeviceLimitError(error)) return { refused: true, error: DEVICE_LIMIT_ERROR };\n\t\t\tthrow error;\n\t\t}","sourceCodeStart":359,"sourceCodeEnd":395,"githubUrl":"https://github.com/thedotmack/claude-mem/blob/d8bc9755e74915e5c3b999181e10a67c889bce2a/workers/sync-hub/src/do/SyncHub.ts#L359-L395","documentation":"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.","triggerScenarios":"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.","commonSituations":"Client sends { op: {...} } instead of { ops: [...] }, older client version with a different payload schema, hand-rolled test harness posting the wrong shape.","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"],"exampleFix":"// before\nbody: JSON.stringify({ ops: singleOp })\n// after\nbody: JSON.stringify({ ops: Array.isArray(singleOp) ? singleOp : [singleOp] })","handlingStrategy":"validation","validationCode":"function assertOps(ops: unknown): PushOp[] {\n  if (!Array.isArray(ops)) throw new Error('ops must be an array');\n  return ops;\n}","typeGuard":"const isPushOpArray = (v: unknown): v is PushOp[] => Array.isArray(v);","tryCatchPattern":"try {\n  return await hub.push(deviceId, ops);\n} catch (e) {\n  if (String(e).includes('ops must be an array')) return pushNormalized(Array.isArray(ops) ? ops : [ops]);\n  throw e;\n}","preventionTips":["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"],"tags":["validation","sync","input-validation"],"backgroundTag":"invalid-argument-value","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"}