@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.
- 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 +83 -3
- 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/src/testing.ts
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Thomas Foster
|
|
3
|
+
*
|
|
4
|
+
* The integration-test transaction runner: every spec runs inside a real
|
|
5
|
+
* database transaction that is ALWAYS rolled back, so a suite needs no
|
|
6
|
+
* truncation, no fixture reset and no ordering discipline.
|
|
7
|
+
*
|
|
8
|
+
* The mechanics are the ones the consuming applications carry today, with
|
|
9
|
+
* three things removed because they are the caller's, not the library's:
|
|
10
|
+
* the viewer services a spec layers on top, the proxy-client variants an
|
|
11
|
+
* application's own deployment shape needs, and the resolution of the test
|
|
12
|
+
* database URL (the caller passes it — see {@link testDatabaseLayer}).
|
|
13
|
+
*
|
|
14
|
+
* The shape is:
|
|
15
|
+
*
|
|
16
|
+
* 1. `db.transaction(...)` opens an OUTER transaction whose only job is the
|
|
17
|
+
* always-rollback, and whose handle is discarded.
|
|
18
|
+
* 2. The spec's effect is wrapped in the application's own `transact`, so the
|
|
19
|
+
* spec opens its own transaction exactly as a request handler does in
|
|
20
|
+
* production (website #1883) — a SAVEPOINT on the outer one. Binding
|
|
21
|
+
* `CurrentTransaction` from the harness instead would work and touch far
|
|
22
|
+
* fewer lines, but it would make the specs the one place in the system
|
|
23
|
+
* where a write runs without anything having called `transact` — precisely
|
|
24
|
+
* the code path they exist to exercise.
|
|
25
|
+
* 3. The inner `Exit` is captured, then a sentinel failure forces the outer
|
|
26
|
+
* transaction to roll back.
|
|
27
|
+
* 4. The captured `Exit` is projected back to a Promise (success resolves,
|
|
28
|
+
* failure throws the underlying cause) so ordinary
|
|
29
|
+
* `await expect(...).rejects` assertions keep working.
|
|
30
|
+
*
|
|
31
|
+
* The library owns no schema, so a spec creates the `TEMPORARY` tables it
|
|
32
|
+
* needs inside that transaction: DDL is transactional in PostgreSQL, so the
|
|
33
|
+
* always-rollback disposes of them with everything else.
|
|
34
|
+
*
|
|
35
|
+
* The database the runner connects to is provisioned by
|
|
36
|
+
* {@link provisionTestDatabase} (defined in `./provisioning.ts`, re-exported
|
|
37
|
+
* here): an explicit URL, else a fresh migrated database on a reachable
|
|
38
|
+
* local server, else a Testcontainers container.
|
|
39
|
+
*
|
|
40
|
+
* @module testing
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import type { AnyRelations } from "drizzle-orm/relations";
|
|
44
|
+
import * as Arr from "effect/Array";
|
|
45
|
+
import * as Cause from "effect/Cause";
|
|
46
|
+
import * as Duration from "effect/Duration";
|
|
47
|
+
import * as Effect from "effect/Effect";
|
|
48
|
+
import * as Exit from "effect/Exit";
|
|
49
|
+
import * as HashSet from "effect/HashSet";
|
|
50
|
+
import * as Layer from "effect/Layer";
|
|
51
|
+
import type * as ManagedRuntime from "effect/ManagedRuntime";
|
|
52
|
+
import * as Predicate from "effect/Predicate";
|
|
53
|
+
import type * as Redacted from "effect/Redacted";
|
|
54
|
+
import * as Record from "effect/Record";
|
|
55
|
+
import * as Schedule from "effect/Schedule";
|
|
56
|
+
import * as Schema from "effect/Schema";
|
|
57
|
+
import { isSqlError, type SqlError } from "effect/sql/SqlError";
|
|
58
|
+
import { type PgDatabase, pgClientFromUrl } from "./index.ts";
|
|
59
|
+
|
|
60
|
+
export {
|
|
61
|
+
connectionUrl,
|
|
62
|
+
ContainerStartFailed,
|
|
63
|
+
type DatabaseSource,
|
|
64
|
+
type DatabaseSourceOptions,
|
|
65
|
+
DatabaseUnreachable,
|
|
66
|
+
InvalidDatabasePrefix,
|
|
67
|
+
type MigrationRunner,
|
|
68
|
+
probeServer,
|
|
69
|
+
type ProvisionedDatabase,
|
|
70
|
+
type ProvisionOptions,
|
|
71
|
+
provisionTestDatabase,
|
|
72
|
+
type ResolutionEnvironment,
|
|
73
|
+
resolveDatabaseSource,
|
|
74
|
+
type ServerProbe,
|
|
75
|
+
ServerUnreachable,
|
|
76
|
+
testDatabaseName,
|
|
77
|
+
} from "./provisioning.ts";
|
|
78
|
+
|
|
79
|
+
// ── Connect-seam retry classification ─────────────────────────────────
|
|
80
|
+
//
|
|
81
|
+
// {@link runInTestTransaction} retries the *open* of a per-test transaction
|
|
82
|
+
// when it fails at the connect/acquire seam under fork-pool startup
|
|
83
|
+
// contention, but never re-runs test logic. Which `SqlError`s count as a
|
|
84
|
+
// retryable connect failure is decided here.
|
|
85
|
+
//
|
|
86
|
+
// ## Why `isRetryable` alone is not enough (website #1055)
|
|
87
|
+
//
|
|
88
|
+
// The observed CI flake surfaces as
|
|
89
|
+
// `SqlError: Failed to acquire connection for transaction (cause: … timeout
|
|
90
|
+
// exceeded when trying to connect)` and, on immediate re-runs, bursts of
|
|
91
|
+
// `read ECONNRESET`. Both are raised by `@effect/sql-pg`'s pool acquirer
|
|
92
|
+
// (`reserveRaw`) and passed through its `classifyError`, which only maps a
|
|
93
|
+
// cause to the **retryable** `ConnectionError` when the cause carries a
|
|
94
|
+
// PostgreSQL SQLSTATE in the `08` class. node-pg's pool-acquisition timeout
|
|
95
|
+
// is a plain `Error` with **no** `.code`, and a socket `ECONNRESET` carries a
|
|
96
|
+
// Node `code` (`"ECONNRESET"`) that is not an `08xxx` SQLSTATE — so both are
|
|
97
|
+
// bucketed as a **non-retryable** `UnknownError` (`isRetryable === false`). A
|
|
98
|
+
// guard that only trusted `isRetryable` therefore never fired for the exact
|
|
99
|
+
// failure it was built to absorb.
|
|
100
|
+
//
|
|
101
|
+
// These failures still originate at the connect/acquire seam — before the
|
|
102
|
+
// transaction body opens — so they are safe to retry when composed (at the
|
|
103
|
+
// call site) with the `captured === null` guard that proves no test work has
|
|
104
|
+
// run. We identify the seam by the `operation` label `@effect/sql-pg` stamps
|
|
105
|
+
// on the reason (`"acquireConnection"` for the pool `reserveRaw` path,
|
|
106
|
+
// `"connect"` for client/pool creation), which is present regardless of how
|
|
107
|
+
// the underlying cause classified.
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The `operation` labels `@effect/sql-pg` stamps on a connect/acquire-seam
|
|
111
|
+
* `SqlError` reason. A failure carrying one of these happened while opening a
|
|
112
|
+
* connection, i.e. before any transaction body ran.
|
|
113
|
+
*/
|
|
114
|
+
const CONNECT_SEAM_OPERATIONS = HashSet.make("acquireConnection", "connect");
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether `error` is a `SqlError` that failed at the connect/acquire seam and
|
|
118
|
+
* so is safe to retry (when the caller has also confirmed no test body ran).
|
|
119
|
+
*
|
|
120
|
+
* Returns `true` for:
|
|
121
|
+
* - any reason the driver already flags `isRetryable` (`ConnectionError` from
|
|
122
|
+
* an `08xxx` SQLSTATE, `DeadlockError`, `SerializationError`, the timeout
|
|
123
|
+
* reasons), and
|
|
124
|
+
* - a reason whose `operation` is a connect/acquire seam label, which catches
|
|
125
|
+
* the misclassified `UnknownError` the website #1055 flake surfaces as
|
|
126
|
+
* (pool "timeout exceeded when trying to connect" / `ECONNRESET`, neither
|
|
127
|
+
* of which carries an `08` SQLSTATE).
|
|
128
|
+
*
|
|
129
|
+
* Returns `false` for a `UniqueViolation` / `ConstraintError` /
|
|
130
|
+
* `SqlSyntaxError` raised by a test's own query (`operation === "execute"`),
|
|
131
|
+
* so genuine test failures are never retried.
|
|
132
|
+
*/
|
|
133
|
+
// oxlint-disable-next-line effect/no-unknown-parameters -- published signature: the predicate classifies whatever failure value a retry's `while` hands it, and `isSqlError` is the parse; narrowing the parameter would break callers
|
|
134
|
+
export const isRetryableConnectFailure = (error: unknown): boolean => {
|
|
135
|
+
if (!isSqlError(error)) return false;
|
|
136
|
+
if (error.isRetryable) return true;
|
|
137
|
+
const operation = error.reason.operation;
|
|
138
|
+
return operation !== undefined && HashSet.has(CONNECT_SEAM_OPERATIONS, operation);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* `maxConnections` is pinned to 1 because a fork runs its tests strictly
|
|
143
|
+
* sequentially and the runner holds exactly one transaction-bound connection
|
|
144
|
+
* at a time (the whole test body runs inside a single `db.transaction`, with
|
|
145
|
+
* the spec's own `transact` nesting a SAVEPOINT on that same connection) — so
|
|
146
|
+
* 1 is the real per-fork demand, not a compromise. It is also what forces an
|
|
147
|
+
* operation-opened transaction to nest as a SAVEPOINT on the same connection
|
|
148
|
+
* (and so see the outer transaction's uncommitted writes) rather than
|
|
149
|
+
* silently acquiring a second connection that cannot.
|
|
150
|
+
*/
|
|
151
|
+
const TEST_POOL_MAX_CONNECTIONS = 1;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* How long a single connection attempt may take before the driver gives up.
|
|
155
|
+
*
|
|
156
|
+
* Under fork parallelism all forks race to open their connection at once
|
|
157
|
+
* while each imports the module graph, so at startup the connect handshakes
|
|
158
|
+
* contend. A generous per-attempt timeout costs nothing on the happy path (a
|
|
159
|
+
* healthy connect resolves in milliseconds and never approaches this
|
|
160
|
+
* ceiling), so it stays as cheap insurance for slow CI service-container
|
|
161
|
+
* startup.
|
|
162
|
+
*/
|
|
163
|
+
const TEST_CONNECT_TIMEOUT = Duration.seconds(30);
|
|
164
|
+
|
|
165
|
+
/** Sentinel cause used to force the outer test transaction to roll back. */
|
|
166
|
+
const ROLLBACK_TAG = "__effect_test_rollback__";
|
|
167
|
+
|
|
168
|
+
interface RollbackSignal {
|
|
169
|
+
readonly _tag: typeof ROLLBACK_TAG;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const rollback: RollbackSignal = { _tag: ROLLBACK_TAG };
|
|
173
|
+
|
|
174
|
+
const isRollback = (u: unknown): u is RollbackSignal =>
|
|
175
|
+
Predicate.isObjectOrArray(u) && Predicate.isTagged(u, ROLLBACK_TAG);
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Whether a captured failure cause carries the rollback sentinel.
|
|
179
|
+
*
|
|
180
|
+
* Effect causes are nested objects; this is a structural walk that finds the
|
|
181
|
+
* rollback tag without depending on `Cause` internals.
|
|
182
|
+
*/
|
|
183
|
+
const causeContainsRollback = (cause: unknown): boolean => {
|
|
184
|
+
// oxlint-disable-next-line effect/avoid-native-object-helpers -- the visited set must compare by REFERENCE (cycle detection over an opaque graph); `HashSet` compares by `Equal`, under which distinct but structurally equal `Cause` nodes collide
|
|
185
|
+
const seen = new Set<unknown>();
|
|
186
|
+
const stack: Array<unknown> = [cause];
|
|
187
|
+
// oxlint-disable-next-line effect/imperative-loops -- an explicit stack walk over an opaque object graph; the ported mechanism, unchanged.
|
|
188
|
+
while (Arr.isReadonlyArrayNonEmpty(stack)) {
|
|
189
|
+
const node = stack.pop();
|
|
190
|
+
if (!Predicate.isObjectOrArray(node) || seen.has(node)) continue;
|
|
191
|
+
seen.add(node);
|
|
192
|
+
if (isRollback(node)) return true;
|
|
193
|
+
Arr.forEach(Array.isArray(node) ? node : Record.values(node), (value) => {
|
|
194
|
+
stack.push(value);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// ── Connect-time retry policy ─────────────────────────────────────────
|
|
201
|
+
//
|
|
202
|
+
// See the connect-seam classification above for the flake this guards
|
|
203
|
+
// against and why `isRetryable` alone doesn't catch it (website #1055). The
|
|
204
|
+
// attempt count and backoff floor are tunable here. Five retries over an
|
|
205
|
+
// exponential, jittered backoff (≈0.25s, 0.5s, 1s, 2s, 4s, with jitter to
|
|
206
|
+
// de-correlate the parallel forks) comfortably outlasts a transient
|
|
207
|
+
// saturation spike without meaningfully slowing a healthy run (which never
|
|
208
|
+
// retries — the schedule only fires for a connect-seam `SqlError` that failed
|
|
209
|
+
// before any test body ran; see the `while` guard at the call site).
|
|
210
|
+
const CONNECT_RETRY_ATTEMPTS = 5;
|
|
211
|
+
const CONNECT_RETRY_BASE_DELAY = Duration.millis(250);
|
|
212
|
+
|
|
213
|
+
const connectRetrySchedule = Schedule.max([
|
|
214
|
+
Schedule.exponential(CONNECT_RETRY_BASE_DELAY),
|
|
215
|
+
Schedule.recurs(CONNECT_RETRY_ATTEMPTS),
|
|
216
|
+
]).pipe(Schedule.jittered);
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A failure of the runner itself rather than of the spec it ran: the outer
|
|
220
|
+
* transaction failed before the spec's `Exit` was captured, or failed with
|
|
221
|
+
* something other than the rollback sentinel. Thrown (rejecting the runner's
|
|
222
|
+
* Promise), never put in an Effect error channel — the runner is the test
|
|
223
|
+
* harness's boundary.
|
|
224
|
+
*/
|
|
225
|
+
class TestTransactionError extends Schema.TaggedError<TestTransactionError>()(
|
|
226
|
+
"TestTransactionError",
|
|
227
|
+
{ message: Schema.String },
|
|
228
|
+
) {}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The always-rollback test transaction runner for one application's
|
|
232
|
+
* {@link PgDatabase}, built by {@link makeTestTransaction}.
|
|
233
|
+
*
|
|
234
|
+
* Only the service KEY matters to these signatures — the handle types are
|
|
235
|
+
* closed over by the factory — so the interface is parameterised by the key
|
|
236
|
+
* string alone.
|
|
237
|
+
*/
|
|
238
|
+
export interface TestTransaction<Key extends string> {
|
|
239
|
+
/**
|
|
240
|
+
* Runs `effect` inside a transaction that is always rolled back once the
|
|
241
|
+
* test completes, against a caller-supplied runtime.
|
|
242
|
+
*
|
|
243
|
+
* `R` is the runtime's own context. It **must include the `Database`
|
|
244
|
+
* key**, which is what lets the program below yield the client; that
|
|
245
|
+
* relationship is not expressible as a generic constraint once `R` can be a
|
|
246
|
+
* union that also carries unrelated services (a harness's platform binding
|
|
247
|
+
* stubs, a spec's viewer services), so it is documented here rather than
|
|
248
|
+
* enforced by the type checker — the same trade-off the ported wrapper
|
|
249
|
+
* makes.
|
|
250
|
+
*
|
|
251
|
+
* `${Key}/CurrentTransaction` is in the ACCEPTED requirement, not in the
|
|
252
|
+
* runtime's catalogue: nothing provides it as a layer. The runner wraps the
|
|
253
|
+
* effect in `transact`, so the spec stands in for the handler that opens
|
|
254
|
+
* the boundary in production, and the requirement is discharged there.
|
|
255
|
+
*/
|
|
256
|
+
readonly runInTestTransaction: <R, A, E>(
|
|
257
|
+
runtime: ManagedRuntime.ManagedRuntime<R, SqlError>,
|
|
258
|
+
effect: Effect.Effect<A, E, R | `${Key}/CurrentTransaction`>,
|
|
259
|
+
) => Promise<A>;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* {@link runInTestTransaction} with the runtime bound once — the form a
|
|
263
|
+
* suite exports as its own `withTestTransaction(effect)` helper.
|
|
264
|
+
*/
|
|
265
|
+
readonly withTestTransaction: <R>(
|
|
266
|
+
runtime: ManagedRuntime.ManagedRuntime<R, SqlError>,
|
|
267
|
+
) => <A, E>(effect: Effect.Effect<A, E, R | `${Key}/CurrentTransaction`>) => Promise<A>;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Run `effect` inside a **SAVEPOINT** and hand back its `Exit`, leaving the
|
|
271
|
+
* enclosing transaction usable whether it succeeded or failed.
|
|
272
|
+
*
|
|
273
|
+
* This is what a spec asserting "the constraint rejects this write" needs.
|
|
274
|
+
* A statement that raises in PostgreSQL aborts the whole transaction, so a
|
|
275
|
+
* bare `Effect.exit(tx.insert(…))` captures the expected failure and then
|
|
276
|
+
* poisons every later statement in the test with `current transaction is
|
|
277
|
+
* aborted, commands ignored until end of transaction block` — which
|
|
278
|
+
* surfaces as the *next* query failing, several assertions away from the
|
|
279
|
+
* cause. Nesting the attempt (`tx.transaction`, a SAVEPOINT on the same
|
|
280
|
+
* pinned connection) confines the abort to the savepoint: the driver rolls
|
|
281
|
+
* back to it and the outer transaction, with every fixture the test has
|
|
282
|
+
* already written, carries on.
|
|
283
|
+
*
|
|
284
|
+
* `effect` keeps using the ambient `CurrentTransaction` handle — the
|
|
285
|
+
* savepoint is a property of the CONNECTION, which `maxConnections: 1`
|
|
286
|
+
* pins, so nothing has to be re-threaded through a nested handle.
|
|
287
|
+
*
|
|
288
|
+
* The `Exit` is returned rather than asserted on, so the caller keeps the
|
|
289
|
+
* choice: `expect(exit._tag).toBe("Failure")` for a rejection, or a look at
|
|
290
|
+
* the cause when the message matters.
|
|
291
|
+
*/
|
|
292
|
+
readonly attemptInSavepoint: <A, E, R>(
|
|
293
|
+
effect: Effect.Effect<A, E, R>,
|
|
294
|
+
) => Effect.Effect<Exit.Exit<A, E | SqlError>, never, R | `${Key}/CurrentTransaction`>;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Builds the always-rollback test transaction runner for one application's
|
|
299
|
+
* database services.
|
|
300
|
+
*
|
|
301
|
+
* The services carry the key strings and the `transact` boundary, so the
|
|
302
|
+
* runner needs nothing else: a suite calls this once next to its
|
|
303
|
+
* {@link makePgDatabase} call and exports the three helpers.
|
|
304
|
+
*/
|
|
305
|
+
export const makeTestTransaction = <Relations extends AnyRelations, Key extends string>(
|
|
306
|
+
database: PgDatabase<Relations, Key>,
|
|
307
|
+
): TestTransaction<Key> => {
|
|
308
|
+
const runInTestTransaction = async <R, A, E>(
|
|
309
|
+
runtime: ManagedRuntime.ManagedRuntime<R, SqlError>,
|
|
310
|
+
effect: Effect.Effect<A, E, R | `${Key}/CurrentTransaction`>,
|
|
311
|
+
): Promise<A> => {
|
|
312
|
+
// oxlint-disable-next-line effect/prefer-option-over-null -- write-once slot assigned inside the nested `Effect.gen` closure; the `null` sentinel is what the "inner Exit was never captured" check below tests, and TS's control-flow analysis (see the note at the read site) already treats this declaration specially
|
|
313
|
+
let captured: Exit.Exit<A, E | SqlError> | null = null;
|
|
314
|
+
|
|
315
|
+
const program = Effect.gen(function* () {
|
|
316
|
+
const db = yield* database.Database;
|
|
317
|
+
return yield* db.transaction(() =>
|
|
318
|
+
Effect.gen(function* () {
|
|
319
|
+
captured = yield* Effect.exit(database.transact(effect));
|
|
320
|
+
return yield* Effect.fail(rollback);
|
|
321
|
+
}),
|
|
322
|
+
);
|
|
323
|
+
}).pipe(
|
|
324
|
+
// Retry ONLY a connect-time transient failure — never test logic.
|
|
325
|
+
//
|
|
326
|
+
// Two guards keep this honest:
|
|
327
|
+
// 1. `isRetryableConnectFailure(e)` — fires for a `SqlError` that
|
|
328
|
+
// failed at the connect/acquire seam (the retryable
|
|
329
|
+
// `ConnectionError`, plus the misclassified `UnknownError` the
|
|
330
|
+
// website #1055 acquisition-timeout / `ECONNRESET` flake surfaces
|
|
331
|
+
// as) but NOT for a `UniqueViolation` / `ConstraintError` /
|
|
332
|
+
// `SqlSyntaxError` raised by a test's own query. The rollback
|
|
333
|
+
// sentinel that the body always forces is a plain object, not a
|
|
334
|
+
// `SqlError`, so a *successful* test and a *genuinely failing* test
|
|
335
|
+
// (whose Exit is captured before the rollback) both fail the
|
|
336
|
+
// program with `rollback` and never retry.
|
|
337
|
+
// 2. `captured === null` — the inner test body never ran. A
|
|
338
|
+
// connect-seam `SqlError` can in principle surface *after* the body
|
|
339
|
+
// has executed and been captured (e.g. the connection drops on the
|
|
340
|
+
// rollback raise); re-running then would double-execute the test's
|
|
341
|
+
// side effects, so we refuse to retry once any test work has
|
|
342
|
+
// happened. The connect flake by construction fails before the
|
|
343
|
+
// transaction body opens, so `captured` is still null then —
|
|
344
|
+
// exactly the case we retry.
|
|
345
|
+
Effect.retry({
|
|
346
|
+
schedule: connectRetrySchedule,
|
|
347
|
+
while: (e) => captured === null && isRetryableConnectFailure(e),
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const outer = await runtime.runPromiseExit(
|
|
352
|
+
// SAFETY: the only requirement `program` adds beyond `R` is the
|
|
353
|
+
// `Database` key it yields, and `R` includes that key — the documented
|
|
354
|
+
// contract of `runInTestTransaction` (see `TestTransaction`), which a
|
|
355
|
+
// generic constraint cannot express.
|
|
356
|
+
program as Effect.Effect<Effect.Success<typeof program>, Effect.Error<typeof program>, R>,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// Snapshot to a local const: `captured` is only ever reassigned inside the
|
|
360
|
+
// nested `Effect.gen` closure above, so TS's control-flow analysis doesn't
|
|
361
|
+
// track that write here — it narrows `captured` as if still `null` (its
|
|
362
|
+
// only textually-direct assignment), collapsing every read below to
|
|
363
|
+
// `never`. The annotation restates the variable's real declared type,
|
|
364
|
+
// which the runtime assignment inside the closure does in fact produce.
|
|
365
|
+
// SAFETY: restates `captured`'s declared type verbatim, undoing only the
|
|
366
|
+
// control-flow narrowing described above.
|
|
367
|
+
// oxlint-disable-next-line effect/prefer-option-over-null -- restates `captured`'s declared type verbatim; see the comment above
|
|
368
|
+
const innerExit = captured as Exit.Exit<A, E | SqlError> | null;
|
|
369
|
+
if (!innerExit) {
|
|
370
|
+
if (Exit.isFailure(outer)) {
|
|
371
|
+
throw new TestTransactionError({
|
|
372
|
+
message: `runInTestTransaction: outer transaction failed: ${String(outer.cause)}`,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
throw new TestTransactionError({
|
|
376
|
+
message: "runInTestTransaction: inner Exit was never captured",
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// The outer Exit should be the rollback failure we forced. Anything
|
|
381
|
+
// else is a transaction-level failure we didn't expect (e.g. the
|
|
382
|
+
// connection dropped between the inner result and the rollback raise).
|
|
383
|
+
if (Exit.isFailure(outer) && !causeContainsRollback(outer.cause)) {
|
|
384
|
+
throw new TestTransactionError({
|
|
385
|
+
message: `runInTestTransaction: unexpected outer failure: ${String(outer.cause)}`,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (Exit.isSuccess(innerExit)) return innerExit.value;
|
|
390
|
+
// Project the captured `Cause` to something a test can assert on.
|
|
391
|
+
// Throwing the raw `Cause` makes vitest serialise it as `{ _tag: 'Die',
|
|
392
|
+
// defect: [Object] }` with an empty `.message`, so `.rejects.toThrow(/…/)`
|
|
393
|
+
// never matches and a defect's real error is invisible. `Cause.squash`
|
|
394
|
+
// collapses the cause to its underlying value — a typed `Fail`'s error
|
|
395
|
+
// (keeps `_tag` + message) or a `Die`'s thrown defect (a real `Error` with
|
|
396
|
+
// a message + stack) — so the actual failure surfaces in vitest output
|
|
397
|
+
// and `.rejects.toThrow(/…/)` matches.
|
|
398
|
+
throw Cause.squash(innerExit.cause);
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
runInTestTransaction,
|
|
403
|
+
withTestTransaction:
|
|
404
|
+
<R>(runtime: ManagedRuntime.ManagedRuntime<R, SqlError>) =>
|
|
405
|
+
<A, E>(effect: Effect.Effect<A, E, R | `${Key}/CurrentTransaction`>): Promise<A> =>
|
|
406
|
+
runInTestTransaction(runtime, effect),
|
|
407
|
+
attemptInSavepoint: <A, E, R>(
|
|
408
|
+
effect: Effect.Effect<A, E, R>,
|
|
409
|
+
): Effect.Effect<Exit.Exit<A, E | SqlError>, never, R | `${Key}/CurrentTransaction`> =>
|
|
410
|
+
Effect.gen(function* () {
|
|
411
|
+
const tx = yield* database.CurrentTransaction;
|
|
412
|
+
return yield* Effect.exit(tx.transaction(() => effect));
|
|
413
|
+
}),
|
|
414
|
+
};
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The `Database` layer an integration suite runs against: the application's
|
|
419
|
+
* own {@link PgDatabase.layer} over a `PgClient` built from `url`, with the
|
|
420
|
+
* pool pinned to one connection (see {@link TEST_POOL_MAX_CONNECTIONS}).
|
|
421
|
+
*
|
|
422
|
+
* Built once per process and reused by every spec in it. `url` is the
|
|
423
|
+
* caller's: this library does not read `DATABASE_URL_TEST` or decide how a
|
|
424
|
+
* test database is provisioned.
|
|
425
|
+
*
|
|
426
|
+
* Unlike production, this uses the pinging {@link pgClientFromUrl} on
|
|
427
|
+
* purpose — a suite that cannot reach its database should fail at layer build
|
|
428
|
+
* with one legible `SqlError`, not once per spec on the first query.
|
|
429
|
+
*/
|
|
430
|
+
/* oxlint-disable effect/prefer-option-over-null -- driver-facing config: `connectTimeout` is forwarded verbatim to `pgClientFromUrl` and from there to `@effect/sql-pg`'s `PgPoolConfig`, which reads `undefined` as "not supplied". See `pgClientFromUrl`'s block in ./index.ts. */
|
|
431
|
+
export const testDatabaseLayer = <Relations extends AnyRelations, Key extends string>(
|
|
432
|
+
database: PgDatabase<Relations, Key>,
|
|
433
|
+
options: {
|
|
434
|
+
readonly url: Redacted.Redacted<string>;
|
|
435
|
+
readonly connectTimeout?: Duration.Input | undefined;
|
|
436
|
+
},
|
|
437
|
+
): Layer.Layer<Key, SqlError> =>
|
|
438
|
+
database.layer.pipe(
|
|
439
|
+
Layer.provide(
|
|
440
|
+
pgClientFromUrl({
|
|
441
|
+
url: options.url,
|
|
442
|
+
maxConnections: TEST_POOL_MAX_CONNECTIONS,
|
|
443
|
+
connectTimeout: options.connectTimeout ?? TEST_CONNECT_TIMEOUT,
|
|
444
|
+
}),
|
|
445
|
+
),
|
|
446
|
+
);
|
|
447
|
+
/* oxlint-enable effect/prefer-option-over-null */
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Thomas Foster
|
|
3
|
+
*
|
|
4
|
+
* **Schema and migration guards for a drizzle-kit `migrations/` directory**,
|
|
5
|
+
* as bin-less Effects an application's own `db:*` tasks run with its schema
|
|
6
|
+
* module: {@link checkMigrationDrift} (DAG integrity, zero drift against the
|
|
7
|
+
* latest snapshot, base ancestry) and {@link checkViewDependencies} (no view
|
|
8
|
+
* selects from another view). Neither needs a database.
|
|
9
|
+
*
|
|
10
|
+
* `drizzle-kit` is an optional peer that only this subpath imports
|
|
11
|
+
* (`drizzle-kit/api-postgres`). The file-system and `git` access go through
|
|
12
|
+
* the `FileSystem` / `Path` / `ChildProcessSpawner` services — a Node script
|
|
13
|
+
* provides `@effect/platform-node`'s `NodeServices.layer`.
|
|
14
|
+
*
|
|
15
|
+
* @module tooling
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
type BaseMigrations,
|
|
20
|
+
type BaseRefEnvironment,
|
|
21
|
+
checkBaseAncestry,
|
|
22
|
+
checkDagIntegrity,
|
|
23
|
+
checkMigrationDrift,
|
|
24
|
+
findHeads,
|
|
25
|
+
type GitBase,
|
|
26
|
+
MalformedSnapshot,
|
|
27
|
+
MigrationDriftError,
|
|
28
|
+
type MigrationDriftOptions,
|
|
29
|
+
type MigrationDriftReport,
|
|
30
|
+
readBaseMigrations,
|
|
31
|
+
resolveBaseRef,
|
|
32
|
+
ROOT_SENTINEL_ID,
|
|
33
|
+
SchemaDiffError,
|
|
34
|
+
type Snapshot,
|
|
35
|
+
type SnapshotEntry,
|
|
36
|
+
} from "./migration-drift.ts";
|
|
37
|
+
export {
|
|
38
|
+
checkViewDependencies,
|
|
39
|
+
findViewEdges,
|
|
40
|
+
parseViewDefinitions,
|
|
41
|
+
referencesView,
|
|
42
|
+
type ViewDefinition,
|
|
43
|
+
ViewDependencyError,
|
|
44
|
+
type ViewEdge,
|
|
45
|
+
viewKey,
|
|
46
|
+
} from "./view-dependencies.ts";
|