@shivaedev/test-sql 0.13.0 → 0.15.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 (38) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +24 -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/observeUntil.d.ts +26 -0
  24. package/dist/server/observeUntil.d.ts.map +1 -0
  25. package/dist/server/observeUntil.js +35 -0
  26. package/dist/server/observeUntil.js.map +1 -0
  27. package/dist/server/raceWithLock.d.ts +1 -1
  28. package/dist/server/raceWithLock.d.ts.map +1 -1
  29. package/dist/server/raceWithLock.js +12 -11
  30. package/dist/server/raceWithLock.js.map +1 -1
  31. package/package.json +6 -6
  32. package/src/server/adapters/environmentVariable.ts +5 -0
  33. package/src/server/adapters/notificationFilter.ts +24 -0
  34. package/src/server/adapters/openDisconnectProxy.ts +110 -0
  35. package/src/server/disconnectProxy.ts +14 -0
  36. package/src/server/emptyPostgres.ts +10 -0
  37. package/src/server/observeUntil.ts +59 -0
  38. package/src/server/raceWithLock.ts +13 -12
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0 - 2026-10-30
4
+
5
+ ### Added
6
+
7
+ - `observeUntil(read, settled, waitingFor, options?)` from `@shivaedev/test-sql/server/observeUntil.ts` waits for PostgreSQL state that sends no notification, such as sessions in `pg_stat_activity` or lock waiters. It repeats one read on live time, even under a paused test clock, and returns the value once `settled` holds for `stableReads` reads in a row (default 2), so a value that flips back is not accepted. When `within` (default 5 seconds) runs out it fails with a typed `NeverSettled` whose message names what it waited for, the read count and the last value.
8
+
9
+ ### Breaking
10
+
11
+ - `raceWithLock` waits for its contenders to block through `observeUntil`, so that wait runs on live time. Its `timeout` now bounds each phase on its own: taking the lock and the contenders' finish after release still fail with `TimeoutError`, while contenders that never all block fail with `NeverSettled` naming how many blocked. Handle `NeverSettled` wherever you handled the `TimeoutError` of contenders that never queued on the lock.
12
+
13
+ ## 0.14.0 - 2026-10-30
14
+
15
+ ### Added
16
+
17
+ - 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.
18
+ - Shared story answer readouts translate Effect failures into words for package test kits.
19
+
3
20
  ## 0.13.0 - 2026-10-30
4
21
 
5
22
  ### Added
package/README.md CHANGED
@@ -169,7 +169,21 @@ expect(results.filter(Result.isSuccess)).toHaveLength(1);
169
169
  expect(results.filter(Result.isFailure).map((result) => result.failure)).toEqual(["full", "full"]);
170
170
  ```
171
171
 
172
- `claimLastPlace` is the application's guarded write, failing with the typed `"full"` value when no place remains; `Result` is Effect's native `Result` module. Results stay in input order, including each call's typed failure. The holder rolls back by default, discarding any writes in `lock`; set `release: "commit"` when a contender must observe the holder's committed write. The default timeout is five seconds; `timeout` accepts an Effect duration. Defects and interruption retain their native failure kind, and the helper's scope owns the holder and competing fibers.
172
+ `claimLastPlace` is the application's guarded write, failing with the typed `"full"` value when no place remains; `Result` is Effect's native `Result` module. Results stay in input order, including each call's typed failure. The holder rolls back by default, discarding any writes in `lock`; set `release: "commit"` when a contender must observe the holder's committed write. `timeout` accepts an Effect duration, five seconds by default, and bounds each phase on its own: taking the lock and the contenders' finish after release fail with `TimeoutError`, and contenders that never all block fail with `NeverSettled` from `observeUntil`, naming how many blocked at the last read. The wait for blocked contenders runs on live time. Defects and interruption retain their native failure kind, and the helper's scope owns the holder and competing fibers.
173
+
174
+ ### Wait for PostgreSQL state that sends no notification
175
+
176
+ PostgreSQL announces no change to its catalog views: a session leaving `pg_stat_activity` or a backend starting to wait on a lock happens without a signal. `observeUntil` is the one poll for such state. It repeats one read, returns the value once `settled` holds for `stableReads` reads in a row, and sleeps on live time, so a story's paused test clock does not stall it:
177
+
178
+ ```ts
179
+ import { observeUntil } from "@shivaedev/test-sql/server/observeUntil.ts";
180
+
181
+ const sessions = sql<{ count: number }>`select count(*)::int as count from pg_stat_activity
182
+ where application_name = ${applicationName}`.pipe(Effect.map(([row]) => row?.count ?? 0));
183
+ yield* observeUntil(sessions, (count) => count === 0, "the app's sessions to close");
184
+ ```
185
+
186
+ Options are `every` (default 25 milliseconds between reads), `within` (default 5 seconds) and `stableReads` (default 2). A count that reaches zero and rises again restarts the run of agreeing reads instead of being accepted. When `within` runs out the Effect fails with a typed `NeverSettled`, whose `last` holds the last value as an `Option` and whose message reads like `Timed out after 1s waiting for the app's sessions to close: 40 reads, last value 1`. Use it only for state PostgreSQL does not announce; wait on the engine's own signals everywhere else.
173
187
 
