@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,113 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { ConvertCustomConfig, PgCustomColumnBuilder, PgTimestampBuilder, SetHasDefault, SetNotNull } from "drizzle-orm/pg-core";
3
+ /**
4
+ * Builds a typed JSONB Drizzle custom type backed by an Effect Schema.
5
+ *
6
+ * Values are stored as PostgreSQL `jsonb`. The schema validates on both
7
+ * read (`fromDriver`) and write (`toDriver`): writing encodes through the
8
+ * schema and then through {@link JsonText}; reading decodes unknown driver
9
+ * output straight through the schema.
10
+ *
11
+ * The driver this library ships a Postgres binding for (node-pg, via
12
+ * `@effect/sql-pg`) already parses `jsonb` columns before `fromDriver` sees
13
+ * them, so the `typeof value === "string"` branch below is dead under that
14
+ * driver — it exists for a driver that hands back raw JSON text instead,
15
+ * and is exercised directly by this module's tests.
16
+ *
17
+ * Nullable columns are handled transparently — `null` values pass through
18
+ * Drizzle without reaching this codec, so `.nullable()` is not required on
19
+ * `schema`.
20
+ *
21
+ * `schema` is bound to `Schema.Codec<unknown, unknown, never, never>` rather
22
+ * than the wider `Schema.Top`: `toDriver` / `fromDriver` are synchronous
23
+ * Drizzle hooks with no Effect context to satisfy a requirement from, so a
24
+ * schema this codec accepts must already need no services to decode or
25
+ * encode.
26
+ */
27
+ export declare const jsonbSchema: <S extends Schema.Codec<unknown, unknown, never, never>>(schema: S) => (name: string) => PgCustomColumnBuilder<ConvertCustomConfig<{
28
+ data: S["Type"];
29
+ driverData: unknown;
30
+ }>>;
31
+ /**
32
+ * Custom column type for PostgreSQL `tsvector`.
33
+ *
34
+ * Used for generated full-text search columns. The value is opaque to the
35
+ * consuming application — queries use `sql` template literals for matching.
36
+ */
37
+ export declare const tsvector: (name: string) => PgCustomColumnBuilder<ConvertCustomConfig<{
38
+ data: string;
39
+ driverData: string;
40
+ }>>;
41
+ /**
42
+ * Custom column type for PostgreSQL `bytea` (binary data).
43
+ *
44
+ * Typed `Uint8Array` rather than `Buffer` — see {@link byteaToHex} for why
45
+ * the Node global is off-limits here. Both directions are honest about the
46
+ * runtime: `@effect/sql-pg`'s binary `bytea` codec decodes to a `Uint8Array`
47
+ * and encodes any `Uint8Array` (a Node `Buffer` included, as a subclass) as
48
+ * raw bytes, so a plain `Uint8Array` binds identically to a `Buffer`.
49
+ */
50
+ export declare const bytea: (name: string) => PgCustomColumnBuilder<ConvertCustomConfig<{
51
+ data: Uint8Array;
52
+ driverData: Uint8Array;
53
+ }>>;
54
+ /**
55
+ * The `created_at` / `updated_at` builder pair {@link timestamps} returns:
56
+ * `timestamptz`, `NOT NULL`, defaulting to `now()`.
57
+ */
58
+ export interface TimestampColumns {
59
+ /** `created_at timestamptz NOT NULL DEFAULT now()`. */
60
+ readonly createdAt: SetHasDefault<SetNotNull<PgTimestampBuilder>>;
61
+ /** `updated_at timestamptz NOT NULL DEFAULT now()`. */
62
+ readonly updatedAt: SetHasDefault<SetNotNull<PgTimestampBuilder>>;
63
+ }
64
+ /**
65
+ * A fresh `created_at` / `updated_at` pair: `timestamptz`, `NOT NULL`,
66
+ * defaulting to `now()`.
67
+ *
68
+ * A factory, never a shared object: Drizzle column builders are
69
+ * **mutable** — `.notNull()` and `.defaultNow()` mutate and return the same
70
+ * builder instance. A module-level constant reused across tables would
71
+ * therefore hand every table the same two builders, and whichever table
72
+ * was declared last would win any later refinement. Calling `timestamps()`
73
+ * per table mints a fresh pair.
74
+ *
75
+ * `updated_at` defaults rather than auto-updates — nothing in Postgres
76
+ * bumps it, the write path does.
77
+ *
78
+ * Spread rather than a base table: Drizzle records a table's columns in
79
+ * declaration order, and that order is what a consumer's `db:generate`
80
+ * emits as the `CREATE TABLE` column order. Spreading at the position two
81
+ * hand-written lines occupied keeps generated SQL byte-identical, so
82
+ * adopting this helper does not move a baseline migration.
83
+ */
84
+ export declare const timestamps: () => TimestampColumns;
85
+ /**
86
+ * Renders a `bytea` value (typically a content hash) as its lowercase hex
87
+ * string.
88
+ *
89
+ * Written against `Uint8Array` rather than `Buffer.prototype.toString("hex")`
90
+ * on purpose: a runtime that declares the Node `Buffer` global alongside
91
+ * `@types/node` (Cloudflare Workers' `nodejs_compat` types are one example)
92
+ * can strip the Node-only methods off the global `Buffer` interface, or
93
+ * degrade the `Buffer` *value* to `any`, so every `Buffer.*` call goes
94
+ * unchecked with no signal at all. `Uint8Array` is what a `bytea` row value
95
+ * structurally is on every runtime, so this encodes identically everywhere
96
+ * without depending on that global.
97
+ */
98
+ export declare const byteaToHex: (bytes: Uint8Array) => string;
99
+ /**
100
+ * Decodes a hex string into the `Uint8Array` a `bytea` column binds — the
101
+ * inverse of {@link byteaToHex}, replacing `Buffer.from(hex, "hex")`.
102
+ *
103
+ * Strict where `Buffer.from` is silent: `Buffer.from` decodes the leading
104
+ * valid pairs and drops the rest, so a malformed hash quietly becomes a
105
+ * short-but-plausible key that matches no row. This throws instead — a
106
+ * caller that takes a hash from outside the trust boundary is expected to
107
+ * validate its shape before calling this, and gets a loud failure instead
108
+ * of a silently truncated key when it doesn't.
109
+ *
110
+ * @throws TypeError if `hex` is not an even-length run of hex digits.
111
+ */
112
+ export declare const hexToBytea: (hex: string) => Uint8Array;
113
+ //# sourceMappingURL=columns.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"columns.d.ts","names":[],"sources":["../src/columns.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;qBAwDa,cACV,UAAU,OAAO,uCAAqC,QAAU,OAAC,iBAG/D,sBAAsB;EAAsB,MAAM;EAAW;;;;;;;;qBA0BrD,WAAQ,iBAElB,sBAAsB;EAAsB;EAAc;;;;;;;;;;;qBAgBhD,QAAK,iBAEf,sBAAsB;EAAsB,MAAM;EAAY,YAAY;;;;;;iBAW5D;;WAEN,WAAW,cAAc,WAAW;;WAEpC,WAAW,cAAc,WAAW;;;;;;;;;;;;;;;;;;;;;;qBAuBlC,kBAAiB;;;;;;;;;;;;;;qBAkBjB,aAAU,OAAW;;;;;;;;;;;;;;qBAgBrB,aAAU,gBAAkB"}
@@ -0,0 +1,146 @@
1
+ import * as Option from "effect/Option";
2
+ import * as Predicate from "effect/Predicate";
3
+ import * as Schema from "effect/Schema";
4
+ import { customType, timestamp } from "drizzle-orm/pg-core";
5
+ // src/columns.ts
6
+ /**
7
+ * @copyright Thomas Foster
8
+ *
9
+ * Column helpers for Drizzle table definitions this library owns outright:
10
+ * a generic JSONB codec bridging any Effect Schema to `jsonb`, `tsvector`
11
+ * and `bytea` custom types, the `created_at` / `updated_at` timestamp pair
12
+ * every plain table carries, and the hex codec for rendering / parsing
13
+ * `bytea` values without the Node `Buffer` global.
14
+ *
15
+ * @module columns
16
+ */
17
+ /**
18
+ * The JSON text boundary, expressed as a schema rather than the `JSON`
19
+ * global: encoding runs `JSON.stringify` and decoding runs a total
20
+ * `JSON.parse` whose `None` covers a malformed string.
21
+ */
22
+ const JsonText = Schema.fromJsonString(Schema.Unknown);
23
+ const encodeJsonText = Schema.encodeSync(JsonText);
24
+ const decodeJsonText = Schema.decodeUnknownOption(JsonText);
25
+ /**
26
+ * Builds a typed JSONB Drizzle custom type backed by an Effect Schema.
27
+ *
28
+ * Values are stored as PostgreSQL `jsonb`. The schema validates on both
29
+ * read (`fromDriver`) and write (`toDriver`): writing encodes through the
30
+ * schema and then through {@link JsonText}; reading decodes unknown driver
31
+ * output straight through the schema.
32
+ *
33
+ * The driver this library ships a Postgres binding for (node-pg, via
34
+ * `@effect/sql-pg`) already parses `jsonb` columns before `fromDriver` sees
35
+ * them, so the `typeof value === "string"` branch below is dead under that
36
+ * driver — it exists for a driver that hands back raw JSON text instead,
37
+ * and is exercised directly by this module's tests.
38
+ *
39
+ * Nullable columns are handled transparently — `null` values pass through
40
+ * Drizzle without reaching this codec, so `.nullable()` is not required on
41
+ * `schema`.
42
+ *
43
+ * `schema` is bound to `Schema.Codec<unknown, unknown, never, never>` rather
44
+ * than the wider `Schema.Top`: `toDriver` / `fromDriver` are synchronous
45
+ * Drizzle hooks with no Effect context to satisfy a requirement from, so a
46
+ * schema this codec accepts must already need no services to decode or
47
+ * encode.
48
+ */
49
+ const jsonbSchema = (schema) => (name) => {
50
+ const encode = Schema.encodeSync(schema);
51
+ const decode = Schema.decodeUnknownSync(schema);
52
+ return customType({
53
+ dataType() {
54
+ return "jsonb";
55
+ },
56
+ toDriver(value) {
57
+ return encodeJsonText(encode(value));
58
+ },
59
+ fromDriver(value) {
60
+ if (!Predicate.isString(value)) return decode(value);
61
+ return Option.match(decodeJsonText(value), {
62
+ onNone: () => decode(value),
63
+ onSome: (parsed) => decode(parsed)
64
+ });
65
+ }
66
+ })(name);
67
+ };
68
+ /**
69
+ * Custom column type for PostgreSQL `tsvector`.
70
+ *
71
+ * Used for generated full-text search columns. The value is opaque to the
72
+ * consuming application — queries use `sql` template literals for matching.
73
+ */
74
+ const tsvector = (name) => customType({ dataType() {
75
+ return "tsvector";
76
+ } })(name);
77
+ /**
78
+ * Custom column type for PostgreSQL `bytea` (binary data).
79
+ *
80
+ * Typed `Uint8Array` rather than `Buffer` — see {@link byteaToHex} for why
81
+ * the Node global is off-limits here. Both directions are honest about the
82
+ * runtime: `@effect/sql-pg`'s binary `bytea` codec decodes to a `Uint8Array`
83
+ * and encodes any `Uint8Array` (a Node `Buffer` included, as a subclass) as
84
+ * raw bytes, so a plain `Uint8Array` binds identically to a `Buffer`.
85
+ */
86
+ const bytea = (name) => customType({ dataType() {
87
+ return "bytea";
88
+ } })(name);
89
+ /**
90
+ * A fresh `created_at` / `updated_at` pair: `timestamptz`, `NOT NULL`,
91
+ * defaulting to `now()`.
92
+ *
93
+ * A factory, never a shared object: Drizzle column builders are
94
+ * **mutable** — `.notNull()` and `.defaultNow()` mutate and return the same
95
+ * builder instance. A module-level constant reused across tables would
96
+ * therefore hand every table the same two builders, and whichever table
97
+ * was declared last would win any later refinement. Calling `timestamps()`
98
+ * per table mints a fresh pair.
99
+ *
100
+ * `updated_at` defaults rather than auto-updates — nothing in Postgres
101
+ * bumps it, the write path does.
102
+ *
103
+ * Spread rather than a base table: Drizzle records a table's columns in
104
+ * declaration order, and that order is what a consumer's `db:generate`
105
+ * emits as the `CREATE TABLE` column order. Spreading at the position two
106
+ * hand-written lines occupied keeps generated SQL byte-identical, so
107
+ * adopting this helper does not move a baseline migration.
108
+ */
109
+ const timestamps = () => ({
110
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
111
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
112
+ });
113
+ /**
114
+ * Renders a `bytea` value (typically a content hash) as its lowercase hex
115
+ * string.
116
+ *
117
+ * Written against `Uint8Array` rather than `Buffer.prototype.toString("hex")`
118
+ * on purpose: a runtime that declares the Node `Buffer` global alongside
119
+ * `@types/node` (Cloudflare Workers' `nodejs_compat` types are one example)
120
+ * can strip the Node-only methods off the global `Buffer` interface, or
121
+ * degrade the `Buffer` *value* to `any`, so every `Buffer.*` call goes
122
+ * unchecked with no signal at all. `Uint8Array` is what a `bytea` row value
123
+ * structurally is on every runtime, so this encodes identically everywhere
124
+ * without depending on that global.
125
+ */
126
+ const byteaToHex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
127
+ /**
128
+ * Decodes a hex string into the `Uint8Array` a `bytea` column binds — the
129
+ * inverse of {@link byteaToHex}, replacing `Buffer.from(hex, "hex")`.
130
+ *
131
+ * Strict where `Buffer.from` is silent: `Buffer.from` decodes the leading
132
+ * valid pairs and drops the rest, so a malformed hash quietly becomes a
133
+ * short-but-plausible key that matches no row. This throws instead — a
134
+ * caller that takes a hash from outside the trust boundary is expected to
135
+ * validate its shape before calling this, and gets a loud failure instead
136
+ * of a silently truncated key when it doesn't.
137
+ *
138
+ * @throws TypeError if `hex` is not an even-length run of hex digits.
139
+ */
140
+ const hexToBytea = (hex) => {
141
+ if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) throw new TypeError(`Expected an even-length hex string, received ${encodeJsonText(hex)}`);
142
+ return Uint8Array.from({ length: hex.length / 2 }, (_unused, index) => Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16));
143
+ };
144
+ export { bytea, byteaToHex, hexToBytea, jsonbSchema, timestamps, tsvector };
145
+
146
+ //# sourceMappingURL=columns.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"columns.js","names":[],"sources":["../src/columns.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;AA4BA,MAAM,WAAW,OAAO,eAAe,OAAO,OAAO;AACrD,MAAM,iBAAiB,OAAO,WAAW,QAAQ;AACjD,MAAM,iBAAiB,OAAO,oBAAoB,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;AA0B1D,MAAa,eAC8C,YAEvD,SACyF;CACzF,MAAM,SAAS,OAAO,WAAW,MAAM;CACvC,MAAM,SAAS,OAAO,kBAAkB,MAAM;CAC9C,OAAO,WAAqD;EAC1D,WAAW;GACT,OAAO;EACT;EACA,SAAS,OAAO;GACd,OAAO,eAAe,OAAO,KAAK,CAAC;EACrC;EACA,WAAW,OAAO;GAChB,IAAI,CAAC,UAAU,SAAS,KAAK,GAAG,OAAO,OAAO,KAAK;GACnD,OAAO,OAAO,MAAM,eAAe,KAAK,GAAG;IACzC,cAAc,OAAO,KAAK;IAC1B,SAAS,WAAW,OAAO,MAAM;GACnC,CAAC;EACH;CACF,CAAC,CAAC,CAAC,IAAI;AACT;;;;;;;AAQF,MAAa,YACX,SAEA,WAAiD,EAC/C,WAAW;CACT,OAAO;AACT,EACF,CAAC,CAAC,CAAC,IAAI;;;;;;;;;;AAWT,MAAa,SACX,SAEA,WAAyD,EACvD,WAAW;CACT,OAAO;AACT,EACF,CAAC,CAAC,CAAC,IAAI;;;;;;;;;;;;;;;;;;;;;AAiCT,MAAa,oBAAsC;CACjD,WAAW,UAAU,cAAc,EAAE,cAAc,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,WAAW;CAChF,WAAW,UAAU,cAAc,EAAE,cAAc,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,WAAW;AAClF;;;;;;;;;;;;;;AAeA,MAAa,cAAc,UACzB,MAAM,KAAK,QAAQ,SAAS,KAAK,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE;;;;;;;;;;;;;;AAezE,MAAa,cAAc,QAA4B;CACrD,IAAI,IAAI,SAAS,MAAM,KAAK,CAAC,iBAAiB,KAAK,GAAG,GACpD,MAAM,IAAI,UAAU,gDAAgD,eAAe,GAAG,GAAG;CAE3F,OAAO,WAAW,KAAK,EAAE,QAAQ,IAAI,SAAS,EAAE,IAAI,SAAS,UAC3D,OAAO,SAAS,IAAI,MAAM,QAAQ,GAAG,QAAQ,IAAI,CAAC,GAAG,EAAE,CACzD;AACF"}
@@ -0,0 +1,117 @@
1
+ import { AnyPgColumn, ExtraConfigColumn, PgTableExtraConfigValue } from "drizzle-orm/pg-core";
2
+ /**
3
+ * `CHECK (column IN ('a', 'b', …))`, named `name`.
4
+ *
5
+ * The models declare each vocabulary once as an exported `as const` array and
6
+ * validate the wire against `Schema.Literals` over it; the database restates
7
+ * the same array as an `IN (...)` CHECK so a row written by any path — a
8
+ * batched ingestion upsert included — cannot carry a value the API would refuse
9
+ * to serve. There is no Postgres enum type: a `text` column plus a CHECK is
10
+ * what lets a vocabulary grow with one migration (`ALTER … DROP/ADD
11
+ * CONSTRAINT`) instead of an `ALTER TYPE … ADD VALUE` that cannot run inside a
12
+ * transaction.
13
+ *
14
+ * A `NULL` in the column **passes** — `NULL IN (...)` is unknown, which CHECK
15
+ * accepts — so the same constraint serves nullable vocabularies without an
16
+ * `IS NULL OR` guard. Nullability is the column's own `NOT NULL` to declare.
17
+ *
18
+ * The values are rendered as literal SQL text, not bind parameters: a CHECK
19
+ * expression lives in the migration file, where a `$1` placeholder has nothing
20
+ * to bind to. They come straight from the models' `as const` arrays, so the two
21
+ * vocabularies cannot drift — a model change shows up as schema drift in the
22
+ * consumer's migration-drift check until the migration is regenerated.
23
+ */
24
+ export declare const enumCheck: (name: string, column: ExtraConfigColumn, values: readonly string[]) => PgTableExtraConfigValue;
25
+ /** A stable table contributes its `id` + `type` columns to the discriminator. */
26
+ export interface TypedStable {
27
+ readonly id: AnyPgColumn;
28
+ readonly type: AnyPgColumn;
29
+ }
30
+ /**
31
+ * Constraint-name templates, overriding the defaults
32
+ * `${level}_id_type_unique`, `${level}_id_type_fk` and `${level}_type_valid`.
33
+ * A consumer adopting this helper over hand-written constraints pins whatever
34
+ * names its existing migrations already carry, so the switch renames nothing.
35
+ */
36
+ export interface DiscriminatorNames {
37
+ /** Name of the `UNIQUE(id, type)` constraint. */
38
+ readonly unique?: ((level: string) => string) | undefined;
39
+ /** Name of the composite `(id, type)` → parent `(id, type)` foreign key. */
40
+ readonly parentFk?: ((level: string) => string) | undefined;
41
+ /** Name of the per-level `type` CHECK. */
42
+ readonly check?: ((level: string) => string) | undefined;
43
+ }
44
+ /**
45
+ * The three constraints that enforce a threaded `type` discriminator, bound to
46
+ * one hierarchy. Generic over the hierarchy's level union `Level`, so a level
47
+ * the consumer has no leaf set for is a type error at the call site.
48
+ */
49
+ export interface DiscriminatorToolkit<Level extends string> {
50
+ /**
51
+ * `UNIQUE(id, type)` — the target every child's composite `(id, type)` FK
52
+ * needs. `level` is a plain `string`, not `Level`: a root table sitting
53
+ * *above* the ontology's levels (and so not itself a level) can carry it too.
54
+ */
55
+ readonly typeUnique: (level: string, stable: TypedStable) => PgTableExtraConfigValue;
56
+ /**
57
+ * The composite `(id, type)` → parent `(id, type)` foreign key. `onDelete` is
58
+ * left to the plain single-column `id` FK the table already carries
59
+ * (cascade); this arc only enforces the discriminator match.
60
+ *
61
+ * `onUpdate: "cascade"` (via {@link DiscriminatorToolkit.typeConstraints}'
62
+ * `refinable` option) makes a bare parent row **refinable**: an operation
63
+ * that flips the root's `type` from a generic value to a concrete leaf
64
+ * cascades the new `type` down the pre-existing `(id, type)` chain before the
65
+ * concrete rows are inserted — so the flip is a plain ordered transaction, no
66
+ * `DEFERRABLE` constraint needed. Branches that don't refine keep the default
67
+ * (no referential action on this arc).
68
+ */
69
+ readonly typeParentFk: (level: Level, stable: TypedStable, parent: TypedStable, onUpdate?: "cascade") => PgTableExtraConfigValue;
70
+ /**
71
+ * The per-level `type` CHECK. A leaf admits only its own type; an
72
+ * intermediate or branch root admits every concrete leaf in its subtree (the
73
+ * `leaves` callback the toolkit was built with). The value list is
74
+ * parameterised, not interpolated.
75
+ *
76
+ * A childless marker — an ontology level with no concrete leaf beneath it yet
77
+ * — has an empty leaf set, so it admits no `type` at all: no leaf names it as
78
+ * an ancestor, so nothing ever materialises a row there and the table stays
79
+ * empty until the ontology grows a leaf under it. Emits `CHECK (false)` for
80
+ * that case — `type IN ()` is a syntax error, and `false` correctly marks the
81
+ * level uninstantiable rather than admitting an arbitrary value.
82
+ */
83
+ readonly typeCheck: (level: Level, stable: TypedStable) => PgTableExtraConfigValue;
84
+ /**
85
+ * All three discriminator constraints for a non-root level:
86
+ * `UNIQUE(id, type)`, the composite `(id, type)` → parent FK, and the
87
+ * per-level CHECK. A branch root passes the table above it as `parent`; the
88
+ * root table itself gets only {@link DiscriminatorToolkit.typeUnique} (no
89
+ * parent, no CHECK — it admits any leaf).
90
+ *
91
+ * `refinable: true` gives the composite FK `ON UPDATE CASCADE` — see
92
+ * {@link DiscriminatorToolkit.typeParentFk}.
93
+ */
94
+ readonly typeConstraints: (level: Level, stable: TypedStable, parent: TypedStable, options?: {
95
+ readonly refinable?: boolean | undefined;
96
+ }) => PgTableExtraConfigValue[];
97
+ }
98
+ /**
99
+ * Build a {@link DiscriminatorToolkit} for one hierarchy.
100
+ *
101
+ * `leaves(level)` is the seam the ontology plugs into: it answers the concrete
102
+ * leaf `type` values admissible at `level` (its subtree's leaves — for a leaf
103
+ * level, itself). The library carries no level ontology of its own, so this
104
+ * callback, and the optional {@link DiscriminatorNames} templates, are the
105
+ * whole configuration.
106
+ *
107
+ * The constraints it emits are what make a sibling-subtype chimera
108
+ * unrepresentable: `UNIQUE(id, type)` gives children a composite key to point
109
+ * at, the composite FK arcs the discriminator up one level (a child can only
110
+ * exist under a parent carrying the SAME `type`), and the CHECK narrows `type`
111
+ * to the level's own subtree.
112
+ */
113
+ export declare const makeDiscriminatorConstraints: <Level extends string>(options: {
114
+ readonly leaves: (level: Level) => ReadonlyArray<string>;
115
+ readonly names?: DiscriminatorNames | undefined;
116
+ }) => DiscriminatorToolkit<Level>;
117
+ //# sourceMappingURL=constraints.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constraints.d.ts","names":[],"sources":["../src/constraints.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;qBAsDa,YAAS,cACR,QACJ,mBAAiB,8BAExB;;iBAMc;WACN,IAAI;WACJ,MAAM;;;;;;;;iBAmBA;;WAEN,WAAW;;WAEX,aAAa;;WAEb,UAAU;;;;;;;iBAQJ,qBAAqB;;;;;;WAM3B,aAAa,eAAe,QAAQ,gBAAgB;;;;;;;;;;;;;;WAcpD,eACP,OAAO,OACP,QAAQ,aACR,QAAQ,aACR,yBACG;;;;;;;;;;;;;;WAcI,YAAY,OAAO,OAAO,QAAQ,gBAAgB;;;;;;;;;;;WAWlD,kBACP,OAAO,OACP,QAAQ,aACR,QAAQ,aACR;aAAqB;QAClB;;;;;;;;;;;;;;;;;qBAkBM,+BAAgC,sBAAoB;WACtD,SAAS,OAAO,UAAU;WAC1B,QAAQ;MACf,qBAAqB"}
@@ -0,0 +1,93 @@
1
+ import * as Array from "effect/Array";
2
+ import { sql } from "drizzle-orm";
3
+ import { check, foreignKey, unique } from "drizzle-orm/pg-core";
4
+ // src/constraints.ts
5
+ /**
6
+ * @copyright Thomas Foster
7
+ *
8
+ * **Table-level CHECK / UNIQUE / composite-FK helpers.**
9
+ *
10
+ * Two independent families, both emitting values for a table factory's
11
+ * `extraConstraints` seam:
12
+ *
13
+ * - {@link enumCheck} — the `CHECK (col IN (…))` behind an enum-valued `text`
14
+ * column, so a vocabulary declared once in a model constrains the database
15
+ * too.
16
+ * - {@link makeDiscriminatorConstraints} — the three constraints that thread a
17
+ * `type` discriminator up a hierarchy of stable tables, parameterised over
18
+ * the hierarchy (the library knows no level ontology; the consumer supplies
19
+ * the leaf set per level).
20
+ *
21
+ * @module constraints
22
+ */
23
+ /**
24
+ * `CHECK (column IN ('a', 'b', …))`, named `name`.
25
+ *
26
+ * The models declare each vocabulary once as an exported `as const` array and
27
+ * validate the wire against `Schema.Literals` over it; the database restates
28
+ * the same array as an `IN (...)` CHECK so a row written by any path — a
29
+ * batched ingestion upsert included — cannot carry a value the API would refuse
30
+ * to serve. There is no Postgres enum type: a `text` column plus a CHECK is
31
+ * what lets a vocabulary grow with one migration (`ALTER … DROP/ADD
32
+ * CONSTRAINT`) instead of an `ALTER TYPE … ADD VALUE` that cannot run inside a
33
+ * transaction.
34
+ *
35
+ * A `NULL` in the column **passes** — `NULL IN (...)` is unknown, which CHECK
36
+ * accepts — so the same constraint serves nullable vocabularies without an
37
+ * `IS NULL OR` guard. Nullability is the column's own `NOT NULL` to declare.
38
+ *
39
+ * The values are rendered as literal SQL text, not bind parameters: a CHECK
40
+ * expression lives in the migration file, where a `$1` placeholder has nothing
41
+ * to bind to. They come straight from the models' `as const` arrays, so the two
42
+ * vocabularies cannot drift — a model change shows up as schema drift in the
43
+ * consumer's migration-drift check until the migration is regenerated.
44
+ */
45
+ const enumCheck = (name, column, values) => check(name, sql`${column} IN (${sql.raw(values.map((value) => `'${value}'`).join(", "))})`);
46
+ /**
47
+ * Build a {@link DiscriminatorToolkit} for one hierarchy.
48
+ *
49
+ * `leaves(level)` is the seam the ontology plugs into: it answers the concrete
50
+ * leaf `type` values admissible at `level` (its subtree's leaves — for a leaf
51
+ * level, itself). The library carries no level ontology of its own, so this
52
+ * callback, and the optional {@link DiscriminatorNames} templates, are the
53
+ * whole configuration.
54
+ *
55
+ * The constraints it emits are what make a sibling-subtype chimera
56
+ * unrepresentable: `UNIQUE(id, type)` gives children a composite key to point
57
+ * at, the composite FK arcs the discriminator up one level (a child can only
58
+ * exist under a parent carrying the SAME `type`), and the CHECK narrows `type`
59
+ * to the level's own subtree.
60
+ */
61
+ const makeDiscriminatorConstraints = (options) => {
62
+ const uniqueName = options.names?.unique ?? ((level) => `${level}_id_type_unique`);
63
+ const parentFkName = options.names?.parentFk ?? ((level) => `${level}_id_type_fk`);
64
+ const checkName = options.names?.check ?? ((level) => `${level}_type_valid`);
65
+ const typeUnique = (level, stable) => unique(uniqueName(level)).on(stable.id, stable.type);
66
+ const typeParentFk = (level, stable, parent, onUpdate) => {
67
+ const fk = foreignKey({
68
+ name: parentFkName(level),
69
+ columns: [stable.id, stable.type],
70
+ foreignColumns: [parent.id, parent.type]
71
+ });
72
+ return onUpdate === "cascade" ? fk.onUpdate("cascade") : fk;
73
+ };
74
+ const typeCheck = (level, stable) => {
75
+ const leaves = options.leaves(level);
76
+ if (Array.isReadonlyArrayEmpty(leaves)) return check(checkName(level), sql`false`);
77
+ const list = sql.join(leaves.map((leaf) => sql`${leaf}`), sql`, `);
78
+ return check(checkName(level), sql`${stable.type} IN (${list})`);
79
+ };
80
+ return {
81
+ typeUnique,
82
+ typeParentFk,
83
+ typeCheck,
84
+ typeConstraints: (level, stable, parent, { refinable = false } = {}) => [
85
+ typeUnique(level, stable),
86
+ typeParentFk(level, stable, parent, refinable ? "cascade" : void 0),
87
+ typeCheck(level, stable)
88
+ ]
89
+ };
90
+ };
91
+ export { enumCheck, makeDiscriminatorConstraints };
92
+
93
+ //# sourceMappingURL=constraints.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constraints.js","names":[],"sources":["../src/constraints.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsDA,MAAa,aACX,MACA,QACA,WAEA,MAAM,MAAM,GAAG,GAAG,OAAO,OAAO,IAAI,IAAI,OAAO,KAAK,UAAU,IAAI,MAAM,EAAE,CAAC,CAAC,KAAK,IAAI,CAAC,EAAE,EAAE;;;;;;;;;;;;;;;;AAiH5F,MAAa,gCAAsD,YAGhC;CACjC,MAAM,aAAa,QAAQ,OAAO,YAAY,UAAkB,GAAG,MAAM;CACzE,MAAM,eAAe,QAAQ,OAAO,cAAc,UAAkB,GAAG,MAAM;CAC7E,MAAM,YAAY,QAAQ,OAAO,WAAW,UAAkB,GAAG,MAAM;CAEvE,MAAM,cAAc,OAAe,WACjC,OAAO,WAAW,KAAK,CAAC,CAAC,CAAC,GAAG,OAAO,IAAI,OAAO,IAAI;CAErD,MAAM,gBACJ,OACA,QACA,QACA,aAC4B;EAC5B,MAAM,KAAK,WAAW;GACpB,MAAM,aAAa,KAAK;GACxB,SAAS,CAAC,OAAO,IAAI,OAAO,IAAI;GAChC,gBAAgB,CAAC,OAAO,IAAI,OAAO,IAAI;EACzC,CAAC;EACD,OAAO,aAAa,YAAY,GAAG,SAAS,SAAS,IAAI;CAC3D;CAEA,MAAM,aAAa,OAAc,WAAiD;EAChF,MAAM,SAAS,QAAQ,OAAO,KAAK;EACnC,IAAI,MAAM,qBAAqB,MAAM,GAAG,OAAO,MAAM,UAAU,KAAK,GAAG,GAAG,OAAO;EACjF,MAAM,OAAO,IAAI,KACf,OAAO,KAAK,SAAS,GAAG,GAAG,MAAM,GACjC,GAAG,IACL;EACA,OAAO,MAAM,UAAU,KAAK,GAAG,GAAG,GAAG,OAAO,KAAK,OAAO,KAAK,EAAE;CACjE;CAEA,OAAO;EACL;EACA;EACA;EACA,kBACE,OACA,QACA,QACA,EAAE,YAAY,UAAwD,CAAC,MACzC;GAC9B,WAAW,OAAO,MAAM;GACxB,aAAa,OAAO,QAAQ,QAAQ,YAAY,YAAY,KAAA,CAAS;GACrE,UAAU,OAAO,MAAM;EACzB;CACF;AACF"}