@zeroxsolutions/db 10.3.0 → 10.4.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/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## 10.4.0 (2026-10-28)
2
+
3
+ ### 🚀 Features
4
+
5
+ - **db:** wait on a named blocking backend ([e241940](https://github.com/zeroxsolutions/zeroxsolutions/commit/e241940))
6
+
7
+ ### ❤️ Thank You
8
+
9
+ - luongvantuit
10
+
11
+ ## 10.3.1 (2026-10-21)
12
+
13
+ This was a version bump only for @zeroxsolutions/db to align it with other projects, there were no code changes.
14
+
1
15
  ## 10.3.0 (2026-10-20)
2
16
 
3
17
  ### 🚀 Features
package/README.md CHANGED
@@ -40,7 +40,7 @@ declares `"."`, but no `src/index.ts` exists for the build to emit it from, so
40
40
  | `@zeroxsolutions/db/postgres` | PostgreSQL entity, repository, view repository, unit of work, outbox, and types |
41
41
  | `@zeroxsolutions/db/d1` | Cloudflare D1 (SQLite) entity, repository, unit of work, outbox, and types |
42
42
  | `@zeroxsolutions/db/utils` | `defineReads`, `relationsOf`, `withoutRelation`, the read and order types, the filter-value schema + operator enum, the refusals, omit masks |
43
- | `@zeroxsolutions/db/testing` | A throwaway Postgres container carrying your schema, on the engine you name, plus the container timeout and a fake uuid v7 |
43
+ | `@zeroxsolutions/db/testing` | A throwaway Postgres container carrying your schema, on the engine you name, the container timeout, a fake uuid v7, and a wait on a lock |
44
44
  | `@zeroxsolutions/db/containers` | The forced remove that tears down a container you started yourself |
45
45
 
46
46
  ---
@@ -519,6 +519,33 @@ typecheck failure rather than a suite that quietly passed on a major you do not
519
519
  | `CONTAINER_TIMEOUT_MS` | 180s, for the hook that starts a container: an image pull on a cold machine outruns vitest's 5s default |
520
520
  | `fakeUuidV7` | Random bits in a v7 layout: it passes a v7 check, and is not time-ordered like a minted id |
521
521
 
522
+ ### Waiting until one backend blocks another
523
+
524
+ A spec proving that a second transaction waits on a lock the first one holds runs each on its own
525
+ backend with `runOnBackend`, then waits for that pair with `waitUntilBlockedBy`:
526
+
527
+ ```ts
528
+ import { runOnBackend, waitUntilBlockedBy } from '@zeroxsolutions/db/testing';
529
+
530
+ const holder = runOnBackend(database.db, async (tx) => {
531
+ await tx.select().from(notes).where(eq(notes.id, id)).for('update');
532
+ await released;
533
+ });
534
+ await holder.pid;
535
+ const waiter = runOnBackend(database.db, (tx) => tx.select().from(notes).where(eq(notes.id, id)).for('update'));
536
+
537
+ await waitUntilBlockedBy(database.db, waiter, holder);
538
+ ```
539
+
540
+ Counting the sessions `pg_stat_activity` lists as waiting on a lock does not do this job: every spec
541
+ file sharing the server adds its own waiters to the count, so a wait for one passes before your
542
+ session blocks, and a wait for an exact number never passes.
543
+
544
+ | Export | What it is |
545
+ | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
546
+ | `runOnBackend(db, work)` | Opens a transaction, reads its backend's pid, then runs `work` in it. Returns `{ pid, result }`: the pid once the transaction has started, and `work`'s own result |
547
+ | `waitUntilBlockedBy(db, waiter, holder, options?)` | Resolves once `holder` is among the backends `waiter` waits on. Each is a `runOnBackend` handle or a bare pid. Rejects when `waiter`'s result settles first, and after `timeoutMs` (5000; polled every `intervalMs`, 20) |
548
+
522
549
  ### A container you started yourself
523
550
 
524
551
  A global setup that starts its own container, to migrate a template or several databases, tears it down
@@ -0,0 +1,35 @@
1
+ import { type ExtractTablesWithRelations } from 'drizzle-orm';
2
+ import type { PgDatabase, PgQueryResultHKT, PgTransaction } from 'drizzle-orm/pg-core';
3
+ /** Work running in its own transaction, on a backend a spec can name in a lock wait. */
4
+ export interface BackendRun<T> {
5
+ /** The backend the work runs on, known once its transaction has started. */
6
+ pid: Promise<number>;
7
+ /** The work's own result. */
8
+ result: Promise<T>;
9
+ }
10
+ /** How long {@link waitUntilBlockedBy} polls, and how often. */
11
+ export interface BlockedWaitOptions {
12
+ /** Milliseconds before the wait rejects; 5000 when omitted. */
13
+ timeoutMs?: number;
14
+ /** Milliseconds between two polls; 20 when omitted. */
15
+ intervalMs?: number;
16
+ }
17
+ /**
18
+ * Opens a transaction on `db`, reads the backend it runs on, then runs `work` inside it.
19
+ *
20
+ * The transaction holds one pooled connection until `work` settles, so a spec can hold a lock on one
21
+ * backend and queue a second backend behind it. `pid` rejects with `result` when the transaction
22
+ * cannot start.
23
+ */
24
+ export declare function runOnBackend<TSchema extends Record<string, unknown>, T>(db: PgDatabase<PgQueryResultHKT, TSchema>, work: (tx: PgTransaction<PgQueryResultHKT, TSchema, ExtractTablesWithRelations<TSchema>>) => Promise<T>): BackendRun<T>;
25
+ /**
26
+ * Resolves once backend `holder` is among the backends `waiter` waits on for a lock.
27
+ *
28
+ * Only that pair counts: another backend queued behind `holder`, from this spec or from another file
29
+ * sharing the server, never satisfies the wait. Each argument is a {@link BackendRun} or a bare pid.
30
+ *
31
+ * @throws Error when `waiter`'s result settles before `holder` blocks it, which means the lock was
32
+ * never contended, or when `timeoutMs` passes; both messages name the two pids.
33
+ */
34
+ export declare function waitUntilBlockedBy(db: PgDatabase<PgQueryResultHKT, Record<string, unknown>>, waiter: BackendRun<unknown> | number, holder: BackendRun<unknown> | number, options?: BlockedWaitOptions): Promise<void>;
35
+ //# sourceMappingURL=blocking-backend.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blocking-backend.d.ts","sourceRoot":"","sources":["../../src/testing/blocking-backend.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,0BAA0B,EAAO,MAAM,aAAa,CAAC;AACnE,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEvF,wFAAwF;AACxF,MAAM,WAAW,UAAU,CAAC,CAAC;IAC3B,4EAA4E;IAC5E,GAAG,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;IACrB,6BAA6B;IAC7B,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;CACpB;AAED,gEAAgE;AAChE,MAAM,WAAW,kBAAkB;IACjC,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,EACrE,EAAE,EAAE,UAAU,CAAC,gBAAgB,EAAE,OAAO,CAAC,EACzC,IAAI,EAAE,CAAC,EAAE,EAAE,aAAa,CAAC,gBAAgB,EAAE,OAAO,EAAE,0BAA0B,CAAC,OAAO,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,GACtG,UAAU,CAAC,CAAC,CAAC,CAkBf;AAED;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,UAAU,CAAC,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EACzD,MAAM,EAAE,UAAU,CAAC,OAAO,CAAC,GAAG,MAAM,EACpC,MAAM,EAAE,UAAU,CAAC,OAAO,CAAC,GAAG,MAAM,EACpC,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,IAAI,CAAC,CAuBf"}
@@ -0,0 +1,60 @@
1
+ import { sql } from 'drizzle-orm';
2
+ /**
3
+ * Opens a transaction on `db`, reads the backend it runs on, then runs `work` inside it.
4
+ *
5
+ * The transaction holds one pooled connection until `work` settles, so a spec can hold a lock on one
6
+ * backend and queue a second backend behind it. `pid` rejects with `result` when the transaction
7
+ * cannot start.
8
+ */
9
+ export function runOnBackend(db, work) {
10
+ let resolvePid = (_pid) => undefined;
11
+ let rejectPid = (_reason) => undefined;
12
+ const pid = new Promise((resolve, reject) => {
13
+ resolvePid = resolve;
14
+ rejectPid = reject;
15
+ });
16
+ // A caller that awaits only `result` still hears a failed start through it, so the copy of that
17
+ // failure on `pid` must not also surface as an unhandled rejection.
18
+ pid.catch(() => undefined);
19
+ const result = db.transaction(async (tx) => {
20
+ const [backend] = await tx.select({ pid: sql `pid` }).from(sql `pg_backend_pid() as pid`);
21
+ if (backend === undefined)
22
+ throw new Error('pg_backend_pid() returned no row');
23
+ resolvePid(backend.pid);
24
+ return work(tx);
25
+ });
26
+ result.catch(rejectPid);
27
+ return { pid, result };
28
+ }
29
+ /**
30
+ * Resolves once backend `holder` is among the backends `waiter` waits on for a lock.
31
+ *
32
+ * Only that pair counts: another backend queued behind `holder`, from this spec or from another file
33
+ * sharing the server, never satisfies the wait. Each argument is a {@link BackendRun} or a bare pid.
34
+ *
35
+ * @throws Error when `waiter`'s result settles before `holder` blocks it, which means the lock was
36
+ * never contended, or when `timeoutMs` passes; both messages name the two pids.
37
+ */
38
+ export async function waitUntilBlockedBy(db, waiter, holder, options = {}) {
39
+ const { timeoutMs = 5000, intervalMs = 20 } = options;
40
+ const deadline = Date.now() + timeoutMs;
41
+ let waiterEnded = false;
42
+ if (typeof waiter !== 'number') {
43
+ waiter.result.then(() => (waiterEnded = true), () => (waiterEnded = true));
44
+ }
45
+ const waiterPid = typeof waiter === 'number' ? waiter : await waiter.pid;
46
+ const holderPid = typeof holder === 'number' ? holder : await holder.pid;
47
+ for (;;) {
48
+ const [row] = await db
49
+ .select({ blocked: sql `${holderPid} = any(blockers)` })
50
+ .from(sql `pg_blocking_pids(${waiterPid}) as blockers`);
51
+ if (row?.blocked === true)
52
+ return;
53
+ if (waiterEnded)
54
+ throw new Error(`backend ${waiterPid} ended before backend ${holderPid} blocked it`);
55
+ if (Date.now() >= deadline) {
56
+ throw new Error(`timed out after ${timeoutMs} ms waiting for backend ${holderPid} to block backend ${waiterPid}`);
57
+ }
58
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
59
+ }
60
+ }
@@ -1,3 +1,4 @@
1
+ export * from './blocking-backend.js';
1
2
  export * from './disposable-postgres.js';
2
3
  export * from './fake-uuid-v7.js';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA,cAAc,0BAA0B,CAAC;AACzC,cAAc,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,mBAAmB,CAAC"}
@@ -1,2 +1,3 @@
1
+ export * from './blocking-backend.js';
1
2
  export * from './disposable-postgres.js';
2
3
  export * from './fake-uuid-v7.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeroxsolutions/db",
3
- "version": "10.3.0",
3
+ "version": "10.4.0",
4
4
  "description": "Generic Drizzle toolkit - the id column and audit timestamps a table takes, a base repository, the unit-of-work and transactional outbox impls, and the filter/sort/pagination query helpers, for PostgreSQL (postgres-js) and Cloudflare D1; ships no domain schema. Shared across ZeroXSolutions services.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -43,7 +43,7 @@
43
43
  "tslib": "^2.3.0",
44
44
  "uuid": "^14.0.1",
45
45
  "@zeroxsolutions/cosmic": "0.3.4",
46
- "@zeroxsolutions/query": "0.7.4"
46
+ "@zeroxsolutions/query": "0.7.5"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@testcontainers/postgresql": "^12.1.0",