@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
package/dist/testing.js
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { pgClientFromUrl } from "./index.js";
|
|
2
|
+
import { applyMigrations } from "./migrations.js";
|
|
3
|
+
import * as PgClient from "@effect/sql-pg/PgClient";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Layer from "effect/Layer";
|
|
6
|
+
import * as Arr from "effect/Array";
|
|
7
|
+
import * as Schema from "effect/Schema";
|
|
8
|
+
import * as Cause from "effect/Cause";
|
|
9
|
+
import * as Duration from "effect/Duration";
|
|
10
|
+
import * as Exit from "effect/Exit";
|
|
11
|
+
import * as HashSet from "effect/HashSet";
|
|
12
|
+
import * as Predicate from "effect/Predicate";
|
|
13
|
+
import * as Record from "effect/Record";
|
|
14
|
+
import * as Schedule from "effect/Schedule";
|
|
15
|
+
import { isSqlError } from "effect/sql/SqlError";
|
|
16
|
+
import * as Redacted from "effect/Redacted";
|
|
17
|
+
// src/provisioning.ts
|
|
18
|
+
/**
|
|
19
|
+
* @copyright Thomas Foster
|
|
20
|
+
*
|
|
21
|
+
* **Ephemeral test-database provisioning** — the half of an integration
|
|
22
|
+
* suite's global setup every consuming application carried as
|
|
23
|
+
* `packages/integration-harness/local-database.ts`. Re-exported from
|
|
24
|
+
* `./testing`; the reference is the preferenceflow application's copy.
|
|
25
|
+
*
|
|
26
|
+
* A suite's database is resolved in this order ({@link resolveDatabaseSource}):
|
|
27
|
+
*
|
|
28
|
+
* 0. **An explicit `DATABASE_URL_TEST` / `DATABASE_URL`** wins and is used
|
|
29
|
+
* as-is: it must already be migrated — the escape hatch for pointing the
|
|
30
|
+
* suite at a deployed stage's database or any other prepared server.
|
|
31
|
+
* Nothing is provisioned and nothing is torn down.
|
|
32
|
+
* 1. **A reachable local server** — a CI service container, a devcontainer's
|
|
33
|
+
* PostgreSQL, or anything the standard `PG*` variables (`PGHOST`,
|
|
34
|
+
* `PGPORT`, `PGUSER`, `PGPASSWORD`) point at; the default is a trust-auth
|
|
35
|
+
* `postgres@127.0.0.1:5432`. Each run gets a **freshly created database**
|
|
36
|
+
* on it: drop, create, apply every migration.
|
|
37
|
+
* 2. **A Testcontainers-launched container** when nothing is listening and
|
|
38
|
+
* no `PG*` variable was set explicitly. Needs a Docker daemon and the
|
|
39
|
+
* optional peer `@testcontainers/postgresql`, which is imported only on
|
|
40
|
+
* this path. The container is brand new, so migrations apply directly, and
|
|
41
|
+
* it stops when the provisioning scope closes.
|
|
42
|
+
*
|
|
43
|
+
* Explicit `PG*` config that is unreachable is a hard error
|
|
44
|
+
* ({@link DatabaseUnreachable}), not a silent fallback: if you pointed the
|
|
45
|
+
* suite somewhere, it should not quietly run elsewhere.
|
|
46
|
+
*
|
|
47
|
+
* Either way run N's verdict is independent of run N−1: no state survives
|
|
48
|
+
* between runs. The per-checkout database name
|
|
49
|
+
* `<prefix>_test_<sha256(migrationsDir)[:8]>` keeps parallel worktrees
|
|
50
|
+
* sharing one server from dropping each other's database mid-run — pass the
|
|
51
|
+
* migrations directory as an absolute path, which is per-checkout.
|
|
52
|
+
*
|
|
53
|
+
* What stays with the application: fixture users and seed data (website
|
|
54
|
+
* #1847 keeps the harness → database edge one-way), and the container image
|
|
55
|
+
* and database-name prefix, which are parameters here.
|
|
56
|
+
*
|
|
57
|
+
* @module provisioning
|
|
58
|
+
*/
|
|
59
|
+
/** The variables that count as "explicitly pointed at a server". */
|
|
60
|
+
const SERVER_VARIABLES = [
|
|
61
|
+
"PGHOST",
|
|
62
|
+
"PGPORT",
|
|
63
|
+
"PGUSER",
|
|
64
|
+
"PGPASSWORD"
|
|
65
|
+
];
|
|
66
|
+
/** How long one connection attempt against the admin database may take. */
|
|
67
|
+
const CONNECT_TIMEOUT = Duration.seconds(10);
|
|
68
|
+
/** Where a local server is expected, honouring the standard `PG*` variables. */
|
|
69
|
+
const serverConfig = (env) => ({
|
|
70
|
+
host: env.PGHOST ?? "127.0.0.1",
|
|
71
|
+
port: Number(env.PGPORT ?? "5432"),
|
|
72
|
+
user: env.PGUSER ?? "postgres",
|
|
73
|
+
password: env.PGPASSWORD ?? ""
|
|
74
|
+
});
|
|
75
|
+
/** Was a server location explicitly configured (vs. the loopback default)? */
|
|
76
|
+
const serverExplicitlyConfigured = (env) => SERVER_VARIABLES.some((name) => (env[name] ?? "") !== "");
|
|
77
|
+
/** The connection URL for `database` on the configured local server. */
|
|
78
|
+
const connectionUrl = (env, database) => {
|
|
79
|
+
const { host, port, user, password } = serverConfig(env);
|
|
80
|
+
const auth = password === "" ? user : `${user}:${encodeURIComponent(password)}`;
|
|
81
|
+
return Redacted.make(`postgresql://${auth}@${host}:${port}/${database}`);
|
|
82
|
+
};
|
|
83
|
+
/** `user@host:port` — the configured server, printable (no password). */
|
|
84
|
+
const serverLabel = (env) => {
|
|
85
|
+
const { host, port, user } = serverConfig(env);
|
|
86
|
+
return `${user}@${host}:${port}`;
|
|
87
|
+
};
|
|
88
|
+
/** A database-name prefix is spliced into DDL unquoted-safe, so it is restricted to an identifier. */
|
|
89
|
+
const DATABASE_PREFIX = /^[a-z_][a-z0-9_]*$/;
|
|
90
|
+
/** The prefix is not a lower-case SQL identifier. */
|
|
91
|
+
var InvalidDatabasePrefix = class extends Schema.TaggedError()("InvalidDatabasePrefix", { prefix: Schema.String }) {
|
|
92
|
+
get message() {
|
|
93
|
+
return `database-name prefix '${this.prefix}' must match ${DATABASE_PREFIX.source}`;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* The deterministic per-checkout database name
|
|
98
|
+
* `<prefix>_test_<sha256(migrationsDir)[:8]>`: concurrent runs from different
|
|
99
|
+
* worktrees never collide, and a stale database self-heals on the next run.
|
|
100
|
+
*/
|
|
101
|
+
const testDatabaseName = (options) => Effect.gen(function* () {
|
|
102
|
+
if (!DATABASE_PREFIX.test(options.prefix)) return yield* new InvalidDatabasePrefix({ prefix: options.prefix });
|
|
103
|
+
const digest = yield* Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(options.migrationsDir)));
|
|
104
|
+
const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 8);
|
|
105
|
+
return `${options.prefix}_test_${hex}`;
|
|
106
|
+
});
|
|
107
|
+
/** One connection attempt against a server's admin database failed. */
|
|
108
|
+
var ServerUnreachable = class extends Schema.TaggedError()("ServerUnreachable", { cause: Schema.Defect() }) {};
|
|
109
|
+
/** A `SELECT 1` over one short-lived connection. */
|
|
110
|
+
const probeServer = (adminUrl) => Effect.gen(function* () {
|
|
111
|
+
yield* (yield* PgClient.PgClient)`SELECT 1`;
|
|
112
|
+
}).pipe(Effect.provide(PgClient.layer({
|
|
113
|
+
url: adminUrl,
|
|
114
|
+
maxConnections: 1,
|
|
115
|
+
connectTimeout: CONNECT_TIMEOUT
|
|
116
|
+
})), Effect.mapError((cause) => new ServerUnreachable({ cause })));
|
|
117
|
+
/** An explicit but unreachable `PG*` target: this run will not fall back to a container. */
|
|
118
|
+
var DatabaseUnreachable = class extends Schema.TaggedError()("DatabaseUnreachable", {
|
|
119
|
+
server: Schema.String,
|
|
120
|
+
cause: Schema.Defect()
|
|
121
|
+
}) {
|
|
122
|
+
get message() {
|
|
123
|
+
return [
|
|
124
|
+
`no PostgreSQL server at ${this.server}.`,
|
|
125
|
+
"PG* variables are set, so this run will not fall back to Testcontainers —",
|
|
126
|
+
"fix the PG* target, unset it to let the suite start its own server, or set",
|
|
127
|
+
"DATABASE_URL_TEST to an already-migrated database."
|
|
128
|
+
].join("\n");
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Resolve where a run's database comes from, in the order the module doc
|
|
133
|
+
* describes: an explicit URL, then a reachable local server (an explicit but
|
|
134
|
+
* unreachable `PG*` target is a {@link DatabaseUnreachable}), then the
|
|
135
|
+
* Testcontainers fallback. Pure apart from the one probe, which only runs
|
|
136
|
+
* when no explicit URL is set.
|
|
137
|
+
*/
|
|
138
|
+
const resolveDatabaseSource = (options) => Effect.gen(function* () {
|
|
139
|
+
const { env } = options;
|
|
140
|
+
const explicit = env.DATABASE_URL_TEST ?? env.DATABASE_URL ?? "";
|
|
141
|
+
if (explicit !== "") return {
|
|
142
|
+
_tag: "Explicit",
|
|
143
|
+
url: Redacted.make(explicit)
|
|
144
|
+
};
|
|
145
|
+
const database = yield* testDatabaseName({
|
|
146
|
+
prefix: options.dbNamePrefix,
|
|
147
|
+
migrationsDir: options.migrationsDir
|
|
148
|
+
});
|
|
149
|
+
const adminUrl = connectionUrl(env, "postgres");
|
|
150
|
+
if (yield* (options.probe ?? probeServer)(adminUrl).pipe(Effect.as(true), Effect.catchTag("ServerUnreachable", (unreachable) => serverExplicitlyConfigured(env) ? Effect.fail(new DatabaseUnreachable({
|
|
151
|
+
server: serverLabel(env),
|
|
152
|
+
cause: unreachable.cause
|
|
153
|
+
})) : Effect.succeed(false)))) return {
|
|
154
|
+
_tag: "LocalServer",
|
|
155
|
+
adminUrl,
|
|
156
|
+
database,
|
|
157
|
+
url: connectionUrl(env, database)
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
_tag: "Container",
|
|
161
|
+
image: options.image
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
/** Starting the Testcontainers fallback failed — most often, no Docker daemon. */
|
|
165
|
+
var ContainerStartFailed = class extends Schema.TaggedError()("ContainerStartFailed", {
|
|
166
|
+
image: Schema.String,
|
|
167
|
+
server: Schema.String,
|
|
168
|
+
cause: Schema.Defect()
|
|
169
|
+
}) {
|
|
170
|
+
get message() {
|
|
171
|
+
return [
|
|
172
|
+
`no PostgreSQL server at ${this.server}, and starting a ${this.image} container via`,
|
|
173
|
+
"Testcontainers failed — is a Docker daemon available (and `@testcontainers/postgresql`",
|
|
174
|
+
"installed)? The suite needs one of:",
|
|
175
|
+
" - a Docker daemon, or",
|
|
176
|
+
" - a local PostgreSQL server, with PGHOST/PGPORT/PGUSER/PGPASSWORD pointing at it, or",
|
|
177
|
+
" - DATABASE_URL_TEST set to an already-migrated database to skip provisioning."
|
|
178
|
+
].join("\n");
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
/** The default {@link MigrationRunner}: `./migrations` over a one-connection client. */
|
|
182
|
+
const defaultRunner = (migrationsDir) => (url) => applyMigrations(migrationsDir).pipe(Effect.provide(PgClient.layer({
|
|
183
|
+
url,
|
|
184
|
+
maxConnections: 1
|
|
185
|
+
})));
|
|
186
|
+
/** Drop and recreate this checkout's database on the reachable server. */
|
|
187
|
+
const recreateDatabase = (source) => Effect.gen(function* () {
|
|
188
|
+
const admin = yield* PgClient.PgClient;
|
|
189
|
+
yield* admin.unsafe(`DROP DATABASE IF EXISTS "${source.database}" WITH (FORCE)`);
|
|
190
|
+
yield* admin.unsafe(`CREATE DATABASE "${source.database}"`);
|
|
191
|
+
}).pipe(Effect.provide(PgClient.layer({
|
|
192
|
+
url: source.adminUrl,
|
|
193
|
+
maxConnections: 1,
|
|
194
|
+
connectTimeout: CONNECT_TIMEOUT
|
|
195
|
+
})));
|
|
196
|
+
/**
|
|
197
|
+
* Start a fresh server via Testcontainers, stopped when the scope closes. The
|
|
198
|
+
* import is dynamic so a run that found a reachable server never loads the
|
|
199
|
+
* library or talks to Docker at all.
|
|
200
|
+
*/
|
|
201
|
+
const startContainer = (image, server) => Effect.acquireRelease(Effect.tryPromise({
|
|
202
|
+
try: async () => {
|
|
203
|
+
const { PostgreSqlContainer } = await import("@testcontainers/postgresql");
|
|
204
|
+
return new PostgreSqlContainer(image).start();
|
|
205
|
+
},
|
|
206
|
+
catch: (cause) => new ContainerStartFailed({
|
|
207
|
+
image,
|
|
208
|
+
server,
|
|
209
|
+
cause
|
|
210
|
+
})
|
|
211
|
+
}), (container) => Effect.promise(() => container.stop())).pipe(Effect.map((container) => Redacted.make(container.getConnectionUri())));
|
|
212
|
+
/**
|
|
213
|
+
* Provision a run's fresh, fully-migrated database — resolve the source,
|
|
214
|
+
* then drop / create / migrate on a reachable server, or start and migrate a
|
|
215
|
+
* container — and return its URL. An explicit source is returned as-is.
|
|
216
|
+
*
|
|
217
|
+
* Scoped: a started container stops when the scope closes. From a vitest
|
|
218
|
+
* global setup, hold a `Scope` across the run and close it in the returned
|
|
219
|
+
* teardown:
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```ts
|
|
223
|
+
* // vitest global setup
|
|
224
|
+
* import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
225
|
+
* import { provisionTestDatabase } from "@thomasfosterau/effect-drizzle-ext-pg/testing";
|
|
226
|
+
* import * as Effect from "effect/Effect";
|
|
227
|
+
* import * as Exit from "effect/Exit";
|
|
228
|
+
* import * as Redacted from "effect/Redacted";
|
|
229
|
+
* import * as Scope from "effect/Scope";
|
|
230
|
+
*
|
|
231
|
+
* const MIGRATIONS_DIR = "packages/database/migrations";
|
|
232
|
+
*
|
|
233
|
+
* export default async function setup() {
|
|
234
|
+
* const scope = Effect.runSync(Scope.make());
|
|
235
|
+
* const database = await Effect.runPromise(
|
|
236
|
+
* provisionTestDatabase({
|
|
237
|
+
* env: process.env,
|
|
238
|
+
* migrationsDir: MIGRATIONS_DIR,
|
|
239
|
+
* dbNamePrefix: "website",
|
|
240
|
+
* image: "pgvector/pgvector:pg17",
|
|
241
|
+
* }).pipe(Scope.provide(scope), Effect.provide(NodeServices.layer)),
|
|
242
|
+
* );
|
|
243
|
+
* process.env.DATABASE_URL_TEST = Redacted.value(database.url);
|
|
244
|
+
* return () => Effect.runPromise(Scope.close(scope, Exit.void));
|
|
245
|
+
* }
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
const provisionTestDatabase = (options) => Effect.gen(function* () {
|
|
249
|
+
const source = yield* resolveDatabaseSource(options);
|
|
250
|
+
if (Predicate.isTagged(source, "Explicit")) return {
|
|
251
|
+
url: source.url,
|
|
252
|
+
source
|
|
253
|
+
};
|
|
254
|
+
const url = Predicate.isTagged(source, "LocalServer") ? yield* recreateDatabase(source).pipe(Effect.as(source.url)) : yield* startContainer(source.image, serverLabel(options.env));
|
|
255
|
+
const applied = yield* (options.applyMigrations ?? defaultRunner(options.migrationsDir))(url);
|
|
256
|
+
yield* Effect.logInfo(`provisioned ${Predicate.isTagged(source, "LocalServer") ? `'${source.database}'` : `a ${source.image} container`} (${applied} migrations)`);
|
|
257
|
+
return {
|
|
258
|
+
url,
|
|
259
|
+
source
|
|
260
|
+
};
|
|
261
|
+
}).pipe(Effect.withLogSpan("provisionTestDatabase"));
|
|
262
|
+
// src/testing.ts
|
|
263
|
+
/**
|
|
264
|
+
* The `operation` labels `@effect/sql-pg` stamps on a connect/acquire-seam
|
|
265
|
+
* `SqlError` reason. A failure carrying one of these happened while opening a
|
|
266
|
+
* connection, i.e. before any transaction body ran.
|
|
267
|
+
*/
|
|
268
|
+
const CONNECT_SEAM_OPERATIONS = HashSet.make("acquireConnection", "connect");
|
|
269
|
+
/**
|
|
270
|
+
* Whether `error` is a `SqlError` that failed at the connect/acquire seam and
|
|
271
|
+
* so is safe to retry (when the caller has also confirmed no test body ran).
|
|
272
|
+
*
|
|
273
|
+
* Returns `true` for:
|
|
274
|
+
* - any reason the driver already flags `isRetryable` (`ConnectionError` from
|
|
275
|
+
* an `08xxx` SQLSTATE, `DeadlockError`, `SerializationError`, the timeout
|
|
276
|
+
* reasons), and
|
|
277
|
+
* - a reason whose `operation` is a connect/acquire seam label, which catches
|
|
278
|
+
* the misclassified `UnknownError` the website #1055 flake surfaces as
|
|
279
|
+
* (pool "timeout exceeded when trying to connect" / `ECONNRESET`, neither
|
|
280
|
+
* of which carries an `08` SQLSTATE).
|
|
281
|
+
*
|
|
282
|
+
* Returns `false` for a `UniqueViolation` / `ConstraintError` /
|
|
283
|
+
* `SqlSyntaxError` raised by a test's own query (`operation === "execute"`),
|
|
284
|
+
* so genuine test failures are never retried.
|
|
285
|
+
*/
|
|
286
|
+
const isRetryableConnectFailure = (error) => {
|
|
287
|
+
if (!isSqlError(error)) return false;
|
|
288
|
+
if (error.isRetryable) return true;
|
|
289
|
+
const operation = error.reason.operation;
|
|
290
|
+
return operation !== void 0 && HashSet.has(CONNECT_SEAM_OPERATIONS, operation);
|
|
291
|
+
};
|
|
292
|
+
/**
|
|
293
|
+
* `maxConnections` is pinned to 1 because a fork runs its tests strictly
|
|
294
|
+
* sequentially and the runner holds exactly one transaction-bound connection
|
|
295
|
+
* at a time (the whole test body runs inside a single `db.transaction`, with
|
|
296
|
+
* the spec's own `transact` nesting a SAVEPOINT on that same connection) — so
|
|
297
|
+
* 1 is the real per-fork demand, not a compromise. It is also what forces an
|
|
298
|
+
* operation-opened transaction to nest as a SAVEPOINT on the same connection
|
|
299
|
+
* (and so see the outer transaction's uncommitted writes) rather than
|
|
300
|
+
* silently acquiring a second connection that cannot.
|
|
301
|
+
*/
|
|
302
|
+
const TEST_POOL_MAX_CONNECTIONS = 1;
|
|
303
|
+
/**
|
|
304
|
+
* How long a single connection attempt may take before the driver gives up.
|
|
305
|
+
*
|
|
306
|
+
* Under fork parallelism all forks race to open their connection at once
|
|
307
|
+
* while each imports the module graph, so at startup the connect handshakes
|
|
308
|
+
* contend. A generous per-attempt timeout costs nothing on the happy path (a
|
|
309
|
+
* healthy connect resolves in milliseconds and never approaches this
|
|
310
|
+
* ceiling), so it stays as cheap insurance for slow CI service-container
|
|
311
|
+
* startup.
|
|
312
|
+
*/
|
|
313
|
+
const TEST_CONNECT_TIMEOUT = Duration.seconds(30);
|
|
314
|
+
/** Sentinel cause used to force the outer test transaction to roll back. */
|
|
315
|
+
const ROLLBACK_TAG = "__effect_test_rollback__";
|
|
316
|
+
const rollback = { _tag: ROLLBACK_TAG };
|
|
317
|
+
const isRollback = (u) => Predicate.isObjectOrArray(u) && Predicate.isTagged(u, ROLLBACK_TAG);
|
|
318
|
+
/**
|
|
319
|
+
* Whether a captured failure cause carries the rollback sentinel.
|
|
320
|
+
*
|
|
321
|
+
* Effect causes are nested objects; this is a structural walk that finds the
|
|
322
|
+
* rollback tag without depending on `Cause` internals.
|
|
323
|
+
*/
|
|
324
|
+
const causeContainsRollback = (cause) => {
|
|
325
|
+
const seen = /* @__PURE__ */ new Set();
|
|
326
|
+
const stack = [cause];
|
|
327
|
+
while (Arr.isReadonlyArrayNonEmpty(stack)) {
|
|
328
|
+
const node = stack.pop();
|
|
329
|
+
if (!Predicate.isObjectOrArray(node) || seen.has(node)) continue;
|
|
330
|
+
seen.add(node);
|
|
331
|
+
if (isRollback(node)) return true;
|
|
332
|
+
Arr.forEach(Array.isArray(node) ? node : Record.values(node), (value) => {
|
|
333
|
+
stack.push(value);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return false;
|
|
337
|
+
};
|
|
338
|
+
const CONNECT_RETRY_ATTEMPTS = 5;
|
|
339
|
+
const CONNECT_RETRY_BASE_DELAY = Duration.millis(250);
|
|
340
|
+
const connectRetrySchedule = Schedule.max([Schedule.exponential(CONNECT_RETRY_BASE_DELAY), Schedule.recurs(CONNECT_RETRY_ATTEMPTS)]).pipe(Schedule.jittered);
|
|
341
|
+
/**
|
|
342
|
+
* A failure of the runner itself rather than of the spec it ran: the outer
|
|
343
|
+
* transaction failed before the spec's `Exit` was captured, or failed with
|
|
344
|
+
* something other than the rollback sentinel. Thrown (rejecting the runner's
|
|
345
|
+
* Promise), never put in an Effect error channel — the runner is the test
|
|
346
|
+
* harness's boundary.
|
|
347
|
+
*/
|
|
348
|
+
var TestTransactionError = class extends Schema.TaggedError()("TestTransactionError", { message: Schema.String }) {};
|
|
349
|
+
/**
|
|
350
|
+
* Builds the always-rollback test transaction runner for one application's
|
|
351
|
+
* database services.
|
|
352
|
+
*
|
|
353
|
+
* The services carry the key strings and the `transact` boundary, so the
|
|
354
|
+
* runner needs nothing else: a suite calls this once next to its
|
|
355
|
+
* {@link makePgDatabase} call and exports the three helpers.
|
|
356
|
+
*/
|
|
357
|
+
const makeTestTransaction = (database) => {
|
|
358
|
+
const runInTestTransaction = async (runtime, effect) => {
|
|
359
|
+
let captured = null;
|
|
360
|
+
const program = Effect.gen(function* () {
|
|
361
|
+
return yield* (yield* database.Database).transaction(() => Effect.gen(function* () {
|
|
362
|
+
captured = yield* Effect.exit(database.transact(effect));
|
|
363
|
+
return yield* Effect.fail(rollback);
|
|
364
|
+
}));
|
|
365
|
+
}).pipe(Effect.retry({
|
|
366
|
+
schedule: connectRetrySchedule,
|
|
367
|
+
while: (e) => captured === null && isRetryableConnectFailure(e)
|
|
368
|
+
}));
|
|
369
|
+
const outer = await runtime.runPromiseExit(program);
|
|
370
|
+
const innerExit = captured;
|
|
371
|
+
if (!innerExit) {
|
|
372
|
+
if (Exit.isFailure(outer)) throw new TestTransactionError({ message: `runInTestTransaction: outer transaction failed: ${String(outer.cause)}` });
|
|
373
|
+
throw new TestTransactionError({ message: "runInTestTransaction: inner Exit was never captured" });
|
|
374
|
+
}
|
|
375
|
+
if (Exit.isFailure(outer) && !causeContainsRollback(outer.cause)) throw new TestTransactionError({ message: `runInTestTransaction: unexpected outer failure: ${String(outer.cause)}` });
|
|
376
|
+
if (Exit.isSuccess(innerExit)) return innerExit.value;
|
|
377
|
+
throw Cause.squash(innerExit.cause);
|
|
378
|
+
};
|
|
379
|
+
return {
|
|
380
|
+
runInTestTransaction,
|
|
381
|
+
withTestTransaction: (runtime) => (effect) => runInTestTransaction(runtime, effect),
|
|
382
|
+
attemptInSavepoint: (effect) => Effect.gen(function* () {
|
|
383
|
+
const tx = yield* database.CurrentTransaction;
|
|
384
|
+
return yield* Effect.exit(tx.transaction(() => effect));
|
|
385
|
+
})
|
|
386
|
+
};
|
|
387
|
+
};
|
|
388
|
+
/**
|
|
389
|
+
* The `Database` layer an integration suite runs against: the application's
|
|
390
|
+
* own {@link PgDatabase.layer} over a `PgClient` built from `url`, with the
|
|
391
|
+
* pool pinned to one connection (see {@link TEST_POOL_MAX_CONNECTIONS}).
|
|
392
|
+
*
|
|
393
|
+
* Built once per process and reused by every spec in it. `url` is the
|
|
394
|
+
* caller's: this library does not read `DATABASE_URL_TEST` or decide how a
|
|
395
|
+
* test database is provisioned.
|
|
396
|
+
*
|
|
397
|
+
* Unlike production, this uses the pinging {@link pgClientFromUrl} on
|
|
398
|
+
* purpose — a suite that cannot reach its database should fail at layer build
|
|
399
|
+
* with one legible `SqlError`, not once per spec on the first query.
|
|
400
|
+
*/
|
|
401
|
+
const testDatabaseLayer = (database, options) => database.layer.pipe(Layer.provide(pgClientFromUrl({
|
|
402
|
+
url: options.url,
|
|
403
|
+
maxConnections: TEST_POOL_MAX_CONNECTIONS,
|
|
404
|
+
connectTimeout: options.connectTimeout ?? TEST_CONNECT_TIMEOUT
|
|
405
|
+
})));
|
|
406
|
+
export { ContainerStartFailed, DatabaseUnreachable, InvalidDatabasePrefix, ServerUnreachable, connectionUrl, isRetryableConnectFailure, makeTestTransaction, probeServer, provisionTestDatabase, resolveDatabaseSource, testDatabaseLayer, testDatabaseName };
|
|
407
|
+
|
|
408
|
+
//# sourceMappingURL=testing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.js","names":[],"sources":["../src/provisioning.ts","../src/testing.ts"],"mappings":"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"}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as FileSystem from "effect/FileSystem";
|
|
3
|
+
import * as Path from "effect/Path";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
5
|
+
import * as Option from "effect/Option";
|
|
6
|
+
import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
|
|
7
|
+
import { PlatformError } from "effect/PlatformError";
|
|
8
|
+
/**
|
|
9
|
+
* drizzle-kit's sentinel for "no real parent" (its `originUUID`): the genesis
|
|
10
|
+
* migration's `prevIds` names this instead of being empty.
|
|
11
|
+
*/
|
|
12
|
+
export declare const ROOT_SENTINEL_ID = "00000000-0000-0000-0000-000000000000";
|
|
13
|
+
/** The identity every snapshot must carry for the DAG checks. */
|
|
14
|
+
interface Snapshot {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly prevIds: ReadonlyArray<string>;
|
|
17
|
+
}
|
|
18
|
+
/** One migration directory and its snapshot's identity. */
|
|
19
|
+
interface SnapshotEntry {
|
|
20
|
+
readonly dir: string;
|
|
21
|
+
readonly snapshot: Snapshot;
|
|
22
|
+
}
|
|
23
|
+
declare const MalformedSnapshot_base: Schema.Class<MalformedSnapshot, Schema.TaggedStruct<"MalformedSnapshot", {
|
|
24
|
+
readonly file: Schema.String;
|
|
25
|
+
readonly cause: Schema.Defect;
|
|
26
|
+
}>, import("effect/Cause").YieldableError>;
|
|
27
|
+
/** A snapshot.json could not be read as a drizzle-kit snapshot. */
|
|
28
|
+
export declare class MalformedSnapshot extends MalformedSnapshot_base {
|
|
29
|
+
get message(): string;
|
|
30
|
+
}
|
|
31
|
+
declare const SchemaDiffError_base: Schema.Class<SchemaDiffError, Schema.TaggedStruct<"SchemaDiffError", {
|
|
32
|
+
readonly cause: Schema.Defect;
|
|
33
|
+
}>, import("effect/Cause").YieldableError>;
|
|
34
|
+
/**
|
|
35
|
+
* drizzle-kit itself failed while generating or diffing a snapshot — broken
|
|
36
|
+
* input (a schema module drizzle-kit cannot read) rather than drift.
|
|
37
|
+
*/
|
|
38
|
+
export declare class SchemaDiffError extends SchemaDiffError_base {
|
|
39
|
+
get message(): string;
|
|
40
|
+
}
|
|
41
|
+
declare const MigrationDriftError_base: Schema.Class<MigrationDriftError, Schema.TaggedStruct<"MigrationDriftError", {
|
|
42
|
+
readonly issues: Schema.$Array<Schema.String>;
|
|
43
|
+
}>, import("effect/Cause").YieldableError>;
|
|
44
|
+
/** The gate found problems; `issues` holds one human-readable report per violation. */
|
|
45
|
+
export declare class MigrationDriftError extends MigrationDriftError_base {
|
|
46
|
+
get message(): string;
|
|
47
|
+
}
|
|
48
|
+
/** Snapshots not named in any other snapshot's `prevIds` — the DAG's tips. */
|
|
49
|
+
export declare const findHeads: (entries: ReadonlyArray<SnapshotEntry>) => ReadonlyArray<SnapshotEntry>;
|
|
50
|
+
/**
|
|
51
|
+
* Every snapshot id is unique, every `prevIds` reference resolves (or is
|
|
52
|
+
* {@link ROOT_SENTINEL_ID}), the lineage has exactly one head and no cycle,
|
|
53
|
+
* and every snapshot is reachable from that head. One human-readable report
|
|
54
|
+
* per violation; empty when clean.
|
|
55
|
+
*/
|
|
56
|
+
export declare const checkDagIntegrity: (entries: ReadonlyArray<SnapshotEntry>) => ReadonlyArray<string>;
|
|
57
|
+
/** The base branch's migration state: its directory names and its latest-by-name snapshot. */
|
|
58
|
+
interface BaseMigrations {
|
|
59
|
+
readonly ref: string;
|
|
60
|
+
readonly dirs: ReadonlyArray<string>;
|
|
61
|
+
readonly head: Option.Option<SnapshotEntry>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Every migration this branch ADDS must descend from the base's current
|
|
65
|
+
* head. Presence of the base head's snapshot id in this checkout's entries is
|
|
66
|
+
* sufficient: {@link checkDagIntegrity} separately proves the local DAG has a
|
|
67
|
+
* single head from which everything is reachable, so containing the base head
|
|
68
|
+
* means the new tail grew on top of it. A branch that adds no migration
|
|
69
|
+
* directory passes.
|
|
70
|
+
*/
|
|
71
|
+
export declare const checkBaseAncestry: (entries: ReadonlyArray<SnapshotEntry>, base: BaseMigrations) => ReadonlyArray<string>;
|
|
72
|
+
/** The environment {@link resolveBaseRef} reads — the shape of `process.env`. */
|
|
73
|
+
interface BaseRefEnvironment {
|
|
74
|
+
readonly [name: string]: string | undefined;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The base ref for the ancestry check, when one is knowable: an explicit
|
|
78
|
+
* `MIGRATION_BASE_REF` wins (local use: `MIGRATION_BASE_REF=origin/trunk`),
|
|
79
|
+
* else `origin/$GITHUB_BASE_REF` — which GitHub Actions sets only on
|
|
80
|
+
* `pull_request` events, so pushes and local runs skip the check.
|
|
81
|
+
*/
|
|
82
|
+
export declare const resolveBaseRef: (env: BaseRefEnvironment) => Option.Option<string>;
|
|
83
|
+
/** Where the base branch's migrations are read from, through `git`. */
|
|
84
|
+
interface GitBase {
|
|
85
|
+
/** The base ref, e.g. `origin/trunk` — from {@link resolveBaseRef}. */
|
|
86
|
+
readonly ref: string;
|
|
87
|
+
/** The migrations directory relative to the repository root, e.g. `packages/database/migrations`. */
|
|
88
|
+
readonly migrationsPath: string;
|
|
89
|
+
/** The repository checkout to run `git` in. */
|
|
90
|
+
readonly cwd: string;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The base branch's migration state, read with `git ls-tree` / `git show` so
|
|
94
|
+
* it reflects the base's CURRENT tip. Fetches the base first when the
|
|
95
|
+
* checkout lacks it (a shallow pull-request checkout does).
|
|
96
|
+
*/
|
|
97
|
+
export declare const readBaseMigrations: (base: GitBase) => Effect.Effect<BaseMigrations, PlatformError, ChildProcessSpawner>;
|
|
98
|
+
/** The inputs {@link checkMigrationDrift} takes. */
|
|
99
|
+
interface MigrationDriftOptions {
|
|
100
|
+
/** The schema module (`import * as schema from "./schema.ts"`), as drizzle-kit's `generateDrizzleJson` takes it. */
|
|
101
|
+
readonly schema: Record<string, unknown>;
|
|
102
|
+
/** The committed drizzle-kit migrations directory. */
|
|
103
|
+
readonly migrationsDir: string;
|
|
104
|
+
/**
|
|
105
|
+
* The base branch to check ancestry against — `Option.none()` (or absent,
|
|
106
|
+
* or `undefined`) on pushes and local runs, which is what mapping
|
|
107
|
+
* {@link resolveBaseRef} gives outside a pull request.
|
|
108
|
+
*/
|
|
109
|
+
readonly base?: Option.Option<GitBase> | undefined;
|
|
110
|
+
}
|
|
111
|
+
/** What a passing run checked. */
|
|
112
|
+
interface MigrationDriftReport {
|
|
113
|
+
readonly snapshots: number;
|
|
114
|
+
readonly baseRef: Option.Option<string>;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Run the three checks. Succeeds with a {@link MigrationDriftReport};
|
|
118
|
+
* fails with a {@link MigrationDriftError} carrying every issue found.
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* ```ts
|
|
122
|
+
* // An application's `db:migration-drift` task, run with `NodeRuntime.runMain`.
|
|
123
|
+
* import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
124
|
+
* import { checkMigrationDrift, resolveBaseRef } from "@thomasfosterau/effect-drizzle-ext-pg/tooling";
|
|
125
|
+
* import { integer, pgTable } from "drizzle-orm/pg-core";
|
|
126
|
+
* import * as Effect from "effect/Effect";
|
|
127
|
+
* import * as Option from "effect/Option";
|
|
128
|
+
* import * as path from "node:path";
|
|
129
|
+
*
|
|
130
|
+
* const schema = { widget: pgTable("widget", { id: integer().primaryKey() }) };
|
|
131
|
+
* const repoRoot = process.cwd();
|
|
132
|
+
* const base = Option.map(resolveBaseRef(process.env), (ref) => ({
|
|
133
|
+
* ref,
|
|
134
|
+
* migrationsPath: "packages/database/migrations",
|
|
135
|
+
* cwd: repoRoot,
|
|
136
|
+
* }));
|
|
137
|
+
* export const program = checkMigrationDrift({
|
|
138
|
+
* schema,
|
|
139
|
+
* migrationsDir: path.join(repoRoot, "packages/database/migrations"),
|
|
140
|
+
* base,
|
|
141
|
+
* }).pipe(Effect.provide(NodeServices.layer));
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
export declare const checkMigrationDrift: (options: MigrationDriftOptions) => Effect.Effect<MigrationDriftReport, MigrationDriftError | MalformedSnapshot | SchemaDiffError | PlatformError, FileSystem.FileSystem | Path.Path | ChildProcessSpawner>;
|
|
145
|
+
/** One view as drizzle-kit would create it. */
|
|
146
|
+
interface ViewDefinition {
|
|
147
|
+
readonly schema: string;
|
|
148
|
+
readonly name: string;
|
|
149
|
+
readonly sql: string;
|
|
150
|
+
}
|
|
151
|
+
/** A view that selects from another view. */
|
|
152
|
+
interface ViewEdge {
|
|
153
|
+
readonly from: ViewDefinition;
|
|
154
|
+
readonly to: ViewDefinition;
|
|
155
|
+
}
|
|
156
|
+
/** `schema.name`, the identity used in messages and in reference matching. */
|
|
157
|
+
export declare const viewKey: (view: {
|
|
158
|
+
readonly schema: string;
|
|
159
|
+
readonly name: string;
|
|
160
|
+
}) => string;
|
|
161
|
+
/** Every `CREATE VIEW` in a statement list, as {@link ViewDefinition}s. */
|
|
162
|
+
export declare const parseViewDefinitions: (statements: ReadonlyArray<string>) => ReadonlyArray<ViewDefinition>;
|
|
163
|
+
/**
|
|
164
|
+
* Whether `definition` selects from `target`.
|
|
165
|
+
*
|
|
166
|
+
* Matches both spellings a definition can carry: the quoted,
|
|
167
|
+
* schema-qualified form Drizzle's query builder emits (`"app"."work_view"`),
|
|
168
|
+
* and the bare form a hand-written `sql.raw` FROM clause may use
|
|
169
|
+
* (`app.work_view`), guarded so it cannot fire on a longer identifier ending
|
|
170
|
+
* the same way. A derived table aliased with a view's *unqualified* name is
|
|
171
|
+
* deliberately not a match — it names no schema, so references no view.
|
|
172
|
+
* The one blind spot that leaves: a hand-written FROM naming a view
|
|
173
|
+
* unqualified and leaning on `search_path`.
|
|
174
|
+
*/
|
|
175
|
+
export declare const referencesView: (definition: ViewDefinition, target: ViewDefinition) => boolean;
|
|
176
|
+
/** Every view-on-view edge among `views`. Empty is the passing state. */
|
|
177
|
+
export declare const findViewEdges: (views: ReadonlyArray<ViewDefinition>) => ReadonlyArray<ViewEdge>;
|
|
178
|
+
declare const ViewDependencyError_base: Schema.Class<ViewDependencyError, Schema.TaggedStruct<"ViewDependencyError", {
|
|
179
|
+
readonly edges: Schema.$Array<Schema.Struct<{
|
|
180
|
+
readonly from: Schema.String;
|
|
181
|
+
readonly to: Schema.String;
|
|
182
|
+
}>>;
|
|
183
|
+
}>, import("effect/Cause").YieldableError>;
|
|
184
|
+
/** The schema has at least one view-on-view edge. */
|
|
185
|
+
export declare class ViewDependencyError extends ViewDependencyError_base {
|
|
186
|
+
get message(): string;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Generate the whole schema from an empty snapshot — every view exactly as a
|
|
190
|
+
* migration would create it — and assert no definition references another
|
|
191
|
+
* view. Succeeds with the number of views checked.
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```ts
|
|
195
|
+
* // An application's `db:view-dependencies` task passes its own schema module.
|
|
196
|
+
* import { checkViewDependencies } from "@thomasfosterau/effect-drizzle-ext-pg/tooling";
|
|
197
|
+
* import { integer, pgTable, pgView, text } from "drizzle-orm/pg-core";
|
|
198
|
+
* import * as Effect from "effect/Effect";
|
|
199
|
+
*
|
|
200
|
+
* const article = pgTable("article", { id: integer().primaryKey(), title: text().notNull() });
|
|
201
|
+
* const articleTitles = pgView("article_titles").as((qb) =>
|
|
202
|
+
* qb.select({ title: article.title }).from(article),
|
|
203
|
+
* );
|
|
204
|
+
*
|
|
205
|
+
* const { viewCount } = await Effect.runPromise(
|
|
206
|
+
* checkViewDependencies({ schema: { article, articleTitles } }),
|
|
207
|
+
* );
|
|
208
|
+
* console.log(viewCount); // 1
|
|
209
|
+
* ```
|
|
210
|
+
*/
|
|
211
|
+
export declare const checkViewDependencies: (options: {
|
|
212
|
+
readonly schema: Record<string, unknown>;
|
|
213
|
+
}) => Effect.Effect<{
|
|
214
|
+
readonly viewCount: number;
|
|
215
|
+
}, ViewDependencyError | SchemaDiffError>;
|
|
216
|
+
export type { BaseMigrations, BaseRefEnvironment, GitBase, MigrationDriftOptions, MigrationDriftReport, Snapshot, SnapshotEntry, ViewDefinition, ViewEdge };
|
|
217
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../../src/tooling/migration-drift.ts","../../src/tooling/view-dependencies.ts"],"mappings":";;;;;;;;;;;qBA+Da;;UAMI;WACN;WACA,SAAS;;;UAIH;WACN;WACA,UAAU;;;;;;;qBAsCR,0BAA0B;MAIxB;;;;;;;;;qBASF,wBAAwB;MAGtB;;;;;;qBAUF,4BAA4B;MAI1B;;;qBAQF,YAAS,SAAa,cAAc,mBAAiB,cAAc;;;;;;;qBA0EnE,oBAAiB,SAAa,cAAc,mBAAiB;;UAiEzD;WACN;WACA,MAAM;WACN,MAAM,OAAO,OAAO;;;;;;;;;;qBAWlB,oBAAiB,SACnB,cAAc,gBAAc,MAC/B,mBACL;;UAiBc;YAEL;;;;;;;;qBASC,iBAAc,KAAS,uBAAqB,OAAO;;UA6E/C;;WAEN;;WAEA;;WAEA;;;;;;;qBAqCE,qBAAkB,MACvB,YACL,OAAO,OAAO,gBAAgB,eAAe;;UA6G/B;;WAGN,QAAQ;;WAER;;;;;;WAOA,OAAO,OAAO,OAAO;;;UAIf;WACN;WACA,SAAS,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBA+Bd,sBAAmB,SACrB,0BACR,OAAO,OACR,sBACA,sBAAsB,oBAAoB,kBAAkB,eAC5D,WAAW,aAAa,KAAK,OAAO;;UCxkBrB;WACN;WACA;WACA;;;UAIM;WACN,MAAM;WACN,IAAI;;;qBAUF,UAAO;WAAqB;WAAyB;;;qBAIrD,uBAAoB,YACnB,0BACX,cAAc;;;;;;;;;;;;;qBAyBJ,iBAAc,YAAgB,gBAAc,QAAU;;qBAQtD,gBAAa,OAAW,cAAc,oBAAkB,cAAc;;;;;;;;qBAMtE,4BAA4B;MAM1B;;;;;;;;;;;;;;;;;;;;;;;;;qBA6CF,wBAAqB;WACvB,QAAQ;MACf,OAAO;WAAkB;GAAqB,sBAAsB"}
|