supabase-js try/catch Doesn't Catch Errors: Check { error }
Conclusion supabase-js query methods never throw on network or PostgREST failure. The contract template's current-version resolver branched only on whether data was present, without ever looking at the error that came
Conclusion
supabase-js query methods never throw on network or PostgREST failure. The contract template's current-version resolver branched only on whether data was present, without ever looking at the error that came back alongside it. That meant a transient read failure couldn't be told apart from "no row has been published yet" — and when that return value fed a version-freeze decision, it could freeze an old version the customer never agreed to. The fix: pull the read into its own function, check error explicitly, and fail closed (return null, freeze nothing) when the read itself failed.
What this can look like
The affected code is kimiteras-portal's contract-template resolver (contract-template-store.ts). The advertiser terms start as a JSON file shipped with the code (v1, the baseline, immutable). When an operator publishes a revision from the admin screen, a new version (v2 and up) is inserted into contract_template_versions in the database. Two functions shared the "DB if present, baseline otherwise" logic: getContractTemplate, which returns the body text, and getContractMeta, which returns metadata such as the version number and whether an auto-renewal clause applies.
Before the fix, getContractMeta looked like this:
export async function getContractMeta(
kind: string
): Promise<ContractMeta | null> {
try {
const admin = createSupabaseAdminClient();
const { data } = await admin
.from("contract_template_versions")
.select(SELECT)
.eq("kind", kind)
.eq("is_current", true)
.maybeSingle();
if (data) return metaOf(data as Row);
} catch (e) {
console.error("[contract-template] current meta read failed:", e);
}
return getContractMetaBaseline(kind);
}
Only data is destructured from maybeSingle()'s return value — error isn't even pulled out. When the read fails, data simply comes back null; nothing is thrown. So the code has no way to tell whether data is null because no version has been published yet, or because the read itself transiently failed — either case falls through to the same return getContractMetaBaseline(kind) (the baseline v1 metadata).
In the signing flow, freezeSignedTermsSourceVersion sets signed_terms_source_version from this return value, and applyAutoRenewFromSignedTerms later decides whether to turn on auto_renew based on that version's has_auto_renew_clause. The baseline v1 can carry an auto-renewal clause while the current version an operator published (say, v3) doesn't. Every time the read transiently failed, v1 — not the version the customer actually read and agreed to — became the version frozen as the source of truth, opening a path to auto-renewal turning on with no contractual basis.
Cause
supabase-js query methods don't reject their promise. Network errors and PostgREST error responses are both expressed through the same {data, error} return shape. If the caller looks only at data and ignores error, a failure is silently treated as just another case of "no data" — a normal-looking outcome. try/catch is JavaScript's exception mechanism; it can't catch a failure that was never thrown. The mere presence of a catch block gave a false sense that this function was handling failure.
The fix
The current-version read was pulled out into a dedicated function, readCurrentRow, that explicitly checks error and returns {row, failed}:
async function readCurrentRow(
kind: string
): Promise<{ row: Row | null; failed: boolean }> {
try {
const admin = createSupabaseAdminClient();
const { data, error } = await admin
.from("contract_template_versions")
.select(SELECT)
.eq("kind", kind)
.order("version", { ascending: false })
.limit(1)
.maybeSingle();
if (error) {
console.error("[contract-template] current read failed:", error.message);
return { row: null, failed: true };
}
return { row: (data as Row | null) ?? null, failed: false };
} catch (e) {
console.error("[contract-template] current read threw:", e);
return { row: null, failed: true };
}
}
getContractMeta now branches on that failed flag:
export async function getContractMeta(
kind: string
): Promise<ContractMeta | null> {
const { row, failed } = await readCurrentRow(kind);
if (row) return metaOf(row);
if (failed) return null;
return getContractMetaBaseline(kind);
}
When the read fails (failed === true), it returns null and does no version freeze at all — fail-closed. Since the caller, freezeSignedTermsSourceVersion, already does nothing when meta is null, this change alone closes the path where a transient failure kept freezing the wrong version. Only once "no row exists (not yet published)" and "the read failed" were distinguishable did falling back to baseline actually mean what it was supposed to mean: only when nothing had been published.
getContractTemplate, which returns the body text, still falls back to baseline on a read failure — deliberately. "The body can't be shown" would stop the application and signing screens outright, a different trade-off that the team chose to keep, so it's intentionally asymmetric with the fail-closed getContractMeta.
Lesson
This function already had a try/catch at commit time, complete with a console.error in the catch block — whoever wrote it believed they were handling failure. But because supabase-js expresses failure through a return value rather than an exception, the moment only data was destructured, error was silently discarded and that catch block became dead code that could never run. What made this a real defect rather than a minor oversight was that the function's return value fed directly into a version-freeze decision tied to billing.
Originally published at tech.rebounder.jp.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.