@ultimat3/db 15.0.0 → 17.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,125 @@
1
+ // Single responsibility: the five numbers a Postgres pool runs on — the per-role defaults, the one
2
+ // environment override an operator may layer over them, and the screen every resolved profile
3
+ // passes. Split from `client.ts`, which now owns connecting and nothing about sizing.
4
+
5
+ import { assert, type Role, resolveRole } from '@ultimat3/core';
6
+ import { poolMaxInvalid } from './errors';
7
+
8
+ export interface PoolProfile {
9
+ readonly max: number;
10
+ /** 0 disables the timeout — only `migrate`, which is allowed to take as long as it takes. */
11
+ readonly statementTimeoutMs: number;
12
+ readonly idleTimeoutMs: number;
13
+ /**
14
+ * How long a statement may **wait for a lock** before `55P03`, distinct from how long it may run.
15
+ * 0 everywhere but `migrate`, which is the only role that takes `ACCESS EXCLUSIVE`: an `alter
16
+ * table` queued behind a long `SELECT` puts every later query on that table behind it too,
17
+ * because Postgres' lock queue is FIFO — and `migrate` runs `statement_timeout = 0`, so nothing
18
+ * else would ever end the wait. Read by `migrate()` as a `SET LOCAL`, never by the pool.
19
+ */
20
+ readonly lockTimeoutMs: number;
21
+ /**
22
+ * How long `reserve()` may wait for a free connection before `X_DB_POOL_EXHAUSTED`. 0 waits
23
+ * forever, which is what a run-once role wants and what a request-serving one must never do:
24
+ * queueing turns exhaustion into a hang, `/readyz`'s `select 1` joins the same queue, the kubelet
25
+ * kills the pod, and the replacement inherits the same saturated database.
26
+ */
27
+ readonly acquireTimeoutMs: number;
28
+ }
29
+
30
+ /** Sized per role because the failure modes differ: RPS bursts vs. queue depth vs. run-once. */
31
+ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
32
+ web: {
33
+ max: 20,
34
+ statementTimeoutMs: 10_000,
35
+ idleTimeoutMs: 30_000,
36
+ lockTimeoutMs: 0,
37
+ acquireTimeoutMs: 5_000,
38
+ },
39
+ sync: {
40
+ max: 10,
41
+ statementTimeoutMs: 10_000,
42
+ idleTimeoutMs: 60_000,
43
+ lockTimeoutMs: 0,
44
+ acquireTimeoutMs: 5_000,
45
+ },
46
+ worker: {
47
+ max: 8,
48
+ statementTimeoutMs: 120_000,
49
+ idleTimeoutMs: 30_000,
50
+ lockTimeoutMs: 0,
51
+ acquireTimeoutMs: 10_000,
52
+ },
53
+ scheduler: {
54
+ max: 2,
55
+ statementTimeoutMs: 15_000,
56
+ idleTimeoutMs: 60_000,
57
+ lockTimeoutMs: 0,
58
+ acquireTimeoutMs: 10_000,
59
+ },
60
+ // `migrate` waits: its pool is `max: 1` and the advisory-lock pin holds it for the whole run, so
61
+ // a deadline here would refuse the migration's own session. The wait that needed bounding is the
62
+ // advisory lock's, and `MIGRATION_LOCK_WAIT_MS` bounds it.
63
+ migrate: {
64
+ max: 1,
65
+ statementTimeoutMs: 0,
66
+ idleTimeoutMs: 10_000,
67
+ lockTimeoutMs: 3_000,
68
+ acquireTimeoutMs: 0,
69
+ },
70
+ replicator: {
71
+ max: 4,
72
+ statementTimeoutMs: 0,
73
+ idleTimeoutMs: 60_000,
74
+ lockTimeoutMs: 0,
75
+ acquireTimeoutMs: 0,
76
+ },
77
+ });
78
+
79
+ export function poolProfileFor(role: Role = resolveRole()): PoolProfile {
80
+ return POOL_PROFILES[role];
81
+ }
82
+
83
+ /** The one pool knob an operator can turn without a rebuild. Layered over the role default. */
84
+ export const POOL_MAX_ENV = 'DATABASE_POOL_MAX';
85
+
86
+ /**
87
+ * `DATABASE_POOL_MAX`, or nothing. `POOL_PROFILES` is frozen into the build, so before this the
88
+ * only way to change a fleet's connection count was to ship a new image — and 400 `web` pods at
89
+ * `max: 20` is 8,000 backends against a `max_connections` of 450. An unparseable value **refuses**
90
+ * rather than falling back: a fleet that ignored the number it was given is the failure the
91
+ * variable exists to prevent, and it would only be found in `pg_stat_activity` at 3am.
92
+ */
93
+ export function poolMaxFromEnv(): Partial<PoolProfile> {
94
+ const raw = process.env[POOL_MAX_ENV];
95
+ if (raw === undefined || raw.trim() === '') return {};
96
+ const max = Number(raw);
97
+ if (!Number.isSafeInteger(max) || max < 1) throw poolMaxInvalid(raw);
98
+ return { max };
99
+ }
100
+
101
+ /**
102
+ * The five numbers a pool runs on, screened on the MERGED profile — an override is spread over a
103
+ * role default the caller never restated, so the resolved object is the only one that can be
104
+ * judged. Every one of them is a plausible `Number(process.env.…)`, which is `NaN` for an unset
105
+ * variable and not nullish, so `??` and the spread both keep it. None of the five then fails
106
+ * loudly: `idleTimeout: NaN` goes to `Bun.SQL`, `statement_timeout=NaN` goes into the libpq
107
+ * options string for the SERVER to reject on connect, and a timer given `NaN` fires at 1ms in this
108
+ * Bun — so a pool with free connections reports itself exhausted. `0` stays legal for the three
109
+ * budgets that document it as "no bound"; `max` is at least one connection, or nothing can run.
110
+ */
111
+ export function assertPoolProfile(profile: PoolProfile): PoolProfile {
112
+ const whole = (option: string, value: number, min: 0 | 1): void => {
113
+ assert(
114
+ Number.isSafeInteger(value) && value >= min,
115
+ `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"'}`,
116
+ `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`,
117
+ );
118
+ };
119
+ whole('max', profile.max, 1);
120
+ whole('statementTimeoutMs', profile.statementTimeoutMs, 0);
121
+ whole('idleTimeoutMs', profile.idleTimeoutMs, 0);
122
+ whole('lockTimeoutMs', profile.lockTimeoutMs, 0);
123
+ whole('acquireTimeoutMs', profile.acquireTimeoutMs, 0);
124
+ return profile;
125
+ }
@@ -0,0 +1,50 @@
1
+ // Single responsibility: pinning a connection out of the pool under the profile's acquire deadline,
2
+ // and giving back a reservation that arrives after the deadline has passed. Split from `client.ts`,
3
+ // which now asks for a pin rather than owning what "waited too long" means.
4
+
5
+ import type { BunSqlDriver, BunSqlReserved } from './bun-sql';
6
+ import { poolAcquireTimeout } from './errors';
7
+ import type { PoolProfile } from './pool-profile';
8
+
9
+ /**
10
+ * `pool.reserve()` under a deadline. Without one an exhausted pool does not fail, it **queues** —
11
+ * so a slow endpoint filling all 20 slots turns every later request, `/readyz`'s `select 1`
12
+ * included, into a wait with no end and no error, and the pod is killed for being unready rather
13
+ * than answering 503 for the requests it cannot serve.
14
+ *
15
+ * The losing reservation is released, never dropped: the pool hands out a connection whenever one
16
+ * frees, deadline or no deadline, and a pin nobody holds is a connection nobody gets back. That is
17
+ * the whole reason this is not a bare `Promise.race`.
18
+ */
19
+ export async function reserveWithin(
20
+ pool: Pick<BunSqlDriver, 'reserve'>,
21
+ profile: PoolProfile,
22
+ ): Promise<BunSqlReserved> {
23
+ const budget = profile.acquireTimeoutMs;
24
+ if (budget <= 0) return pool.reserve();
25
+ let timer: ReturnType<typeof setTimeout> | undefined;
26
+ let expired = false;
27
+ const pending = pool.reserve();
28
+ try {
29
+ return await Promise.race([
30
+ pending,
31
+ new Promise<never>((_resolve, reject) => {
32
+ timer = setTimeout(() => {
33
+ expired = true;
34
+ reject(poolAcquireTimeout(budget, profile.max));
35
+ }, budget);
36
+ // The deadline must not be what keeps a finished process alive.
37
+ timer.unref?.();
38
+ }),
39
+ ]);
40
+ } finally {
41
+ if (timer !== undefined) clearTimeout(timer);
42
+ // Attached unconditionally so a rejection arriving after we gave up is handled, not unhandled.
43
+ void pending.then(
44
+ (late) => {
45
+ if (expired) late.release();
46
+ },
47
+ () => undefined,
48
+ );
49
+ }
50
+ }
@@ -3,6 +3,7 @@
3
3
  // enforcing read-only beats a regex, and the timeout bounds a runaway scan the agent never
