@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.
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 +102 -4
  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
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>>();