@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.
@@ -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
+ }
@@ -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
 
@@ -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