@thomasfosterau/effect-drizzle-ext 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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +508 -2
  3. package/dist/columns.d.ts +113 -0
  4. package/dist/columns.d.ts.map +1 -0
  5. package/dist/columns.js +146 -0
  6. package/dist/columns.js.map +1 -0
  7. package/dist/constraints.d.ts +117 -0
  8. package/dist/constraints.d.ts.map +1 -0
  9. package/dist/constraints.js +93 -0
  10. package/dist/constraints.js.map +1 -0
  11. package/dist/database.d.ts +189 -0
  12. package/dist/database.d.ts.map +1 -0
  13. package/dist/database.js +51 -0
  14. package/dist/database.js.map +1 -0
  15. package/dist/derive.d.ts +97 -0
  16. package/dist/derive.d.ts.map +1 -0
  17. package/dist/derive.js +142 -0
  18. package/dist/derive.js.map +1 -0
  19. package/dist/errors.d.ts +157 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +287 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/index-CFhgcF4N.d.ts +418 -0
  24. package/dist/index-CFhgcF4N.d.ts.map +1 -0
  25. package/dist/index.d.ts +2 -0
  26. package/dist/index.js +1 -0
  27. package/dist/jsonapi/derive.d.ts +28 -0
  28. package/dist/jsonapi/derive.d.ts.map +1 -0
  29. package/dist/jsonapi/derive.js +28 -0
  30. package/dist/jsonapi/derive.js.map +1 -0
  31. package/dist/jsonapi/index.d.ts +189 -0
  32. package/dist/jsonapi/index.d.ts.map +1 -0
  33. package/dist/jsonapi/index.js +164 -0
  34. package/dist/jsonapi/index.js.map +1 -0
  35. package/dist/query.d.ts +111 -0
  36. package/dist/query.d.ts.map +1 -0
  37. package/dist/query.js +142 -0
  38. package/dist/query.js.map +1 -0
  39. package/dist/references.d.ts +38 -0
  40. package/dist/references.d.ts.map +1 -0
  41. package/dist/references.js +60 -0
  42. package/dist/references.js.map +1 -0
  43. package/dist/sort.d.ts +129 -0
  44. package/dist/sort.d.ts.map +1 -0
  45. package/dist/sort.js +225 -0
  46. package/dist/sort.js.map +1 -0
  47. package/dist/src-CBeuhy5b.js +478 -0
  48. package/dist/src-CBeuhy5b.js.map +1 -0
  49. package/dist/update.d.ts +61 -0
  50. package/dist/update.d.ts.map +1 -0
  51. package/dist/update.js +64 -0
  52. package/dist/update.js.map +1 -0
  53. package/dist/versioned-chain.d.ts +524 -0
  54. package/dist/versioned-chain.d.ts.map +1 -0
  55. package/dist/versioned-chain.js +298 -0
  56. package/dist/versioned-chain.js.map +1 -0
  57. package/package.json +109 -3
  58. package/src/columns.ts +186 -0
  59. package/src/constraints.ts +225 -0
  60. package/src/database.ts +273 -0
  61. package/src/derivation-error.ts +27 -0
  62. package/src/derive.ts +320 -0
  63. package/src/errors.ts +466 -0
  64. package/src/index.ts +653 -0
  65. package/src/jsonapi/derive.ts +74 -0
  66. package/src/jsonapi/index.ts +355 -0
  67. package/src/query.ts +183 -0
  68. package/src/references.ts +103 -0
  69. package/src/sort.ts +368 -0
  70. package/src/tree.ts +367 -0
  71. package/src/update.ts +88 -0
  72. package/src/versioned-chain.ts +1027 -0
