@shivaedev/test-sql 0.10.2 → 0.11.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,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 - 2026-10-30
4
+
5
+ ### Added
6
+
7
+ - `raceWithLock` holds a native PostgreSQL row, table or advisory lock until every competing call is queued, then releases the holder and returns each call's result or typed failure in input order.
8
+
9
+ ### Changed
10
+
11
+ - Require Effect 4.0.6 and align the Effect companion package peers and generated starter catalog to the same exact release. Install the 4.0.6 Effect stack together.
12
+
13
+ ### Fixed
14
+
15
+ - Register hosted RPC cache-control handling through Effect 4.0.6's request-scoped pre-response API so responses keep `private, no-store`.
16
+ - Accept native RPC's `application/x-ndjson` media type while preserving `application/ndjson` callers at the transport guard.
17
+ - Keep subscriptions open after their HTTP request body completes; closing the socket still cancels them.
18
+
3
19
  ## 0.10.2 - 2026-10-28
4
20
 
5
21
  ### Changed
package/README.md CHANGED
@@ -8,11 +8,9 @@ Table cleanup couples tests to the order they run. A rollback test keeps its wri
8
8
 
9
9
  ```ts
10
10
  effectApp("renames an item", function* () {
11
- const sql = yield* SqlClient.SqlClient;
12
- yield* sql`update items set name = 'Renamed' where id = 1`;
13
- expect(yield* sql`select name from items where id = 1`).toEqual([
14
- { name: "Renamed" },
15
- ]);
11
+ const sql = yield* SqlClient.SqlClient;
12
+ yield* sql`update items set name = 'Renamed' where id = 1`;
13
+ expect(yield* sql`select name from items where id = 1`).toEqual([{ name: "Renamed" }]);
16
14
  });
17
15
  ```
18
16
 
@@ -37,27 +35,27 @@ import { makePostgresTestLayer } from "@shivaedev/test-sql/server/postgres.ts";
37
35
  import { withTestTransaction } from "@shivaedev/test-sql/server/withTestTransaction.ts";
38
36
 
39
37
  const layer = makePostgresTestLayer({
40
- url: Redacted.make(Effect.runSync(Config.String("TEST_DATABASE_URL"))),
41
- migrations: {
42
- lockTimeout: "5 seconds",
43
- loader: Migrator.fromRecord({
44
- "1_create_items": Effect.gen(function* () {
45
- const sql = yield* SqlClient.SqlClient;
46
- yield* sql`create table items (id integer primary key, name text not null)`;
47
- }),
48
- }),
49
- },
50
- fixtures: Effect.gen(function* () {
51
- const sql = yield* SqlClient.SqlClient;
52
- yield* sql`insert into items values (1, 'Original')`;
53
- }),
38
+ url: Redacted.make(Effect.runSync(Config.String("TEST_DATABASE_URL"))),
39
+ migrations: {
40
+ lockTimeout: "5 seconds",
41
+ loader: Migrator.fromRecord({
42
+ "1_create_items": Effect.gen(function* () {
43
+ const sql = yield* SqlClient.SqlClient;
44
+ yield* sql`create table items (id integer primary key, name text not null)`;
45
+ }),
46
+ }),
47
+ },
48
+ fixtures: Effect.gen(function* () {
49
+ const sql = yield* SqlClient.SqlClient;
50
+ yield* sql`insert into items values (1, 'Original')`;
51
+ }),
54
52
  });
55
53
 
56
54
  export const { effectApp } = makeEffectIt({
57
- layer,
58
- clock: "live",
59
- around: withTestTransaction,
60
- makeHarness: () => Effect.void,
55
+ layer,
56
+ clock: "live",
57
+ around: withTestTransaction,
58
+ makeHarness: () => Effect.void,
61
59
  });
