@shivaedev/test-sql 0.14.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.
- package/CHANGELOG.md +10 -0
- package/README.md +16 -1
- package/dist/server/observeUntil.d.ts +26 -0
- package/dist/server/observeUntil.d.ts.map +1 -0
- package/dist/server/observeUntil.js +35 -0
- package/dist/server/observeUntil.js.map +1 -0
- package/dist/server/raceWithLock.d.ts +1 -1
- package/dist/server/raceWithLock.d.ts.map +1 -1
- package/dist/server/raceWithLock.js +12 -11
- package/dist/server/raceWithLock.js.map +1 -1
- package/package.json +5 -5
- package/src/server/observeUntil.ts +59 -0
- package/src/server/raceWithLock.ts +13 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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
|
+
|
|
3
13
|
## 0.14.0 - 2026-10-30
|
|
4
14
|
|
|
5
15
|
### 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.
|
|
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
|
|
|
@@ -270,6 +284,7 @@ Import each name from the module that defines it. The package has no root entry.
|
|
|
270
284
|
| `@shivaedev/test-sql/server/migrationSteps.ts` | `migrationSteps(appMigrations)` returning `{ migrateTo(name), migrateToLatest }` |
|
|
271
285
|
| `@shivaedev/test-sql/server/migrationUpgrade.ts` | `migrationUpgrade({ migrations, upTo, table })`, `MigrationUpgradeOptions`, `PostgresColumn` |
|
|
272
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` |
|
|
273
288
|
|
|
274
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.
|
|
275
290
|
|
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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(
|
|
36
|
-
return yield* Fiber.join(
|
|
37
|
-
}))
|
|
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":"AAAA,OAAO,EAAE,QAAQ,EAAiB,MAAM,EAAE,KAAK,EAAE,
|
|
1
|
+
{"version":3,"file":"raceWithLock.js","sourceRoot":"","sources":["../../src/server/raceWithLock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAiB,MAAM,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAChE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAClD,OAAO,EAAE,QAAQ,EAAE,MAAM,4BAA4B,CAAC;AACtD,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAE9D,SAAS,MAAM,CAAU,MAA8B,EAAE,GAAW;IACnE,OAAO,MAAM,CAAC,cAAc,CAAC,MAAM,EAAE,SAAS,CAAC,kBAAkB,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,EAAE,CACrF,MAAM,CAAC,OAAO,CAAC,GAAG,CAAA,GAAG,GAAG,CAAC,OAAO,CAAC,MAAM,GAAG,KAAK,CAAC,IAAI,SAAS,EAAE,CAAC,CAChE,CAAC;AACH,CAAC;AAED,MAAM,UAAU,YAAY,CAAqC,OAMhE;IACA,MAAM,mBAAmB,GAAG,OAAO,CAAC,KAAK,KAAK,uBAAuB,CAAC;IACtE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,WAAW,CAAC;IAC/C,MAAM,UAAU,GAAG,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,IAAI,mBAAmB,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,YAAY,uBAAuB,CAAC;IACvH,OAAO,MAAM,CAAC,MAAM,CACnB,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QACnB,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC;QACvC,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,IAAI,EAAU,CAAC;QAC7C,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,IAAI,EAAQ,CAAC;QAC7C,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;YAChC,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;YAC7C,MAAM,CAAC,MAAM,CAAC,GAAG,KAAK,CAAC,CAAC,GAAG,CAAiB,gCAAgC,CAAC;YAC7E,MAAM,IAAI,GAAG,gBAAgB,MAAM,EAAE,GAAG,GAAG,CAAC;YAC5C,KAAK,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;YACrC,KAAK,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;YAC/B,MAAM,OAAO,GAAG,GAAG,CAAA,iCAAiC,CAAC,IAAI,CACxD,MAAM,CAAC,OAAO,CAAC,GAAG,CAAqB;;;;gBAI5B,mBAAmB,CAAC,CAAC,CAAC,GAAG,CAAA,UAAU,CAAC,CAAC,CAAC,GAAG,CAAA,sDAAsD;;aAElG,mBAAmB,CAAC,CAAC,CAAC,GAAG,CAAA,MAAM,CAAC,CAAC,CAAC,GAAG,CAAA,+BAA+B,MAAM,IAAI,EAAE,GAAG,EAAE,CAAC,EAC9F,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,GAAG,EAAE,OAAO,IAAI,CAAC,CAAC,CACxC,CAAC;YACF,KAAK,CAAC,CAAC,YAAY,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,IAAI,OAAO,CAAC,OAAO,CAAC,MAAM,EAAE,UAAU,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;QAC3G,CAAC,CAAC,CAAC;QACH,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,CACrB,OAAO,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,EAAE,UAAU,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,mBAAmB,CAAC,IAAI,CAAC,CAC1H,CAAC,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;QAC1B,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACnH,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CACnC,OAAO,CAAC,OAAO,EACf,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,IAAI,GAAG,KAAK,EAAE,CAAC,CAAC,EAClG,EAAE,WAAW,EAAE,WAAW,EAAE,CAC5B,CAAC,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;QAC1B,KAAK,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;QAC5C,KAAK,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACnG,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC;IAC3D,CAAC,CAAC,CACF,CAAC;AACH,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/test-sql",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
52
|
-
"@shivaedev/quality": "0.
|
|
51
|
+
"@shivaedev/jobs": "0.23.2",
|
|
52
|
+
"@shivaedev/quality": "0.48.0",
|
|
53
53
|
"@shivaedev/service": "0.6.0",
|
|
54
54
|
"@shivaedev/test": "0.12.0",
|
|
55
|
-
"@shivaedev/test-story": "0.26.
|
|
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,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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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(
|
|
56
|
-
return yield* Fiber.join(
|
|
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
|
-
)
|
|
59
|
+
);
|
|
59
60
|
}
|