@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,298 @@
1
+ import { m as treeError } from "./src-CBeuhy5b.js";
2
+ import { makeDeriveColumns } from "./derive.js";
3
+ import { enumCheck, makeDiscriminatorConstraints } from "./constraints.js";
4
+ import * as Arr from "effect/Array";
5
+ import * as Option from "effect/Option";
6
+ import * as Predicate from "effect/Predicate";
7
+ import * as R from "effect/Record";
8
+ import { and, eq, getTableName, max } from "drizzle-orm";
9
+ import { alias, bigint, foreignKey, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core";
10
+ import * as Order from "effect/Order";
11
+ // src/versioned-chain.ts
12
+ /**
13
+ * @copyright Thomas Foster
14
+ *
15
+ * **Effect Schema → Drizzle derivation: the opt-in versioned-chain interpreter
16
+ * (Drizzle).**
17
+ *
18
+ * The generic "tree of levels, each a stable + version table pair, joined up a
19
+ * composite `(id, version)` FK chain to a root, with fail-closed current-version
20
+ * views" structure — the shape a temporal-versioned node hierarchy takes. Two
21
+ * factories, each parameterised over the bits a specific hierarchy pins:
22
+ *
23
+ * - {@link makeSubtypeTables} — the stable + version table-pair builder. Emits
24
+ * the structural plumbing (stable `id` → parent FK, version `id` / `version`
25
+ * columns, the `(id, version)` primary key, and the `(id, version)` →
26
+ * parent-version FK) while the domain columns stay authored per leaf. The
27
+ * discriminator constraints (a `type` column arced up the tree) are injected,
28
+ * so a hierarchy without a discriminator, or with different discriminator
29
+ * semantics, configures its own.
30
+ *
31
+ * - {@link makeCurrentVersionView} — the uniform fail-closed current-version
32
+ * view. Resolves each node's current version from a **root version table**,
33
+ * then INNER JOINs the leaf (and any intermediate version tables) at exactly
34
+ * that `(id, version)`; a node whose root version advanced past its leaf (a
35
+ * partial-chain tombstone) yields **no row** — the view fails closed rather
36
+ * than surfacing a stale older leaf row. The root version table and the
37
+ * always-joined identity row (plus the columns it merges into every view) are
38
+ * injected.
39
+ *
40
+ * Depends on Drizzle. The annotation layer ({@link .}) stays free of any
41
+ * versioning opinion — this interpreter is opt-in.
42
+ *
43
+ * @module versioned-chain
44
+ */
45
+ /**
46
+ * Build a `defineSubtypeTables` bound to a discriminator hook. The hook turns a
47
+ * leaf's `discriminator` declaration into the stable-table constraints that
48
+ * enforce it (e.g. a `UNIQUE(id, type)` + composite `(id, type)` → parent FK +
49
+ * per-level CHECK trio); a hierarchy without a discriminator passes a hook that
50
+ * is simply never invoked (its leaves omit `discriminator`).
51
+ *
52
+ * The returned builder emits a regular subtype's `{ stable, version }` table
53
+ * pair with the composite-`(id, version)` FK chain to its parent branch.
54
+ */
55
+ const makeSubtypeTables = (applyDiscriminator) => (schema, { name, parentId, parentVersion, columns, versionDefault, extraConstraints, discriminator }) => {
56
+ const stableColumns = {
57
+ id: uuid("id").primaryKey().references(() => parentId, { onDelete: "cascade" }),
58
+ type: discriminator ? text("type").notNull() : text("type")
59
+ };
60
+ const stable = discriminator ? schema.table(name, stableColumns, (table) => applyDiscriminator(name, {
61
+ id: table.id,
62
+ type: table.type
63
+ }, discriminator)) : schema.table(name, stableColumns);
64
+ return {
65
+ stable,
66
+ version: schema.table(`${name}_version`, {
67
+ id: uuid("id").notNull().references(() => stable.id, {
68
+ onUpdate: "cascade",
69
+ onDelete: "cascade"
70
+ }),
71
+ version: versionDefault === void 0 ? bigint("version", { mode: "bigint" }).notNull() : bigint("version", { mode: "bigint" }).notNull().default(versionDefault),
72
+ ...columns
73
+ }, (table) => {
74
+ const constraints = [primaryKey({
75
+ name: `${name}_version_pkey`,
76
+ columns: [table.id, table.version]
77
+ }), foreignKey({
78
+ name: `${name}_version_${getTableName(parentVersion)}_fk`,
79
+ columns: [table.id, table.version],
80
+ foreignColumns: [parentVersion.id, parentVersion.version]
81
+ })];
82
+ return extraConstraints ? [...constraints, ...extraConstraints(table)] : constraints;
83
+ })
84
+ };
85
+ };
86
+ /** Whether a `merge` is the function form, which receives the birth-row alias. */
87
+ const isMergeFunction = (merge) => Predicate.isFunction(merge);
88
+ /**
89
+ * Build a `currentVersionView` bound to a root version table + identity row.
90
+ * `schema.view(name).as(...)` with the uniform join chain generated from
91
+ * {@link CurrentVersionViewConfig}.
92
+ *
93
+ * The joins only widen the `FROM` clause; they never change the explicit
94
+ * `columns` selection, so the view's `$inferSelect` is derived directly from
95
+ * `columns` + the injected `merge` via {@link CurrentVersionViewResult}. The
96
+ * runtime query is assembled untyped (the chain alternates join shapes), which
97
+ * is why the return type is asserted rather than inferred: letting `.as()` infer
98
+ * the selection through `.from(current)` collapses every column to `never`.
99
+ *
100
+ * Currency is resolved from `rootVersion` (fail-closed): a `MAX(version)`
101
+ * sub-select drives the FROM, and the leaf / intermediate version tables INNER
102
+ * JOIN at that current `(id, version)`. One row per node (the sub-select groups
103
+ * by id) landing on unique `(id, version)` PKs, so no `DISTINCT ON` / `ORDER BY`
104
+ * is needed. A leaf missing at the current version drops out of the INNER JOIN —
105
+ * the view yields no row.
106
+ */
107
+ const makeCurrentVersionView = ({ rootVersion, identity, merge }) => (schema, name, { leaf, through = [], optional = [], on = [], columns }) => {
108
+ const rootTable = rootVersion;
109
+ return schema.view(name).as((qb) => {
110
+ const current = qb.select({
111
+ id: rootVersion.id,
112
+ version: max(rootVersion.version).as("current_version")
113
+ }).from(rootTable).groupBy(rootVersion.id).as("current_version");
114
+ const versionZero = isMergeFunction(merge) ? alias(rootVersion, "version_zero") : void 0;
115
+ const mergeColumns = isMergeFunction(merge) ? merge(versionZero) : merge;
116
+ let q = qb.select({
117
+ ...columns,
118
+ ...mergeColumns
119
+ }).from(current);
120
+ q = q.innerJoin(leaf, and(eq(current.id, leaf.id), eq(current.version, leaf.version)));
121
+ q = Arr.reduce(through, q, (acc, t) => acc.innerJoin(t, and(eq(current.id, t.id), eq(current.version, t.version))));
122
+ q = Arr.reduce(optional, q, (acc, t) => acc.leftJoin(t, and(eq(current.id, t.id), eq(current.version, t.version))));
123
+ q = Arr.reduce(on, q, (acc, t) => acc.innerJoin(t, eq(current.id, t.id)));
124
+ q = q.innerJoin(identity, eq(current.id, identity.id));
125
+ q = q.innerJoin(rootVersion, and(eq(current.id, rootVersion.id), eq(current.version, rootVersion.version)));
126
+ if (versionZero) q = q.innerJoin(versionZero, and(eq(current.id, versionZero.id), eq(versionZero.version, 0n)));
127
+ return q;
128
+ });
129
+ };
130
+ /**
131
+ * The `createdAt` / `updatedAt` pair of a current-version row: `updatedAt` is
132
+ * the current version's `timestamp`; `createdAt` is the birth row's own
133
+ * timestamp where the view merges one in (a function `merge` of
134
+ * {@link makeCurrentVersionView}), and falls back to `updatedAt` for a view
135
+ * that carries none (website #1223).
136
+ */
137
+ const versionTimestamps = (row) => ({
138
+ createdAt: row.createdAt ?? row.timestamp,
139
+ updatedAt: row.timestamp
140
+ });
141
+ /**
142
+ * An injected extras map, or `{}` when the member is absent.
143
+ *
144
+ * SAFETY: `T` is inferred from this very member, so it is a real map when the
145
+ * member is present and defaults to `{}` (the `IE` / `VE` defaults of
146
+ * {@link makeTreeTables}) exactly when it is absent.
147
+ */
148
+ const columnsOrEmpty = (columns) => columns ?? {};
149
+ /** The `action` values the root version table admits. */
150
+ const VERSION_ACTIONS = [
151
+ "create",
152
+ "update",
153
+ "delete"
154
+ ];
155
+ /**
156
+ * Derive every table and constraint of a tree in one call: the root identity
157
+ * table (`id`, `type`, extras; `UNIQUE (id, type)`) and root version table
158
+ * (`(id, version)` key, `action` checked to `create` / `update` / `delete`,
159
+ * `timestamp`, the actor, extras); then per level a stable table (`id` →
160
+ * parent `id`, cascade delete; `type` NOT NULL with `UNIQUE (id, type)`,
161
+ * `(id, type)` → parent `(id, type)`, and a CHECK narrowing `type` to the
162
+ * level's subtree leaves) and a version table (`(id, version)` key and
163
+ * `(id, version)` → parent version FK) carrying the level's level columns,
164
+ * its leaf's columns (both derived through `builders`) and its hook columns.
165
+ * A fact-scoped level's version table is a spine, with one
166
+ * `${level}_${column}` fact table per own column, keyed and foreign-keyed on
167
+ * `(id, version)` (cascade delete).
168
+ *
169
+ * Constraint names are canonical (`${table}_pkey`, `${table}_${parent}_fk`,
170
+ * `${level}_id_type_unique` / `_fk` / `_type_valid`); no version table carries
171
+ * `action`. Column, constraint and CHECK value order follow declaration order,
172
+ * so the same tree always generates the same migration. Throws
173
+ * `DerivationError` at module load for a column kind `builders` lacks or an
174
+ * unresolvable reference.
175
+ *
176
+ * Every table is typed from the tree's column phantom. The one type-erasure
177
+ * seam is inside: the level and fact records are assembled from the runtime
178
+ * tree, then named by {@link TreeTables}.
179
+ *
180
+ * @example
181
+ * ```ts
182
+ * import { dbColumn, defineTree } from "@thomasfosterau/effect-drizzle-ext";
183
+ * import { makeTreeTables } from "@thomasfosterau/effect-drizzle-ext/versioned-chain";
184
+ * import { getTableConfig, integer, pgSchema, text } from "drizzle-orm/pg-core";
185
+ * import * as Schema from "effect/Schema";
186
+ *
187
+ * const tree = defineTree({
188
+ * work: {
189
+ * columns: { title: dbColumn(Schema.String, { column: "title", pg: "text", notNull: true }) },
190
+ * },
191
+ * book: {
192
+ * parent: "work",
193
+ * leaf: { pages: dbColumn(Schema.Finite, { column: "pages", pg: "integer", notNull: false }) },
194
+ * },
195
+ * });
196
+ *
197
+ * const tables = makeTreeTables(tree, {
198
+ * schema: pgSchema("catalogue"),
199
+ * builders: { text: (c: string) => text(c), integer: (c: string) => integer(c) },
200
+ * });
201
+ *
202
+ * console.log(getTableConfig(tables.levels.book.version).columns.map((c) => c.name));
203
+ * // ["id", "version", "pages"]
204
+ * ```
205
+ */
206
+ const makeTreeTables = (tree, config) => {
207
+ const { schema } = config;
208
+ const derive = makeDeriveColumns(config.builders, { resolveReference: config.resolveReference });
209
+ const identityName = config.root?.identity ?? "node";
210
+ const versionName = config.root?.version ?? `${identityName}_version`;
211
+ const toolkit = makeDiscriminatorConstraints({ leaves: (level) => Option.getOrElse(Option.map(Arr.findFirst(tree.levels, (candidate) => candidate.name === level), (found) => found.subtreeLeaves), () => []) });
212
+ const identity = schema.table(identityName, {
213
+ id: uuid("id").primaryKey(),
214
+ type: text("type").notNull(),
215
+ ...columnsOrEmpty(config.root?.identityColumns)
216
+ }, (table) => [toolkit.typeUnique(identityName, {
217
+ id: table.id,
218
+ type: table.type
219
+ })]);
220
+ const version = schema.table(versionName, {
221
+ id: uuid("id").notNull().references(() => identity.id, { onDelete: "cascade" }),
222
+ version: bigint("version", { mode: "bigint" }).notNull(),
223
+ action: text("action").notNull(),
224
+ timestamp: timestamp("timestamp", { withTimezone: true }).notNull().defaultNow(),
225
+ actor: text(config.root?.actorColumn ?? "actor"),
226
+ ...columnsOrEmpty(config.root?.versionColumns)
227
+ }, (table) => [primaryKey({
228
+ name: `${versionName}_pkey`,
229
+ columns: [table.id, table.version]
230
+ }), enumCheck(`${versionName}_action_valid`, table.action, VERSION_ACTIONS)]);
231
+ const hooksOf = (name) => config.levels?.[name] ?? {};
232
+ const subtype = makeSubtypeTables((level, stable, parent) => toolkit.typeConstraints(level, stable, parent.stable, { refinable: parent.refinable }));
233
+ const builtLevel = (built, name) => Option.getOrElse(R.get(built, name), () => treeError(`level "${name}" was read before it was built`));
234
+ const buildLevel = (built, level) => {
235
+ const parent = Option.match(level.parent, {
236
+ onNone: () => ({
237
+ stable: identity,
238
+ version
239
+ }),
240
+ onSome: (name) => builtLevel(built, name).tables
241
+ });
242
+ const hooks = hooksOf(level.name);
243
+ const metas = {
244
+ ...level.levelColumns,
245
+ ...level.leafColumns
246
+ };
247
+ const own = derive.fromMeta(metas);
248
+ const factScoped = level.kind === "fact-scoped";
249
+ const tables = subtype(schema, {
250
+ name: level.name,
251
+ parentId: parent.stable.id,
252
+ parentVersion: parent.version,
253
+ columns: factScoped ? { ...hooks.columns } : {
254
+ ...own,
255
+ ...hooks.columns
256
+ },
257
+ extraConstraints: hooks.extraConstraints,
258
+ discriminator: {
259
+ stable: parent.stable,
260
+ refinable: Arr.some([...level.ancestors, level.name], (name) => hooksOf(name).refinable === true)
261
+ }
262
+ });
263
+ const factOf = ([key, meta], builder) => {
264
+ const name = `${level.name}_${meta.column}`;
265
+ return [key, schema.table(name, {
266
+ id: uuid("id").notNull(),
267
+ version: bigint("version", { mode: "bigint" }).notNull(),
268
+ [key]: builder
269
+ }, (table) => [primaryKey({
270
+ name: `${name}_pkey`,
271
+ columns: [table.id, table.version]
272
+ }), foreignKey({
273
+ name: `${name}_${getTableName(tables.version)}_fk`,
274
+ columns: [table.id, table.version],
275
+ foreignColumns: [tables.version.id, tables.version.version]
276
+ }).onDelete("cascade")])];
277
+ };
278
+ return {
279
+ tables,
280
+ facts: factScoped ? R.fromEntries(Arr.map(Arr.zip(R.toEntries(metas), R.values(own)), ([entry, builder]) => factOf(entry, builder))) : {}
281
+ };
282
+ };
283
+ const built = Arr.reduce(Arr.sort(tree.levels, Order.mapInput(Order.Number, (level) => level.ancestors.length)), R.empty(), (acc, level) => ({
284
+ ...acc,
285
+ [level.name]: buildLevel(acc, level)
286
+ }));
287
+ return {
288
+ root: {
289
+ identity,
290
+ version
291
+ },
292
+ levels: R.fromEntries(Arr.map(tree.names, (name) => [name, builtLevel(built, name).tables])),
293
+ facts: R.fromEntries(Arr.map(tree.names, (name) => [name, builtLevel(built, name).facts]))
294
+ };
295
+ };
296
+ export { makeCurrentVersionView, makeSubtypeTables, makeTreeTables, versionTimestamps };
297
+
298
+ //# sourceMappingURL=versioned-chain.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"versioned-chain.js","names":[],"sources":["../src/versioned-chain.ts"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,112 @@
1
1
  {
2
2
  "name": "@thomasfosterau/effect-drizzle-ext",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "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.",
5
+ "keywords": [
6
+ "drizzle",
7
+ "drizzle-orm",
8
+ "effect",
9
+ "effect-schema",
10
+ "postgres",
11
+ "typescript"
12
+ ],
13
+ "homepage": "https://github.com/thomasfosterau/effect-drizzle-ext#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/thomasfosterau/effect-drizzle-ext/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Thomas Foster <me@thomasfoster.au>",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/thomasfosterau/effect-drizzle-ext.git",
22
+ "directory": "packages/core"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src",
27
+ "!src/**/*.test.ts",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "type": "module",
32
+ "sideEffects": false,
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "default": "./dist/index.js"
37
+ },
38
+ "./derive": {
39
+ "types": "./dist/derive.d.ts",
40
+ "default": "./dist/derive.js"
41
+ },
42
+ "./references": {
43
+ "types": "./dist/references.d.ts",
44
+ "default": "./dist/references.js"
45
+ },
46
+ "./constraints": {
47
+ "types": "./dist/constraints.d.ts",
48
+ "default": "./dist/constraints.js"
49
+ },
50
+ "./columns": {
51
+ "types": "./dist/columns.d.ts",
52
+ "default": "./dist/columns.js"
53
+ },
54
+ "./query": {
55
+ "types": "./dist/query.d.ts",
56
+ "default": "./dist/query.js"
57
+ },
58
+ "./sort": {
59
+ "types": "./dist/sort.d.ts",
60
+ "default": "./dist/sort.js"
61
+ },
62
+ "./update": {
63
+ "types": "./dist/update.d.ts",
64
+ "default": "./dist/update.js"
65
+ },
66
+ "./errors": {
67
+ "types": "./dist/errors.d.ts",
68
+ "default": "./dist/errors.js"
69
+ },
70
+ "./database": {
71
+ "types": "./dist/database.d.ts",
72
+ "default": "./dist/database.js"
73
+ },
74
+ "./versioned-chain": {
75
+ "types": "./dist/versioned-chain.d.ts",
76
+ "default": "./dist/versioned-chain.js"
77
+ },
78
+ "./jsonapi": {
79
+ "types": "./dist/jsonapi/index.d.ts",
80
+ "default": "./dist/jsonapi/index.js"
81
+ },
82
+ "./jsonapi/derive": {
83
+ "types": "./dist/jsonapi/derive.d.ts",
84
+ "default": "./dist/jsonapi/derive.js"
85
+ }
86
+ },
87
+ "publishConfig": {
88
+ "access": "public"
89
+ },
90
+ "devDependencies": {
91
+ "@effect/language-service": "^0.87.5",
92
+ "@thomasfosterau/effect-jsonapi": "^0.20.0",
93
+ "@types/node": "^26.6.6",
94
+ "@vitest/coverage-istanbul": "5.0.5",
95
+ "drizzle-orm": "1.0.0-rc.5",
96
+ "effect": "4.1.2",
97
+ "vitest": "5.0.5"
98
+ },
99
+ "peerDependencies": {
100
+ "@thomasfosterau/effect-jsonapi": ">=0.16.0",
101
+ "drizzle-orm": ">=1.0.0-rc.5",
102
+ "effect": "^4.0.0"
103
+ },
104
+ "peerDependenciesMeta": {
105
+ "@thomasfosterau/effect-jsonapi": {
106
+ "optional": true
107
+ }
108
+ },
109
+ "engines": {
110
+ "node": ">=22"
111
+ }
6
112
  }