62
60
  ```
63
61
 
@@ -69,13 +67,14 @@ Fixtures can also be a `PostgresTestLoader`: a callback from the migrated templa
69
67
  import * as PgClient from "@effect/sql-pg/PgClient";
70
68
 
71
69
  const layer = makePostgresTestLayer({
72
- url,
73
- migrations: appMigrations,
74
- fixtures: (templateUrl) => Effect.gen(function* () {
75
- const client = yield* PgClient.makeClient({ url: templateUrl });
76
- yield* client`create table app_fixture (name text not null)`;
77
- yield* client`insert into app_fixture values ('Original')`;
78
- }),
70
+ url,
71
+ migrations: appMigrations,
72
+ fixtures: (templateUrl) =>
73
+ Effect.gen(function* () {
74
+ const client = yield* PgClient.makeClient({ url: templateUrl });
75
+ yield* client`create table app_fixture (name text not null)`;
76
+ yield* client`insert into app_fixture values ('Original')`;
77
+ }),
79
78
  });
80
79
  ```
81
80
 
@@ -85,8 +84,8 @@ Supply `initialize` instead of `migrations` when the application already owns it
85
84
 
86
85
  ```ts
87
86
  const layer = makePostgresTestLayer({
88
- url: testDatabaseUrl,
89
- initialize: (templateUrl) => migrateDatabase(templateUrl),
87
+ url: testDatabaseUrl,
88
+ initialize: (templateUrl) => migrateDatabase(templateUrl),
90
89
  });
91
90
  ```
92
91
 
@@ -98,9 +97,9 @@ Choose exactly one of `migrations` and `initialize`. The initializer is an Effec
98
97
 
99
98
  ```ts
100
99
  const layer = makePostgresTestLayer({
101
- ...appClientOptions,
102
- url: testDatabaseUrl,
103
- migrations: appMigrations,
100
+ ...appClientOptions,
101
+ url: testDatabaseUrl,
102
+ migrations: appMigrations,
104
103
  });
105
104
  ```
106
105
 
@@ -108,13 +107,13 @@ Declare the mapping once in `appClientOptions`, for example with `clientOptions(
108
107
 
109
108
  ### Budget PostgreSQL connections
110
109
 
111
- Each database pool opens connections on demand, with a default maximum of **two**: the shared database can use two, and each active per-test or per-story clone can use another two. Preparation uses the same limit and closes before tests start. An administrative connection exists only during a create or drop operation and closes as soon as that operation finishes; idle workers keep no administrative connection. Set `maxConnections` on `makePostgresTestLayer` to override the limit for the suite's preparation, shared and isolated pools:
110
+ Each database pool opens connections on demand, with a default maximum of two: the shared database can use two, and each active per-test or per-story clone can use another two. Preparation uses the same limit and closes before tests start. An administrative connection exists only during a create or drop operation and closes as soon as that operation finishes; idle workers keep no administrative connection. Set `maxConnections` on `makePostgresTestLayer` to override the limit for the suite's preparation, shared and isolated pools:
112
111
 
113
112
  ```ts
114
113
  const layer = makePostgresTestLayer({
115
- url,
116
- migrations,
117
- maxConnections: 3,
114
+ url,
115
+ migrations,
116
+ maxConnections: 3,
118
117
  });
119
118
  ```
120
119
 
@@ -142,10 +141,12 @@ An isolated block can live inside the rollback tester above:
142
141
  import { withIsolatedDatabase } from "@shivaedev/test-sql/server/withIsolatedDatabase.ts";
143
142
 
144
143
  effectApp("commits in its own database", function* () {
145
- yield* withIsolatedDatabase(Effect.gen(function* () {
146
- const sql = yield* SqlClient.SqlClient;
147
- yield* sql`update items set name = 'Committed' where id = 1`;
148
- }));
144
+ yield* withIsolatedDatabase(
145
+ Effect.gen(function* () {
146
+ const sql = yield* SqlClient.SqlClient;
147
+ yield* sql`update items set name = 'Committed' where id = 1`;
148
+ }),
149
+ );
149
150
  });
150
151
  ```
151
152
 
@@ -153,6 +154,23 @@ The clone starts from the template, including the original fixtures, even if the
153
154
 
154
155
  Inside an isolated block, `PostgresTestDatabase.url` names that clone. Use it to acquire additional native PostgreSQL connections in the block's scope. Native SQL services are replaced in the block's Context; application services acquired earlier retain the client they captured. Build application service Layers inside the isolated block when they acquire SQL clients; use `Layer.fresh` when reusing a Layer definition already acquired by the worker. Keep servers and background fibers in that scope so they finish before the database is dropped. Separate databases isolate database contents; external services and server capacity need separate planning.
