@shivaedev/effect-test-sql 0.4.0 → 0.5.1

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1 - 2026-10-17
4
+
5
+ ### Fixed
6
+
7
+ - The `migrateTo(name: string | number)` helper returned by `migrationSteps` accepts zero-padded migration names as written, unpadded names and bare numeric ids. Unknown or ambiguous targets fail with a typed `MigrationError` naming the requested target.
8
+
9
+ ## 0.5.0 - 2026-10-17
10
+
11
+ ### Added
12
+
13
+ - `migrationSteps(appMigrations)` from `migrationSteps.ts` provides `migrateTo("1_name")` and `migrateToLatest` on the current PostgreSQL client, so tests can seed old-schema rows before running a backfill. Unknown names fail with a typed `MigrationError`; loader dependencies and scope stay explicit.
14
+ - `makePostgresTestLayer({ prepare: false, ... })` skips migration and fixture preparation, giving isolated tests and test-story stories empty databases without a second migration list.
15
+
3
16
  ## 0.4.0 - 2026-10-17
4
17
 
5
18
  ### Added
package/README.md CHANGED
@@ -22,7 +22,7 @@ The next test still sees the original name. Tests that must observe a committed
22
22
 
23
23
  ### Prepare one fixture
24
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.
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. Set `prepare: false` for migration tests: both migrations and fixtures are skipped, so the shared database and each clone start empty.
26
26
 
27
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
28
 
@@ -110,6 +110,45 @@ The clone starts from the template, including the original fixtures, even if the
110
110
 
111
111
  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.
112
112
 
113
+ ### Test a backfill against existing rows
114
+
115
+ A backfill needs rows in the old schema. Prepare an empty template with the application's own `PostgresMigrationOptions`, then use `migrationSteps` inside `withIsolatedDatabase`. `migrateTo` accepts the migration name as written without a file extension (`"001_create_contacts"`), its unpadded form (`"1_create_contacts"`), or its bare numeric id (`1`, `"1"` or `"001"`). It commits migrations through that target; seed rows with the native SQL client, then yield `migrateToLatest` to apply the remaining migrations on the same database.
116
+
117
+ This example uses an application loader whose `001_create_contacts` migration creates `contacts(id, email)`, `002_backfill_emails` creates `emails(contact_id, address)` and copies the contact email addresses into it, and `003_drop_email` drops `contacts.email`:
118
+
119
+ ```ts
120
+ import { migrationSteps } from "@shivaedev/effect-test-sql/migrationSteps.ts";
121
+ import { withIsolatedDatabase } from "@shivaedev/effect-test-sql/withIsolatedDatabase.ts";
122
+ import { appMigrations } from "./migrations.ts";
123
+
124
+ const migrationLayer = makePostgresTestLayer({
125
+ url,
126
+ migrations: appMigrations,
127
+ prepare: false,
128
+ });
129
+
130
+ const { effectApp: migrationTest } = makeEffectIt({
131
+ layer: migrationLayer,
132
+ clock: "live",
133
+ around: withIsolatedDatabase,
134
+ makeHarness: () => migrationSteps(appMigrations),
135
+ });
136
+
137
+ migrationTest("preserves contact emails in the new table", function* (steps) {
138
+ yield* steps.migrateTo("001_create_contacts");
139
+ const sql = yield* SqlClient.SqlClient;
140
+ yield* sql`insert into contacts (id, email) values (1, 'alice@example.test')`;
141
+ yield* steps.migrateToLatest;
142
+ expect(yield* sql`select contact_id, address from emails`).toEqual([
143
+ { contact_id: 1, address: "alice@example.test" },
144
+ ]);
145
+ });
146
+ ```
147
+
148
+ The helper resolves the loader once and uses `migratePostgres` for each step, preserving its ledger table and lock timeout. Unknown or ambiguous targets fail with a typed `MigrationError` that names the requested target. Moving behind an already applied migration and running inside a wrapping transaction also fail with `MigrationError`. Loader dependencies, including `Scope`, remain requirements of `migrationSteps`; its resources live until the caller's scope closes. Use the returned steps inside that database's scope. SQL and loader failures retain their failure channels.
149
+
150
+ For test-story, use the same Layer and `around: withIsolatedDatabase`, acquire `migrationSteps(appMigrations)` in the kit's `create`, and let schema and data traits call `migrateTo` and seed native SQL. A verb can yield `migrateToLatest` before the story asserts the backfilled rows.
151
+
113
152
  ### Check upgrades against the current model
