@ultimat3/db 16.0.0 → 18.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 +170 -12
- package/README.md +4 -3
- package/package.json +3 -3
- package/src/array-parameter.ts +91 -0
- package/src/bun-sql.ts +32 -0
- package/src/client.ts +17 -321
- package/src/connection-url.ts +102 -0
- package/src/db-health.ts +31 -0
- package/src/default-client.ts +2 -1
- 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 +0 -9
- package/src/generate.ts +40 -2
- package/src/index.ts +15 -15
- package/src/introspect.ts +16 -0
- package/src/migrate.ts +19 -11
- 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/replica-identity.ts +84 -0
- package/src/snapshot-parse.ts +11 -2
- package/src/sql.ts +36 -0
- package/src/statement-funnel.ts +101 -0
- package/src/ungeneratable.ts +11 -0
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,84 @@
|
|
|
1
|
+
// Single responsibility: the `alter table … replica identity full` a live query needs, emitted once.
|
|
2
|
+
// `@ultimat3/realtime` refuses a subscription to a table without it — logical replication carries no
|
|
3
|
+
// old row on an UPDATE, so no patch can be computed — and which tables need it is a tier-3 fact no
|
|
4
|
+
// entity carries, so it arrives as `GenerateOptions.replicaIdentityFull` and is recorded on the
|
|
5
|
+
// snapshot: the record is what stops the statement being re-emitted on every `x db gen`.
|
|
6
|
+
|
|
7
|
+
import type { Plan } from './foreign-key-plan';
|
|
8
|
+
import { findTable, type SchemaDescription } from './introspect';
|
|
9
|
+
import { identifier } from './sql';
|
|
10
|
+
|
|
11
|
+
export interface ReplicaIdentityInput {
|
|
12
|
+
/** Tables a live query subscribes to. Absent or empty emits nothing and reverts nothing. */
|
|
13
|
+
readonly wanted: readonly string[] | undefined;
|
|
14
|
+
/** The tables this migration leaves standing — the ALTER may name no other. */
|
|
15
|
+
readonly declared: ReadonlySet<string>;
|
|
16
|
+
/** Of those, the ones it CREATES: their whole `down` is already `drop table`. */
|
|
17
|
+
readonly created: ReadonlySet<string>;
|
|
18
|
+
/** What migrations already recorded — `expectedSchema(migrations, ledger)`. */
|
|
19
|
+
readonly current: SchemaDescription;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether the recorded schema already says this table carries it.
|
|
24
|
+
*
|
|
25
|
+
* Absent — never `false` — is a sidecar written before the field existed: it declares nothing, so
|
|
26
|
+
* the ALTER is emitted once more against a table that may already have it, which Postgres accepts.
|
|
27
|
+
* The opposite reading would be "recorded as not full", and a snapshot that predates the field
|
|
28
|
+
* cannot mean that. `TableDescription.checks` states the same rule for the same reason.
|
|
29
|
+
*/
|
|
30
|
+
export function recordsReplicaIdentityFull(current: SchemaDescription, table: string): boolean {
|
|
31
|
+
return findTable(current, table)?.replicaIdentityFull === true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Which tables to record on the snapshot — sorted only by the caller's own table order. */
|
|
35
|
+
function pending(input: ReplicaIdentityInput): readonly string[] {
|
|
36
|
+
// Deduplicated and sorted, so the same live-query set generates the same bytes whatever order the
|
|
37
|
+
// manifest walked its queries in: a diff that moves a line is a diff an author has to read.
|
|
38
|
+
return [...new Set(input.wanted ?? [])]
|
|
39
|
+
.filter((table) => input.declared.has(table))
|
|
40
|
+
.filter((table) => !recordsReplicaIdentityFull(input.current, table))
|
|
41
|
+
.sort();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The tables carrying it once this migration has applied — what `snapshotOf` records.
|
|
46
|
+
*
|
|
47
|
+
* The UNION with what is already recorded, never this run's set alone. A caller that passes no
|
|
48
|
+
* live-query set (`x db gen` from a command that never learned about one) must not silently erase
|
|
49
|
+
* the fact from the sidecar, or the very next run emits the ALTER again on a table that has it.
|
|
50
|
+
*/
|
|
51
|
+
export function replicaIdentityFullAfter(input: ReplicaIdentityInput): ReadonlySet<string> {
|
|
52
|
+
const after = new Set<string>();
|
|
53
|
+
for (const table of input.declared) {
|
|
54
|
+
if (recordsReplicaIdentityFull(input.current, table)) after.add(table);
|
|
55
|
+
}
|
|
56
|
+
for (const table of pending(input)) after.add(table);
|
|
57
|
+
return after;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Additive only, and never destructive. `alter table … replica identity full` widens what logical
|
|
62
|
+
* replication carries; it drops no row, rewrites no column and matches none of `destructive.ts`'s
|
|
63
|
+
* four rules, so the migration it lands in needs no `-- destructive: true` marker.
|
|
64
|
+
*
|
|
65
|
+
* A name matching no entity is SKIPPED rather than refused: the list is derived from the manifest's
|
|
66
|
+
* live queries, and a query whose entity has been deleted is an app fault the generator cannot
|
|
67
|
+
* repair — emitting the statement anyway would be `42P01` at `ROLE=migrate`, which is the one place
|
|
68
|
+
* this package refuses to put a fault.
|
|
69
|
+
*
|
|
70
|
+
* Nothing is ever reverted. A table dropping out of the live-query set keeps the identity it has:
|
|
71
|
+
* the option is optional, so "absent" and "no live query subscribes any more" are the same value,
|
|
72
|
+
* and reading them alike would let a caller that never passes the option turn off replication for
|
|
73
|
+
* every subscribed table in the app.
|
|
74
|
+
*/
|
|
75
|
+
export function replicaIdentityPlan(plan: Plan, input: ReplicaIdentityInput): void {
|
|
76
|
+
for (const table of pending(input)) {
|
|
77
|
+
const name = identifier(table).text;
|
|
78
|
+
plan.up.push(`alter table ${name} replica identity full;`);
|
|
79
|
+
// A table this migration created is dropped by its own `down`; a second statement ahead of that
|
|
80
|
+
// is a line an author reads and nothing performs.
|
|
81
|
+
if (input.created.has(table)) continue;
|
|
82
|
+
plan.down.push(`alter table ${name} replica identity default;`);
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/snapshot-parse.ts
CHANGED
|
@@ -112,11 +112,20 @@ function tableOf(value: unknown): TableDescription | undefined {
|
|
|
112
112
|
// Absent, never `[]`. A sidecar written before constraints were recorded says nothing about
|
|
113
113
|
// them, and reading that as "this table declares none" would drop every invariant an app has
|
|
114
114
|
// already generated instead of adding the ones its database is missing.
|
|
115
|
+
// `true` or nothing. `false` is accepted and NORMALISED away rather than rejecting the file: it
|
|
116
|
+
// is a hand-edit meaning exactly what absence means, and discarding the whole snapshot over it
|
|
117
|
+
// makes `x db gen` refuse with `X_MIGRATION_SNAPSHOT_MISSING` on a sidecar that says nothing
|
|
118
|
+
// wrong. Any other value is garbage and takes the file with it, like every field above.
|
|
119
|
+
const identity = value['replicaIdentityFull'];
|
|
120
|
+
if (!(identity === undefined || bool(identity))) return undefined;
|
|
121
|
+
const replica = identity === true ? { replicaIdentityFull: true as const } : {};
|
|
115
122
|
const raw = value['checks'];
|
|
116
|
-
if (raw === undefined)
|
|
123
|
+
if (raw === undefined) {
|
|
124
|
+
return { schema, name, columns, primaryKey, indexes, foreignKeys, ...replica };
|
|
125
|
+
}
|
|
117
126
|
const checks = all(raw, check);
|
|
118
127
|
if (checks === undefined) return undefined;
|
|
119
|
-
return { schema, name, columns, primaryKey, indexes, foreignKeys, checks };
|
|
128
|
+
return { schema, name, columns, primaryKey, indexes, foreignKeys, checks, ...replica };
|
|
120
129
|
}
|
|
121
130
|
|
|
122
131
|
/**
|
package/src/sql.ts
CHANGED
|
@@ -131,6 +131,42 @@ export function identifier(name: string): SqlFragment {
|
|
|
131
131
|
return raw(`"${name}"`);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/**
|
|
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
|
+
|
|
134
170
|
/**
|
|
135
171
|
* A quoted string literal Postgres reads IDENTICALLY under both settings of
|
|
136
172
|
* `standard_conforming_strings`. Utility and DDL statements (`CREATE DATABASE`, `COMMENT ON`,
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Single responsibility: the pooled driver's statement funnel — one statement on one handle, every
|
|
2
|
+
// driver failure typed on the way out, and the installed `StatementObserver` notified on both
|
|
3
|
+
// settle paths. Split from `client.ts` at the file-size rule; `pglite.ts` holds the mirror pair
|
|
4
|
+
// (`send`/`statement`) for the embedded driver, and the two must keep answering the same way.
|
|
5
|
+
|
|
6
|
+
import { encodeArrayParameters } from './array-parameter';
|
|
7
|
+
import { statementAttribution } from './attribution';
|
|
8
|
+
import type { BunSqlDriver } from './bun-sql';
|
|
9
|
+
import { driverError } from './errors';
|
|
10
|
+
import { expectedQueryLoopReason } from './expected-loop';
|
|
11
|
+
import { statementObserver } from './observe';
|
|
12
|
+
import type { SqlFragment } from './sql';
|
|
13
|
+
import { withStatementSpan } from './statement-span';
|
|
14
|
+
|
|
15
|
+
export function rowsOf<T>(result: unknown): readonly T[] {
|
|
16
|
+
return Array.isArray(result) ? (result as readonly T[]) : [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The command tag only when it counted something, exactly like `rowsOf` in `pglite.ts` — one rule
|
|
20
|
+
// across both drivers, so `execute()` and the observer's event cannot answer differently for the
|
|
21
|
+
// same statement depending on which database is behind them. A driver that tags a read `0` while
|
|
22
|
+
// returning rows would otherwise report 0 here and the row count there.
|
|
23
|
+
export function affectedBy(result: unknown): number {
|
|
24
|
+
if (!Array.isArray(result)) return 0;
|
|
25
|
+
const count = (result as { count?: unknown }).count;
|
|
26
|
+
return typeof count === 'number' && count > 0 ? count : result.length;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The send itself: one statement on one handle, every driver failure typed on the way out. */
|
|
30
|
+
async function sendOn(
|
|
31
|
+
driver: Pick<BunSqlDriver, 'unsafe'>,
|
|
32
|
+
fragment: SqlFragment,
|
|
33
|
+
): Promise<unknown> {
|
|
34
|
+
try {
|
|
35
|
+
// `encodeArrayParameters`, never `fragment.values` raw: `Bun.SQL` joins a JS array's elements
|
|
36
|
+
// with commas, so every `any($n::T[])` in this framework sent Postgres a malformed literal and
|
|
37
|
+
// failed — the worker's claim loop among them (#384). One encoder here rather than one import
|
|
38
|
+
// per call site, because this is the only place this driver's `unsafe` is called.
|
|
39
|
+
return await driver.unsafe(fragment.text, encodeArrayParameters(fragment.values));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
// `driverError`, not `dbUnavailable`: the SQLSTATE has always been on this error and nothing
|
|
42
|
+
// read it, so a `23505` from two clicks racing a signup told the operator the database was
|
|
43
|
+
// unreachable and paged on-call for an outage that never happened. Everything the table does
|
|
44
|
+
// not classify is still `X_DB_UNAVAILABLE`, byte for byte.
|
|
45
|
+
throw driverError(`statement failed: ${fragment.text.slice(0, 120)}`, error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The funnel — pooled and pinned statements both arrive here, which is why the observer hangs
|
|
51
|
+
* off this one function and nowhere else. Uninstalled it costs one property read and one
|
|
52
|
+
* branch: no clock read, no span, no event object, and `sendOn` receives exactly the call `runOn`
|
|
53
|
+
* made before the seam existed (axiom 6).
|
|
54
|
+
*/
|
|
55
|
+
export async function runOn(
|
|
56
|
+
driver: Pick<BunSqlDriver, 'unsafe'>,
|
|
57
|
+
fragment: SqlFragment,
|
|
58
|
+
): Promise<unknown> {
|
|
59
|
+
const observer = statementObserver();
|
|
60
|
+
if (observer === undefined) return sendOn(driver, fragment);
|
|
61
|
+
// Read here, not by the consumer: the scope is gone by the time a per-request detector judges
|
|
62
|
+
// what it collected, so the reason has to be captured with the statement it defends.
|
|
63
|
+
const expected = expectedQueryLoopReason();
|
|
64
|
+
// Same moment, same argument: `postgresRepo` is several frames and a microtask above this one,
|
|
65
|
+
// and what it knows — the entity and the operation — is what turns fifty identical `select`s
|
|
66
|
+
// into "50× findById on members". Absent for hand-written SQL, a migration, a health probe.
|
|
67
|
+
const attribution = statementAttribution();
|
|
68
|
+
const started = performance.now();
|
|
69
|
+
let result: unknown;
|
|
70
|
+
try {
|
|
71
|
+
// The span wraps the send and nothing else, so its duration is the statement's and the
|
|
72
|
+
// observer's own work is not charged to the database.
|
|
73
|
+
result = await withStatementSpan(fragment.text, () => sendOn(driver, fragment));
|
|
74
|
+
} catch (error) {
|
|
75
|
+
// A statement that failed is still a statement: fifty identical timeouts are an N+1 of
|
|
76
|
+
// timeouts. The error is already `X_DB_UNAVAILABLE`, so the event carries what the caller
|
|
77
|
+
// is about to be thrown — and an observer that throws here replaces it, which is why
|
|
78
|
+
// `observe.ts` says a reporting-only observer must not throw.
|
|
79
|
+
observer.onStatement({
|
|
80
|
+
text: fragment.text,
|
|
81
|
+
values: fragment.values,
|
|
82
|
+
durationMs: performance.now() - started,
|
|
83
|
+
rows: 0,
|
|
84
|
+
error,
|
|
85
|
+
attribution,
|
|
86
|
+
expected,
|
|
87
|
+
});
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
// Outside the `try` deliberately: a throw from `onStatement` is the observer's, not the
|
|
91
|
+
// database's, and catching it above would report a statement that succeeded as failed.
|
|
92
|
+
observer.onStatement({
|
|
93
|
+
text: fragment.text,
|
|
94
|
+
values: fragment.values,
|
|
95
|
+
durationMs: performance.now() - started,
|
|
96
|
+
rows: affectedBy(result),
|
|
97
|
+
attribution,
|
|
98
|
+
expected,
|
|
99
|
+
});
|
|
100
|
+
return result;
|
|
101
|
+
}
|
package/src/ungeneratable.ts
CHANGED
|
@@ -59,6 +59,17 @@ export const GENERATABLE_FORMS: readonly GeneratableForm[] = [
|
|
|
59
59
|
name: 'alter column drop expression',
|
|
60
60
|
pattern: /^alter\s+table\s[\s\S]*?\balter\s+column\s[\s\S]*?\bdrop\s+expression\b/,
|
|
61
61
|
},
|
|
62
|
+
// The form the doc block above calls "the statement that started this", finally on the list:
|
|
63
|
+
// `GenerateOptions.replicaIdentityFull` emits it `As of 2026-08-26`, so without this entry the
|
|
64
|
+
// rail reports SQL the generator itself just wrote. Covers `full` and `default` in one phrase —
|
|
65
|
+
// the down side is as generated as the up side.
|
|
66
|
+
{
|
|
67
|
+
name: 'alter table … replica identity full/default',
|
|
68
|
+
// `full` and `default` ONLY — the two modes `replicaIdentityPlan` emits. `using index` and
|
|
69
|
+
// `nothing` are hand-written configuration no entity declares, and admitting them here would
|
|
70
|
+
// read them as generatable and let a squash discard the replication setup in silence.
|
|
71
|
+
pattern: /^alter\s+table\s[\s\S]*?\breplica\s+identity\s+(?:full|default)\b/,
|
|
72
|
+
},
|
|
62
73
|
];
|
|
63
74
|
|
|
64
75
|
/**
|