@shivaedev/test-sql 0.12.0 → 0.14.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +9 -1
  3. package/dist/server/adapters/environmentVariable.d.ts +2 -0
  4. package/dist/server/adapters/environmentVariable.d.ts.map +1 -0
  5. package/dist/server/adapters/environmentVariable.js +5 -0
  6. package/dist/server/adapters/environmentVariable.js.map +1 -0
  7. package/dist/server/adapters/notificationFilter.d.ts +3 -0
  8. package/dist/server/adapters/notificationFilter.d.ts.map +1 -0
  9. package/dist/server/adapters/notificationFilter.js +25 -0
  10. package/dist/server/adapters/notificationFilter.js.map +1 -0
  11. package/dist/server/adapters/openDisconnectProxy.d.ts +14 -0
  12. package/dist/server/adapters/openDisconnectProxy.d.ts.map +1 -0
  13. package/dist/server/adapters/openDisconnectProxy.js +110 -0
  14. package/dist/server/adapters/openDisconnectProxy.js.map +1 -0
  15. package/dist/server/disconnectProxy.d.ts +18 -0
  16. package/dist/server/disconnectProxy.d.ts.map +1 -0
  17. package/dist/server/disconnectProxy.js +6 -0
  18. package/dist/server/disconnectProxy.js.map +1 -0
  19. package/dist/server/emptyPostgres.d.ts +4 -0
  20. package/dist/server/emptyPostgres.d.ts.map +1 -0
  21. package/dist/server/emptyPostgres.js +10 -0
  22. package/dist/server/emptyPostgres.js.map +1 -0
  23. package/dist/server/raceWithLock.d.ts +1 -0
  24. package/dist/server/raceWithLock.d.ts.map +1 -1
  25. package/dist/server/raceWithLock.js +16 -5
  26. package/dist/server/raceWithLock.js.map +1 -1
  27. package/package.json +6 -6
  28. package/src/server/adapters/environmentVariable.ts +5 -0
  29. package/src/server/adapters/notificationFilter.ts +24 -0
  30. package/src/server/adapters/openDisconnectProxy.ts +110 -0
  31. package/src/server/disconnectProxy.ts +14 -0
  32. package/src/server/emptyPostgres.ts +10 -0
  33. package/src/server/raceWithLock.ts +26 -7
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0 - 2026-10-30
4
+
5
+ ### Added
6
+
7
+ - A scoped PostgreSQL disconnect proxy supports disconnecting, refusing and stalling traffic, holding disconnect acknowledgements, observing open clients and dropping LISTEN notifications. Test kits can share the environment reader and empty PostgreSQL template.
8
+ - Shared story answer readouts translate Effect failures into words for package test kits.
9
+
10
+ ## 0.13.0 - 2026-10-30
11
+
12
+ ### Added
13
+
14
+ - `raceWithLock` takes `count: "every blocked session"` for contenders whose SQL runs on other fibers, such as an HTTP server's handlers. The holder then releases once it blocks as many sessions as there are effects.
15
+
16
+ ### Fixed
17
+
18
+ - `raceWithLock` releases its holder once its own contenders wait, instead of hanging until its timeout when another session also waits on the holder. Each contender's statements now start with a `/* raceWithLock <holder pid>.<index> */` comment.
19
+
3
20
  ## 0.12.0 - 2026-10-30
4
21
 
5
22
  ### Added
package/README.md CHANGED
@@ -156,7 +156,7 @@ Inside an isolated block, `PostgresTestDatabase.url` names that clone. Use it to
156
156
 
157
157
  ### Race stories
158
158
 
159
- Use `raceWithLock` with a live story clock inside an isolated database, outside a wrapping transaction. Supply the native SQL Effect that takes a row, table or transaction advisory lock, and one Effect per competing call. The helper holds the lock in a separate transaction, starts the calls, and waits for PostgreSQL's blocker graph to contain every contender before releasing the holder. Keep other lock-taking work out of that database during the race, and allow at least `effects.length + 1` pool connections. Contenders keep their own transaction and service conventions.
159
+ Use `raceWithLock` with a live story clock inside an isolated database, outside a wrapping transaction. Supply the native SQL Effect that takes a row, table or transaction advisory lock, and one Effect per competing call. The helper holds the lock in a separate transaction, starts the calls, and waits until PostgreSQL's blocker graph contains every contender before releasing the holder. Each contender's statements start with a `/* raceWithLock <holder pid>.<index> */` comment, so the helper counts only its own contenders and another session waiting on the holder does not hold up the release. When the contenders' SQL runs on other fibers, such as an HTTP server's handlers, the tag cannot reach it; set `count: "every blocked session"` to release once the holder blocks as many sessions as there are effects, and keep other waiters out of that database. Other lock-taking work in that database can still queue ahead of the contenders and change who wins, and the helper needs at least `effects.length + 1` pool connections. Contenders keep their own transaction and service conventions.
160
160
 