114
153
 
115
154
  A fresh-database test cannot prove that older databases upgrade to the current model. Run `migrationUpgrade` in a disposable database with an empty migration ledger, outside a wrapping transaction. It resolves the application's loader once, commits migrations through the existing `upTo` id, then applies the remaining migrations through `migratePostgres`. It returns the target table's column names and PostgreSQL `format_type` strings, including type modifiers such as `numeric(10,2)`; dropped columns are excluded.
@@ -130,7 +169,7 @@ yield* assertModelColumns(columns, Item, {
130
169
  });
131
170
  ```
132
171
 
133
- Use an empty loader when preparing the test Layer, and `withIsolatedDatabase` for the upgrade test. The helper requires the native `SqlClient`; loader dependencies remain Effect requirements. Unknown starting ids, populated ledgers and wrapping transactions fail with `MigrationError`. Native loader and SQL failures retain their failure channel.
172
+ Use `prepare: false` when preparing the test Layer, and `withIsolatedDatabase` for the upgrade test. The helper requires the native `SqlClient`; loader dependencies remain Effect requirements. Unknown starting ids, populated ledgers and wrapping transactions fail with `MigrationError`. Native loader and SQL failures retain their failure channel.
134
173
 
135
174
  The assertion uses the SQL `Model`'s selected database fields, including generated ids and excluding JSON-only fields. Supply an exact PostgreSQL type for every field: schemas such as `Schema.Number` do not determine whether storage is `integer`, `numeric` or `double precision`. It fails with a typed `ModelColumnMismatch` containing all detected missing, extra and wrongly typed column problems. The type map is checked against the model both by TypeScript and at runtime. This check covers names and types; it does not check defaults, nullability, constraints or application data transformations.
136
175
 
@@ -142,10 +181,11 @@ Import each name from the module that defines it. The package has no root entry.
142
181
 
143
182
  | Module | Exports |
144
183
  | --- | --- |
145
- | `@shivaedev/effect-test-sql/postgres.ts` | `makePostgresTestLayer({ url, migrations, fixtures?, maxConnections? })`, `PostgresTestOptions` |
184
+ | `@shivaedev/effect-test-sql/postgres.ts` | `makePostgresTestLayer({ url, migrations, fixtures?, maxConnections?, prepare? })`, `PostgresTestOptions` |
146
185
  | `@shivaedev/effect-test-sql/withTestTransaction.ts` | `withTestTransaction(effect)` |
147
186
  | `@shivaedev/effect-test-sql/withIsolatedDatabase.ts` | `withIsolatedDatabase(effect)` |
148
187
  | `@shivaedev/effect-test-sql/database.ts` | `PostgresTestDatabase`, `PostgresTestDatabaseService`, `PostgresTestServices` |
188
+ | `@shivaedev/effect-test-sql/migrationSteps.ts` | `migrationSteps(appMigrations)` returning `{ migrateTo(name), migrateToLatest }` |
149
189
  | `@shivaedev/effect-test-sql/migrationUpgrade.ts` | `migrationUpgrade({ migrations, upTo, table })`, `MigrationUpgradeOptions`, `PostgresColumn` |
150
190
  | `@shivaedev/effect-test-sql/assertModelColumns.ts` | `assertModelColumns(columns, model, types)`, `ModelColumnMismatch` |
151
191
 
@@ -0,0 +1,8 @@
1
+ import { Effect } from "effect";
2
+ import { Migrator, SqlClient } from "effect/sql";
3
+ import { type PostgresMigrationOptions } from "@shivaedev/effect-sql/migrations.ts";
4
+ export declare const migrationSteps: <R>(migrations: PostgresMigrationOptions<R>) => Effect.Effect<{
5
+ migrateTo: (name: string | number) => Effect.Effect<readonly (readonly [id: number, name: string])[], Migrator.MigrationError | import("effect/sql/SqlError").SqlError, never>;
6
+ migrateToLatest: Effect.Effect<readonly (readonly [id: number, name: string])[], Migrator.MigrationError | import("effect/sql/SqlError").SqlError, never>;
7
+ }, Migrator.MigrationError, R | SqlClient.SqlClient>;
8
+ //# sourceMappingURL=migrationSteps.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrationSteps.d.ts","sourceRoot":"","sources":["../../src/server/migrationSteps.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAU,MAAM,QAAQ,CAAC;AACxC,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACjD,OAAO,EAAmB,KAAK,wBAAwB,EAAE,MAAM,qCAAqC,CAAC;AAYrG,eAAO,MAAM,cAAc,GAAuD,CAAC;sBAuB/D,MAAM,GAAG,MAAM;;oDAejC,CAAC"}
@@ -0,0 +1,50 @@
1
+ import { Effect, Option } from "effect";
2
+ import { Migrator, SqlClient } from "effect/sql";
3
+ import { migratePostgres } from "@shivaedev/effect-sql/migrations.ts";
4
+ function invalid(message) {
5
+ return Effect.fail(new Migrator.MigrationError({ kind: "BadState", message }));
6
+ }
7
+ const outsideTransaction = Effect.fnUntraced(function* (sql) {
8
+ if (Option.isSome(yield* Effect.serviceOption(sql.transactionService))) {
9
+ return yield* invalid("Migration steps must run outside a wrapping transaction.");
10
+ }
11
+ });
12
+ export const migrationSteps = Effect.fn("PostgresTest.migrationSteps")(function* (migrations) {
13
+ const sql = yield* SqlClient.SqlClient;
14
+ yield* sql.onDialectOrElse({ orElse: () => invalid("Migration steps require PostgreSQL."), pg: () => Effect.void });
15
+ yield* outsideTransaction(sql);
16
+ const resolved = yield* migrations.loader;
17
+ const ledger = migrations.table ?? "effect_sql_migrations";
18
+ const identifier = sql `${sql(ledger)}`.compile()[0];
19
+ function run(loader, target) {
20
+ return Effect.gen(function* () {
21
+ yield* outsideTransaction(sql);
22
+ if (target !== undefined) {
23
+ const [existing] = yield* sql `select to_regclass(${identifier})::text as name`.withoutTransform;
24
+ if (existing !== undefined && existing.name !== null) {
25
+ const later = yield* sql `select migration_id from ${sql(ledger)} where migration_id > ${target} limit 1`;
26
+ if (later.length > 0) {
27
+ return yield* invalid(`Cannot migrate to ${target}: later migrations are already applied.`);
28
+ }
29
+ }
30
+ }
31
+ return yield* migratePostgres({ ...migrations, loader }).pipe(Effect.provideService(SqlClient.SqlClient, sql));
32
+ });
33
+ }
34
+ return {
35
+ migrateTo: (name) => {
36
+ const match = /^(?<id>\d+)(?:_(?<label>.+))?$/u.exec(String(name))?.groups;
37
+ const matches = match === undefined ? [] : resolved.filter(([id, label]) => id === Number(match.id) && (match.label === undefined || label === match.label));
38
+ const [target] = matches;
39
+ if (target === undefined) {
40
+ return invalid(`Migration "${name}" is not in the loader.`);
41
+ }
42
+ if (matches.length > 1) {
43
+ return invalid(`Migration "${name}" is ambiguous in the loader.`);
44
+ }
45
+ return run(Effect.succeed(resolved.filter(([id]) => id <= target[0])), target[0]);
46
+ },
47
+ migrateToLatest: run(Effect.succeed(resolved)),
48
+ };
49
+ });
50
+ //# sourceMappingURL=migrationSteps.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrationSteps.js","sourceRoot":"","sources":["../../src/server/migrationSteps.ts"],"names":[],"mappings":"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"}
@@ -1,11 +1,12 @@
1
1
  import { Effect, Layer, type Redacted } from "effect";
2
2
  import * as Reactivity from "effect/reactivity/Reactivity";
3
3
  import { type PostgresMigrationOptions } from "@shivaedev/effect-sql/migrations.ts";
4
- import { PostgresTestDatabase, type PostgresTestServices } from "./database.ts";
4
+ import { PostgresTestDatabase, type PostgresTestServices } from "#database.ts";
5
5
  export interface PostgresTestOptions<E = never, R = never, TFixtures = never> {
6
6
  readonly fixtures?: Effect.Effect<unknown, E, TFixtures | PostgresTestServices>;
7
7
  readonly maxConnections?: number;
8
8
  readonly migrations: PostgresMigrationOptions<R>;
9
+ readonly prepare?: boolean;
9
10
  readonly url: Redacted.Redacted<string>;
10
11
  }
11
12
  export declare function makePostgresTestLayer<E = never, R = never, TFixtures = never>(options: PostgresTestOptions<E, R, TFixtures>): Layer.Layer<import("@effect/sql-pg/PgClient").PgClient | PostgresTestDatabase | Reactivity.Reactivity | import("effect/sql/SqlClient").SqlClient, E | import("effect/sql/Migrator").MigrationError | import("effect/sql/SqlError").SqlError, Exclude<Exclude<Exclude<Exclude<R, import("@effect/sql-pg/PgClient").PgClient | Reactivity.Reactivity | import("effect/sql/SqlClient").SqlClient>, import("effect/Scope").Scope>, import("effect/Scope").Scope>, Reactivity.Reactivity> | Exclude<Exclude<Exclude<Exclude<TFixtures, import("@effect/sql-pg/PgClient").PgClient | Reactivity.Reactivity | import("effect/sql/SqlClient").SqlClient>, import("effect/Scope").Scope>, import("effect/Scope").Scope>, Reactivity.Reactivity>>;
@@ -1 +1 @@
1
- {"version":3,"file":"postgres.d.ts","sourceRoot":"","sources":["../../src/server/postgres.ts"],"names":[],"mappings":"AAAA,OAAO,EAAW,MAAM,EAAE,KAAK,EAAE,KAAK,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAC/D,OAAO,KAAK,UAAU,MAAM,8BAA8B,CAAC;AAC3D,OAAO,EAAmB,KAAK,wBAAwB,EAAE,MAAM,qCAAqC,CAAC;AAGrG,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,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,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,2sBAwB3H"}
1
+ {"version":3,"file":"postgres.d.ts","sourceRoot":"","sources":["../../src/server/postgres.ts"],"names":[],"mappings":"AAAA,OAAO,EAAW,MAAM,EAAE,KAAK,EAAE,KAAK,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAC/D,OAAO,KAAK,UAAU,MAAM,8BAA8B,CAAC;AAC3D,OAAO,EAAmB,KAAK,wBAAwB,EAAE,MAAM,qCAAqC,CAAC;AACrG,OAAO,EAAE,oBAAoB,EAAoC,KAAK,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAIjH,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,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,UAAU,EAAE,wBAAwB,CAAC,CAAC,CAAC,CAAC;IACjD,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,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,2sBA0B3H"}
@@ -1,20 +1,22 @@
1
1
  import { Context, Effect, Layer } from "effect";
2
2
  import * as Reactivity from "effect/reactivity/Reactivity";
3
3
  import { migratePostgres } from "@shivaedev/effect-sql/migrations.ts";
4
+ import { PostgresTestDatabase } from "#database.ts";
4
5
  import { connectionLimitHint } from "#internal/connectionLimitHint.ts";
5
6
  import { connection, database } from "#internal/databases.ts";
6
- import { PostgresTestDatabase } from "./database.js";
7
7
  export function makePostgresTestLayer(options) {
8
8
  return Layer.effectContext(Effect.gen(function* () {
9
9
  const maxConnections = options.maxConnections ?? 2;
10
10
  const template = yield* database(options.url);
11
- yield* Effect.scoped(Effect.gen(function* () {
12
- const services = yield* Layer.build(connection(template.url, maxConnections));
13
- yield* migratePostgres(options.migrations).pipe(Effect.provideContext(services));
14
- if (options.fixtures !== undefined) {
15
- yield* options.fixtures.pipe(Effect.provideContext(services));
16
- }
17
- }));
11
+ if (options.prepare !== false) {
12
+ yield* Effect.scoped(Effect.gen(function* () {
13
+ const services = yield* Layer.build(connection(template.url, maxConnections));
14
+ yield* migratePostgres(options.migrations).pipe(Effect.provideContext(services));
15
+ if (options.fixtures !== undefined) {
16
+ yield* options.fixtures.pipe(Effect.provideContext(services));
17
+ }
18
+ }));
19
+ }
18
20
  const shared = yield* database(options.url, template.name);
19
21
  const services = yield* Layer.build(connection(shared.url, maxConnections));
20
22
  const isolated = Effect.gen(function* () {
@@ -1 +1 @@
1
- {"version":3,"file":"postgres.js","sourceRoot":"","sources":["../../src/server/postgres.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAiB,MAAM,QAAQ,CAAC;AAC/D,OAAO,KAAK,UAAU,MAAM,8BAA8B,CAAC;AAC3D,OAAO,EAAE,eAAe,EAAiC,MAAM,qCAAqC,CAAC;AACrG,OAAO,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AACvE,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAC9D,OAAO,EAAE,oBAAoB,EAA+D,MAAM,eAAe,CAAC;AASlH,MAAM,UAAU,qBAAqB,CAA0C,OAA6C;IAC3H,OAAO,KAAK,CAAC,aAAa,CACzB,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QACnB,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,IAAI,CAAC,CAAC;QACnD,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC9C,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,EAAE,cAAc,CAAC,CAAC,CAAC;YAC9E,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,OAAO,CAAC,GAAG,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC;QAC3D,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,GAAG,EAAE,cAAc,CAAC,CAAC,CAAC;QAC5E,MAAM,QAAQ,GAA4C,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;YAC7E,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC;YAC1D,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,GAAG,EAAE,cAAc,CAAC,CAAC,CAAC;YAC1E,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,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,mBAAmB,CAAC,CAAC,CAC7C,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC;AACzC,CAAC"}
1
+ {"version":3,"file":"postgres.js","sourceRoot":"","sources":["../../src/server/postgres.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAiB,MAAM,QAAQ,CAAC;AAC/D,OAAO,KAAK,UAAU,MAAM,8BAA8B,CAAC;AAC3D,OAAO,EAAE,eAAe,EAAiC,MAAM,qCAAqC,CAAC;AACrG,OAAO,EAAE,oBAAoB,EAA+D,MAAM,cAAc,CAAC;AACjH,OAAO,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AACvE,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAU9D,MAAM,UAAU,qBAAqB,CAA0C,OAA6C;IAC3H,OAAO,KAAK,CAAC,aAAa,CACzB,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QACnB,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,IAAI,CAAC,CAAC;QACnD,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC9C,IAAI,OAAO,CAAC,OAAO,KAAK,KAAK,EAAE,CAAC;YAC/B,KAAK,CAAC,CAAC,MAAM,CAAC,MAAM,CACnB,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;gBACnB,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,QAAQ,CAAC,GAAG,EAAE,cAAc,CAAC,CAAC,CAAC;gBAC9E,KAAK,CAAC,CAAC,eAAe,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC,CAAC;gBACjF,IAAI,OAAO,CAAC,QAAQ,KAAK,SAAS,EAAE,CAAC;oBACpC,KAAK,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC,CAAC;gBAC/D,CAAC;YACF,CAAC,CAAC,CACF,CAAC;QACH,CAAC;QACD,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC;QAC3D,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,GAAG,EAAE,cAAc,CAAC,CAAC,CAAC;QAC5E,MAAM,QAAQ,GAA4C,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;YAC7E,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC;YAC1D,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,GAAG,EAAE,cAAc,CAAC,CAAC,CAAC;YAC1E,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,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,mBAAmB,CAAC,CAAC,CAC7C,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC;AACzC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/effect-test-sql",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "PostgreSQL rollback and real-commit test isolation for Effect",
5
5
  "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/effect-test-sql#readme",
6
6
  "bugs": {
@@ -49,6 +49,7 @@
49
49
  "@effect/vitest": "4.0.4",
50
50
  "@shivaedev/effect-test": "0.5.0",
51
51
  "@shivaedev/quality": "0.22.0",
52
+ "@shivaedev/test-story": "0.4.1",
52
53
  "@types/node": "24.19.1",
53
54
  "@typescript/native": "npm:typescript@7.0.2",
54
55
  "effect": "4.0.4",
@@ -0,0 +1,53 @@
1
+ import { Effect, Option } from "effect";
2
+ import { Migrator, SqlClient } from "effect/sql";
3
+ import { migratePostgres, type PostgresMigrationOptions } from "@shivaedev/effect-sql/migrations.ts";
4
+
5
+ function invalid(message: string) {
6
+ return Effect.fail(new Migrator.MigrationError({ kind: "BadState", message }));
7
+ }
8
+
9
+ const outsideTransaction = Effect.fnUntraced(function* (sql: SqlClient.SqlClient) {
10
+ if (Option.isSome(yield* Effect.serviceOption(sql.transactionService))) {
11
+ return yield* invalid("Migration steps must run outside a wrapping transaction.");
12
+ }
13
+ });
14
+
15
+ export const migrationSteps = Effect.fn("PostgresTest.migrationSteps")(function* <R>(migrations: PostgresMigrationOptions<R>) {
16
+ const sql = yield* SqlClient.SqlClient;
17
+ yield* sql.onDialectOrElse({ orElse: () => invalid("Migration steps require PostgreSQL."), pg: () => Effect.void });
18
+ yield* outsideTransaction(sql);
19
+ const resolved = yield* migrations.loader;
20
+ const ledger = migrations.table ?? "effect_sql_migrations";
21
+ const identifier = sql`${sql(ledger)}`.compile()[0];
22
+ function run(loader: Migrator.Loader, target?: number) {
23
+ return Effect.gen(function* () {
24
+ yield* outsideTransaction(sql);
25
+ if (target !== undefined) {
26
+ const [existing] = yield* sql<{ readonly name: string | null }>`select to_regclass(${identifier})::text as name`.withoutTransform;
27
+ if (existing !== undefined && existing.name !== null) {
28
+ const later = yield* sql`select migration_id from ${sql(ledger)} where migration_id > ${target} limit 1`;
29
+ if (later.length > 0) {
30
+ return yield* invalid(`Cannot migrate to ${target}: later migrations are already applied.`);
31
+ }
32
+ }
33
+ }
34
+ return yield* migratePostgres({ ...migrations, loader }).pipe(Effect.provideService(SqlClient.SqlClient, sql));
35
+ });
36
+ }
37
+ return {
38
+ migrateTo: (name: string | number) => {
39
+ const match = /^(?<id>\d+)(?:_(?<label>.+))?$/u.exec(String(name))?.groups;
40
+ const matches =
41
+ match === undefined ? [] : resolved.filter(([id, label]) => id === Number(match.id) && (match.label === undefined || label === match.label));
42
+ const [target] = matches;
43
+ if (target === undefined) {
44
+ return invalid(`Migration "${name}" is not in the loader.`);
45
+ }
46
+ if (matches.length > 1) {
47
+ return invalid(`Migration "${name}" is ambiguous in the loader.`);
48
+ }
49
+ return run(Effect.succeed(resolved.filter(([id]) => id <= target[0])), target[0]);
50
+ },
51
+ migrateToLatest: run(Effect.succeed(resolved)),
52
+ };
53
+ });
@@ -1,14 +1,15 @@
1
1
  import { Context, Effect, Layer, type Redacted } from "effect";
2
2
  import * as Reactivity from "effect/reactivity/Reactivity";
3
3
  import { migratePostgres, type PostgresMigrationOptions } from "@shivaedev/effect-sql/migrations.ts";
4
+ import { PostgresTestDatabase, type PostgresTestDatabaseService, type PostgresTestServices } from "#database.ts";
4
5
  import { connectionLimitHint } from "#internal/connectionLimitHint.ts";
5
6
  import { connection, database } from "#internal/databases.ts";
6
- import { PostgresTestDatabase, type PostgresTestDatabaseService, type PostgresTestServices } from "./database.ts";
7
7
 
8
8
  export interface PostgresTestOptions<E = never, R = never, TFixtures = never> {
9
9
  readonly fixtures?: Effect.Effect<unknown, E, TFixtures | PostgresTestServices>;
10
10
  readonly maxConnections?: number;
11
11
  readonly migrations: PostgresMigrationOptions<R>;
12
+ readonly prepare?: boolean;
12
13
  readonly url: Redacted.Redacted<string>;
13
14
  }
14
15
 
@@ -17,15 +18,17 @@ export function makePostgresTestLayer<E = never, R = never, TFixtures = never>(o
17
18
  Effect.gen(function* () {
18
19
  const maxConnections = options.maxConnections ?? 2;
19
20
  const template = yield* database(options.url);
20
- yield* Effect.scoped(
21
- Effect.gen(function* () {
22
- const services = yield* Layer.build(connection(template.url, maxConnections));
23
- yield* migratePostgres(options.migrations).pipe(Effect.provideContext(services));
24
- if (options.fixtures !== undefined) {
25
- yield* options.fixtures.pipe(Effect.provideContext(services));
26
- }
27
- }),
28
- );
21
+ if (options.prepare !== false) {
22
+ yield* Effect.scoped(
23
+ Effect.gen(function* () {
24
+ const services = yield* Layer.build(connection(template.url, maxConnections));
25
+ yield* migratePostgres(options.migrations).pipe(Effect.provideContext(services));
26
+ if (options.fixtures !== undefined) {
27
+ yield* options.fixtures.pipe(Effect.provideContext(services));
28
+ }
29
+ }),
30
+ );
31
+ }
29
32
  const shared = yield* database(options.url, template.name);
30
33
  const services = yield* Layer.build(connection(shared.url, maxConnections));
31
34
  const isolated: PostgresTestDatabaseService["isolated"] = Effect.gen(function* () {