@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.
- package/CLAUDE.md +249 -22
- package/README.md +3 -2
- package/package.json +2 -2
- package/src/bun-sql.ts +32 -0
- package/src/client.ts +14 -232
- package/src/connection-url.ts +60 -0
- package/src/db-health.ts +31 -0
- package/src/default-client.ts +2 -1
- package/src/dependent-view.ts +224 -0
- package/src/drift-errors.ts +36 -0
- package/src/drift-findings.ts +63 -6
- package/src/drift-fixtures.ts +23 -0
- package/src/errors.ts +2 -109
- package/src/foreign-key-plan.ts +35 -14
- package/src/foreign-key.ts +33 -0
- package/src/generate.ts +36 -48
- package/src/generated-column.ts +31 -6
- package/src/index-ddl.ts +51 -0
- package/src/index-plan.ts +119 -0
- package/src/index.ts +24 -21
- package/src/migrate.ts +30 -12
- package/src/migration-errors.ts +132 -0
- package/src/pool-profile.ts +125 -0
- package/src/pool-reserve.ts +50 -0
- package/src/readonly-query.ts +21 -8
- package/src/replica-client.ts +16 -3
- package/src/retype-keys.ts +139 -0
- package/src/sql-type.ts +35 -0
- package/src/sql.ts +64 -10
|
@@ -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
|
+
}
|
package/src/readonly-query.ts
CHANGED
|
@@ -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
|
package/src/replica-client.ts
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
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
|
+
}
|
package/src/sql-type.ts
ADDED
|
@@ -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
|
-
*
|
|
136
|
-
*
|
|
137
|
-
|
|
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
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
-
|
|
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. */
|