/** * Guards for on-demand `ensureTable()` DDL so the common already-migrated path * takes NO `ACCESS EXCLUSIVE` lock on Postgres. * * Lives in its own module (like `./widen-columns.js`) so stores can import it * without every `vi.mock("../db/client.js")` test needing to stub it: the * helpers resolve `isPostgres()` / `getDbExec()` through `client.js`, so a test * that mocks the client to SQLite (`isPostgres: () => false`) makes the * Postgres-only existence checks no-ops automatically. * * ## Why * * `ensureTable()` runs once per process on first DB touch. In a long-lived Node * server the cost is paid once and is invisible. In a Netlify `-background` * function the process is fresh, so this is the FIRST touch — and the * `CREATE TABLE`/`ALTER TABLE ... ADD COLUMN` DDL it issues takes an * `ACCESS EXCLUSIVE` lock on the shared Neon Postgres database. Behind a * concurrent connection that already holds a conflicting lock, that DDL can * block ~indefinitely (observed >16s hangs in the bg worker vs ~1s inline). * * The tables/columns are essentially always already present in production, so * the DDL is redundant in the hot path. These helpers let a store cheaply check * `information_schema` first and only issue DDL when something is actually * missing — and when DDL must run, wrap it in a short `lock_timeout` so a * contended lock fails fast instead of hanging. * * All of this is Postgres-only behaviour gated on `isPostgres()`. On SQLite * (local dev) there is no such lock problem, so callers keep their existing * behaviour there. */ import { type DbExec } from "./client.js"; /** * True when running against Postgres AND the given table already exists in the * `public` schema. Returns `false` on SQLite (callers gate their own behaviour * there), for invalid identifiers, or when `information_schema` is unreadable — * the conservative answer "not known to exist" makes the caller fall through to * its idempotent `CREATE TABLE IF NOT EXISTS`, preserving today's behaviour. * * This is a plain read (no lock), so it never blocks on an `ACCESS EXCLUSIVE` * lock the way `CREATE`/`ALTER` would. */ export declare function pgTableExists(table: string, injectedClient?: DbExec, dialectIsPostgres?: boolean): Promise; /** * True when running against Postgres AND the given column already exists on the * given table in the `public` schema. Returns `false` on SQLite, for invalid * identifiers, or when `information_schema` is unreadable. * * Plain read — no lock taken. */ export declare function pgColumnExists(table: string, column: string, injectedClient?: DbExec): Promise; /** * True when running against Postgres AND an index with the given name already * exists in the `public` schema. Returns `false` on SQLite, for invalid * identifiers, or when `pg_indexes` is unreadable. * * `CREATE INDEX` (without CONCURRENTLY) takes a `SHARE` lock that blocks * writes, so on a fresh background-worker process behind a concurrent * connection this can hang just like a `CREATE`/`ALTER` would; checking first * skips the lock on the already-migrated hot path. */ export declare function pgIndexExists(indexName: string, injectedClient?: DbExec, dialectIsPostgres?: boolean): Promise; /** * Probe → guarded-DDL → re-probe sequence for one piece of on-demand schema. * * This is the single safe primitive every `ensureTable()` should use so a * swallowed `lock_timeout` can NEVER poison a store's init memo with missing * schema. The flow: * * 1. `probe()` — cheap, lock-free existence check (pgTableExists / etc.). * Already present → return `false` (nothing to do; the hot path takes NO * lock). * 2. Missing → run `ddl` through `runGuardedDdl` (bounded `lock_timeout`). * - DDL completed → return `true`. * - DDL was swallowed by a lock-timeout (`runGuardedDdl` returned `false`) * → RE-PROBE. A concurrent connection virtually always created the * object meanwhile, so the re-probe usually now reports it present → * return `true`. But if it is STILL missing, the required schema does * NOT exist and we must NOT let the caller memoize success — so THROW. * The caller's `_initPromise` rejects and the next call retries instead * of running forever against absent schema. * * On SQLite the probe is always `false` (helpers no-op there) and the SQLite * branch of each store keeps its own create-then-catch behaviour, so this is a * Postgres-path primitive in practice. Returns `true` when the object exists * after this call (either pre-existing-after-timeout-race or freshly created), * `false` only when it already existed up front (no DDL issued). */ export declare function ensureSchemaObject(options: { /** Lock-free existence check; `true` ⇒ already present, skip DDL. */ probe: () => Promise; /** DDL to run only when `probe()` reports the object missing. */ ddl: string; /** Human-readable name of what's being ensured, for the error message. */ label: string; /** Forwarded to `runGuardedDdl`. */ lockTimeout?: string; /** Injectable client for tests. */ injectedClient?: DbExec; /** Dialect override for injected per-app clients; defaults to the global. */ dialectIsPostgres?: boolean; }): Promise; /** * Convenience wrapper: ensure a TABLE exists (probe via `pgTableExists`). * No-op-returns `false` on SQLite (probe is always false there) — callers run * the SQLite create on their own branch, so this is used on the Postgres path. */ export declare function ensureTableExists(table: string, createSql: string, options?: { lockTimeout?: string; injectedClient?: DbExec; dialectIsPostgres?: boolean; }): Promise; /** * Convenience wrapper: ensure a COLUMN exists (probe via `pgColumnExists`). * `addColumnSql` should be the full `ALTER TABLE … ADD COLUMN IF NOT EXISTS …`. */ export declare function ensureColumnExists(table: string, column: string, addColumnSql: string, options?: { lockTimeout?: string; injectedClient?: DbExec; }): Promise; /** * Convenience wrapper: ensure an INDEX exists (probe via `pgIndexExists`). * `createIndexSql` should be the full `CREATE INDEX IF NOT EXISTS …`. */ export declare function ensureIndexExists(indexName: string, createIndexSql: string, options?: { lockTimeout?: string; injectedClient?: DbExec; dialectIsPostgres?: boolean; }): Promise; /** True when an error looks like a Postgres `lock_timeout` (SQLSTATE 55P03). */ export declare function isLockTimeoutError(err: unknown): boolean; /** * Run a DDL statement that MUST execute (the schema is actually missing), with * a short `lock_timeout` so a contended `ACCESS EXCLUSIVE` lock fails fast * instead of hanging the whole process. * * Postgres path: wrap the DDL in an explicit transaction and set * `SET LOCAL lock_timeout` so the timeout is scoped to THIS transaction only * and reset automatically on COMMIT/ROLLBACK — it never leaks onto the pooled * (session-reused) connection the way a bare `SET lock_timeout` would. If the * DbExec has no `transaction` (shouldn't happen for Postgres, but defensively), * fall back to a session `SET` + `RESET` in a finally. A lock-timeout error is * swallowed: the table/column is virtually always already correct by the time a * contended boot retries, and the caller's memoization should still resolve so * the path isn't retried in a tight loop. Any non-lock-timeout error rethrows. * * SQLite path: no lock problem — just run the DDL directly. * * @returns `true` if the DDL ran to completion, `false` if it was skipped due to * a lock-timeout (so the caller can decide whether to log). */ export declare function runGuardedDdl(ddl: string, options?: { lockTimeout?: string; injectedClient?: DbExec; dialectIsPostgres?: boolean; }): Promise; //# sourceMappingURL=ddl-guard.d.ts.map