Hmbown/CodeWhale · error · Error
supabase-bad-row
supabase-bad-row
Error message
supabase-bad-row
What it means
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.
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.
Example fix
// before: duplicates possible CREATE TABLE facts_current (...); // after CREATE UNIQUE INDEX facts_current_channel_scope ON facts_current (channel, scope);
Defensive patterns
Strategy: try-catch
Validate before calling
// Publisher-side guard before writing a row:
if (!isEnvelope(envelope)) throw new Error('refusing to publish invalid envelope'); Type guard
function isFactsRow(v) {
return typeof v === 'object' && v !== null &&
typeof v.channel === 'string' && typeof v.payload_b64 === 'string' &&
typeof v.sig_b64 === 'string' && typeof v.payload_sha256 === 'string';
} Try / catch
try {
const row = await fetchCurrentRow(channel, env);
} catch (e) {
if (e.message === 'supabase-bad-row') {
log.error('facts row failed validation', { channel });
return null; // or fall back to last-known-good cached facts
}
throw e;
} Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- invalid-channel
- Invalid interval for
- World requires contiguous version 1 pet buckets.
- 1
- A pinned task provider requires an explicit model
AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15).
Data as JSON: /api/errors/67c58b51d2da6c50.
Report an issue: GitHub.
Appendix: source
Thrown at web/lib/cloud-facts.ts:272
let base: URL;
try {
base = new URL(env.SUPABASE_URL ?? "");
if (base.protocol !== "https:" || base.username || base.password || base.search || base.hash || !key || !isPublishableKey(key)) throw new Error();
} catch { throw new Error("supabase-not-configured"); }
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? SUPABASE_TIMEOUT_MS);
try {
const url = new URL(`${base.href.replace(/\/+$/, "")}/rest/v1/facts_current`);
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();
const res = await (opts.fetchImpl ?? fetch)(url, {
headers: { apikey: key!, Authorization: `Bearer ${key}`, Accept: "application/json" },
signal: controller.signal,
redirect: "error",
});
if (!res.ok) { await res.body?.cancel(); throw new Error(`supabase-http-${res.status}`); }
const bytes = await readBoundedBody(res, MAX_ENVELOPE_BYTES);
const rows: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
if (!Array.isArray(rows) || rows.length > 1) throw new Error("supabase-bad-row");
if (rows.length === 0) return null;
if (!isObject(rows[0]) || !isEnvelope(envelopeFromRow(rows[0] as unknown as FactsCurrentRow))) throw new Error("supabase-bad-row");
return rows[0] as unknown as FactsCurrentRow;
} finally { clearTimeout(timer); }
}
async function kvGet(env: CloudFactsEnv, channel: string): Promise<unknown> {
if (!env.CURATED_KV) return null;
try {
const body = await env.CURATED_KV.get(`${KV_PREFIX}${channel}`, "stream");
if (!body) return null;
const bytes = await readBoundedBody({ body, headers: new Headers() }, MAX_ENVELOPE_BYTES);
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
} catch { return null; }
}
export async function resolveCloudFacts(channel: string, env: CloudFactsEnv, opts: ResolveOptions = {}): Promise<CloudFactsResult> {
if (!isValidChannel(channel)) return { kind: "none" };View on GitHub (pinned to 433685b202)