4
4
  // meant to ask for.
5
5
 
6
+ import { finiteCount } from '@ultimat3/core';
6
7
  import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
7
8
  import { multipleStatements } from './errors';
8
9
  import { identifier, raw, sql } from './sql';
@@ -89,6 +90,26 @@ export async function readOnlyQuery<T>(
89
90
  const statements = statementsOf(statement);
90
91
  if (statements.length > 1) throw multipleStatements(statement, statements.length);
91
92
 
93
+ // Decided before anything is opened, for the reason `rollback({ steps })` screens before it
94
+ // takes the advisory lock: a value this build cannot honour is not a fact about the pool. It
95
+ // was computed after `reserve()` and after `BEGIN READ ONLY`, so an unbounded `timeoutMs`
96
+ // against an exhausted pool answered with the pool's error — or waited for a connection it was
97
+ // never going to use — in place of the `X_INVARIANT` naming the option that was wrong.
98
+ //
99
+ // Clamped and truncated to an integer: the result is a JS number the caller never touches
100
+ // as text, so there is nothing here for `raw()` to inject — `SET LOCAL` can't bind `$n`
101
+ // parameters, which is why this can't go through `sql` the normal, parameterised way.
102
+ // REFUSED rather than normalised: `NaN` used to take the default silently, so a config typo
103
+ // ran under a timeout nobody wrote. Only an explicit 0 disables the layer, which is why the
104
+ // floor is 0 and not 1; the hour ceiling is a clamp on a number that IS one.
105
+ const asked = finiteCount(
106
+ 'readonlyQuery',
107
+ 'timeoutMs',
108
+ options.timeoutMs ?? READONLY_TIMEOUT_MS,
109
+ 0,
110
+ );
111
+ const ms = Math.min(3_600_000, asked);
112
+
92
113
  const client = options.client ?? baseClient();
93
114
  // A pooled BEGIN that lands on a different physical connection than the query that follows is
94
115
  // not a transaction at all, so a reservable client must pin one connection for the sequence.
@@ -104,14 +125,6 @@ export async function readOnlyQuery<T>(
104
125
  await connection.execute(raw('BEGIN READ ONLY'));
105
126
  guards.push('txn:read-only');
106
127
 
107
- // Clamped and truncated to an integer: the result is a JS number the caller never touches
108
- // as text, so there is nothing here for `raw()` to inject — `SET LOCAL` can't bind `$n`
109
- // parameters, which is why this can't go through `sql` the normal, parameterised way.
110
- // NaN normalises to the default first: only an explicit 0 disables the timeout, and
111
- // `Math.min(3_600_000, NaN)` is NaN, which would fail `ms > 0` and silently skip the layer.
112
- const asked = options.timeoutMs ?? READONLY_TIMEOUT_MS;
113
- const requested = Number.isNaN(asked) ? READONLY_TIMEOUT_MS : asked;
114
- const ms = Math.max(0, Math.min(3_600_000, Math.trunc(requested)));
115
128
  if (ms > 0) {
116
129
  // `LOCAL`, so the setting dies with the transaction — one agent read must not re-time
117
130
  // every request the pool serves afterwards. Caveat: embedded PGlite applies the setting
@@ -3,7 +3,7 @@
3
3
  // handle a statement is sent on, what happens when the replica will not answer, and the counters
4
4
  // that make both visible to a test that cannot scrape a metrics endpoint.
5
5
 
6
- import { type Clock, logger, renderThrowable, systemClock } from '@ultimat3/core';
6
+ import { type Clock, finiteCount, logger, renderThrowable, systemClock } from '@ultimat3/core';
7
7
  import { type DbClient, type DbConnection, isReservable, type ReservableClient } from './client';
8
8
  import { isPlainRead } from './replica-route';
9
9
  import { markScopeWrote, replicaScope } from './replica-scope';
@@ -53,8 +53,21 @@ export function replicatedClient(
53
53
  options: ReplicatedClientOptions = {},
54
54
  ): ReplicatedClient {
55
55
  const clock = options.clock ?? systemClock;
56
- const limit = options.breakerFailures ?? BREAKER_FAILURES;
57
- const cooldown = options.breakerCooldownMs ?? BREAKER_COOLDOWN_MS;
56
+ // Both are comparisons and nothing else — `consecutiveFailures >= limit` opens the breaker,
57
+ // `monotonic() < parkedUntil` holds it open — so a `NaN` in either is a breaker that never trips
58
+ // and never parks, with every read still going to the replica that is failing.
59
+ const limit = finiteCount(
60
+ 'replicatedClient',
61
+ 'breakerFailures',
62
+ options.breakerFailures ?? BREAKER_FAILURES,
63
+ 1,
64
+ );
65
+ const cooldown = finiteCount(
66
+ 'replicatedClient',
67
+ 'breakerCooldownMs',
68
+ options.breakerCooldownMs ?? BREAKER_COOLDOWN_MS,
69
+ 1,
70
+ );
58
71
  let replicaCount = 0;
59
72
  let primaryCount = 0;
60
73
  let fallbackCount = 0;
@@ -0,0 +1,139 @@
1
+ // Single responsibility: which columns this migration RETYPES, and which recorded FOREIGN KEYS
2
+ // that breaks — the one answer to both, computed over the whole schema and above `diffTable`.
3
+ //
4
+ // **Why above `diffTable`, and not inside it like every other dependent.** Postgres re-checks a
5
+ // key's two ends against each other on every `alter column … type` and cannot rebuild one whose
6
+ // sides stopped matching: measured on 18.4, `42804 foreign key constraint "rk_posts_org_code_fkey"
7
+ // cannot be implemented — Key columns "org_code" … and "code" … are of incompatible types: integer
8
+ // and text`, thrown by the ALTER itself, inside `ROLE=migrate`, with the ledger recording nothing.
9
+ // The constraint that breaks is recorded on the table that OWNS it, which for a retype of the
10
+ // key's TARGET is a different entity's record — `diffTable(orgs)` is handed `orgs`'s row and can
11
+ // never see `posts.foreignKeys`. So the retype set is derived once, from `entities` and `current`
12
+ // together, and `retypeColumn` READS it rather than deciding again: two answers to "is this column
13
+ // being retyped" is the axiom-1 split this package has spent the week closing.
14
+ //
15
+ // **Over-approximated on purpose, the rule `retype-dependents.ts` states.** Whether two types keep
16
+ // an equality operator between them is operator resolution, which is exactly the knowledge a
17
+ // generator with no database cannot have — `varchar(80)` and `text` share one, `integer` and
18
+ // `text` do not. A key moved aside that did not need to be is one `add constraint` re-validating a
19
+ // table the ALTER beside it is already rewriting under ACCESS EXCLUSIVE; a key missed is the
20
+ // release phase failing with the server's words and none of the entity's.
21
+ //
22
+ // **What it cannot see.** A key the recorded schema does not hold — a hand-written migration's,
23
+ // or one from a sidecar written before `foreignKeys` was recorded — is invisible here and still
24
+ // `42804`, the same construction limit `x db gen` has against a hand-added expression index. And
25
+ // re-adding the key is still the SERVER's judgement: an entity that retypes one end and not the
26
+ // other declares a pairing Postgres has no operator for, and the `add constraint` at the end of
27
+ // `up` is where that is said. Refusing it here would need the type knowledge two paragraphs up.
28
+
29
+ import type { EntityDescriptionLike } from './entity-shape';
30
+ import { addForeignKey, dropForeignKey, keyId, unrestorableNote } from './foreign-key';
31
+ import type { Plan } from './foreign-key-plan';
32
+ import { isGenerated } from './generated-column';
33
+ import type { ForeignKeyDescription, SchemaDescription, TableDescription } from './introspect';
34
+ import { findTable } from './introspect';
35
+ import { sqlType } from './sql-type';
36
+
37
+ /** Table name to the columns whose physical type this migration moves. Empty entries are omitted. */
38
+ export type RetypedColumns = ReadonlyMap<string, ReadonlySet<string>>;
39
+
40
+ /** The columns of one table this migration retypes — `retypeColumn`'s own read of the set above. */
41
+ export function retypedIn(retyped: RetypedColumns, table: string): ReadonlySet<string> {
42
+ return retyped.get(table) ?? new Set<string>();
43
+ }
44
+
45
+ /**
46
+ * Every plain `alter column … type` this migration will emit, before any of them is written.
47
+ *
48
+ * A GENERATED column is deliberately absent: `generated-column.ts` owns every statement one of
49
+ * them produces, and its plain -> generated path is a `drop column` that takes the key with it
50
+ * rather than an ALTER that trips over it. That gap is real and is named in `generated-column.ts`.
51
+ */
52
+ export function retypedColumns(
53
+ entities: readonly EntityDescriptionLike[],
54
+ current: SchemaDescription,
55
+ ): RetypedColumns {
56
+ const moved = new Map<string, ReadonlySet<string>>();
57
+ for (const entity of entities) {
58
+ const live = findTable(current, entity.table);
59
+ if (live === undefined) continue;
60
+ const recorded = new Map(live.columns.map((column) => [column.name, column]));
61
+ const columns = new Set<string>();
62
+ for (const column of entity.columns) {
63
+ const held = recorded.get(column.column);
64
+ if (held === undefined) continue;
65
+ if (isGenerated(column) || held.generated !== undefined) continue;
66
+ if (held.dataType !== sqlType(column.kind)) columns.add(column.column);
67
+ }
68
+ if (columns.size > 0) moved.set(entity.table, columns);
69
+ }
70
+ return moved;
71
+ }
72
+
73
+ /** Whether either end of `key` sits on a column this migration retypes. `owner` owns the key. */
74
+ function breaksOn(key: ForeignKeyDescription, owner: string, retyped: RetypedColumns): boolean {
75
+ const own = retyped.get(owner);
76
+ if (own !== undefined && key.columns.some((column) => own.has(column))) return true;
77
+ const target = retyped.get(key.referencedTable);
78
+ return target !== undefined && key.referencedColumns.some((column) => target.has(column));
79
+ }
80
+
81
+ /**
82
+ * Drop every recorded key a retype breaks, restore it in `down`, and answer which names were moved.
83
+ *
84
+ * The two statements go in the plan's OWN buckets and not beside the ALTER, because the drop has
85
+ * to precede every alter in the migration and the restore has to follow every one of them — both
86
+ * ends of a key can move, in two different entities' diffs. `preAlters` is merged at the very top
87
+ * of `up` and at the very FRONT of `down`, which reversal turns into the very end: so the reversed
88
+ * script reads drop-the-new-key, retype both ends back, add the recorded key. Restoring it any
89
+ * earlier is `42804` in the other direction.
90
+ *
91
+ * What comes back in `up` is not written here at all: `foreignKeyPlan` reads the returned set,
92
+ * treats a moved key as one the schema does not record, and adds the DECLARED key in the
93
+ * `constraints` bucket that already runs after every table statement. That is what makes the three
94
+ * outcomes fall out of code that already exists — still declared (added back), no longer declared
95
+ * (gone, exactly as the removal arm would have left it), and declared with a new `on delete` rule
96
+ * (added back carrying it) — instead of three branches restating them here.
97
+ */
98
+ export function moveKeysAside(
99
+ current: SchemaDescription,
100
+ retyped: RetypedColumns,
101
+ doomed: ReadonlySet<string>,
102
+ preAlters: Plan,
103
+ ): ReadonlySet<string> {
104
+ const moved = new Set<string>();
105
+ for (const table of current.tables) {
106
+ for (const key of table.foreignKeys) {
107
+ if (!breaksOn(key, table.name, retyped)) continue;
108
+ moved.add(keyId(table.name, key.name));
109
+ preAlters.up.push(dropForeignKey(table.name, key.name));
110
+ preAlters.down.push(restore(table, key, doomed));
111
+ }
112
+ }
113
+ return moved;
114
+ }
115
+
116
+ /**
117
+ * The `down` half. A key whose own table or whose target is being dropped has no `add constraint`
118
+ * that could run at all, so it gets the SAME note `unrestorableDrop` gives one — the text is
119
+ * `unrestorableNote`'s, in `foreign-key.ts`, and not a second spelling here. One failed rollback
120
+ * has one wording whichever module emitted it (axiom 2); these two had already drifted, this one
121
+ * naming no table at all while `foreign-key-plan.ts` named the target.
122
+ *
123
+ * The table it names is the key's OWN when that is the one going: a constraint whose table is
124
+ * gone is the more proximate reason there is nothing to add it back onto.
125
+ */
126
+ function restore(
127
+ table: TableDescription,
128
+ key: ForeignKeyDescription,
129
+ doomed: ReadonlySet<string>,
130
+ ): string {
131
+ if (!doomed.has(table.name) && !doomed.has(key.referencedTable)) {
132
+ return addForeignKey(table.name, key);
133
+ }
134
+ return unrestorableNote(
135
+ table.name,
136
+ key.name,
137
+ doomed.has(table.name) ? table.name : key.referencedTable,
138
+ );
139
+ }
@@ -0,0 +1,35 @@
1
+ // Single responsibility: the physical Postgres type a declared column KIND becomes. One table, so
2
+ // the statement that writes a column, the snapshot that records it and the pass that decides
3
+ // whether a retype is happening at all cannot disagree about what `char` means.
4
+ //
5
+ // Split out of `generate.ts` for `retype-keys.ts`, which has to answer "does this column's type
6
+ // move" ABOVE `diffTable` — a foreign key over a retyped column lives in another table's record.
7
+
8
+ const SQL_TYPES: Readonly<Record<string, string>> = {
9
+ uuid: 'uuid',
10
+ text: 'text',
11
+ // Bare `char` is `char(1)` in Postgres, and the only column carrying this kind is money's
12
+ // currency — a three-letter ISO 4217 code whose CHECK the entity emits on the same line.
13
+ // Without the length no currency ever fits the constraint the same statement demands.
14
+ char: 'char(3)',
15
+ boolean: 'boolean',
16
+ integer: 'integer',
17
+ bigint: 'bigint',
18
+ numeric: 'numeric',
19
+ timestamptz: 'timestamptz',
20
+ date: 'date',
21
+ jsonb: 'jsonb',
22
+ };
23
+
24
+ /**
25
+ * A kind this table does not name passes through verbatim — an app's own domain, an enum type a
26
+ * hand-written migration created.
27
+ *
28
+ * `Object.hasOwn` and not a bare index: `kind` is DATA, so `SQL_TYPES['constructor']` answered the
29
+ * `Object` function and `type ${wanted}` spliced its source into a statement, while `'__proto__'`
30
+ * answered `[object Object]`. Guarded, both behave like every other unknown kind and pass through
31
+ * as themselves. Measured across the package's 766 tests: no other input's answer moves.
32
+ */
33
+ export function sqlType(kind: string): string {
34
+ return Object.hasOwn(SQL_TYPES, kind) ? (SQL_TYPES[kind] ?? kind) : kind;
35
+ }
package/src/sql.ts CHANGED
@@ -132,19 +132,73 @@ export function identifier(name: string): SqlFragment {
132
132
  }
133
133
 
134
134
  /**
135
- * A quoted string literal. Postgres utility statements (`CREATE DATABASE`, `COMMENT ON`) reject
136
- * bound parameters, so this is the only place a value may be inlined — and it escapes quotes.
137
- * Never reach for it in a query: `sql` binds parameters there.
135
+ * The two characters a shell substitutes INSIDE double quotes. Rejected before `identifier` is
136
+ * consulted at all, because `identifier` accepts both.
137
+ */
138
+ const SHELL_ACTIVE = /[`$]/;
139
+
140
+ /**
141
+ * A quoted identifier that is ALSO inert wherever a human pastes it — or `null` for a name no line
142
+ * built here may spell. The one screen a catalog name goes through before it reaches a `fix:`.
143
+ *
144
+ * TWO layers, and `identifier` closes only the first. It answers about SQL: it refuses `"`, `\`
145
+ * and whitespace, and ACCEPTS a backtick and a `$` — `SAFE_IDENTIFIER` above allows `$` on its
146
+ * fast path — which are exactly the two characters `$(…)` and a command substitution are built
147
+ * from. A column called `$(id)` inside `x db gen "add $(id)"` therefore RUNS `id` the moment its
148
+ * reader pastes the line, and a screen reusing `identifier` unchanged would ship a green suite
149
+ * over that.
150
+ *
151
+ * The name is DATA at every caller: `create table "x""; drop table users; --" ("id" int)` is legal
152
+ * DDL, so whoever can create a table or a column picks the text that lands in a `fix:`. A refusal
153
+ * is degraded to prose by its caller and never escaped — the argument to `x db gen` is a migration
154
+ * DESCRIPTION, not an identifier, so there is no quoted form that makes a hostile name safe to
155
+ * pass, and a fix naming no command beats one running a second command the reader never read.
156
+ *
157
+ * `'` is deliberately NOT refused: it is legal in an identifier and inert both in a psql session
158
+ * and inside shell double quotes. The price of the two that ARE refused is a legal `a$b` losing
159
+ * its executable fix line, which is prose in place of something nobody read running.
160
+ */
161
+ export function shellInertIdentifier(name: string): string | null {
162
+ if (SHELL_ACTIVE.test(name)) return null;
163
+ try {
164
+ return identifier(name).text;
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * A quoted string literal Postgres reads IDENTICALLY under both settings of
172
+ * `standard_conforming_strings`. Utility and DDL statements (`CREATE DATABASE`, `COMMENT ON`,
173
+ * `create table … default …`) reject bound parameters, so this is the only place a value may be
174
+ * inlined. Never reach for it in a query: `sql` binds parameters there.
175
+ *
176
+ * **It DOES receive caller input, and this comment said otherwise until 2026-08-25.**
177
+ * `column-default.ts:43` renders `ColumnDefaultLike` here, which is an app's own
178
+ * `.default('C:\\logs')` crossing the tier seam from `@ultimat3/entity` — nothing validates it and
179
+ * no `identifier()` guards it. (The package's two other callers are safe by CONSTRUCTION, not by
180
+ * input: `readonly-role.ts:71` sits in the same `sql` template as an `identifier(role)` that throws
181
+ * first, and `branch.ts:85` runs after an already-awaited `identifier(base)`.)
182
+ *
183
+ * Doubling the quote is not the whole rule. That GUC is settable per session, per database and per
184
+ * role and `SET` needs no privilege, and with it `off` a backslash escapes the character after it
185
+ * inside an ordinary `'…'`. Measured on 18.4 through `generateMigration`: `.default('C:\\logs')`
186
+ * emits `default 'C:\logs'`, which stores `C:\logs` with the GUC on and **`C:logs`** with it off —
187
+ * a column defaulting to a value nobody wrote, with no error anywhere. A value ENDING in a
188
+ * backslash is worse than wrong: the escaped quote leaves the literal unterminated and the text
189
+ * after it is string data until the next `'` puts the remainder back into code position.
138
190
  *
139
- * The doubling is only an escape while `standard_conforming_strings` is `on`, which has been the
140
- * server default since 9.1: with it OFF, a backslash escapes the quote that follows and a value
141
- * ending in one closes the literal early. So this is safe for framework-supplied names — a
142
- * database, a schema, a comment this repo writes — and is NOT an escape for untrusted text under
143
- * an arbitrary server configuration. Nothing passes it caller input today; if something must,
144
- * bind a parameter instead, or send `E''`-style quoting from a statement that can take one.
191
+ * `E'…'` fixes the dialect in the text itself rather than trusting a setting, so both readings
192
+ * agree — **only** when the value actually carries a backslash. Without one there is no escape
193
+ * mechanism for the two settings to disagree about, so every migration already generated stays byte
194
+ * for byte what it was and nothing regenerates spuriously; both tracked apps have applied
195
+ * migrations on disk with hashes over this text. Same rule, same measurement, as
196
+ * `packages/entity/src/sql-literal.ts`, which is where it was first written and which adopts this
197
+ * one — tier 1 holds it, tier 2 imports down.
145
198
  */
146
199
  export function literal(value: string): SqlFragment {
147
- return raw(`'${value.replaceAll("'", "''")}'`);
200
+ const quoted = value.replaceAll("'", "''");
201
+ return raw(value.includes('\\') ? `E'${quoted.replaceAll('\\', '\\\\')}'` : `'${quoted}'`);
148
202
  }
149
203
 
150
204
  /** `a, b, c` — the one blessed way to build an IN list or a column list. */