{"record":{"id":"67c58b51d2da6c50","repo":"Hmbown/CodeWhale","slug":"supabase-bad-row","errorCode":"supabase-bad-row","errorMessage":"supabase-bad-row","messagePattern":"supabase-bad-row","errorType":"error_code","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"web/lib/cloud-facts.ts","lineNumber":272,"sourceCode":"  let base: URL;\n  try {\n    base = new URL(env.SUPABASE_URL ?? \"\");\n    if (base.protocol !== \"https:\" || base.username || base.password || base.search || base.hash || !key || !isPublishableKey(key)) throw new Error();\n  } catch { throw new Error(\"supabase-not-configured\"); }\n  const controller = new AbortController();\n  const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? SUPABASE_TIMEOUT_MS);\n  try {\n    const url = new URL(`${base.href.replace(/\\/+$/, \"\")}/rest/v1/facts_current`);\n    url.search = new URLSearchParams({ channel: `eq.${channel}`, scope: \"eq.global\", select: \"channel,release_id,facts_version,schema_version,envelope_version,applies_to,key_id,payload_b64,sig_b64,sigs,payload_sha256,published_at,not_after\", limit: \"1\" }).toString();\n    const res = await (opts.fetchImpl ?? fetch)(url, {\n      headers: { apikey: key!, Authorization: `Bearer ${key}`, Accept: \"application/json\" },\n      signal: controller.signal,\n      redirect: \"error\",\n    });\n    if (!res.ok) { await res.body?.cancel(); throw new Error(`supabase-http-${res.status}`); }\n    const bytes = await readBoundedBody(res, MAX_ENVELOPE_BYTES);\n    const rows: unknown = JSON.parse(new TextDecoder(\"utf-8\", { fatal: true }).decode(bytes));\n    if (!Array.isArray(rows) || rows.length > 1) throw new Error(\"supabase-bad-row\");\n    if (rows.length === 0) return null;\n    if (!isObject(rows[0]) || !isEnvelope(envelopeFromRow(rows[0] as unknown as FactsCurrentRow))) throw new Error(\"supabase-bad-row\");\n    return rows[0] as unknown as FactsCurrentRow;\n  } finally { clearTimeout(timer); }\n}\n\nasync function kvGet(env: CloudFactsEnv, channel: string): Promise<unknown> {\n  if (!env.CURATED_KV) return null;\n  try {\n    const body = await env.CURATED_KV.get(`${KV_PREFIX}${channel}`, \"stream\");\n    if (!body) return null;\n    const bytes = await readBoundedBody({ body, headers: new Headers() }, MAX_ENVELOPE_BYTES);\n    return JSON.parse(new TextDecoder(\"utf-8\", { fatal: true }).decode(bytes));\n  } catch { return null; }\n}\n\nexport async function resolveCloudFacts(channel: string, env: CloudFactsEnv, opts: ResolveOptions = {}): Promise<CloudFactsResult> {\n  if (!isValidChannel(channel)) return { kind: \"none\" };","sourceCodeStart":254,"sourceCodeEnd":290,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/433685b2024e7bc4c99e1e2e326bcad39b4d9d65/web/lib/cloud-facts.ts#L254-L290","documentation":"The response body must parse as UTF-8 JSON, be an array of at most one row, and that row must be an object whose envelope (via envelopeFromRow) passes isEnvelope validation. Anything else — non-JSON body, multiple rows, wrong shape, invalid envelope fields — throws \"supabase-bad-row\". It guards against schema drift or corrupted data in the facts_current table.","triggerScenarios":"The facts_current row's payload/signature columns were hand-edited or written by an older publisher; a non-UTF-8 body is returned; two rows share the same channel+scope (missing unique constraint); the row JSON is truncated or invalid.","commonSituations":"Schema version bump where publisher writes fields the verifier rejects; duplicate rows inserted without a unique index on (channel, scope); manual psql edits to fix a bad release; a proxy returning an HTML error page with 200.","solutions":["Query facts_current for duplicate (channel, scope='global') rows and enforce a unique index so limit=1 never silently hides extra rows.","Republish the row through the current publisher so the envelope passes isEnvelope (matching schema/envelope versions, key_id, signatures).","Decode payload_b64/sig_b64 and check payload_sha256 to find which envelope field fails validation.","If a proxy/CDN is in front of Supabase, bypass it to rule out an HTML 200 body."],"exampleFix":"// before: duplicates possible\nCREATE TABLE facts_current (...);\n\n// after\nCREATE UNIQUE INDEX facts_current_channel_scope ON facts_current (channel, scope);","handlingStrategy":"try-catch","validationCode":"// Publisher-side guard before writing a row:\nif (!isEnvelope(envelope)) throw new Error('refusing to publish invalid envelope');","typeGuard":"function isFactsRow(v) {\n  return typeof v === 'object' && v !== null &&\n    typeof v.channel === 'string' && typeof v.payload_b64 === 'string' &&\n    typeof v.sig_b64 === 'string' && typeof v.payload_sha256 === 'string';\n}","tryCatchPattern":"try {\n  const row = await fetchCurrentRow(channel, env);\n} catch (e) {\n  if (e.message === 'supabase-bad-row') {\n    log.error('facts row failed validation', { channel });\n    return null; // or fall back to last-known-good cached facts\n  }\n  throw e;\n}","preventionTips":["Enforce a unique index on (channel, scope) in facts_current.","Validate envelopes at publish time, not just read time.","Never hand-edit rows in psql; republish through the publisher.","Add a CI job that fetches a sample row and runs the same isEnvelope validation."],"tags":["validation","supabase","data-integrity"],"backgroundTag":"unexpected-response-shape","analyzedSha":"433685b2024e7bc4c99e1e2e326bcad39b4d9d65","analyzedAt":"2026-09-15T12:24:24.634Z","contentChangedAt":"2026-09-15T12:24:24.634Z","schemaVersion":2},"datasetVersion":"2026-09-22T11:17:16.035Z"}