@thomasfosterau/effect-drizzle-ext 0.0.0-stage → 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 +109 -3
  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,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
+ );