@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.
- package/LICENSE +21 -0
- package/README.md +508 -2
- package/dist/columns.d.ts +113 -0
- package/dist/columns.d.ts.map +1 -0
- package/dist/columns.js +146 -0
- package/dist/columns.js.map +1 -0
- package/dist/constraints.d.ts +117 -0
- package/dist/constraints.d.ts.map +1 -0
- package/dist/constraints.js +93 -0
- package/dist/constraints.js.map +1 -0
- package/dist/database.d.ts +189 -0
- package/dist/database.d.ts.map +1 -0
- package/dist/database.js +51 -0
- package/dist/database.js.map +1 -0
- package/dist/derive.d.ts +97 -0
- package/dist/derive.d.ts.map +1 -0
- package/dist/derive.js +142 -0
- package/dist/derive.js.map +1 -0
- package/dist/errors.d.ts +157 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +287 -0
- package/dist/errors.js.map +1 -0
- package/dist/index-CFhgcF4N.d.ts +418 -0
- package/dist/index-CFhgcF4N.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/jsonapi/derive.d.ts +28 -0
- package/dist/jsonapi/derive.d.ts.map +1 -0
- package/dist/jsonapi/derive.js +28 -0
- package/dist/jsonapi/derive.js.map +1 -0
- package/dist/jsonapi/index.d.ts +189 -0
- package/dist/jsonapi/index.d.ts.map +1 -0
- package/dist/jsonapi/index.js +164 -0
- package/dist/jsonapi/index.js.map +1 -0
- package/dist/query.d.ts +111 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +142 -0
- package/dist/query.js.map +1 -0
- package/dist/references.d.ts +38 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +60 -0
- package/dist/references.js.map +1 -0
- package/dist/sort.d.ts +129 -0
- package/dist/sort.d.ts.map +1 -0
- package/dist/sort.js +225 -0
- package/dist/sort.js.map +1 -0
- package/dist/src-CBeuhy5b.js +478 -0
- package/dist/src-CBeuhy5b.js.map +1 -0
- package/dist/update.d.ts +61 -0
- package/dist/update.d.ts.map +1 -0
- package/dist/update.js +64 -0
- package/dist/update.js.map +1 -0
- package/dist/versioned-chain.d.ts +524 -0
- package/dist/versioned-chain.d.ts.map +1 -0
- package/dist/versioned-chain.js +298 -0
- package/dist/versioned-chain.js.map +1 -0
- package/package.json +109 -3
- package/src/columns.ts +186 -0
- package/src/constraints.ts +225 -0
- package/src/database.ts +273 -0
- package/src/derivation-error.ts +27 -0
- package/src/derive.ts +320 -0
- package/src/errors.ts +466 -0
- package/src/index.ts +653 -0
- package/src/jsonapi/derive.ts +74 -0
- package/src/jsonapi/index.ts +355 -0
- package/src/query.ts +183 -0
- package/src/references.ts +103 -0
- package/src/sort.ts +368 -0
- package/src/tree.ts +367 -0
- package/src/update.ts +88 -0
- 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 */
|
package/src/database.ts
ADDED
|
@@ -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
|
+
}) {}
|