Files
0pt/lib/db.ts
adminandClaude Opus 5 db868f4bdf
CI (SIAX Cloud) / security (push) Successful in 14s
Security Scan (SIAX Cloud) / security (push) Successful in 21s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / quality (push) Successful in 44s
CI (SIAX Cloud) / integration (push) Successful in 45s
CI (SIAX Cloud) / integration (pull_request) Successful in 27s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m45s
fix(prod): close legacy storage fallbacks in orders/subscriptions/blobs
The `resolveStoreMode` kill switch (Master-DoD §3/§10) covered the newer
`src/repositories/*` stores but not two older paths, both of which are
live production execution paths today.

1. LEGACY NEXT.JS STORES. `lib/orders-store.ts` and
   `lib/subscriptions-store.ts` predate `src/platform/dependency-mode.ts`
   and called `getSql()` directly, silently falling back to module-global
   `Map`s when no database is configured. For orders, payments and
   subscriptions that is not a degraded mode, it is invisible loss of
   money-bearing state: the API returns 201 and the row evaporates on the
   next restart or on any other replica. The live 0pt deployment
   (Coolify app 54) runs NODE_ENV=production with DATABASE_URL,
   POSTGRES_URL and POSTGRES_PRISMA_URL all unset — so this is the
   behavior in production right now, across 7 API routes.

   These now go through `getSqlForDurableStore()`, which reuses the SAME
   `resolveStoreMode` decision every `src/repositories/*` store already
   uses rather than re-implementing a second production check. The reuse
   is load-bearing: `isProductionRuntime` deliberately exempts
   `NEXT_PHASE=phase-production-build`, because `next build` runs
   page-data collection under NODE_ENV=production while serving nothing.
   A naive `process.env.NODE_ENV === "production"` test here would have
   failed the BUILD instead of protecting a request path.

2. LOCALBLOBSTORE. It writes document bytes to a container-local disk
   while `document-capture-service` registers an artifact pointer
   claiming the bytes are stored — durable object storage silently
   swapped for ephemeral disk. It is the default blob store for both the
   capture and extraction services. In production it now raises
   DEPENDENCY_UNAVAILABLE (capability blocked: no ST0RE bytes path
   exists yet) instead of fabricating success. Asserted per-operation,
   not in the constructor, so merely wiring the services is not fatal —
   only actually persisting or reading bytes is.

Evidence: full suite 956 passed / 184 skipped, typecheck clean,
dependency-cruiser 0 errors, lint identical to main's pre-existing
baseline (17 problems, none in changed files). Both guards asserted in
both directions and mutation-tested: bypassing resolveStoreMode -> 2
failed; dropping the write assert -> 2 failed; dropping the read assert
-> 1 failed; all restored -> 8 passed. 7 non-test call sites.

DEPLOYMENT NOTE: merging changes no runtime (no Gitea webhook exists on
any repo; deploys are manual). Deploying this build REQUIRES
provisioning DATABASE_URL for 0pt first, or the order/subscription
routes will correctly return DEPENDENCY_UNAVAILABLE instead of silently
accepting orders into memory. That provisioning is a Simon action.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:38:51 +02:00

74 lines
3.3 KiB
TypeScript

import postgres from "postgres";
import { resolveStoreMode } from "@/src/platform/dependency-mode";
// Lazy, env-guarded Postgres client. Works with Vercel Postgres (POSTGRES_URL)
// or any DATABASE_URL. Returns null when no DB is configured so the app still
// builds/runs (with in-memory fallback) before storage is provisioned.
let _sql: ReturnType<typeof postgres> | null = null;
export function dbUrl(): string | undefined {
return process.env.DATABASE_URL || process.env.POSTGRES_URL || process.env.POSTGRES_PRISMA_URL;
}
export function hasDb(): boolean {
return Boolean(dbUrl());
}
/**
* P014 fix: `ssl` used to be hardcoded to `"require"` unconditionally. That
* is the right default for every REAL deployment target this app has
* (Vercel Postgres, managed Postgres providers), but it made `compose.dev.yaml`'s
* local Postgres container -- which speaks plain, non-TLS Postgres -- and
* ANY other throwaway/local Postgres (e.g. a restore-drill target) silently
* unreachable via this function, contradicting that file's own doc comment
* ("local-only Postgres for developers who want real persistence") and its
* own "UNVERIFIED... not smoke-tested end to end" caveat -- this is
* precisely why it was never smoke-tested: it could not have worked.
* `DATABASE_SSL` is opt-in-to-disable ONLY; absence (the default, matching
* every existing deployment's actual env) keeps the exact prior behavior.
*/
export function sslMode(): "require" | false {
const raw = process.env.DATABASE_SSL?.trim().toLowerCase();
if (raw === "false" || raw === "disable" || raw === "off" || raw === "0") return false;
return "require";
}
export function getSql() {
const url = dbUrl();
if (!url) return null;
if (!_sql) {
_sql = postgres(url, { prepare: false, idle_timeout: 20, max: 5, ssl: sslMode() });
}
return _sql;
}
/**
* Durable-store accessor for the legacy Next.js stores (`lib/orders-store.ts`,
* `lib/subscriptions-store.ts`).
*
* Those stores predate `src/platform/dependency-mode.ts` and still call
* `getSql()` directly, so when no database is configured they silently fall
* back to module-global `Map`s. For orders, payments and subscriptions that is
* not a degraded mode, it is invisible data loss: the API accepts an order,
* returns 201, and the row evaporates on the next restart or on any other
* container replica. Money-bearing state must never have a process-memory
* "success" path in production.
*
* This routes those stores through the SAME decision every `src/repositories/*`
* store already uses — `resolveStoreMode` — rather than re-implementing a
* second production check. Reusing it matters for more than tidiness:
* `isProductionRuntime` deliberately exempts `NEXT_PHASE=phase-production-build`,
* because `next build` runs page-data collection under NODE_ENV=production
* while serving nothing. A naive `process.env.NODE_ENV === "production"` test
* here would fail the BUILD instead of protecting a request path.
*
* Returns a `sql` client when a database is configured, `null` outside
* production when it is not (keeping the in-memory seam for tests/local dev),
* and throws `SiaxError("DEPENDENCY_UNAVAILABLE")` in a production runtime.
*/
export function getSqlForDurableStore(storeName: string) {
const mode = resolveStoreMode(storeName, dbUrl());
if (mode.mode === "memory") return null;
return getSql();
}