@thomasfosterau/effect-drizzle-ext 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +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 +102 -4
- 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
package/src/index.ts
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Thomas Foster
|
|
3
|
+
*
|
|
4
|
+
* **Effect Schema → Drizzle derivation: the annotation entry (no Drizzle).**
|
|
5
|
+
*
|
|
6
|
+
* A model attribute's base schema carries a {@link dbColumn} annotation
|
|
7
|
+
* describing the database column it backs; a downstream derivation (the
|
|
8
|
+
* {@link ./derive.ts} entry) reads that annotation and emits the matching
|
|
9
|
+
* Drizzle column builder. One declaration — the annotated field — is the single
|
|
10
|
+
* source for both the model projection and the column.
|
|
11
|
+
*
|
|
12
|
+
* This entry point has **no Drizzle dependency**: it is the half a client- /
|
|
13
|
+
* model-layer package can import without pulling the ORM into its bundle. The
|
|
14
|
+
* Drizzle-emitting half lives behind the {@link ./derive.ts} and
|
|
15
|
+
* {@link ./versioned-chain.ts} subpaths.
|
|
16
|
+
*
|
|
17
|
+
* **Why a phantom rides alongside the annotation.** Effect Schema annotations
|
|
18
|
+
* are erased from the static type (`schema.annotate(...)` returns the *same*
|
|
19
|
+
* type, and `resolveAnnotations` widens every read to the augmented
|
|
20
|
+
* `Annotations` interface). So a purely-annotation-driven derivation cannot stay
|
|
21
|
+
* type-safe — it can neither tell *which* fields are columns nor recover their
|
|
22
|
+
* precise column type at the type level. {@link dbColumn} therefore does two
|
|
23
|
+
* things: it attaches a real runtime annotation (the source of truth the runtime
|
|
24
|
+
* reads) **and** carries the metadata literally in a type-level phantom (so the
|
|
25
|
+
* column map stays precisely typed, preserving `$inferSelect`). Annotations
|
|
26
|
+
* alone are runtime-only; type safety needs the phantom.
|
|
27
|
+
*
|
|
28
|
+
* **Extensible column kinds.** The `pg` kind is an open `string` at this layer —
|
|
29
|
+
* the package bakes in no fixed set of kinds. A consumer pins its own kind union
|
|
30
|
+
* (e.g. `text | integer | boolean | timestamp | …custom`) via {@link dbColumnFor}
|
|
31
|
+
* and supplies the matching Drizzle builders to the derivation. So a repo can
|
|
32
|
+
* inject bespoke column kinds (a block-document store, a JSON codec, …) without
|
|
33
|
+
* the package knowing about them.
|
|
34
|
+
*
|
|
35
|
+
* **Why the readers take `Schema.Constraint`, not `Schema.Top`.** Every reader
|
|
36
|
+
* here only *reads* a schema (`resolveAnnotations`, `.ast`, the structural
|
|
37
|
+
* wrapper members); none of them calls `annotate` / `check` / `rebuild`. That is
|
|
38
|
+
* exactly effect's `Schema.Constraint` surface, and it is also the element type
|
|
39
|
+
* of `Schema.Struct.Fields` — so the field readers below can hand a struct's own
|
|
40
|
+
* field straight to a reader with no cast. `Schema.Top` is strictly narrower, so
|
|
41
|
+
* this widening accepts everything a `Schema.Top` parameter would.
|
|
42
|
+
*
|
|
43
|
+
* @module index
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import * as Arr from "effect/Array";
|
|
47
|
+
import type * as JsonSchema from "effect/JsonSchema";
|
|
48
|
+
import * as Option from "effect/Option";
|
|
49
|
+
import * as Predicate from "effect/Predicate";
|
|
50
|
+
import * as R from "effect/Record";
|
|
51
|
+
import * as Result from "effect/Result";
|
|
52
|
+
import * as Schema from "effect/Schema";
|
|
53
|
+
import * as SchemaAST from "effect/SchemaAST";
|
|
54
|
+
import type * as SchemaRepresentation from "effect/SchemaRepresentation";
|
|
55
|
+
import { DerivationError } from "./derivation-error.ts";
|
|
56
|
+
import { buildTree, type CheckedTreeSpec, type Tree } from "./tree.ts";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A construction-time derivation failure, thrown at module load when a table
|
|
60
|
+
* or a tree cannot be built from its declarations; never an Effect failure.
|
|
61
|
+
* The same class `./derive` exports.
|
|
62
|
+
*/
|
|
63
|
+
export { DerivationError };
|
|
64
|
+
|
|
65
|
+
export type {
|
|
66
|
+
AnyTreeColumnsMeta,
|
|
67
|
+
CheckedTreeSpec,
|
|
68
|
+
LevelColumnsMeta,
|
|
69
|
+
LeafName,
|
|
70
|
+
LevelKind,
|
|
71
|
+
LevelName,
|
|
72
|
+
LevelSpec,
|
|
73
|
+
MarkerName,
|
|
74
|
+
Tree,
|
|
75
|
+
TreeLevel,
|
|
76
|
+
} from "./tree.ts";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A referential action for a derived foreign-key column — the Drizzle
|
|
80
|
+
* `onDelete` / `onUpdate` vocabulary, restated as plain data so the annotation
|
|
81
|
+
* layer stays Drizzle-free.
|
|
82
|
+
*/
|
|
83
|
+
export type ReferentialAction = "cascade" | "restrict" | "no action" | "set null" | "set default";
|
|
84
|
+
|
|
85
|
+
/*
|
|
86
|
+
* The optional members of the three metadata interfaces below are spelled
|
|
87
|
+
* `?: T | undefined`, not `Option`, because they are **Drizzle-facing config**:
|
|
88
|
+
* the derivation hands them to a Drizzle column builder, which reads `undefined`
|
|
89
|
+
* as "not supplied" and would emit an `Option` object as a literal DEFAULT —
|
|
90
|
+
* wrong DDL that no type error and no unit test catches. The boundary is stated
|
|
91
|
+
* once in ./derive.ts; the readers this module owns return `Option`.
|
|
92
|
+
*/
|
|
93
|
+
/* oxlint-disable effect/prefer-option-over-null -- Drizzle-facing config: Drizzle reads `undefined` as "not supplied" */
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Reference / foreign-key metadata a column annotation may carry: the target
|
|
97
|
+
* relation + column the derived column foreign-keys to, plus its referential
|
|
98
|
+
* actions. Expressed as **names** (not Drizzle column thunks) so it survives in
|
|
99
|
+
* the Drizzle-free annotation layer; the derivation resolves the names to real
|
|
100
|
+
* columns via its own `resolveReference` hook (see the {@link ./derive.ts}
|
|
101
|
+
* entry).
|
|
102
|
+
*
|
|
103
|
+
* This is the metadata that lets an FK-backed attribute be derived rather than
|
|
104
|
+
* hand-authored as a table-only `references()` column and re-stated in the
|
|
105
|
+
* consuming application's operation value map.
|
|
106
|
+
*/
|
|
107
|
+
export interface DbColumnReference {
|
|
108
|
+
/** The referenced relation (table) name. */
|
|
109
|
+
readonly table: string;
|
|
110
|
+
/** The referenced column name. */
|
|
111
|
+
readonly column: string;
|
|
112
|
+
/** Referential action on delete of the referenced row. */
|
|
113
|
+
readonly onDelete?: ReferentialAction | undefined;
|
|
114
|
+
/** Referential action on update of the referenced row. */
|
|
115
|
+
readonly onUpdate?: ReferentialAction | undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Column metadata a model attribute declares about the column it backs. Generic
|
|
120
|
+
* over the column-kind union `Kind` (open `string` by default) — a consumer pins
|
|
121
|
+
* it to its own kinds via {@link dbColumnFor}.
|
|
122
|
+
*/
|
|
123
|
+
export interface DbColumn<Kind extends string = string> {
|
|
124
|
+
/** The snake_case column name (e.g. `published_at`). */
|
|
125
|
+
readonly column: string;
|
|
126
|
+
/** The column kind (selects the Drizzle builder in the derivation). */
|
|
127
|
+
readonly pg: Kind;
|
|
128
|
+
/** Whether the column is `NOT NULL`. */
|
|
129
|
+
readonly notNull: boolean;
|
|
130
|
+
/** Optional SQL `DEFAULT`. */
|
|
131
|
+
readonly default?: boolean | number | string | undefined;
|
|
132
|
+
/** Optional foreign-key metadata (see {@link DbColumnReference}). */
|
|
133
|
+
readonly references?: DbColumnReference | undefined;
|
|
134
|
+
/**
|
|
135
|
+
* Optional explicit name for the CHECK constraint the column's numeric
|
|
136
|
+
* bounds derive — a migration-compat escape hatch for keeping a historical
|
|
137
|
+
* constraint name where the canonical `${table}_${column}_${suffix}` would
|
|
138
|
+
* rename it. Omit for new columns.
|
|
139
|
+
*/
|
|
140
|
+
readonly checkName?: string | undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* oxlint-enable effect/prefer-option-over-null */
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The annotation key under which {@link dbColumn} stores its metadata.
|
|
147
|
+
* {@link dbColumn} writes it; {@link readDbColumn} and {@link fieldColumns}
|
|
148
|
+
* read it. Namespaced to the package so a consumer's own annotations never
|
|
149
|
+
* collide.
|
|
150
|
+
*/
|
|
151
|
+
export const DB_COLUMN_KEY = "effect-drizzle-ext/dbColumn";
|
|
152
|
+
|
|
153
|
+
declare const DbColumnTypeId: unique symbol;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A schema carrying db-column metadata `M` in a type-level phantom (the
|
|
157
|
+
* annotation value itself is erased from the type, so the phantom is what keeps
|
|
158
|
+
* the derived column map precise). The phantom property is **required for type
|
|
159
|
+
* matching** — a non-annotated schema must yield `never`, not `unknown`, so the
|
|
160
|
+
* column derivation can tell columns from plain / computed fields — but it is
|
|
161
|
+
* never present at runtime (the runtime reads the real annotation instead).
|
|
162
|
+
*/
|
|
163
|
+
export type Annotated<S extends Schema.Top, M extends DbColumn> = S & {
|
|
164
|
+
readonly [DbColumnTypeId]: M;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** Recovers the db-column metadata phantom from a schema, or `never`. */
|
|
168
|
+
export type DbColumnOf<S> = S extends { readonly [DbColumnTypeId]: infer M extends DbColumn }
|
|
169
|
+
? M
|
|
170
|
+
: never;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Annotate a base schema with the database column it backs. Attaches a genuine
|
|
174
|
+
* Effect annotation (the runtime reads it via {@link readDbColumn}) and carries
|
|
175
|
+
* the metadata literally in the type (so the column derivation stays precisely
|
|
176
|
+
* typed). Wrap the result in a model helper as usual — the helper's projection
|
|
177
|
+
* carries the annotation through to the table factory.
|
|
178
|
+
*
|
|
179
|
+
* Accepts any `pg` string; pin the kind union with {@link dbColumnFor} to catch
|
|
180
|
+
* unknown-kind typos at the annotation site.
|
|
181
|
+
*/
|
|
182
|
+
export const dbColumn = <S extends Schema.Top, const M extends DbColumn>(
|
|
183
|
+
schema: S,
|
|
184
|
+
meta: M,
|
|
185
|
+
): Annotated<S, M> =>
|
|
186
|
+
// SAFETY: the phantom is type-only (never present at runtime), and the
|
|
187
|
+
// annotation just attached under DB_COLUMN_KEY is exactly `meta`.
|
|
188
|
+
schema.annotate({ [DB_COLUMN_KEY]: meta }) as Annotated<S, M>;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A {@link dbColumn} constrained to a specific column-kind union `Kind`. A
|
|
192
|
+
* consumer pins its own kinds once — `const dbColumn = dbColumnFor<PgKind>()` —
|
|
193
|
+
* so an annotation naming a kind the derivation can't build is a type error at
|
|
194
|
+
* the annotation site rather than a runtime surprise.
|
|
195
|
+
*/
|
|
196
|
+
export const dbColumnFor =
|
|
197
|
+
<Kind extends string>() =>
|
|
198
|
+
<S extends Schema.Top, const M extends DbColumn<Kind>>(schema: S, meta: M): Annotated<S, M> =>
|
|
199
|
+
// SAFETY: as in `dbColumn` — the phantom is type-only and the annotation
|
|
200
|
+
// just attached under DB_COLUMN_KEY is exactly `meta`.
|
|
201
|
+
schema.annotate({ [DB_COLUMN_KEY]: meta }) as Annotated<S, M>;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Read the db-column annotation off a schema at runtime. `Option.none()` when
|
|
205
|
+
* the schema declares none.
|
|
206
|
+
*
|
|
207
|
+
* Reads the schema's **own** annotations only: `Schema.resolveAnnotations` on a
|
|
208
|
+
* `Schema.optional(...)` / `Schema.NullOr(...)` wrapper does not see the inner
|
|
209
|
+
* schema's annotation. Use {@link fieldColumns} for a struct's fields, which
|
|
210
|
+
* peels those wrappers structurally.
|
|
211
|
+
*/
|
|
212
|
+
/*
|
|
213
|
+
* NOT migrated to `Option`: this union is not a modelling choice, it names the
|
|
214
|
+
* runtime shape effect's own `Schema.resolveAnnotations` returns — an absent
|
|
215
|
+
* annotation record, and an absent key inside it, are both a literal
|
|
216
|
+
* `undefined`. The foreign `undefined` is converted to `Option` on the very
|
|
217
|
+
* next expression, which is where the boundary is.
|
|
218
|
+
*/
|
|
219
|
+
/* oxlint-disable effect/prefer-option-over-null -- names effect's own runtime shape at a cast, converted on the same line */
|
|
220
|
+
// SAFETY: DB_COLUMN_KEY is namespaced to this package and only `dbColumn` /
|
|
221
|
+
// `dbColumnFor` write it, always with a `DbColumn` value.
|
|
222
|
+
export const readDbColumn = (schema: Schema.Constraint): Option.Option<DbColumn> =>
|
|
223
|
+
Option.fromNullishOr(Schema.resolveAnnotations(schema)?.[DB_COLUMN_KEY] as DbColumn | undefined);
|
|
224
|
+
/* oxlint-enable effect/prefer-option-over-null */
|
|
225
|
+
|
|
226
|
+
// ── Table-level annotation ────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The base table metadata {@link dbTable} attaches: the SQL leaf name the
|
|
230
|
+
* annotated target backs (a versioned consumer's version table is
|
|
231
|
+
* `${name}_version`).
|
|
232
|
+
*
|
|
233
|
+
* `TableMeta` is the floor, not the whole vocabulary: {@link dbTable} accepts
|
|
234
|
+
* any `TableMeta`, so a **flat** consumer — one whose tables anchor at no
|
|
235
|
+
* hierarchy at all — annotates with `{ name }` and nothing else, instead of
|
|
236
|
+
* re-implementing `dbTable` locally just to drop a field it has no use for.
|
|
237
|
+
* A level-dispatching consumer pins {@link DbTable} via {@link dbTableFor}.
|
|
238
|
+
*/
|
|
239
|
+
export interface TableMeta {
|
|
240
|
+
/** The snake_case leaf table name (e.g. `podcast_series`). */
|
|
241
|
+
readonly name: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Table metadata that additionally names the hierarchy level the leaf anchors
|
|
246
|
+
* at (selecting the parent table pair in a level-dispatching consumer). Generic
|
|
247
|
+
* over the level union `Level` (open `string` by default) — a consumer pins it
|
|
248
|
+
* to its own levels via {@link dbTableFor}, so a leaf naming an unknown level is
|
|
249
|
+
* a type error at the annotation site.
|
|
250
|
+
*/
|
|
251
|
+
export interface DbTable<Level extends string = string> extends TableMeta {
|
|
252
|
+
/** The hierarchy level the leaf anchors at (selects the parent table pair). */
|
|
253
|
+
readonly level: Level;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
declare const DbTableTypeId: unique symbol;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A target object carrying db-table metadata `M` in a type-level phantom — the
|
|
260
|
+
* table-level counterpart of {@link Annotated}. The phantom keeps a consumer's
|
|
261
|
+
* level dispatch precise (a `"work"`-anchored leaf cannot be passed to a
|
|
262
|
+
* `"manifestation"` table factory); the runtime reads the real attached
|
|
263
|
+
* metadata via {@link readDbTable}.
|
|
264
|
+
*/
|
|
265
|
+
export type TableAnnotated<T extends object, M extends TableMeta> = T & {
|
|
266
|
+
readonly [DbTableTypeId]: M;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** Recovers the db-table metadata phantom from a target, or `never`. */
|
|
270
|
+
export type DbTableOf<T> = T extends { readonly [DbTableTypeId]: infer M extends TableMeta }
|
|
271
|
+
? M
|
|
272
|
+
: never;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The runtime key under which {@link dbTable} attaches its metadata. A symbol
|
|
276
|
+
* **property on the target object**, not an Effect Schema annotation — and that
|
|
277
|
+
* is the reason {@link dbTable} is generic over any `T extends object` rather
|
|
278
|
+
* than over `Schema.Top`.
|
|
279
|
+
*
|
|
280
|
+
* `annotate` does not annotate in place: it **rebuilds** the schema. For a
|
|
281
|
+
* target assembled as `Object.assign(struct, { … })` — the shape a resource /
|
|
282
|
+
* model definition in the consuming applications takes — a rebuild drops every
|
|
283
|
+
* assigned property, and it breaks the referential identity that family
|
|
284
|
+
* membership and registry lockstep checks compare by. A non-enumerable own
|
|
285
|
+
* symbol property annotates in place, preserving both, and works on a plain
|
|
286
|
+
* object just as well as on a schema.
|
|
287
|
+
*/
|
|
288
|
+
const DB_TABLE_KEY: unique symbol = Symbol.for("effect-drizzle-ext/dbTable");
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Annotate any object with the database table it backs. Attaches the metadata
|
|
292
|
+
* as a non-enumerable symbol property (see {@link DB_TABLE_KEY} for why not
|
|
293
|
+
* `annotate`) and carries it literally in the type, so a level-bound table
|
|
294
|
+
* factory can constrain its input to leaves anchored at its level. Returns the
|
|
295
|
+
* **same** object.
|
|
296
|
+
*
|
|
297
|
+
* Accepts any {@link TableMeta}; pin a level union with {@link dbTableFor}.
|
|
298
|
+
*/
|
|
299
|
+
export const dbTable = <T extends object, const M extends TableMeta>(
|
|
300
|
+
target: T,
|
|
301
|
+
meta: M,
|
|
302
|
+
): TableAnnotated<T, M> => {
|
|
303
|
+
Object.defineProperty(target, DB_TABLE_KEY, { value: meta, enumerable: false });
|
|
304
|
+
// SAFETY: the phantom is type-only; the runtime metadata was just attached
|
|
305
|
+
// under DB_TABLE_KEY on this same object.
|
|
306
|
+
return target as TableAnnotated<T, M>;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A {@link dbTable} constrained to a specific level union `Level`, requiring the
|
|
311
|
+
* full {@link DbTable} metadata. A consumer pins its own levels once — `const
|
|
312
|
+
* dbTable = dbTableFor<LeafLevel>()` — so an annotation naming a level the
|
|
313
|
+
* derivation has no parent binding for is a type error at the annotation site
|
|
314
|
+
* rather than a runtime surprise.
|
|
315
|
+
*/
|
|
316
|
+
export const dbTableFor =
|
|
317
|
+
<Level extends string>() =>
|
|
318
|
+
<T extends object, const M extends DbTable<Level>>(target: T, meta: M): TableAnnotated<T, M> =>
|
|
319
|
+
dbTable(target, meta);
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Read the db-table metadata off a target at runtime. `Option.none()` when the
|
|
323
|
+
* target carries none.
|
|
324
|
+
*
|
|
325
|
+
* Typed to the {@link TableMeta} floor: a consumer that annotated with a
|
|
326
|
+
* {@link DbTable} recovers its `level` through the {@link DbTableOf} phantom,
|
|
327
|
+
* which is where the level is statically known.
|
|
328
|
+
*/
|
|
329
|
+
export const readDbTable = <T extends object>(target: T): Option.Option<TableMeta> =>
|
|
330
|
+
Option.fromNullishOr(
|
|
331
|
+
// SAFETY: DB_TABLE_KEY is a module-private symbol that only `dbTable`
|
|
332
|
+
// writes, always with a `TableMeta`; an unannotated target lacks it.
|
|
333
|
+
(target as T & Partial<Record<typeof DB_TABLE_KEY, TableMeta>>)[DB_TABLE_KEY],
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// ── Numeric bounds (schema checks → CHECK-constraint metadata) ────────
|
|
337
|
+
|
|
338
|
+
/* oxlint-disable effect/prefer-option-over-null -- Drizzle-facing config: consumed by the CHECK derivation alongside DbColumn */
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Numeric bounds recovered from a schema's checks — the JSON-Schema bound
|
|
342
|
+
* vocabulary. The source of a derived SQL `CHECK` constraint: one declaration
|
|
343
|
+
* (the model's `Schema.isGreaterThanOrEqualTo(0)` / `isBetween` check) drives
|
|
344
|
+
* wire validation **and** the column's database constraint.
|
|
345
|
+
*/
|
|
346
|
+
export interface NumericBounds {
|
|
347
|
+
readonly minimum?: number | undefined;
|
|
348
|
+
readonly maximum?: number | undefined;
|
|
349
|
+
readonly exclusiveMinimum?: number | undefined;
|
|
350
|
+
readonly exclusiveMaximum?: number | undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* oxlint-enable effect/prefer-option-over-null */
|
|
354
|
+
|
|
355
|
+
/** The bounds of one column-backed attribute, paired with its column metadata. */
|
|
356
|
+
export interface ColumnBounds extends NumericBounds {
|
|
357
|
+
readonly column: DbColumn;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const BOUND_KEYS = ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"] as const;
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The neutral `CheckInput` the walker hands a check's `toJsonSchema`: no outer
|
|
364
|
+
* type, no member schemas, which the bound filters ignore.
|
|
365
|
+
*/
|
|
366
|
+
const NEUTRAL_CHECK_INPUT: SchemaRepresentation.ToJsonSchema.CheckInput = {
|
|
367
|
+
type: undefined,
|
|
368
|
+
schemas: [],
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Whether a `toJsonSchema` output is a plain fragment. The approximate
|
|
373
|
+
* `[fragment, true]` form is an array, whose bound keys read as absent, so the
|
|
374
|
+
* walker reads no bounds from it.
|
|
375
|
+
*/
|
|
376
|
+
const isFragment = (
|
|
377
|
+
output: SchemaRepresentation.ToJsonSchema.CheckOutput,
|
|
378
|
+
): output is JsonSchema.JsonSchema => Predicate.isObject(output);
|
|
379
|
+
|
|
380
|
+
/** The JSON-Schema fragment a check's `toJsonSchema` annotation exposes, if any. */
|
|
381
|
+
const checkFragment = (check: SchemaAST.Check<never>): Option.Option<JsonSchema.JsonSchema> =>
|
|
382
|
+
Option.flatMap(Option.fromNullishOr(check.annotations?.toJsonSchema), (toJsonSchema) =>
|
|
383
|
+
Option.filter(
|
|
384
|
+
// A filter whose toJsonSchema needs context it wasn't given throws — no bounds.
|
|
385
|
+
Option.flatMap(
|
|
386
|
+
Result.getSuccess(Result.try(() => toJsonSchema(NEUTRAL_CHECK_INPUT))),
|
|
387
|
+
Option.fromNullishOr,
|
|
388
|
+
),
|
|
389
|
+
isFragment,
|
|
390
|
+
),
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
/** Merge a fragment's numeric bound keys over `bounds` (the later check wins a key). */
|
|
394
|
+
const mergeBounds = (fragment: JsonSchema.JsonSchema, bounds: NumericBounds): NumericBounds =>
|
|
395
|
+
Arr.reduce(BOUND_KEYS, bounds, (acc, key) => {
|
|
396
|
+
const value = fragment[key];
|
|
397
|
+
return Predicate.isNumber(value) ? { ...acc, [key]: value } : acc;
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Merge the numeric-bound fragments of a check list over `bounds`.
|
|
402
|
+
*
|
|
403
|
+
* `checks` is an `Option` because effect's own AST leaves the field absent; the
|
|
404
|
+
* callers convert that foreign `undefined` at the boundary with
|
|
405
|
+
* `Option.fromNullishOr`.
|
|
406
|
+
*/
|
|
407
|
+
const collectCheckBounds = (
|
|
408
|
+
checks: Option.Option<ReadonlyArray<SchemaAST.Check<never>>>,
|
|
409
|
+
bounds: NumericBounds,
|
|
410
|
+
): NumericBounds =>
|
|
411
|
+
Arr.reduce(
|
|
412
|
+
Option.getOrElse(checks, () => []),
|
|
413
|
+
bounds,
|
|
414
|
+
(acc, check) => {
|
|
415
|
+
// A FilterGroup nests its members under `checks`; recurse into them.
|
|
416
|
+
const nested = Predicate.isTagged(check, "FilterGroup")
|
|
417
|
+
? collectCheckBounds(Option.some(check.checks), acc)
|
|
418
|
+
: acc;
|
|
419
|
+
return Option.match(checkFragment(check), {
|
|
420
|
+
onNone: () => nested,
|
|
421
|
+
onSome: (fragment) => mergeBounds(fragment, nested),
|
|
422
|
+
});
|
|
423
|
+
},
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Recover the {@link NumericBounds} a single attribute schema declares, or
|
|
428
|
+
* `Option.none()` when it declares none. Reads the schema's own checks plus the
|
|
429
|
+
* checks of each union member — the `Schema.NullOr(Schema.Finite.check(...))`
|
|
430
|
+
* shape a nullable numeric attribute takes. Deliberately shallow: bounds
|
|
431
|
+
* buried deeper than one union level are a modelling smell, not a supported
|
|
432
|
+
* shape.
|
|
433
|
+
*
|
|
434
|
+
* Exported because the `./jsonapi` entry's resource-level bounds reader is the
|
|
435
|
+
* same walk over a different field source.
|
|
436
|
+
*/
|
|
437
|
+
export const numericBounds = (schema: Schema.Constraint): Option.Option<NumericBounds> => {
|
|
438
|
+
const { ast } = schema;
|
|
439
|
+
const bounds = Arr.reduce(
|
|
440
|
+
SchemaAST.isUnion(ast) ? ast.types : [],
|
|
441
|
+
collectCheckBounds(Option.fromNullishOr(ast.checks), {}),
|
|
442
|
+
(acc, member) => collectCheckBounds(Option.fromNullishOr(member.checks), acc),
|
|
443
|
+
);
|
|
444
|
+
return Arr.some(BOUND_KEYS, (key) => bounds[key] !== undefined)
|
|
445
|
+
? Option.some(bounds)
|
|
446
|
+
: Option.none();
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// ── Struct-field projection ───────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Peels the `Schema.optional(...)` / `Schema.optionalKey(...)` /
|
|
453
|
+
* `Schema.NullOr(...)` / `Schema.UndefinedOr(...)` wrappers a model attribute
|
|
454
|
+
* applies, to reach a {@link dbColumn}-annotated base schema's phantom, or
|
|
455
|
+
* `never`. A required attribute carries the annotation on its base schema
|
|
456
|
+
* directly (no peel); an optional / nullable projection wraps it.
|
|
457
|
+
*
|
|
458
|
+
* The wrapper set mirrors what {@link fieldColumns} peels at runtime — note
|
|
459
|
+
* that `Schema.optional(S)` *is* `optionalKey(UndefinedOr(S))`, so the two pairs
|
|
460
|
+
* are one chain, not four independent cases.
|
|
461
|
+
*/
|
|
462
|
+
export type UnwrapColumn<S> = [DbColumnOf<S>] extends [never]
|
|
463
|
+
? [S] extends [Schema.optional<infer Inner>]
|
|
464
|
+
? UnwrapColumn<Inner>
|
|
465
|
+
: [S] extends [Schema.optionalKey<infer Inner>]
|
|
466
|
+
? UnwrapColumn<Inner>
|
|
467
|
+
: [S] extends [Schema.NullOr<infer Inner>]
|
|
468
|
+
? UnwrapColumn<Inner>
|
|
469
|
+
: [S] extends [Schema.UndefinedOr<infer Inner>]
|
|
470
|
+
? UnwrapColumn<Inner>
|
|
471
|
+
: never
|
|
472
|
+
: DbColumnOf<S>;
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The db-column metadata map a `Schema.Struct`'s fields project to, keyed by the
|
|
476
|
+
* fields that declare a {@link dbColumn} (others drop out of the map entirely).
|
|
477
|
+
* The type-level counterpart of {@link fieldColumns}.
|
|
478
|
+
*/
|
|
479
|
+
export type FieldColumnsMetaOf<F extends Schema.Struct.Fields> = {
|
|
480
|
+
readonly [K in keyof F as [UnwrapColumn<F[K]>] extends [never] ? never : K]: UnwrapColumn<F[K]>;
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* One structural peel of a field wrapper: the schema `schema` wraps, or
|
|
485
|
+
* `Option.none()` when it wraps nothing.
|
|
486
|
+
*
|
|
487
|
+
* Structural, not annotation-driven, because `Schema.resolveAnnotations` on a
|
|
488
|
+
* wrapper does not see the inner schema's annotations — the wrapper is a
|
|
489
|
+
* different schema with its own (empty) annotation record. An
|
|
490
|
+
* `optional` / `optionalKey` wrapper exposes its inner schema as `.schema`; a
|
|
491
|
+
* `NullOr` / `UndefinedOr` union exposes its members as `.members`, of which the
|
|
492
|
+
* `null` / `undefined` arms are the ones to skip.
|
|
493
|
+
*/
|
|
494
|
+
const underlyingSchema = (schema: Schema.Constraint): Option.Option<Schema.Constraint> => {
|
|
495
|
+
/*
|
|
496
|
+
* NOT migrated to `Option`: this structural view is not a modelling choice,
|
|
497
|
+
* it names the runtime shape of effect's own wrapper schemas — a schema that
|
|
498
|
+
* wraps nothing simply has no `schema` / `members` own property, so the
|
|
499
|
+
* absence *is* `undefined`. The function converts it at its return.
|
|
500
|
+
*/
|
|
501
|
+
/* oxlint-disable effect/prefer-option-over-null -- names effect's own runtime shape at a cast, converted at the return */
|
|
502
|
+
// SAFETY: both members are optional and checked before use; a schema that
|
|
503
|
+
// is neither wrapper simply lacks both properties.
|
|
504
|
+
const wrapper = schema as {
|
|
505
|
+
readonly schema?: Schema.Constraint | undefined;
|
|
506
|
+
readonly members?: ReadonlyArray<Schema.Constraint> | undefined;
|
|
507
|
+
};
|
|
508
|
+
/* oxlint-enable effect/prefer-option-over-null */
|
|
509
|
+
if (wrapper.schema !== undefined) return Option.some(wrapper.schema);
|
|
510
|
+
return Arr.findFirst(wrapper.members ?? [], (member) => {
|
|
511
|
+
const tag = member.ast._tag;
|
|
512
|
+
return tag !== "Null" && tag !== "Undefined";
|
|
513
|
+
});
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* How many wrappers deep the peel walks before giving up. The deepest shape the
|
|
518
|
+
* projection supports is `optional(NullOr(annotated))`, which is three peels
|
|
519
|
+
* (`optionalKey` → `UndefinedOr` → `NullOr`); the cap is slack above that, and
|
|
520
|
+
* it is what keeps a self-referential schema from recursing forever.
|
|
521
|
+
*/
|
|
522
|
+
const MAX_WRAPPER_DEPTH = 8;
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The schema that actually carries the {@link dbColumn} annotation for a struct
|
|
526
|
+
* field: the field itself when it is annotated, otherwise the first annotated
|
|
527
|
+
* schema found by peeling its wrappers. `Option.none()` for a field that backs
|
|
528
|
+
* no column.
|
|
529
|
+
*
|
|
530
|
+
* Returning the *schema* rather than the metadata is what lets
|
|
531
|
+
* {@link fieldColumnBounds} read the bounds off the same node the column was
|
|
532
|
+
* declared on — bounds on `dbColumn(NullOr(Number.check(…)), …)` sit one union
|
|
533
|
+
* level below the annotated node, which is exactly what {@link numericBounds}
|
|
534
|
+
* walks.
|
|
535
|
+
*/
|
|
536
|
+
const columnSchema = (field: Schema.Constraint, depth = 0): Option.Option<Schema.Constraint> => {
|
|
537
|
+
if (depth > MAX_WRAPPER_DEPTH) return Option.none();
|
|
538
|
+
if (Option.isSome(readDbColumn(field))) return Option.some(field);
|
|
539
|
+
return Option.flatMap(underlyingSchema(field), (inner) => columnSchema(inner, depth + 1));
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Project a `Schema.Struct`'s fields to the per-field {@link DbColumn} metadata
|
|
544
|
+
* the derivation consumes, dropping fields that declare no column. Peels the
|
|
545
|
+
* `optional` / `NullOr` wrappers a field may apply. The type-level counterpart
|
|
546
|
+
* is {@link FieldColumnsMetaOf}.
|
|
547
|
+
*/
|
|
548
|
+
export const fieldColumns = (fields: Schema.Struct.Fields): Record<string, DbColumn> =>
|
|
549
|
+
R.getSomes(R.map(fields, (field) => Option.flatMap(columnSchema(field), readDbColumn)));
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Project a `Schema.Struct`'s per-field numeric bounds into plain data a
|
|
553
|
+
* `CHECK`-constraint derivation consumes: for each {@link dbColumn}-annotated
|
|
554
|
+
* field whose schema carries numeric-bound checks, the bounds paired with the
|
|
555
|
+
* column metadata. Fields without a column, or without bounds, drop. The
|
|
556
|
+
* Drizzle-emitting counterpart is the `./derive` entry's
|
|
557
|
+
* `deriveFieldCheckConstraints`.
|
|
558
|
+
*/
|
|
559
|
+
export const fieldColumnBounds = (fields: Schema.Struct.Fields): Record<string, ColumnBounds> =>
|
|
560
|
+
R.getSomes(
|
|
561
|
+
R.map(fields, (field) =>
|
|
562
|
+
Option.flatMap(columnSchema(field), (schema) =>
|
|
563
|
+
Option.zipWith(
|
|
564
|
+
readDbColumn(schema),
|
|
565
|
+
numericBounds(schema),
|
|
566
|
+
(column, bounds): ColumnBounds => ({ column, ...bounds }),
|
|
567
|
+
),
|
|
568
|
+
),
|
|
569
|
+
),
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
// ── Tree ──────────────────────────────────────────────────────────────
|
|
573
|
+
|
|
574
|
+
/** The fields an entry's member `K` (`columns` or `leaf`) declares; `{}` when absent. */
|
|
575
|
+
type EntryFields<E, K extends "columns" | "leaf"> = E extends { readonly [P in K]?: infer F }
|
|
576
|
+
? Exclude<F, undefined> extends Schema.Struct.Fields
|
|
577
|
+
? Exclude<F, undefined>
|
|
578
|
+
: Record<never, never>
|
|
579
|
+
: Record<never, never>;
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* The typed column metadata of a {@link defineTree} declaration `S`: per level,
|
|
583
|
+
* the `DbColumn` maps its level columns and its leaf fields carry. The phantom
|
|
584
|
+
* a `Tree` built by `defineTree` holds for the table derivation.
|
|
585
|
+
*/
|
|
586
|
+
export type FieldsTreeMeta<S> = {
|
|
587
|
+
readonly [K in keyof S]: {
|
|
588
|
+
readonly level: FieldColumnsMetaOf<EntryFields<S[K], "columns">>;
|
|
589
|
+
readonly leaf: FieldColumnsMetaOf<EntryFields<S[K], "leaf">>;
|
|
590
|
+
};
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* A {@link defineTree} whose every entry also carries the consumer's
|
|
595
|
+
* extension record `Ext` (pulpit's `owned` / `pageable` flags, say); the
|
|
596
|
+
* library reads none of its members and hands each entry back on
|
|
597
|
+
* `tree.level(name).entry`. Pin it once — `const defineTree =
|
|
598
|
+
* defineTreeFor<RegistryFlags>()` — so an entry missing a flag is a type
|
|
599
|
+
* error. Throws {@link DerivationError} at module load as `defineTree` does.
|
|
600
|
+
*/
|
|
601
|
+
export const defineTreeFor =
|
|
602
|
+
<Ext extends object>() =>
|
|
603
|
+
<const S extends CheckedTreeSpec<S, Schema.Struct.Fields, Ext>>(
|
|
604
|
+
spec: S,
|
|
605
|
+
): Tree<S, FieldsTreeMeta<S>> =>
|
|
606
|
+
buildTree<S, Schema.Struct.Fields, FieldsTreeMeta<S>>(spec, {
|
|
607
|
+
levelColumns: fieldColumns,
|
|
608
|
+
leafColumns: fieldColumns,
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Declare an ontology's level tree once, Drizzle-free, so a models package can
|
|
613
|
+
* state it and the database layer derive every table, constraint and view from
|
|
614
|
+
* it. Each entry is a level: a **marker** (no `leaf`) or a **leaf**, whose
|
|
615
|
+
* `leaf` is the struct fields of its domain columns (`dbColumn`-annotated) and
|
|
616
|
+
* whose name is its discriminator value. An entry names its `parent` (absent:
|
|
617
|
+
* a branch root under the tree's root), and may carry level `columns`
|
|
618
|
+
* contributed to every descendant's view and a level `kind`.
|
|
619
|
+
*
|
|
620
|
+
* Returns the checked {@link Tree}: per level, its ancestors, children,
|
|
621
|
+
* descendants, subtree leaves (the discriminator CHECK value set) and branch,
|
|
622
|
+
* all in declaration order. A parent naming no level of the tree, or the level
|
|
623
|
+
* itself, is a type error. Throws {@link DerivationError} at module load for
|
|
624
|
+
* every structural fault the types can't catch: a missing parent or a cycle, a
|
|
625
|
+
* leaf with children, a marker with no leaf below it, an unannotated level
|
|
626
|
+
* column, a reserved column name (`id`, `version`, `action`, `type`), or two
|
|
627
|
+
* columns on one root-to-leaf path sharing a field key or a column name.
|
|
628
|
+
*
|
|
629
|
+
* @example
|
|
630
|
+
* ```ts
|
|
631
|
+
* import { dbColumn, defineTree } from "@thomasfosterau/effect-drizzle-ext";
|
|
632
|
+
* import * as Schema from "effect/Schema";
|
|
633
|
+
*
|
|
634
|
+
* const tree = defineTree({
|
|
635
|
+
* work: {
|
|
636
|
+
* columns: { title: dbColumn(Schema.String, { column: "title", pg: "text", notNull: true }) },
|
|
637
|
+
* },
|
|
638
|
+
* book: {
|
|
639
|
+
* parent: "work",
|
|
640
|
+
* leaf: { pages: dbColumn(Schema.Finite, { column: "pages", pg: "integer", notNull: false }) },
|
|
641
|
+
* },
|
|
642
|
+
* film: { parent: "work", leaf: {} },
|
|
643
|
+
* });
|
|
644
|
+
*
|
|
645
|
+
* console.log(tree.level("work").subtreeLeaves); // ["book", "film"]
|
|
646
|
+
* console.log(tree.level("book").ancestors); // ["work"]
|
|
647
|
+
* ```
|
|
648
|
+
*/
|
|
649
|
+
export const defineTree: <
|
|
650
|
+
const S extends CheckedTreeSpec<S, Schema.Struct.Fields, Record<never, never>>,
|
|
651
|
+
>(
|
|
652
|
+
spec: S,
|
|
653
|
+
) => Tree<S, FieldsTreeMeta<S>> = defineTreeFor<Record<never, never>>();
|