155
156
 
157
+ ### Race stories
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.
160
+
161
+ ```ts
162
+ import { raceWithLock } from "@shivaedev/test-sql/server/raceWithLock.ts";
163
+
164
+ const results = yield* raceWithLock({
165
+ lock: sql`lock table items in share mode`,
166
+ effects: [claimLastPlace(), claimLastPlace(), claimLastPlace()],
167
+ });
168
+ expect(results.filter(Result.isSuccess)).toHaveLength(1);
169
+ expect(results.filter(Result.isFailure).map((result) => result.failure)).toEqual(["full", "full"]);
170
+ ```
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.
173
+
156
174
  ### Choose an empty database per story or trait
157
175
 
158
176
  The default clone is migrated. A first-deploy or upgrade block can select the empty template from the same Layer:
@@ -180,26 +198,24 @@ import { withIsolatedDatabase } from "@shivaedev/test-sql/server/withIsolatedDat
180
198
  import { appMigrations } from "./migrations.ts";
181
199
 
182
200
  const migrationLayer = makePostgresTestLayer({
183
- url,
184
- migrations: appMigrations,
185
- prepare: false,
201
+ url,
202
+ migrations: appMigrations,
203
+ prepare: false,
186
204
  });
187
205
 
188
206
  const { effectApp: migrationTest } = makeEffectIt({
189
- layer: migrationLayer,
190
- clock: "live",
191
- around: withIsolatedDatabase,
192
- makeHarness: () => migrationSteps(appMigrations),
207
+ layer: migrationLayer,
208
+ clock: "live",
209
+ around: withIsolatedDatabase,
210
+ makeHarness: () => migrationSteps(appMigrations),
193
211
  });
194
212
 
195
213
  migrationTest("preserves contact emails in the new table", function* (steps) {
196
- yield* steps.migrateTo("001_create_contacts");
197
- const sql = yield* SqlClient.SqlClient;
198
- yield* sql`insert into contacts (id, email) values (1, 'alice@example.test')`;
199
- yield* steps.migrateToLatest;
200
- expect(yield* sql`select contact_id, address from emails`).toEqual([
201
- { contact_id: 1, address: "alice@example.test" },
202
- ]);
214
+ yield* steps.migrateTo("001_create_contacts");
215
+ const sql = yield* SqlClient.SqlClient;
216
+ yield* sql`insert into contacts (id, email) values (1, 'alice@example.test')`;
217
+ yield* steps.migrateToLatest;
218
+ expect(yield* sql`select contact_id, address from emails`).toEqual([{ contact_id: 1, address: "alice@example.test" }]);
203
219
  });
