@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas Foster
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,509 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@thomasfosterau/effect-drizzle-ext`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Effect ↔ Drizzle bridge: Effect Schema → Drizzle column derivation with an extensible column-kind system, a versioned-chain interpreter, typed Postgres error classification, pagination and transaction combinators.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@thomasfosterau/effect-drizzle-ext)
|
|
6
|
+
|
|
7
|
+
**Disclaimer:** This repository and package have been created using Claude Code. The package's API and functionality is in flux, so expect churn and breaking changes.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add @thomasfosterau/effect-drizzle-ext effect drizzle-orm
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`effect` (`^4.0.0`) and `drizzle-orm` (`>=1.0.0-rc.5`) are peer dependencies. `@thomasfosterau/effect-jsonapi` (`>=0.16.0`) is an **optional** peer, needed only by the `./jsonapi` and `./jsonapi/derive` subpaths — a plain `Schema.Struct` consumer never installs it. Node.js 22 or newer is required.
|
|
16
|
+
|
|
17
|
+
## Overview
|
|
18
|
+
|
|
19
|
+
A model attribute's base schema carries a `dbColumn` annotation describing the database column it backs, and a table's factory reads that annotation through a builder registry it supplies. One declaration — the annotated field — is the single source for both the model projection and the column: no hand-maintained `columns: { … }` map to keep in sync with the schema, and no drift between what the wire validates and what the database enforces.
|
|
20
|
+
|
|
21
|
+
This library **extends** drizzle-orm's own Effect integration (`drizzle-orm/effect-postgres`, `drizzle-orm/effect-core`) rather than replacing it — the derived columns are ordinary Drizzle column builders, and the transaction services in `./database` are generic over drizzle's own database/transaction types. It re-implements none of drizzle's Effect support. The sibling [`@thomasfosterau/effect-drizzle-ext-pg`](https://github.com/thomasfosterau/effect-drizzle-ext/blob/trunk/packages/pg/README.md) package binds those services to `@effect/sql-pg` (`drizzle-orm/effect-postgres` + a real Postgres client) and ships a sentinel-rollback test transaction runner.
|
|
22
|
+
|
|
23
|
+
| Subpath | Drizzle? | Contents |
|
|
24
|
+
| --- | --- | --- |
|
|
25
|
+
| `.` | no | The `dbColumn` / `dbTable` annotation vocabulary, phantoms, `Option` readers, numeric bounds, struct-field projection. |
|
|
26
|
+
| `./derive` | yes | The builder registry, `makeDeriveColumns`, CHECK derivation from bounds, `DerivationError`. |
|
|
27
|
+
| `./references` | yes | The lazy FK-reference registry a `resolveReference` hook is built from. |
|
|
28
|
+
| `./constraints` | yes | `enumCheck` and the threaded `type`-discriminator toolkit. |
|
|
29
|
+
| `./columns` | yes | `bytea`, `tsvector`, `jsonbSchema`, `timestamps`, the hex codec. |
|
|
30
|
+
| `./query` | yes | The unpaged-total window column, `Page`, `combineFilters`, `boundedPagination`, `executeRows`. |
|
|
31
|
+
| `./sort` | yes | The `?sort=` resolution seam: the derived sortable allow-list, `resolveSortOrder` / `chronologicalOrder` with a total-order `id` tiebreak, `UnsupportedSortError`. |
|
|
32
|
+
| `./update` | no | The update tri-state as a `SET` map: `updateSetMap`, `insertValues`. |
|
|
33
|
+
| `./errors` | no (types only) | Postgres error facts and tagged classification over drizzle / `@effect/sql` error graphs. |
|
|
34
|
+
| `./database` | types only | The driver-agnostic `makeDatabaseServices` factory (`Database`, `CurrentTransaction`, `transact`, `readHandle`). |
|
|
35
|
+
| `./versioned-chain` | yes | `makeSubtypeTables`, `makeCurrentVersionView`, the `VersionChainEntry` / `VersionSnapshot` read shapes, `versionTimestamps`. |
|
|
36
|
+
| `./jsonapi` | no | `Resource.*`-shaped readers, including `resourceAttributeProjections` (optional peer `@thomasfosterau/effect-jsonapi`). |
|
|
37
|
+
| `./jsonapi/derive` | yes | The Drizzle-emitting half of `./jsonapi`. |
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
A consumer pins its own column-kind and hierarchy-level unions once, in one file:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { dbColumnFor, dbTableFor } from "@thomasfosterau/effect-drizzle-ext";
|
|
45
|
+
|
|
46
|
+
export type PgKind = "text" | "integer" | "boolean" | "timestamp";
|
|
47
|
+
export type LeafLevel = "work" | "manifestation";
|
|
48
|
+
|
|
49
|
+
export const dbColumn = dbColumnFor<PgKind>();
|
|
50
|
+
export const dbTable = dbTableFor<LeafLevel>();
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Then annotates a model's fields with the column they back:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import * as Schema from "effect/Schema";
|
|
57
|
+
import { dbColumn } from "./db-annotation.ts";
|
|
58
|
+
|
|
59
|
+
export const Article = Schema.Struct({
|
|
60
|
+
id: dbColumn(Schema.String.check(Schema.isUUID()), { column: "id", pg: "text", notNull: true }),
|
|
61
|
+
title: dbColumn(Schema.NonEmptyString, { column: "title", pg: "text", notNull: true }),
|
|
62
|
+
views: dbColumn(Schema.Finite, { column: "views", pg: "integer", notNull: true, default: 0 }),
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A builder registry supplies the Drizzle column for each `pg` kind, and `makeDeriveColumns` interprets the annotations against it:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { boolean, integer, text, timestamp } from "drizzle-orm/pg-core";
|
|
70
|
+
import { makeDeriveColumns, type ColumnBuilders } from "@thomasfosterau/effect-drizzle-ext/derive";
|
|
71
|
+
import { makeReferenceRegistry } from "@thomasfosterau/effect-drizzle-ext/references";
|
|
72
|
+
import type { PgKind } from "./db-annotation.ts";
|
|
73
|
+
|
|
74
|
+
export const references = makeReferenceRegistry();
|
|
75
|
+
const builders: ColumnBuilders<PgKind> = { text, integer, boolean, timestamp };
|
|
76
|
+
export const { fromFields } = makeDeriveColumns(builders, { resolveReference: references.resolve });
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The table factory spreads `fromFields(...)` for the columns and `deriveFieldCheckConstraints(...)` for the numeric-bound CHECKs the model's own `Schema.isGreaterThanOrEqualTo` / `isBetween` checks imply:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { pgTable } from "drizzle-orm/pg-core";
|
|
83
|
+
import { deriveFieldCheckConstraints } from "@thomasfosterau/effect-drizzle-ext/derive";
|
|
84
|
+
import { fromFields, references } from "./derive.ts";
|
|
85
|
+
import { Article } from "./article.model.ts";
|
|
86
|
+
|
|
87
|
+
export const articleTable = pgTable(
|
|
88
|
+
"article",
|
|
89
|
+
fromFields(Article.fields),
|
|
90
|
+
deriveFieldCheckConstraints(Article.fields, "article"),
|
|
91
|
+
);
|
|
92
|
+
references.register(articleTable);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
This library only goes **Schema → table**. The reverse direction — deriving a `Schema.Struct` back off a Drizzle table — is drizzle's own `drizzle-orm/effect-schema` (`createSelectSchema` and friends); nothing here duplicates it.
|
|
96
|
+
|
|
97
|
+
## `.` — annotation entry
|
|
98
|
+
|
|
99
|
+
No Drizzle dependency: the half a client- or model-layer package can import without pulling the ORM into its bundle. Effect Schema annotations are erased from the static type, so a purely annotation-driven derivation could not stay type-safe — it could tell neither _which_ fields are columns nor recover their precise column type. `dbColumn` therefore does two things: it attaches a real runtime annotation under `DB_COLUMN_KEY`, and it carries the metadata literally in a type-level phantom (`Annotated<S, M>`), so a derived column map stays precisely typed. `dbColumnFor<Kind>()` pins a consumer's own `pg` kind union once, so an annotation naming an unknown kind is a type error at the annotation site.
|
|
100
|
+
|
|
101
|
+
`dbTable` is the table-level counterpart: it stamps `TableMeta` (`{ name }`) or the level-carrying `DbTable<Level>` onto any object as a non-enumerable symbol property, not an Effect annotation — `annotate` rebuilds a schema, which would drop the properties a `Resource.make` definition assigns and break the referential identity a registry compares by. `dbTable` accepts any `TableMeta`, so a flat consumer (no hierarchy) annotates with `{ name }` alone rather than re-implementing the helper just to drop `level`; `dbTableFor<Level>()` pins the level union for a level-dispatching consumer.
|
|
102
|
+
|
|
103
|
+
Every reader the library owns returns `Option`: `readDbColumn`, `readDbTable`, `numericBounds`. `numericBounds` recovers the `minimum` / `maximum` / `exclusiveMinimum` / `exclusiveMaximum` a schema's own checks declare — the source of a derived CHECK constraint — reading own checks plus one union level (the `Schema.NullOr(Schema.Finite.check(...))` shape a nullable numeric attribute takes). `fieldColumns` and `fieldColumnBounds` project a whole `Schema.Struct.Fields` map at once, peeling the `optional` / `optionalKey` / `NullOr` / `UndefinedOr` wrappers an attribute applies, and are what `./derive`'s `fromFields` and `deriveFieldCheckConstraints` are built on.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
export const dbColumn: <S extends Schema.Top, const M extends DbColumn>(
|
|
107
|
+
schema: S,
|
|
108
|
+
meta: M,
|
|
109
|
+
) => Annotated<S, M>;
|
|
110
|
+
export const readDbColumn: (schema: Schema.Constraint) => Option.Option<DbColumn>;
|
|
111
|
+
export const dbTable: <T extends object, const M extends TableMeta>(
|
|
112
|
+
target: T,
|
|
113
|
+
meta: M,
|
|
114
|
+
) => TableAnnotated<T, M>;
|
|
115
|
+
export const readDbTable: (target: object) => Option.Option<TableMeta>;
|
|
116
|
+
export const numericBounds: (schema: Schema.Constraint) => Option.Option<NumericBounds>;
|
|
117
|
+
export const fieldColumns: (fields: Schema.Struct.Fields) => Record<string, DbColumn>;
|
|
118
|
+
export const fieldColumnBounds: (fields: Schema.Struct.Fields) => Record<string, ColumnBounds>;
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## `./derive`
|
|
122
|
+
|
|
123
|
+
Reads the `DbColumn` metadata a schema declaration carries — via `fieldColumns`, or any other projection yielding a `Record<string, DbColumn>` (the `./jsonapi/derive` entry is one) — and emits the matching Drizzle column builders. `makeDeriveColumns(builders, options)` binds a derivation to a consumer's own kind → builder registry and returns `{ fromFields, fromMeta }`: `fromFields` takes a `Schema.Struct`'s fields directly, `fromMeta` an already-projected metadata map.
|
|
124
|
+
|
|
125
|
+
Failures here are **thrown**, never Effect failures — this module runs at _module load_, while a consumer's table modules are being evaluated, before any Effect runtime exists to fail an Effect in. A missing builder, a `references` annotation with no `resolveReference` hook, or numeric bounds on a column the table does not carry each throw `DerivationError`, a `Schema.TaggedError`. Runtime query failures are the opposite and are never thrown: see `./errors`.
|
|
126
|
+
|
|
127
|
+
`deriveCheckConstraintsFrom` / `deriveFieldCheckConstraints` turn a bounds map into `check()` constraints, named `${tableName}_${column}_${suffix}` unless the column annotation pins a `checkName`:
|
|
128
|
+
|
|
129
|
+
| Bounds | Suffix | Expression |
|
|
130
|
+
| --------------------------- | -------------- | -------------------------- |
|
|
131
|
+
| both sides | `range` | `col BETWEEN min AND max` |
|
|
132
|
+
| `minimum: 0` only | `non_negative` | `col >= 0` |
|
|
133
|
+
| one-sided minimum (nonzero) | `min` | `col >= min` / `col > min` |
|
|
134
|
+
| one-sided maximum | `max` | `col <= max` / `col < max` |
|
|
135
|
+
|
|
136
|
+
A nullable column's expression is guarded with `col IS NULL OR …`.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
export class DerivationError extends Schema.TaggedError<DerivationError>()("DerivationError", {
|
|
140
|
+
message: Schema.String,
|
|
141
|
+
}) {}
|
|
142
|
+
export const makeDeriveColumns: <B extends ColumnBuilders>(
|
|
143
|
+
builders: B,
|
|
144
|
+
options?: DeriveOptions,
|
|
145
|
+
) => {
|
|
146
|
+
readonly fromMeta: <Meta extends Record<string, DbColumn>>(
|
|
147
|
+
metas: Meta,
|
|
148
|
+
) => DerivedColumns<Meta, B>;
|
|
149
|
+
readonly fromFields: <F extends Schema.Struct.Fields>(
|
|
150
|
+
fields: F,
|
|
151
|
+
) => DerivedColumns<FieldColumnsMetaOf<F>, B>;
|
|
152
|
+
};
|
|
153
|
+
export const deriveCheckConstraintsFrom: (
|
|
154
|
+
bounds: Record<string, ColumnBounds>,
|
|
155
|
+
tableName: string,
|
|
156
|
+
) => (table: Record<string, ExtraConfigColumn>) => PgTableExtraConfigValue[];
|
|
157
|
+
export const deriveFieldCheckConstraints: (
|
|
158
|
+
fields: Schema.Struct.Fields,
|
|
159
|
+
tableName: string,
|
|
160
|
+
) => (table: Record<string, ExtraConfigColumn>) => PgTableExtraConfigValue[];
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## `./references`
|
|
164
|
+
|
|
165
|
+
A `dbColumn`'s `references` annotation names its FK target as `{ table, column }`, not a Drizzle column thunk — `./derive`'s `resolveReference` hook is what turns a name pair into the thunk `.references()` needs, and `makeReferenceRegistry` is one implementation of that hook. `register(table)` records a table by its SQL name; `resolve(reference)` returns a **lazy** thunk, forced only when Drizzle materialises the foreign key (migration generation or query build), long after every table module (and its `register` call) has loaded — this sidesteps the import cycle a static `import { targetTable }` would create, since the deriver is imported _by_ the table modules. Forcing the thunk against an unregistered table, or a table with no such column, throws `DerivationError`.
|
|
166
|
+
|
|
167
|
+
A factory, not a singleton: two independent schemas in one process (an app and its test fixtures, two tenants' table sets) would share one name space under a module-scope registry and resolve each other's tables, so each call to `makeReferenceRegistry` owns its own map.
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
export interface ReferenceRegistry {
|
|
171
|
+
readonly register: (table: PgTable) => void;
|
|
172
|
+
readonly resolve: (reference: DbColumnReference) => () => AnyPgColumn;
|
|
173
|
+
}
|
|
174
|
+
export const makeReferenceRegistry: () => ReferenceRegistry;
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## `./constraints`
|
|
178
|
+
|
|
179
|
+
`enumCheck(name, column, values)` emits `CHECK (column IN ('a', 'b', …))` — a vocabulary declared once as a model's `Schema.Literals` also constrains the database, with no Postgres enum type (so growing the vocabulary is one `ALTER … DROP/ADD CONSTRAINT`, not an `ALTER TYPE … ADD VALUE` that cannot run inside a transaction). A `NULL` in the column **passes**: `NULL IN (...)` is unknown, which CHECK accepts, so the same constraint serves a nullable vocabulary with no extra `IS NULL OR` guard — nullability is the column's own `NOT NULL` to declare.
|
|
180
|
+
|
|
181
|
+
`makeDiscriminatorConstraints({ leaves, names? })` builds a `DiscriminatorToolkit<Level>` for one hierarchy: the three constraints that thread a `type` discriminator up a tree of stable tables and make a sibling-subtype chimera unrepresentable.
|
|
182
|
+
|
|
183
|
+
- `typeUnique` — `UNIQUE(id, type)`, the target every child's composite FK points at.
|
|
184
|
+
- `typeParentFk` — the composite `(id, type)` → parent `(id, type)` foreign key (a child can only exist under a parent carrying the same `type`); `onUpdate: "cascade"` (via `typeConstraints`' `refinable: true`) makes a bare parent row **refinable** — a `type` flip cascades down the pre-existing chain before the concrete rows are inserted, with no `DEFERRABLE` constraint needed.
|
|
185
|
+
- `typeCheck` — the per-level `type` CHECK, narrowing to the level's own subtree (`options.leaves(level)`). A childless marker level — no concrete leaf beneath it yet — has an empty leaf set and gets `CHECK (false)`: `type IN ()` is a syntax error, and `false` correctly marks the level uninstantiable.
|
|
186
|
+
- `typeConstraints` — all three for a non-root level in one call.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
export const enumCheck: (
|
|
190
|
+
name: string,
|
|
191
|
+
column: ExtraConfigColumn,
|
|
192
|
+
values: readonly string[],
|
|
193
|
+
) => PgTableExtraConfigValue;
|
|
194
|
+
export const makeDiscriminatorConstraints: <Level extends string>(options: {
|
|
195
|
+
readonly leaves: (level: Level) => ReadonlyArray<string>;
|
|
196
|
+
readonly names?: DiscriminatorNames | undefined;
|
|
197
|
+
}) => DiscriminatorToolkit<Level>;
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## `./columns`
|
|
201
|
+
|
|
202
|
+
- `jsonbSchema(schema)(name)` — a typed `jsonb` custom column backed by an Effect Schema: writes encode through the schema then to JSON text, reads decode driver output straight through the schema (a string-typed branch is kept for a driver that hands back raw JSON text instead of a parsed value).
|
|
203
|
+
- `tsvector(name)` — a `tsvector` custom column, opaque to the consumer; queries use `sql` template literals to match against it.
|
|
204
|
+
- `bytea(name)` — a `bytea` custom column typed `Uint8Array`, never `Buffer` (a runtime that degrades the `Buffer` global, e.g. Cloudflare Workers' `nodejs_compat` types, would silently lose type checking on every `Buffer.*` call).
|
|
205
|
+
- `timestamps()` — a fresh `{ createdAt, updatedAt }` pair, `timestamptz NOT NULL DEFAULT now()`. A **factory**, never a shared object: Drizzle column builders are mutable, so a module-level constant reused across tables would hand every table the same two builders.
|
|
206
|
+
- `byteaToHex` / `hexToBytea` — the hex codec for a `bytea` value without the Node `Buffer` global; `hexToBytea` **throws `TypeError`** on a malformed input, where `Buffer.from` would silently truncate it.
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
export const jsonbSchema: <S extends Schema.Codec<unknown, unknown, never, never>>(schema: S) => (name: string) => PgCustomColumnBuilder<…>;
|
|
210
|
+
export const tsvector: (name: string) => PgCustomColumnBuilder<…>;
|
|
211
|
+
export const bytea: (name: string) => PgCustomColumnBuilder<…>;
|
|
212
|
+
export const timestamps: () => { readonly createdAt: …; readonly updatedAt: … };
|
|
213
|
+
export const byteaToHex: (bytes: Uint8Array) => string;
|
|
214
|
+
export const hexToBytea: (hex: string) => Uint8Array;
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## `./query`
|
|
218
|
+
|
|
219
|
+
`totalCountColumn` spreads `(count(*) over ())::int` into a `.select({ … })` map; `pageOf` / `pageWithTotal` read it back off the first row into a `Page<T>`. `COUNT(*) OVER ()` is evaluated after `WHERE` and before `LIMIT`/`OFFSET`, so the unpaged total rides back on every row of the page a list read is already fetching — one round trip instead of a second sequential `count(*)` query.
|
|
220
|
+
|
|
221
|
+
A window function over zero rows returns nothing, so **an empty page reports `totalCount: 0`** — exact when nothing matches, and wrong only when a caller paged past the end of a non-empty collection. That case is self-describing (`items: []` says it) and self-correcting (paging back gives the true total); the alternative is a second query on every list read.
|
|
222
|
+
|
|
223
|
+
`combineFilters(...filters)` `AND`-joins the defined ones and returns `undefined` with none defined, so a caller omits `.where(...)` entirely rather than applying a vacuous `WHERE true`. `boundedPagination({ maxLimit, defaultLimit })` builds a `limit` / `offset` input schema bounded to `(0, maxLimit]` / `>= 0`, decoding absent keys to `defaultLimit` / `0`.
|
|
224
|
+
|
|
225
|
+
`executeRows` is a rows-normalising wrapper for raw-SQL reads (`db.execute<T>`): drizzle's effect-postgres driver types `db.execute<T>` as `readonly T[]`, but depending on the SQL client bound underneath, the query can resolve in "raw" mode with no row mapper and hand back the full driver result object (`{ rows, rowCount, … }`) instead — so `.map` on the yielded value throws. Every raw-SQL read that needs its rows should go through `executeRows` instead of `db.execute` directly; write-only executes (INSERT/DDL) that never read rows can keep calling `db.execute`.
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
export const totalCountColumn: { readonly __totalCount: SQL<number> };
|
|
229
|
+
export interface Page<T> {
|
|
230
|
+
readonly items: readonly T[];
|
|
231
|
+
readonly totalCount: number;
|
|
232
|
+
}
|
|
233
|
+
export const pageOf: <TRow, TResource>(
|
|
234
|
+
rows: readonly TRow[],
|
|
235
|
+
toResource: (row: TRow) => TResource,
|
|
236
|
+
) => Page<TResource>;
|
|
237
|
+
export const pageWithTotal: <TResource>(
|
|
238
|
+
items: readonly TResource[],
|
|
239
|
+
rows: readonly unknown[],
|
|
240
|
+
) => Page<TResource>;
|
|
241
|
+
export const combineFilters: (...filters: ReadonlyArray<SQL | undefined>) => SQL | undefined;
|
|
242
|
+
export const boundedPagination: (options: {
|
|
243
|
+
readonly maxLimit: number;
|
|
244
|
+
readonly defaultLimit: number;
|
|
245
|
+
}) => BoundedPagination;
|
|
246
|
+
export const executeRows: <T extends Record<string, unknown>>(
|
|
247
|
+
db: {
|
|
248
|
+
readonly execute: (
|
|
249
|
+
query: SQLWrapper | string,
|
|
250
|
+
) => Effect.Effect<unknown, EffectDrizzleQueryError>;
|
|
251
|
+
},
|
|
252
|
+
query: SQLWrapper | string,
|
|
253
|
+
) => Effect.Effect<readonly T[], EffectDrizzleQueryError>;
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## `./sort`
|
|
257
|
+
|
|
258
|
+
The `?sort=` **resolution** seam: which fields a list may be sorted by, and the `ORDER BY` terms a decoded sort list becomes. The wire grammar (`?sort=-publishedAt,name`) is the request decoder's; what arrives here is a list of `SortKey`s (`{ field, direction }`).
|
|
259
|
+
|
|
260
|
+
`sortableColumnsFor(view, options?)` **derives** the allow-list from the view itself, rather than a hand-kept list: every column whose Drizzle `dataType` orders usefully (string, number, bigint, boolean, timestamp) under its own key, plus the version-chain names `options` configures. Its defaults match `./versioned-chain`'s conventions: `action` and the raw `timestamp` are internal; the aliased birth-row `createdAt` is admitted by name; `timestamp` is published as `updatedAt`. A document column, an embedding, and anything not in the view are refused. The derivation is memoised per view.
|
|
261
|
+
|
|
262
|
+
`resolveSortOrder({ view, sort, fallback?, tiebreak?, sortable? })` returns the caller's order when `sort` is non-empty, the resource's `fallback` otherwise, and **always closes with the view's `id`** so `LIMIT` / `OFFSET` paging is total-ordered. The tiebreak mirrors the last key's direction, and is left off when the caller already sorted by `id`. An unknown field fails the whole request with `UnsupportedSortError`, before any key is mapped. That is a `Schema.TaggedError` carrying `field`, the sorted `allowed` list (so the error document can say what the caller could have asked for) and `code: "unsupported.sort-field"`. A JSON:API boundary maps it to a 400 with `source: { parameter: "sort" }`. `chronologicalOrder({ view, sort, direction? })` is the commonest fallback spelled once: newest-first by `timestamp`, with a mirrored tiebreak.
|
|
263
|
+
|
|
264
|
+
Sorting a current-version view is bounded (the page keeps its `LIMIT`) but not index-backed; a client `?sort=` changes which column is sorted, not the cost class of the query.
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
export interface SortKey {
|
|
268
|
+
readonly field: string;
|
|
269
|
+
readonly direction: "asc" | "desc";
|
|
270
|
+
}
|
|
271
|
+
export type OrderTerm = PgColumn | SQL | SQL.Aliased;
|
|
272
|
+
export type SortableColumns = Readonly<Record<string, OrderTerm>>;
|
|
273
|
+
export class UnsupportedSortError /* Schema.TaggedError: field, allowed; code, message */ {}
|
|
274
|
+
export const sortableColumnsFor: (
|
|
275
|
+
relation: Table | View,
|
|
276
|
+
options?: SortableColumnsOptions, // { internal?, aliased?, renamed? }
|
|
277
|
+
) => SortableColumns;
|
|
278
|
+
export const resolveSortOrder: (
|
|
279
|
+
options: ResolveSortOrderOptions,
|
|
280
|
+
) => Effect.Effect<ReadonlyArray<OrderTerm>, UnsupportedSortError>;
|
|
281
|
+
export const chronologicalOrder: (
|
|
282
|
+
options: ChronologicalOrderOptions,
|
|
283
|
+
) => Effect.Effect<ReadonlyArray<OrderTerm>, UnsupportedSortError>;
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
const list = Effect.gen(function* () {
|
|
288
|
+
const order = yield* resolveSortOrder({
|
|
289
|
+
view: postView,
|
|
290
|
+
sort: query.sort,
|
|
291
|
+
fallback: [desc(postView.timestamp)],
|
|
292
|
+
tiebreak: "desc",
|
|
293
|
+
});
|
|
294
|
+
return yield* db
|
|
295
|
+
.select()
|
|
296
|
+
.from(postView)
|
|
297
|
+
.orderBy(...order)
|
|
298
|
+
.limit(limit)
|
|
299
|
+
.offset(offset);
|
|
300
|
+
});
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## `./update`
|
|
304
|
+
|
|
305
|
+
The update tri-state at a single seam. An update input encodes three states across key presence and value — a value **sets**, a present `null` **clears** (a nullable column), an absent key or explicit `undefined` **carries the stored value forward**. `updateSetMap(input, writable)` builds the `UPDATE … SET` map from the input's own keys: every key in `writable` with a present value (`null` included), in the input's key order. The routing `id`, server-owned columns and unknown keys drop out, and so does an `undefined` value, so a carry-forward can never become a clear. `insertValues` is the same projection for a create: an absent optional is left to the column default.
|
|
306
|
+
|
|
307
|
+
Nothing here is `Option`: the `null` / `undefined` split _is_ the tri-state, and the map is Drizzle-facing.
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
export type UpdateSet<I, K extends string> = {
|
|
311
|
+
readonly [P in Extract<keyof I, K>]?: Exclude<I[P], undefined>;
|
|
312
|
+
};
|
|
313
|
+
export const updateSetMap: <I extends Readonly<Record<string, unknown>>, K extends string>(
|
|
314
|
+
input: I,
|
|
315
|
+
writable: Iterable<K>,
|
|
316
|
+
) => UpdateSet<I, K>;
|
|
317
|
+
export const insertValues: typeof updateSetMap;
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
const WRITABLE = HashSet.make("name", "summary", "birthPlaceId");
|
|
322
|
+
yield * tx.update(person).set(updateSetMap(input, WRITABLE)).where(eq(person.id, input.id));
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## `./errors`
|
|
326
|
+
|
|
327
|
+
`@effect/sql-pg` already classifies SQLSTATE into `SqlError.reason`, but keeps the violated constraint for `23505` only — every other `23xxx` becomes a bare `ConstraintError` with no constraint / table / column — and drizzle wraps the whole thing inside `EffectDrizzleQueryError.cause`, an Effect `Cause`, not the driver error. `findPgError` recovers the facts anyway: a bounded (depth 8), cycle-guarded walk of the error graph — reading `.cause` explicitly (it is non-enumerable on both `SqlError` and `EffectDrizzleQueryError`) plus enumerable values and arrays (a `Cause`'s `reasons`) — for the first node whose `code` / `sqlState` looks like a five-character SQLSTATE. It matches a bare driver error, a bare `SqlError`, or the fully drizzle-wrapped shape alike, and imports neither `drizzle-orm` nor `@effect/sql-pg` to do it (the shapes are recognised structurally).
|
|
328
|
+
|
|
329
|
+
`classifyError` dispatches the recovered SQLSTATE into one of seven tagged errors:
|
|
330
|
+
|
|
331
|
+
| SQLSTATE | Class | Facts | Retryable |
|
|
332
|
+
| -------- | ---------------------- | ------------------------------- | --------- |
|
|
333
|
+
| `23505` | `UniqueViolation` | `constraint`, `table`, `detail` | no |
|
|
334
|
+
| `23503` | `ForeignKeyViolation` | `constraint`, `table`, `detail` | no |
|
|
335
|
+
| `23502` | `NotNullViolation` | `column`, `table` | no |
|
|
336
|
+
| `23514` | `CheckViolation` | `constraint`, `table` | no |
|
|
337
|
+
| `23P01` | `ExclusionViolation` | `constraint`, `table` | no |
|
|
338
|
+
| `40001` | `SerializationFailure` | — | yes |
|
|
339
|
+
| `40P01` | `DeadlockDetected` | — | yes |
|
|
340
|
+
|
|
341
|
+
Every class carries `cause` (the value classification started from, so the wrapping graph — drizzle's query text and params, the `SqlError` — survives). `classifyError` is `Option.none()` outside these seven (a syntax error, a connection failure — `SqlError.reason` already covers those well). `catchConstraintViolation` / `catchUniqueViolation` are data-last Effect combinators that recover the matching class and re-fail everything else, including other constraint classes, unchanged. `isUniqueViolation` is kept for adoption parity with the consuming applications' pre-existing predicate: same name, same semantics, including a fallback to the stable Postgres message fragment when no `code` survived the wrapping.
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
export const findPgError: (error: unknown) => Option.Option<PgErrorFacts>;
|
|
345
|
+
export const classifyError: (error: unknown) => Option.Option<ClassifiedError>;
|
|
346
|
+
export const isUniqueViolation: (error: unknown) => boolean;
|
|
347
|
+
export const isConstraintViolation: (error: unknown) => boolean;
|
|
348
|
+
export const isRetryableTransactionFailure: (error: unknown) => boolean;
|
|
349
|
+
export const catchConstraintViolation: <A2, E2, R2>(
|
|
350
|
+
f: (violation: ConstraintViolation) => Effect.Effect<A2, E2, R2>,
|
|
351
|
+
) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A | A2, E | E2, R | R2>;
|
|
352
|
+
export const catchUniqueViolation: <A2, E2, R2>(
|
|
353
|
+
f: (violation: UniqueViolation) => Effect.Effect<A2, E2, R2>,
|
|
354
|
+
) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A | A2, E | E2, R | R2>;
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Not owned here: `NotFound` / `Unauthorized` / `Forbidden` (application / JSON:API wire vocabulary).
|
|
358
|
+
|
|
359
|
+
## `./database`
|
|
360
|
+
|
|
361
|
+
`makeDatabaseServices<Db, Tx>()({ key })` builds one application's transaction boundary as a pair of `Context.Service` keys plus two helpers, generic over the app's own Effect-native Drizzle database and transaction types — no `@effect/sql-pg` type reaches this module. The identifier of each key is the key string itself, so an application writes `type Database = typeof Database.Identifier` and gets a requirement it can name in its own signatures; `CurrentTransaction`'s key is `${Key}/CurrentTransaction`, so two applications composed into one process never collide.
|
|
362
|
+
|
|
363
|
+
`transact(body)` is **always-open, never join-if-present**: calling it while a transaction is already open deliberately opens a _nested_ one (a SAVEPOINT on the same connection, via `tx.transaction(...)` rather than `db.transaction(...)`, which is a SAVEPOINT on every driver). A "join the ambient transaction if there is one" variant is easy to write and is the wrong call — it would silently delete every SAVEPOINT the calling code currently takes, changing rollback granularity without changing a line of that code. To reuse the open transaction, yield `CurrentTransaction` directly and do not open another; this is adjudicated (website #1883) and is not something to "improve" into join-if-present.
|
|
364
|
+
|
|
365
|
+
`readHandle` is the asymmetric counterpart for reads: it prefers the ambient `CurrentTransaction` when one is open, falling back to `Database` otherwise. A read opens no boundary, so there is nothing to lose by joining — preferring the transaction is also the safer default, not merely convenient, since a read issued against `Database` from inside an open transaction takes its own pooled connection and is not guaranteed to see that transaction's uncommitted writes.
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
export interface DatabaseServices<Db, Tx extends ReadSurface, Key extends string> {
|
|
369
|
+
readonly Database: Context.Service<Key, Db>;
|
|
370
|
+
readonly CurrentTransaction: Context.Service<`${Key}/CurrentTransaction`, Tx>;
|
|
371
|
+
readonly transact: <A, E, R>(
|
|
372
|
+
body: Effect.Effect<A, E, R>,
|
|
373
|
+
) => Effect.Effect<A, E | SqlError, Exclude<R, `${Key}/CurrentTransaction`> | Key>;
|
|
374
|
+
readonly readHandle: Effect.Effect<ReadHandle<Tx>, never, Key>;
|
|
375
|
+
}
|
|
376
|
+
export const makeDatabaseServices: <
|
|
377
|
+
Db extends TransactionalDatabase<Tx> & ReadHandle<Tx>,
|
|
378
|
+
Tx extends ReadSurface & TransactionalDatabase<Tx>,
|
|
379
|
+
>() => <const Key extends string>(options: { readonly key: Key }) => DatabaseServices<Db, Tx, Key>;
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
## `./versioned-chain`
|
|
383
|
+
|
|
384
|
+
The opt-in interpreter for "a tree of levels, each a stable + version table pair, joined up a composite `(id, version)` FK chain to a root, with fail-closed current-version views" — the shape a temporal-versioned node hierarchy takes. `./versioned-chain` carries no application ontology; both factories are parameterised over the bits one hierarchy pins.
|
|
385
|
+
|
|
386
|
+
`makeSubtypeTables(applyDiscriminator)(schema, config)` builds one leaf's `{ stable, version }` pair: the stable identity row (`id` primary key referencing the parent branch's stable `id`, plus a denormalised `type` discriminator), and the version row (`(id, version)` primary key, `(id, version)` foreign-keyed to the parent branch's version table). Domain columns are authored per leaf and merged in via `config.columns`; `discriminator`, when present, makes `type` `NOT NULL` and appends the constraints `applyDiscriminator` returns (a `./constraints` `DiscriminatorToolkit` is the intended hook). Its return type, `SubtypeTables<TColumns>`, is written out as an explicit annotation rather than inferred — the declaration printer cannot reduce the homomorphic `PgBuildColumns` mapped type while `TColumns` is still generic, and would otherwise emit an unbound `TTableName` reference that `skipLibCheck` hides.
|
|
387
|
+
|
|
388
|
+
`makeCurrentVersionView(deps)(schema, name, config)` builds the uniform fail-closed current-version view: currency resolves from `deps.rootVersion` (a `MAX(version)` sub-select grouped by `id`), and `config.leaf` / `config.through` INNER JOIN at exactly that `(id, version)` — a node whose root version advanced past its leaf (a partial-chain tombstone) yields **no row**, rather than surfacing a stale older leaf row. `config.optional` LEFT JOINs 6NF satellite fact tables instead, where row-absence at a version encodes NULL. `deps.merge` can be a plain object (columns merged from `deps.identity`, no extra join, the emitted view definition unchanged) or a function (additionally INNER JOINs a `version = 0` alias of `rootVersion` and hands it to the callback, so a hierarchy can merge in a true `createdAt` sourced from the node's birth row, distinct from the current version's timestamp) — the object form matters for a root version table whose view is itself joined by another hand-written view, since drizzle-kit does not topologically order view-on-view DROPs.
|
|
389
|
+
|
|
390
|
+
**No view built by `makeCurrentVersionView` may select from another view built by `makeCurrentVersionView`** — a library-level rule, not just an observation about the DROP-ordering hazard above.
|
|
391
|
+
|
|
392
|
+
The read shapes of a chain are structural types, so a history listing and the page that renders it can share them without depending on an operations module. `VersionChainEntry<Action>` is one `{ version, action, timestamp }` row. `VersionSnapshot<Action, Extra>` is one version's reconstructed state: the entry plus `columns` (every leaf-chain domain column at that version) and whatever branch-level facts `Extra` names. `Action` defaults to `"create" | "update" | "delete"`. `versionTimestamps(row)` reads a current-version row's `createdAt` / `updatedAt` pair: `updatedAt` is the current `timestamp`, and `createdAt` comes from the birth-row merge, falling back to `updatedAt` for a view that has none.
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
export const makeSubtypeTables: <TDiscriminator>(
|
|
396
|
+
applyDiscriminator: (
|
|
397
|
+
level: string,
|
|
398
|
+
stable: DiscriminatorColumns,
|
|
399
|
+
discriminator: TDiscriminator,
|
|
400
|
+
) => PgTableExtraConfigValue[],
|
|
401
|
+
) => <TColumns extends Record<string, ColumnBuilderBase>>(
|
|
402
|
+
schema: PgSchema,
|
|
403
|
+
config: SubtypeTablesConfig<TColumns, TDiscriminator>,
|
|
404
|
+
) => SubtypeTables<TColumns>;
|
|
405
|
+
export const makeCurrentVersionView: <
|
|
406
|
+
TRoot extends PgTable & VersionTable,
|
|
407
|
+
TMerge extends ViewColumns,
|
|
408
|
+
>(
|
|
409
|
+
deps: CurrentVersionViewDeps<TRoot, TMerge>,
|
|
410
|
+
) => <TColumns extends ViewColumns>(
|
|
411
|
+
schema: PgSchema,
|
|
412
|
+
name: string,
|
|
413
|
+
config: CurrentVersionViewConfig<TColumns>,
|
|
414
|
+
) => CurrentVersionViewResult<TColumns, TMerge>;
|
|
415
|
+
export type VersionAction = "create" | "update" | "delete";
|
|
416
|
+
export interface VersionChainEntry<Action extends string = VersionAction> {
|
|
417
|
+
readonly version: bigint;
|
|
418
|
+
readonly action: Action;
|
|
419
|
+
readonly timestamp: Date;
|
|
420
|
+
}
|
|
421
|
+
export type VersionSnapshot<
|
|
422
|
+
Action extends string = VersionAction,
|
|
423
|
+
Extra extends object = {},
|
|
424
|
+
> = VersionChainEntry<Action> & Extra & { readonly columns: Readonly<Record<string, unknown>> };
|
|
425
|
+
export const versionTimestamps: (row: {
|
|
426
|
+
readonly timestamp: Date;
|
|
427
|
+
readonly createdAt?: Date | null | undefined;
|
|
428
|
+
}) => { readonly createdAt: Date; readonly updatedAt: Date };
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
## `./jsonapi` and `./jsonapi/derive`
|
|
432
|
+
|
|
433
|
+
For a consumer whose models are `@thomasfosterau/effect-jsonapi` `Resource.make` definitions, whose `dbColumn` annotations sit behind an extra layer — the `Resource.attribute` descriptor, and the `optionalKey` wrapper a `resource: "optional"` attribute takes. `./jsonapi` is the reader for that shape (the resource-shaped counterpart of `fieldColumns` / `fieldColumnBounds`); `./jsonapi/derive` is the Drizzle-emitting half, a thin join over `./derive`'s generic interpreter — no failure modes, CHECK naming or `resolveReference` semantics of its own.
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
import { Resource } from "@thomasfosterau/effect-jsonapi";
|
|
437
|
+
import { dbColumn, dbTable } from "./db-annotation.ts"; // resource-narrowed dbColumnFor / dbTableFor
|
|
438
|
+
|
|
439
|
+
export const Article = Resource.make("articles", {
|
|
440
|
+
attributes: {
|
|
441
|
+
title: dbColumn(Schema.NonEmptyString, { column: "title", pg: "text", notNull: true }),
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
dbTable(Article, { name: "article", level: "work" });
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
import { makeDeriveResourceColumns } from "@thomasfosterau/effect-drizzle-ext/jsonapi/derive";
|
|
449
|
+
|
|
450
|
+
const deriveResourceColumns = makeDeriveResourceColumns(builders, {
|
|
451
|
+
resolveReference: references.resolve,
|
|
452
|
+
});
|
|
453
|
+
export const articleColumns = deriveResourceColumns(Article);
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
`resourceAttributeProjections(resource)` reads each resource-object attribute's **projection shape** as plain data, in declaration order. That shape is what an operation factory needs to derive a row → resource mapper and a column map from the model alone:
|
|
457
|
+
|
|
458
|
+
- `optional` — an `optionalKey` projection, read off the AST;
|
|
459
|
+
- `nullable`, `array`, `date` — behavioural decode probes through the attribute's JSON codec, so `Schema.Date` counts as a date because an ISO string decodes to a `Date`;
|
|
460
|
+
- `column` — the `dbColumn` column name, `Option.none()` when the attribute declares none.
|
|
461
|
+
|
|
462
|
+
Input-only attributes (`resource: false`) are not on the resource object and are not listed.
|
|
463
|
+
|
|
464
|
+
`dbTable` / `dbTableFor` / `readDbTable` are re-exported here narrowed to `R extends AnyResource` — the same functions as `.`, so a resource-facing `db-annotation.ts` imports every name it needs from one place.
|
|
465
|
+
|
|
466
|
+
```ts
|
|
467
|
+
export type AnyResource = Resource.Any;
|
|
468
|
+
export const resourceColumns: (resource: AnyResource) => Record<string, DbColumn>;
|
|
469
|
+
export const resourceColumnBounds: (resource: AnyResource) => Record<string, ColumnBounds>;
|
|
470
|
+
export interface ResourceAttributeProjection {
|
|
471
|
+
readonly key: string;
|
|
472
|
+
readonly optional: boolean;
|
|
473
|
+
readonly nullable: boolean;
|
|
474
|
+
readonly array: boolean;
|
|
475
|
+
readonly date: boolean;
|
|
476
|
+
readonly column: Option.Option<string>;
|
|
477
|
+
}
|
|
478
|
+
export const resourceAttributeProjections: (
|
|
479
|
+
resource: AnyResource,
|
|
480
|
+
) => ReadonlyArray<ResourceAttributeProjection>;
|
|
481
|
+
export const makeDeriveResourceColumns: <B extends ColumnBuilders>(
|
|
482
|
+
builders: B,
|
|
483
|
+
options?: DeriveOptions,
|
|
484
|
+
) => <R extends AnyResource>(resource: R) => DerivedResourceColumns<R, B>;
|
|
485
|
+
export const deriveCheckConstraints: (
|
|
486
|
+
resource: AnyResource,
|
|
487
|
+
tableName: string,
|
|
488
|
+
) => (table: Record<string, ExtraConfigColumn>) => PgTableExtraConfigValue[];
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
## Conventions
|
|
492
|
+
|
|
493
|
+
**Drizzle-facing config members are `?: T | undefined`, never `Option`.** Drizzle reads `undefined` as "not supplied"; handed an `Option`, it would emit the wrapper object itself as a literal value — wrong DDL that no type error and no unit test catches. Every reader the library owns (`readDbColumn`, `readDbTable`, `findPgError`, …) returns `Option` instead; the conversion happens at that boundary.
|
|
494
|
+
|
|
495
|
+
**The derivation decides column _types_, never column _order_ or _names_.** Both are what a consumer's `db:generate` emits into a migration, so reordering or renaming fields in a schema declaration is a migration to write by hand, not something this library infers.
|
|
496
|
+
|
|
497
|
+
**No view selects from another view built by `makeCurrentVersionView`.** drizzle-kit does not topologically order view-on-view DROPs, so layering one generated current-version view on top of another breaks migration generation; a hierarchy's own hand-written views may join a generated one, never the reverse.
|
|
498
|
+
|
|
499
|
+
## Limitations / non-goals
|
|
500
|
+
|
|
501
|
+
- **Id brands** — a consumer's own branded id types (`ArticleId`, `PersonId`, …) stay in the application; this library is id-agnostic.
|
|
502
|
+
- **Owner scoping** — multi-tenant visibility predicates (`owner_id`, role ladders) are application trust policy and stay in the application.
|
|
503
|
+
- **Migrations** — `drizzle-kit generate`, the generated SQL files and the deploy-time migrator stay in the application. The fresh-database runner, test-database provisioning and the drift / view-dependency guards are in [`@thomasfosterau/effect-drizzle-ext-pg`](https://github.com/thomasfosterau/effect-drizzle-ext/blob/trunk/packages/pg/README.md) (`./migrations`, `./testing`, `./tooling`).
|
|
504
|
+
- **Seed data** — fixture users and application seeds stay in the application's harness.
|
|
505
|
+
- **Operation factories** — the model / route / handler layer built around a derived table (create/update/list operations, viewer-scoped queries) stays in the application. The pieces of it that are generic over any table — the sort seam, the update `SET` map, the attribute projections — ship here; JSON:API link builders belong to `@thomasfosterau/effect-jsonapi`, and list visibility predicates to the authorization layer.
|
|
506
|
+
|
|
507
|
+
## License
|
|
508
|
+
|
|
509
|
+
MIT © Thomas Foster. Repository: [github.com/thomasfosterau/effect-drizzle-ext](https://github.com/thomasfosterau/effect-drizzle-ext). Extracted from the `thomasfosterau/website`, `thomasfosterau/gardeneria` and `thomasfosterau/preferenceflow` applications.
|