@ultimat3/db
Advanced tools
| // Single responsibility: the slice of `Bun.SQL` this package uses, declared structurally, and the | ||
| // lazy lookup of the global that provides it. Reached through a function so importing the client | ||
| // never touches `Bun` at module evaluation — the CLI imports it to print help. | ||
| import { dbUnavailable } from './errors'; | ||
| /** One connection pinned out of `Bun.SQL`'s pool, released back by hand. */ | ||
| export interface BunSqlReserved { | ||
| unsafe(text: string, values?: readonly unknown[]): Promise<unknown>; | ||
| release(): void; | ||
| } | ||
| /** The slice of `Bun.SQL` we use. Declared structurally so this package has no dependency. */ | ||
| export interface BunSqlDriver { | ||
| unsafe(text: string, values?: readonly unknown[]): Promise<unknown>; | ||
| reserve(): Promise<BunSqlReserved>; | ||
| close(options?: { readonly timeout?: number }): Promise<void>; | ||
| } | ||
| export type BunSqlFactory = new ( | ||
| url: string, | ||
| options?: Readonly<Record<string, unknown>>, | ||
| ) => BunSqlDriver; | ||
| export function bunSqlFactory(): BunSqlFactory { | ||
| const host = globalThis as unknown as { readonly Bun?: { readonly SQL?: unknown } }; | ||
| const factory = host.Bun?.SQL; | ||
| if (typeof factory !== 'function') { | ||
| throw dbUnavailable('Bun.SQL is unavailable — this package requires Bun >= 1.3'); | ||
| } | ||
| return factory as BunSqlFactory; | ||
| } |
| // Single responsibility: turning `DATABASE_URL` plus a resolved pool profile into the connection | ||
| // string the driver opens — the libpq `options` merge and the `application_name` label. Split from | ||
| // `client.ts` because which settings reach a connection is a rule, not a step of connecting. | ||
| import { describeValue } from '@ultimat3/core'; | ||
| import { dbUnavailable } from './errors'; | ||
| import { declaresLibpqOption, mergeLibpqOptions } from './libpq-options'; | ||
| import type { PoolProfile } from './pool-profile'; | ||
| export interface ConnectionUrlOptions { | ||
| readonly url?: string | undefined; | ||
| readonly applicationName?: string | undefined; | ||
| } | ||
| export function connectionUrl(options: ConnectionUrlOptions, profile: PoolProfile): string { | ||
| const raw = options.url ?? process.env['DATABASE_URL']; | ||
| if (raw === undefined || raw === '') { | ||
| throw dbUnavailable('DATABASE_URL is not set, so there is no database to connect to'); | ||
| } | ||
| let url: URL; | ||
| try { | ||
| url = new URL(raw); | ||
| } catch (error) { | ||
| // The SHAPE of the rejected value, never the value. A connection string is | ||
| // `user:password@host` by construction, and this `cause` is the boot log line AND the `--json` | ||
| // payload — the logger redacts `fields` by key, so a password baked into a message has no key | ||
| // left to redact it by. `@ultimat3/core`'s `defineEnv` reached the identical conclusion about | ||
| // the identical variable (`packages/core/src/env.ts`); this is the same rule at the second | ||
| // reader, not a second rule. The value still has a printer: `x env check`, through | ||
| // `maskedEnvValues`. | ||
| throw dbUnavailable(`DATABASE_URL is not a valid url: received ${describeValue(raw)}`, error); | ||
| } | ||
| // libpq `options` is the portable way to pin a statement timeout for every pooled connection — | ||
| // MERGED into the operator's own, never assigned over it, and emitted for every role including | ||
| // the two whose bound is 0. `set` here dropped a `?options=-c search_path=app` on `web`, `sync`, | ||
| // `worker` and `scheduler` and kept it on `migrate` and `replicator`, so the role that runs the | ||
| // migrations and the role that serves the traffic read different schemas. 0 is a value, not a | ||
| // silence: it is `migrate` saying it may take as long as it takes, and left unsaid a server-side | ||
| // `alter database ... set statement_timeout` kills the one role that must outlive it. | ||
| // `application_name` is a LABEL, not a bound: 'ultimate' is a DEFAULT, and a default may not | ||
| // overwrite what the operator wrote — `?application_name=billing-api` is the filter their | ||
| // `pg_stat_activity` query, their pooler rule and their audit rule all match on, and losing it | ||
| // is silent. Both spellings count, or the URL parameter and a `-c application_name=` in | ||
| // `options` disagree and which one the backend honours is argument order nobody here measured. | ||
| const named = options.applicationName; | ||
| const settings: Record<string, string> = { | ||
| statement_timeout: String(profile.statementTimeoutMs), | ||
| }; | ||
| const inOptions = declaresLibpqOption(url.searchParams.get('options'), 'application_name'); | ||
| // An explicit `applicationName` is a deliberate call by the role that opened the pool, so it | ||
| // wins. Only then is the setting named to the merge, and only when the operator wrote the other | ||
| // spelling: `mergeLibpqOptions` drops their assignment before appending, so the two cannot | ||
| // disagree — and a URL with no assignment in it keeps the exact `options` it always had. | ||
| if (named !== undefined && inOptions) settings['application_name'] = named; | ||
| const declared = url.searchParams.has('application_name') || inOptions; | ||
| url.searchParams.set('options', mergeLibpqOptions(url.searchParams.get('options'), settings)); | ||
| if (named !== undefined) url.searchParams.set('application_name', named); | ||
| else if (!declared) url.searchParams.set('application_name', 'ultimate'); | ||
| return url.toString(); | ||
| } |
| // Single responsibility: the database's readiness answer — one `select 1` timed and reported, never | ||
| // thrown. Split from `client.ts` because a probe's report is a different job from opening a pool, | ||
| // and every role's `/readyz` reads this one and nothing else of the client. | ||
| import { renderThrowable } from '@ultimat3/core'; | ||
| import { baseClient, type DbClient } from './client'; | ||
| import { sql } from './sql'; | ||
| /** Named `Db*` because `@ultimat3/core` already exports a `HealthReport` for the lifecycle. */ | ||
| export interface DbHealthReport { | ||
| readonly ok: boolean; | ||
| readonly latencyMs: number; | ||
| readonly error?: string | undefined; | ||
| } | ||
| /** Backs `/readyz` for every role. Never throws — the probe wants a report, not an exception. */ | ||
| export async function checkDb(client: DbClient = baseClient()): Promise<DbHealthReport> { | ||
| const started = performance.now(); | ||
| try { | ||
| await client.query(sql`select 1`); | ||
| return { ok: true, latencyMs: Math.round(performance.now() - started) }; | ||
| } catch (error) { | ||
| return { | ||
| ok: false, | ||
| latencyMs: Math.round(performance.now() - started), | ||
| // `renderThrowable`, never `error.message`: the probe wants a report, and a render that | ||
| // throws is an exception out of `/readyz` — the one caller that cannot catch it. | ||
| error: renderThrowable(error), | ||
| }; | ||
| } | ||
| } |
| // Single responsibility: the `X_DB_DRIFT` constructor — the error form of the finding | ||
| // `drift-findings.ts`'s `unexpectedColumn` reports, thrown where a caller has no report to hand | ||
| // back. Split out of `errors.ts` for the import it needs and nothing else: the fix line puts a | ||
| // column name into a shell command, so it screens through `shellInertIdentifier` (`sql.ts`) — | ||
| // and `sql.ts` imports `errors.ts`, so this cannot live there without a cycle around the module | ||
| // whose evaluation registers every code. The code is still declared, titled and registered in | ||
| // `errors.ts`, exactly as `migration-errors.ts` and `invariant-errors.ts` are. | ||
| import { DbError } from './errors'; | ||
| import { shellInertIdentifier } from './sql'; | ||
| /** | ||
| * The contract's pinned wording. Mirror of `@ultimat3/entity`'s `dbDrift()` — keep in sync; that | ||
| * one screens the column through the same `@ultimat3/db` export, so the two lines are the same | ||
| * text on both sides of the tier seam. | ||
| * | ||
| * The column name is the CATALOG's, so it is data: whoever can add a column picks the text that | ||
| * lands here, and `x db gen "add C"` puts it inside SHELL DOUBLE QUOTES, where `$(…)` and a | ||
| * backtick substitute before `x` is reached at all. The argument is a migration DESCRIPTION and | ||
| * not an identifier, so no quoted form makes a hostile name safe to pass — a name the screen | ||
| * refuses is left OUT of the command rather than escaped into it. The command still runs and | ||
| * still generates the migration; the name is read off `cause` and `meta`, which are prose nobody | ||
| * pastes. | ||
| */ | ||
| export const dbDrift = (tableName: string, columnName: string): DbError => | ||
| new DbError({ | ||
| code: 'X_DB_DRIFT', | ||
| cause: `table "${tableName}" has column "${columnName}" not present in any migration`, | ||
| fix: | ||
| shellInertIdentifier(columnName) === null | ||
| ? 'x db gen "add the column named in this error" # its name carries a backtick, a ' + | ||
| 'dollar sign, a quote, a backslash or whitespace, so it is in the cause and not in ' + | ||
| 'this command' | ||
| : `x db gen "add ${columnName}"`, | ||
| meta: { table: tableName, column: columnName }, | ||
| }); |
| // TEST-ONLY. The two builders every drift suite compares with — a `TableDescription` of text | ||
| // columns and the `SchemaDescription` around it. One copy, because three suites arguing about | ||
| // schemas built differently would each be judging a different fixture. Never exported from | ||
| // `index.ts`. | ||
| import type { SchemaDescription, TableDescription } from './introspect'; | ||
| export const table = (name: string, columns: readonly string[]): TableDescription => ({ | ||
| schema: 'public', | ||
| name, | ||
| columns: columns.map((column, index) => ({ | ||
| name: column, | ||
| dataType: 'text', | ||
| nullable: true, | ||
| default: null, | ||
| position: index + 1, | ||
| })), | ||
| primaryKey: ['id'], | ||
| indexes: [], | ||
| foreignKeys: [], | ||
| }); | ||
| export const schema = (...tables: readonly TableDescription[]): SchemaDescription => ({ tables }); |
| // Single responsibility: the five numbers a Postgres pool runs on — the per-role defaults, the one | ||
| // environment override an operator may layer over them, and the screen every resolved profile | ||
| // passes. Split from `client.ts`, which now owns connecting and nothing about sizing. | ||
| import { assert, type Role, resolveRole } from '@ultimat3/core'; | ||
| import { poolMaxInvalid } from './errors'; | ||
| export interface PoolProfile { | ||
| readonly max: number; | ||
| /** 0 disables the timeout — only `migrate`, which is allowed to take as long as it takes. */ | ||
| readonly statementTimeoutMs: number; | ||
| readonly idleTimeoutMs: number; | ||
| /** | ||
| * How long a statement may **wait for a lock** before `55P03`, distinct from how long it may run. | ||
| * 0 everywhere but `migrate`, which is the only role that takes `ACCESS EXCLUSIVE`: an `alter | ||
| * table` queued behind a long `SELECT` puts every later query on that table behind it too, | ||
| * because Postgres' lock queue is FIFO — and `migrate` runs `statement_timeout = 0`, so nothing | ||
| * else would ever end the wait. Read by `migrate()` as a `SET LOCAL`, never by the pool. | ||
| */ | ||
| readonly lockTimeoutMs: number; | ||
| /** | ||
| * How long `reserve()` may wait for a free connection before `X_DB_POOL_EXHAUSTED`. 0 waits | ||
| * forever, which is what a run-once role wants and what a request-serving one must never do: | ||
| * queueing turns exhaustion into a hang, `/readyz`'s `select 1` joins the same queue, the kubelet | ||
| * kills the pod, and the replacement inherits the same saturated database. | ||
| */ | ||
| readonly acquireTimeoutMs: number; | ||
| } | ||
| /** Sized per role because the failure modes differ: RPS bursts vs. queue depth vs. run-once. */ | ||
| export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({ | ||
| web: { | ||
| max: 20, | ||
| statementTimeoutMs: 10_000, | ||
| idleTimeoutMs: 30_000, | ||
| lockTimeoutMs: 0, | ||
| acquireTimeoutMs: 5_000, | ||
| }, | ||
| sync: { | ||
| max: 10, | ||
| statementTimeoutMs: 10_000, | ||
| idleTimeoutMs: 60_000, | ||
| lockTimeoutMs: 0, | ||
| acquireTimeoutMs: 5_000, | ||
| }, | ||
| worker: { | ||
| max: 8, | ||
| statementTimeoutMs: 120_000, | ||
| idleTimeoutMs: 30_000, | ||
| lockTimeoutMs: 0, | ||
| acquireTimeoutMs: 10_000, | ||
| }, | ||
| scheduler: { | ||
| max: 2, | ||
| statementTimeoutMs: 15_000, | ||
| idleTimeoutMs: 60_000, | ||
| lockTimeoutMs: 0, | ||
| acquireTimeoutMs: 10_000, | ||
| }, | ||
| // `migrate` waits: its pool is `max: 1` and the advisory-lock pin holds it for the whole run, so | ||
| // a deadline here would refuse the migration's own session. The wait that needed bounding is the | ||
| // advisory lock's, and `MIGRATION_LOCK_WAIT_MS` bounds it. | ||
| migrate: { | ||
| max: 1, | ||
| statementTimeoutMs: 0, | ||
| idleTimeoutMs: 10_000, | ||
| lockTimeoutMs: 3_000, | ||
| acquireTimeoutMs: 0, | ||
| }, | ||
| replicator: { | ||
| max: 4, | ||
| statementTimeoutMs: 0, | ||
| idleTimeoutMs: 60_000, | ||
| lockTimeoutMs: 0, | ||
| acquireTimeoutMs: 0, | ||
| }, | ||
| }); | ||
| export function poolProfileFor(role: Role = resolveRole()): PoolProfile { | ||
| return POOL_PROFILES[role]; | ||
| } | ||
| /** The one pool knob an operator can turn without a rebuild. Layered over the role default. */ | ||
| export const POOL_MAX_ENV = 'DATABASE_POOL_MAX'; | ||
| /** | ||
| * `DATABASE_POOL_MAX`, or nothing. `POOL_PROFILES` is frozen into the build, so before this the | ||
| * only way to change a fleet's connection count was to ship a new image — and 400 `web` pods at | ||
| * `max: 20` is 8,000 backends against a `max_connections` of 450. An unparseable value **refuses** | ||
| * rather than falling back: a fleet that ignored the number it was given is the failure the | ||
| * variable exists to prevent, and it would only be found in `pg_stat_activity` at 3am. | ||
| */ | ||
| export function poolMaxFromEnv(): Partial<PoolProfile> { | ||
| const raw = process.env[POOL_MAX_ENV]; | ||
| if (raw === undefined || raw.trim() === '') return {}; | ||
| const max = Number(raw); | ||
| if (!Number.isSafeInteger(max) || max < 1) throw poolMaxInvalid(raw); | ||
| return { max }; | ||
| } | ||
| /** | ||
| * The five numbers a pool runs on, screened on the MERGED profile — an override is spread over a | ||
| * role default the caller never restated, so the resolved object is the only one that can be | ||
| * judged. Every one of them is a plausible `Number(process.env.…)`, which is `NaN` for an unset | ||
| * variable and not nullish, so `??` and the spread both keep it. None of the five then fails | ||
| * loudly: `idleTimeout: NaN` goes to `Bun.SQL`, `statement_timeout=NaN` goes into the libpq | ||
| * options string for the SERVER to reject on connect, and a timer given `NaN` fires at 1ms in this | ||
| * Bun — so a pool with free connections reports itself exhausted. `0` stays legal for the three | ||
| * budgets that document it as "no bound"; `max` is at least one connection, or nothing can run. | ||
| */ | ||
| export function assertPoolProfile(profile: PoolProfile): PoolProfile { | ||
| const whole = (option: string, value: number, min: 0 | 1): void => { | ||
| assert( | ||
| Number.isSafeInteger(value) && value >= min, | ||
| `pool profile ${option} is ${String(value)}; it must be a whole number of ${min === 1 ? 'at least 1' : '0 or more, where 0 is the documented "no bound"'}`, | ||
| `pass a whole number for ${option} in createPostgresClient({ profile }), and parse an environment value first — Number(process.env.DATABASE_${option.toUpperCase()} ?? '') is NaN when the variable is unset`, | ||
| ); | ||
| }; | ||
| whole('max', profile.max, 1); | ||
| whole('statementTimeoutMs', profile.statementTimeoutMs, 0); | ||
| whole('idleTimeoutMs', profile.idleTimeoutMs, 0); | ||
| whole('lockTimeoutMs', profile.lockTimeoutMs, 0); | ||
| whole('acquireTimeoutMs', profile.acquireTimeoutMs, 0); | ||
| return profile; | ||
| } |
| // Single responsibility: pinning a connection out of the pool under the profile's acquire deadline, | ||
| // and giving back a reservation that arrives after the deadline has passed. Split from `client.ts`, | ||
| // which now asks for a pin rather than owning what "waited too long" means. | ||
| import type { BunSqlDriver, BunSqlReserved } from './bun-sql'; | ||
| import { poolAcquireTimeout } from './errors'; | ||
| import type { PoolProfile } from './pool-profile'; | ||
| /** | ||
| * `pool.reserve()` under a deadline. Without one an exhausted pool does not fail, it **queues** — | ||
| * so a slow endpoint filling all 20 slots turns every later request, `/readyz`'s `select 1` | ||
| * included, into a wait with no end and no error, and the pod is killed for being unready rather | ||
| * than answering 503 for the requests it cannot serve. | ||
| * | ||
| * The losing reservation is released, never dropped: the pool hands out a connection whenever one | ||
| * frees, deadline or no deadline, and a pin nobody holds is a connection nobody gets back. That is | ||
| * the whole reason this is not a bare `Promise.race`. | ||
| */ | ||
| export async function reserveWithin( | ||
| pool: Pick<BunSqlDriver, 'reserve'>, | ||
| profile: PoolProfile, | ||
| ): Promise<BunSqlReserved> { | ||
| const budget = profile.acquireTimeoutMs; | ||
| if (budget <= 0) return pool.reserve(); | ||
| let timer: ReturnType<typeof setTimeout> | undefined; | ||
| let expired = false; | ||
| const pending = pool.reserve(); | ||
| try { | ||
| return await Promise.race([ | ||
| pending, | ||
| new Promise<never>((_resolve, reject) => { | ||
| timer = setTimeout(() => { | ||
| expired = true; | ||
| reject(poolAcquireTimeout(budget, profile.max)); | ||
| }, budget); | ||
| // The deadline must not be what keeps a finished process alive. | ||
| timer.unref?.(); | ||
| }), | ||
| ]); | ||
| } finally { | ||
| if (timer !== undefined) clearTimeout(timer); | ||
| // Attached unconditionally so a rejection arriving after we gave up is handled, not unhandled. | ||
| void pending.then( | ||
| (late) => { | ||
| if (expired) late.release(); | ||
| }, | ||
| () => undefined, | ||
| ); | ||
| } | ||
| } |
+2
-2
| { | ||
| "name": "@ultimat3/db", | ||
| "version": "16.0.0", | ||
| "version": "17.0.0", | ||
| "description": "Postgres access, transactions, migrations and drift detection", | ||
@@ -34,3 +34,3 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "16.0.0" | ||
| "@ultimat3/core": "17.0.0" | ||
| }, | ||
@@ -37,0 +37,0 @@ "peerDependencies": { |
+3
-2
@@ -29,2 +29,3 @@ # @ultimat3/db 🐘 | ||
| | `sql` / `raw` / `identifier` / `literal` / `join` | fragment builders | | ||
| | `shellInertIdentifier()` | `As of 2026-08-26`: a quoted identifier that is also inert wherever a human PASTES it — or `null`. The one screen a catalog name goes through before it reaches a `fix:`. `identifier()` answers about SQL and **accepts** a backtick and a `$`, which are exactly what a shell substitutes inside double quotes, so a column called `$(id)` inside `x db gen "add $(id)"` runs `id` on paste | | ||
| | `db()` / `baseClient()` / `setDbClient()` | the ambient client; `db()` returns the open tx if any | | ||
@@ -220,5 +221,5 @@ | `DbTx.origin` | `As of 2026-08`: the client the transaction was **opened on** — `options.client` or `baseClient()`, never the reservation it runs statements through. `@ultimat3/entity` compares a pinned repository's client against it, so a pinned repo joins its own shard's transaction instead of being refused | | ||
| |---|---|---| | ||
| | live column, no migration | `table "T" has column "C" not present in any migration` | `x db gen "add C"` | | ||
| | live column, no migration | `table "T" has column "C" not present in any migration` | `x db gen "add C"` — the name goes through `shellInertIdentifier()`, and one it refuses is left OUT of the command rather than escaped into it (`x db gen "add the undeclared column"`, the name in the cause) | | ||
| | migrated column, not live | `table "T" is missing column "C" that migrations declare` | `x db migrate` | | ||
| | live table, no migration | `table "T" is not present in any migration` | `x db gen "add T"` | | ||
| | live table, no migration | `table "T" is not present in any migration` | a `create table if not exists` in a migration, then `x db migrate` — or `drop table` in `psql` where nothing owns it. Never `x db gen`, which diffs a table nothing declares against nothing and writes no file (issue #345). The name goes through `shellInertIdentifier()`, and one it refuses leaves the fix as prose | | ||
| | migrated table, not live | `table "T" is declared by migrations but does not exist` | `x db migrate` | | ||
@@ -225,0 +226,0 @@ | index rebuilt differently | `index "I" on "T" covers (…)` / `is unique` / `is descending` / `is partial`, `not what migrations declare` | `x db migrate` | |
+14
-232
@@ -1,15 +0,18 @@ | ||
| // Single responsibility: the Postgres connection and the ambient `db()` handle. Pool size and | ||
| // statement timeout are chosen by runtime ROLE — a `worker` draining a queue must not size its | ||
| // pool like a `web` process behind a CDN. `Bun.SQL` is reached lazily so importing this module | ||
| // never opens a socket (the CLI imports it to print help). | ||
| // Single responsibility: the pooled Postgres client and the ambient `db()` handle — one statement | ||
| // funnel, the reserved-connection pin, and the process-wide client every repository reaches | ||
| // through. Sizing lives in `pool-profile.ts`, the connection string in `connection-url.ts`, the | ||
| // `Bun.SQL` slice in `bun-sql.ts`, so importing this module never opens a socket. | ||
| import { type Role, renderThrowable, resolveRole } from '@ultimat3/core'; | ||
| import { type Role, resolveRole } from '@ultimat3/core'; | ||
| import { statementAttribution } from './attribution'; | ||
| import { type BunSqlDriver, type BunSqlReserved, bunSqlFactory } from './bun-sql'; | ||
| import { connectionUrl } from './connection-url'; | ||
| // Deliberate cycle, the same shape as `client.ts ⇄ transaction.ts`: nothing here is referenced at | ||
| // module evaluation, and both sides are `function` declarations, so hoisting covers the TDZ. | ||
| import { defaultClient } from './default-client'; | ||
| import { DbError, dbUnavailable, driverError, poolAcquireTimeout, poolMaxInvalid } from './errors'; | ||
| import { DbError, driverError } from './errors'; | ||
| import { expectedQueryLoopReason } from './expected-loop'; | ||
| import { declaresLibpqOption, mergeLibpqOptions } from './libpq-options'; | ||
| import { statementObserver } from './observe'; | ||
| import { assertPoolProfile, type PoolProfile, poolProfileFor } from './pool-profile'; | ||
| import { reserveWithin } from './pool-reserve'; | ||
| import { type SqlFragment, sql } from './sql'; | ||
@@ -44,119 +47,2 @@ import { withStatementSpan } from './statement-span'; | ||
| export interface PoolProfile { | ||
| readonly max: number; | ||
| /** 0 disables the timeout — only `migrate`, which is allowed to take as long as it takes. */ | ||
| readonly statementTimeoutMs: number; | ||
| readonly idleTimeoutMs: number; | ||
| /** | ||
| * How long a statement may **wait for a lock** before `55P03`, distinct from how long it may run. | ||
| * 0 everywhere but `migrate`, which is the only role that takes `ACCESS EXCLUSIVE`: an `alter | ||
| * table` queued behind a long `SELECT` puts every later query on that table behind it too, | ||
| * because Postgres' lock queue is FIFO — and `migrate` runs `statement_timeout = 0`, so nothing | ||
| * else would ever end the wait. Read by `migrate()` as a `SET LOCAL`, never by the pool. | ||
| */ | ||
| readonly lockTimeoutMs: number; | ||
| /** | ||
| * How long `reserve()` may wait for a free connection before `X_DB_POOL_EXHAUSTED`. 0 waits | ||
| * forever, which is what a run-once role wants and what a request-serving one must never do: | ||
| * queueing turns exhaustion into a hang, `/readyz`'s `select 1` joins the same queue, the kubelet | ||
| * kills the pod, and the replacement inherits the same saturated database. | ||
| */ | ||
| readonly acquireTimeoutMs: number; | ||
| } | ||
| /** Sized per role because the failure modes differ: RPS bursts vs. queue depth vs. run-once. */ | ||
| export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({ | ||
| web: { | ||
| max: 20, | ||
| statementTimeoutMs: 10_000, | ||
| idleTimeoutMs: 30_000, | ||
| lockTimeoutMs: 0, | ||
| acquireTimeoutMs: 5_000, | ||
| }, | ||
| sync: { | ||
| max: 10, | ||
| statementTimeoutMs: 10_000, | ||
| idleTimeoutMs: 60_000, | ||
| lockTimeoutMs: 0, | ||
| acquireTimeoutMs: 5_000, | ||
| }, | ||
| worker: { | ||
| max: 8, | ||
| statementTimeoutMs: 120_000, | ||
| idleTimeoutMs: 30_000, | ||
| lockTimeoutMs: 0, | ||
| acquireTimeoutMs: 10_000, | ||
| }, | ||
| scheduler: { | ||
| max: 2, | ||
| statementTimeoutMs: 15_000, | ||
| idleTimeoutMs: 60_000, | ||
| lockTimeoutMs: 0, | ||
| acquireTimeoutMs: 10_000, | ||
| }, | ||
| // `migrate` waits: its pool is `max: 1` and the advisory-lock pin holds it for the whole run, so | ||
| // a deadline here would refuse the migration's own session. The wait that needed bounding is the | ||
| // advisory lock's, and `MIGRATION_LOCK_WAIT_MS` bounds it. | ||
| migrate: { | ||
| max: 1, | ||
| statementTimeoutMs: 0, | ||
| idleTimeoutMs: 10_000, | ||
| lockTimeoutMs: 3_000, | ||
| acquireTimeoutMs: 0, | ||
| }, | ||
| replicator: { | ||
| max: 4, | ||
| statementTimeoutMs: 0, | ||
| idleTimeoutMs: 60_000, | ||
| lockTimeoutMs: 0, | ||
| acquireTimeoutMs: 0, | ||
| }, | ||
| }); | ||
| export function poolProfileFor(role: Role = resolveRole()): PoolProfile { | ||
| return POOL_PROFILES[role]; | ||
| } | ||
| /** The one pool knob an operator can turn without a rebuild. Layered over the role default. */ | ||
| export const POOL_MAX_ENV = 'DATABASE_POOL_MAX'; | ||
| /** | ||
| * `DATABASE_POOL_MAX`, or nothing. `POOL_PROFILES` is frozen into the build, so before this the | ||
| * only way to change a fleet's connection count was to ship a new image — and 400 `web` pods at | ||
| * `max: 20` is 8,000 backends against a `max_connections` of 450. An unparseable value **refuses** | ||
| * rather than falling back: a fleet that ignored the number it was given is the failure the | ||
| * variable exists to prevent, and it would only be found in `pg_stat_activity` at 3am. | ||
| */ | ||
| export function poolMaxFromEnv(): Partial<PoolProfile> { | ||
| const raw = process.env[POOL_MAX_ENV]; | ||
| if (raw === undefined || raw.trim() === '') return {}; | ||
| const max = Number(raw); | ||
| if (!Number.isSafeInteger(max) || max < 1) throw poolMaxInvalid(raw); | ||
| return { max }; | ||
| } | ||
| /** One connection pinned out of `Bun.SQL`'s pool, released back by hand. */ | ||
| interface BunSqlReserved { | ||
| unsafe(text: string, values?: readonly unknown[]): Promise<unknown>; | ||
| release(): void; | ||
| } | ||
| /** The slice of `Bun.SQL` we use. Declared structurally so this package has no dependency. */ | ||
| interface BunSqlDriver { | ||
| unsafe(text: string, values?: readonly unknown[]): Promise<unknown>; | ||
| reserve(): Promise<BunSqlReserved>; | ||
| close(options?: { readonly timeout?: number }): Promise<void>; | ||
| } | ||
| type BunSqlFactory = new (url: string, options?: Readonly<Record<string, unknown>>) => BunSqlDriver; | ||
| function bunSqlFactory(): BunSqlFactory { | ||
| const host = globalThis as unknown as { readonly Bun?: { readonly SQL?: unknown } }; | ||
| const factory = host.Bun?.SQL; | ||
| if (typeof factory !== 'function') { | ||
| throw dbUnavailable('Bun.SQL is unavailable — this package requires Bun >= 1.3'); | ||
| } | ||
| return factory as BunSqlFactory; | ||
| } | ||
| export interface PostgresClientOptions { | ||
@@ -169,42 +55,2 @@ readonly url?: string | undefined; | ||
| function connectionUrl(options: PostgresClientOptions, profile: PoolProfile): string { | ||
| const raw = options.url ?? process.env['DATABASE_URL']; | ||
| if (raw === undefined || raw === '') { | ||
| throw dbUnavailable('DATABASE_URL is not set, so there is no database to connect to'); | ||
| } | ||
| let url: URL; | ||
| try { | ||
| url = new URL(raw); | ||
| } catch (error) { | ||
| throw dbUnavailable(`DATABASE_URL is not a valid url: ${raw}`, error); | ||
| } | ||
| // libpq `options` is the portable way to pin a statement timeout for every pooled connection — | ||
| // MERGED into the operator's own, never assigned over it, and emitted for every role including | ||
| // the two whose bound is 0. `set` here dropped a `?options=-c search_path=app` on `web`, `sync`, | ||
| // `worker` and `scheduler` and kept it on `migrate` and `replicator`, so the role that runs the | ||
| // migrations and the role that serves the traffic read different schemas. 0 is a value, not a | ||
| // silence: it is `migrate` saying it may take as long as it takes, and left unsaid a server-side | ||
| // `alter database ... set statement_timeout` kills the one role that must outlive it. | ||
| // `application_name` is a LABEL, not a bound: 'ultimate' is a DEFAULT, and a default may not | ||
| // overwrite what the operator wrote — `?application_name=billing-api` is the filter their | ||
| // `pg_stat_activity` query, their pooler rule and their audit rule all match on, and losing it | ||
| // is silent. Both spellings count, or the URL parameter and a `-c application_name=` in | ||
| // `options` disagree and which one the backend honours is argument order nobody here measured. | ||
| const named = options.applicationName; | ||
| const settings: Record<string, string> = { | ||
| statement_timeout: String(profile.statementTimeoutMs), | ||
| }; | ||
| const inOptions = declaresLibpqOption(url.searchParams.get('options'), 'application_name'); | ||
| // An explicit `applicationName` is a deliberate call by the role that opened the pool, so it | ||
| // wins. Only then is the setting named to the merge, and only when the operator wrote the other | ||
| // spelling: `mergeLibpqOptions` drops their assignment before appending, so the two cannot | ||
| // disagree — and a URL with no assignment in it keeps the exact `options` it always had. | ||
| if (named !== undefined && inOptions) settings['application_name'] = named; | ||
| const declared = url.searchParams.has('application_name') || inOptions; | ||
| url.searchParams.set('options', mergeLibpqOptions(url.searchParams.get('options'), settings)); | ||
| if (named !== undefined) url.searchParams.set('application_name', named); | ||
| else if (!declared) url.searchParams.set('application_name', 'ultimate'); | ||
| return url.toString(); | ||
| } | ||
| function rowsOf<T>(result: unknown): readonly T[] { | ||
@@ -224,45 +70,2 @@ return Array.isArray(result) ? (result as readonly T[]) : []; | ||
| /** | ||
| * `pool.reserve()` under a deadline. Without one an exhausted pool does not fail, it **queues** — | ||
| * so a slow endpoint filling all 20 slots turns every later request, `/readyz`'s `select 1` | ||
| * included, into a wait with no end and no error, and the pod is killed for being unready rather | ||
| * than answering 503 for the requests it cannot serve. | ||
| * | ||
| * The losing reservation is released, never dropped: the pool hands out a connection whenever one | ||
| * frees, deadline or no deadline, and a pin nobody holds is a connection nobody gets back. That is | ||
| * the whole reason this is not a bare `Promise.race`. | ||
| */ | ||
| async function reserveWithin( | ||
| pool: Pick<BunSqlDriver, 'reserve'>, | ||
| profile: PoolProfile, | ||
| ): Promise<BunSqlReserved> { | ||
| const budget = profile.acquireTimeoutMs; | ||
| if (budget <= 0) return pool.reserve(); | ||
| let timer: ReturnType<typeof setTimeout> | undefined; | ||
| let expired = false; | ||
| const pending = pool.reserve(); | ||
| try { | ||
| return await Promise.race([ | ||
| pending, | ||
| new Promise<never>((_resolve, reject) => { | ||
| timer = setTimeout(() => { | ||
| expired = true; | ||
| reject(poolAcquireTimeout(budget, profile.max)); | ||
| }, budget); | ||
| // The deadline must not be what keeps a finished process alive. | ||
| timer.unref?.(); | ||
| }), | ||
| ]); | ||
| } finally { | ||
| if (timer !== undefined) clearTimeout(timer); | ||
| // Attached unconditionally so a rejection arriving after we gave up is handled, not unhandled. | ||
| void pending.then( | ||
| (late) => { | ||
| if (expired) late.release(); | ||
| }, | ||
| () => undefined, | ||
| ); | ||
| } | ||
| } | ||
| export interface PostgresClient extends ReservableClient { | ||
@@ -277,3 +80,6 @@ readonly profile: PoolProfile; | ||
| const role = options.role ?? resolveRole(); | ||
| const profile: PoolProfile = { ...poolProfileFor(role), ...(options.profile ?? {}) }; | ||
| const profile: PoolProfile = assertPoolProfile({ | ||
| ...poolProfileFor(role), | ||
| ...(options.profile ?? {}), | ||
| }); | ||
| let driver: BunSqlDriver | undefined; | ||
@@ -462,25 +268,1 @@ | ||
| } | ||
| /** Named `Db*` because `@ultimat3/core` already exports a `HealthReport` for the lifecycle. */ | ||
| export interface DbHealthReport { | ||
| readonly ok: boolean; | ||
| readonly latencyMs: number; | ||
| readonly error?: string | undefined; | ||
| } | ||
| /** Backs `/readyz` for every role. Never throws — the probe wants a report, not an exception. */ | ||
| export async function checkDb(client: DbClient = baseClient()): Promise<DbHealthReport> { | ||
| const started = performance.now(); | ||
| try { | ||
| await client.query(sql`select 1`); | ||
| return { ok: true, latencyMs: Math.round(performance.now() - started) }; | ||
| } catch (error) { | ||
| return { | ||
| ok: false, | ||
| latencyMs: Math.round(performance.now() - started), | ||
| // `renderThrowable`, never `error.message`: the probe wants a report, and a render that | ||
| // throws is an exception out of `/readyz` — the one caller that cannot catch it. | ||
| error: renderThrowable(error), | ||
| }; | ||
| } | ||
| } |
@@ -6,3 +6,4 @@ // Single responsibility: the client `baseClient()` builds when an app installed none — the primary | ||
| import { createPostgresClient, type DbClient, poolMaxFromEnv } from './client'; | ||
| import { createPostgresClient, type DbClient } from './client'; | ||
| import { poolMaxFromEnv } from './pool-profile'; | ||
| import { replicatedClient } from './replica-client'; | ||
@@ -9,0 +10,0 @@ |
@@ -17,2 +17,3 @@ // Single responsibility: what a schema difference is CALLED and what its `fix:` line says — one | ||
| import type { Migration } from './migrate'; | ||
| import { shellInertIdentifier } from './sql'; | ||
@@ -45,2 +46,11 @@ export type DriftKind = | ||
| /** | ||
| * The one `fix:` here whose second layer no quoting closes. `x db gen "add C"` puts the column | ||
| * inside SHELL DOUBLE QUOTES, where `$(…)` and a backtick substitute before `x` is reached at all | ||
| * — and the argument is a migration DESCRIPTION, not an identifier, so there is no quoted form | ||
| * that would make a hostile name safe to pass. A name `shellInertIdentifier` (`sql.ts`) refuses | ||
| * is therefore left out of the command rather than escaped into it: the command still runs and | ||
| * still generates the migration, and the name is read off `cause` and `column`, which are prose | ||
| * nobody pastes. | ||
| */ | ||
| export function unexpectedColumn(table: string, column: string): DriftDifference { | ||
@@ -53,3 +63,8 @@ return { | ||
| cause: `table "${table}" has column "${column}" not present in any migration`, | ||
| fix: `x db gen "add ${column}"`, | ||
| fix: | ||
| shellInertIdentifier(column) === null | ||
| ? 'x db gen "add the undeclared column" # the live column name carries a backtick, a ' + | ||
| 'dollar sign, a quote, a backslash or whitespace, so it is in the cause and not in ' + | ||
| 'this command' | ||
| : `x db gen "add ${column}"`, | ||
| }; | ||
@@ -88,2 +103,4 @@ } | ||
| const clause = liveNullable ? 'set not null' : 'drop not null'; | ||
| const relation = shellInertIdentifier(table); | ||
| const attribute = shellInertIdentifier(column); | ||
| return { | ||
@@ -96,9 +113,30 @@ kind: 'changed-column', | ||
| : `table "${table}" forbids NULL in column "${column}" that migrations declare nullable`, | ||
| // Both identifiers are the catalog's, so both go through the one screen. A refusal names the | ||
| // column as the thing it could not spell, which is what tells this line apart from | ||
| // `missingCheck`'s refusal in a report that carries both. | ||
| fix: | ||
| `alter table "${table}" alter column "${column}" ${clause}; # in a new migration` + | ||
| (liveNullable ? ' — backfill the existing NULLs first' : ''), | ||
| relation === null || attribute === null | ||
| ? `${clause} on the column named in this difference, in a new migration, then ` + | ||
| 'x db migrate — its table or column name carries a backtick, a dollar sign, a quote, ' + | ||
| 'a backslash or whitespace, so no statement here can spell it' | ||
| : `alter table ${relation} alter column ${attribute} ${clause}; # in a new migration` + | ||
| (liveNullable ? ' — backfill the existing NULLs first' : ''), | ||
| }; | ||
| } | ||
| /** | ||
| * The `fix:` names the two edits that actually resolve this, and neither is `x db gen` (issue | ||
| * #345). That command diffs the ENTITY REGISTRY against the newest snapshot, and a table nothing | ||
| * declares is absent from both sides of that diff — so it wrote an EMPTY migration, and the | ||
| * generator's own empty-diff branch writes no file at all, leaving the reader with nothing to run | ||
| * and the same finding on the next deploy. | ||
| * | ||
| * What is left once `@ultimat3/cli`'s `acceptCreatedTables` has run is a table no migration's SQL | ||
| * creates and no entity declares — so either a migration should claim it (`if not exists`, because | ||
| * the relation is already there, and `x db migrate` then accepts a table its own SQL creates), or | ||
| * nothing owns it and it should not be in this schema. No migration PATH is named: where an app | ||
| * keeps its migrations is the CLI's fact, not this package's. | ||
| */ | ||
| export function unexpectedTable(table: string): DriftDifference { | ||
| const name = shellInertIdentifier(table); | ||
| return { | ||
@@ -109,3 +147,10 @@ kind: 'unexpected-table', | ||
| cause: `table "${table}" is not present in any migration`, | ||
| fix: `x db gen "add ${table}"`, | ||
| fix: | ||
| name === null | ||
| ? 'claim it in a migration with create table if not exists, or drop it by hand — its ' + | ||
| 'table name carries a backtick, a dollar sign, a quote, a backslash or whitespace, so ' + | ||
| 'no statement here can spell it' | ||
| : `put a create table if not exists ${name} (…) statement in a migration — x db migrate ` + | ||
| 'then accepts a table its own SQL creates — or, if nothing owns it, run ' + | ||
| `drop table ${name}; inside psql "$DATABASE_URL"`, | ||
| }; | ||
@@ -183,2 +228,4 @@ } | ||
| export function missingCheck(table: string, check: CheckDescription): DriftDifference { | ||
| const relation = shellInertIdentifier(table); | ||
| const constraint = shellInertIdentifier(check.name); | ||
| return { | ||
@@ -193,5 +240,15 @@ kind: 'missing-check', | ||
| // migration` leaves the second half to be guessed. | ||
| // | ||
| // Both NAMES go through the one screen; the EXPRESSION deliberately does not, and cannot. It | ||
| // is a predicate, so no screen could accept `status in ('draft', 'published')` and reject a | ||
| // second statement — and it is the DECLARED side's own text, out of the author's migration, | ||
| // where both names are the catalog's and a sidecar's. Narrower than "this line is safe", and | ||
| // it is the honest claim. | ||
| fix: | ||
| `alter table "${table}" add constraint "${check.name}" ` + | ||
| `check (${check.expression}); # in a new migration, then x db migrate`, | ||
| relation === null || constraint === null | ||
| ? 'add the constraint named in this difference back in a new migration, then ' + | ||
| 'x db migrate — its table or constraint name carries a backtick, a dollar sign, a ' + | ||
| 'quote, a backslash or whitespace, so no statement here can spell it' | ||
| : `alter table ${relation} add constraint ${constraint} ` + | ||
| `check (${check.expression}); # in a new migration, then x db migrate`, | ||
| }; | ||
@@ -198,0 +255,0 @@ } |
+0
-9
@@ -288,11 +288,2 @@ // The database layer's stable error codes. Every factory produces the exact command that | ||
| /** The contract's pinned wording. Mirror of `@ultimat3/entity`'s `dbDrift()` — keep in sync. */ | ||
| export const dbDrift = (tableName: string, columnName: string): DbError => | ||
| new DbError({ | ||
| code: 'X_DB_DRIFT', | ||
| cause: `table "${tableName}" has column "${columnName}" not present in any migration`, | ||
| fix: `x db gen "add ${columnName}"`, | ||
| meta: { table: tableName, column: columnName }, | ||
| }); | ||
| export const sqlUnsafe = (received: string, position: number): DbError => | ||
@@ -299,0 +290,0 @@ new DbError({ |
+15
-15
@@ -26,4 +26,2 @@ // Single responsibility: the public API of @ultimat3/db. Explicit named exports only — | ||
| DbConnection, | ||
| DbHealthReport, | ||
| PoolProfile, | ||
| PostgresClient, | ||
@@ -33,15 +31,7 @@ PostgresClientOptions, | ||
| } from './client'; | ||
| export { | ||
| baseClient, | ||
| checkDb, | ||
| createPostgresClient, | ||
| db, | ||
| isReservable, | ||
| POOL_MAX_ENV, | ||
| POOL_PROFILES, | ||
| poolProfileFor, | ||
| setDbClient, | ||
| } from './client'; | ||
| export { baseClient, createPostgresClient, db, isReservable, setDbClient } from './client'; | ||
| export type { ColumnDefaultLike } from './column-default'; | ||
| export { defaultExpression } from './column-default'; | ||
| export type { DbHealthReport } from './db-health'; | ||
| export { checkDb } from './db-health'; | ||
| export { defaultClient, REPLICA_URL_ENV } from './default-client'; | ||
@@ -67,2 +57,3 @@ export type { DestructiveKind, DestructiveStatement } from './destructive'; | ||
| } from './drift'; | ||
| export { dbDrift } from './drift-errors'; | ||
| export type { | ||
@@ -82,3 +73,2 @@ ColumnDescriptionLike, | ||
| DbError, | ||
| dbDrift, | ||
| dbNotImplemented, | ||
@@ -176,2 +166,4 @@ dbUnavailable, | ||
| export { branchPglite, pgliteBranchDir } from './pglite-branch'; | ||
| export type { PoolProfile } from './pool-profile'; | ||
| export { POOL_MAX_ENV, POOL_PROFILES, poolProfileFor } from './pool-profile'; | ||
| export type { ReadOnlyQueryOptions, ReadOnlyQueryResult } from './readonly-query'; | ||
@@ -193,3 +185,11 @@ export { READONLY_TIMEOUT_MS, readOnlyQuery } from './readonly-query'; | ||
| export type { SqlFragment } from './sql'; | ||
| export { identifier, isSqlFragment, join, literal, raw, sql } from './sql'; | ||
| export { | ||
| identifier, | ||
| isSqlFragment, | ||
| join, | ||
| literal, | ||
| raw, | ||
| shellInertIdentifier, | ||
| sql, | ||
| } from './sql'; | ||
| export { stripSqlNoise } from './sql-noise'; | ||
@@ -196,0 +196,0 @@ export type { DbSqlStateCode } from './sqlstate'; |
+19
-11
@@ -6,10 +6,4 @@ // Single responsibility: apply pending migrations and keep the `x_migrations` ledger honest. | ||
| import { appVersion } from '@ultimat3/core'; | ||
| import { | ||
| baseClient, | ||
| type DbClient, | ||
| type DbConnection, | ||
| isReservable, | ||
| poolProfileFor, | ||
| } from './client'; | ||
| import { appVersion, finiteCount } from '@ultimat3/core'; | ||
| import { baseClient, type DbClient, type DbConnection, isReservable } from './client'; | ||
| import { refuseDependentViews } from './dependent-view'; | ||
@@ -19,2 +13,3 @@ import { expectedQueryLoop } from './expected-loop'; | ||
| import { migrateConcurrent, migrationConflict, rollbackStepsInvalid } from './migration-errors'; | ||
| import { poolProfileFor } from './pool-profile'; | ||
| import { raw, sql } from './sql'; | ||
@@ -242,2 +237,5 @@ import { SQLSTATE, sqlState } from './sqlstate'; | ||
| if (row?.locked === true) return; | ||
| // `waitMs` is screened at both call sites: `NaN - elapsed` is `NaN`, `NaN <= 0` is false | ||
| // and `Bun.sleep(Math.min(POLL, NaN))` does not sleep — so an unbounded wait is not what a | ||
| // non-finite one produced, a tight spin re-taking `pg_try_advisory_lock` was. | ||
| const remaining = waitMs - (performance.now() - started); | ||
@@ -323,3 +321,13 @@ if (remaining <= 0) { | ||
| function migrationLockTimeoutMs(explicit: number | undefined): number { | ||
| return explicit ?? poolProfileFor('migrate').lockTimeoutMs; | ||
| // Screened here, where BOTH `migrate()` and `rollback()` resolve it, and before either takes the | ||
| // advisory lock. `lockWaitMs` beside it was screened from the start and this one was not, so | ||
| // `Number(process.env.…)` on an unset variable travelled all the way to `SET LOCAL lock_timeout | ||
| // = NaN`, which the server rejects inside the migration's own transaction — the option that was | ||
| // wrong appears nowhere in what the deploy prints. `finiteCount`, never a second bound checker. | ||
| return finiteCount( | ||
| 'migrate', | ||
| 'lockTimeoutMs', | ||
| explicit ?? poolProfileFor('migrate').lockTimeoutMs, | ||
| 0, | ||
| ); | ||
| } | ||
@@ -336,3 +344,3 @@ | ||
| options.lock !== false, | ||
| options.lockWaitMs ?? MIGRATION_LOCK_WAIT_MS, | ||
| finiteCount('migrate', 'lockWaitMs', options.lockWaitMs ?? MIGRATION_LOCK_WAIT_MS, 0), | ||
| async (session) => { | ||
@@ -427,3 +435,3 @@ await ensureLedger(session); | ||
| options.lock !== false, | ||
| options.lockWaitMs ?? MIGRATION_LOCK_WAIT_MS, | ||
| finiteCount('migrate', 'lockWaitMs', options.lockWaitMs ?? MIGRATION_LOCK_WAIT_MS, 0), | ||
| async (session) => { | ||
@@ -430,0 +438,0 @@ const ledger = await readLedger(session); |
@@ -6,2 +6,3 @@ // Single responsibility: LAYER 2 of `db.query`'s defence-in-depth — an isolated `BEGIN READ | ||
| import { finiteCount } from '@ultimat3/core'; | ||
| import { baseClient, type DbClient, type DbConnection, isReservable } from './client'; | ||
@@ -93,2 +94,22 @@ import { multipleStatements } from './errors'; | ||
| // Decided before anything is opened, for the reason `rollback({ steps })` screens before it | ||
| // takes the advisory lock: a value this build cannot honour is not a fact about the pool. It | ||
| // was computed after `reserve()` and after `BEGIN READ ONLY`, so an unbounded `timeoutMs` | ||
| // against an exhausted pool answered with the pool's error — or waited for a connection it was | ||
| // never going to use — in place of the `X_INVARIANT` naming the option that was wrong. | ||
| // | ||
| // Clamped and truncated to an integer: the result is a JS number the caller never touches | ||
| // as text, so there is nothing here for `raw()` to inject — `SET LOCAL` can't bind `$n` | ||
| // parameters, which is why this can't go through `sql` the normal, parameterised way. | ||
| // REFUSED rather than normalised: `NaN` used to take the default silently, so a config typo | ||
| // ran under a timeout nobody wrote. Only an explicit 0 disables the layer, which is why the | ||
| // floor is 0 and not 1; the hour ceiling is a clamp on a number that IS one. | ||
| const asked = finiteCount( | ||
| 'readonlyQuery', | ||
| 'timeoutMs', | ||
| options.timeoutMs ?? READONLY_TIMEOUT_MS, | ||
| 0, | ||
| ); | ||
| const ms = Math.min(3_600_000, asked); | ||
| const client = options.client ?? baseClient(); | ||
@@ -109,10 +130,2 @@ // A pooled BEGIN that lands on a different physical connection than the query that follows is | ||
| // Clamped and truncated to an integer: the result is a JS number the caller never touches | ||
| // as text, so there is nothing here for `raw()` to inject — `SET LOCAL` can't bind `$n` | ||
| // parameters, which is why this can't go through `sql` the normal, parameterised way. | ||
| // NaN normalises to the default first: only an explicit 0 disables the timeout, and | ||
| // `Math.min(3_600_000, NaN)` is NaN, which would fail `ms > 0` and silently skip the layer. | ||
| const asked = options.timeoutMs ?? READONLY_TIMEOUT_MS; | ||
| const requested = Number.isNaN(asked) ? READONLY_TIMEOUT_MS : asked; | ||
| const ms = Math.max(0, Math.min(3_600_000, Math.trunc(requested))); | ||
| if (ms > 0) { | ||
@@ -119,0 +132,0 @@ // `LOCAL`, so the setting dies with the transaction — one agent read must not re-time |
@@ -6,3 +6,3 @@ // Single responsibility: one `DbClient` over a primary and a read replica. It decides nothing about | ||
| import { type Clock, logger, renderThrowable, systemClock } from '@ultimat3/core'; | ||
| import { type Clock, finiteCount, logger, renderThrowable, systemClock } from '@ultimat3/core'; | ||
| import { type DbClient, type DbConnection, isReservable, type ReservableClient } from './client'; | ||
@@ -57,4 +57,17 @@ import { isPlainRead } from './replica-route'; | ||
| const clock = options.clock ?? systemClock; | ||
| const limit = options.breakerFailures ?? BREAKER_FAILURES; | ||
| const cooldown = options.breakerCooldownMs ?? BREAKER_COOLDOWN_MS; | ||
| // Both are comparisons and nothing else — `consecutiveFailures >= limit` opens the breaker, | ||
| // `monotonic() < parkedUntil` holds it open — so a `NaN` in either is a breaker that never trips | ||
| // and never parks, with every read still going to the replica that is failing. | ||
| const limit = finiteCount( | ||
| 'replicatedClient', | ||
| 'breakerFailures', | ||
| options.breakerFailures ?? BREAKER_FAILURES, | ||
| 1, | ||
| ); | ||
| const cooldown = finiteCount( | ||
| 'replicatedClient', | ||
| 'breakerCooldownMs', | ||
| options.breakerCooldownMs ?? BREAKER_COOLDOWN_MS, | ||
| 1, | ||
| ); | ||
| let replicaCount = 0; | ||
@@ -61,0 +74,0 @@ let primaryCount = 0; |
+36
-0
@@ -135,2 +135,38 @@ // Single responsibility: build parameterised SQL. String interpolation is how every SQL | ||
| /** | ||
| * The two characters a shell substitutes INSIDE double quotes. Rejected before `identifier` is | ||
| * consulted at all, because `identifier` accepts both. | ||
| */ | ||
| const SHELL_ACTIVE = /[`$]/; | ||
| /** | ||
| * A quoted identifier that is ALSO inert wherever a human pastes it — or `null` for a name no line | ||
| * built here may spell. The one screen a catalog name goes through before it reaches a `fix:`. | ||
| * | ||
| * TWO layers, and `identifier` closes only the first. It answers about SQL: it refuses `"`, `\` | ||
| * and whitespace, and ACCEPTS a backtick and a `$` — `SAFE_IDENTIFIER` above allows `$` on its | ||
| * fast path — which are exactly the two characters `$(…)` and a command substitution are built | ||
| * from. A column called `$(id)` inside `x db gen "add $(id)"` therefore RUNS `id` the moment its | ||
| * reader pastes the line, and a screen reusing `identifier` unchanged would ship a green suite | ||
| * over that. | ||
| * | ||
| * The name is DATA at every caller: `create table "x""; drop table users; --" ("id" int)` is legal | ||
| * DDL, so whoever can create a table or a column picks the text that lands in a `fix:`. A refusal | ||
| * is degraded to prose by its caller and never escaped — the argument to `x db gen` is a migration | ||
| * DESCRIPTION, not an identifier, so there is no quoted form that makes a hostile name safe to | ||
| * pass, and a fix naming no command beats one running a second command the reader never read. | ||
| * | ||
| * `'` is deliberately NOT refused: it is legal in an identifier and inert both in a psql session | ||
| * and inside shell double quotes. The price of the two that ARE refused is a legal `a$b` losing | ||
| * its executable fix line, which is prose in place of something nobody read running. | ||
| */ | ||
| export function shellInertIdentifier(name: string): string | null { | ||
| if (SHELL_ACTIVE.test(name)) return null; | ||
| try { | ||
| return identifier(name).text; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| /** | ||
| * A quoted string literal Postgres reads IDENTICALLY under both settings of | ||
@@ -137,0 +173,0 @@ * `standard_conforming_strings`. Utility and DDL statements (`CREATE DATABASE`, `COMMENT ON`, |
Sorry, the diff of this file is too big to display
574828
3.98%70
11.11%8051
3.13%443
0.23%+ Added
- Removed
Updated