204
220
  ```
205
221
 
@@ -237,15 +253,15 @@ The assertion uses the SQL `Model`'s selected database fields, including generat
237
253
 
238
254
  Import each name from the module that defines it. The package has no root entry.
239
255
 
240
- | Module | Exports |
241
- | --- | --- |
242
- | `@shivaedev/test-sql/server/postgres.ts` | `makePostgresTestLayer({ url, migrations?, initialize?, fixtures?, maxConnections?, prepare?, transformQueryNames?, transformResultNames? })`, `PostgresTestOptions`, `PostgresTestLoader` |
243
- | `@shivaedev/test-sql/server/withTestTransaction.ts` | `withTestTransaction(effect)` |
244
- | `@shivaedev/test-sql/server/withIsolatedDatabase.ts` | `withIsolatedDatabase(effect, { template? }?)` |
245
- | `@shivaedev/test-sql/server/database.ts` | `PostgresTestDatabase`, `PostgresTestDatabaseService`, `PostgresTestServices`, `PostgresTestTemplate` |
246
- | `@shivaedev/test-sql/server/migrationSteps.ts` | `migrationSteps(appMigrations)` returning `{ migrateTo(name), migrateToLatest }` |
247
- | `@shivaedev/test-sql/server/migrationUpgrade.ts` | `migrationUpgrade({ migrations, upTo, table })`, `MigrationUpgradeOptions`, `PostgresColumn` |
248
- | `@shivaedev/test-sql/server/assertModelColumns.ts` | `assertModelColumns(columns, model, types)`, `ModelColumnMismatch` |
256
+ | Module | Exports |
257
+ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
258
+ | `@shivaedev/test-sql/server/postgres.ts` | `makePostgresTestLayer({ url, migrations?, initialize?, fixtures?, maxConnections?, prepare?, transformQueryNames?, transformResultNames? })`, `PostgresTestOptions`, `PostgresTestLoader` |
259
+ | `@shivaedev/test-sql/server/withTestTransaction.ts` | `withTestTransaction(effect)` |
260
+ | `@shivaedev/test-sql/server/withIsolatedDatabase.ts` | `withIsolatedDatabase(effect, { template? }?)` |
261
+ | `@shivaedev/test-sql/server/database.ts` | `PostgresTestDatabase`, `PostgresTestDatabaseService`, `PostgresTestServices`, `PostgresTestTemplate` |
262
+ | `@shivaedev/test-sql/server/migrationSteps.ts` | `migrationSteps(appMigrations)` returning `{ migrateTo(name), migrateToLatest }` |
263
+ | `@shivaedev/test-sql/server/migrationUpgrade.ts` | `migrationUpgrade({ migrations, upTo, table })`, `MigrationUpgradeOptions`, `PostgresColumn` |
264
+ | `@shivaedev/test-sql/server/assertModelColumns.ts` | `assertModelColumns(columns, model, types)`, `ModelColumnMismatch` |
249
265
 
250
266
  `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.
251
267
 
@@ -255,4 +271,4 @@ Import each name from the module that defines it. The package has no root entry.
255
271
  pnpm add --save-dev @shivaedev/test-sql @shivaedev/test @effect/sql-pg @effect/vitest effect vitest
256
272
  ```
257
273
 
258
- Use versions compatible with the package's peer dependencies. The package requires Node.js 24 or later. The PostgreSQL role must be able to create and drop databases; use a dedicated test server. PostgreSQL 13 or later supports the forced database drops used for cleanup. The migrations and fixtures must complete their work during preparation; their scoped resources are closed before cloning.
274
+ `@effect/sql-pg` and `effect` are peer dependencies; `@shivaedev/test` supplies the `makeEffectIt` runner the examples use. The package requires Node.js 24 or later. The PostgreSQL role must be able to create and drop databases; use a dedicated test server. PostgreSQL 13 or later supports the forced database drops used for cleanup. The migrations and fixtures must complete their work during preparation; their scoped resources are closed before cloning.
@@ -0,0 +1,9 @@
1
+ import { type Duration, Effect } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ export declare function raceWithLock<A, E, R, TLockError, TLockServices>(options: {
4
+ readonly lock: Effect.Effect<unknown, TLockError, TLockServices>;
5
+ readonly effects: readonly Effect.Effect<A, E, R>[];
6
+ readonly timeout?: Duration.Input;
7
+ readonly release?: "commit" | "rollback";
8
+ }): 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
+ //# sourceMappingURL=raceWithLock.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,28 @@
1
+ import { Deferred, Effect, Fiber, Schedule } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import { transact } from "@shivaedev/sql/transact.ts";
4
+ import { withTestTransaction } from "#withTestTransaction.ts";
5
+ export function raceWithLock(options) {
6
+ return Effect.scoped(Effect.gen(function* () {
7
+ const sql = yield* SqlClient.SqlClient;
8
+ const ready = yield* Deferred.make();
9
+ const started = yield* Deferred.make();
10
+ const hold = Effect.gen(function* () {
11
+ yield* options.lock;
12
+ yield* Deferred.succeed(ready, undefined);
13
+ yield* Deferred.await(started);
14
+ yield* sql `select pg_stat_clear_snapshot()`.pipe(Effect.andThen(sql `with recursive blocked(pid) as (
15
+ select pid from pg_stat_activity where pg_backend_pid() = any(pg_blocking_pids(pid))
16
+ union
17
+ 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 }));
19
+ });
20
+ 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);
23
+ yield* Deferred.succeed(started, undefined);
24
+ yield* Effect.raceFirst(Fiber.join(holder), Fiber.join(contenders).pipe(Effect.andThen(Effect.never)));
25
+ return yield* Fiber.join(contenders);
26
+ })).pipe(Effect.timeout(options.timeout ?? "5 seconds"));
27
+ }
28
+ //# sourceMappingURL=raceWithLock.js.map
@@ -0,0 +1 @@
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.10.2",
3
+ "version": "0.11.0",
4
4
  "description": "PostgreSQL rollback and real-commit test isolation for Effect",
