@ultimat3/db 11.3.0 → 13.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 +171 -3
- package/README.md +74 -5
- package/package.json +2 -2
- package/src/app-relation.ts +57 -0
- package/src/client.ts +7 -3
- package/src/default-client.ts +35 -0
- package/src/drift.ts +18 -0
- package/src/entity-shape.ts +20 -0
- package/src/errors.ts +16 -0
- package/src/generate.ts +68 -8
- package/src/generated-column.ts +110 -0
- package/src/index-method.ts +75 -0
- package/src/index.ts +18 -0
- package/src/introspect.ts +42 -6
- package/src/replica-client.ts +137 -0
- package/src/replica-route.ts +60 -0
- package/src/replica-scope.ts +54 -0
- package/src/snapshot-parse.ts +8 -1
- package/src/transaction.ts +6 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Single responsibility: may THIS statement be served by a replica. An allow-list with a refusal
|
|
2
|
+
// list under it, whose default is the primary — the opposite bias to the `readonly.ts` lexer this
|
|
3
|
+
// package deleted, and the reason that deletion does not forbid this file.
|
|
4
|
+
|
|
5
|
+
import { statementVerb } from './statement-shape';
|
|
6
|
+
|
|
7
|
+
export type DbNode = 'primary' | 'replica';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The only verbs a replica may be offered. `with` is here because a CTE read is the shape half the
|
|
11
|
+
* framework's paginated queries take, and `WRITE_WORD` below is what tells `with … select` from
|
|
12
|
+
* `with … update … returning` — which `statementKind()` calls a read, and is exactly why that
|
|
13
|
+
* function is not the authority here.
|
|
14
|
+
*/
|
|
15
|
+
const READ_VERBS: ReadonlySet<string> = new Set(['select', 'table', 'values']);
|
|
16
|
+
const CTE_VERB = 'with';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A word that disqualifies the whole statement. Matched with word boundaries against the raw
|
|
20
|
+
* lowercased text — NOT against `stripSqlNoise`'d text, deliberately: a `;`-in-a-literal is data
|
|
21
|
+
* and must not split a statement, but a `'update'` in a literal costing one read its replica is a
|
|
22
|
+
* false positive on the SAFE side, and blanking every statement on the hot path to buy back that
|
|
23
|
+
* one read is a cost axiom 6 refuses. `share` covers `for share` and `for key share`; `update`
|
|
24
|
+
* covers `for update` and `for no key update`; `into` covers `select … into`, which creates a table.
|
|
25
|
+
*/
|
|
26
|
+
const WRITE_WORD =
|
|
27
|
+
/\b(?:insert|update|delete|merge|truncate|copy|create|drop|alter|grant|revoke|call|do|lock|share|refresh|reindex|vacuum|analyze|set|reset|begin|commit|rollback|savepoint|into)\b/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Function names a word boundary cannot reach — `pg_advisory_lock` has a `_` before `advisory`, so
|
|
31
|
+
* `\badvisory\b` never matches it. Every one of these either writes or mutates session state that
|
|
32
|
+
* belongs to whichever backend ran it, and a standby accepts them silently rather than answering
|
|
33
|
+
* `25006`, so the server's own refusal cannot be the safety net here the way it is for a real write.
|
|
34
|
+
*/
|
|
35
|
+
const UNSAFE_CALLS: readonly string[] = [
|
|
36
|
+
'nextval',
|
|
37
|
+
'setval',
|
|
38
|
+
'set_config',
|
|
39
|
+
'advisory',
|
|
40
|
+
'dblink',
|
|
41
|
+
'lo_import',
|
|
42
|
+
'lo_export',
|
|
43
|
+
'pg_export_snapshot',
|
|
44
|
+
'pg_replication',
|
|
45
|
+
'pg_create',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Provably a plain read, or `false`. Never "probably": everything this cannot vouch for is the
|
|
50
|
+
* primary's, so a statement shape nobody anticipated costs a replica opportunity and never a wrong
|
|
51
|
+
* answer. That inversion is the whole difference from `readonly.ts`, whose 22-word deny-list read
|
|
52
|
+
* `select pg_sleep(60)` as safe because the default was permission.
|
|
53
|
+
*/
|
|
54
|
+
export function isPlainRead(text: string): boolean {
|
|
55
|
+
const lowered = text.toLowerCase();
|
|
56
|
+
const verb = statementVerb(lowered);
|
|
57
|
+
if (!READ_VERBS.has(verb) && verb !== CTE_VERB) return false;
|
|
58
|
+
if (WRITE_WORD.test(lowered)) return false;
|
|
59
|
+
return !UNSAFE_CALLS.some((call) => lowered.includes(call));
|
|
60
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Single responsibility: the scope inside which a read may be served by a replica, and the one bit
|
|
2
|
+
// that closes read-your-writes — has this scope written yet. A mutable value on an async context,
|
|
3
|
+
// the same shape `transaction.ts` uses for `TxState.live`, so a write at any depth and across any
|
|
4
|
+
// `await` is seen by every later read in the same scope.
|
|
5
|
+
|
|
6
|
+
import { asyncContext } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Deliberately mutable, and deliberately not `readonly`. The whole mechanism is that a write ten
|
|
10
|
+
* frames and three `await`s below the scope's opener flips this, and the read after it sees it —
|
|
11
|
+
* a fresh object per statement could not carry that, and threading a parameter would be the same
|
|
12
|
+
* fact written at every call site, with every path an author forgot serving a stale row.
|
|
13
|
+
*/
|
|
14
|
+
export interface ReplicaScope {
|
|
15
|
+
wrote: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const scope = asyncContext<ReplicaScope>('the replica read scope');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Declare that reads inside `fn` may be served by a replica — until `fn` writes, after which every
|
|
22
|
+
* read in it is the primary's for the rest of the scope.
|
|
23
|
+
*
|
|
24
|
+
* **Opt-in, and that is the safety argument, not an ergonomic one.** `packages/db` cannot see a
|
|
25
|
+
* request boundary: `@ultimat3/http`'s pipeline opens the `Ctx` and nothing tells this tier when a
|
|
26
|
+
* request ended, so a write-marker keyed on `Ctx.requestId` would be a `Map` that only grows —
|
|
27
|
+
* ~100 bytes per request, forever — and any eviction policy that forgets a request that WROTE
|
|
28
|
+
* serves it a stale row, which is worse than the capacity problem replicas exist to solve. With no
|
|
29
|
+
* scope open nothing routes and the client is byte-identical to a single-pool one, so the failure
|
|
30
|
+
* mode of "nobody opened one" is today's behaviour rather than a wrong answer.
|
|
31
|
+
*
|
|
32
|
+
* Nesting is one scope, not two: an inner `withReplicaReads` inside a scope that has already
|
|
33
|
+
* written must not un-write it. The innermost call reuses the store it finds.
|
|
34
|
+
*/
|
|
35
|
+
export function withReplicaReads<T>(fn: () => T): T {
|
|
36
|
+
const open = scope.get();
|
|
37
|
+
if (open !== undefined) return fn();
|
|
38
|
+
return scope.run({ wrote: false }, fn);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The scope in flight, or `undefined` — which is every caller that never opened one. */
|
|
42
|
+
export function replicaScope(): ReplicaScope | undefined {
|
|
43
|
+
return scope.get();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Record that this scope has written. Called for every statement that is not provably a plain read
|
|
48
|
+
* — including `begin`, a `set`, and anything the router could not classify — because the direction
|
|
49
|
+
* that is safe to be wrong in is "assume it wrote". A no-op outside a scope, where nothing routes.
|
|
50
|
+
*/
|
|
51
|
+
export function markScopeWrote(): void {
|
|
52
|
+
const open = scope.get();
|
|
53
|
+
if (open !== undefined) open.wrote = true;
|
|
54
|
+
}
|
package/src/snapshot-parse.ts
CHANGED
|
@@ -36,13 +36,19 @@ function column(value: unknown): ColumnDescription | undefined {
|
|
|
36
36
|
|
|
37
37
|
function index(value: unknown): IndexDescription | undefined {
|
|
38
38
|
if (!isRow(value)) return undefined;
|
|
39
|
-
const { name, columns, unique, primary, where, order: direction } = value;
|
|
39
|
+
const { name, columns, unique, primary, where, order: direction, using } = value;
|
|
40
40
|
if (!str(name) || !strings(columns) || !bool(unique) || !bool(primary)) return undefined;
|
|
41
41
|
// Written by 1.2.0 onwards. A sidecar from before it carries neither, and the total, ascending
|
|
42
42
|
// reading is what that generation actually emitted — so an older file stays readable rather
|
|
43
43
|
// than being discarded whole, which would refuse to generate against every existing app.
|
|
44
44
|
if (!(where === undefined || nullableStr(where))) return undefined;
|
|
45
45
|
if (!(direction === undefined || order(direction))) return undefined;
|
|
46
|
+
// Any string, not the closed set. The live side of this type is the CATALOG's, which answers
|
|
47
|
+
// `gist` and an extension's own access method, and a sidecar recording one must round-trip so
|
|
48
|
+
// drift can report it — the refusal belongs at generation, where `declaredMethod` names the
|
|
49
|
+
// method and the fix, not here, where it would discard the whole snapshot without saying which
|
|
50
|
+
// field was wrong. Absent stays absent: `indexMethodOf` reads it as the btree it always was.
|
|
51
|
+
if (!(using === undefined || str(using))) return undefined;
|
|
46
52
|
return {
|
|
47
53
|
name,
|
|
48
54
|
columns,
|
|
@@ -50,6 +56,7 @@ function index(value: unknown): IndexDescription | undefined {
|
|
|
50
56
|
primary,
|
|
51
57
|
where: where === undefined ? null : where,
|
|
52
58
|
order: direction === undefined ? null : direction,
|
|
59
|
+
...(using === undefined ? {} : { using }),
|
|
53
60
|
};
|
|
54
61
|
}
|
|
55
62
|
|
package/src/transaction.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { Random } from '@ultimat3/core';
|
|
|
7
7
|
import { assert, asyncContext, nanoid } from '@ultimat3/core';
|
|
8
8
|
import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
|
|
9
9
|
import { isolationLevelInvalid, serializationExhausted } from './errors';
|
|
10
|
+
import { markScopeWrote } from './replica-scope';
|
|
10
11
|
import { raw, type SqlFragment } from './sql';
|
|
11
12
|
import { isRetryableState } from './sqlstate';
|
|
12
13
|
import { serializationRetryDelayMs } from './transaction-backoff';
|
|
@@ -206,6 +207,11 @@ async function runNested<T>(outer: TxState, fn: (tx: DbTx) => Promise<T>): Promi
|
|
|
206
207
|
*/
|
|
207
208
|
async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOptions): Promise<T> {
|
|
208
209
|
const client = options.client ?? baseClient();
|
|
210
|
+
// A transaction is assumed to write unless it said otherwise, so every read AFTER it in the same
|
|
211
|
+
// `withReplicaReads` scope is the primary's. The pin below already keeps the transaction's own
|
|
212
|
+
// statements off any replica — this is about the rest of the request, which `replica-client.ts`
|
|
213
|
+
// could not otherwise see: `runRoot` sends through a reserved connection, not through the router.
|
|
214
|
+
if (options.readOnly !== true) markScopeWrote();
|
|
209
215
|
// A pooled BEGIN that lands on a different physical connection than the statements after it is
|
|
210
216
|
// not a transaction at all, so a reservable client pins one connection for the whole scope.
|
|
211
217
|
// Held by a `using` declaration rather than a `finally`, because a `finally` only covers what
|