174
188
  ### Choose an empty database per story or trait
175
189
 
@@ -249,6 +263,14 @@ With a PostgreSQL client in the current Context, the assertion compares names th
249
263
 
250
264
  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
265
 
266
+ ### Database faults
267
+
268
+ `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.
269
+
270
+ 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.
271
+
272
+ `@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`.
273
+
252
274
  ### API
253
275
 
254
276
  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/`.
@@ -262,6 +284,7 @@ Import each name from the module that defines it. The package has no root entry.
262
284
  | `@shivaedev/test-sql/server/migrationSteps.ts` | `migrationSteps(appMigrations)` returning `{ migrateTo(name), migrateToLatest }` |
263
285
  | `@shivaedev/test-sql/server/migrationUpgrade.ts` | `migrationUpgrade({ migrations, upTo, table })`, `MigrationUpgradeOptions`, `PostgresColumn` |
264
286
  | `@shivaedev/test-sql/server/assertModelColumns.ts` | `assertModelColumns(columns, model, types)`, `ModelColumnMismatch` |
287
+ | `@shivaedev/test-sql/server/observeUntil.ts` | `observeUntil(read, settled, waitingFor, { every?, within?, stableReads? }?)`, `NeverSettled`, `ObserveOptions` |
265
288
 
266
289
  `url` is a redacted PostgreSQL URL for an existing administrative database. `PostgresTestDatabase` supplies the current database's redacted `url`, an `awaitCleanup: Effect<void>` drain, a scoped `isolated` Effect that acquires a migrated clone's service Context, and `clone(template)` for an explicit `"empty"` or `"migrated"` choice. With `prepare: false`, both choices are empty. Prefer `withIsolatedDatabase` to acquire and release that Context around a test. Wrappers retain the body's result, failures and unmet service requirements, with native `SqlError` added for acquisition or transaction failures. Cleanup failures surface as SQL defects when the Layer closes. Drops that fail with connection exhaustion (SQLSTATE `53300`) retry every 100 ms of live time for up to 300 retries, even while a test clock is paused; if capacity does not return, the SQL defect remains visible. Startup sweeps databases left by dead processes on the same host and databases from the older unowned naming format. Databases owned by live processes or another host are retained, so local parallel workers keep their databases. Use the same host to reclaim its abandoned databases.
267
290
 