@@ -0,0 +1,225 @@
1
+ /**
2
+ * @copyright Thomas Foster
3
+ *
4
+ * **Table-level CHECK / UNIQUE / composite-FK helpers.**
5
+ *
6
+ * Two independent families, both emitting values for a table factory's
7
+ * `extraConstraints` seam:
8
+ *
9
+ * - {@link enumCheck} — the `CHECK (col IN (…))` behind an enum-valued `text`
10
+ * column, so a vocabulary declared once in a model constrains the database
11
+ * too.
12
+ * - {@link makeDiscriminatorConstraints} — the three constraints that thread a
13
+ * `type` discriminator up a hierarchy of stable tables, parameterised over
14
+ * the hierarchy (the library knows no level ontology; the consumer supplies
15
+ * the leaf set per level).
16
+ *
17
+ * @module constraints
18
+ */
19
+
20
+ import { sql } from "drizzle-orm";
21
+ import {
22
+ check,
23
+ foreignKey,
24
+ unique,
25
+ type AnyPgColumn,
26
+ type ExtraConfigColumn,
27
+ type PgTableExtraConfigValue,
28
+ } from "drizzle-orm/pg-core";
29
+ import * as Array from "effect/Array";
30
+
31
+ // ── Enum-valued text columns ──────────────────────────────────────────
32
+
33
+ /**
34
+ * `CHECK (column IN ('a', 'b', …))`, named `name`.
35
+ *
36
+ * The models declare each vocabulary once as an exported `as const` array and
37
+ * validate the wire against `Schema.Literals` over it; the database restates
38
+ * the same array as an `IN (...)` CHECK so a row written by any path — a
39
+ * batched ingestion upsert included — cannot carry a value the API would refuse
40
+ * to serve. There is no Postgres enum type: a `text` column plus a CHECK is
41
+ * what lets a vocabulary grow with one migration (`ALTER … DROP/ADD
42
+ * CONSTRAINT`) instead of an `ALTER TYPE … ADD VALUE` that cannot run inside a
43
+ * transaction.
44
+ *
45
+ * A `NULL` in the column **passes** — `NULL IN (...)` is unknown, which CHECK
46
+ * accepts — so the same constraint serves nullable vocabularies without an
47
+ * `IS NULL OR` guard. Nullability is the column's own `NOT NULL` to declare.
48
+ *
49
+ * The values are rendered as literal SQL text, not bind parameters: a CHECK
50
+ * expression lives in the migration file, where a `$1` placeholder has nothing
51
+ * to bind to. They come straight from the models' `as const` arrays, so the two
52
+ * vocabularies cannot drift — a model change shows up as schema drift in the
53
+ * consumer's migration-drift check until the migration is regenerated.
54
+ */
55
+ export const enumCheck = (
56
+ name: string,
57
+ column: ExtraConfigColumn,
58
+ values: readonly string[],
59
+ ): PgTableExtraConfigValue =>
60
+ check(name, sql`${column} IN (${sql.raw(values.map((value) => `'${value}'`).join(", "))})`);
61
+
62
+ // ── Threaded `type` discriminator ─────────────────────────────────────
63
+
64
+ /** A stable table contributes its `id` + `type` columns to the discriminator. */
65
+ export interface TypedStable {
66
+ readonly id: AnyPgColumn;
67
+ readonly type: AnyPgColumn;
68
+ }
69
+
70
+ /*
71
+ * Every optional member from here to the end of the file — the name templates,
72
+ * `onUpdate`, the `refinable` option — is `?: T | undefined`, never `Option`,
73
+ * for the reason stated once in ./derive.ts: they are config a consumer hands
74
+ * to a Drizzle-emitting factory, and `undefined` is what "not supplied" means
75
+ * there. `foreignKey(...).onUpdate(Option.none())` would emit `ON UPDATE
76
+ * [object Object]`.
77
+ */
78
+ /* oxlint-disable effect/prefer-option-over-null -- config type: `undefined` means "not supplied" (see ./derive.ts) */
79
+
80
+ /**
81
+ * Constraint-name templates, overriding the defaults
82
+ * `${level}_id_type_unique`, `${level}_id_type_fk` and `${level}_type_valid`.
83
+ * A consumer adopting this helper over hand-written constraints pins whatever
84
+ * names its existing migrations already carry, so the switch renames nothing.
85
+ */
86
+ export interface DiscriminatorNames {
87
+ /** Name of the `UNIQUE(id, type)` constraint. */
88
+ readonly unique?: ((level: string) => string) | undefined;
89
+ /** Name of the composite `(id, type)` → parent `(id, type)` foreign key. */
90
+ readonly parentFk?: ((level: string) => string) | undefined;
91
+ /** Name of the per-level `type` CHECK. */
92
+ readonly check?: ((level: string) => string) | undefined;
93
+ }
94
+
95
+ /**
96
+ * The three constraints that enforce a threaded `type` discriminator, bound to
97
+ * one hierarchy. Generic over the hierarchy's level union `Level`, so a level
98
+ * the consumer has no leaf set for is a type error at the call site.
99
+ */
100
+ export interface DiscriminatorToolkit<Level extends string> {
101
+ /**
102
+ * `UNIQUE(id, type)` — the target every child's composite `(id, type)` FK
103
+ * needs. `level` is a plain `string`, not `Level`: a root table sitting
104
+ * *above* the ontology's levels (and so not itself a level) can carry it too.
105
+ */
106
+ readonly typeUnique: (level: string, stable: TypedStable) => PgTableExtraConfigValue;
107
+ /**
108
+ * The composite `(id, type)` → parent `(id, type)` foreign key. `onDelete` is
109
+ * left to the plain single-column `id` FK the table already carries
110
+ * (cascade); this arc only enforces the discriminator match.
111
+ *
112
+ * `onUpdate: "cascade"` (via {@link DiscriminatorToolkit.typeConstraints}'
113
+ * `refinable` option) makes a bare parent row **refinable**: an operation
114
+ * that flips the root's `type` from a generic value to a concrete leaf
115
+ * cascades the new `type` down the pre-existing `(id, type)` chain before the
116
+ * concrete rows are inserted — so the flip is a plain ordered transaction, no
117
+ * `DEFERRABLE` constraint needed. Branches that don't refine keep the default
118
+ * (no referential action on this arc).
119
+ */
120
+ readonly typeParentFk: (
121
+ level: Level,
122
+ stable: TypedStable,
123
+ parent: TypedStable,
124
+ onUpdate?: "cascade",
125
+ ) => PgTableExtraConfigValue;
126
+ /**
127
+ * The per-level `type` CHECK. A leaf admits only its own type; an
128
+ * intermediate or branch root admits every concrete leaf in its subtree (the
129
+ * `leaves` callback the toolkit was built with). The value list is
130
+ * parameterised, not interpolated.
131
+ *
132
+ * A childless marker — an ontology level with no concrete leaf beneath it yet
133
+ * — has an empty leaf set, so it admits no `type` at all: no leaf names it as
134
+ * an ancestor, so nothing ever materialises a row there and the table stays
135
+ * empty until the ontology grows a leaf under it. Emits `CHECK (false)` for
136
+ * that case — `type IN ()` is a syntax error, and `false` correctly marks the
137
+ * level uninstantiable rather than admitting an arbitrary value.
138
+ */
139
+ readonly typeCheck: (level: Level, stable: TypedStable) => PgTableExtraConfigValue;
140
+ /**
141
+ * All three discriminator constraints for a non-root level:
142
+ * `UNIQUE(id, type)`, the composite `(id, type)` → parent FK, and the
143
+ * per-level CHECK. A branch root passes the table above it as `parent`; the
144
+ * root table itself gets only {@link DiscriminatorToolkit.typeUnique} (no
145
+ * parent, no CHECK — it admits any leaf).
146
+ *
147
+ * `refinable: true` gives the composite FK `ON UPDATE CASCADE` — see
148
+ * {@link DiscriminatorToolkit.typeParentFk}.
149
+ */
150
+ readonly typeConstraints: (
151
+ level: Level,
152
+ stable: TypedStable,
153
+ parent: TypedStable,
154
+ options?: { readonly refinable?: boolean | undefined },
155
+ ) => PgTableExtraConfigValue[];
156
+ }
157
+
158
+ /**
159
+ * Build a {@link DiscriminatorToolkit} for one hierarchy.
160
+ *
161
+ * `leaves(level)` is the seam the ontology plugs into: it answers the concrete
162
+ * leaf `type` values admissible at `level` (its subtree's leaves — for a leaf
163
+ * level, itself). The library carries no level ontology of its own, so this
164
+ * callback, and the optional {@link DiscriminatorNames} templates, are the
165
+ * whole configuration.
166
+ *
167
+ * The constraints it emits are what make a sibling-subtype chimera
168
+ * unrepresentable: `UNIQUE(id, type)` gives children a composite key to point
169
+ * at, the composite FK arcs the discriminator up one level (a child can only
170
+ * exist under a parent carrying the SAME `type`), and the CHECK narrows `type`
171
+ * to the level's own subtree.
172
+ */
173
+ export const makeDiscriminatorConstraints = <Level extends string>(options: {
174
+ readonly leaves: (level: Level) => ReadonlyArray<string>;
175
+ readonly names?: DiscriminatorNames | undefined;
176
+ }): DiscriminatorToolkit<Level> => {
177
+ const uniqueName = options.names?.unique ?? ((level: string) => `${level}_id_type_unique`);
178
+ const parentFkName = options.names?.parentFk ?? ((level: string) => `${level}_id_type_fk`);
179
+ const checkName = options.names?.check ?? ((level: string) => `${level}_type_valid`);
180
+
181
+ const typeUnique = (level: string, stable: TypedStable): PgTableExtraConfigValue =>
182
+ unique(uniqueName(level)).on(stable.id, stable.type);
183
+
184
+ const typeParentFk = (
185
+ level: Level,
186
+ stable: TypedStable,
187
+ parent: TypedStable,
188
+ onUpdate?: "cascade",
189
+ ): PgTableExtraConfigValue => {
190
+ const fk = foreignKey({
191
+ name: parentFkName(level),
192
+ columns: [stable.id, stable.type],
193
+ foreignColumns: [parent.id, parent.type],
194
+ });
195
+ return onUpdate === "cascade" ? fk.onUpdate("cascade") : fk;
196
+ };
197
+
198
+ const typeCheck = (level: Level, stable: TypedStable): PgTableExtraConfigValue => {
199
+ const leaves = options.leaves(level);
200
+ if (Array.isReadonlyArrayEmpty(leaves)) return check(checkName(level), sql`false`);
201
+ const list = sql.join(
202
+ leaves.map((leaf) => sql`${leaf}`),
203
+ sql`, `,
204
+ );
205
+ return check(checkName(level), sql`${stable.type} IN (${list})`);
206
+ };
207
+
208
+ return {
209
+ typeUnique,
210
+ typeParentFk,
211
+ typeCheck,
212
+ typeConstraints: (
213
+ level: Level,
214
+ stable: TypedStable,
215
+ parent: TypedStable,
216
+ { refinable = false }: { readonly refinable?: boolean | undefined } = {},
217
+ ): PgTableExtraConfigValue[] => [
218
+ typeUnique(level, stable),
219
+ typeParentFk(level, stable, parent, refinable ? "cascade" : undefined),
220
+ typeCheck(level, stable),
221
+ ],
222
+ };
223
+ };
224
+
225
+ /* oxlint-enable effect/prefer-option-over-null */
@@ -0,0 +1,273 @@
1
+ /**
2
+ * @copyright Thomas Foster
3
+ *
4
+ * The Effect-native Drizzle transaction boundary, as a per-application
5
+ * service factory: the `Database` and `CurrentTransaction` service keys, the
6
+ * `transact` helper that opens a transaction and discharges its handle into
7
+ * the context, and the `readHandle` resolver that prefers an ambient
8
+ * transaction over the client.
9
+ *
10
+ * The transaction value is whatever `db.transaction((tx) => Effect.gen(…))`
11
+ * passes its callback against the Effect-native Drizzle database. Its query
12
+ * methods (`select`, `insert`, `update`, `delete`, `transaction`) return
13
+ * Effects instead of Promises, so callers `yield*` them directly inside the
14
+ * transactional `Effect.gen`.
15
+ *
16
+ * Nothing here names a driver. The module is generic over the database and
17
+ * transaction types, so `drizzle-orm/effect-postgres` (and any other
18
+ * Effect-native Drizzle driver) binds them from outside;
19
+ * `@thomasfosterau/effect-drizzle-ext-pg` is the binding this repository
20
+ * ships. The only Drizzle type that reaches this file is
21
+ * {@link PgTransactionConfig}, a plain options record.
22
+ *
23
+ * Operations that predate the services took the transaction as an explicit
24
+ * `tx` parameter rather than a `Client | Transaction` union. The pair
25
+ * {@link DatabaseServices.CurrentTransaction} + {@link
26
+ * DatabaseServices.transact} replaces that threading (website #1883); the
27
+ * handle type stays visible because it is still the service's payload and the
28
+ * driver's callback parameter type.
29
+ *
30
+ * @module database
31
+ */
32
+
33
+ import type { PgTransactionConfig } from "drizzle-orm/pg-core";
34
+ import * as Context from "effect/Context";
35
+ import * as Effect from "effect/Effect";
36
+ import * as Option from "effect/Option";
37
+ import type { SqlError } from "effect/sql/SqlError";
38
+
39
+ /**
40
+ * The five read-side members {@link ReadHandle} narrows a handle to, as a
41
+ * constraint.
42
+ *
43
+ * `Pick<T, K>` requires `K extends keyof T`, which an unconstrained type
44
+ * parameter cannot satisfy; this interface is what lets {@link ReadHandle}
45
+ * name the five keys structurally instead. Members are typed `unknown` on
46
+ * purpose — the constraint asserts only that a handle HAS a read surface,
47
+ * never what it looks like, so the driver keeps full control of the
48
+ * signatures a consumer sees.
49
+ */
50
+ export interface ReadSurface {
51
+ readonly select: unknown;
52
+ readonly selectDistinct: unknown;
53
+ readonly selectDistinctOn: unknown;
54
+ readonly with: unknown;
55
+ readonly $count: unknown;
56
+ }
57
+
58
+ /**
59
+ * The read surface shared by an Effect-native Drizzle database and its
60
+ * transaction: every way of starting a SELECT, and nothing that writes.
61
+ *
62
+ * A read may run either side of a transaction boundary, so the value
63
+ * {@link DatabaseServices.readHandle} yields has to be the part both handles
64
+ * agree on. Narrowing to it is a `Pick`, never a cast: both handles satisfy
65
+ * it structurally, and the driver's own signatures survive the narrowing.
66
+ */
67
+ export type ReadHandle<Db extends ReadSurface> = Pick<
68
+ Db,
69
+ "select" | "selectDistinct" | "selectDistinctOn" | "with" | "$count"
70
+ >;
71
+
72
+ /**
73
+ * The one method {@link DatabaseServices.transact} needs from a handle: open
74
+ * a transaction, run an Effect against it, commit on success and roll back on
75
+ * failure.
76
+ *
77
+ * Both the database and the transaction satisfy this — a transaction's own
78
+ * `transaction` opens a SAVEPOINT — which is what makes nesting expressible
79
+ * without a second type.
80
+ *
81
+ * `config` is optional here and absent from a transaction handle's own
82
+ * signature; a method that ignores it stays assignable, so one interface
83
+ * covers both.
84
+ */
85
+ export interface TransactionalDatabase<Tx> {
86
+ readonly transaction: <A, E, R>(
87
+ f: (tx: Tx) => Effect.Effect<A, E, R>,
88
+ config?: PgTransactionConfig,
89
+ ) => Effect.Effect<A, E | SqlError, R>;
90
+ }
91
+
92
+ /**
93
+ * The services one application's database handles are reached through: the
94
+ * two service keys, the write boundary, and the read-side resolver.
95
+ *
96
+ * The identifier of each key is the **key string itself**, deliberately: an
97
+ * application writes `type Database = typeof services.Database.Identifier`
98
+ * and gets a requirement it can name in its own signatures, the way a
99
+ * class-style `Context.Service` gives the class. Two factories called with
100
+ * different keys therefore produce services that are distinct to the type
101
+ * checker as well as at runtime.
102
+ */
103
+ export interface DatabaseServices<Db, Tx extends ReadSurface, Key extends string> {
104
+ /**
105
+ * The canonical database service: the Effect-native Drizzle client, keyed
106
+ * by the string the factory was given. Queries yielded from it are Effects
107
+ * executed against whatever pool the layer providing it holds open.
108
+ */
109
+ readonly Database: Context.Service<Key, Db>;
110
+
111
+ /**
112
+ * The ambient transaction handle for the unit of work in flight.
113
+ *
114
+ * Providing it is {@link transact}'s job; an operation that needs the
115
+ * handle yields this key instead of taking a `tx` parameter, so "must run
116
+ * inside a transaction" lands in the `R` channel where the compiler checks
117
+ * it rather than in a parameter a caller can satisfy with the wrong handle.
118
+ *
119
+ * The payload is NOT interchangeable with {@link Database}. A transaction
120
+ * handle is the CONCRETE transaction the driver passed to the callback,
121
+ * whereas the `Database` payload can be a proxy the consuming application
122
+ * wrapped around its client (website #593 depends on that difference for
123
+ * subquery recursion). Read this key as "the open transaction", not as
124
+ * "permission to write".
125
+ *
126
+ * The key string is the `Database` key plus `/CurrentTransaction`, so the
127
+ * pair is legible in a context dump and two applications composed into one
128
+ * process never collide.
129
+ */
130
+ readonly CurrentTransaction: Context.Service<`${Key}/CurrentTransaction`, Tx>;
131
+
132
+ /**
133
+ * Runs `body` inside a database transaction, providing the handle as
134
+ * {@link CurrentTransaction}.
135
+ *
136
+ * Replaces the hand-rolled `db.transaction((tx) => …)` boundary at every
137
+ * call site: the handle is yielded here, the driver's callback parameter is
138
+ * discharged into the context, and `CurrentTransaction` leaves the
139
+ * requirement channel while everything else in it is preserved.
140
+ *
141
+ * **Always-open, never join-if-present.** Calling `transact` while a
142
+ * transaction is already open deliberately opens a NESTED one (a SAVEPOINT
143
+ * on the same connection), exactly as the hand-rolled `db.transaction` it
144
+ * replaces did. A "join the ambient transaction if there is one" variant is
145
+ * easy to write and is the wrong call here: it would silently delete every
146
+ * SAVEPOINT the calling code currently takes, changing rollback granularity
147
+ * without changing a line of that code. If you want to reuse the open
148
+ * transaction, yield {@link CurrentTransaction} directly and do not open
149
+ * another. Adjudicated on website #1883 (owner, 2026-09-07, condition 3) —
150
+ * please do not "improve" this into a join-if-present helper.
151
+ *
152
+ * The nested boundary is opened on the ambient handle
153
+ * (`tx.transaction(…)`), not on the client. Some drivers pin the open
154
+ * connection to the fiber and would nest either way; a driver that does not
155
+ * would hand `db.transaction` a SECOND pooled connection, making the
156
+ * "nested" transaction an independent one that cannot see its parent's
157
+ * uncommitted writes and does not roll back with it. Going through the
158
+ * ambient handle is a SAVEPOINT on every driver.
159
+ */
160
+ readonly transact: <A, E, R>(
161
+ body: Effect.Effect<A, E, R>,
162
+ ) => Effect.Effect<A, E | SqlError, Exclude<R, `${Key}/CurrentTransaction`> | Key>;
163
+
164
+ /**
165
+ * The handle a **read** should use: the ambient {@link CurrentTransaction}
166
+ * when one is open, the {@link Database} client otherwise.
167
+ *
168
+ * Reads sit either side of a boundary. A snapshot reconstruction runs with
169
+ * no transaction in sight and also runs inside a restore's; a
170
+ * carry-forward read happens once on a plain GET and again inside the
171
+ * update. Threading a handle through those is what keeps a
172
+ * `Pick<Tx, "select">` parameter alive after the services have removed
173
+ * every other one — functions carrying a parameter purely because they are
174
+ * honest about being callable from both sides.
175
+ *
176
+ * Preferring the transaction is also the safer default, not merely the
177
+ * convenient one. A read issued against `Database` from inside an open
178
+ * transaction takes its own pooled connection, so it is not guaranteed to
179
+ * see that transaction's uncommitted writes — a carry-forward read that
180
+ * missed the row the same unit of work had just written would be a silent
181
+ * wrong-answer bug. Reading through the open transaction removes the
182
+ * question. (A test harness that pins the pool to one connection cannot
183
+ * demonstrate the difference; the reasoning is about the pooled production
184
+ * path.)
185
+ *
186
+ * This is deliberately **not** what {@link transact} does for writes.
187
+ * There, joining an ambient transaction would delete a SAVEPOINT and change
188
+ * rollback granularity, which is why it is always-open (website #1883,
189
+ * owner, 2026-09-07). A read opens nothing, so there is no boundary to lose
190
+ * — the asymmetry is the point, not an inconsistency.
191
+ *
192
+ * Not for a caller that needs a handle which is concretely NOT the
193
+ * consuming application's proxy (website #593). This falls back to exactly
194
+ * that proxy when no transaction is open, so such a caller must keep
195
+ * opening one of its own.
196
+ *
197
+ * The requirement channel says {@link Database} either way:
198
+ * `Effect.serviceOption` does not surface `CurrentTransaction` in `R`, so
199
+ * the preference is invisible to the type checker and is pinned by a test
200
+ * instead.
201
+ */
202
+ readonly readHandle: Effect.Effect<ReadHandle<Tx>, never, Key>;
203
+ }
204
+
205
+ /**
206
+ * Builds one application's {@link DatabaseServices} around its own database
207
+ * and transaction types.
208
+ *
209
+ * Curried in two stages because TypeScript has no partial type-argument
210
+ * inference: the first call fixes the driver's handle types (which a consumer
211
+ * always writes out — they carry its relations graph), the second infers the
212
+ * key string literal from the argument. Call it once per database, at module
213
+ * scope, and export what it returns; calling it twice with the same key
214
+ * produces services that share a runtime slot.
215
+ *
216
+ * @example
217
+ * ```ts
218
+ * import { makeDatabaseServices } from "@thomasfosterau/effect-drizzle-ext/database";
219
+ * // Any driver's handle types fit; these are the `@effect/sql-pg` binding's.
220
+ * import type { PgDatabaseClient, PgTransaction } from "@thomasfosterau/effect-drizzle-ext-pg";
221
+ *
222
+ * const relations = {}; // the application's drizzle relations graph
223
+ * type AppDatabase = PgDatabaseClient<typeof relations>;
224
+ * type AppTransaction = PgTransaction<typeof relations>;
225
+ *
226
+ * const services = makeDatabaseServices<AppDatabase, AppTransaction>()({
227
+ * key: "Database",
228
+ * });
229
+ * export const { Database, CurrentTransaction, transact, readHandle } = services;
230
+ * export type Database = typeof services.Database.Identifier; // "Database"
231
+ *
232
+ * console.log(Database.key); // "Database"
233
+ * ```
234
+ */
235
+ export const makeDatabaseServices =
236
+ <
237
+ Db extends TransactionalDatabase<Tx> & ReadHandle<Tx>,
238
+ Tx extends ReadSurface & TransactionalDatabase<Tx>,
239
+ >() =>
240
+ <const Key extends string>(options: { readonly key: Key }): DatabaseServices<Db, Tx, Key> => {
241
+ const Database = Context.Service<Key, Db>(options.key);
242
+ const CurrentTransaction = Context.Service<`${Key}/CurrentTransaction`, Tx>(
243
+ `${options.key}/CurrentTransaction`,
244
+ );
245
+
246
+ /** Widens either handle to the read surface without a cast — both satisfy it structurally. */
247
+ const asReadHandle = (handle: ReadHandle<Tx>): ReadHandle<Tx> => handle;
248
+
249
+ /** Opens the body's boundary on `handle`, discharging the new transaction into the context. */
250
+ const openOn = <A, E, R>(
251
+ handle: TransactionalDatabase<Tx>,
252
+ body: Effect.Effect<A, E, R>,
253
+ ): Effect.Effect<A, E | SqlError, Exclude<R, `${Key}/CurrentTransaction`>> =>
254
+ handle.transaction((tx) => Effect.provideService(body, CurrentTransaction, tx));
255
+
256
+ return {
257
+ Database,
258
+ CurrentTransaction,
259
+ transact: <A, E, R>(
260
+ body: Effect.Effect<A, E, R>,
261
+ ): Effect.Effect<A, E | SqlError, Exclude<R, `${Key}/CurrentTransaction`> | Key> =>
262
+ Effect.flatMap(Effect.serviceOption(CurrentTransaction), (open) =>
263
+ Option.isSome(open)
264
+ ? openOn(open.value, body)
265
+ : Effect.flatMap(Database, (db) => openOn(db, body)),
266
+ ),
267
+ readHandle: Effect.flatMap(Effect.serviceOption(CurrentTransaction), (open) =>
268
+ Option.isSome(open)
269
+ ? Effect.succeed(asReadHandle(open.value))
270
+ : Effect.map(Database, asReadHandle),
271
+ ),
272
+ };
273
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The construction-time failure every module-load derivation throws. Kept in a
3
+ * module of its own that imports nothing from Drizzle, so the Drizzle-free
4
+ * entry (`.`, which `defineTree` lives in) and `./derive` share one class: a
5
+ * consumer matching on it structurally, or with `instanceof`, sees the same
6
+ * error from either. Not a published subpath.
7
+ *
8
+ * @module derivation-error
9
+ */
10
+ import * as Schema from "effect/Schema";
11
+
12
+ /**
13
+ * A construction-time derivation failure: a table or a tree could not be built
14
+ * from its declarations (no builder registered for a column kind, a
15
+ * `references` annotation with no resolver, numeric bounds on a column the
16
+ * table has no column for, an unresolvable FK target, a tree level whose
17
+ * parent is missing or forms a cycle).
18
+ *
19
+ * **Thrown, never failed.** Derivation runs at module load, before an Effect
20
+ * runtime exists, so the only way to stop a misdeclared table or tree from
21
+ * reaching production is to make its `import` fail. It is a tagged error all
22
+ * the same, so a consumer that does catch one (a registry loader, a codegen
23
+ * script) can match it structurally.
24
+ */
25
+ export class DerivationError extends Schema.TaggedError<DerivationError>()("DerivationError", {
26
+ message: Schema.String,
27
+ }) {}