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

  1. Query facts_current for duplicate (channel, scope='global') rows and enforce a unique index so limit=1 never silently hides extra rows.
  2. Republish the row through the current publisher so the envelope passes isEnvelope (matching schema/envelope versions, key_id, signatures).
  3. Decode payload_b64/sig_b64 and check payload_sha256 to find which envelope field fails validation.
  4. 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

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


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)