161
161
  ```ts
162
162
  import { raceWithLock } from "@shivaedev/test-sql/server/raceWithLock.ts";
@@ -249,6 +249,14 @@ With a PostgreSQL client in the current Context, the assertion compares names th
249
249
 
250
250
  The assertion uses the SQL `Model`'s selected database fields, including generated ids and excluding JSON-only fields. Supply an exact PostgreSQL type for every field: schemas such as `Schema.Number` do not determine whether storage is `integer`, `numeric` or `double precision`. It fails with a typed `ModelColumnMismatch` containing all detected missing, extra and wrongly typed column problems. The type map is checked against the model both by TypeScript and at runtime. This check covers names and types; it does not check defaults, nullability, constraints or application data transformations.
251
251
 
252
+ ### Database faults
253
+
254
+ `disconnectProxy` from `@shivaedev/test-sql/server/disconnectProxy.ts` acquires a loopback TCP proxy in the current Effect scope. Give its `url` to the application database client. `disconnect()` cuts existing connections, `setReachable(false)` also refuses new connections, and `setResponsive(false)` stalls traffic until `setResponsive(true)` resumes it. Closing the scope destroys the sockets and closes the listener.
255
+
256
+ For teardown stories, pass `{ holdDisconnects: true }`: `disconnecting` signals the first upstream disconnect, `allHeld` signals when every upstream has disconnected, and `release()` forwards the held acknowledgements. For LISTEN health stories, `{ dropNotifications: true }` filters PostgreSQL notifications while other SQL traffic continues; `dropped` signals a filtered notification.
257
+
258
+ `@shivaedev/test-sql/server/emptyPostgres.ts` provides `databaseUrl` and `postgres`, an empty migration template selected by `PLATFORM_SQL_TEST_DATABASE_URL`. Use it with `withIsolatedDatabase` for kits that initialize their own schema. `environmentVariable(name)` from `@shivaedev/test-sql/server/adapters/environmentVariable.ts` reads an optional string through Effect Config when declaring a kit's `runIf`.
259
+
252
260
  ### API
253
261
 
254
262
  Import each name from the module that defines it. The package has no root entry. Every source module is available through its `.ts` subpath, including modules under `server/internal/`.
@@ -0,0 +1,2 @@
1
+ export declare function environmentVariable(name: string): string | undefined;
2
+ //# sourceMappingURL=environmentVariable.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"environmentVariable.d.ts","sourceRoot":"","sources":["../../../src/server/adapters/environmentVariable.ts"],"names":[],"mappings":"AAEA,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEpE"}
@@ -0,0 +1,5 @@
1
+ import { Config, Effect, Option } from "effect";
2
+ export function environmentVariable(name) {
3
+ return Option.getOrUndefined(Effect.runSync(Config.option(Config.String(name))));
4
+ }
5
+ //# sourceMappingURL=environmentVariable.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"environmentVariable.js","sourceRoot":"","sources":["../../../src/server/adapters/environmentVariable.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhD,MAAM,UAAU,mBAAmB,CAAC,IAAY;IAC/C,OAAO,MAAM,CAAC,cAAc,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;AAClF,CAAC"}
@@ -0,0 +1,3 @@
1
+ import { Transform } from "node:stream";
2
+ export declare function notificationFilter(dropped: () => void): Transform;
3
+ //# sourceMappingURL=notificationFilter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notificationFilter.d.ts","sourceRoot":"","sources":["../../../src/server/adapters/notificationFilter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,IAAI,aAqBrD"}
@@ -0,0 +1,25 @@
1
+ import { Transform } from "node:stream";
2
+ export function notificationFilter(dropped) {
3
+ let pending = Buffer.alloc(0);
4
+ const filter = new Transform({
5
+ transform(chunk, _encoding, callback) {
6
+ pending = Buffer.concat([pending, chunk]);
7
+ while (pending.length >= 5) {
8
+ const length = pending.readInt32BE(1) + 1;
9
+ if (pending.length < length) {
10
+ break;
11
+ }
12
+ if (pending[0] === 65) {
13
+ dropped();
14
+ }
15
+ else {
16
+ filter.push(pending.subarray(0, length));
17
+ }
18
+ pending = pending.subarray(length);
19
+ }
20
+ callback();
21
+ },
22
+ });
23
+ return filter;
24
+ }
25
+ //# sourceMappingURL=notificationFilter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notificationFilter.js","sourceRoot":"","sources":["../../../src/server/adapters/notificationFilter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,MAAM,UAAU,kBAAkB,CAAC,OAAmB;IACrD,IAAI,OAAO,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC9B,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC;QAC5B,SAAS,CAAC,KAAa,EAAE,SAAS,EAAE,QAAQ;YAC3C,OAAO,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC;YAC1C,OAAO,OAAO,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC;gBAC5B,MAAM,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;gBAC1C,IAAI,OAAO,CAAC,MAAM,GAAG,MAAM,EAAE,CAAC;oBAC7B,MAAM;gBACP,CAAC;gBACD,IAAI,OAAO,CAAC,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC;oBACvB,OAAO,EAAE,CAAC;gBACX,CAAC;qBAAM,CAAC;oBACP,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;gBAC1C,CAAC;gBACD,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;YACpC,CAAC;YACD,QAAQ,EAAE,CAAC;QACZ,CAAC;KACD,CAAC,CAAC;IACH,OAAO,MAAM,CAAC;AACf,CAAC"}
@@ -0,0 +1,14 @@
1
+ import type { DisconnectProxyOptions } from "#disconnectProxy.ts";
2
+ export declare function openDisconnectProxy(destination: string, options: DisconnectProxyOptions): Promise<{
3
+ allHeld: Promise<void>;
4
+ close: () => Promise<void>;
5
+ disconnect: () => void;
6
+ disconnecting: Promise<void>;
7
+ dropped: Promise<void>;
8
+ openClients: () => number;
9
+ release: () => void;
10
+ setReachable: (value: boolean) => void;
11
+ setResponsive: (value: boolean) => void;
12
+ url: string;
13
+ }>;
14
+ //# sourceMappingURL=openDisconnectProxy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openDisconnectProxy.d.ts","sourceRoot":"","sources":["../../../src/server/adapters/openDisconnectProxy.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AAGlE,wBAAsB,mBAAmB,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,sBAAsB;;;;;;;;0BAuFtE,OAAO;2BAMN,OAAO;;GAY/B"}
@@ -0,0 +1,110 @@
1
+ import { createConnection, createServer } from "node:net";
2
+ import { notificationFilter } from "./notificationFilter.js";
3
+ export async function openDisconnectProxy(destination, options) {
4
+ const target = new URL(destination);
5
+ const address = { host: target.hostname, port: Number(target.port || 5432) };
6
+ const sockets = new Set();
7
+ const clients = new Set();
8
+ const pending = new Set();
9
+ const disconnecting = Promise.withResolvers();
10
+ const allHeld = Promise.withResolvers();
11
+ const dropped = Promise.withResolvers();
12
+ let held = options.holdDisconnects === true;
13
+ let reachable = true;
14
+ let responsive = true;
15
+ let upstreams = 0;
16
+ function disconnect() {
17
+ for (const socket of sockets) {
18
+ socket.destroy();
19
+ }
20
+ }
21
+ function release() {
22
+ held = false;
23
+ for (const socket of pending) {
24
+ socket.end();
25
+ }
26
+ }
27
+ const server = createServer({ allowHalfOpen: true }, (client) => {
28
+ if (!reachable) {
29
+ client.destroy();
30
+ return;
31
+ }
32
+ const upstream = createConnection(address);
33
+ clients.add(client);
34
+ upstreams += 1;
35
+ for (const socket of [client, upstream]) {
36
+ sockets.add(socket);
37
+ socket.on("close", () => sockets.delete(socket));
38
+ socket.on("error", () => {
39
+ client.destroy();
40
+ upstream.destroy();
41
+ });
42
+ }
43
+ client.pipe(upstream, { end: false });
44
+ const incoming = options.dropNotifications === true ? upstream.pipe(notificationFilter(dropped.resolve)) : upstream;
45
+ incoming.pipe(client, { end: false });
46
+ client.on("end", () => {
47
+ clients.delete(client);
48
+ upstream.end();
49
+ });
50
+ client.on("close", () => clients.delete(client));
51
+ upstream.on("end", () => {
52
+ pending.add(client);
53
+ disconnecting.resolve();
54
+ if (pending.size === upstreams) {
55
+ allHeld.resolve();
56
+ }
57
+ if (!held) {
58
+ client.end();
59
+ }
60
+ });
61
+ if (!responsive) {
62
+ client.pause();
63
+ upstream.pause();
64
+ }
65
+ });
66
+ await new Promise((resolve, reject) => {
67
+ server.once("error", reject);
68
+ server.listen(0, "127.0.0.1", resolve);
69
+ });
70
+ const listening = server.address();
71
+ if (listening === null || typeof listening === "string") {
72
+ throw new Error("Expected a TCP listener");
73
+ }
74
+ target.hostname = "127.0.0.1";
75
+ target.port = String(listening.port);
76
+ if (options.dropNotifications === true) {
77
+ target.searchParams.set("sslmode", "disable");
78
+ }
79
+ return {
80
+ allHeld: allHeld.promise,
81
+ close: async () => {
82
+ disconnect();
83
+ await new Promise((resolve) => server.close(() => resolve()));
84
+ },
85
+ disconnect,
86
+ disconnecting: disconnecting.promise,
87
+ dropped: dropped.promise,
88
+ openClients: () => clients.size,
89
+ release,
90
+ setReachable: (value) => {
91
+ reachable = value;
92
+ if (!value) {
93
+ disconnect();
94
+ }
95
+ },
96
+ setResponsive: (value) => {
97
+ responsive = value;
98
+ for (const socket of sockets) {
99
+ if (value) {
100
+ socket.resume();
101
+ }
102
+ else {
103
+ socket.pause();
104
+ }
105
+ }
106
+ },
107
+ url: target.toString(),
108
+ };
109
+ }
110
+ //# sourceMappingURL=openDisconnectProxy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openDisconnectProxy.js","sourceRoot":"","sources":["../../../src/server/adapters/openDisconnectProxy.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ import { Effect } from "effect";
2
+ export interface DisconnectProxyOptions {
3
+ readonly dropNotifications?: boolean;
4
+ readonly holdDisconnects?: boolean;
5
+ }
6
+ export declare const disconnectProxy: (destination: string, options?: DisconnectProxyOptions | undefined) => Effect.Effect<{
7
+ allHeld: Promise<void>;
8
+ close: () => Promise<void>;
9
+ disconnect: () => void;
10
+ disconnecting: Promise<void>;
11
+ dropped: Promise<void>;
12
+ openClients: () => number;
13
+ release: () => void;
14
+ setReachable: (value: boolean) => void;
15
+ setResponsive: (value: boolean) => void;
16
+ url: string;
17
+ }, never, import("effect/Scope").Scope>;
18
+ //# sourceMappingURL=disconnectProxy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"disconnectProxy.d.ts","sourceRoot":"","sources":["../../src/server/disconnectProxy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAGhC,MAAM,WAAW,sBAAsB;IACtC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IACrC,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC;CACnC;AAED,eAAO,MAAM,eAAe;;;;;;;;;;;uCAK1B,CAAC"}
@@ -0,0 +1,6 @@
1
+ import { Effect } from "effect";
2
+ import { openDisconnectProxy } from "#adapters/openDisconnectProxy.ts";
3
+ export const disconnectProxy = Effect.fnUntraced(function* (destination, options = {}) {
4
+ return yield* Effect.acquireRelease(Effect.promise(() => openDisconnectProxy(destination, options)), (proxy) => Effect.promise(proxy.close));
5
+ });
6
+ //# sourceMappingURL=disconnectProxy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"disconnectProxy.js","sourceRoot":"","sources":["../../src/server/disconnectProxy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AAOvE,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,WAAmB,EAAE,OAAO,GAA2B,EAAE;IACpH,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,cAAc,CAClC,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,mBAAmB,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC,EAC/D,CAAC,KAAK,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CACtC,CAAC;AACH,CAAC,CAAC,CAAC"}
@@ -0,0 +1,4 @@
1
+ import { Migrator } from "effect/sql";
2
+ export declare const databaseUrl: string | undefined;
3
+ export declare const postgres: import("effect/Layer").Layer<import("@effect/sql-pg/PgClient").PgClient | import("./database.ts").PostgresTestDatabase | import("effect/reactivity/Reactivity").Reactivity | import("effect/sql/SqlClient").SqlClient, Migrator.MigrationError | import("effect/Schema").SchemaError | import("effect/sql/SqlError").SqlError, never>;
4
+ //# sourceMappingURL=emptyPostgres.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"emptyPostgres.d.ts","sourceRoot":"","sources":["../../src/server/emptyPostgres.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAItC,eAAO,MAAM,WAAW,oBAAwD,CAAC;AACjF,eAAO,MAAM,QAAQ,uUAGnB,CAAC"}
@@ -0,0 +1,10 @@
1
+ import { Redacted } from "effect";
2
+ import { Migrator } from "effect/sql";
3
+ import { environmentVariable } from "#adapters/environmentVariable.ts";
4
+ import { makePostgresTestLayer } from "#postgres.ts";
5
+ export const databaseUrl = environmentVariable("PLATFORM_SQL_TEST_DATABASE_URL");
6
+ export const postgres = makePostgresTestLayer({
7
+ migrations: { loader: Migrator.fromRecord({}), lockTimeout: "5 seconds" },
8
+ url: Redacted.make(databaseUrl ?? "postgresql://integration-tests-disabled"),
9
+ });
10
+ //# sourceMappingURL=emptyPostgres.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"emptyPostgres.js","sourceRoot":"","sources":["../../src/server/emptyPostgres.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAClC,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AACvE,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAErD,MAAM,CAAC,MAAM,WAAW,GAAG,mBAAmB,CAAC,gCAAgC,CAAC,CAAC;AACjF,MAAM,CAAC,MAAM,QAAQ,GAAG,qBAAqB,CAAC;IAC7C,UAAU,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,WAAW,EAAE,WAAW,EAAE;IACzE,GAAG,EAAE,QAAQ,CAAC,IAAI,CAAC,WAAW,IAAI,yCAAyC,CAAC;CAC5E,CAAC,CAAC"}
@@ -5,5 +5,6 @@ export declare function raceWithLock<A, E, R, TLockError, TLockServices>(options
5
5
  readonly effects: readonly Effect.Effect<A, E, R>[];
6
6
  readonly timeout?: Duration.Input;
7
7
  readonly release?: "commit" | "rollback";
8
+ readonly count?: "contenders" | "every blocked session";
8
9
  }): Effect.Effect<import("effect/Result").Result<A, E>[], TLockError | import("effect/sql/SqlError").SqlError | import("effect/Cause").TimeoutError, import("effect/reactivity/Reactivity").Reactivity | SqlClient.SqlClient | Exclude<R, import("effect/Scope").Scope> | Exclude<Exclude<TLockServices, import("effect/Scope").Scope>, import("effect/Scope").Scope>>;
9
10
  //# sourceMappingURL=raceWithLock.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"raceWithLock.d.ts","sourceRoot":"","sources":["../../src/server/raceWithLock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,QAAQ,EAAE,MAAM,EAAmB,MAAM,QAAQ,CAAC;AAC1E,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAIvC,wBAAgB,YAAY,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,UAAU,EAAE,aAAa,EAAE,OAAO,EAAE;IACzE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,UAAU,EAAE,aAAa,CAAC,CAAC;IACjE,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;IACpD,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAC;IAClC,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,GAAG,UAAU,CAAC;CACzC,sWA6BA"}
1
+ {"version":3,"file":"raceWithLock.d.ts","sourceRoot":"","sources":["../../src/server/raceWithLock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,QAAQ,EAAE,MAAM,EAAmB,MAAM,QAAQ,CAAC;AAC1E,OAAO,EAAE,SAAS,EAAa,MAAM,YAAY,CAAC;AAUlD,wBAAgB,YAAY,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,UAAU,EAAE,aAAa,EAAE,OAAO,EAAE;IACzE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,UAAU,EAAE,aAAa,CAAC,CAAC;IACjE,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;IACpD,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAC;IAClC,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,GAAG,UAAU,CAAC;IACzC,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,GAAG,uBAAuB,CAAC;CACxD,sWAyCA"}
@@ -1,25 +1,36 @@
1
1
  import { Deferred, Effect, Fiber, Schedule } from "effect";
2
- import { SqlClient } from "effect/sql";
2
+ import { SqlClient, Statement } from "effect/sql";
3
3
  import { transact } from "@shivaedev/sql/transact.ts";
4
4
  import { withTestTransaction } from "#withTestTransaction.ts";
5
+ function tagged(effect, tag) {
6
+ return Effect.provideService(effect, Statement.CurrentTransformer, (statement, sql) => Effect.succeed(sql `${sql.literal(`/* ${tag} */`)} ${statement}`));
7
+ }
5
8
  export function raceWithLock(options) {
9
+ const everyBlockedSession = options.count === "every blocked session";
6
10
  return Effect.scoped(Effect.gen(function* () {
7
11
  const sql = yield* SqlClient.SqlClient;
8
12
  const ready = yield* Deferred.make();
9
13
  const started = yield* Deferred.make();
10
14
  const hold = Effect.gen(function* () {
11
15
  yield* options.lock;
12
- yield* Deferred.succeed(ready, undefined);
16
+ const [holder] = yield* sql `select pg_backend_pid() as pid`;
17
+ const race = `raceWithLock ${holder?.pid}.`;
18
+ yield* Deferred.succeed(ready, race);
13
19
  yield* Deferred.await(started);
20
+ // PostgreSQL signals nothing when a session starts waiting on a lock, so the holder polls its blocker graph.
14
21
  yield* sql `select pg_stat_clear_snapshot()`.pipe(Effect.andThen(sql `with recursive blocked(pid) as (
15
22
  select pid from pg_stat_activity where pg_backend_pid() = any(pg_blocking_pids(pid))
16
23
  union
17
24
  select activity.pid from pg_stat_activity activity join blocked on blocked.pid = any(pg_blocking_pids(activity.pid))
18
- ) select count(*)::integer as waiting from blocked`), Effect.repeat({ schedule: Schedule.spaced("10 millis"), while: ([row]) => row?.waiting !== options.effects.length }));
25
+ ) select ${everyBlockedSession ? sql `count(*)` : sql `count(distinct split_part(activity.query, ' */', 1))`}::integer as waiting
26
+ from pg_stat_activity activity join blocked using (pid)
27
+ where ${everyBlockedSession ? sql `true` : sql `starts_with(activity.query, ${`/* ${race}`})`}`), Effect.repeat({ schedule: Schedule.spaced("10 millis"), while: ([row]) => (row?.waiting ?? 0) < options.effects.length }));
19
28
  });