5
5
  "keywords": [
6
6
  "effect"
@@ -44,23 +44,23 @@
44
44
  "README.md"
45
45
  ],
46
46
  "dependencies": {
47
- "@shivaedev/sql": "0.24.0"
47
+ "@shivaedev/sql": "0.25.0"
48
48
  },
49
49
  "devDependencies": {
50
- "@effect/sql-pg": "4.0.5",
51
- "@effect/vitest": "4.0.5",
52
- "@shivaedev/quality": "0.43.0",
53
- "@shivaedev/test": "0.10.0",
54
- "@shivaedev/test-story": "0.22.0",
50
+ "@effect/sql-pg": "4.0.6",
51
+ "@effect/vitest": "4.0.6",
52
+ "@shivaedev/quality": "0.45.0",
53
+ "@shivaedev/test": "0.10.1",
54
+ "@shivaedev/test-story": "0.24.0",
55
55
  "@types/node": "24.19.2",
56
- "effect": "4.0.5",
56
+ "effect": "4.0.6",
57
57
  "pg-boss": "12.38.0",
58
58
  "typescript": "7.0.2",
59
59
  "vitest": "5.0.5"
60
60
  },
61
61
  "peerDependencies": {
62
- "@effect/sql-pg": "4.0.5",
63
- "effect": "4.0.5"
62
+ "@effect/sql-pg": "4.0.6",
63
+ "effect": "4.0.6"
64
64
  },
65
65
  "engines": {
66
66
  "node": ">=24"
@@ -0,0 +1,40 @@
1
+ import { Deferred, type Duration, Effect, Fiber, Schedule } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import { transact } from "@shivaedev/sql/transact.ts";
4
+ import { withTestTransaction } from "#withTestTransaction.ts";
5
+
6
+ export function raceWithLock<A, E, R, TLockError, TLockServices>(options: {
7
+ readonly lock: Effect.Effect<unknown, TLockError, TLockServices>;
8
+ readonly effects: readonly Effect.Effect<A, E, R>[];
9
+ readonly timeout?: Duration.Input;
10
+ readonly release?: "commit" | "rollback";
11
+ }) {
12
+ return Effect.scoped(
13
+ Effect.gen(function* () {
14
+ const sql = yield* SqlClient.SqlClient;
15
+ const ready = yield* Deferred.make<void>();
16
+ const started = yield* Deferred.make<void>();
17
+ const hold = Effect.gen(function* () {
18
+ yield* options.lock;
19
+ yield* Deferred.succeed(ready, undefined);
20
+ yield* Deferred.await(started);
21
+ yield* sql`select pg_stat_clear_snapshot()`.pipe(
22
+ Effect.andThen(sql<{ waiting: number }>`with recursive blocked(pid) as (
23
+ select pid from pg_stat_activity where pg_backend_pid() = any(pg_blocking_pids(pid))
24
+ union
25
+ 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 }),
28
+ );
29
+ });
30
+ const holder = yield* (
31
+ options.release === "commit" ? transact(Effect.scoped(hold), { onSqlError: (error) => error }) : withTestTransaction(hold)
32
+ ).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);
35
+ yield* Deferred.succeed(started, undefined);
36
+ yield* Effect.raceFirst(Fiber.join(holder), Fiber.join(contenders).pipe(Effect.andThen(Effect.never)));
37
+ return yield* Fiber.join(contenders);
38
+ }),
39
+ ).pipe(Effect.timeout(options.timeout ?? "5 seconds"));
40
+ }