@shivaedev/test-sql 0.10.1 → 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 +22 -0
- package/README.md +83 -67
- package/dist/server/raceWithLock.d.ts +9 -0
- package/dist/server/raceWithLock.d.ts.map +1 -0
- package/dist/server/raceWithLock.js +28 -0
- package/dist/server/raceWithLock.js.map +1 -0
- package/package.json +10 -10
- package/src/server/raceWithLock.ts +40 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
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
|
+
|
|
19
|
+
## 0.10.2 - 2026-10-28
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Release with updated workspace dependencies.
|
|
24
|
+
|
|
3
25
|
## 0.10.1 - 2026-10-27
|
|
4
26
|
|
|
5
27
|
### 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
201
|
+
url,
|
|
202
|
+
migrations: appMigrations,
|
|
203
|
+
prepare: false,
|
|
186
204
|
});
|
|
187
205
|
|
|
188
206
|
const { effectApp: migrationTest } = makeEffectIt({
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
241
|
-
|
|
|
242
|
-
| `@shivaedev/test-sql/server/postgres.ts`
|
|
243
|
-
| `@shivaedev/test-sql/server/withTestTransaction.ts`
|
|
244
|
-
| `@shivaedev/test-sql/server/withIsolatedDatabase.ts` | `withIsolatedDatabase(effect, { template? }?)`
|
|
245
|
-
| `@shivaedev/test-sql/server/database.ts`
|
|
246
|
-
| `@shivaedev/test-sql/server/migrationSteps.ts`
|
|
247
|
-
| `@shivaedev/test-sql/server/migrationUpgrade.ts`
|
|
248
|
-
| `@shivaedev/test-sql/server/assertModelColumns.ts`
|
|
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
|
-
|
|
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.
|
|
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.
|
|
47
|
+
"@shivaedev/sql": "0.25.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@effect/sql-pg": "4.0.
|
|
51
|
-
"@effect/vitest": "4.0.
|
|
52
|
-
"@shivaedev/quality": "0.
|
|
53
|
-
"@shivaedev/test": "0.10.
|
|
54
|
-
"@shivaedev/test-story": "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.
|
|
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.
|
|
63
|
-
"effect": "4.0.
|
|
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
|
+
}
|