20
29
  const holder = yield* (options.release === "commit" ? transact(Effect.scoped(hold), { onSqlError: (error) => error }) : withTestTransaction(hold)).pipe(Effect.forkScoped);
21
- yield* Effect.raceFirst(Deferred.await(ready), Fiber.join(holder).pipe(Effect.andThen(Effect.never)));
22
- const contenders = yield* Effect.forEach(options.effects, Effect.result, { concurrency: "unbounded" }).pipe(Effect.forkScoped);
30
+ const race = yield* Effect.raceFirst(Deferred.await(ready), Fiber.join(holder).pipe(Effect.andThen(Effect.never)));
31
+ const contenders = yield* Effect.forEach(options.effects, (effect, index) => Effect.result(everyBlockedSession ? effect : tagged(effect, `${race}${index}`)), {
32
+ concurrency: "unbounded",
33
+ }).pipe(Effect.forkScoped);
23
34
  yield* Deferred.succeed(started, undefined);
24
35
  yield* Effect.raceFirst(Fiber.join(holder), Fiber.join(contenders).pipe(Effect.andThen(Effect.never)));
25
36
  return yield* Fiber.join(contenders);
@@ -1 +1 @@
1
- {"version":3,"file":"raceWithLock.js","sourceRoot":"","sources":["../../src/server/raceWithLock.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"raceWithLock.js","sourceRoot":"","sources":["../../src/server/raceWithLock.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/test-sql",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "PostgreSQL rollback and real-commit test isolation for Effect",
5
5
  "keywords": [
6
6
  "effect"
@@ -43,16 +43,16 @@
43
43
  "README.md"
44
44
  ],
45
45
  "dependencies": {
46
- "@shivaedev/sql": "0.26.0"
46
+ "@shivaedev/sql": "0.26.1"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@effect/sql-pg": "4.0.6",
50
50
  "@effect/vitest": "4.0.6",
51
- "@shivaedev/jobs": "0.23.0",
52
- "@shivaedev/quality": "0.46.0",
51
+ "@shivaedev/jobs": "0.23.1",
52
+ "@shivaedev/quality": "0.47.0",
53
53
  "@shivaedev/service": "0.6.0",
54
- "@shivaedev/test": "0.11.0",
55
- "@shivaedev/test-story": "0.25.0",
54
+ "@shivaedev/test": "0.12.0",
55
+ "@shivaedev/test-story": "0.26.0",
56
56
  "@types/node": "24.19.2",
57
57
  "effect": "4.0.6",
58
58
  "pg-boss": "12.38.0",
@@ -0,0 +1,5 @@
1
+ import { Config, Effect, Option } from "effect";
2
+
3
+ export function environmentVariable(name: string): string | undefined {
4
+ return Option.getOrUndefined(Effect.runSync(Config.option(Config.String(name))));
5
+ }
@@ -0,0 +1,24 @@
1
+ import { Transform } from "node:stream";
2
+
3
+ export function notificationFilter(dropped: () => void) {
4
+ let pending = Buffer.alloc(0);
5
+ const filter = new Transform({
6
+ transform(chunk: Buffer, _encoding, callback) {
7
+ pending = Buffer.concat([pending, chunk]);
8
+ while (pending.length >= 5) {
9
+ const length = pending.readInt32BE(1) + 1;
10
+ if (pending.length < length) {
11
+ break;
12
+ }
13
+ if (pending[0] === 65) {
14
+ dropped();
15
+ } else {
16
+ filter.push(pending.subarray(0, length));
17
+ }
18
+ pending = pending.subarray(length);
19
+ }
20
+ callback();
21
+ },
22
+ });
23
+ return filter;
24
+ }
@@ -0,0 +1,110 @@
1
+ import { createConnection, createServer, type Socket } from "node:net";
2
+ import type { DisconnectProxyOptions } from "#disconnectProxy.ts";
3
+ import { notificationFilter } from "./notificationFilter.ts";
4
+
5
+ export async function openDisconnectProxy(destination: string, options: DisconnectProxyOptions) {
6
+ const target = new URL(destination);
7
+ const address = { host: target.hostname, port: Number(target.port || 5432) };
8
+ const sockets = new Set<Socket>();
9
+ const clients = new Set<Socket>();
10
+ const pending = new Set<Socket>();
11
+ const disconnecting = Promise.withResolvers<void>();
12
+ const allHeld = Promise.withResolvers<void>();
13
+ const dropped = Promise.withResolvers<void>();
14
+ let held = options.holdDisconnects === true;
15
+ let reachable = true;
16
+ let responsive = true;
17
+ let upstreams = 0;
18
+ function disconnect() {
19
+ for (const socket of sockets) {
20
+ socket.destroy();
21
+ }
22
+ }
23
+ function release() {
24
+ held = false;
25
+ for (const socket of pending) {
26
+ socket.end();
27
+ }
28
+ }
29
+ const server = createServer({ allowHalfOpen: true }, (client) => {
30
+ if (!reachable) {
31
+ client.destroy();
32
+ return;
33
+ }
34
+ const upstream = createConnection(address);
35
+ clients.add(client);
36
+ upstreams += 1;
37
+ for (const socket of [client, upstream]) {
38
+ sockets.add(socket);
39
+ socket.on("close", () => sockets.delete(socket));
40
+ socket.on("error", () => {
41
+ client.destroy();
42
+ upstream.destroy();
43
+ });
44
+ }
45
+ client.pipe(upstream, { end: false });
46
+ const incoming = options.dropNotifications === true ? upstream.pipe(notificationFilter(dropped.resolve)) : upstream;
47
+ incoming.pipe(client, { end: false });
48
+ client.on("end", () => {
49
+ clients.delete(client);
50
+ upstream.end();
51
+ });
52
+ client.on("close", () => clients.delete(client));
53
+ upstream.on("end", () => {
54
+ pending.add(client);
55
+ disconnecting.resolve();
56
+ if (pending.size === upstreams) {
57
+ allHeld.resolve();
58
+ }
59
+ if (!held) {
60
+ client.end();
61
+ }
62
+ });
63
+ if (!responsive) {
64
+ client.pause();
65
+ upstream.pause();
66
+ }
67
+ });
68
+ await new Promise<void>((resolve, reject) => {
69
+ server.once("error", reject);
70
+ server.listen(0, "127.0.0.1", resolve);
71
+ });
72
+ const listening = server.address();
73
+ if (listening === null || typeof listening === "string") {
74
+ throw new Error("Expected a TCP listener");
75
+ }
76
+ target.hostname = "127.0.0.1";
77
+ target.port = String(listening.port);
78
+ if (options.dropNotifications === true) {
79
+ target.searchParams.set("sslmode", "disable");
80
+ }
81
+ return {
82
+ allHeld: allHeld.promise,
83
+ close: async () => {
84
+ disconnect();
85
+ await new Promise<void>((resolve) => server.close(() => resolve()));
86
+ },
87
+ disconnect,
88
+ disconnecting: disconnecting.promise,
89
+ dropped: dropped.promise,
90
+ openClients: () => clients.size,
91
+ release,
92
+ setReachable: (value: boolean) => {
93
+ reachable = value;
94
+ if (!value) {
95
+ disconnect();
96
+ }
97
+ },
98
+ setResponsive: (value: boolean) => {
99
+ responsive = value;
100
+ for (const socket of sockets) {
101
+ if (value) {
102
+ socket.resume();
103
+ } else {
104
+ socket.pause();
105
+ }
106
+ }
107
+ },
108
+ url: target.toString(),
109
+ };
110
+ }
@@ -0,0 +1,14 @@
1
+ import { Effect } from "effect";
2
+ import { openDisconnectProxy } from "#adapters/openDisconnectProxy.ts";
3
+
4
+ export interface DisconnectProxyOptions {
5
+ readonly dropNotifications?: boolean;
6
+ readonly holdDisconnects?: boolean;
7
+ }
8
+
9
+ export const disconnectProxy = Effect.fnUntraced(function* (destination: string, options: DisconnectProxyOptions = {}) {
10
+ return yield* Effect.acquireRelease(
11
+ Effect.promise(() => openDisconnectProxy(destination, options)),
12
+ (proxy) => Effect.promise(proxy.close),
13
+ );
14
+ });
@@ -0,0 +1,10 @@
1
+ import { Redacted } from "effect";
2
+ import { Migrator } from "effect/sql";
3
+ import { environmentVariable } from "#adapters/environmentVariable.ts";
4
+ import { makePostgresTestLayer } from "#postgres.ts";
5
+
6
+ export const databaseUrl = environmentVariable("PLATFORM_SQL_TEST_DATABASE_URL");
7
+ export const postgres = makePostgresTestLayer({
8
+ migrations: { loader: Migrator.fromRecord({}), lockTimeout: "5 seconds" },
9
+ url: Redacted.make(databaseUrl ?? "postgresql://integration-tests-disabled"),
10
+ });
@@ -1,37 +1,56 @@
1
1
  import { Deferred, type Duration, Effect, Fiber, Schedule } from "effect";
2
- import { SqlClient } from "effect/sql";
2
+ import { SqlClient, Statement } from "effect/sql";
3
3
  import { transact } from "@shivaedev/sql/transact.ts";
4
4
  import { withTestTransaction } from "#withTestTransaction.ts";
5
5
 
6
+ function tagged<A, E, R>(effect: Effect.Effect<A, E, R>, tag: string) {
7
+ return Effect.provideService(effect, Statement.CurrentTransformer, (statement, sql) =>
8
+ Effect.succeed(sql`${sql.literal(`/* ${tag} */`)} ${statement}`),
9
+ );
10
+ }
11
+
6
12
  export function raceWithLock<A, E, R, TLockError, TLockServices>(options: {
7
13
  readonly lock: Effect.Effect<unknown, TLockError, TLockServices>;
8
14
  readonly effects: readonly Effect.Effect<A, E, R>[];
9
15
  readonly timeout?: Duration.Input;
10
16
  readonly release?: "commit" | "rollback";
17
+ readonly count?: "contenders" | "every blocked session";
11
18
  }) {
19
+ const everyBlockedSession = options.count === "every blocked session";
12
20
  return Effect.scoped(
13
21
  Effect.gen(function* () {
14
22
  const sql = yield* SqlClient.SqlClient;
15
- const ready = yield* Deferred.make<void>();
23
+ const ready = yield* Deferred.make<string>();
16
24
  const started = yield* Deferred.make<void>();
17
25
  const hold = Effect.gen(function* () {
18
26
  yield* options.lock;
19
- yield* Deferred.succeed(ready, undefined);
27
+ const [holder] = yield* sql<{ pid: number }>`select pg_backend_pid() as pid`;
28
+ const race = `raceWithLock ${holder?.pid}.`;
29
+ yield* Deferred.succeed(ready, race);
20
30
  yield* Deferred.await(started);
31
+ // PostgreSQL signals nothing when a session starts waiting on a lock, so the holder polls its blocker graph.
21
32
  yield* sql`select pg_stat_clear_snapshot()`.pipe(
22
33
  Effect.andThen(sql<{ waiting: number }>`with recursive blocked(pid) as (
23
34
  select pid from pg_stat_activity where pg_backend_pid() = any(pg_blocking_pids(pid))
24
35
  union
25
36
  select activity.pid from pg_stat_activity activity join blocked on blocked.pid = any(pg_blocking_pids(activity.pid))
26
- ) select count(*)::integer as waiting from blocked`),
27
- Effect.repeat({ schedule: Schedule.spaced("10 millis"), while: ([row]) => row?.waiting !== options.effects.length }),
37
+ ) select ${everyBlockedSession ? sql`count(*)` : sql`count(distinct split_part(activity.query, ' */', 1))`}::integer as waiting
38
+ from pg_stat_activity activity join blocked using (pid)
39
+ where ${everyBlockedSession ? sql`true` : sql`starts_with(activity.query, ${`/* ${race}`})`}`),
40
+ Effect.repeat({ schedule: Schedule.spaced("10 millis"), while: ([row]) => (row?.waiting ?? 0) < options.effects.length }),
28
41
  );
29
42
  });
30
43
  const holder = yield* (
31
44
  options.release === "commit" ? transact(Effect.scoped(hold), { onSqlError: (error) => error }) : withTestTransaction(hold)
32
45
  ).pipe(Effect.forkScoped);
33
- yield* Effect.raceFirst(Deferred.await(ready), Fiber.join(holder).pipe(Effect.andThen(Effect.never)));
34
- const contenders = yield* Effect.forEach(options.effects, Effect.result, { concurrency: "unbounded" }).pipe(Effect.forkScoped);
46
+ const race = yield* Effect.raceFirst(Deferred.await(ready), Fiber.join(holder).pipe(Effect.andThen(Effect.never)));
47
+ const contenders = yield* Effect.forEach(
48
+ options.effects,
49
+ (effect, index) => Effect.result(everyBlockedSession ? effect : tagged(effect, `${race}${index}`)),
50
+ {
51
+ concurrency: "unbounded",
52
+ },
53
+ ).pipe(Effect.forkScoped);
35
54
  yield* Deferred.succeed(started, undefined);
36
55
  yield* Effect.raceFirst(Fiber.join(holder), Fiber.join(contenders).pipe(Effect.andThen(Effect.never)));
37
56
  return yield* Fiber.join(contenders);