@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
|
@@ -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
|
+
};
|