@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/src/index.ts ADDED
@@ -0,0 +1,231 @@
1
+ /**
2
+ * @copyright Thomas Foster
3
+ *
4
+ * The `@effect/sql-pg` binding of the driver-agnostic service factory: one
5
+ * call to {@link makePgDatabase} gives an application the `Database` and
6
+ * `CurrentTransaction` services, the `transact` write boundary and the
7
+ * `readHandle` resolver, all typed over its own `relations` graph, plus the
8
+ * `Effect` and `Layer` that build the Drizzle client itself.
9
+ *
10
+ * `drizzle-orm/effect-postgres` already ships the Effect-native Drizzle
11
+ * driver; nothing here re-implements it. What this module adds is the two
12
+ * pieces every consuming application was spelling out by hand:
13
+ *
14
+ * - the `Layer.effect(Database, PgDrizzle.make({ relations }).pipe(provide(
15
+ * DefaultServices)))` stanza, which appeared four times per application
16
+ * (request path, event path, test harness, proxied test harness), and
17
+ * - the choice of `PgClient` construction, which is a real decision rather
18
+ * than a detail — see {@link pgClientFromUrl} and {@link pgClientFromPool}.
19
+ *
20
+ * Deployment topology stays with the consuming application: resolving a
21
+ * connection string from a platform binding, degrading a failed binding to a
22
+ * chainable proxy client, and any proxy wrapper around the concrete client
23
+ * are all things only the application knows about.
24
+ *
25
+ * @module index
26
+ */
27
+
28
+ import * as PgClient from "@effect/sql-pg/PgClient";
29
+ import type * as PgTypes from "@effect/sql-pg/PgTypes";
30
+ import {
31
+ type DatabaseServices,
32
+ makeDatabaseServices,
33
+ } from "@thomasfosterau/effect-drizzle-ext/database";
34
+ import * as PgDrizzle from "drizzle-orm/effect-postgres";
35
+ import type {
36
+ EffectPgDatabase,
37
+ EffectPgQueryResultHKT,
38
+ EffectPgTransaction,
39
+ } from "drizzle-orm/effect-postgres";
40
+ import type { AnyRelations } from "drizzle-orm/relations";
41
+ import type * as Duration from "effect/Duration";
42
+ import * as Effect from "effect/Effect";
43
+ import * as Layer from "effect/Layer";
44
+ import type * as Redacted from "effect/Redacted";
45
+ import type { SqlClient } from "effect/sql/SqlClient";
46
+ import type { SqlError } from "effect/sql/SqlError";
47
+
48
+ /**
49
+ * The Effect-native Drizzle client an application's `Database` service holds,
50
+ * typed over its relations graph.
51
+ *
52
+ * `$client` is the `PgClient` the driver was built against; it is part of the
53
+ * value `drizzle-orm/effect-postgres`'s `make` returns, and is named here so
54
+ * the service payload type is self-contained rather than
55
+ * `Effect.Success<typeof make>` of a local binding.
56
+ */
57
+ export type PgDatabaseClient<Relations extends AnyRelations> = EffectPgDatabase<Relations> & {
58
+ readonly $client: PgClient.PgClient;
59
+ };
60
+
61
+ /**
62
+ * The transaction handle the driver passes to a `db.transaction(...)`
63
+ * callback — the payload of the `CurrentTransaction` service.
64
+ *
65
+ * The query-result HKT is pinned to `EffectPgQueryResultHKT` (the `@effect/
66
+ * sql-pg` one) because that is what `EffectPgDatabase` opens its transactions
67
+ * with; a consumer never writes the parameter.
68
+ */
69
+ export type PgTransaction<Relations extends AnyRelations> = EffectPgTransaction<
70
+ EffectPgQueryResultHKT,
71
+ Relations
72
+ >;
73
+
74
+ /**
75
+ * One application's database services: the driver-agnostic
76
+ * {@link DatabaseServices} surface, plus the two things that are specific to
77
+ * having chosen a driver — the `Effect` that builds the client and the
78
+ * `Layer` that installs it under the `Database` key.
79
+ *
80
+ * Both carry `PgClient` in their requirement channel and nothing else: WHERE
81
+ * the pool comes from is the consuming application's decision, made by
82
+ * providing {@link pgClientFromUrl}, {@link pgClientFromPool}, or a client
83
+ * layer of its own.
84
+ */
85
+ export interface PgDatabase<
86
+ Relations extends AnyRelations,
87
+ Key extends string,
88
+ > extends DatabaseServices<PgDatabaseClient<Relations>, PgTransaction<Relations>, Key> {
89
+ /**
90
+ * Builds the Drizzle client over an ambient `PgClient`.
91
+ *
92
+ * `DefaultServices` supplies the no-op logger and cache the driver
93
+ * requires; an application that wants real ones builds its own client and
94
+ * provides it under {@link DatabaseServices.Database} instead of using
95
+ * {@link layer}.
96
+ */
97
+ readonly make: Effect.Effect<PgDatabaseClient<Relations>, never, PgClient.PgClient>;
98
+
99
+ /**
100
+ * {@link make} installed under the `Database` key — the stanza every
101
+ * consuming application was repeating once per runtime graph.
102
+ *
103
+ * Requires a `PgClient`, which is the seam the deployment topology plugs
104
+ * into: a request-scoped pool over a platform binding in production, a
105
+ * URL-configured pool in a harness.
106
+ */
107
+ readonly layer: Layer.Layer<Key, never, PgClient.PgClient>;
108
+ }
109
+
110
+ /**
111
+ * Builds one application's {@link PgDatabase} from its relations graph and a
112
+ * service key.
113
+ *
114
+ * Call it once, at module scope, and export what it returns; the keys are
115
+ * derived from `key`, so calling it twice with the same key produces services
116
+ * that share a runtime slot.
117
+ *
118
+ * @example
119
+ * ```ts
120
+ * import { makePgDatabase } from "@thomasfosterau/effect-drizzle-ext-pg";
121
+ *
122
+ * const relations = {}; // the application's drizzle relations graph
123
+ * const database = makePgDatabase({ relations, key: "Database" });
124
+ * export const { Database, CurrentTransaction, transact, readHandle } = database;
125
+ * export type Database = typeof database.Database.Identifier; // "Database"
126
+ *
127
+ * console.log(Database.key); // "Database"
128
+ * ```
129
+ */
130
+ export const makePgDatabase = <Relations extends AnyRelations, const Key extends string>(options: {
131
+ readonly relations: Relations;
132
+ readonly key: Key;
133
+ }): PgDatabase<Relations, Key> => {
134
+ const services = makeDatabaseServices<PgDatabaseClient<Relations>, PgTransaction<Relations>>()({
135
+ key: options.key,
136
+ });
137
+
138
+ const make: Effect.Effect<
139
+ PgDatabaseClient<Relations>,
140
+ never,
141
+ PgClient.PgClient
142
+ > = PgDrizzle.make<Relations>({ relations: options.relations }).pipe(
143
+ Effect.provide(PgDrizzle.DefaultServices),
144
+ );
145
+
146
+ return { ...services, make, layer: Layer.effect(services.Database, make) };
147
+ };
148
+
149
+ /**
150
+ * A `PgClient` layer over a connection URL that **pings the database
151
+ * (`SELECT 1`) at layer BUILD**, failing the build with a `SqlError` if the
152
+ * ping does not answer within `connectTimeout`.
153
+ *
154
+ * That is the right trade for a long-lived process — a misconfigured URL
155
+ * fails once, loudly, at startup rather than on the first query — and the
156
+ * wrong one for a runtime that is built per request, where it opens a
157
+ * connection even for requests that never touch the database. Use
158
+ * {@link pgClientFromPool} there.
159
+ *
160
+ * `@effect/sql-pg` 4.0.0-rc.115 replaced the `pg` driver with its own wire
161
+ * protocol, and its `PgClient.layer` no longer pings on its own: the pool
162
+ * opens connections on first use. The ping is therefore this function's, run
163
+ * once over the freshly built client.
164
+ *
165
+ * The output includes `SqlClient` as well as `PgClient` because that is what
166
+ * `PgClient.layerFrom` provides; a consumer that only needs `PgClient` can
167
+ * ignore the extra service.
168
+ */
169
+ /* oxlint-disable effect/prefer-option-over-null -- `config` is handed STRAIGHT to `@effect/sql-pg`'s `PgPoolConfig`, whose optional members are `?: T | undefined`: the driver reads `undefined` as "not supplied" and would treat an `Option` object as a value. Same boundary rule as the Drizzle-facing config in packages/core/src/derive.ts. */
170
+ export const pgClientFromUrl = (config: {
171
+ readonly url: Redacted.Redacted<string>;
172
+ readonly maxConnections?: number | undefined;
173
+ readonly connectTimeout?: Duration.Input | undefined;
174
+ /**
175
+ * A per-client codec registry (`PgTypes.makeRegistry()` plus your
176
+ * `register` calls), forwarded to `@effect/sql-pg`. See the README's
177
+ * "Binary codecs" section for why Drizzle needs one.
178
+ */
179
+ readonly types?: PgTypes.Registry | undefined;
180
+ /**
181
+ * Named prepared statements (`@effect/sql-pg` defaults this to `true`).
182
+ * Set `false` behind a pooler that cannot keep them across queries, such
183
+ * as Cloudflare Hyperdrive.
184
+ */
185
+ readonly prepare?: boolean | undefined;
186
+ }): Layer.Layer<PgClient.PgClient | SqlClient, SqlError> =>
187
+ PgClient.layerFrom(Effect.tap(PgClient.make(config), (client) => client`SELECT 1`));
188
+
189
+ /**
190
+ * A `PgClient` layer over a connection URL that **does not connect at layer
191
+ * build** — the lazy counterpart to {@link pgClientFromUrl}.
192
+ *
193
+ * The pool is `@effect/sql-pg`'s own: it opens connections on first use (up
194
+ * to `maxConnections`) and is shut down, every session released, when the
195
+ * layer's scope closes. A runtime built per request therefore disposes of its
196
+ * pool with that request, and a connection failure surfaces on the first
197
+ * query as a `SqlError` instead of at build. Platforms that pin I/O to the
198
+ * event that created it require exactly this — a pool must never outlive
199
+ * that event (website #455).
200
+ *
201
+ * Until `@effect/sql-pg` 4.0.0-rc.115 this wrapped a bare `pg.Pool` via
202
+ * `PgClient.fromPool`, because `PgClient.layer` pinged at build; rc.115
203
+ * dropped both the `pg` driver and `fromPool`, and its pool is lazy by
204
+ * construction. The name is kept for the contract, not the mechanism.
205
+ */
206
+ /* oxlint-disable effect/prefer-option-over-null -- driver-facing config: every optional member is forwarded to `@effect/sql-pg`'s `PgPoolConfig`, which reads `undefined` as "not supplied". See `pgClientFromUrl`'s block. */
207
+ export const pgClientFromPool = (options: {
208
+ readonly connectionString: Redacted.Redacted<string>;
209
+ readonly maxConnections?: number | undefined;
210
+ readonly connectTimeout?: Duration.Input | undefined;
211
+ /**
212
+ * A per-client codec registry (`PgTypes.makeRegistry()` plus your
213
+ * `register` calls), forwarded to `@effect/sql-pg`. See the README's
214
+ * "Binary codecs" section for why Drizzle needs one.
215
+ */
216
+ readonly types?: PgTypes.Registry | undefined;
217
+ /**
218
+ * Named prepared statements (`@effect/sql-pg` defaults this to `true`).
219
+ * Set `false` behind a pooler that cannot keep them across queries, such
220
+ * as Cloudflare Hyperdrive.
221
+ */
222
+ readonly prepare?: boolean | undefined;
223
+ }): Layer.Layer<PgClient.PgClient | SqlClient, SqlError> =>
224
+ PgClient.layer({
225
+ url: options.connectionString,
226
+ maxConnections: options.maxConnections,
227
+ connectTimeout: options.connectTimeout,
228
+ types: options.types,
229
+ prepare: options.prepare,
230
+ });
231
+ /* oxlint-enable effect/prefer-option-over-null */
@@ -0,0 +1,173 @@
1
+ /**
2
+ * @copyright Thomas Foster
3
+ *
4
+ * **The ordered migration runner for a brand-new database.** Reads a
5
+ * drizzle-kit `migrations/` directory and applies every migration in it, in
6
+ * directory-name order, one transaction per migration, with no ledger.
7
+ *
8
+ * It is what an integration suite's provisioning runs against the database it
9
+ * has just created ({@link ../testing.ts}'s `provisionTestDatabase` calls it by
10
+ * default), and it mirrors the apply order and per-migration transaction
11
+ * shape of alchemy's deploy-time `migrationsDir` runner, minus its ledger:
12
+ * the database is **always** brand new, so every migration applies
13
+ * unconditionally and no bookkeeping table is needed. A database that already
14
+ * holds some of the migrations is not this runner's input — use the
15
+ * deployment's own migrator there.
16
+ *
17
+ * ## The layout it reads
18
+ *
19
+ * drizzle-kit writes one directory per migration, `<digits>_<slug>/`,
20
+ * holding `migration.sql` (and `snapshot.json`, which this runner ignores).
21
+ * The all-digit, fixed-width prefixes make name order chronological. A
22
+ * directory with no `migration.sql` is skipped rather than failing the run —
23
+ * drizzle-kit writes the snapshot before the SQL, and a sanctioned
24
+ * hand-written bootstrap directory may carry only SQL.
25
+ *
26
+ * ## Statements, not files
27
+ *
28
+ * `@effect/sql-pg` speaks the extended query protocol, which rejects more
29
+ * than one statement per query ("cannot insert multiple commands into a
30
+ * prepared statement"). drizzle-kit separates the statements of a migration
31
+ * with a `--> statement-breakpoint` line, and drizzle's own migrator splits
32
+ * on exactly that marker, so this runner does the same: each migration's
33
+ * statements run in order inside that migration's one transaction. A
34
+ * hand-written migration must keep the marker between statements.
35
+ *
36
+ * Idiomatic Effect: the directory is read through the `FileSystem` / `Path`
37
+ * services (a Node consumer provides `@effect/platform-node`'s
38
+ * `NodeServices.layer`), and the statements run on the ambient `SqlClient`.
39
+ *
40
+ * @module migrations
41
+ */
42
+
43
+ import * as Arr from "effect/Array";
44
+ import * as Effect from "effect/Effect";
45
+ import * as FileSystem from "effect/FileSystem";
46
+ import * as Order from "effect/Order";
47
+ import * as Path from "effect/Path";
48
+ import type { PlatformError } from "effect/PlatformError";
49
+ import * as Schema from "effect/Schema";
50
+ import { SqlClient } from "effect/sql/SqlClient";
51
+ import * as Str from "effect/String";
52
+
53
+ /** drizzle-kit's statement separator inside a `migration.sql`. */
54
+ export const STATEMENT_BREAKPOINT = "--> statement-breakpoint";
55
+
56
+ /** One migration as the runner applies it: its directory name and its statements, in order. */
57
+ export interface Migration {
58
+ readonly name: string;
59
+ readonly statements: ReadonlyArray<string>;
60
+ }
61
+
62
+ /**
63
+ * A migration failed to apply. `migration` names the directory — a bare
64
+ * driver error says only which statement broke, not which file it came from
65
+ * — and `cause` is the driver's `SqlError`. The migration's transaction has
66
+ * been rolled back; the ones before it stay applied.
67
+ */
68
+ export class MigrationError extends Schema.TaggedError<MigrationError>()("MigrationError", {
69
+ migration: Schema.String,
70
+ cause: Schema.Defect(),
71
+ }) {
72
+ override get message(): string {
73
+ return `migration '${this.migration}' failed`;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Split one `migration.sql` into its statements on
79
+ * {@link STATEMENT_BREAKPOINT}, dropping the empty chunks a trailing marker
80
+ * or blank file leaves behind.
81
+ */
82
+ export const splitStatements = (sql: string): ReadonlyArray<string> =>
83
+ sql
84
+ .split(STATEMENT_BREAKPOINT)
85
+ .map((statement) => statement.trim())
86
+ .filter(Str.isNonEmpty);
87
+
88
+ /**
89
+ * Every migration under `dir`, in apply order (directory-name order). Only
90
+ * directories count; one with no `migration.sql` is skipped.
91
+ */
92
+ export const readMigrations = (
93
+ dir: string,
94
+ ): Effect.Effect<ReadonlyArray<Migration>, PlatformError, FileSystem.FileSystem | Path.Path> =>
95
+ Effect.gen(function* () {
96
+ const fs = yield* FileSystem.FileSystem;
97
+ const path = yield* Path.Path;
98
+ const names = Arr.sort(yield* fs.readDirectory(dir), Order.String);
99
+ const read = yield* Effect.forEach(
100
+ names,
101
+ (name) =>
102
+ Effect.gen(function* () {
103
+ const info = yield* fs.stat(path.join(dir, name));
104
+ if (info.type !== "Directory") return [];
105
+ const file = path.join(dir, name, "migration.sql");
106
+ if (!(yield* fs.exists(file))) return [];
107
+ const sql = yield* fs.readFileString(file);
108
+ return [{ name, statements: splitStatements(sql) }];
109
+ }),
110
+ { concurrency: 1 },
111
+ );
112
+ return read.flat();
113
+ });
114
+
115
+ /**
116
+ * Apply `migrations` in order on the ambient `SqlClient`, each inside its own
117
+ * transaction, returning how many ran. Stops at the first failure with a
118
+ * {@link MigrationError} naming the migration.
119
+ */
120
+ export const applyMigrationList = (
121
+ migrations: ReadonlyArray<Migration>,
122
+ ): Effect.Effect<number, MigrationError, SqlClient> =>
123
+ Effect.gen(function* () {
124
+ const sql = yield* SqlClient;
125
+ yield* Effect.forEach(
126
+ migrations,
127
+ (migration) =>
128
+ sql
129
+ .withTransaction(
130
+ // In order, one at a time: a migration's statements depend on each other.
131
+ Effect.forEach(migration.statements, (statement) => sql.unsafe(statement), {
132
+ concurrency: 1,
133
+ discard: true,
134
+ }),
135
+ )
136
+ .pipe(
137
+ Effect.mapError((cause) => new MigrationError({ migration: migration.name, cause })),
138
+ ),
139
+ // Strictly sequential: a later migration may depend on an earlier one.
140
+ { concurrency: 1, discard: true },
141
+ );
142
+ return migrations.length;
143
+ });
144
+
145
+ /**
146
+ * Apply every migration under `dir` to the (brand-new) database behind the
147
+ * ambient `SqlClient` — {@link readMigrations} then {@link applyMigrationList}
148
+ * — returning how many ran.
149
+ *
150
+ * @example
151
+ * ```ts
152
+ * import * as NodeServices from "@effect/platform-node/NodeServices";
153
+ * import * as PgClient from "@effect/sql-pg/PgClient";
154
+ * import { applyMigrations } from "@thomasfosterau/effect-drizzle-ext-pg/migrations";
155
+ * import * as Effect from "effect/Effect";
156
+ * import * as Redacted from "effect/Redacted";
157
+ *
158
+ * const url = Redacted.make("postgres://postgres@localhost:5432/app_test");
159
+ *
160
+ * // Building the Effect connects to nothing; running it applies the chain.
161
+ * export const applied = applyMigrations("packages/database/migrations").pipe(
162
+ * Effect.provide(PgClient.layer({ url, maxConnections: 1 })),
163
+ * Effect.provide(NodeServices.layer),
164
+ * );
165
+ * ```
166
+ */
167
+ export const applyMigrations = (
168
+ dir: string,
169
+ ): Effect.Effect<
170
+ number,
171
+ MigrationError | PlatformError,
172
+ SqlClient | FileSystem.FileSystem | Path.Path
173
+ > => Effect.flatMap(readMigrations(dir), applyMigrationList);