package/src/columns.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * @copyright Thomas Foster
3
+ *
4
+ * Column helpers for Drizzle table definitions this library owns outright:
5
+ * a generic JSONB codec bridging any Effect Schema to `jsonb`, `tsvector`
6
+ * and `bytea` custom types, the `created_at` / `updated_at` timestamp pair
7
+ * every plain table carries, and the hex codec for rendering / parsing
8
+ * `bytea` values without the Node `Buffer` global.
9
+ *
10
+ * @module columns
11
+ */
12
+ import * as Option from "effect/Option";
13
+ import * as Predicate from "effect/Predicate";
14
+ import * as Schema from "effect/Schema";
15
+ import { customType, timestamp } from "drizzle-orm/pg-core";
16
+ import type {
17
+ ConvertCustomConfig,
18
+ PgCustomColumnBuilder,
19
+ PgTimestampBuilder,
20
+ SetHasDefault,
21
+ SetNotNull,
22
+ } from "drizzle-orm/pg-core";
23
+
24
+ /**
25
+ * The JSON text boundary, expressed as a schema rather than the `JSON`
26
+ * global: encoding runs `JSON.stringify` and decoding runs a total
27
+ * `JSON.parse` whose `None` covers a malformed string.
28
+ */
29
+ const JsonText = Schema.fromJsonString(Schema.Unknown);
30
+ const encodeJsonText = Schema.encodeSync(JsonText);
31
+ const decodeJsonText = Schema.decodeUnknownOption(JsonText);
32
+
33
+ /**
34
+ * Builds a typed JSONB Drizzle custom type backed by an Effect Schema.
35
+ *
36
+ * Values are stored as PostgreSQL `jsonb`. The schema validates on both
37
+ * read (`fromDriver`) and write (`toDriver`): writing encodes through the
38
+ * schema and then through {@link JsonText}; reading decodes unknown driver
39
+ * output straight through the schema.
40
+ *
41
+ * The driver this library ships a Postgres binding for (node-pg, via
42
+ * `@effect/sql-pg`) already parses `jsonb` columns before `fromDriver` sees
43
+ * them, so the `typeof value === "string"` branch below is dead under that
44
+ * driver — it exists for a driver that hands back raw JSON text instead,
45
+ * and is exercised directly by this module's tests.
46
+ *
47
+ * Nullable columns are handled transparently — `null` values pass through
48
+ * Drizzle without reaching this codec, so `.nullable()` is not required on
49
+ * `schema`.
50
+ *
51
+ * `schema` is bound to `Schema.Codec<unknown, unknown, never, never>` rather
52
+ * than the wider `Schema.Top`: `toDriver` / `fromDriver` are synchronous
53
+ * Drizzle hooks with no Effect context to satisfy a requirement from, so a
54
+ * schema this codec accepts must already need no services to decode or
55
+ * encode.
56
+ */
57
+ export const jsonbSchema =
58
+ <S extends Schema.Codec<unknown, unknown, never, never>>(schema: S) =>
59
+ (
60
+ name: string,
61
+ ): PgCustomColumnBuilder<ConvertCustomConfig<{ data: S["Type"]; driverData: unknown }>> => {
62
+ const encode = Schema.encodeSync(schema);
63
+ const decode = Schema.decodeUnknownSync(schema);
64
+ return customType<{ data: S["Type"]; driverData: unknown }>({
65
+ dataType() {
66
+ return "jsonb";
67
+ },
68
+ toDriver(value) {
69
+ return encodeJsonText(encode(value));
70
+ },
71
+ fromDriver(value) {
72
+ if (!Predicate.isString(value)) return decode(value);
73
+ return Option.match(decodeJsonText(value), {
74
+ onNone: () => decode(value),
75
+ onSome: (parsed) => decode(parsed),
76
+ });
77
+ },
78
+ })(name);
79
+ };
80
+
81
+ /**
82
+ * Custom column type for PostgreSQL `tsvector`.
83
+ *
84
+ * Used for generated full-text search columns. The value is opaque to the
85
+ * consuming application — queries use `sql` template literals for matching.
86
+ */
87
+ export const tsvector = (
88
+ name: string,
89
+ ): PgCustomColumnBuilder<ConvertCustomConfig<{ data: string; driverData: string }>> =>
90
+ customType<{ data: string; driverData: string }>({
91
+ dataType() {
92
+ return "tsvector";
93
+ },
94
+ })(name);
95
+
96
+ /**
97
+ * Custom column type for PostgreSQL `bytea` (binary data).
98
+ *
99
+ * Typed `Uint8Array` rather than `Buffer` — see {@link byteaToHex} for why
100
+ * the Node global is off-limits here. Both directions are honest about the
101
+ * runtime: `@effect/sql-pg`'s binary `bytea` codec decodes to a `Uint8Array`
102
+ * and encodes any `Uint8Array` (a Node `Buffer` included, as a subclass) as
103
+ * raw bytes, so a plain `Uint8Array` binds identically to a `Buffer`.
104
+ */
105
+ export const bytea = (
106
+ name: string,
107
+ ): PgCustomColumnBuilder<ConvertCustomConfig<{ data: Uint8Array; driverData: Uint8Array }>> =>
108
+ customType<{ data: Uint8Array; driverData: Uint8Array }>({
109
+ dataType() {
110
+ return "bytea";
111
+ },
112
+ })(name);
113
+
114
+ /**
115
+ * The `created_at` / `updated_at` builder pair {@link timestamps} returns:
116
+ * `timestamptz`, `NOT NULL`, defaulting to `now()`.
117
+ */
118
+ export interface TimestampColumns {
119
+ /** `created_at timestamptz NOT NULL DEFAULT now()`. */
120
+ readonly createdAt: SetHasDefault<SetNotNull<PgTimestampBuilder>>;
121
+ /** `updated_at timestamptz NOT NULL DEFAULT now()`. */
122
+ readonly updatedAt: SetHasDefault<SetNotNull<PgTimestampBuilder>>;
123
+ }
124
+
125
+ /**
126
+ * A fresh `created_at` / `updated_at` pair: `timestamptz`, `NOT NULL`,
127
+ * defaulting to `now()`.
128
+ *
129
+ * A factory, never a shared object: Drizzle column builders are
130
+ * **mutable** — `.notNull()` and `.defaultNow()` mutate and return the same
131
+ * builder instance. A module-level constant reused across tables would
132
+ * therefore hand every table the same two builders, and whichever table
133
+ * was declared last would win any later refinement. Calling `timestamps()`
134
+ * per table mints a fresh pair.
135
+ *
136
+ * `updated_at` defaults rather than auto-updates — nothing in Postgres
137
+ * bumps it, the write path does.
138
+ *
139
+ * Spread rather than a base table: Drizzle records a table's columns in
140
+ * declaration order, and that order is what a consumer's `db:generate`
141
+ * emits as the `CREATE TABLE` column order. Spreading at the position two
142
+ * hand-written lines occupied keeps generated SQL byte-identical, so
143
+ * adopting this helper does not move a baseline migration.
144
+ */
145
+ export const timestamps = (): TimestampColumns => ({
146
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
147
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
148
+ });
149
+
150
+ /**
151
+ * Renders a `bytea` value (typically a content hash) as its lowercase hex
152
+ * string.
153
+ *
154
+ * Written against `Uint8Array` rather than `Buffer.prototype.toString("hex")`
155
+ * on purpose: a runtime that declares the Node `Buffer` global alongside
156
+ * `@types/node` (Cloudflare Workers' `nodejs_compat` types are one example)
157
+ * can strip the Node-only methods off the global `Buffer` interface, or
158
+ * degrade the `Buffer` *value* to `any`, so every `Buffer.*` call goes
159
+ * unchecked with no signal at all. `Uint8Array` is what a `bytea` row value
160
+ * structurally is on every runtime, so this encodes identically everywhere
161
+ * without depending on that global.
162
+ */
163
+ export const byteaToHex = (bytes: Uint8Array): string =>
164
+ Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
165
+
166
+ /**
167
+ * Decodes a hex string into the `Uint8Array` a `bytea` column binds — the
168
+ * inverse of {@link byteaToHex}, replacing `Buffer.from(hex, "hex")`.
169
+ *
170
+ * Strict where `Buffer.from` is silent: `Buffer.from` decodes the leading
171
+ * valid pairs and drops the rest, so a malformed hash quietly becomes a
172
+ * short-but-plausible key that matches no row. This throws instead — a
173
+ * caller that takes a hash from outside the trust boundary is expected to
174
+ * validate its shape before calling this, and gets a loud failure instead
175
+ * of a silently truncated key when it doesn't.
176
+ *
177
+ * @throws TypeError if `hex` is not an even-length run of hex digits.
178
+ */
179
+ export const hexToBytea = (hex: string): Uint8Array => {
180
+ if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) {
181
+ throw new TypeError(`Expected an even-length hex string, received ${encodeJsonText(hex)}`);
182
+ }
183
+ return Uint8Array.from({ length: hex.length / 2 }, (_unused, index) =>
184
+ Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16),
185
+ );
186
+ };