@shivaedev/effect-test-sql 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-10-11
4
+
5
+ ### Added
6
+
7
+ - `makePostgresTestLayer` prepares migrations and optional fixtures once in a PostgreSQL template and provides native services on a shared test database. `withTestTransaction` wraps harness setup, the body and scope finalizers in rollback, preserves nested `transact` savepoints, and discards commit-bound invalidations. `withIsolatedDatabase` clones the prepared template for real commits and drops the clone after the test. The Layer scope drops its shared database and template. Real PostgreSQL tests cover concurrency, row-lock release, failure and interruption, template reuse, second-connection commit visibility and resource cleanup.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ShivaeDev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,116 @@
1
- # Temporary Holding Version
1
+ # @shivaedev/effect-test-sql
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ PostgreSQL tests should start from known data and leave it there. This package prepares migrations and fixtures once, wraps ordinary Effect tests in transactions that roll back, and clones the prepared database when a test needs real commits.
4
+
5
+ ## Why you want this
6
+
7
+ Table cleanup couples tests to the order they run. A rollback test keeps its writes private and restores the prepared data when it ends:
8
+
9
+ ```ts
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
+ ]);
16
+ });
17
+ ```
18
+
19
+ The next test still sees the original name. Tests that must observe a committed write use a disposable database copied from the same starting data.
20
+
21
+ ## Using it
22
+
23
+ ### Prepare one fixture
24
+
25
+ `makePostgresTestLayer` creates a template database through an administrative connection, runs `migratePostgres` and optional fixtures there, then closes its preparation connections. It creates a shared test database from that template and provides `SqlClient`, `PgClient`, `Reactivity` and `PostgresTestDatabase`. The template remains closed and unchanged while tests run. Each isolated database is another copy of that template; its migrations and fixtures do not run again.
26
+
27
+ The Layer's scope owns the template, shared database and their connections. `makeEffectIt` builds the Layer once for its worker fixture. Each test's wrapper owns the transaction or isolated database. Closing those scopes releases the connections and drops the disposable databases; the administrative database named by `url` remains in place.
28
+
29
+ Define the tester in test support:
30
+
31
+ ```ts
32
+ import { expect } from "vitest";
33
+ import { Config, Effect, Redacted } from "effect";
34
+ import { Migrator, SqlClient } from "effect/unstable/sql";
35
+ import { makeEffectIt } from "@shivaedev/effect-test/vitest.ts";
36
+ import { makePostgresTestLayer } from "@shivaedev/effect-test-sql/postgres.ts";
37
+ import { withTestTransaction } from "@shivaedev/effect-test-sql/withTestTransaction.ts";
38
+
39
+ 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
+ }),
54
+ });
55
+
56
+ export const { effectApp } = makeEffectIt({
57
+ layer,
58
+ clock: "live",
59
+ around: withTestTransaction,
60
+ makeHarness: () => Effect.void,
61
+ });
62
+ ```
63
+
64
+ Use the application's migration loader in place of the example. `migrations` takes the existing `PostgresMigrationOptions`: `loader`, `lockTimeout`, and an optional ledger `table`. Fixtures are an Effect with access to the native PostgreSQL services. Additional migration or fixture dependencies remain requirements of the Layer and must be supplied before passing it to the runner. Setup failures remain failures; acquired databases are dropped when the acquisition scope closes.
65
+
66
+ ### Ordinary tests: rollback
67
+
68
+ `around: withTestTransaction` wraps harness acquisition, the body and their scope finalizers. Success returns the body's value after rollback. Typed failures, defects and interruption also roll back and retain their failure kind. A nested `transact` uses the native savepoint path: a rejected nested write can roll back without losing earlier writes in the test. Commit-bound invalidations stay buffered and are discarded when the wrapping transaction rolls back.
69
+
70
+ Tests may run concurrently on the shared database, with separate transaction connections. Other tests do not see uncommitted writes. PostgreSQL still enforces row locks and unique constraints: updates to the same keyed row, or inserts with the same unique key, can wait until the first test rolls back. Do not make tests wait for each other after acquiring conflicting locks. Use different keys or isolated databases when a scenario needs overlapping writes without that contention.
71
+
72
+ PostgreSQL sequence advances survive rollback. Avoid asserting exact generated sequence numbers on the shared database; use an isolated database when its sequence state must start from the template.
73
+
74
+ ### Real commits: an isolated database
75
+
76
+ Use `withIsolatedDatabase` for after-commit effects, LISTEN/NOTIFY, advisory-lock scenarios that need independent connections, migration tests, or work through several connections. It runs without a wrapping transaction on its clone. Ordinary autocommit and `transact` commit for real, and the clone is dropped when the wrapper finishes.
77
+
78
+ An isolated block can live inside the rollback tester above:
79
+
80
+ ```ts
81
+ import { withIsolatedDatabase } from "@shivaedev/effect-test-sql/withIsolatedDatabase.ts";
82
+
83
+ effectApp("commits in its own database", function* () {
84
+ yield* withIsolatedDatabase(Effect.gen(function* () {
85
+ const sql = yield* SqlClient.SqlClient;
86
+ yield* sql`update items set name = 'Committed' where id = 1`;
87
+ }));
88
+ });
89
+ ```
90
+
91
+ The clone starts from the template, including the original fixtures, even if the shared database has changed. The outer rollback transaction belongs to a different native client and does not wrap the clone's work. To isolate harness acquisition and the entire body instead, define a tester with `around: withIsolatedDatabase`.
92
+
93
+ 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.
94
+
95
+ ### API
96
+
97
+ Import each name from the module that defines it. The package has no root entry.
98
+
99
+ | Module | Exports |
100
+ | --- | --- |
101
+ | `@shivaedev/effect-test-sql/postgres.ts` | `makePostgresTestLayer({ url, migrations, fixtures? })`, `PostgresTestOptions` |
102
+ | `@shivaedev/effect-test-sql/withTestTransaction.ts` | `withTestTransaction(effect)` |
103
+ | `@shivaedev/effect-test-sql/withIsolatedDatabase.ts` | `withIsolatedDatabase(effect)` |
104
+ | `@shivaedev/effect-test-sql/database.ts` | `PostgresTestDatabase`, `PostgresTestDatabaseService`, `PostgresTestServices` |
105
+
106
+ `url` is a redacted PostgreSQL URL for an existing administrative database. `PostgresTestDatabase` supplies the current database's redacted `url` and a scoped `isolated` Effect that acquires another clone's service Context. 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 are defects rather than successful cleanup.
107
+
108
+ ### Install, setup and evidence
109
+
110
+ ```sh
111
+ pnpm add --save-dev @shivaedev/effect-test-sql @shivaedev/effect-test @effect/sql-pg @effect/vitest effect vitest
112
+ ```
113
+
114
+ 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.
115
+
116
+ The package tests use `PLATFORM_EFFECT_TEST_SQL_TEST_DATABASE_URL`. Repository setup and CI prepare that database on real PostgreSQL. Local tests skip when that variable is absent; CI rejects its absence. The PostgreSQL tests prove overlapping same-row visibility, shared-key lock contention and release, rollback, nested savepoints, runner/harness scope composition, one-time template preparation, commits visible through another connection, and database cleanup. Compiler and packed-consumer tests prove the exercised public type and export contracts. These are library checks, not evidence for an application's servers or notification delivery.
@@ -0,0 +1,15 @@
1
+ import type * as PgClient from "@effect/sql-pg/PgClient";
2
+ import { Context, type Effect, type Redacted, type Scope } from "effect";
3
+ import type * as Reactivity from "effect/unstable/reactivity/Reactivity";
4
+ import type { SqlClient } from "effect/unstable/sql";
5
+ import type { SqlError } from "effect/unstable/sql/SqlError";
6
+ export type PostgresTestServices = PgClient.PgClient | SqlClient.SqlClient | Reactivity.Reactivity;
7
+ export interface PostgresTestDatabaseService {
8
+ readonly isolated: Effect.Effect<Context.Context<PostgresTestServices | PostgresTestDatabase>, SqlError, Scope.Scope>;
9
+ readonly url: Redacted.Redacted<string>;
10
+ }
11
+ declare const PostgresTestDatabase_base: Context.ServiceClass<PostgresTestDatabase, "@shivaedev/effect-test-sql/PostgresTestDatabase", PostgresTestDatabaseService>;
12
+ export declare class PostgresTestDatabase extends PostgresTestDatabase_base {
13
+ }
14
+ export {};
15
+ //# sourceMappingURL=database.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../src/database.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,QAAQ,MAAM,yBAAyB,CAAC;AACzD,OAAO,EAAE,OAAO,EAAE,KAAK,MAAM,EAAE,KAAK,QAAQ,EAAE,KAAK,KAAK,EAAE,MAAM,QAAQ,CAAC;AACzE,OAAO,KAAK,KAAK,UAAU,MAAM,uCAAuC,CAAC;AACzE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AAE7D,MAAM,MAAM,oBAAoB,GAAG,QAAQ,CAAC,QAAQ,GAAG,SAAS,CAAC,SAAS,GAAG,UAAU,CAAC,UAAU,CAAC;AAEnG,MAAM,WAAW,2BAA2B;IAC3C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,oBAAoB,GAAG,oBAAoB,CAAC,EAAE,QAAQ,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;IACtH,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;CACxC;;AAED,qBAAa,oBAAqB,SAAQ,yBAEzC;CAAG"}
@@ -0,0 +1,4 @@
1
+ import { Context } from "effect";
2
+ export class PostgresTestDatabase extends Context.Service()("@shivaedev/effect-test-sql/PostgresTestDatabase") {
3
+ }
4
+ //# sourceMappingURL=database.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"database.js","sourceRoot":"","sources":["../src/database.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAA0C,MAAM,QAAQ,CAAC;AAYzE,MAAM,OAAO,oBAAqB,SAAQ,OAAO,CAAC,OAAO,EAAqD,CAC7G,iDAAiD,CACjD;CAAG"}
@@ -0,0 +1,10 @@
1
+ import * as PgClient from "@effect/sql-pg/PgClient";
2
+ import { Effect, Layer, Redacted } from "effect";
3
+ import * as Reactivity from "effect/unstable/reactivity/Reactivity";
4
+ import { SqlClient } from "effect/unstable/sql";
5
+ export declare function connection(url: Redacted.Redacted<string>): Layer.Layer<PgClient.PgClient | Reactivity.Reactivity | SqlClient.SqlClient, import("effect/unstable/sql/SqlError").SqlError, never>;
6
+ export declare const database: (admin: PgClient.PgClient, url: Redacted.Redacted<string>, template?: any) => Effect.Effect<{
7
+ name: string;
8
+ url: Redacted.Redacted<string>;
9
+ }, import("effect/unstable/sql/SqlError").SqlError, import("effect/Scope").Scope>;
10
+ //# sourceMappingURL=databases.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"databases.d.ts","sourceRoot":"","sources":["../../src/internal/databases.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,QAAQ,MAAM,yBAAyB,CAAC;AACpD,OAAO,EAAW,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAC1D,OAAO,KAAK,UAAU,MAAM,uCAAuC,CAAC;AACpE,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAEhD,wBAAgB,UAAU,CAAC,GAAG,EAAE,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,wIAIxD;AAED,eAAO,MAAM,QAAQ;;;iFAanB,CAAC"}
@@ -0,0 +1,16 @@
1
+ import * as PgClient from "@effect/sql-pg/PgClient";
2
+ import { Context, Effect, Layer, Redacted } from "effect";
3
+ import * as Reactivity from "effect/unstable/reactivity/Reactivity";
4
+ import { SqlClient } from "effect/unstable/sql";
5
+ export function connection(url) {
6
+ return Layer.effectContext(Effect.map(PgClient.make({ url }), (client) => Context.make(SqlClient.SqlClient, client).pipe(Context.add(PgClient.PgClient, client)))).pipe(Layer.provideMerge(Layer.fresh(Reactivity.layer)));
7
+ }
8
+ export const database = Effect.fn("PostgresTest.database")(function* (admin, url, template = "template0") {
9
+ const name = `effect_test_${crypto.randomUUID().replaceAll("-", "")}`;
10
+ const target = new URL(Redacted.value(url));
11
+ target.pathname = `/${name}`;
12
+ target.searchParams.delete("database");
13
+ yield* Effect.acquireRelease(admin `create database ${admin(name)} template ${admin(template)}`, () => Effect.orDie(admin `drop database ${admin(name)} with (force)`));
14
+ return { name, url: Redacted.make(target.toString()) };
15
+ });
16
+ //# sourceMappingURL=databases.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"databases.js","sourceRoot":"","sources":["../../src/internal/databases.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,QAAQ,MAAM,yBAAyB,CAAC;AACpD,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAC1D,OAAO,KAAK,UAAU,MAAM,uCAAuC,CAAC;AACpE,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAEhD,MAAM,UAAU,UAAU,CAAC,GAA8B;IACxD,OAAO,KAAK,CAAC,aAAa,CACzB,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC,CACtI,CAAC,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AAC3D,CAAC;AAED,MAAM,CAAC,MAAM,QAAQ,GAAG,MAAM,CAAC,EAAE,CAAC,uBAAuB,CAAC,CAAC,QAAQ,CAAC,EACnE,KAAwB,EACxB,GAA8B,EAC9B,QAAQ,GAAG,WAAW;IAEtB,MAAM,IAAI,GAAG,eAAe,MAAM,CAAC,UAAU,EAAE,CAAC,UAAU,CAAC,GAAG,EAAE,EAAE,CAAC,EAAE,CAAC;IACtE,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC;IAC5C,MAAM,CAAC,QAAQ,GAAG,IAAI,IAAI,EAAE,CAAC;IAC7B,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IACvC,KAAK,CAAC,CAAC,MAAM,CAAC,cAAc,CAAC,KAAK,CAAA,mBAAmB,KAAK,CAAC,IAAI,CAAC,aAAa,KAAK,CAAC,QAAQ,CAAC,EAAE,EAAE,GAAG,EAAE,CACpG,MAAM,CAAC,KAAK,CAAC,KAAK,CAAA,iBAAiB,KAAK,CAAC,IAAI,CAAC,eAAe,CAAC,CAC9D,CAAC;IACF,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC,EAAE,CAAC;AACxD,CAAC,CAAC,CAAC"}
@@ -0,0 +1,12 @@
1
+ import * as PgClient from "@effect/sql-pg/PgClient";
2
+ import { Effect, Layer, type Redacted } from "effect";
3
+ import * as Reactivity from "effect/unstable/reactivity/Reactivity";
4
+ import { type PostgresMigrationOptions } from "@shivaedev/effect-sql/migrations.ts";
5
+ import { PostgresTestDatabase, type PostgresTestServices } from "./database.ts";
6
+ export interface PostgresTestOptions<E = never, R = never, TFixtures = never> {
7
+ readonly fixtures?: Effect.Effect<unknown, E, TFixtures | PostgresTestServices>;
8
+ readonly migrations: PostgresMigrationOptions<R>;
9
+ readonly url: Redacted.Redacted<string>;
10
+ }
11
+ export declare function makePostgresTestLayer<E = never, R = never, TFixtures = never>(options: PostgresTestOptions<E, R, TFixtures>): Layer.Layer<PgClient.PgClient | PostgresTestDatabase | Reactivity.Reactivity | import("effect/unstable/sql/SqlClient").SqlClient, E | import("effect/unstable/sql/Migrator").MigrationError | import("effect/unstable/sql/SqlError").SqlError, Exclude<Exclude<Exclude<Exclude<R, PgClient.PgClient | Reactivity.Reactivity | import("effect/unstable/sql/SqlClient").SqlClient>, import("effect/Scope").Scope>, import("effect/Scope").Scope>, Reactivity.Reactivity> | Exclude<Exclude<Exclude<Exclude<TFixtures, PgClient.PgClient | Reactivity.Reactivity | import("effect/unstable/sql/SqlClient").SqlClient>, import("effect/Scope").Scope>, import("effect/Scope").Scope>, Reactivity.Reactivity>>;
12
+ //# sourceMappingURL=postgres.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres.d.ts","sourceRoot":"","sources":["../src/postgres.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,QAAQ,MAAM,yBAAyB,CAAC;AACpD,OAAO,EAAW,MAAM,EAAE,KAAK,EAAE,KAAK,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAC/D,OAAO,KAAK,UAAU,MAAM,uCAAuC,CAAC;AACpE,OAAO,EAAmB,KAAK,wBAAwB,EAAE,MAAM,qCAAqC,CAAC;AAErG,OAAO,EAAE,oBAAoB,EAAoC,KAAK,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAElH,MAAM,WAAW,mBAAmB,CAAC,CAAC,GAAG,KAAK,EAAE,CAAC,GAAG,KAAK,EAAE,SAAS,GAAG,KAAK;IAC3E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,EAAE,SAAS,GAAG,oBAAoB,CAAC,CAAC;IAChF,QAAQ,CAAC,UAAU,EAAE,wBAAwB,CAAC,CAAC,CAAC,CAAC;IACjD,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;CACxC;AAED,wBAAgB,qBAAqB,CAAC,CAAC,GAAG,KAAK,EAAE,CAAC,GAAG,KAAK,EAAE,SAAS,GAAG,KAAK,EAAE,OAAO,EAAE,mBAAmB,CAAC,CAAC,EAAE,CAAC,EAAE,SAAS,CAAC,6qBAwB3H"}
@@ -0,0 +1,28 @@
1
+ import * as PgClient from "@effect/sql-pg/PgClient";
2
+ import { Context, Effect, Layer } from "effect";
3
+ import * as Reactivity from "effect/unstable/reactivity/Reactivity";
4
+ import { migratePostgres } from "@shivaedev/effect-sql/migrations.ts";
5
+ import { connection, database } from "#internal/databases.ts";
6
+ import { PostgresTestDatabase } from "./database.js";
7
+ export function makePostgresTestLayer(options) {
8
+ return Layer.effectContext(Effect.gen(function* () {
9
+ const admin = yield* PgClient.make({ url: options.url });
10
+ const template = yield* database(admin, options.url);
11
+ yield* Effect.scoped(Effect.gen(function* () {
12
+ const services = yield* Layer.build(connection(template.url));
13
+ yield* migratePostgres(options.migrations).pipe(Effect.provideContext(services));
14
+ if (options.fixtures !== undefined) {
15
+ yield* options.fixtures.pipe(Effect.provideContext(services));
16
+ }
17
+ }));
18
+ const shared = yield* database(admin, options.url, template.name);
19
+ const services = yield* Layer.build(connection(shared.url));
20
+ const isolated = Effect.gen(function* () {
21
+ const clone = yield* database(admin, options.url, template.name);
22
+ const context = yield* Layer.build(connection(clone.url));
23
+ return Context.add(context, PostgresTestDatabase, { isolated, url: clone.url });
24
+ });
25
+ return Context.add(services, PostgresTestDatabase, { isolated, url: shared.url });
26
+ })).pipe(Layer.provide(Reactivity.layer));
27
+ }
28
+ //# sourceMappingURL=postgres.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres.js","sourceRoot":"","sources":["../src/postgres.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,QAAQ,MAAM,yBAAyB,CAAC;AACpD,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAiB,MAAM,QAAQ,CAAC;AAC/D,OAAO,KAAK,UAAU,MAAM,uCAAuC,CAAC;AACpE,OAAO,EAAE,eAAe,EAAiC,MAAM,qCAAqC,CAAC;AACrG,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAC9D,OAAO,EAAE,oBAAoB,EAA+D,MAAM,eAAe,CAAC;AAQlH,MAAM,UAAU,qBAAqB,CAA0C,OAA6C;IAC3H,OAAO,KAAK,CAAC,aAAa,CACzB,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QACnB,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;QACzD,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC;QACrD,KAAK,CAAC,CAAC,MAAM,CAAC,MAAM,CACnB,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;YACnB,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC;YAC9D,KAAK,CAAC,CAAC,eAAe,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC,CAAC;YACjF,IAAI,OAAO,CAAC,QAAQ,KAAK,SAAS,EAAE,CAAC;gBACpC,KAAK,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC,CAAC;YAC/D,CAAC;QACF,CAAC,CAAC,CACF,CAAC;QACF,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,GAAG,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC;QAClE,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;QAC5D,MAAM,QAAQ,GAA4C,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;YAC7E,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,GAAG,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC;YACjE,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC;YAC1D,OAAO,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,oBAAoB,EAAE,EAAE,QAAQ,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC;QACjF,CAAC,CAAC,CAAC;QACH,OAAO,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,oBAAoB,EAAE,EAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC;IACnF,CAAC,CAAC,CACF,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC;AACzC,CAAC"}
@@ -0,0 +1,4 @@
1
+ import { Effect } from "effect";
2
+ import { PostgresTestDatabase } from "./database.ts";
3
+ export declare function withIsolatedDatabase<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | import("effect/unstable/sql/SqlError").SqlError, PostgresTestDatabase | Exclude<Exclude<R, PostgresTestDatabase | import("./database.ts").PostgresTestServices>, import("effect/Scope").Scope>>;
4
+ //# sourceMappingURL=withIsolatedDatabase.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"withIsolatedDatabase.d.ts","sourceRoot":"","sources":["../src/withIsolatedDatabase.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAErD,wBAAgB,oBAAoB,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,wNAQ3E"}
@@ -0,0 +1,10 @@
1
+ import { Effect } from "effect";
2
+ import { PostgresTestDatabase } from "./database.js";
3
+ export function withIsolatedDatabase(effect) {
4
+ return Effect.scoped(Effect.gen(function* () {
5
+ const database = yield* PostgresTestDatabase;
6
+ const services = yield* database.isolated;
7
+ return yield* effect.pipe(Effect.provideContext(services));
8
+ }));
9
+ }
10
+ //# sourceMappingURL=withIsolatedDatabase.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"withIsolatedDatabase.js","sourceRoot":"","sources":["../src/withIsolatedDatabase.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAErD,MAAM,UAAU,oBAAoB,CAAU,MAA8B;IAC3E,OAAO,MAAM,CAAC,MAAM,CACnB,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QACnB,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,oBAAoB,CAAC;QAC7C,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAC1C,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC,CAAC;IAC5D,CAAC,CAAC,CACF,CAAC;AACH,CAAC"}
@@ -0,0 +1,4 @@
1
+ import { Effect } from "effect";
2
+ import type { SqlError } from "effect/unstable/sql/SqlError";
3
+ export declare function withTestTransaction<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | SqlError, import("effect/unstable/reactivity/Reactivity").Reactivity | import("effect/unstable/sql/SqlClient").SqlClient | Exclude<R, import("effect/Scope").Scope>>;
4
+ //# sourceMappingURL=withTestTransaction.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"withTestTransaction.d.ts","sourceRoot":"","sources":["../src/withTestTransaction.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAQ,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AAO7D,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,6LAM1E"}
@@ -0,0 +1,9 @@
1
+ import { Effect, Exit } from "effect";
2
+ import { transact } from "@shivaedev/effect-sql/transact.ts";
3
+ function isTestExit(value) {
4
+ return Exit.isExit(value);
5
+ }
6
+ export function withTestTransaction(effect) {
7
+ return Effect.uninterruptibleMask((restore) => transact(Effect.flatMap(Effect.exit(Effect.scoped(restore(effect))), Effect.fail), { onSqlError: (error) => error }).pipe(Effect.catchIf(isTestExit, (exit) => exit)));
8
+ }
9
+ //# sourceMappingURL=withTestTransaction.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"withTestTransaction.js","sourceRoot":"","sources":["../src/withTestTransaction.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,EAAE,QAAQ,EAAE,MAAM,mCAAmC,CAAC;AAE7D,SAAS,UAAU,CAAO,KAAiC;IAC1D,OAAO,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAC3B,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAU,MAA8B;IAC1E,OAAO,MAAM,CAAC,mBAAmB,CAAC,CAAC,OAAO,EAAE,EAAE,CAC7C,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,EAAE,EAAE,UAAU,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,EAAE,CAAC,CAAC,IAAI,CACxH,MAAM,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,CAC1C,CACD,CAAC;AACH,CAAC"}
@@ -0,0 +1,9 @@
1
+ # PostgreSQL tests with deliberate lifetimes
2
+
3
+ Database tests repeat expensive setup and fragile cleanup when each application invents its own fixture lifecycle. The ideal is one migration and fixture preparation for a worker fixture, ordinary tests whose data disappears through rollback, and a deliberate escape for tests that need PostgreSQL to commit.
4
+
5
+ Native Effect owns Context, Scope, failure, interruption and SQL transaction connections. `effect-test` owns the runner; `effect-sql` owns migrations and commit-bound transactions. This package joins those paths with PostgreSQL database resources. It does not implement another migration engine, pool driver or test runner.
6
+
7
+ Prepare an immutable template, close its connections, and clone it for the shared database and isolated scenarios. Connection finalizers precede database drops. A rejected or interrupted test must not become a successful test during cleanup. A rolled-back nested transaction uses a savepoint, and a rolled-back test must not publish commit-bound invalidations.
8
+
9
+ Isolation does not erase PostgreSQL's concurrency rules. Conflicting keyed writes share locks in the ordinary path. Separate databases permit independent data and real commits, while cluster-wide resources still need scenario-specific choices. Preserve these boundaries over a promise of universal concurrency.
@@ -0,0 +1,8 @@
1
+ # Roadmap
2
+
3
+ - [x] Prepare migrations and optional fixtures once in a closed template database.
4
+ - [x] Supply native PostgreSQL services on a shared database with per-test rollback.
5
+ - [x] Compose the wrapping transaction with `makeEffectIt`, harness scopes and nested `transact` savepoints.
6
+ - [x] Clone isolated databases for real commits and drop them after use.
7
+ - [x] Prove the database lifecycle against PostgreSQL and the public contracts with compiler and packed-consumer checks.
8
+ - [x] Prepare the approved initial 0.1.0 release metadata.
package/package.json CHANGED
@@ -1,6 +1,75 @@
1
1
  {
2
2
  "name": "@shivaedev/effect-test-sql",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "PostgreSQL rollback and real-commit test isolation for Effect",
5
+ "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/effect-test-sql#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/ShivaeDev/platform/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/ShivaeDev/platform.git",
12
+ "directory": "packages/effect-test-sql"
13
+ },
14
+ "license": "MIT",
15
+ "sideEffects": false,
16
+ "type": "module",
17
+ "imports": {
18
+ "#test/*": "./src/test-support/*",
19
+ "#*.ts": {
20
+ "source": "./src/*.ts",
21
+ "types": "./dist/*.d.ts",
22
+ "default": "./dist/*.js"
23
+ }
24
+ },
25
+ "exports": {
26
+ "./*.ts": {
27
+ "types": "./dist/*.d.ts",
28
+ "source": "./src/*.ts",
29
+ "import": "./dist/*.js",
30
+ "default": "./dist/*.js"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "src",
37
+ "!src/**/*.test.ts",
38
+ "!src/**/*.spec.ts",
39
+ "!src/**/test-support",
40
+ "docs",
41
+ "CHANGELOG.md",
42
+ "README.md"
43
+ ],
44
+ "dependencies": {
45
+ "@shivaedev/effect-sql": "0.2.0"
46
+ },
47
+ "devDependencies": {
48
+ "@effect/sql-pg": "4.0.0-rc.112",
49
+ "@effect/vitest": "4.0.0-rc.112",
50
+ "@shivaedev/effect-test": "0.3.1",
51
+ "@shivaedev/quality": "0.18.0",
52
+ "@types/node": "24.10.1",
53
+ "@typescript/native": "npm:typescript@7.0.2",
54
+ "effect": "4.0.0-rc.112",
55
+ "typescript": "npm:@typescript/typescript6@6.0.2",
56
+ "vitest": "4.1.11"
57
+ },
58
+ "peerDependencies": {
59
+ "@effect/sql-pg": "4.0.0-rc.112",
60
+ "effect": "4.0.0-rc.112"
61
+ },
62
+ "engines": {
63
+ "node": ">=24"
64
+ },
65
+ "publishConfig": {
66
+ "access": "public",
67
+ "provenance": true
68
+ },
69
+ "scripts": {
70
+ "build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc --project tsconfig.build.json",
71
+ "ready": "pnpm typecheck && pnpm test && pnpm build",
72
+ "test": "vitest run",
73
+ "typecheck": "tsc --noEmit"
74
+ }
6
75
  }