@@ -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"}
@@ -0,0 +1,26 @@
1
+ import { Duration, Effect, Option } from "effect";
2
+ export interface ObserveOptions {
3
+ readonly every?: Duration.Input;
4
+ readonly stableReads?: number;
5
+ readonly within?: Duration.Input;
6
+ }
7
+ declare const NeverSettled_base: new <A_1 extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A_1 as P extends "_tag" ? never : P]: A_1[P]; }>) => import("effect/Cause").YieldableError & {
8
+ readonly _tag: "NeverSettled";
9
+ } & Readonly<A_1>;
10
+ export declare class NeverSettled<A> extends NeverSettled_base<{
11
+ readonly reads: number;
12
+ readonly waitingFor: string;
13
+ readonly within: Duration.Duration;
14
+ }> {
15
+ readonly last: Option.Option<A>;
16
+ constructor({ last, ...fields }: {
17
+ readonly last: Option.Option<A>;
18
+ readonly reads: number;
19
+ readonly waitingFor: string;
20
+ readonly within: Duration.Duration;
21
+ });
22
+ get message(): string;
23
+ }
24
+ export declare const observeUntil: <A, E, R>(read: Effect.Effect<A, E, R>, settled: (value: A) => boolean, waitingFor: string, options?: ObserveOptions | undefined) => Effect.Effect<A, E | NeverSettled<A>, R>;
25
+ export {};
26
+ //# sourceMappingURL=observeUntil.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"observeUntil.d.ts","sourceRoot":"","sources":["../../src/server/observeUntil.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,QAAQ,EAAE,MAAM,EAAa,MAAM,EAAE,MAAM,QAAQ,CAAC;AAE1E,MAAM,WAAW,cAAc;IAC9B,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAC;IAChC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAC;CACjC;;;;AAED,qBAAa,YAAY,CAAC,CAAC,CAAE,SAAQ,kBAAiC;IACrE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,QAAQ,CAAC;CACnC,CAAC;IACD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;IAEhC,YAAY,EACX,IAAI,EACJ,GAAG,MAAM,EACT,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,QAAQ,CAAA;KAAE,EAG9H;IAED,IAAa,OAAO,WAMnB;CACD;AAGD,eAAO,MAAM,YAAY,GAAqD,CAAC,EAAE,CAAC,EAAE,CAAC,iDAEnE,CAAC,KAAK,OAAO,uGAuB7B,CAAC"}
@@ -0,0 +1,35 @@
1
+ import { Clock, Data, Duration, Effect, Formatter, Option } from "effect";
2
+ export class NeverSettled extends Data.TaggedError("NeverSettled") {
3
+ last;
4
+ constructor({ last, ...fields }) {
5
+ super(fields);
6
+ this.last = last;
7
+ }
8
+ get message() {
9
+ const seen = Option.match(this.last, {
10
+ onNone: () => "no read finished",
11
+ onSome: (value) => `${this.reads} ${this.reads === 1 ? "read" : "reads"}, last value ${Formatter.format(value)}`,
12
+ });
13
+ return `Timed out after ${Duration.format(this.within)} waiting for ${this.waitingFor}: ${seen}`;
14
+ }
15
+ }
16
+ // PostgreSQL announces no change to its catalog views, so this poll is the sanctioned wait for them. It sleeps on live time because a story's test clock cannot move PostgreSQL.
17
+ export const observeUntil = Effect.fn("PostgresTest.observeUntil")(function* (read, settled, waitingFor, options) {
18
+ const within = Duration.fromInputUnsafe(options?.within ?? "5 seconds");
19
+ const stableReads = options?.stableReads ?? 2;
20
+ const live = Clock.Clock.defaultValue();
21
+ const pause = Effect.sleep(options?.every ?? "25 millis").pipe(Effect.provideService(Clock.Clock, live));
22
+ let last = Option.none();
23
+ let reads = 0;
24
+ function observe(streak) {
25
+ return Effect.flatMap(read, (value) => {
26
+ last = Option.some(value);
27
+ reads += 1;
28
+ const agreeing = settled(value) ? streak + 1 : 0;
29
+ return agreeing >= stableReads ? Effect.succeed(value) : Effect.andThen(pause, observe(agreeing));
30
+ });
31
+ }
32
+ const deadline = Effect.sleep(within).pipe(Effect.provideService(Clock.Clock, live), Effect.andThen(Effect.suspend(() => Effect.fail(new NeverSettled({ last, reads, waitingFor, within })))));
33
+ return yield* Effect.raceFirst(observe(0), deadline);
34
+ });
35
+ //# sourceMappingURL=observeUntil.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"observeUntil.js","sourceRoot":"","sources":["../../src/server/observeUntil.ts"],"names":[],"mappings":"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"}
@@ -6,5 +6,5 @@ export declare function raceWithLock<A, E, R, TLockError, TLockServices>(options
6
6
  readonly timeout?: Duration.Input;
7
7
  readonly release?: "commit" | "rollback";
8
8
  readonly count?: "contenders" | "every blocked session";
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
+ }): Effect.Effect<import("effect/Result").Result<A, E>[], TLockError | import("#observeUntil.ts").NeverSettled<number> | 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>>;
10
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,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
+ {"version":3,"file":"raceWithLock.d.ts","sourceRoot":"","sources":["../../src/server/raceWithLock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,QAAQ,EAAE,MAAM,EAAS,MAAM,QAAQ,CAAC;AAChE,OAAO,EAAE,SAAS,EAAa,MAAM,YAAY,CAAC;AAWlD,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,wZAyCA"}
@@ -1,39 +1,40 @@
1
- import { Deferred, Effect, Fiber, Schedule } from "effect";
1
+ import { Deferred, Effect, Fiber } from "effect";
2
2
  import { SqlClient, Statement } from "effect/sql";
3
3
  import { transact } from "@shivaedev/sql/transact.ts";
4
+ import { observeUntil } from "#observeUntil.ts";
4
5
  import { withTestTransaction } from "#withTestTransaction.ts";
5
6
  function tagged(effect, tag) {
6
7
  return Effect.provideService(effect, Statement.CurrentTransformer, (statement, sql) => Effect.succeed(sql `${sql.literal(`/* ${tag} */`)} ${statement}`));
7
8
  }
8
9
  export function raceWithLock(options) {
9
10
  const everyBlockedSession = options.count === "every blocked session";
11
+ const timeout = options.timeout ?? "5 seconds";
12
+ const contenders = `${options.effects.length} ${everyBlockedSession ? "sessions" : "contenders"} to block on the lock`;
10
13
  return Effect.scoped(Effect.gen(function* () {
11
14
  const sql = yield* SqlClient.SqlClient;
12
15
  const ready = yield* Deferred.make();
13
16
  const started = yield* Deferred.make();
14
17
  const hold = Effect.gen(function* () {
15
- yield* options.lock;
18
+ yield* Effect.timeout(options.lock, timeout);
16
19
  const [holder] = yield* sql `select pg_backend_pid() as pid`;
17
20
  const race = `raceWithLock ${holder?.pid}.`;
18
21
  yield* Deferred.succeed(ready, race);
19
22
  yield* Deferred.await(started);
20
- // PostgreSQL signals nothing when a session starts waiting on a lock, so the holder polls its blocker graph.
21
- yield* sql `select pg_stat_clear_snapshot()`.pipe(Effect.andThen(sql `with recursive blocked(pid) as (
23
+ const waiting = sql `select pg_stat_clear_snapshot()`.pipe(Effect.andThen(sql `with recursive blocked(pid) as (
22
24
  select pid from pg_stat_activity where pg_backend_pid() = any(pg_blocking_pids(pid))
23
25
  union
24
26
  select activity.pid from pg_stat_activity activity join blocked on blocked.pid = any(pg_blocking_pids(activity.pid))
25
27
  ) select ${everyBlockedSession ? sql `count(*)` : sql `count(distinct split_part(activity.query, ' */', 1))`}::integer as waiting
26
28
  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 }));
29
+ where ${everyBlockedSession ? sql `true` : sql `starts_with(activity.query, ${`/* ${race}`})`}`), Effect.map(([row]) => row?.waiting ?? 0));
30
+ yield* observeUntil(waiting, (count) => count >= options.effects.length, contenders, { within: timeout });
28
31
  });
29
32
  const holder = yield* (options.release === "commit" ? transact(Effect.scoped(hold), { onSqlError: (error) => error }) : withTestTransaction(hold)).pipe(Effect.forkScoped);
30
33
  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);
34
+ const racing = yield* Effect.forEach(options.effects, (effect, index) => Effect.result(everyBlockedSession ? effect : tagged(effect, `${race}${index}`)), { concurrency: "unbounded" }).pipe(Effect.forkScoped);
34
35
  yield* Deferred.succeed(started, undefined);
35
- yield* Effect.raceFirst(Fiber.join(holder), Fiber.join(contenders).pipe(Effect.andThen(Effect.never)));
36
- return yield* Fiber.join(contenders);
37
- })).pipe(Effect.timeout(options.timeout ?? "5 seconds"));
36
+ yield* Effect.raceFirst(Fiber.join(holder), Fiber.join(racing).pipe(Effect.andThen(Effect.never)));
37
+ return yield* Effect.timeout(Fiber.join(racing), timeout);
38
+ }));
38
39
  }
39
40
  //# sourceMappingURL=raceWithLock.js.map
@@ -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.13.0",
3
+ "version": "0.15.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.1"
46
+ "@shivaedev/sql": "0.27.0"
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.1",
52
- "@shivaedev/quality": "0.47.0",
51
+ "@shivaedev/jobs": "0.23.2",
52
+ "@shivaedev/quality": "0.48.0",
53
53
  "@shivaedev/service": "0.6.0",
54
- "@shivaedev/test": "0.11.0",
55
- "@shivaedev/test-story": "0.25.1",
54
+ "@shivaedev/test": "0.12.0",
55
+ "@shivaedev/test-story": "0.26.2",
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
+ });
@@ -0,0 +1,59 @@
1
+ import { Clock, Data, Duration, Effect, Formatter, Option } from "effect";
2
+
3
+ export interface ObserveOptions {
4
+ readonly every?: Duration.Input;
5
+ readonly stableReads?: number;
6
+ readonly within?: Duration.Input;
7
+ }
8
+
9
+ export class NeverSettled<A> extends Data.TaggedError("NeverSettled")<{
10
+ readonly reads: number;
11
+ readonly waitingFor: string;
12
+ readonly within: Duration.Duration;
13
+ }> {
14
+ readonly last: Option.Option<A>;
15
+
16
+ constructor({
17
+ last,
18
+ ...fields
19
+ }: { readonly last: Option.Option<A>; readonly reads: number; readonly waitingFor: string; readonly within: Duration.Duration }) {
20
+ super(fields);
21
+ this.last = last;
22
+ }
23
+
24
+ override get message() {
25
+ const seen = Option.match(this.last, {
26
+ onNone: () => "no read finished",
27
+ onSome: (value) => `${this.reads} ${this.reads === 1 ? "read" : "reads"}, last value ${Formatter.format(value)}`,
28
+ });
29
+ return `Timed out after ${Duration.format(this.within)} waiting for ${this.waitingFor}: ${seen}`;
30
+ }
31
+ }
32
+
33
+ // PostgreSQL announces no change to its catalog views, so this poll is the sanctioned wait for them. It sleeps on live time because a story's test clock cannot move PostgreSQL.
34
+ export const observeUntil = Effect.fn("PostgresTest.observeUntil")(function* <A, E, R>(
35
+ read: Effect.Effect<A, E, R>,
36
+ settled: (value: A) => boolean,
37
+ waitingFor: string,
38
+ options?: ObserveOptions,
39
+ ) {
40
+ const within = Duration.fromInputUnsafe(options?.within ?? "5 seconds");
41
+ const stableReads = options?.stableReads ?? 2;
42
+ const live = Clock.Clock.defaultValue();
43
+ const pause = Effect.sleep(options?.every ?? "25 millis").pipe(Effect.provideService(Clock.Clock, live));
44
+ let last = Option.none<A>();
45
+ let reads = 0;
46
+ function observe(streak: number): Effect.Effect<A, E, R> {
47
+ return Effect.flatMap(read, (value) => {
48
+ last = Option.some(value);
49
+ reads += 1;
50
+ const agreeing = settled(value) ? streak + 1 : 0;
51
+ return agreeing >= stableReads ? Effect.succeed(value) : Effect.andThen(pause, observe(agreeing));
52
+ });
53
+ }
54
+ const deadline = Effect.sleep(within).pipe(
55
+ Effect.provideService(Clock.Clock, live),
56
+ Effect.andThen(Effect.suspend(() => Effect.fail(new NeverSettled({ last, reads, waitingFor, within })))),
57
+ );
58
+ return yield* Effect.raceFirst(observe(0), deadline);
59
+ });
@@ -1,6 +1,7 @@
1
- import { Deferred, type Duration, Effect, Fiber, Schedule } from "effect";
1
+ import { Deferred, type Duration, Effect, Fiber } from "effect";
2
2
  import { SqlClient, Statement } from "effect/sql";
3
3
  import { transact } from "@shivaedev/sql/transact.ts";
4
+ import { observeUntil } from "#observeUntil.ts";
4
5
  import { withTestTransaction } from "#withTestTransaction.ts";
5
6
 
6
7
  function tagged<A, E, R>(effect: Effect.Effect<A, E, R>, tag: string) {
@@ -17,19 +18,20 @@ export function raceWithLock<A, E, R, TLockError, TLockServices>(options: {
17
18
  readonly count?: "contenders" | "every blocked session";
18
19
  }) {
19
20
  const everyBlockedSession = options.count === "every blocked session";
21
+ const timeout = options.timeout ?? "5 seconds";
22
+ const contenders = `${options.effects.length} ${everyBlockedSession ? "sessions" : "contenders"} to block on the lock`;
20
23
  return Effect.scoped(
21
24
  Effect.gen(function* () {
22
25
  const sql = yield* SqlClient.SqlClient;
23
26
  const ready = yield* Deferred.make<string>();
24
27
  const started = yield* Deferred.make<void>();
25
28
  const hold = Effect.gen(function* () {
26
- yield* options.lock;
29
+ yield* Effect.timeout(options.lock, timeout);
27
30
  const [holder] = yield* sql<{ pid: number }>`select pg_backend_pid() as pid`;
28
31
  const race = `raceWithLock ${holder?.pid}.`;
29
32
  yield* Deferred.succeed(ready, race);
30
33
  yield* Deferred.await(started);
31
- // PostgreSQL signals nothing when a session starts waiting on a lock, so the holder polls its blocker graph.
32
- yield* sql`select pg_stat_clear_snapshot()`.pipe(
34
+ const waiting = sql`select pg_stat_clear_snapshot()`.pipe(
33
35
  Effect.andThen(sql<{ waiting: number }>`with recursive blocked(pid) as (
34
36
  select pid from pg_stat_activity where pg_backend_pid() = any(pg_blocking_pids(pid))
35
37
  union
@@ -37,23 +39,22 @@ export function raceWithLock<A, E, R, TLockError, TLockServices>(options: {
37
39
  ) select ${everyBlockedSession ? sql`count(*)` : sql`count(distinct split_part(activity.query, ' */', 1))`}::integer as waiting
38
40
  from pg_stat_activity activity join blocked using (pid)
39
41
  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 }),
42
+ Effect.map(([row]) => row?.waiting ?? 0),
41
43
  );
44
+ yield* observeUntil(waiting, (count) => count >= options.effects.length, contenders, { within: timeout });
42
45
  });
43
46
  const holder = yield* (
44
47
  options.release === "commit" ? transact(Effect.scoped(hold), { onSqlError: (error) => error }) : withTestTransaction(hold)
45
48
  ).pipe(Effect.forkScoped);
46
49
  const race = yield* Effect.raceFirst(Deferred.await(ready), Fiber.join(holder).pipe(Effect.andThen(Effect.never)));
47
- const contenders = yield* Effect.forEach(
50
+ const racing = yield* Effect.forEach(
48
51
  options.effects,
49
52
  (effect, index) => Effect.result(everyBlockedSession ? effect : tagged(effect, `${race}${index}`)),
50
- {
51
- concurrency: "unbounded",
52
- },
53
+ { concurrency: "unbounded" },
53
54
  ).pipe(Effect.forkScoped);
54
55
  yield* Deferred.succeed(started, undefined);
55
- yield* Effect.raceFirst(Fiber.join(holder), Fiber.join(contenders).pipe(Effect.andThen(Effect.never)));
56
- return yield* Fiber.join(contenders);
56
+ yield* Effect.raceFirst(Fiber.join(holder), Fiber.join(racing).pipe(Effect.andThen(Effect.never)));
57
+ return yield* Effect.timeout(Fiber.join(racing), timeout);
57
58
  }),
58
- ).pipe(Effect.timeout(options.timeout ?? "5 seconds"));
59
+ );
59
60
  }