@shivaedev/test-sql 0.0.0 → 0.9.3
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 +225 -0
- package/LICENSE +21 -0
- package/README.md +256 -1
- package/dist/server/assertModelColumns.d.ts +13 -0
- package/dist/server/assertModelColumns.d.ts.map +1 -0
- package/dist/server/assertModelColumns.js +11 -0
- package/dist/server/assertModelColumns.js.map +1 -0
- package/dist/server/database.d.ts +18 -0
- package/dist/server/database.d.ts.map +1 -0
- package/dist/server/database.js +4 -0
- package/dist/server/database.js.map +1 -0
- package/dist/server/internal/connectionLimitHint.d.ts +3 -0
- package/dist/server/internal/connectionLimitHint.d.ts.map +1 -0
- package/dist/server/internal/connectionLimitHint.js +15 -0
- package/dist/server/internal/connectionLimitHint.js.map +1 -0
- package/dist/server/internal/databaseCleanup.d.ts +7 -0
- package/dist/server/internal/databaseCleanup.d.ts.map +1 -0
- package/dist/server/internal/databaseCleanup.js +34 -0
- package/dist/server/internal/databaseCleanup.js.map +1 -0
- package/dist/server/internal/databaseOwner.d.ts +3 -0
- package/dist/server/internal/databaseOwner.d.ts.map +1 -0
- package/dist/server/internal/databaseOwner.js +24 -0
- package/dist/server/internal/databaseOwner.js.map +1 -0
- package/dist/server/internal/databases.d.ts +13 -0
- package/dist/server/internal/databases.d.ts.map +1 -0
- package/dist/server/internal/databases.js +26 -0
- package/dist/server/internal/databases.js.map +1 -0
- package/dist/server/internal/modelColumnProblems.d.ts +8 -0
- package/dist/server/internal/modelColumnProblems.d.ts.map +1 -0
- package/dist/server/internal/modelColumnProblems.js +35 -0
- package/dist/server/internal/modelColumnProblems.js.map +1 -0
- package/dist/server/migrationSteps.d.ts +8 -0
- package/dist/server/migrationSteps.d.ts.map +1 -0
- package/dist/server/migrationSteps.js +51 -0
- package/dist/server/migrationSteps.js.map +1 -0
- package/dist/server/migrationUpgrade.d.ts +14 -0
- package/dist/server/migrationUpgrade.d.ts.map +1 -0
- package/dist/server/migrationUpgrade.js +34 -0
- package/dist/server/migrationUpgrade.js.map +1 -0
- package/dist/server/postgres.d.ts +22 -0
- package/dist/server/postgres.d.ts.map +1 -0
- package/dist/server/postgres.js +43 -0
- package/dist/server/postgres.js.map +1 -0
- package/dist/server/withIsolatedDatabase.d.ts +6 -0
- package/dist/server/withIsolatedDatabase.d.ts.map +1 -0
- package/dist/server/withIsolatedDatabase.js +11 -0
- package/dist/server/withIsolatedDatabase.js.map +1 -0
- package/dist/server/withTestTransaction.d.ts +4 -0
- package/dist/server/withTestTransaction.d.ts.map +1 -0
- package/dist/server/withTestTransaction.js +10 -0
- package/dist/server/withTestTransaction.js.map +1 -0
- package/package.json +72 -4
- package/src/server/assertModelColumns.ts +19 -0
- package/src/server/database.ts +21 -0
- package/src/server/internal/connectionLimitHint.ts +16 -0
- package/src/server/internal/databaseCleanup.ts +40 -0
- package/src/server/internal/databaseOwner.ts +25 -0
- package/src/server/internal/databases.ts +47 -0
- package/src/server/internal/modelColumnProblems.ts +45 -0
- package/src/server/migrationSteps.ts +54 -0
- package/src/server/migrationUpgrade.ts +45 -0
- package/src/server/postgres.ts +72 -0
- package/src/server/withIsolatedDatabase.ts +13 -0
- package/src/server/withTestTransaction.ts +17 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Clock, Effect, type Exit, Fiber, Predicate, Schedule } from "effect";
|
|
2
|
+
import type { SqlError } from "effect/sql/SqlError";
|
|
3
|
+
import { abandonedDatabase } from "./databaseOwner.ts";
|
|
4
|
+
import { administer } from "./databases.ts";
|
|
5
|
+
|
|
6
|
+
export const databaseCleanup = Effect.fn("PostgresTest.databaseCleanup")(function* (url: Parameters<typeof administer>[0]) {
|
|
7
|
+
const pending: Fiber.Fiber<Exit.Exit<unknown, SqlError>>[] = [];
|
|
8
|
+
function drop(name: string) {
|
|
9
|
+
return administer(url, (admin) => admin`drop database if exists ${admin(name)} with (force)`).pipe(
|
|
10
|
+
Effect.retry({
|
|
11
|
+
schedule: Schedule.spaced("100 millis"),
|
|
12
|
+
times: 300,
|
|
13
|
+
while: (error) => Predicate.hasProperty(error.reason.cause, "code") && error.reason.cause.code === "53300",
|
|
14
|
+
}),
|
|
15
|
+
Effect.provideService(Clock.Clock, Clock.Clock.defaultValue()),
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
const awaitCleanup = Effect.suspend(() =>
|
|
19
|
+
Effect.gen(function* () {
|
|
20
|
+
const exits = yield* Effect.forEach([...pending], Fiber.join);
|
|
21
|
+
yield* Effect.forEach(exits, (exit) => exit).pipe(Effect.orDie);
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
yield* Effect.addFinalizer(() => awaitCleanup);
|
|
25
|
+
const leftovers = yield* administer(url, (admin) => admin<{ datname: string }>`select datname from pg_database`);
|
|
26
|
+
for (const { datname } of leftovers) {
|
|
27
|
+
if (abandonedDatabase(datname)) {
|
|
28
|
+
yield* drop(datname);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function release(name: string) {
|
|
32
|
+
return Effect.gen(function* () {
|
|
33
|
+
const previous = pending.at(-1);
|
|
34
|
+
const ready = previous === undefined ? Effect.void : Fiber.join(previous);
|
|
35
|
+
const fiber = yield* Effect.andThen(ready, Effect.exit(drop(name))).pipe(Effect.uninterruptible, Effect.forkDetach);
|
|
36
|
+
pending.push(fiber);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return { awaitCleanup, release };
|
|
40
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
|
|
5
|
+
const host = createHash("sha256").update(hostname()).digest("hex").slice(0, 8);
|
|
6
|
+
|
|
7
|
+
export function databaseName() {
|
|
8
|
+
return `effect_test_${host}_${process.pid}_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function abandonedDatabase(name: string) {
|
|
12
|
+
if (/^effect_test_[a-f0-9]{32}$/u.test(name)) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
const owner = /^effect_test_(?<host>[a-f0-9]{8})_(?<pid>[0-9]+)_[a-f0-9]{32}$/u.exec(name);
|
|
16
|
+
if (owner === null || owner.groups?.host !== host) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
process.kill(Number(owner.groups.pid), 0);
|
|
21
|
+
return false;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return error instanceof Error && "code" in error && error.code === "ESRCH";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as PgClient from "@effect/sql-pg/PgClient";
|
|
2
|
+
import { Context, Effect, Layer, Redacted } from "effect";
|
|
3
|
+
import * as Reactivity from "effect/reactivity/Reactivity";
|
|
4
|
+
import { SqlClient } from "effect/sql";
|
|
5
|
+
import type { SqlError } from "effect/sql/SqlError";
|
|
6
|
+
import type { SqlNameTransforms } from "@shivaedev/sql/clientOptions.ts";
|
|
7
|
+
import { connectionLimitHint } from "./connectionLimitHint.ts";
|
|
8
|
+
import { databaseName } from "./databaseOwner.ts";
|
|
9
|
+
|
|
10
|
+
export function connection(url: Redacted.Redacted<string>, maxConnections: number, transforms: SqlNameTransforms) {
|
|
11
|
+
return Layer.effectContext(
|
|
12
|
+
Effect.map(
|
|
13
|
+
PgClient.make({
|
|
14
|
+
maxConnections,
|
|
15
|
+
transformQueryNames: transforms.transformQueryNames,
|
|
16
|
+
transformResultNames: transforms.transformResultNames,
|
|
17
|
+
url,
|
|
18
|
+
}),
|
|
19
|
+
(client) => Context.make(SqlClient.SqlClient, client).pipe(Context.add(PgClient.PgClient, client)),
|
|
20
|
+
),
|
|
21
|
+
).pipe(Layer.provideMerge(Layer.fresh(Reactivity.layer)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const administer = Effect.fn("PostgresTest.administer")(function* <A>(
|
|
25
|
+
url: Redacted.Redacted<string>,
|
|
26
|
+
operation: (admin: PgClient.PgClient) => Effect.Effect<A, SqlError>,
|
|
27
|
+
) {
|
|
28
|
+
return yield* Effect.scoped(Effect.flatMap(PgClient.makeClient({ url }), operation).pipe(Effect.mapError(connectionLimitHint))).pipe(
|
|
29
|
+
Effect.provide(Reactivity.layer),
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const database = Effect.fn("PostgresTest.database")(function* (
|
|
34
|
+
url: Redacted.Redacted<string>,
|
|
35
|
+
release: (name: string) => Effect.Effect<unknown>,
|
|
36
|
+
template = "template0",
|
|
37
|
+
) {
|
|
38
|
+
const name = databaseName();
|
|
39
|
+
const target = new URL(Redacted.value(url));
|
|
40
|
+
target.pathname = `/${name}`;
|
|
41
|
+
target.searchParams.delete("database");
|
|
42
|
+
yield* Effect.acquireRelease(
|
|
43
|
+
administer(url, (admin) => admin`create database ${admin(name)} template ${admin(template)}`),
|
|
44
|
+
() => release(name),
|
|
45
|
+
);
|
|
46
|
+
return { name, url: Redacted.make(target.toString()) };
|
|
47
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { PostgresColumn } from "#migrationUpgrade.ts";
|
|
2
|
+
|
|
3
|
+
interface NameTransforms {
|
|
4
|
+
readonly transformQueryNames?: ((name: string) => string) | undefined;
|
|
5
|
+
readonly transformResultNames?: ((name: string) => string) | undefined;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function modelColumnProblems(
|
|
9
|
+
columns: readonly PostgresColumn[],
|
|
10
|
+
fields: object,
|
|
11
|
+
expectedTypes: Readonly<Record<string, string>>,
|
|
12
|
+
transforms: NameTransforms | undefined,
|
|
13
|
+
): readonly string[] {
|
|
14
|
+
const queryFields = new Map(Object.keys(fields).map((name) => [transforms?.transformQueryNames?.(name) ?? name, name]));
|
|
15
|
+
function fieldName(name: string) {
|
|
16
|
+
return queryFields.get(name) ?? transforms?.transformResultNames?.(name) ?? name;
|
|
17
|
+
}
|
|
18
|
+
function columnKey(name: string) {
|
|
19
|
+
return transforms?.transformQueryNames === undefined ? fieldName(name) : name;
|
|
20
|
+
}
|
|
21
|
+
const actual = new Map(columns.map(({ name, type }) => [columnKey(name), type]));
|
|
22
|
+
const problems: string[] = [];
|
|
23
|
+
for (const name of Object.keys(fields)) {
|
|
24
|
+
const type = actual.get(transforms?.transformQueryNames?.(name) ?? name);
|
|
25
|
+
if (type === undefined) {
|
|
26
|
+
problems.push(`Missing column: ${name}`);
|
|
27
|
+
} else if (type !== expectedTypes[name]) {
|
|
28
|
+
problems.push(`Wrong type for ${name}: expected ${expectedTypes[name]}, received ${type}`);
|
|
29
|
+
}
|
|
30
|
+
if (!Object.hasOwn(expectedTypes, name)) {
|
|
31
|
+
problems.push(`Missing expected PostgreSQL type: ${name}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const column of columns) {
|
|
35
|
+
if (!queryFields.has(columnKey(column.name))) {
|
|
36
|
+
problems.push(`Extra column: ${fieldName(column.name)}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const name of Object.keys(expectedTypes)) {
|
|
40
|
+
if (!Object.hasOwn(fields, name)) {
|
|
41
|
+
problems.push(`Extra expected PostgreSQL type: ${name}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return problems;
|
|
45
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import { Migrator, SqlClient } from "effect/sql";
|
|
3
|
+
import { migratePostgres, type PostgresMigrationOptions } from "@shivaedev/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
|
+
const context = yield* Effect.context<R | SqlClient.SqlClient>();
|
|
18
|
+
yield* sql.onDialectOrElse({ orElse: () => invalid("Migration steps require PostgreSQL."), pg: () => Effect.void });
|
|
19
|
+
yield* outsideTransaction(sql);
|
|
20
|
+
const resolved = yield* migrations.loader;
|
|
21
|
+
const ledger = migrations.table ?? "effect_sql_migrations";
|
|
22
|
+
const identifier = sql`${sql(ledger)}`.compile()[0];
|
|
23
|
+
function run(loader: Migrator.Loader, target?: number) {
|
|
24
|
+
return Effect.gen(function* () {
|
|
25
|
+
yield* outsideTransaction(sql);
|
|
26
|
+
if (target !== undefined) {
|
|
27
|
+
const [existing] = yield* sql<{ readonly name: string | null }>`select to_regclass(${identifier})::text as name`.withoutTransform;
|
|
28
|
+
if (existing !== undefined && existing.name !== null) {
|
|
29
|
+
const later = yield* sql`select migration_id from ${sql(ledger)} where migration_id > ${target} limit 1`;
|
|
30
|
+
if (later.length > 0) {
|
|
31
|
+
return yield* invalid(`Cannot migrate to ${target}: later migrations are already applied.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return yield* migratePostgres({ ...migrations, loader }).pipe(Effect.provideContext(context));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
migrateTo: (name: string | number) => {
|
|
40
|
+
const match = /^(?<id>\d+)(?:_(?<label>.+))?$/u.exec(String(name))?.groups;
|
|
41
|
+
const matches =
|
|
42
|
+
match === undefined ? [] : resolved.filter(([id, label]) => id === Number(match.id) && (match.label === undefined || label === match.label));
|
|
43
|
+
const [target] = matches;
|
|
44
|
+
if (target === undefined) {
|
|
45
|
+
return invalid(`Migration "${name}" is not in the loader.`);
|
|
46
|
+
}
|
|
47
|
+
if (matches.length > 1) {
|
|
48
|
+
return invalid(`Migration "${name}" is ambiguous in the loader.`);
|
|
49
|
+
}
|
|
50
|
+
return run(Effect.succeed(resolved.filter(([id]) => id <= target[0])), target[0]);
|
|
51
|
+
},
|
|
52
|
+
migrateToLatest: run(Effect.succeed(resolved)),
|
|
53
|
+
};
|
|
54
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import { Migrator, SqlClient } from "effect/sql";
|
|
3
|
+
import { migratePostgres, type PostgresMigrationOptions } from "@shivaedev/sql/migrations.ts";
|
|
4
|
+
|
|
5
|
+
export interface PostgresColumn {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly type: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface MigrationUpgradeOptions<R = never> {
|
|
11
|
+
readonly migrations: PostgresMigrationOptions<R>;
|
|
12
|
+
readonly table: string;
|
|
13
|
+
readonly upTo: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const migrationUpgrade = Effect.fn("PostgresTest.migrationUpgrade")(function* <R>(options: MigrationUpgradeOptions<R>) {
|
|
17
|
+
const sql = yield* SqlClient.SqlClient;
|
|
18
|
+
function invalid(message: string) {
|
|
19
|
+
return Effect.fail(new Migrator.MigrationError({ kind: "BadState", message }));
|
|
20
|
+
}
|
|
21
|
+
yield* sql.onDialectOrElse({ orElse: () => invalid("Migration upgrade checks require PostgreSQL."), pg: () => Effect.void });
|
|
22
|
+
if (Option.isSome(yield* Effect.serviceOption(sql.transactionService))) {
|
|
23
|
+
return yield* invalid("Migration upgrade checks must run outside a wrapping transaction.");
|
|
24
|
+
}
|
|
25
|
+
const ledger = options.migrations.table ?? "effect_sql_migrations";
|
|
26
|
+
const identifier = sql`${sql(ledger)}`.compile()[0];
|
|
27
|
+
const existing = yield* sql<{ readonly name: string | null }>`select to_regclass(${identifier})::text as name`.withoutTransform;
|
|
28
|
+
if (existing[0]?.name !== null) {
|
|
29
|
+
const applied = yield* sql`select migration_id from ${sql(ledger)} limit 1`;
|
|
30
|
+
if (applied.length > 0) {
|
|
31
|
+
return yield* invalid("Migration upgrade checks require an empty migration ledger.");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const resolved = yield* options.migrations.loader;
|
|
35
|
+
if (!resolved.some(([id]) => id === options.upTo)) {
|
|
36
|
+
return yield* invalid(`Migration ${options.upTo} is not in the loader.`);
|
|
37
|
+
}
|
|
38
|
+
yield* migratePostgres({ ...options.migrations, loader: Effect.succeed(resolved.filter(([id]) => id <= options.upTo)) });
|
|
39
|
+
yield* migratePostgres({ ...options.migrations, loader: Effect.succeed(resolved) });
|
|
40
|
+
return yield* sql<PostgresColumn>`
|
|
41
|
+
select attname as name, format_type(atttypid, atttypmod) as type
|
|
42
|
+
from pg_attribute
|
|
43
|
+
where attrelid = ${options.table}::regclass and attnum > 0 and not attisdropped
|
|
44
|
+
order by attname`.withoutTransform;
|
|
45
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Context, Effect, Layer, type Redacted, type Scope } from "effect";
|
|
2
|
+
import * as Reactivity from "effect/reactivity/Reactivity";
|
|
3
|
+
import type { SqlNameTransforms } from "@shivaedev/sql/clientOptions.ts";
|
|
4
|
+
import { migratePostgres, type PostgresMigrationOptions } from "@shivaedev/sql/migrations.ts";
|
|
5
|
+
import { PostgresTestDatabase, type PostgresTestDatabaseService, type PostgresTestServices } from "#database.ts";
|
|
6
|
+
import { connectionLimitHint } from "#internal/connectionLimitHint.ts";
|
|
7
|
+
import { databaseCleanup } from "#internal/databaseCleanup.ts";
|
|
8
|
+
import { connection, database } from "#internal/databases.ts";
|
|
9
|
+
|
|
10
|
+
export type PostgresTestLoader<E = never, R = never> = (
|
|
11
|
+
url: Redacted.Redacted<string>,
|
|
12
|
+
) => Effect.Effect<unknown, E, R | PostgresTestServices | Scope.Scope>;
|
|
13
|
+
|
|
14
|
+
interface PostgresTestCommonOptions<E, TFixtures> extends SqlNameTransforms {
|
|
15
|
+
readonly fixtures?: Effect.Effect<unknown, E, TFixtures | PostgresTestServices | Scope.Scope> | PostgresTestLoader<E, TFixtures>;
|
|
16
|
+
readonly maxConnections?: number;
|
|
17
|
+
readonly prepare?: boolean;
|
|
18
|
+
readonly url: Redacted.Redacted<string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type PostgresTestOptions<E = never, R = never, TFixtures = never> = PostgresTestCommonOptions<E, TFixtures> &
|
|
22
|
+
(
|
|
23
|
+
| { readonly migrations: PostgresMigrationOptions<R>; readonly initialize?: never }
|
|
24
|
+
| {
|
|
25
|
+
readonly initialize: Effect.Effect<unknown, E, R | PostgresTestServices | Scope.Scope> | PostgresTestLoader<E, R>;
|
|
26
|
+
readonly migrations?: never;
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
function runLoader<E, R>(
|
|
31
|
+
loader: Effect.Effect<unknown, E, R> | ((url: Redacted.Redacted<string>) => Effect.Effect<unknown, E, R>),
|
|
32
|
+
url: Redacted.Redacted<string>,
|
|
33
|
+
) {
|
|
34
|
+
return typeof loader === "function" ? loader(url) : loader;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function makePostgresTestLayer<E = never, R = never, TFixtures = never>(options: PostgresTestOptions<E, R, TFixtures>) {
|
|
38
|
+
return Layer.effectContext(
|
|
39
|
+
Effect.gen(function* () {
|
|
40
|
+
const maxConnections = options.maxConnections ?? 2;
|
|
41
|
+
const { awaitCleanup, release } = yield* databaseCleanup(options.url);
|
|
42
|
+
const empty = yield* database(options.url, release);
|
|
43
|
+
const template = options.prepare === false ? empty : yield* database(options.url, release, empty.name);
|
|
44
|
+
if (options.prepare !== false) {
|
|
45
|
+
yield* Effect.scoped(
|
|
46
|
+
Effect.gen(function* () {
|
|
47
|
+
const services = yield* Layer.build(connection(template.url, maxConnections, options));
|
|
48
|
+
const initialize = options.initialize;
|
|
49
|
+
if (initialize === undefined) {
|
|
50
|
+
yield* migratePostgres(options.migrations).pipe(Effect.provideContext(services));
|
|
51
|
+
} else {
|
|
52
|
+
yield* runLoader(initialize, template.url).pipe(Effect.provideContext(services));
|
|
53
|
+
}
|
|
54
|
+
if (options.fixtures !== undefined) {
|
|
55
|
+
yield* runLoader(options.fixtures, template.url).pipe(Effect.provideContext(services));
|
|
56
|
+
}
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const shared = yield* database(options.url, release, template.name);
|
|
61
|
+
const services = yield* Layer.build(connection(shared.url, maxConnections, options));
|
|
62
|
+
const clone: PostgresTestDatabaseService["clone"] = (source) =>
|
|
63
|
+
Effect.gen(function* () {
|
|
64
|
+
const target = yield* database(options.url, release, source === "empty" ? empty.name : template.name);
|
|
65
|
+
const context = yield* Layer.build(connection(target.url, maxConnections, options));
|
|
66
|
+
return Context.add(context, PostgresTestDatabase, { awaitCleanup, clone, isolated, url: target.url });
|
|
67
|
+
});
|
|
68
|
+
const isolated = clone("migrated");
|
|
69
|
+
return Context.add(services, PostgresTestDatabase, { awaitCleanup, clone, isolated, url: shared.url });
|
|
70
|
+
}).pipe(Effect.mapError(connectionLimitHint)),
|
|
71
|
+
).pipe(Layer.provide(Reactivity.layer));
|
|
72
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { connectionLimitHint } from "#internal/connectionLimitHint.ts";
|
|
3
|
+
import { PostgresTestDatabase, type PostgresTestTemplate } from "./database.ts";
|
|
4
|
+
|
|
5
|
+
export function withIsolatedDatabase<A, E, R>(effect: Effect.Effect<A, E, R>, options?: { readonly template?: PostgresTestTemplate }) {
|
|
6
|
+
return Effect.scoped(
|
|
7
|
+
Effect.gen(function* () {
|
|
8
|
+
const database = yield* PostgresTestDatabase;
|
|
9
|
+
const services = yield* options?.template === undefined ? database.isolated : database.clone(options.template);
|
|
10
|
+
return yield* Effect.scoped(effect.pipe(Effect.provideContext(services)));
|
|
11
|
+
}),
|
|
12
|
+
).pipe(Effect.mapError(connectionLimitHint));
|
|
13
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Effect, Exit } from "effect";
|
|
2
|
+
import type { SqlError } from "effect/sql/SqlError";
|
|
3
|
+
import { transact } from "@shivaedev/sql/transact.ts";
|
|
4
|
+
import { connectionLimitHint } from "#internal/connectionLimitHint.ts";
|
|
5
|
+
|
|
6
|
+
function isTestExit<A, E>(value: Exit.Exit<A, E> | SqlError): value is Exit.Exit<A, E> {
|
|
7
|
+
return Exit.isExit(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function withTestTransaction<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
|
11
|
+
return Effect.uninterruptibleMask((restore) =>
|
|
12
|
+
transact(Effect.flatMap(Effect.exit(Effect.scoped(restore(effect))), Effect.fail), { onSqlError: (error) => error }).pipe(
|
|
13
|
+
Effect.catchIf(isTestExit, (exit) => exit),
|
|
14
|
+
Effect.mapError(connectionLimitHint),
|
|
15
|
+
),
|
|
16
|
+
);
|
|
17
|
+
}
|