@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
@@ -0,0 +1,1027 @@
1
+ /**
2
+ * @copyright Thomas Foster
3
+ *
4
+ * **Effect Schema → Drizzle derivation: the opt-in versioned-chain interpreter
5
+ * (Drizzle).**
6
+ *
7
+ * The generic "tree of levels, each a stable + version table pair, joined up a
8
+ * composite `(id, version)` FK chain to a root, with fail-closed current-version
9
+ * views" structure — the shape a temporal-versioned node hierarchy takes. Two
10
+ * factories, each parameterised over the bits a specific hierarchy pins:
11
+ *
12
+ * - {@link makeSubtypeTables} — the stable + version table-pair builder. Emits
13
+ * the structural plumbing (stable `id` → parent FK, version `id` / `version`
14
+ * columns, the `(id, version)` primary key, and the `(id, version)` →
15
+ * parent-version FK) while the domain columns stay authored per leaf. The
16
+ * discriminator constraints (a `type` column arced up the tree) are injected,
17
+ * so a hierarchy without a discriminator, or with different discriminator
18
+ * semantics, configures its own.
19
+ *
20
+ * - {@link makeCurrentVersionView} — the uniform fail-closed current-version
21
+ * view. Resolves each node's current version from a **root version table**,
22
+ * then INNER JOINs the leaf (and any intermediate version tables) at exactly
23
+ * that `(id, version)`; a node whose root version advanced past its leaf (a
24
+ * partial-chain tombstone) yields **no row** — the view fails closed rather
25
+ * than surfacing a stale older leaf row. The root version table and the
26
+ * always-joined identity row (plus the columns it merges into every view) are
27
+ * injected.
28
+ *
29
+ * Depends on Drizzle. The annotation layer ({@link .}) stays free of any
30
+ * versioning opinion — this interpreter is opt-in.
31
+ *
32
+ * @module versioned-chain
33
+ */
34
+
35
+ import { and, eq, getTableName, max } from "drizzle-orm";
36
+ import type { ColumnBuilderBase, GetColumnData, SQL } from "drizzle-orm";
37
+ import {
38
+ alias,
39
+ bigint,
40
+ foreignKey,
41
+ primaryKey,
42
+ text,
43
+ timestamp,
44
+ uuid,
45
+ type AnyPgColumn,
46
+ type ExtraConfigColumn,
47
+ type PgBigInt64Builder,
48
+ type PgBuildColumns,
49
+ type PgSchema,
50
+ type PgTable,
51
+ type PgTableExtraConfigValue,
52
+ type PgTableWithColumns,
53
+ type PgTextBuilder,
54
+ type PgTimestampBuilder,
55
+ type PgUUIDBuilder,
56
+ type PgViewWithSelection,
57
+ type SetHasDefault,
58
+ type SetIsPrimaryKey,
59
+ type SetNotNull,
60
+ } from "drizzle-orm/pg-core";
61
+ import * as Arr from "effect/Array";
62
+ import * as Option from "effect/Option";
63
+ import * as Order from "effect/Order";
64
+ import * as Predicate from "effect/Predicate";
65
+ import * as R from "effect/Record";
66
+ import {
67
+ enumCheck,
68
+ // oxlint-disable-next-line effect/no-service-constructor-imports -- `makeDiscriminatorConstraints` is a plain DDL factory over a leaf-set callback, not an Effect service constructor; the rule keys on the `make*` name.
69
+ makeDiscriminatorConstraints,
70
+ type TypedStable,
71
+ } from "./constraints.ts";
72
+ import {
73
+ // oxlint-disable-next-line effect/no-service-constructor-imports -- `makeDeriveColumns` is a plain factory over a builder registry, not an Effect service constructor; the rule keys on the `make*` name.
74
+ makeDeriveColumns,
75
+ type ColumnBuilders,
76
+ type DerivedColumns,
77
+ type DeriveOptions,
78
+ } from "./derive.ts";
79
+ import type { DbColumn } from "./index.ts";
80
+ import { treeError, type Tree } from "./tree.ts";
81
+
82
+ // ── Subtype table pair ────────────────────────────────────────────────
83
+
84
+ /** A parent version table contributes its `(id, version)` key as the FK target. */
85
+ type ParentVersionTable = PgTable & { id: AnyPgColumn; version: AnyPgColumn };
86
+
87
+ /** The stable row's `id` + `type` columns a discriminator hook constrains. */
88
+ export interface DiscriminatorColumns {
89
+ id: AnyPgColumn;
90
+ type: AnyPgColumn;
91
+ }
92
+
93
+ /* oxlint-disable effect/prefer-option-over-null -- Drizzle builder inputs: this library's rule (packages/core/CLAUDE.md) is that any config threaded straight into a Drizzle builder call stays `?: T | undefined`, never `Option` */
94
+ /**
95
+ * Declaration accepted by a {@link makeSubtypeTables} builder.
96
+ *
97
+ * **The optional members below are NOT migrated to `Option`, and must not be.**
98
+ * Two reasons, either sufficient:
99
+ *
100
+ * 1. This is the config a hierarchy hands the Drizzle table builder, and it is
101
+ * handed on unchanged to a consuming application's database package — a
102
+ * signature change here is a signature change for every consumer.
103
+ * 2. Every one of them is threaded straight into a Drizzle builder call
104
+ * (`bigint().default(versionDefault)`, `discriminator`, …). Drizzle reads
105
+ * plain `undefined` as "not supplied"; an `Option` would be an object it
106
+ * would try to emit as a value, producing wrong DDL that no type error and
107
+ * no unit test would catch.
108
+ *
109
+ * The explicit `| undefined` on each is likewise deliberate: a configuring
110
+ * layer can thread its own optional overrides through without per-property
111
+ * conditional spreads (`exactOptionalPropertyTypes`).
112
+ *
113
+ * Constraint and column names are canonical (`${name}_version_pkey`,
114
+ * `${name}_version_${parentVersion}_fk`, an `id` column): no deployed schema
115
+ * needs the historical overrides (effect-drizzle-ext#112, decision 4).
116
+ */
117
+ export interface SubtypeTablesConfig<
118
+ TColumns extends Record<string, ColumnBuilderBase>,
119
+ TDiscriminator,
120
+ > {
121
+ /** Leaf name; the version table is `${name}_version` (e.g. `video`). */
122
+ name: string;
123
+ /**
124
+ * The parent branch's stable `id` column the leaf's stable row references
125
+ * (cascade delete).
126
+ */
127
+ parentId: AnyPgColumn;
128
+ /**
129
+ * The parent branch's version table; the leaf version's `(id, version)`
130
+ * foreign-keys to its `(id, version)`. Its name seeds the FK constraint name
131
+ * (`${name}_version_${parentVersionName}_fk`).
132
+ */
133
+ parentVersion: ParentVersionTable;
134
+ /** The leaf's own domain columns (beyond the generated id/version). */
135
+ columns: TColumns;
136
+ /**
137
+ * Default for the `version` column — e.g. `0n` for branch-extending leaves
138
+ * whose first version row is written with an explicit `0`. Omitted leaves get
139
+ * a plain `NOT NULL` version.
140
+ */
141
+ versionDefault?: bigint | undefined;
142
+ /**
143
+ * Extra version-table constraints/indexes appended after the generated primary
144
+ * key and parent-version foreign key. The callback receives the version
145
+ * table's columns (including any generated column declared in `columns`).
146
+ */
147
+ extraConstraints?:
148
+ | ((table: Record<string, ExtraConfigColumn>) => PgTableExtraConfigValue[])
149
+ | undefined;
150
+ /**
151
+ * The discriminator declaration for this leaf's stable row. When present, the
152
+ * stable `type` column is made `NOT NULL` and the injected discriminator hook
153
+ * appends its constraints; when absent the stable row keeps a nullable `type`.
154
+ * Its shape is whatever the injected hook consumes.
155
+ */
156
+ discriminator?: TDiscriminator | undefined;
157
+ }
158
+ /* oxlint-enable effect/prefer-option-over-null */
159
+
160
+ /**
161
+ * The stable identity row's generated column builders — `id` (the primary key
162
+ * that references the parent branch's stable `id`) and the denormalised leaf
163
+ * `type` discriminator.
164
+ *
165
+ * `type` is a plain {@link PgTextBuilder} rather than a union with its
166
+ * `.notNull()` form because the two reduce: `SetNotNull<T>` is `T & …`, so
167
+ * `SetNotNull<T> | T` collapses to `T` under union subtype reduction. This
168
+ * restates what the compiler already infers for the ternary in the body, it does
169
+ * not widen it — the `NOT NULL` a discriminator adds was never in the inferred
170
+ * type either.
171
+ */
172
+ type SubtypeStableColumnBuilders = {
173
+ id: SetIsPrimaryKey<PgUUIDBuilder>;
174
+ type: PgTextBuilder<[string, ...string[]]>;
175
+ };
176
+
177
+ /**
178
+ * The version row's column builders: the generated `(id, version)` key columns
179
+ * plus the leaf's own domain columns. `version` is a plain
180
+ * `SetNotNull<PgBigInt64Builder>` for the same subtype-reduction reason `type`
181
+ * is above — `bigint().notNull().default(…)` is `HasDefault<SetNotNull<…>>`, a
182
+ * subtype of the no-default branch, so the union the ternary produces has
183
+ * already collapsed.
184
+ *
185
+ * This alias is referenced **both** by {@link SubtypeTables} and by the explicit
186
+ * type arguments at the `schema.table` call that builds the table, and it has to
187
+ * be: see the note on that call.
188
+ */
189
+ type SubtypeVersionColumnBuilders<TColumns extends Record<string, ColumnBuilderBase>> = {
190
+ id: SetNotNull<PgUUIDBuilder>;
191
+ version: SetNotNull<PgBigInt64Builder>;
192
+ } & TColumns;
193
+
194
+ /**
195
+ * The `{ stable, version }` table pair a {@link makeSubtypeTables} builder
196
+ * returns.
197
+ *
198
+ * **Why this is written out rather than inferred.** Declaration emit cannot
199
+ * name the inferred form. `PgBuildColumns<TTableName, TColumnsMap>` is a
200
+ * homomorphic mapped type; when `TColumnsMap` is generic (it contains this
201
+ * builder's `TColumns`) the compiler cannot reduce it, and the declaration
202
+ * printer expands the mapped type's *body* while printing drizzle's own
203
+ * `TTableName` type parameter by name — a name that is not in scope in the
204
+ * emitted `.d.ts`. The result is an unbound type reference: `tsc` resolves it to
205
+ * the error type, `skipLibCheck: true` hides the `TS2304`, and every consumer
206
+ * reading these tables through the emitted declarations silently gets `any`
207
+ * where it should get the column's real type — a consuming application's
208
+ * database package degrading a table's `$inferSelect["id"]` from `string` to
209
+ * `any` with no type error anywhere in the chain (website #1579). This
210
+ * package's own `tests/dist-consumer` type-checks a consumer fixture against
211
+ * the emitted `dist/`, asserting the chain tables' row types precisely to
212
+ * catch this regression (an unbound name degrades them to `any`, and an
213
+ * `Equals<…>` assertion against `any` is false); it is the gate this module
214
+ * exists chiefly to satisfy.
215
+ *
216
+ * An explicit annotation is emitted verbatim as a type *reference*, so the
217
+ * printer never expands the mapped type and `TTableName` stays bound to
218
+ * drizzle's own declaration. Both `PgBuildColumns` instantiations below restate
219
+ * exactly the arguments the call sites already infer — the tables are still
220
+ * built by `schema.table(…)`, and this only names what it returns.
221
+ *
222
+ * Identical behaviour under TypeScript 6.0.3 and the 7.x tsgo build; this is a
223
+ * printer limitation in both, not a version-specific bug.
224
+ */
225
+ export interface SubtypeTables<TColumns extends Record<string, ColumnBuilderBase>> {
226
+ stable: PgTableWithColumns<{
227
+ name: string;
228
+ schema: string;
229
+ columns: PgBuildColumns<string, SubtypeStableColumnBuilders>;
230
+ dialect: "pg";
231
+ isAlias: false;
232
+ }>;
233
+ version: PgTableWithColumns<{
234
+ name: `${string}_version`;
235
+ schema: string;
236
+ columns: PgBuildColumns<`${string}_version`, SubtypeVersionColumnBuilders<TColumns>>;
237
+ dialect: "pg";
238
+ isAlias: false;
239
+ }>;
240
+ }
241
+
242
+ /**
243
+ * Build a `defineSubtypeTables` bound to a discriminator hook. The hook turns a
244
+ * leaf's `discriminator` declaration into the stable-table constraints that
245
+ * enforce it (e.g. a `UNIQUE(id, type)` + composite `(id, type)` → parent FK +
246
+ * per-level CHECK trio); a hierarchy without a discriminator passes a hook that
247
+ * is simply never invoked (its leaves omit `discriminator`).
248
+ *
249
+ * The returned builder emits a regular subtype's `{ stable, version }` table
250
+ * pair with the composite-`(id, version)` FK chain to its parent branch.
251
+ */
252
+ export const makeSubtypeTables =
253
+ <TDiscriminator>(
254
+ applyDiscriminator: (
255
+ level: string,
256
+ stable: DiscriminatorColumns,
257
+ discriminator: TDiscriminator,
258
+ ) => PgTableExtraConfigValue[],
259
+ ) =>
260
+ <TColumns extends Record<string, ColumnBuilderBase>>(
261
+ schema: PgSchema,
262
+ {
263
+ name,
264
+ parentId,
265
+ parentVersion,
266
+ columns,
267
+ versionDefault,
268
+ extraConstraints,
269
+ discriminator,
270
+ }: SubtypeTablesConfig<TColumns, TDiscriminator>,
271
+ ): SubtypeTables<TColumns> => {
272
+ const stableColumns = {
273
+ id: uuid("id")
274
+ .primaryKey()
275
+ .references((): AnyPgColumn => parentId, { onDelete: "cascade" }),
276
+ // The threaded leaf-`type` discriminator, denormalised down every stable
277
+ // level. Nullable until a hierarchy applies its discriminator; the
278
+ // injected hook makes it NOT NULL and adds its constraints. `type` lives on
279
+ // the STABLE (identity) row only — never versioned — so the `(id, version)`
280
+ // arc is untouched.
281
+ type: discriminator ? text("type").notNull() : text("type"),
282
+ };
283
+ const stable = discriminator
284
+ ? schema.table(name, stableColumns, (table) =>
285
+ applyDiscriminator(name, { id: table.id, type: table.type }, discriminator),
286
+ )
287
+ : schema.table(name, stableColumns);
288
+
289
+ // The type arguments are passed explicitly, and through the same
290
+ // `SubtypeVersionColumnBuilders` alias the `SubtypeTables` return
291
+ // annotation uses, so that both sides are the *identical* instantiation.
292
+ // They are exactly the arguments inference picks on its own — this pins
293
+ // them, it does not change them.
294
+ //
295
+ // It has to be pinned because `PgBuildColumns` bottoms out in key-remapped
296
+ // mapped types (`$inferInsert`'s `RequiredKeyOnly` / `OptionalKeyOnly`),
297
+ // and while `TColumns` is still generic those are related only by
298
+ // reference identity, never structurally. Spell the same type two ways —
299
+ // inferred here, written there — and tsgo rejects the annotation as
300
+ // unrelated to a type printed character-for-character the same (TS2719).
301
+ // Stock tsc 6.0.3 accepts that form; tsgo 7.0.2+effect-tsgo.0.24.3 does
302
+ // not, so the alias is shared rather than repeated.
303
+ const version = schema.table<`${string}_version`, SubtypeVersionColumnBuilders<TColumns>>(
304
+ `${name}_version`,
305
+ {
306
+ id: uuid("id")
307
+ .notNull()
308
+ .references((): AnyPgColumn => stable.id, { onUpdate: "cascade", onDelete: "cascade" }),
309
+ version:
310
+ versionDefault === undefined
311
+ ? bigint("version", { mode: "bigint" }).notNull()
312
+ : bigint("version", { mode: "bigint" }).notNull().default(versionDefault),
313
+ ...columns,
314
+ },
315
+ (table) => {
316
+ const constraints: PgTableExtraConfigValue[] = [
317
+ primaryKey({
318
+ name: `${name}_version_pkey`,
319
+ columns: [table.id, table.version],
320
+ }),
321
+ foreignKey({
322
+ name: `${name}_version_${getTableName(parentVersion)}_fk`,
323
+ columns: [table.id, table.version],
324
+ foreignColumns: [parentVersion.id, parentVersion.version],
325
+ }),
326
+ ];
327
+ return extraConstraints ? [...constraints, ...extraConstraints(table)] : constraints;
328
+ },
329
+ );
330
+
331
+ return { stable, version };
332
+ };
333
+
334
+ // ── Current-version view ──────────────────────────────────────────────
335
+
336
+ /** A version table participates in the chain via its `(id, version)` key. */
337
+ interface VersionTable {
338
+ id: AnyPgColumn;
339
+ version: AnyPgColumn;
340
+ }
341
+
342
+ /** A stable identity table is joined on `id` alone. */
343
+ interface StableTable {
344
+ id: AnyPgColumn;
345
+ }
346
+
347
+ /** The explicit, projection-shaped column selection a leaf authors. */
348
+ type ViewColumns = Record<string, AnyPgColumn | SQL | SQL.Aliased>;
349
+
350
+ /** Resolve one selected field to its inferred (nullability-aware) row type. */
351
+ type ViewField<TField> = TField extends AnyPgColumn
352
+ ? GetColumnData<TField>
353
+ : TField extends SQL<infer U>
354
+ ? U
355
+ : TField extends SQL.Aliased<infer U>
356
+ ? U
357
+ : never;
358
+
359
+ /** Inferred `$inferSelect` row for an explicit {@link ViewColumns} map. */
360
+ type ViewRow<TColumns extends ViewColumns> = { [K in keyof TColumns]: ViewField<TColumns[K]> };
361
+
362
+ /**
363
+ * The view type a {@link makeCurrentVersionView} builder returns: a real Drizzle
364
+ * view (usable in `from` / `join` and exposing the column proxies for
365
+ * `eq(view.col, …)`) with `$inferSelect` pinned to the explicit column map plus
366
+ * the injected always-merged columns `TMerge`.
367
+ *
368
+ * The selection is built directly from `columns` + `merge`, so this never
369
+ * depends on the in-body select's nullability/table-name generics — which
370
+ * collapse to `{ [x: string]: never }` because the join chain is assembled
371
+ * untyped.
372
+ */
373
+ type CurrentVersionViewResult<TColumns extends ViewColumns, TMerge extends ViewColumns> = Omit<
374
+ PgViewWithSelection<{
375
+ name: string;
376
+ schema: string;
377
+ existing: false;
378
+ selectedFields: TColumns & TMerge;
379
+ isAlias: false;
380
+ }>,
381
+ "$inferSelect"
382
+ > & {
383
+ $inferSelect: ViewRow<TColumns & TMerge>;
384
+ };
385
+
386
+ /* oxlint-disable effect/prefer-option-over-null -- Drizzle join inputs: this library's rule (packages/core/CLAUDE.md) is that any config threaded straight into a Drizzle builder call stays `?: T | undefined`, never `Option` */
387
+ /**
388
+ * Declaration accepted by a {@link makeCurrentVersionView} builder.
389
+ *
390
+ * **The optional members below are NOT migrated to `Option`, and must not be** —
391
+ * for the same two reasons as {@link SubtypeTablesConfig}: the interface is
392
+ * handed on unchanged to a consuming application's database package, and each
393
+ * member is spread straight into the generated Drizzle join chain
394
+ * (`for (const table of through ?? [])`), where `undefined` means "no such
395
+ * join" and an `Option` would be a truthy object.
396
+ */
397
+ export interface CurrentVersionViewConfig<TColumns extends ViewColumns> {
398
+ /** Leaf version table the view resolves to its latest row. */
399
+ leaf: VersionTable;
400
+ /**
401
+ * Intermediate version tables joined on the composite `(id, version)` key,
402
+ * ordered leaf → branch. The root version table is always appended after these
403
+ * by the builder. A **required fact table** (a 6NF satellite keyed
404
+ * `(id, version)` whose row must exist at every version) also joins here —
405
+ * the INNER JOIN doubles as its fail-closed witness.
406
+ */
407
+ through?: VersionTable[] | undefined;
408
+ /**
409
+ * **Optional fact tables** LEFT JOINed at the current `(id, version)`: 6NF
410
+ * satellites whose row-absence at a version encodes NULL (the fact-scoped
411
+ * version storage decision). Selected columns sourced from these must be
412
+ * projected nullability-aware (an `sql<T | null>` aliased expression), since
413
+ * the LEFT JOIN widens a NOT NULL fact column to nullable in the row.
414
+ */
415
+ optional?: VersionTable[] | undefined;
416
+ /** Stable identity tables joined on `id` (explicit `undefined` admitted, as above). */
417
+ on?: StableTable[] | undefined;
418
+ /** Explicit column selection, including `id`, `action`, and `timestamp`. */
419
+ columns: TColumns;
420
+ }
421
+ /* oxlint-enable effect/prefer-option-over-null */
422
+
423
+ /** Injected dependencies for a {@link makeCurrentVersionView} builder. */
424
+ export interface CurrentVersionViewDeps<
425
+ TRoot extends PgTable & VersionTable,
426
+ TMerge extends ViewColumns,
427
+ > {
428
+ /**
429
+ * The root version table currency resolves from — a `MAX(version)` sub-select
430
+ * groups it by `id`, and the leaf / intermediate version tables INNER JOIN at
431
+ * that current `(id, version)`. Also joined for the current row's own columns
432
+ * (e.g. an `action` soft-delete gate and a `timestamp`).
433
+ */
434
+ rootVersion: TRoot;
435
+ /** The always-joined identity row (joined on `id`). */
436
+ identity: StableTable;
437
+ /**
438
+ * Columns merged into every view's selection (sourced from `identity`), so no
439
+ * leaf needs to name them — e.g. a shared `slug`.
440
+ *
441
+ * Passing a **function** instead of a plain object additionally INNER JOINs a
442
+ * `version = 0` alias of `rootVersion` — the node's birth row — and hands it
443
+ * to the callback, so a hierarchy can merge in a true `createdAt` sourced
444
+ * from the first version's own timestamp, distinct from the current
445
+ * version's. The plain-object form skips that join entirely, leaving the
446
+ * emitted view definition byte-identical to before: this matters for a root
447
+ * version table whose current-version view is itself joined **as a view** by
448
+ * another hand-written view, where changing its definition would require
449
+ * drizzle-kit to DROP it — and drizzle-kit doesn't topologically order
450
+ * view-on-view DROPs, so it fails against the still-dependent view.
451
+ *
452
+ * That hazard is confined to a hierarchy's own hand-written views layered on
453
+ * top: the join chain this builder generates only ever reaches version and
454
+ * stable tables (`leaf`, `through`, `optional`, `on`, `identity`,
455
+ * `rootVersion`), never a {@link CurrentVersionViewResult} this module
456
+ * itself returns. No view built by `makeCurrentVersionView` may select from
457
+ * another view built by `makeCurrentVersionView` — that is a library-level
458
+ * rule, not just this comment's observation.
459
+ */
460
+ merge: TMerge | ((versionZero: TRoot) => TMerge);
461
+ }
462
+
463
+ /** Whether a `merge` is the function form, which receives the birth-row alias. */
464
+ const isMergeFunction = <TRoot, TMerge>(
465
+ merge: TMerge | ((versionZero: TRoot) => TMerge),
466
+ ): merge is (versionZero: TRoot) => TMerge => Predicate.isFunction(merge);
467
+
468
+ /**
469
+ * Build a `currentVersionView` bound to a root version table + identity row.
470
+ * `schema.view(name).as(...)` with the uniform join chain generated from
471
+ * {@link CurrentVersionViewConfig}.
472
+ *
473
+ * The joins only widen the `FROM` clause; they never change the explicit
474
+ * `columns` selection, so the view's `$inferSelect` is derived directly from
475
+ * `columns` + the injected `merge` via {@link CurrentVersionViewResult}. The
476
+ * runtime query is assembled untyped (the chain alternates join shapes), which
477
+ * is why the return type is asserted rather than inferred: letting `.as()` infer
478
+ * the selection through `.from(current)` collapses every column to `never`.
479
+ *
480
+ * Currency is resolved from `rootVersion` (fail-closed): a `MAX(version)`
481
+ * sub-select drives the FROM, and the leaf / intermediate version tables INNER
482
+ * JOIN at that current `(id, version)`. One row per node (the sub-select groups
483
+ * by id) landing on unique `(id, version)` PKs, so no `DISTINCT ON` / `ORDER BY`
484
+ * is needed. A leaf missing at the current version drops out of the INNER JOIN —
485
+ * the view yields no row.
486
+ */
487
+ export const makeCurrentVersionView =
488
+ <TRoot extends PgTable & VersionTable, TMerge extends ViewColumns>({
489
+ rootVersion,
490
+ identity,
491
+ merge,
492
+ }: CurrentVersionViewDeps<TRoot, TMerge>) =>
493
+ <TColumns extends ViewColumns>(
494
+ schema: PgSchema,
495
+ name: string,
496
+ { leaf, through = [], optional = [], on = [], columns }: CurrentVersionViewConfig<TColumns>,
497
+ ): CurrentVersionViewResult<TColumns, TMerge> => {
498
+ // `.from()` infers its selection from the table's generic; pinned to the
499
+ // `PgTable` base it is, the sub-select keeps its explicit selection.
500
+ const rootTable: PgTable = rootVersion;
501
+ const view = schema.view(name).as((qb) => {
502
+ // Current version per node, resolved from the root version table — the
503
+ // single home of currency. This sub-select drives the FROM so the leaf
504
+ // join below fails closed on a partial chain.
505
+ const current = qb
506
+ .select({ id: rootVersion.id, version: max(rootVersion.version).as("current_version") })
507
+ .from(rootTable)
508
+ .groupBy(rootVersion.id)
509
+ .as("current_version");
510
+ // A function `merge` additionally resolves the node's birth row (`version
511
+ // = 0`) via an aliased join, so it can merge in a true `createdAt`. A
512
+ // plain-object `merge` skips this entirely — no extra join, no change to
513
+ // the emitted SQL beyond what `columns` already determines (see the
514
+ // `CurrentVersionViewDeps.merge` doc for why that matters).
515
+ const versionZero = isMergeFunction(merge) ? alias(rootVersion, "version_zero") : undefined;
516
+ const mergeColumns =
517
+ // SAFETY: `versionZero` is an alias of `rootVersion` (defined whenever
518
+ // `merge` is a function), exposing the same columns under another name.
519
+ isMergeFunction(merge) ? merge(versionZero as TRoot) : merge;
520
+ // `select(columns)` fixes the selection; the join chain appended below only
521
+ // widens the FROM. The chain is built untyped because its join shapes
522
+ // alternate, and the builder's return type re-pins the selection from
523
+ // `columns` + `merge`. The injected `merge` columns ride along on every
524
+ // view — merged into the fixed selection here, resolved via the identity
525
+ // (and, if present, version-zero) join below.
526
+ const select = qb.select({ ...columns, ...mergeColumns }).from(current);
527
+ // reason: dynamic join chain.
528
+ let q: any = select;
529
+ q = q.innerJoin(leaf, and(eq(current.id, leaf.id), eq(current.version, leaf.version)));
530
+ q = Arr.reduce(through, q, (acc, t) =>
531
+ acc.innerJoin(t, and(eq(current.id, t.id), eq(current.version, t.version))),
532
+ );
533
+ q = Arr.reduce(optional, q, (acc, t) =>
534
+ acc.leftJoin(t, and(eq(current.id, t.id), eq(current.version, t.version))),
535
+ );
536
+ q = Arr.reduce(on, q, (acc, t) => acc.innerJoin(t, eq(current.id, t.id)));
537
+ q = q.innerJoin(identity, eq(current.id, identity.id));
538
+ q = q.innerJoin(
539
+ rootVersion,
540
+ and(eq(current.id, rootVersion.id), eq(current.version, rootVersion.version)),
541
+ );
542
+ if (versionZero) {
543
+ q = q.innerJoin(
544
+ versionZero,
545
+ and(eq(current.id, versionZero.id), eq(versionZero.version, 0n)),
546
+ );
547
+ }
548
+ // SAFETY: the joins only widen the FROM clause; the selection is still
549
+ // exactly `select`'s.
550
+ return q as typeof select;
551
+ });
552
+ // SAFETY: the view selects exactly `columns` + `merge` (see the doc above);
553
+ // `CurrentVersionViewResult` re-pins that selection's `$inferSelect`.
554
+ return view as CurrentVersionViewResult<TColumns, TMerge>;
555
+ };
556
+
557
+ // ── Version-chain read shapes ─────────────────────────────────────────
558
+
559
+ /**
560
+ * The actions a version-chain row records by default: a chain appends a
561
+ * `(version, action, timestamp)` row per edit rather than rewriting in place,
562
+ * and a delete is a tombstone version.
563
+ */
564
+ export type VersionAction = "create" | "update" | "delete";
565
+
566
+ /**
567
+ * One entry in a node's append-only version chain — what a version-history
568
+ * listing returns per row. `Action` is the chain's own action vocabulary
569
+ * (default {@link VersionAction}).
570
+ */
571
+ export interface VersionChainEntry<Action extends string = VersionAction> {
572
+ /** The version number (0 is the create). */
573
+ readonly version: bigint;
574
+ /** The edit action this version recorded. */
575
+ readonly action: Action;
576
+ /** When the version was written. */
577
+ readonly timestamp: Date;
578
+ }
579
+
580
+ /**
581
+ * One version's reconstructed state — what a browse / compare surface shows,
582
+ * and what a restore re-applies as a new version. Every leaf-chain domain
583
+ * column at `(id, version)`, keyed by column name (leaf-most level wins on a
584
+ * shared name), plus the root chain's `action` / `timestamp`. Generated
585
+ * columns (a search vector) are opaque and belong outside `columns`.
586
+ *
587
+ * `Extra` carries the branch-level facts a hierarchy adds to a snapshot (a
588
+ * content branch's visibility and draft flag, say); the library knows none of
589
+ * them.
590
+ */
591
+ export type VersionSnapshot<
592
+ Action extends string = VersionAction,
593
+ Extra extends object = Record<never, never>,
594
+ > = VersionChainEntry<Action> &
595
+ Extra & {
596
+ /** Every leaf-chain domain column at this version, keyed by column name. */
597
+ // oxlint-disable-next-line effect/no-unsafe-dictionary-type -- public type: column values are whatever the driver decodes for the consumer's columns; narrowing it would break producers
598
+ readonly columns: Readonly<Record<string, unknown>>;
599
+ };
600
+
601
+ /** The `createdAt` / `updatedAt` pair {@link versionTimestamps} returns. */
602
+ export interface VersionTimestamps {
603
+ /** The birth row's timestamp, or `updatedAt` when the view merges none in. */
604
+ readonly createdAt: Date;
605
+ /** The current version's `timestamp`. */
606
+ readonly updatedAt: Date;
607
+ }
608
+
609
+ /**
610
+ * The `createdAt` / `updatedAt` pair of a current-version row: `updatedAt` is
611
+ * the current version's `timestamp`; `createdAt` is the birth row's own
612
+ * timestamp where the view merges one in (a function `merge` of
613
+ * {@link makeCurrentVersionView}), and falls back to `updatedAt` for a view
614
+ * that carries none (website #1223).
615
+ */
616
+ /* oxlint-disable effect/prefer-option-over-null -- a Drizzle row seam: a view row's nullable column is typed `T | null` by Drizzle itself, and a view without the birth-row merge has no `createdAt` key at all */
617
+ export const versionTimestamps = (row: {
618
+ readonly timestamp: Date;
619
+ readonly createdAt?: Date | null | undefined;
620
+ }): VersionTimestamps => ({
621
+ createdAt: row.createdAt ?? row.timestamp,
622
+ updatedAt: row.timestamp,
623
+ });
624
+ /* oxlint-enable effect/prefer-option-over-null */
625
+
626
+ // ── Tree → tables ─────────────────────────────────────────────────────
627
+
628
+ /** A builder map a table's `columns` takes. */
629
+ type ColumnMap = Record<string, ColumnBuilderBase>;
630
+
631
+ /** `T` as a {@link ColumnMap}, so a mapped column type satisfies the table constraint. */
632
+ type AsColumns<T> = T extends ColumnMap ? T : never;
633
+
634
+ /** The metadata map member `P` of level `K` in tree metadata `M`; `{}` when absent. */
635
+ type MetaOf<M, K, P extends "level" | "leaf"> = K extends keyof M
636
+ ? M[K] extends { readonly [Q in P]: infer X }
637
+ ? X
638
+ : Record<never, never>
639
+ : Record<never, never>;
640
+
641
+ /** The hand-authored columns the hooks `H` give level `K`; `{}` when none. */
642
+ type HookColumnsOf<H, K> = K extends keyof H
643
+ ? H[K] extends { readonly columns?: infer C }
644
+ ? Exclude<C, undefined> extends ColumnMap
645
+ ? Exclude<C, undefined>
646
+ : Record<never, never>
647
+ : Record<never, never>
648
+ : Record<never, never>;
649
+
650
+ /** Whether an entry declares the `"fact-scoped"` level kind. */
651
+ type IsFactScoped<E> = E extends { readonly kind?: infer Kind }
652
+ ? [Exclude<Kind, undefined>] extends ["fact-scoped"]
653
+ ? true
654
+ : false
655
+ : false;
656
+
657
+ /** Level `K`'s own derived columns: its level columns, then its leaf's. */
658
+ type OwnColumns<M, K, B extends ColumnBuilders> = DerivedColumns<MetaOf<M, K, "level">, B> &
659
+ DerivedColumns<MetaOf<M, K, "leaf">, B>;
660
+
661
+ /**
662
+ * The columns level `K`'s version table carries beyond `(id, version)`: its own
663
+ * derived columns (a fact-scoped level keeps them in fact tables instead), then
664
+ * the hand-authored hook columns.
665
+ */
666
+ export type TreeVersionColumns<S, M, B extends ColumnBuilders, H, K extends keyof S> = AsColumns<
667
+ (IsFactScoped<S[K]> extends true ? Record<never, never> : OwnColumns<M, K, B>) &
668
+ HookColumnsOf<H, K>
669
+ >;
670
+
671
+ /** A fact table's column builders: the `(id, version)` key plus one fact. */
672
+ type FactColumnBuilders<TColumns extends ColumnMap> = {
673
+ id: SetNotNull<PgUUIDBuilder>;
674
+ version: SetNotNull<PgBigInt64Builder>;
675
+ } & TColumns;
676
+
677
+ /**
678
+ * One fact table of a fact-scoped level: `(id, version)` keyed, carrying one
679
+ * column, foreign-keyed to the level's version spine. Written out for the
680
+ * declaration printer, as {@link SubtypeTables} is.
681
+ */
682
+ export type FactTable<TColumns extends ColumnMap> = PgTableWithColumns<{
683
+ name: string;
684
+ schema: string;
685
+ columns: PgBuildColumns<string, FactColumnBuilders<TColumns>>;
686
+ dialect: "pg";
687
+ isAlias: false;
688
+ }>;
689
+
690
+ /** Every level's `{ stable, version }` pair, keyed by level name. */
691
+ export type TreeLevelTables<S, M, B extends ColumnBuilders, H> = {
692
+ readonly [K in keyof S & string]: SubtypeTables<TreeVersionColumns<S, M, B, H, K>>;
693
+ };
694
+
695
+ /** Every fact-scoped level's fact tables, keyed by level then field; `{}` for a plain level. */
696
+ export type TreeFactTables<S, M, B extends ColumnBuilders> = {
697
+ readonly [K in keyof S & string]: IsFactScoped<S[K]> extends true
698
+ ? {
699
+ readonly [F in keyof OwnColumns<M, K, B>]: FactTable<
700
+ AsColumns<Pick<OwnColumns<M, K, B>, F>>
701
+ >;
702
+ }
703
+ : Record<never, never>;
704
+ };
705
+
706
+ /** The root identity table's column builders: `id`, the leaf `type`, then the injected extras. */
707
+ type RootIdentityColumnBuilders<IE extends ColumnMap> = {
708
+ id: SetIsPrimaryKey<PgUUIDBuilder>;
709
+ type: SetNotNull<PgTextBuilder<[string, ...string[]]>>;
710
+ } & IE;
711
+
712
+ /**
713
+ * The root version table's column builders: the `(id, version)` key, `action`
714
+ * (the chain's only currency and tombstone state), `timestamp`, the actor,
715
+ * then the injected extras.
716
+ */
717
+ type RootVersionColumnBuilders<VE extends ColumnMap> = {
718
+ id: SetNotNull<PgUUIDBuilder>;
719
+ version: SetNotNull<PgBigInt64Builder>;
720
+ action: SetNotNull<PgTextBuilder<[string, ...string[]]>>;
721
+ timestamp: SetHasDefault<SetNotNull<PgTimestampBuilder>>;
722
+ actor: PgTextBuilder<[string, ...string[]]>;
723
+ } & VE;
724
+
725
+ /** The root `{ identity, version }` pair. Written out for the declaration printer. */
726
+ export interface TreeRootTables<IE extends ColumnMap, VE extends ColumnMap> {
727
+ identity: PgTableWithColumns<{
728
+ name: string;
729
+ schema: string;
730
+ columns: PgBuildColumns<string, RootIdentityColumnBuilders<IE>>;
731
+ dialect: "pg";
732
+ isAlias: false;
733
+ }>;
734
+ version: PgTableWithColumns<{
735
+ name: string;
736
+ schema: string;
737
+ columns: PgBuildColumns<string, RootVersionColumnBuilders<VE>>;
738
+ dialect: "pg";
739
+ isAlias: false;
740
+ }>;
741
+ }
742
+
743
+ /** Everything {@link makeTreeTables} derives from a tree. */
744
+ export interface TreeTables<
745
+ S,
746
+ M,
747
+ B extends ColumnBuilders,
748
+ H,
749
+ IE extends ColumnMap,
750
+ VE extends ColumnMap,
751
+ > {
752
+ /** The root identity and version tables. */
753
+ readonly root: TreeRootTables<IE, VE>;
754
+ /** Every level's stable + version pair, keyed by level name. */
755
+ readonly levels: TreeLevelTables<S, M, B, H>;
756
+ /** Every fact-scoped level's fact tables, keyed by level then field. */
757
+ readonly facts: TreeFactTables<S, M, B>;
758
+ }
759
+
760
+ /* oxlint-disable effect/prefer-option-over-null -- Drizzle builder inputs: this library's rule (packages/core/CLAUDE.md) is that any config threaded straight into a Drizzle builder call stays `?: T | undefined`, never `Option` */
761
+ /**
762
+ * Per-level hooks for what a model can't carry. Every member is threaded into
763
+ * a Drizzle builder call, so each is `?: T | undefined` (see
764
+ * {@link SubtypeTablesConfig}).
765
+ */
766
+ export interface TreeLevelHooks {
767
+ /** Hand-authored version-table columns (an FK backing a relationship, a generated column). */
768
+ readonly columns?: ColumnMap | undefined;
769
+ /** Extra version-table constraints and indexes, after the generated key and FK. */
770
+ readonly extraConstraints?:
771
+ | ((table: Record<string, ExtraConfigColumn>) => PgTableExtraConfigValue[])
772
+ | undefined;
773
+ /**
774
+ * A refinable branch: the `(id, type)` arc of this level and of every level
775
+ * below it gets `ON UPDATE CASCADE`, so flipping the root's `type` from a
776
+ * bare value to a concrete leaf cascades down the existing chain (website's
777
+ * entity refinement).
778
+ */
779
+ readonly refinable?: boolean | undefined;
780
+ }
781
+
782
+ /** The root tables' names and injected columns. */
783
+ export interface TreeRootConfig<IE extends ColumnMap, VE extends ColumnMap> {
784
+ /** The identity table's name; `node` when absent. */
785
+ readonly identity?: string | undefined;
786
+ /** The root version table's name; `${identity}_version` when absent. */
787
+ readonly version?: string | undefined;
788
+ /** Extra identity columns (website's `slug`). */
789
+ readonly identityColumns?: IE | undefined;
790
+ /** Extra root-version columns (pulpit's `proposed_by`). */
791
+ readonly versionColumns?: VE | undefined;
792
+ /** The actor column's name; `actor` when absent. */
793
+ readonly actorColumn?: string | undefined;
794
+ }
795
+
796
+ /** The configuration {@link makeTreeTables} takes. */
797
+ export interface TreeTablesConfig<
798
+ B extends ColumnBuilders,
799
+ H,
800
+ IE extends ColumnMap,
801
+ VE extends ColumnMap,
802
+ > {
803
+ /** The schema every table is created in. */
804
+ readonly schema: PgSchema;
805
+ /** The builder registry the `dbColumn` kinds resolve against. */
806
+ readonly builders: B;
807
+ /** Resolves a `references` annotation to its target column (see `./derive`). */
808
+ readonly resolveReference?: DeriveOptions["resolveReference"];
809
+ /** The root tables' names and injected columns. */
810
+ readonly root?: TreeRootConfig<IE, VE> | undefined;
811
+ /** Per-level hooks, keyed by level name. */
812
+ readonly levels?: H | undefined;
813
+ }
814
+
815
+ /**
816
+ * An injected extras map, or `{}` when the member is absent.
817
+ *
818
+ * SAFETY: `T` is inferred from this very member, so it is a real map when the
819
+ * member is present and defaults to `{}` (the `IE` / `VE` defaults of
820
+ * {@link makeTreeTables}) exactly when it is absent.
821
+ */
822
+ const columnsOrEmpty = <T extends ColumnMap>(columns: T | undefined): T => columns ?? ({} as T);
823
+ /* oxlint-enable effect/prefer-option-over-null */
824
+
825
+ /** The `action` values the root version table admits. */
826
+ const VERSION_ACTIONS: ReadonlyArray<VersionAction> = ["create", "update", "delete"];
827
+
828
+ /**
829
+ * Derive every table and constraint of a tree in one call: the root identity
830
+ * table (`id`, `type`, extras; `UNIQUE (id, type)`) and root version table
831
+ * (`(id, version)` key, `action` checked to `create` / `update` / `delete`,
832
+ * `timestamp`, the actor, extras); then per level a stable table (`id` →
833
+ * parent `id`, cascade delete; `type` NOT NULL with `UNIQUE (id, type)`,
834
+ * `(id, type)` → parent `(id, type)`, and a CHECK narrowing `type` to the
835
+ * level's subtree leaves) and a version table (`(id, version)` key and
836
+ * `(id, version)` → parent version FK) carrying the level's level columns,
837
+ * its leaf's columns (both derived through `builders`) and its hook columns.
838
+ * A fact-scoped level's version table is a spine, with one
839
+ * `${level}_${column}` fact table per own column, keyed and foreign-keyed on
840
+ * `(id, version)` (cascade delete).
841
+ *
842
+ * Constraint names are canonical (`${table}_pkey`, `${table}_${parent}_fk`,
843
+ * `${level}_id_type_unique` / `_fk` / `_type_valid`); no version table carries
844
+ * `action`. Column, constraint and CHECK value order follow declaration order,
845
+ * so the same tree always generates the same migration. Throws
846
+ * `DerivationError` at module load for a column kind `builders` lacks or an
847
+ * unresolvable reference.
848
+ *
849
+ * Every table is typed from the tree's column phantom. The one type-erasure
850
+ * seam is inside: the level and fact records are assembled from the runtime
851
+ * tree, then named by {@link TreeTables}.
852
+ *
853
+ * @example
854
+ * ```ts
855
+ * import { dbColumn, defineTree } from "@thomasfosterau/effect-drizzle-ext";
856
+ * import { makeTreeTables } from "@thomasfosterau/effect-drizzle-ext/versioned-chain";
857
+ * import { getTableConfig, integer, pgSchema, text } from "drizzle-orm/pg-core";
858
+ * import * as Schema from "effect/Schema";
859
+ *
860
+ * const tree = defineTree({
861
+ * work: {
862
+ * columns: { title: dbColumn(Schema.String, { column: "title", pg: "text", notNull: true }) },
863
+ * },
864
+ * book: {
865
+ * parent: "work",
866
+ * leaf: { pages: dbColumn(Schema.Finite, { column: "pages", pg: "integer", notNull: false }) },
867
+ * },
868
+ * });
869
+ *
870
+ * const tables = makeTreeTables(tree, {
871
+ * schema: pgSchema("catalogue"),
872
+ * builders: { text: (c: string) => text(c), integer: (c: string) => integer(c) },
873
+ * });
874
+ *
875
+ * console.log(getTableConfig(tables.levels.book.version).columns.map((c) => c.name));
876
+ * // ["id", "version", "pages"]
877
+ * ```
878
+ */
879
+ export const makeTreeTables = <
880
+ S,
881
+ M,
882
+ B extends ColumnBuilders,
883
+ const H extends { readonly [K in keyof S]?: TreeLevelHooks } = Record<never, never>,
884
+ IE extends ColumnMap = Record<never, never>,
885
+ VE extends ColumnMap = Record<never, never>,
886
+ >(
887
+ tree: Tree<S, M>,
888
+ config: TreeTablesConfig<B, H, IE, VE>,
889
+ ): TreeTables<S, M, B, H, IE, VE> => {
890
+ const { schema } = config;
891
+ const derive = makeDeriveColumns(config.builders, { resolveReference: config.resolveReference });
892
+ const identityName = config.root?.identity ?? "node";
893
+ const versionName = config.root?.version ?? `${identityName}_version`;
894
+ const toolkit = makeDiscriminatorConstraints<string>({
895
+ leaves: (level) =>
896
+ Option.getOrElse(
897
+ Option.map(
898
+ Arr.findFirst(tree.levels, (candidate) => candidate.name === level),
899
+ (found) => found.subtreeLeaves,
900
+ ),
901
+ () => [],
902
+ ),
903
+ });
904
+
905
+ const identity = schema.table<string, RootIdentityColumnBuilders<IE>>(
906
+ identityName,
907
+ {
908
+ id: uuid("id").primaryKey(),
909
+ type: text("type").notNull(),
910
+ ...columnsOrEmpty(config.root?.identityColumns),
911
+ },
912
+ (table) => [toolkit.typeUnique(identityName, { id: table.id, type: table.type })],
913
+ );
914
+ const version = schema.table<string, RootVersionColumnBuilders<VE>>(
915
+ versionName,
916
+ {
917
+ id: uuid("id")
918
+ .notNull()
919
+ .references((): AnyPgColumn => identity.id, { onDelete: "cascade" }),
920
+ version: bigint("version", { mode: "bigint" }).notNull(),
921
+ action: text("action").notNull(),
922
+ timestamp: timestamp("timestamp", { withTimezone: true }).notNull().defaultNow(),
923
+ actor: text(config.root?.actorColumn ?? "actor"),
924
+ ...columnsOrEmpty(config.root?.versionColumns),
925
+ },
926
+ (table) => [
927
+ primaryKey({ name: `${versionName}_pkey`, columns: [table.id, table.version] }),
928
+ enumCheck(`${versionName}_action_valid`, table.action, VERSION_ACTIONS),
929
+ ],
930
+ );
931
+
932
+ const hooksOf = (name: keyof S & string): TreeLevelHooks => config.levels?.[name] ?? {};
933
+
934
+ /** One built level: its pair, plus a fact-scoped level's fact tables. */
935
+ interface BuiltLevel {
936
+ readonly tables: SubtypeTables<ColumnMap>;
937
+ readonly facts: Readonly<Record<string, PgTable>>;
938
+ }
939
+ const subtype = makeSubtypeTables(
940
+ (level, stable, parent: { readonly stable: TypedStable; readonly refinable: boolean }) =>
941
+ toolkit.typeConstraints(level, stable, parent.stable, { refinable: parent.refinable }),
942
+ );
943
+ const builtLevel = (built: Readonly<Record<string, BuiltLevel>>, name: string): BuiltLevel =>
944
+ Option.getOrElse(R.get(built, name), () =>
945
+ treeError(`level "${name}" was read before it was built`),
946
+ );
947
+ const buildLevel = (
948
+ built: Readonly<Record<string, BuiltLevel>>,
949
+ level: Tree<S, M>["levels"][number],
950
+ ): BuiltLevel => {
951
+ const parent: { readonly stable: TypedStable; readonly version: ParentVersionTable } =
952
+ Option.match(level.parent, {
953
+ onNone: () => ({ stable: identity, version }),
954
+ onSome: (name) => builtLevel(built, name).tables,
955
+ });
956
+ const hooks = hooksOf(level.name);
957
+ const metas = { ...level.levelColumns, ...level.leafColumns };
958
+ const own: ColumnMap = derive.fromMeta(metas);
959
+ const factScoped = level.kind === "fact-scoped";
960
+ const tables = subtype(schema, {
961
+ name: level.name,
962
+ parentId: parent.stable.id,
963
+ parentVersion: parent.version,
964
+ columns: factScoped ? { ...hooks.columns } : { ...own, ...hooks.columns },
965
+ extraConstraints: hooks.extraConstraints,
966
+ discriminator: {
967
+ stable: parent.stable,
968
+ // A refinable level's whole subtree cascades: a flipped root `type`
969
+ // must travel down every arc below it, not just the first.
970
+ refinable: Arr.some(
971
+ [...level.ancestors, level.name],
972
+ (name) => hooksOf(name).refinable === true,
973
+ ),
974
+ },
975
+ });
976
+ const factOf = ([key, meta]: readonly [string, DbColumn], builder: ColumnBuilderBase) => {
977
+ const name = `${level.name}_${meta.column}`;
978
+ const fact: PgTable = schema.table(
979
+ name,
980
+ {
981
+ id: uuid("id").notNull(),
982
+ version: bigint("version", { mode: "bigint" }).notNull(),
983
+ [key]: builder,
984
+ },
985
+ (table) => [
986
+ primaryKey({ name: `${name}_pkey`, columns: [table.id, table.version] }),
987
+ foreignKey({
988
+ name: `${name}_${getTableName(tables.version)}_fk`,
989
+ columns: [table.id, table.version],
990
+ foreignColumns: [tables.version.id, tables.version.version],
991
+ }).onDelete("cascade"),
992
+ ],
993
+ );
994
+ return [key, fact] as const;
995
+ };
996
+ const facts = factScoped
997
+ ? R.fromEntries(
998
+ Arr.map(Arr.zip(R.toEntries(metas), R.values(own)), ([entry, builder]) =>
999
+ factOf(entry, builder),
1000
+ ),
1001
+ )
1002
+ : {};
1003
+ return { tables, facts };
1004
+ };
1005
+
1006
+ // Parents first (by depth, declaration order within a depth), so every
1007
+ // level's parent tables exist when it is built.
1008
+ const built = Arr.reduce(
1009
+ Arr.sort(
1010
+ tree.levels,
1011
+ Order.mapInput(Order.Number, (level: Tree<S, M>["levels"][number]) => level.ancestors.length),
1012
+ ),
1013
+ R.empty<string, BuiltLevel>(),
1014
+ (acc, level) => ({ ...acc, [level.name]: buildLevel(acc, level) }),
1015
+ );
1016
+ const derived = {
1017
+ root: { identity, version },
1018
+ levels: R.fromEntries(Arr.map(tree.names, (name) => [name, builtLevel(built, name).tables])),
1019
+ facts: R.fromEntries(Arr.map(tree.names, (name) => [name, builtLevel(built, name).facts])),
1020
+ };
1021
+ // SAFETY: the type-erasure seam. Every level's tables were built above from
1022
+ // the same runtime column maps the tree's phantom `M` types (the level's
1023
+ // level and leaf `DbColumn` maps through `builders`, plus its hook columns),
1024
+ // in the record shape `TreeTables` names; only the per-level precision was
1025
+ // erased by assembling them from the runtime tree.
1026
+ return derived as TreeTables<S, M, B, H, IE, VE>;
1027
+ };