@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,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Thomas Foster
|
|
3
|
+
*
|
|
4
|
+
* **The JSON:API resource projection of the derivation entry (Drizzle).**
|
|
5
|
+
*
|
|
6
|
+
* `./jsonapi` reads a `Resource.make` definition's `dbColumn` annotations;
|
|
7
|
+
* `./derive` turns db-column metadata into Drizzle column builders. This entry
|
|
8
|
+
* is the join: the resource-shaped signatures the consuming applications'
|
|
9
|
+
* table factories call, implemented by handing `resourceColumns` /
|
|
10
|
+
* `resourceColumnBounds` to the generic interpreter. No semantics of its own —
|
|
11
|
+
* failure modes, CHECK naming and the `resolveReference` seam are all
|
|
12
|
+
* `./derive`'s, and its {@link DerivationError} is what a misdeclared resource
|
|
13
|
+
* throws at module load.
|
|
14
|
+
*
|
|
15
|
+
* @module jsonapi/derive
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ExtraConfigColumn, PgTableExtraConfigValue } from "drizzle-orm/pg-core";
|
|
19
|
+
import {
|
|
20
|
+
deriveCheckConstraintsFrom,
|
|
21
|
+
// 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.
|
|
22
|
+
makeDeriveColumns,
|
|
23
|
+
type ColumnBuilders,
|
|
24
|
+
type DerivedColumns,
|
|
25
|
+
type DeriveOptions,
|
|
26
|
+
} from "../derive.ts";
|
|
27
|
+
import {
|
|
28
|
+
resourceColumnBounds,
|
|
29
|
+
resourceColumns,
|
|
30
|
+
type AnyResource,
|
|
31
|
+
type ResourceColumnsMetaOf,
|
|
32
|
+
} from "./index.ts";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The Drizzle column builders a `Resource.make` definition's annotated
|
|
36
|
+
* attributes produce against registry `B`, keyed and typed per attribute.
|
|
37
|
+
*/
|
|
38
|
+
export type DerivedResourceColumns<
|
|
39
|
+
R extends AnyResource,
|
|
40
|
+
B extends ColumnBuilders,
|
|
41
|
+
> = DerivedColumns<ResourceColumnsMetaOf<R>, B>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build a `deriveResourceColumns` bound to a specific builder registry. The
|
|
45
|
+
* returned function interprets a `Resource.make` definition's per-attribute
|
|
46
|
+
* `dbColumn` annotations into the Drizzle column builders a table factory's
|
|
47
|
+
* `columns` expects.
|
|
48
|
+
*/
|
|
49
|
+
export const makeDeriveResourceColumns = <B extends ColumnBuilders>(
|
|
50
|
+
builders: B,
|
|
51
|
+
options: DeriveOptions = {},
|
|
52
|
+
): (<R extends AnyResource>(resource: R) => DerivedResourceColumns<R, B>) => {
|
|
53
|
+
const derive = makeDeriveColumns(builders, options);
|
|
54
|
+
return <R extends AnyResource>(resource: R): DerivedResourceColumns<R, B> =>
|
|
55
|
+
// SAFETY: `resourceColumns` projects exactly the annotated attributes
|
|
56
|
+
// `ResourceColumnsMetaOf` keys, so the derived map is the one it describes.
|
|
57
|
+
derive.fromMeta(resourceColumns(resource)) as DerivedResourceColumns<R, B>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Derive the SQL `CHECK` constraints a resource definition's numeric-bound
|
|
62
|
+
* checks imply, named `${tableName}_${column}_${suffix}` unless the annotation
|
|
63
|
+
* pins a `checkName`, and guarded with `col IS NULL OR …` for a nullable
|
|
64
|
+
* column. Returns a callback shaped for a table factory's `extraConstraints`
|
|
65
|
+
* seam; a bounded attribute whose column is absent from the table throws
|
|
66
|
+
* {@link DerivationError}.
|
|
67
|
+
*
|
|
68
|
+
* The projection is deferred to the returned callback, so this composes inside
|
|
69
|
+
* a `pgTable(...)` call whose columns are still being assembled.
|
|
70
|
+
*/
|
|
71
|
+
export const deriveCheckConstraints =
|
|
72
|
+
(resource: AnyResource, tableName: string) =>
|
|
73
|
+
(table: Record<string, ExtraConfigColumn>): PgTableExtraConfigValue[] =>
|
|
74
|
+
deriveCheckConstraintsFrom(resourceColumnBounds(resource), tableName)(table);
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Thomas Foster
|
|
3
|
+
*
|
|
4
|
+
* **The JSON:API resource projection of the annotation entry (no Drizzle).**
|
|
5
|
+
*
|
|
6
|
+
* The {@link ../index.ts} entry reads `dbColumn` annotations off a
|
|
7
|
+
* `Schema.Struct`'s fields. A consumer whose models are
|
|
8
|
+
* `@thomasfosterau/effect-jsonapi` `Resource.make` definitions declares the
|
|
9
|
+
* same annotations on its **attributes**, which sit behind an extra layer:
|
|
10
|
+
* `Resource.attribute` descriptors, and the `optionalKey` wrapper a
|
|
11
|
+
* `resource: "optional"` attribute takes. This entry is the reader for that
|
|
12
|
+
* shape — the resource-shaped counterpart of `fieldColumns` /
|
|
13
|
+
* `fieldColumnBounds`.
|
|
14
|
+
*
|
|
15
|
+
* **No Drizzle, and an optional peer.** `@thomasfosterau/effect-jsonapi` is an
|
|
16
|
+
* *optional* peer dependency: only this subpath and `./derive` import it, so a
|
|
17
|
+
* consumer with plain `Schema.Struct` models never installs it. The
|
|
18
|
+
* Drizzle-emitting half is `./jsonapi/derive`.
|
|
19
|
+
*
|
|
20
|
+
* The table-level annotation helpers are re-exported here with an
|
|
21
|
+
* `R extends AnyResource` bound. They are the same functions — the narrower
|
|
22
|
+
* bound exists so a consumer's resource-facing `db-annotation.ts` can import
|
|
23
|
+
* every name it needs from one place, and so a non-resource object cannot be
|
|
24
|
+
* annotated through the resource-shaped API by accident.
|
|
25
|
+
*
|
|
26
|
+
* @module jsonapi
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { Resource } from "@thomasfosterau/effect-jsonapi";
|
|
30
|
+
import * as Arr from "effect/Array";
|
|
31
|
+
import * as Option from "effect/Option";
|
|
32
|
+
import * as Predicate from "effect/Predicate";
|
|
33
|
+
import * as R from "effect/Record";
|
|
34
|
+
import * as Schema from "effect/Schema";
|
|
35
|
+
import * as SchemaAST from "effect/SchemaAST";
|
|
36
|
+
import {
|
|
37
|
+
dbTable as coreDbTable,
|
|
38
|
+
readDbTable as coreReadDbTable,
|
|
39
|
+
numericBounds,
|
|
40
|
+
DB_COLUMN_KEY,
|
|
41
|
+
type ColumnBounds,
|
|
42
|
+
type DbColumn,
|
|
43
|
+
type TableAnnotated as CoreTableAnnotated,
|
|
44
|
+
type DbTableOf as CoreDbTableOf,
|
|
45
|
+
type DbTable,
|
|
46
|
+
type TableMeta,
|
|
47
|
+
type UnwrapColumn,
|
|
48
|
+
type FieldColumnsMetaOf,
|
|
49
|
+
fieldColumns,
|
|
50
|
+
} from "../index.ts";
|
|
51
|
+
import { buildTree, treeError, type LevelSpec, type Tree } from "../tree.ts";
|
|
52
|
+
|
|
53
|
+
export type { DbTable, TableMeta };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Any `Resource.make` resource definition. Re-exported so a derivation layer
|
|
57
|
+
* can name the constraint of {@link ResourceColumnsMetaOf} without importing
|
|
58
|
+
* `@thomasfosterau/effect-jsonapi` directly.
|
|
59
|
+
*/
|
|
60
|
+
export type AnyResource = Resource.Any;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The underlying schema of an attribute field. A `Resource.attribute`
|
|
64
|
+
* descriptor carries the schema `S` in its config (recoverable through the
|
|
65
|
+
* `Resource.Attribute` type), peeling the `resource: "optional"` `optionalKey`
|
|
66
|
+
* wrapper that would otherwise bury the `dbColumn` phantom; a plain annotated
|
|
67
|
+
* attribute (e.g. `dbColumn(schema, …)` passed directly) is its own schema.
|
|
68
|
+
*/
|
|
69
|
+
type UnderlyingSchema<A> =
|
|
70
|
+
A extends Resource.Attribute<infer S, infer _R, infer _C, infer _U, infer _Cl> ? S : A;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The db-column metadata map a resource definition projects to, keyed by the
|
|
74
|
+
* attributes that declare a `dbColumn` (others drop out of the map entirely).
|
|
75
|
+
* Recovers each attribute's underlying schema (peeling the descriptor + any
|
|
76
|
+
* `optionalKey` resource wrapper) and reads its phantom via
|
|
77
|
+
* {@link UnwrapColumn}. The resource-shaped counterpart of
|
|
78
|
+
* `FieldColumnsMetaOf`.
|
|
79
|
+
*/
|
|
80
|
+
export type ResourceColumnsMetaOf<R extends AnyResource> = {
|
|
81
|
+
readonly [
|
|
82
|
+
K in keyof Resource.AttributesOf<R> as [
|
|
83
|
+
UnwrapColumn<UnderlyingSchema<Resource.AttributesOf<R>[K]>>,
|
|
84
|
+
] extends [never]
|
|
85
|
+
? never
|
|
86
|
+
: K
|
|
87
|
+
]: UnwrapColumn<UnderlyingSchema<Resource.AttributesOf<R>[K]>>;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Project a resource definition's per-attribute {@link DbColumn} metadata into
|
|
92
|
+
* plain data the derivation consumes, dropping attributes that declare no
|
|
93
|
+
* column. Reads the annotation via `Resource.attributeAnnotations`, which
|
|
94
|
+
* resolves each attribute schema's annotations — including through the
|
|
95
|
+
* `optionalKey` wrapper a `resource: "optional"` attribute carries, whose AST
|
|
96
|
+
* is the inner schema's. The type-level counterpart is
|
|
97
|
+
* {@link ResourceColumnsMetaOf}.
|
|
98
|
+
*/
|
|
99
|
+
export const resourceColumns = (resource: AnyResource): Record<string, DbColumn> =>
|
|
100
|
+
// `Resource.attributeAnnotations` (an optional peer, outside this workspace)
|
|
101
|
+
// reports an attribute with no annotations as a literal `undefined` entry;
|
|
102
|
+
// the foreign `undefined` becomes an `Option` on the same expression.
|
|
103
|
+
R.getSomes(
|
|
104
|
+
R.map(Resource.attributeAnnotations(resource), (annotations) =>
|
|
105
|
+
Option.map(
|
|
106
|
+
Option.fromUndefinedOr(annotations?.[DB_COLUMN_KEY]),
|
|
107
|
+
// SAFETY: DB_COLUMN_KEY is namespaced to this package and only
|
|
108
|
+
// `dbColumn` / `dbColumnFor` write it, always with a `DbColumn` value.
|
|
109
|
+
(meta) => meta as DbColumn,
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Project a resource definition's per-attribute numeric bounds into plain data
|
|
116
|
+
* a `CHECK`-constraint derivation consumes: for each `dbColumn`-annotated
|
|
117
|
+
* attribute whose schema carries numeric-bound checks, the bounds paired with
|
|
118
|
+
* the column metadata. Attributes without a column, or without bounds, drop.
|
|
119
|
+
* The Drizzle-emitting counterpart is the `./jsonapi/derive` entry's
|
|
120
|
+
* `deriveCheckConstraints`.
|
|
121
|
+
*/
|
|
122
|
+
export const resourceColumnBounds = (resource: AnyResource): Record<string, ColumnBounds> => {
|
|
123
|
+
// SAFETY: `Resource.attributes` returns the resource object's
|
|
124
|
+
// `Schema.Struct.Fields`; every value is a schema.
|
|
125
|
+
const fields = Resource.attributes(resource) as Record<string, Schema.Constraint>;
|
|
126
|
+
return R.getSomes(
|
|
127
|
+
R.map(resourceColumns(resource), (column, name) =>
|
|
128
|
+
Option.map(numericBounds(fields[name]), (bounds): ColumnBounds => ({ column, ...bounds })),
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// ── Resource projection shapes ────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The resource-projection shape of one `Resource.make` attribute, as plain
|
|
137
|
+
* data — what an operation factory needs to derive a row → resource mapper
|
|
138
|
+
* and a version-table column map from the model alone:
|
|
139
|
+
*
|
|
140
|
+
* - `date` (a `createdAt` / `updatedAt` / `deletedAt`-style `Date`) → a
|
|
141
|
+
* timestamp axis the mapper reads from the chain rather than a column,
|
|
142
|
+
* - `array` (a materialised-list attribute a flat view does not carry) → a
|
|
143
|
+
* placeholder the mapper fills from a join,
|
|
144
|
+
* - `optional` (an `optionalKey` resource projection) → the value drops when
|
|
145
|
+
* the column is absent rather than surfacing as a present `undefined`,
|
|
146
|
+
* - everything else (including a `NullOr` column read straight off a view) →
|
|
147
|
+
* passthrough.
|
|
148
|
+
*
|
|
149
|
+
* `column` is the column the attribute's `dbColumn` annotation names, `none`
|
|
150
|
+
* for an attribute that declares none.
|
|
151
|
+
*/
|
|
152
|
+
export interface ResourceAttributeProjection {
|
|
153
|
+
readonly key: string;
|
|
154
|
+
/** The resource projection is an `optionalKey` (drops when absent). */
|
|
155
|
+
readonly optional: boolean;
|
|
156
|
+
/** The attribute schema accepts `null` (a `Schema.NullOr`). */
|
|
157
|
+
readonly nullable: boolean;
|
|
158
|
+
/** The attribute schema is an array (a materialised-list attribute). */
|
|
159
|
+
readonly array: boolean;
|
|
160
|
+
/** The attribute schema decodes an ISO string to a `Date` (a timestamp axis). */
|
|
161
|
+
readonly date: boolean;
|
|
162
|
+
/** The column the attribute backs, if it declares one. */
|
|
163
|
+
readonly column: Option.Option<string>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** An ISO timestamp a `Date`-decoding attribute schema accepts. */
|
|
167
|
+
const ISO_PROBE = "2024-01-02T03:04:05.000Z";
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* What a schema's **JSON codec** decodes `input` to — the wire reading, so an
|
|
171
|
+
* ISO string decodes to a `Date` for `Schema.Date` (whose own decoder accepts
|
|
172
|
+
* only a `Date` instance). A decoder is built per call: this is start-up
|
|
173
|
+
* introspection over a model, not a hot path.
|
|
174
|
+
*/
|
|
175
|
+
const decodesFromJson = (schema: Schema.Top, input: Schema.Json): Option.Option<unknown> =>
|
|
176
|
+
// SAFETY: an attribute schema's decoding needs no services (a resource's
|
|
177
|
+
// attributes are plain codecs), which `Schema.Top` cannot express.
|
|
178
|
+
Schema.decodeOption(Schema.toCodecJson(schema as Schema.Codec<unknown>))(input);
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Project a `Resource.make` definition's resource-object attributes into
|
|
182
|
+
* ordered plain-data {@link ResourceAttributeProjection}s (declaration
|
|
183
|
+
* order). Optionality is read off each field's AST (`optionalKey` marks the
|
|
184
|
+
* property optional); nullability, array-ness and date-ness are recovered by
|
|
185
|
+
* behavioural decode probes through each schema's JSON codec (the wire
|
|
186
|
+
* reading), so the read stays robust across schema-AST shapes (a `NullOr` of
|
|
187
|
+
* a transformation, a branded array, …).
|
|
188
|
+
*
|
|
189
|
+
* Input-only attributes (`resource: false`) are not on the resource object,
|
|
190
|
+
* and so not here. The `column` read is {@link resourceColumns}'.
|
|
191
|
+
*/
|
|
192
|
+
export const resourceAttributeProjections = (
|
|
193
|
+
resource: AnyResource,
|
|
194
|
+
): ReadonlyArray<ResourceAttributeProjection> => {
|
|
195
|
+
// SAFETY: `Resource.attributes` returns the resource object's
|
|
196
|
+
// `Schema.Struct.Fields`; every value is a schema.
|
|
197
|
+
const fields = Resource.attributes(resource) as Record<string, Schema.Top>;
|
|
198
|
+
const columns = resourceColumns(resource);
|
|
199
|
+
return Arr.map(R.toEntries(fields), ([key, schema]) => ({
|
|
200
|
+
key,
|
|
201
|
+
optional: SchemaAST.isOptional(schema.ast),
|
|
202
|
+
nullable: Option.isSome(decodesFromJson(schema, null)),
|
|
203
|
+
array: Option.isSome(decodesFromJson(schema, [])),
|
|
204
|
+
date: Option.exists(decodesFromJson(schema, ISO_PROBE), Predicate.isDate),
|
|
205
|
+
column: Option.fromNullishOr(columns[key]?.column),
|
|
206
|
+
}));
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// ── Table-level annotation, narrowed to resources ─────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A resource definition carrying db-table metadata `M` in a type-level phantom
|
|
213
|
+
* — `TableAnnotated` from the annotation entry with its target narrowed to a
|
|
214
|
+
* resource.
|
|
215
|
+
*/
|
|
216
|
+
export type TableAnnotated<R extends AnyResource, M extends TableMeta> = CoreTableAnnotated<R, M>;
|
|
217
|
+
|
|
218
|
+
/** Recovers the db-table metadata phantom from a resource, or `never`. */
|
|
219
|
+
export type DbTableOf<R> = CoreDbTableOf<R>;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Annotate a resource definition with the database table it backs. The
|
|
223
|
+
* annotation entry's `dbTable`, narrowed to `R extends AnyResource`: it
|
|
224
|
+
* attaches the metadata as a non-enumerable symbol property and returns the
|
|
225
|
+
* **same** resource object, so `Resource.make`'s assigned properties (`type`,
|
|
226
|
+
* `Id`, `identifier`, …) and the referential identity a registry compares by
|
|
227
|
+
* both survive.
|
|
228
|
+
*/
|
|
229
|
+
export const dbTable = <R extends AnyResource, const M extends TableMeta>(
|
|
230
|
+
resource: R,
|
|
231
|
+
meta: M,
|
|
232
|
+
): TableAnnotated<R, M> => coreDbTable(resource, meta);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A {@link dbTable} constrained to a specific level union `Level`, requiring
|
|
236
|
+
* the full {@link DbTable} metadata. A consumer pins its own levels once —
|
|
237
|
+
* `const dbTable = dbTableFor<LeafLevel>()` — so an annotation naming a level
|
|
238
|
+
* the derivation has no parent binding for is a type error at the annotation
|
|
239
|
+
* site.
|
|
240
|
+
*/
|
|
241
|
+
export const dbTableFor =
|
|
242
|
+
<Level extends string>() =>
|
|
243
|
+
<R extends AnyResource, const M extends DbTable<Level>>(
|
|
244
|
+
resource: R,
|
|
245
|
+
meta: M,
|
|
246
|
+
): TableAnnotated<R, M> =>
|
|
247
|
+
coreDbTable(resource, meta);
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Read the db-table metadata off a resource at runtime. `Option.none()` when
|
|
251
|
+
* the resource carries none. Typed to the {@link TableMeta} floor; a consumer
|
|
252
|
+
* that annotated with a {@link DbTable} recovers its `level` through the
|
|
253
|
+
* {@link DbTableOf} phantom.
|
|
254
|
+
*/
|
|
255
|
+
export const readDbTable = (resource: AnyResource): Option.Option<TableMeta> =>
|
|
256
|
+
coreReadDbTable(resource);
|
|
257
|
+
|
|
258
|
+
// ── Tree ──────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
/** The resource an entry's `leaf` holds, when it holds one. */
|
|
261
|
+
type EntryResource<E> = E extends { readonly leaf?: infer L }
|
|
262
|
+
? Exclude<L, undefined> extends AnyResource
|
|
263
|
+
? Exclude<L, undefined>
|
|
264
|
+
: never
|
|
265
|
+
: never;
|
|
266
|
+
|
|
267
|
+
/** The fields an entry's `columns` declares; `{}` when absent. */
|
|
268
|
+
type EntryLevelFields<E> = E extends { readonly columns?: infer F }
|
|
269
|
+
? Exclude<F, undefined> extends Schema.Struct.Fields
|
|
270
|
+
? Exclude<F, undefined>
|
|
271
|
+
: Record<never, never>
|
|
272
|
+
: Record<never, never>;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The typed column metadata of a {@link defineResourceTree} declaration `S`:
|
|
276
|
+
* per level, its level columns' `DbColumn` map and its leaf resource's
|
|
277
|
+
* attribute columns. The phantom the table derivation reads.
|
|
278
|
+
*/
|
|
279
|
+
export type ResourceTreeMeta<S> = {
|
|
280
|
+
readonly [K in keyof S]: {
|
|
281
|
+
readonly level: FieldColumnsMetaOf<EntryLevelFields<S[K]>>;
|
|
282
|
+
readonly leaf: [EntryResource<S[K]>] extends [never]
|
|
283
|
+
? Record<never, never>
|
|
284
|
+
: ResourceColumnsMetaOf<EntryResource<S[K]>>;
|
|
285
|
+
};
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The constraint a resource tree declaration `S` is checked against: the
|
|
290
|
+
* core `CheckedTreeSpec`, with each leaf a `Resource.make` definition whose
|
|
291
|
+
* wire `type` is the level's own name. A leaf filed under another name, or a
|
|
292
|
+
* parent naming no level (or the level itself), is a type error.
|
|
293
|
+
*/
|
|
294
|
+
export type CheckedResourceTreeSpec<S, Ext> = {
|
|
295
|
+
readonly [K in keyof S]: LevelSpec<
|
|
296
|
+
Exclude<keyof S & string, K>,
|
|
297
|
+
AnyResource & { readonly type: K }
|
|
298
|
+
> &
|
|
299
|
+
Ext;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* A {@link defineResourceTree} whose every entry also carries the consumer's
|
|
304
|
+
* extension record `Ext`, which the library never reads (pulpit's
|
|
305
|
+
* `RegistryEntry` flags). Pin it once — `const defineTree =
|
|
306
|
+
* defineResourceTreeFor<RegistryFlags>()`. Throws `DerivationError` at module
|
|
307
|
+
* load as `defineResourceTree` does.
|
|
308
|
+
*/
|
|
309
|
+
export const defineResourceTreeFor =
|
|
310
|
+
<Ext extends object>() =>
|
|
311
|
+
<const S extends CheckedResourceTreeSpec<S, Ext>>(spec: S): Tree<S, ResourceTreeMeta<S>> =>
|
|
312
|
+
buildTree<S, AnyResource, ResourceTreeMeta<S>>(spec, {
|
|
313
|
+
levelColumns: fieldColumns,
|
|
314
|
+
leafColumns: (resource, name) =>
|
|
315
|
+
resource.type === name
|
|
316
|
+
? resourceColumns(resource)
|
|
317
|
+
: treeError(
|
|
318
|
+
`leaf "${name}" carries resource "${resource.type}"; a leaf's name is its resource's type`,
|
|
319
|
+
),
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The core entry's `defineTree` over `Resource.make` definitions: each leaf's
|
|
324
|
+
* `leaf` is the resource whose wire `type` is the level's name (the
|
|
325
|
+
* discriminator value), and its domain columns are the resource's
|
|
326
|
+
* `dbColumn`-annotated attributes. Every other check is the core one. Throws
|
|
327
|
+
* `DerivationError` at module load for a leaf whose resource `type` isn't its
|
|
328
|
+
* name, and for every structural fault `defineTree` names.
|
|
329
|
+
*
|
|
330
|
+
* @example
|
|
331
|
+
* ```ts
|
|
332
|
+
* import { dbColumn } from "@thomasfosterau/effect-drizzle-ext";
|
|
333
|
+
* import { defineResourceTree } from "@thomasfosterau/effect-drizzle-ext/jsonapi";
|
|
334
|
+
* import { Resource } from "@thomasfosterau/effect-jsonapi";
|
|
335
|
+
* import * as Schema from "effect/Schema";
|
|
336
|
+
*
|
|
337
|
+
* const Book = Resource.make("book", {
|
|
338
|
+
* attributes: {
|
|
339
|
+
* pages: dbColumn(Schema.Finite, { column: "pages", pg: "integer", notNull: false }),
|
|
340
|
+
* },
|
|
341
|
+
* });
|
|
342
|
+
*
|
|
343
|
+
* const tree = defineResourceTree({
|
|
344
|
+
* work: {
|
|
345
|
+
* columns: { title: dbColumn(Schema.String, { column: "title", pg: "text", notNull: true }) },
|
|
346
|
+
* },
|
|
347
|
+
* book: { parent: "work", leaf: Book },
|
|
348
|
+
* });
|
|
349
|
+
*
|
|
350
|
+
* console.log(tree.leaves); // ["book"]
|
|
351
|
+
* ```
|
|
352
|
+
*/
|
|
353
|
+
export const defineResourceTree: <const S extends CheckedResourceTreeSpec<S, Record<never, never>>>(
|
|
354
|
+
spec: S,
|
|
355
|
+
) => Tree<S, ResourceTreeMeta<S>> = defineResourceTreeFor<Record<never, never>>();
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Thomas Foster
|
|
3
|
+
*
|
|
4
|
+
* List-read helpers: the unpaged-total window column and the {@link Page}
|
|
5
|
+
* shape every list read returns, a variadic AND filter combinator, a bounded
|
|
6
|
+
* limit/offset input schema, and the rows-normalising wrapper over a raw-SQL
|
|
7
|
+
* `execute` that papers over a driver mismatch between typed and runtime
|
|
8
|
+
* `db.execute` shapes.
|
|
9
|
+
*
|
|
10
|
+
* @module query
|
|
11
|
+
*/
|
|
12
|
+
import { and, sql } from "drizzle-orm";
|
|
13
|
+
import type { SQL, SQLWrapper } from "drizzle-orm";
|
|
14
|
+
import type { EffectDrizzleQueryError } from "drizzle-orm/effect-core";
|
|
15
|
+
import * as Effect from "effect/Effect";
|
|
16
|
+
import * as Predicate from "effect/Predicate";
|
|
17
|
+
import * as Schema from "effect/Schema";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The alias the unpaged total rides back on. Prefixed so it cannot collide
|
|
21
|
+
* with a projected column, and so a clash surfaces as a visible key rather
|
|
22
|
+
* than silently overwriting an attribute.
|
|
23
|
+
*/
|
|
24
|
+
export const TOTAL_COUNT_ALIAS = "__totalCount";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The selection entry adding the unpaged total to a list query. Spread into
|
|
28
|
+
* a `.select({ … })` map alongside the columns the read already projects.
|
|
29
|
+
*
|
|
30
|
+
* `COUNT(*) OVER ()` is evaluated after `WHERE` and before `LIMIT`/`OFFSET`,
|
|
31
|
+
* so it rides back on every row of the page the query was already
|
|
32
|
+
* fetching — one round trip instead of a separate sequential `count(*)`
|
|
33
|
+
* query for data the first one can already carry.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* import { totalCountColumn } from "@thomasfosterau/effect-drizzle-ext/query";
|
|
38
|
+
* import { eq } from "drizzle-orm";
|
|
39
|
+
* import { integer, pgTable, QueryBuilder, text } from "drizzle-orm/pg-core";
|
|
40
|
+
*
|
|
41
|
+
* const article = pgTable("article", { id: integer().primaryKey(), title: text().notNull() });
|
|
42
|
+
*
|
|
43
|
+
* const page = new QueryBuilder()
|
|
44
|
+
* .select({ id: article.id, title: article.title, ...totalCountColumn })
|
|
45
|
+
* .from(article)
|
|
46
|
+
* .where(eq(article.title, "Hello"))
|
|
47
|
+
* .limit(20);
|
|
48
|
+
*
|
|
49
|
+
* console.log(page.toSQL().sql);
|
|
50
|
+
* // select "id", "title", (count(*) over ())::int from "article" where … limit $2
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export const totalCountColumn = {
|
|
54
|
+
[TOTAL_COUNT_ALIAS]: sql<number>`(count(*) over ())::int`,
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
/** One page of a list read: the window's rows plus the unpaged total. */
|
|
58
|
+
export interface Page<TResource> {
|
|
59
|
+
readonly items: readonly TResource[];
|
|
60
|
+
readonly totalCount: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reads the total off the first row — every row carries the same value,
|
|
65
|
+
* since the window is evaluated over the whole matching set. Falls back to
|
|
66
|
+
* `0` for an empty page: a window function over zero rows returns nothing,
|
|
67
|
+
* so **an empty page reports `totalCount: 0`** — exact when nothing
|
|
68
|
+
* matches, and wrong only when a caller paged past the end of a non-empty
|
|
69
|
+
* collection. That case is self-describing (`items: []` says it) and
|
|
70
|
+
* self-correcting (paging back gives the true total); the alternative is a
|
|
71
|
+
* second query on every list read. The alias is added by
|
|
72
|
+
* {@link totalCountColumn} outside the row's declared type, so it is
|
|
73
|
+
* recovered by an `in` narrowing rather than a cast.
|
|
74
|
+
*/
|
|
75
|
+
const totalFrom = (rows: readonly unknown[]): number => {
|
|
76
|
+
const first = rows[0];
|
|
77
|
+
return Predicate.isObjectOrArray(first) && TOTAL_COUNT_ALIAS in first
|
|
78
|
+
? Number(first[TOTAL_COUNT_ALIAS])
|
|
79
|
+
: 0;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build a {@link Page} from rows selected with {@link totalCountColumn},
|
|
84
|
+
* mapping each row to its resource.
|
|
85
|
+
*/
|
|
86
|
+
export const pageOf = <TRow, TResource>(
|
|
87
|
+
rows: readonly TRow[],
|
|
88
|
+
toResource: (row: TRow) => TResource,
|
|
89
|
+
): Page<TResource> => ({
|
|
90
|
+
items: rows.map(toResource),
|
|
91
|
+
totalCount: totalFrom(rows),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A {@link Page} over resources already mapped — for a list whose rows go
|
|
96
|
+
* through several transformation steps before becoming resources, where
|
|
97
|
+
* {@link pageOf}'s single mapper doesn't fit.
|
|
98
|
+
*/
|
|
99
|
+
export const pageWithTotal = <TResource>(
|
|
100
|
+
items: readonly TResource[],
|
|
101
|
+
rows: readonly unknown[],
|
|
102
|
+
): Page<TResource> => ({
|
|
103
|
+
items,
|
|
104
|
+
totalCount: totalFrom(rows),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Combines a set of optional SQL filters into one `AND`-joined condition.
|
|
109
|
+
* Filters an operation didn't build (an optional query parameter left
|
|
110
|
+
* unset) are `undefined`; `and()` drops them, so passing every filter
|
|
111
|
+
* unconditionally is safe. With no defined filter at all, the result is
|
|
112
|
+
* `undefined` — the caller omits `.where(...)` entirely rather than
|
|
113
|
+
* applying a vacuous `WHERE true`.
|
|
114
|
+
*/
|
|
115
|
+
/* oxlint-disable effect/prefer-option-over-null -- Drizzle-facing: mirrors drizzle-orm's own `and()` signature, which callers pass straight into `.where(...)` */
|
|
116
|
+
export const combineFilters = (...filters: ReadonlyArray<SQL | undefined>): SQL | undefined =>
|
|
117
|
+
and(...filters);
|
|
118
|
+
/* oxlint-enable effect/prefer-option-over-null */
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Builds a `limit` / `offset` input schema bounded by `options`, plus the
|
|
122
|
+
* two field schemas standalone (for a caller that validates them outside a
|
|
123
|
+
* struct, e.g. against a query-string parameter one at a time).
|
|
124
|
+
*
|
|
125
|
+
* `limit` accepts an integer in `(0, maxLimit]`; `offset` accepts an
|
|
126
|
+
* integer `>= 0`. Both keys are optional on input and decode to
|
|
127
|
+
* `defaultLimit` / `0` respectively when absent, via
|
|
128
|
+
* {@link Schema.withDecodingDefaultKey} — so `Input`'s decoded `Type` always
|
|
129
|
+
* carries both fields, while its `Encoded` (request) side may omit either.
|
|
130
|
+
*/
|
|
131
|
+
export const boundedPagination = (options: {
|
|
132
|
+
readonly maxLimit: number;
|
|
133
|
+
readonly defaultLimit: number;
|
|
134
|
+
}) => {
|
|
135
|
+
const { maxLimit, defaultLimit } = options;
|
|
136
|
+
const Limit = Schema.Int.pipe(
|
|
137
|
+
Schema.check(Schema.isBetween({ minimum: 0, maximum: maxLimit, exclusiveMinimum: true })),
|
|
138
|
+
);
|
|
139
|
+
const Offset = Schema.Natural;
|
|
140
|
+
const Input = Schema.Struct({
|
|
141
|
+
limit: Limit.pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultLimit))),
|
|
142
|
+
offset: Offset.pipe(Schema.withDecodingDefaultKey(Effect.succeed(0))),
|
|
143
|
+
});
|
|
144
|
+
return { Limit, Offset, Input };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** The shape {@link boundedPagination} returns. */
|
|
148
|
+
export type BoundedPagination = ReturnType<typeof boundedPagination>;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Rows-normalising wrapper for raw-SQL reads (`db.execute<T>`).
|
|
152
|
+
*
|
|
153
|
+
* drizzle-orm's effect-postgres driver types `db.execute<T>` as
|
|
154
|
+
* `readonly T[]`, but depending on the SQL client bound underneath, the
|
|
155
|
+
* query can resolve in "raw" mode with no row mapper, handing back the
|
|
156
|
+
* full driver result object (`{ rows, rowCount, … }`) instead of a bare
|
|
157
|
+
* array — so `.map` on the yielded value throws `rows.map is not a
|
|
158
|
+
* function`. Every raw-SQL read should go through this helper instead of
|
|
159
|
+
* `db.execute` directly: it accepts both shapes — array in → array out —
|
|
160
|
+
* so which shape the underlying driver actually returns changes nothing
|
|
161
|
+
* for call sites. Write-only executes (INSERT/DDL) that never read rows
|
|
162
|
+
* may keep calling `db.execute` directly.
|
|
163
|
+
*
|
|
164
|
+
* `db` is typed to the narrowest shape this needs (`execute` alone),
|
|
165
|
+
* widened so both a real Effect-Drizzle database and a stub client in a
|
|
166
|
+
* test satisfy it without a cast.
|
|
167
|
+
*/
|
|
168
|
+
export const executeRows = <T extends Record<string, unknown>>(
|
|
169
|
+
db: {
|
|
170
|
+
readonly execute: (
|
|
171
|
+
query: SQLWrapper | string,
|
|
172
|
+
) => Effect.Effect<unknown, EffectDrizzleQueryError>;
|
|
173
|
+
},
|
|
174
|
+
query: SQLWrapper | string,
|
|
175
|
+
): Effect.Effect<readonly T[], EffectDrizzleQueryError> =>
|
|
176
|
+
Effect.map(db.execute(query), (result) =>
|
|
177
|
+
// SAFETY: `T` is the row type the caller asserts its query selects (the
|
|
178
|
+
// driver hands rows back untyped); a non-array result is the driver's
|
|
179
|
+
// result object, whose `rows` holds those rows when present.
|
|
180
|
+
Array.isArray(result)
|
|
181
|
+
? (result as readonly T[])
|
|
182
|
+
: ((result as { rows?: readonly T[] }).rows ?? []),
|
|
183
|
+
);
|