@thomasfosterau/effect-drizzle-ext-pg 0.0.0 → 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/LICENSE +21 -0
- package/README.md +442 -2
- package/dist/index.d.ts +159 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +109 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations-Dp67FrXh.d.ts +68 -0
- package/dist/migrations-Dp67FrXh.d.ts.map +1 -0
- package/dist/migrations.d.ts +2 -0
- package/dist/migrations.js +136 -0
- package/dist/migrations.js.map +1 -0
- package/dist/testing.d.ts +270 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +408 -0
- package/dist/testing.js.map +1 -0
- package/dist/tooling/index.d.ts +217 -0
- package/dist/tooling/index.d.ts.map +1 -0
- package/dist/tooling/index.js +479 -0
- package/dist/tooling/index.js.map +1 -0
- package/package.json +76 -4
- package/src/index.ts +231 -0
- package/src/migrations.ts +173 -0
- package/src/provisioning.ts +407 -0
- package/src/testing.ts +447 -0
- package/src/tooling/index.ts +46 -0
- package/src/tooling/migration-drift.ts +640 -0
- package/src/tooling/view-dependencies.ts +168 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Thomas Foster
|
|
3
|
+
*
|
|
4
|
+
* **Ephemeral test-database provisioning** — the half of an integration
|
|
5
|
+
* suite's global setup every consuming application carried as
|
|
6
|
+
* `packages/integration-harness/local-database.ts`. Re-exported from
|
|
7
|
+
* `./testing`; the reference is the preferenceflow application's copy.
|
|
8
|
+
*
|
|
9
|
+
* A suite's database is resolved in this order ({@link resolveDatabaseSource}):
|
|
10
|
+
*
|
|
11
|
+
* 0. **An explicit `DATABASE_URL_TEST` / `DATABASE_URL`** wins and is used
|
|
12
|
+
* as-is: it must already be migrated — the escape hatch for pointing the
|
|
13
|
+
* suite at a deployed stage's database or any other prepared server.
|
|
14
|
+
* Nothing is provisioned and nothing is torn down.
|
|
15
|
+
* 1. **A reachable local server** — a CI service container, a devcontainer's
|
|
16
|
+
* PostgreSQL, or anything the standard `PG*` variables (`PGHOST`,
|
|
17
|
+
* `PGPORT`, `PGUSER`, `PGPASSWORD`) point at; the default is a trust-auth
|
|
18
|
+
* `postgres@127.0.0.1:5432`. Each run gets a **freshly created database**
|
|
19
|
+
* on it: drop, create, apply every migration.
|
|
20
|
+
* 2. **A Testcontainers-launched container** when nothing is listening and
|
|
21
|
+
* no `PG*` variable was set explicitly. Needs a Docker daemon and the
|
|
22
|
+
* optional peer `@testcontainers/postgresql`, which is imported only on
|
|
23
|
+
* this path. The container is brand new, so migrations apply directly, and
|
|
24
|
+
* it stops when the provisioning scope closes.
|
|
25
|
+
*
|
|
26
|
+
* Explicit `PG*` config that is unreachable is a hard error
|
|
27
|
+
* ({@link DatabaseUnreachable}), not a silent fallback: if you pointed the
|
|
28
|
+
* suite somewhere, it should not quietly run elsewhere.
|
|
29
|
+
*
|
|
30
|
+
* Either way run N's verdict is independent of run N−1: no state survives
|
|
31
|
+
* between runs. The per-checkout database name
|
|
32
|
+
* `<prefix>_test_<sha256(migrationsDir)[:8]>` keeps parallel worktrees
|
|
33
|
+
* sharing one server from dropping each other's database mid-run — pass the
|
|
34
|
+
* migrations directory as an absolute path, which is per-checkout.
|
|
35
|
+
*
|
|
36
|
+
* What stays with the application: fixture users and seed data (website
|
|
37
|
+
* #1847 keeps the harness → database edge one-way), and the container image
|
|
38
|
+
* and database-name prefix, which are parameters here.
|
|
39
|
+
*
|
|
40
|
+
* @module provisioning
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import * as PgClient from "@effect/sql-pg/PgClient";
|
|
44
|
+
import * as Duration from "effect/Duration";
|
|
45
|
+
import * as Effect from "effect/Effect";
|
|
46
|
+
import type * as FileSystem from "effect/FileSystem";
|
|
47
|
+
import type * as Path from "effect/Path";
|
|
48
|
+
import type { PlatformError } from "effect/PlatformError";
|
|
49
|
+
import * as Predicate from "effect/Predicate";
|
|
50
|
+
import * as Redacted from "effect/Redacted";
|
|
51
|
+
import * as Schema from "effect/Schema";
|
|
52
|
+
import type * as Scope from "effect/Scope";
|
|
53
|
+
import type { SqlError } from "effect/sql/SqlError";
|
|
54
|
+
import { applyMigrations, type MigrationError } from "./migrations.ts";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The environment the resolution reads — the shape of `process.env`, taken
|
|
58
|
+
* as a parameter so the order is testable against a fake.
|
|
59
|
+
*/
|
|
60
|
+
export interface ResolutionEnvironment {
|
|
61
|
+
// oxlint-disable-next-line effect/prefer-option-over-null -- this IS `process.env`'s shape (an index signature whose unset variables are missing keys); the real environment is passed straight in
|
|
62
|
+
readonly [name: string]: string | undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The variables that count as "explicitly pointed at a server". */
|
|
66
|
+
const SERVER_VARIABLES = ["PGHOST", "PGPORT", "PGUSER", "PGPASSWORD"] as const;
|
|
67
|
+
|
|
68
|
+
/** How long one connection attempt against the admin database may take. */
|
|
69
|
+
const CONNECT_TIMEOUT = Duration.seconds(10);
|
|
70
|
+
|
|
71
|
+
/** Where a local server is expected, honouring the standard `PG*` variables. */
|
|
72
|
+
const serverConfig = (env: ResolutionEnvironment) => ({
|
|
73
|
+
host: env.PGHOST ?? "127.0.0.1",
|
|
74
|
+
port: Number(env.PGPORT ?? "5432"),
|
|
75
|
+
user: env.PGUSER ?? "postgres",
|
|
76
|
+
password: env.PGPASSWORD ?? "",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** Was a server location explicitly configured (vs. the loopback default)? */
|
|
80
|
+
const serverExplicitlyConfigured = (env: ResolutionEnvironment): boolean =>
|
|
81
|
+
SERVER_VARIABLES.some((name) => (env[name] ?? "") !== "");
|
|
82
|
+
|
|
83
|
+
/** The connection URL for `database` on the configured local server. */
|
|
84
|
+
export const connectionUrl = (
|
|
85
|
+
env: ResolutionEnvironment,
|
|
86
|
+
database: string,
|
|
87
|
+
): Redacted.Redacted<string> => {
|
|
88
|
+
const { host, port, user, password } = serverConfig(env);
|
|
89
|
+
const auth = password === "" ? user : `${user}:${encodeURIComponent(password)}`;
|
|
90
|
+
return Redacted.make(`postgresql://${auth}@${host}:${port}/${database}`);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** `user@host:port` — the configured server, printable (no password). */
|
|
94
|
+
const serverLabel = (env: ResolutionEnvironment): string => {
|
|
95
|
+
const { host, port, user } = serverConfig(env);
|
|
96
|
+
return `${user}@${host}:${port}`;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** A database-name prefix is spliced into DDL unquoted-safe, so it is restricted to an identifier. */
|
|
100
|
+
const DATABASE_PREFIX = /^[a-z_][a-z0-9_]*$/;
|
|
101
|
+
|
|
102
|
+
/** The prefix is not a lower-case SQL identifier. */
|
|
103
|
+
export class InvalidDatabasePrefix extends Schema.TaggedError<InvalidDatabasePrefix>()(
|
|
104
|
+
"InvalidDatabasePrefix",
|
|
105
|
+
{ prefix: Schema.String },
|
|
106
|
+
) {
|
|
107
|
+
override get message(): string {
|
|
108
|
+
return `database-name prefix '${this.prefix}' must match ${DATABASE_PREFIX.source}`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The deterministic per-checkout database name
|
|
114
|
+
* `<prefix>_test_<sha256(migrationsDir)[:8]>`: concurrent runs from different
|
|
115
|
+
* worktrees never collide, and a stale database self-heals on the next run.
|
|
116
|
+
*/
|
|
117
|
+
export const testDatabaseName = (options: {
|
|
118
|
+
readonly prefix: string;
|
|
119
|
+
readonly migrationsDir: string;
|
|
120
|
+
}): Effect.Effect<string, InvalidDatabasePrefix> =>
|
|
121
|
+
Effect.gen(function* () {
|
|
122
|
+
if (!DATABASE_PREFIX.test(options.prefix)) {
|
|
123
|
+
return yield* new InvalidDatabasePrefix({ prefix: options.prefix });
|
|
124
|
+
}
|
|
125
|
+
// oxlint-disable-next-line effect/effect-promise-vs-trypromise -- SHA-256 over a byte array cannot reject; a rejection would be a platform defect, not an error a caller could handle
|
|
126
|
+
const digest = yield* Effect.promise(() =>
|
|
127
|
+
crypto.subtle.digest("SHA-256", new TextEncoder().encode(options.migrationsDir)),
|
|
128
|
+
);
|
|
129
|
+
const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0"))
|
|
130
|
+
.join("")
|
|
131
|
+
.slice(0, 8);
|
|
132
|
+
return `${options.prefix}_test_${hex}`;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/** One connection attempt against a server's admin database failed. */
|
|
136
|
+
export class ServerUnreachable extends Schema.TaggedError<ServerUnreachable>()(
|
|
137
|
+
"ServerUnreachable",
|
|
138
|
+
{ cause: Schema.Defect() },
|
|
139
|
+
) {}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* One connection attempt against a server's admin (`postgres`) database.
|
|
143
|
+
* Injected into {@link resolveDatabaseSource} so the resolution order can be
|
|
144
|
+
* exercised without a server; {@link probeServer} is the real one.
|
|
145
|
+
*/
|
|
146
|
+
export type ServerProbe = (
|
|
147
|
+
adminUrl: Redacted.Redacted<string>,
|
|
148
|
+
) => Effect.Effect<void, ServerUnreachable>;
|
|
149
|
+
|
|
150
|
+
/** A `SELECT 1` over one short-lived connection. */
|
|
151
|
+
export const probeServer: ServerProbe = (adminUrl) =>
|
|
152
|
+
Effect.gen(function* () {
|
|
153
|
+
const sql = yield* PgClient.PgClient;
|
|
154
|
+
yield* sql`SELECT 1`;
|
|
155
|
+
}).pipe(
|
|
156
|
+
Effect.provide(
|
|
157
|
+
PgClient.layer({ url: adminUrl, maxConnections: 1, connectTimeout: CONNECT_TIMEOUT }),
|
|
158
|
+
),
|
|
159
|
+
Effect.mapError((cause) => new ServerUnreachable({ cause })),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
/** An explicit but unreachable `PG*` target: this run will not fall back to a container. */
|
|
163
|
+
export class DatabaseUnreachable extends Schema.TaggedError<DatabaseUnreachable>()(
|
|
164
|
+
"DatabaseUnreachable",
|
|
165
|
+
{ server: Schema.String, cause: Schema.Defect() },
|
|
166
|
+
) {
|
|
167
|
+
override get message(): string {
|
|
168
|
+
return [
|
|
169
|
+
`no PostgreSQL server at ${this.server}.`,
|
|
170
|
+
"PG* variables are set, so this run will not fall back to Testcontainers —",
|
|
171
|
+
"fix the PG* target, unset it to let the suite start its own server, or set",
|
|
172
|
+
"DATABASE_URL_TEST to an already-migrated database.",
|
|
173
|
+
].join("\n");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Where a run's database comes from — the resolved decision, before anything is provisioned. */
|
|
178
|
+
export type DatabaseSource =
|
|
179
|
+
/** An explicit, already-migrated database: used as-is, never provisioned or torn down. */
|
|
180
|
+
| { readonly _tag: "Explicit"; readonly url: Redacted.Redacted<string> }
|
|
181
|
+
/** A reachable local server: this checkout's database is dropped, recreated and migrated on it. */
|
|
182
|
+
| {
|
|
183
|
+
readonly _tag: "LocalServer";
|
|
184
|
+
readonly adminUrl: Redacted.Redacted<string>;
|
|
185
|
+
readonly database: string;
|
|
186
|
+
readonly url: Redacted.Redacted<string>;
|
|
187
|
+
}
|
|
188
|
+
/** No explicit config and nothing listening: a fresh container is started via Testcontainers. */
|
|
189
|
+
| { readonly _tag: "Container"; readonly image: string };
|
|
190
|
+
|
|
191
|
+
/** The inputs {@link resolveDatabaseSource} reads. */
|
|
192
|
+
export interface DatabaseSourceOptions {
|
|
193
|
+
/** The environment — `process.env` in a global setup. */
|
|
194
|
+
readonly env: ResolutionEnvironment;
|
|
195
|
+
/** The migrations directory, as an absolute path: the per-checkout key of the database name. */
|
|
196
|
+
readonly migrationsDir: string;
|
|
197
|
+
/** The database-name prefix, a lower-case SQL identifier (the application's name, say). */
|
|
198
|
+
readonly dbNamePrefix: string;
|
|
199
|
+
/** The container image the Testcontainers fallback starts (`pgvector/pgvector:pg17`, `postgres:17`, …). */
|
|
200
|
+
readonly image: string;
|
|
201
|
+
/** The server probe: {@link probeServer} when absent or `undefined`. A test substitutes its own. */
|
|
202
|
+
// oxlint-disable-next-line effect/prefer-option-over-null -- the `?: T | undefined` spelling (AGENTS.md → Conventions) lets a caller under `exactOptionalPropertyTypes` forward its own optional; read through `??`, so `undefined` means absent
|
|
203
|
+
readonly probe?: ServerProbe | undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolve where a run's database comes from, in the order the module doc
|
|
208
|
+
* describes: an explicit URL, then a reachable local server (an explicit but
|
|
209
|
+
* unreachable `PG*` target is a {@link DatabaseUnreachable}), then the
|
|
210
|
+
* Testcontainers fallback. Pure apart from the one probe, which only runs
|
|
211
|
+
* when no explicit URL is set.
|
|
212
|
+
*/
|
|
213
|
+
export const resolveDatabaseSource = (
|
|
214
|
+
options: DatabaseSourceOptions,
|
|
215
|
+
): Effect.Effect<DatabaseSource, DatabaseUnreachable | InvalidDatabasePrefix> =>
|
|
216
|
+
Effect.gen(function* () {
|
|
217
|
+
const { env } = options;
|
|
218
|
+
const explicit = env.DATABASE_URL_TEST ?? env.DATABASE_URL ?? "";
|
|
219
|
+
if (explicit !== "") return { _tag: "Explicit", url: Redacted.make(explicit) } as const;
|
|
220
|
+
|
|
221
|
+
const database = yield* testDatabaseName({
|
|
222
|
+
prefix: options.dbNamePrefix,
|
|
223
|
+
migrationsDir: options.migrationsDir,
|
|
224
|
+
});
|
|
225
|
+
const adminUrl = connectionUrl(env, "postgres");
|
|
226
|
+
const probe = options.probe ?? probeServer;
|
|
227
|
+
const reachable = yield* probe(adminUrl).pipe(
|
|
228
|
+
Effect.as(true),
|
|
229
|
+
Effect.catchTag("ServerUnreachable", (unreachable) =>
|
|
230
|
+
serverExplicitlyConfigured(env)
|
|
231
|
+
? Effect.fail(
|
|
232
|
+
new DatabaseUnreachable({ server: serverLabel(env), cause: unreachable.cause }),
|
|
233
|
+
)
|
|
234
|
+
: Effect.succeed(false),
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
if (reachable) {
|
|
238
|
+
return {
|
|
239
|
+
_tag: "LocalServer",
|
|
240
|
+
adminUrl,
|
|
241
|
+
database,
|
|
242
|
+
url: connectionUrl(env, database),
|
|
243
|
+
} as const;
|
|
244
|
+
}
|
|
245
|
+
return { _tag: "Container", image: options.image } as const;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
/** Starting the Testcontainers fallback failed — most often, no Docker daemon. */
|
|
249
|
+
export class ContainerStartFailed extends Schema.TaggedError<ContainerStartFailed>()(
|
|
250
|
+
"ContainerStartFailed",
|
|
251
|
+
{ image: Schema.String, server: Schema.String, cause: Schema.Defect() },
|
|
252
|
+
) {
|
|
253
|
+
override get message(): string {
|
|
254
|
+
return [
|
|
255
|
+
`no PostgreSQL server at ${this.server}, and starting a ${this.image} container via`,
|
|
256
|
+
"Testcontainers failed — is a Docker daemon available (and `@testcontainers/postgresql`",
|
|
257
|
+
"installed)? The suite needs one of:",
|
|
258
|
+
" - a Docker daemon, or",
|
|
259
|
+
" - a local PostgreSQL server, with PGHOST/PGPORT/PGUSER/PGPASSWORD pointing at it, or",
|
|
260
|
+
" - DATABASE_URL_TEST set to an already-migrated database to skip provisioning.",
|
|
261
|
+
].join("\n");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** A provisioned database: its URL, and the source it was resolved from. */
|
|
266
|
+
export interface ProvisionedDatabase {
|
|
267
|
+
readonly url: Redacted.Redacted<string>;
|
|
268
|
+
readonly source: DatabaseSource;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* How a provisioned database is migrated: handed the new database's URL,
|
|
273
|
+
* returns how many migrations ran. The default applies the migrations under
|
|
274
|
+
* {@link ProvisionOptions.migrationsDir} with `./migrations`'
|
|
275
|
+
* `applyMigrations` over a one-connection `PgClient`.
|
|
276
|
+
*/
|
|
277
|
+
export type MigrationRunner<E, R> = (url: Redacted.Redacted<string>) => Effect.Effect<number, E, R>;
|
|
278
|
+
|
|
279
|
+
/** The inputs {@link provisionTestDatabase} takes. */
|
|
280
|
+
export interface ProvisionOptions<E = never, R = never> extends DatabaseSourceOptions {
|
|
281
|
+
/**
|
|
282
|
+
* Replaces the default runner (for a harness whose migrations are not a
|
|
283
|
+
* drizzle-kit directory); absent or `undefined` keeps the default.
|
|
284
|
+
*/
|
|
285
|
+
// oxlint-disable-next-line effect/prefer-option-over-null -- the `?: T | undefined` spelling (AGENTS.md → Conventions) lets a caller under `exactOptionalPropertyTypes` forward its own optional; read through `??`, so `undefined` means absent
|
|
286
|
+
readonly applyMigrations?: MigrationRunner<E, R> | undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The default {@link MigrationRunner}: `./migrations` over a one-connection client. */
|
|
290
|
+
const defaultRunner =
|
|
291
|
+
(
|
|
292
|
+
migrationsDir: string,
|
|
293
|
+
): MigrationRunner<
|
|
294
|
+
MigrationError | PlatformError | SqlError,
|
|
295
|
+
FileSystem.FileSystem | Path.Path
|
|
296
|
+
> =>
|
|
297
|
+
(url) =>
|
|
298
|
+
applyMigrations(migrationsDir).pipe(Effect.provide(PgClient.layer({ url, maxConnections: 1 })));
|
|
299
|
+
|
|
300
|
+
/** Drop and recreate this checkout's database on the reachable server. */
|
|
301
|
+
const recreateDatabase = (
|
|
302
|
+
source: Extract<DatabaseSource, { readonly _tag: "LocalServer" }>,
|
|
303
|
+
): Effect.Effect<void, SqlError> =>
|
|
304
|
+
Effect.gen(function* () {
|
|
305
|
+
const admin = yield* PgClient.PgClient;
|
|
306
|
+
// Quoted identifiers; `database` is `<prefix>_test_<hex>` with the prefix
|
|
307
|
+
// restricted to an identifier, so nothing here can close the quote.
|
|
308
|
+
yield* admin.unsafe(`DROP DATABASE IF EXISTS "${source.database}" WITH (FORCE)`);
|
|
309
|
+
yield* admin.unsafe(`CREATE DATABASE "${source.database}"`);
|
|
310
|
+
}).pipe(
|
|
311
|
+
Effect.provide(
|
|
312
|
+
PgClient.layer({ url: source.adminUrl, maxConnections: 1, connectTimeout: CONNECT_TIMEOUT }),
|
|
313
|
+
),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Start a fresh server via Testcontainers, stopped when the scope closes. The
|
|
318
|
+
* import is dynamic so a run that found a reachable server never loads the
|
|
319
|
+
* library or talks to Docker at all.
|
|
320
|
+
*/
|
|
321
|
+
const startContainer = (
|
|
322
|
+
image: string,
|
|
323
|
+
server: string,
|
|
324
|
+
): Effect.Effect<Redacted.Redacted<string>, ContainerStartFailed, Scope.Scope> =>
|
|
325
|
+
Effect.acquireRelease(
|
|
326
|
+
Effect.tryPromise({
|
|
327
|
+
try: async () => {
|
|
328
|
+
const { PostgreSqlContainer } = await import("@testcontainers/postgresql");
|
|
329
|
+
return new PostgreSqlContainer(image).start();
|
|
330
|
+
},
|
|
331
|
+
catch: (cause) => new ContainerStartFailed({ image, server, cause }),
|
|
332
|
+
}),
|
|
333
|
+
// oxlint-disable-next-line effect/effect-promise-vs-trypromise -- an `acquireRelease` finalizer must have `never` in its error channel, so a failed stop has to be a defect
|
|
334
|
+
(container) => Effect.promise(() => container.stop()),
|
|
335
|
+
).pipe(Effect.map((container) => Redacted.make(container.getConnectionUri())));
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Provision a run's fresh, fully-migrated database — resolve the source,
|
|
339
|
+
* then drop / create / migrate on a reachable server, or start and migrate a
|
|
340
|
+
* container — and return its URL. An explicit source is returned as-is.
|
|
341
|
+
*
|
|
342
|
+
* Scoped: a started container stops when the scope closes. From a vitest
|
|
343
|
+
* global setup, hold a `Scope` across the run and close it in the returned
|
|
344
|
+
* teardown:
|
|
345
|
+
*
|
|
346
|
+
* @example
|
|
347
|
+
* ```ts
|
|
348
|
+
* // vitest global setup
|
|
349
|
+
* import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
350
|
+
* import { provisionTestDatabase } from "@thomasfosterau/effect-drizzle-ext-pg/testing";
|
|
351
|
+
* import * as Effect from "effect/Effect";
|
|
352
|
+
* import * as Exit from "effect/Exit";
|
|
353
|
+
* import * as Redacted from "effect/Redacted";
|
|
354
|
+
* import * as Scope from "effect/Scope";
|
|
355
|
+
*
|
|
356
|
+
* const MIGRATIONS_DIR = "packages/database/migrations";
|
|
357
|
+
*
|
|
358
|
+
* export default async function setup() {
|
|
359
|
+
* const scope = Effect.runSync(Scope.make());
|
|
360
|
+
* const database = await Effect.runPromise(
|
|
361
|
+
* provisionTestDatabase({
|
|
362
|
+
* env: process.env,
|
|
363
|
+
* migrationsDir: MIGRATIONS_DIR,
|
|
364
|
+
* dbNamePrefix: "website",
|
|
365
|
+
* image: "pgvector/pgvector:pg17",
|
|
366
|
+
* }).pipe(Scope.provide(scope), Effect.provide(NodeServices.layer)),
|
|
367
|
+
* );
|
|
368
|
+
* process.env.DATABASE_URL_TEST = Redacted.value(database.url);
|
|
369
|
+
* return () => Effect.runPromise(Scope.close(scope, Exit.void));
|
|
370
|
+
* }
|
|
371
|
+
* ```
|
|
372
|
+
*/
|
|
373
|
+
export const provisionTestDatabase = <
|
|
374
|
+
E = MigrationError | PlatformError | SqlError,
|
|
375
|
+
R = FileSystem.FileSystem | Path.Path,
|
|
376
|
+
>(
|
|
377
|
+
options: ProvisionOptions<E, R>,
|
|
378
|
+
): Effect.Effect<
|
|
379
|
+
ProvisionedDatabase,
|
|
380
|
+
| E
|
|
381
|
+
| DatabaseUnreachable
|
|
382
|
+
| InvalidDatabasePrefix
|
|
383
|
+
| ContainerStartFailed
|
|
384
|
+
| SqlError
|
|
385
|
+
| MigrationError
|
|
386
|
+
| PlatformError,
|
|
387
|
+
R | FileSystem.FileSystem | Path.Path | Scope.Scope
|
|
388
|
+
> =>
|
|
389
|
+
Effect.gen(function* () {
|
|
390
|
+
const source = yield* resolveDatabaseSource(options);
|
|
391
|
+
if (Predicate.isTagged(source, "Explicit")) return { url: source.url, source };
|
|
392
|
+
|
|
393
|
+
const url = Predicate.isTagged(source, "LocalServer")
|
|
394
|
+
? yield* recreateDatabase(source).pipe(Effect.as(source.url))
|
|
395
|
+
: yield* startContainer(source.image, serverLabel(options.env));
|
|
396
|
+
|
|
397
|
+
const migrate = options.applyMigrations ?? defaultRunner(options.migrationsDir);
|
|
398
|
+
const applied = yield* migrate(url);
|
|
399
|
+
yield* Effect.logInfo(
|
|
400
|
+
`provisioned ${
|
|
401
|
+
Predicate.isTagged(source, "LocalServer")
|
|
402
|
+
? `'${source.database}'`
|
|
403
|
+
: `a ${source.image} container`
|
|
404
|
+
} (${applied} migrations)`,
|
|
405
|
+
);
|
|
406
|
+
return { url, source };
|
|
407
|
+
}).pipe(Effect.withLogSpan("provisionTestDatabase"));
|