@thomasfosterau/effect-drizzle-ext-pg 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.
@@ -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"));