@@ -0,0 +1,16 @@
1
+ import type * as PgClient from "@effect/sql-pg/PgClient";
2
+ import { Context, type Effect, type Redacted, type Scope } from "effect";
3
+ import type * as Reactivity from "effect/unstable/reactivity/Reactivity";
4
+ import type { SqlClient } from "effect/unstable/sql";
5
+ import type { SqlError } from "effect/unstable/sql/SqlError";
6
+
7
+ export type PostgresTestServices = PgClient.PgClient | SqlClient.SqlClient | Reactivity.Reactivity;
8
+
9
+ export interface PostgresTestDatabaseService {
10
+ readonly isolated: Effect.Effect<Context.Context<PostgresTestServices | PostgresTestDatabase>, SqlError, Scope.Scope>;
11
+ readonly url: Redacted.Redacted<string>;
12
+ }
13
+
14
+ export class PostgresTestDatabase extends Context.Service<PostgresTestDatabase, PostgresTestDatabaseService>()(
15
+ "@shivaedev/effect-test-sql/PostgresTestDatabase",
16
+ ) {}
@@ -0,0 +1,25 @@
1
+ import * as PgClient from "@effect/sql-pg/PgClient";
2
+ import { Context, Effect, Layer, Redacted } from "effect";
3
+ import * as Reactivity from "effect/unstable/reactivity/Reactivity";
4
+ import { SqlClient } from "effect/unstable/sql";
5
+
6
+ export function connection(url: Redacted.Redacted<string>) {
7
+ return Layer.effectContext(
8
+ Effect.map(PgClient.make({ url }), (client) => Context.make(SqlClient.SqlClient, client).pipe(Context.add(PgClient.PgClient, client))),
9
+ ).pipe(Layer.provideMerge(Layer.fresh(Reactivity.layer)));
10
+ }
11
+
12
+ export const database = Effect.fn("PostgresTest.database")(function* (
13
+ admin: PgClient.PgClient,
14
+ url: Redacted.Redacted<string>,
15
+ template = "template0",
16
+ ) {
17
+ const name = `effect_test_${crypto.randomUUID().replaceAll("-", "")}`;
18
+ const target = new URL(Redacted.value(url));
19
+ target.pathname = `/${name}`;
20
+ target.searchParams.delete("database");
21
+ yield* Effect.acquireRelease(admin`create database ${admin(name)} template ${admin(template)}`, () =>
22
+ Effect.orDie(admin`drop database ${admin(name)} with (force)`),
23
+ );
24
+ return { name, url: Redacted.make(target.toString()) };
25
+ });
@@ -0,0 +1,38 @@
1
+ import * as PgClient from "@effect/sql-pg/PgClient";
2
+ import { Context, Effect, Layer, type Redacted } from "effect";
3
+ import * as Reactivity from "effect/unstable/reactivity/Reactivity";
4
+ import { migratePostgres, type PostgresMigrationOptions } from "@shivaedev/effect-sql/migrations.ts";
5
+ import { connection, database } from "#internal/databases.ts";
6
+ import { PostgresTestDatabase, type PostgresTestDatabaseService, type PostgresTestServices } from "./database.ts";
7
+
8
+ export interface PostgresTestOptions<E = never, R = never, TFixtures = never> {
9
+ readonly fixtures?: Effect.Effect<unknown, E, TFixtures | PostgresTestServices>;
10
+ readonly migrations: PostgresMigrationOptions<R>;
11
+ readonly url: Redacted.Redacted<string>;
12
+ }
13
+
14
+ export function makePostgresTestLayer<E = never, R = never, TFixtures = never>(options: PostgresTestOptions<E, R, TFixtures>) {
15
+ return Layer.effectContext(
16
+ Effect.gen(function* () {
17
+ const admin = yield* PgClient.make({ url: options.url });
18
+ const template = yield* database(admin, options.url);
19
+ yield* Effect.scoped(
20
+ Effect.gen(function* () {
21
+ const services = yield* Layer.build(connection(template.url));
22
+ yield* migratePostgres(options.migrations).pipe(Effect.provideContext(services));
23
+ if (options.fixtures !== undefined) {
24
+ yield* options.fixtures.pipe(Effect.provideContext(services));
25
+ }
26
+ }),
27
+ );
28
+ const shared = yield* database(admin, options.url, template.name);
29
+ const services = yield* Layer.build(connection(shared.url));
30
+ const isolated: PostgresTestDatabaseService["isolated"] = Effect.gen(function* () {
31
+ const clone = yield* database(admin, options.url, template.name);
32
+ const context = yield* Layer.build(connection(clone.url));
33
+ return Context.add(context, PostgresTestDatabase, { isolated, url: clone.url });
34
+ });
35
+ return Context.add(services, PostgresTestDatabase, { isolated, url: shared.url });
36
+ }),
37
+ ).pipe(Layer.provide(Reactivity.layer));
38
+ }
@@ -0,0 +1,12 @@
1
+ import { Effect } from "effect";
2
+ import { PostgresTestDatabase } from "./database.ts";
3
+
4
+ export function withIsolatedDatabase<A, E, R>(effect: Effect.Effect<A, E, R>) {
5
+ return Effect.scoped(
6
+ Effect.gen(function* () {
7
+ const database = yield* PostgresTestDatabase;
8
+ const services = yield* database.isolated;
9
+ return yield* effect.pipe(Effect.provideContext(services));
10
+ }),
11
+ );
12
+ }
@@ -0,0 +1,15 @@
1
+ import { Effect, Exit } from "effect";
2
+ import type { SqlError } from "effect/unstable/sql/SqlError";
3
+ import { transact } from "@shivaedev/effect-sql/transact.ts";
4
+
5
+ function isTestExit<A, E>(value: Exit.Exit<A, E> | SqlError): value is Exit.Exit<A, E> {
6
+ return Exit.isExit(value);
7
+ }
8
+
9
+ export function withTestTransaction<A, E, R>(effect: Effect.Effect<A, E, R>) {
10
+ return Effect.uninterruptibleMask((restore) =>
11
+ transact(Effect.flatMap(Effect.exit(Effect.scoped(restore(effect))), Effect.fail), { onSqlError: (error) => error }).pipe(
12
+ Effect.catchIf(isTestExit, (exit) => exit),
13
+ ),
14
+ );
15
+ }