@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
package/src/tree.ts ADDED
@@ -0,0 +1,367 @@
1
+ /**
2
+ * The interpreter behind `defineTree` (`.`) and `defineResourceTree`
3
+ * (`./jsonapi`): one Drizzle-free declaration of an ontology's level tree, from
4
+ * which every structural fact the database layer needs is derived at module
5
+ * load. Not a published subpath: the two entries re-export its types and wrap
6
+ * {@link buildTree} with their own leaf reader.
7
+ *
8
+ * **Shape.** A tree is an ordered record of levels. Each level names its
9
+ * `parent` (another level; absent means a child of the root, i.e. a branch
10
+ * root), may carry level `columns` (`dbColumn`-annotated fields contributed to
11
+ * every descendant's view, landing on that level's version table) and a level
12
+ * `kind`, and is either a **marker** (no `leaf`) or a **leaf** (a `leaf`
13
+ * payload: a struct's fields in `.`, a `Resource.make` definition in
14
+ * `./jsonapi`). A leaf's name is its discriminator value.
15
+ *
16
+ * **Order.** Declaration order is preserved wherever it becomes SQL order: the
17
+ * level list, every descendant and subtree-leaf list (the discriminator CHECK
18
+ * value sets), and each level's columns.
19
+ *
20
+ * **Failures.** Every check throws {@link DerivationError} at module load:
21
+ * there is no runtime yet, and a misdeclared tree must stop its `import`.
22
+ *
23
+ * @module tree
24
+ */
25
+ import * as Arr from "effect/Array";
26
+ import * as Option from "effect/Option";
27
+ import * as R from "effect/Record";
28
+ import type * as Schema from "effect/Schema";
29
+ import { DerivationError } from "./derivation-error.ts";
30
+ import type { DbColumn } from "./index.ts";
31
+
32
+ /**
33
+ * How a level stores its columns. `"plain"` (the default): one version table
34
+ * carrying every level column. `"fact-scoped"`: an `(id, version)` spine plus
35
+ * one fact table per level column, where a fact's absence at a version is its
36
+ * `NULL` (website's 6NF `manifestation_version` pilot).
37
+ */
38
+ export type LevelKind = "plain" | "fact-scoped";
39
+
40
+ /* oxlint-disable effect/prefer-option-over-null -- a declaration literal threaded into the table derivation, like DbColumn: every member is read once at module load and converted to `Option` on the derived TreeLevel */
41
+ /**
42
+ * One level's declaration, generic in the names its `parent` may take and in
43
+ * the leaf payload. Optional members are `?: T | undefined` so a caller can
44
+ * forward its own optional.
45
+ */
46
+ export interface LevelSpec<Parent extends string = string, Leaf = unknown> {
47
+ /** The parent level. Absent: a child of the root (a branch root). */
48
+ readonly parent?: Parent | undefined;
49
+ /** How the level stores its columns; `"plain"` when absent. */
50
+ readonly kind?: LevelKind | undefined;
51
+ /**
52
+ * Level columns: `dbColumn`-annotated fields on this level's version table,
53
+ * contributed to every descendant's view. Every field must carry a
54
+ * `dbColumn` annotation.
55
+ */
56
+ readonly columns?: Schema.Struct.Fields | undefined;
57
+ /** The leaf payload. Present: a concrete leaf; absent: a marker level. */
58
+ readonly leaf?: Leaf | undefined;
59
+ }
60
+ /* oxlint-enable effect/prefer-option-over-null */
61
+
62
+ /**
63
+ * The constraint a tree declaration `S` is checked against: every entry is a
64
+ * {@link LevelSpec} whose `parent` names **another** level of `S`, plus the
65
+ * consumer's extension record `Ext` (pulpit's `owned` / `pageable` flags, say),
66
+ * whose names the library never learns. A parent naming no level, or the level
67
+ * itself, is a type error at the declaration.
68
+ */
69
+ export type CheckedTreeSpec<S, Leaf, Ext> = {
70
+ readonly [K in keyof S]: LevelSpec<Exclude<keyof S & string, K>, Leaf> & Ext;
71
+ };
72
+
73
+ /** Every level name a tree declaration `S` declares (a `Tree` reads its own). */
74
+ export type LevelName<S> = keyof S & string;
75
+
76
+ /** The names of the leaf levels of declaration `S`: the discriminator values. */
77
+ export type LeafName<S> = {
78
+ readonly [K in keyof S]: S[K] extends { readonly leaf?: infer L }
79
+ ? [Exclude<L, undefined>] extends [never]
80
+ ? never
81
+ : K
82
+ : never;
83
+ }[keyof S] &
84
+ string;
85
+
86
+ /** The names of the marker (non-leaf) levels of declaration `S`. */
87
+ export type MarkerName<S> = Exclude<LevelName<S>, LeafName<S>>;
88
+
89
+ /** Everything derived about one level of a tree. */
90
+ export interface TreeLevel<S> {
91
+ /** The level's name (a leaf's discriminator value). */
92
+ readonly name: LevelName<S>;
93
+ /** The declaration entry, extension flags included. */
94
+ readonly entry: S[LevelName<S>];
95
+ /** The parent level; `Option.none()` for a branch root. */
96
+ readonly parent: Option.Option<LevelName<S>>;
97
+ /** The level kind, `"plain"` when the entry names none. */
98
+ readonly kind: LevelKind;
99
+ /** Whether the level is a concrete leaf. */
100
+ readonly isLeaf: boolean;
101
+ /** Every ancestor, root-most first, the level itself excluded. */
102
+ readonly ancestors: ReadonlyArray<LevelName<S>>;
103
+ /** The direct children, in declaration order. */
104
+ readonly children: ReadonlyArray<LevelName<S>>;
105
+ /** Every descendant, in declaration order, the level itself excluded. */
106
+ readonly descendants: ReadonlyArray<LevelName<S>>;
107
+ /**
108
+ * The leaves of the level's subtree in declaration order, the level itself
109
+ * included when it is a leaf: the value set of its discriminator CHECK.
110
+ */
111
+ readonly subtreeLeaves: ReadonlyArray<LeafName<S>>;
112
+ /** The branch root the level belongs to (itself for a branch root). */
113
+ readonly branch: LevelName<S>;
114
+ /** The level columns' db-column metadata, keyed by field, in order. */
115
+ readonly levelColumns: Readonly<Record<string, DbColumn>>;
116
+ /** A leaf's own domain columns, keyed by field, in order; empty for a marker. */
117
+ readonly leafColumns: Readonly<Record<string, DbColumn>>;
118
+ }
119
+
120
+ /**
121
+ * The type-level column metadata of one level: its level columns' and its
122
+ * leaf's `DbColumn` maps, recovered from the declaration's phantoms. What a
123
+ * table derivation reads to type the tables it builds.
124
+ */
125
+ export interface LevelColumnsMeta {
126
+ readonly level: object;
127
+ readonly leaf: object;
128
+ }
129
+
130
+ /** The default column metadata of a declaration `S`: untyped maps per level. */
131
+ export type AnyTreeColumnsMeta<S> = { readonly [K in keyof S]: LevelColumnsMeta };
132
+
133
+ declare const TreeColumnsTypeId: unique symbol;
134
+
135
+ /**
136
+ * A checked tree: the declaration plus every level's derived facts. `M` is a
137
+ * type-only phantom (never present at runtime) carrying each level's typed
138
+ * column metadata, so the table derivation types every table it builds.
139
+ */
140
+ export interface Tree<S, M = AnyTreeColumnsMeta<S>> {
141
+ /** Type-only: each level's typed column metadata. */
142
+ readonly [TreeColumnsTypeId]?: M;
143
+ /** The declaration exactly as written, for type-level consumers. */
144
+ readonly spec: S;
145
+ /** Every level name, in declaration order. */
146
+ readonly names: ReadonlyArray<LevelName<S>>;
147
+ /** Every level, in declaration order. */
148
+ readonly levels: ReadonlyArray<TreeLevel<S>>;
149
+ /** The leaf names, in declaration order. */
150
+ readonly leaves: ReadonlyArray<LeafName<S>>;
151
+ /** The branch roots (children of the root), in declaration order. */
152
+ readonly branchRoots: ReadonlyArray<LevelName<S>>;
153
+ /** Look a level up by name. */
154
+ readonly level: (name: LevelName<S>) => TreeLevel<S>;
155
+ }
156
+
157
+ /**
158
+ * How an entry reads its columns: `levelColumns` maps a level's `columns`
159
+ * fields to their db-column metadata (dropping unannotated fields), and
160
+ * `leafColumns` does the same for a leaf payload, throwing
161
+ * {@link DerivationError} for a payload that doesn't fit its level (a resource
162
+ * whose `type` isn't the level's name).
163
+ */
164
+ export interface TreeReader<Leaf> {
165
+ readonly levelColumns: (fields: Schema.Struct.Fields) => Readonly<Record<string, DbColumn>>;
166
+ readonly leafColumns: (leaf: Leaf, name: string) => Readonly<Record<string, DbColumn>>;
167
+ }
168
+
169
+ /**
170
+ * Names no level column or leaf column may take: the chain's own columns, which
171
+ * the derived tables and views project from the root and from every level.
172
+ */
173
+ const RESERVED_COLUMN_NAMES: ReadonlyArray<string> = ["id", "version", "action", "type"];
174
+
175
+ /** Throw the tree's {@link DerivationError}. */
176
+ export const treeError = (message: string): never => {
177
+ throw new DerivationError({ message: `defineTree: ${message}` });
178
+ };
179
+
180
+ /** Throw for a level whose `parent` names no level of the tree. */
181
+ const unknownParent = (name: string, parent: string): never =>
182
+ treeError(`level "${name}" names parent "${parent}", which is not a level of this tree`);
183
+
184
+ /** A canonical array index, which a JS object would reorder ahead of declaration order. */
185
+ const isArrayIndex = (name: string): boolean => /^(?:0|[1-9]\d*)$/.test(name);
186
+
187
+ /** One column a level contributes to every root-to-leaf path through it. */
188
+ interface PathColumn {
189
+ readonly level: string;
190
+ readonly key: string;
191
+ readonly column: string;
192
+ }
193
+
194
+ /** The first column on a path whose key or column name an earlier one took. */
195
+ const firstClash = (
196
+ path: ReadonlyArray<PathColumn>,
197
+ ): Option.Option<readonly [PathColumn, PathColumn]> =>
198
+ Arr.findFirst(
199
+ Arr.map(path, (later, i) =>
200
+ Option.map(
201
+ Arr.findFirst(
202
+ Arr.take(path, i),
203
+ (earlier) => earlier.key === later.key || earlier.column === later.column,
204
+ ),
205
+ (earlier) => [earlier, later] as const,
206
+ ),
207
+ ),
208
+ Option.isSome,
209
+ ).pipe(Option.flatten);
210
+
211
+ /**
212
+ * Check a tree declaration and derive every level's facts. Throws
213
+ * {@link DerivationError} at module load for an empty tree, a level name a JS
214
+ * object would reorder (an array index) or that is empty, a parent naming no
215
+ * level, a parent cycle, a leaf with children, a marker with no leaf below it,
216
+ * a level column without a `dbColumn` annotation, a column taking a reserved
217
+ * name (`id`, `version`, `action`, `type`), or two columns on one root-to-leaf
218
+ * path sharing a field key or a column name. The entries' own payload checks
219
+ * come from `reader`.
220
+ */
221
+ export const buildTree = <
222
+ S extends Readonly<Record<string, LevelSpec<string, Leaf>>>,
223
+ Leaf,
224
+ M = AnyTreeColumnsMeta<S>,
225
+ >(
226
+ spec: S,
227
+ reader: TreeReader<Leaf>,
228
+ ): Tree<S, M> => {
229
+ const names: ReadonlyArray<LevelName<S>> = R.keys(spec);
230
+ if (!Arr.isReadonlyArrayNonEmpty(names)) treeError("a tree needs at least one level");
231
+ Arr.forEach(names, (name) => {
232
+ if (name === "" || isArrayIndex(name)) {
233
+ treeError(
234
+ `level name "${name}" is not allowed: an empty name or an array index would not keep declaration order`,
235
+ );
236
+ }
237
+ });
238
+
239
+ const has = (name: string): name is LevelName<S> => R.has(spec, name);
240
+ const entryOf = (name: LevelName<S>): S[LevelName<S>] => spec[name];
241
+ const parentOf = (name: LevelName<S>): Option.Option<LevelName<S>> =>
242
+ Option.map(Option.fromUndefinedOr(entryOf(name).parent), (parent: string) =>
243
+ has(parent) ? parent : unknownParent(name, parent),
244
+ );
245
+ const leafOf = (name: LevelName<S>): Option.Option<Leaf> =>
246
+ Option.fromUndefinedOr(entryOf(name).leaf);
247
+ const isLeafName = (name: LevelName<S>): name is LeafName<S> => Option.isSome(leafOf(name));
248
+
249
+ // Root-most first; `trail` is the chain walked so far, so a parent already on
250
+ // it closes a cycle.
251
+ const ancestorsOf = (
252
+ name: LevelName<S>,
253
+ trail: ReadonlyArray<LevelName<S>>,
254
+ ): ReadonlyArray<LevelName<S>> =>
255
+ Option.match(parentOf(name), {
256
+ onNone: () => [],
257
+ onSome: (parent) =>
258
+ Arr.contains(trail, parent)
259
+ ? treeError(
260
+ `the parents form a cycle: ${[...Arr.dropWhile(trail, (step) => step !== parent), parent].join(" → ")}`,
261
+ )
262
+ : [...ancestorsOf(parent, [...trail, parent]), parent],
263
+ });
264
+ const ancestorsFor = (name: LevelName<S>): ReadonlyArray<LevelName<S>> =>
265
+ ancestorsOf(name, [name]);
266
+
267
+ const leaves = Arr.filter(names, isLeafName);
268
+ const childrenOf = (name: LevelName<S>): ReadonlyArray<LevelName<S>> =>
269
+ Arr.filter(names, (candidate) =>
270
+ Option.exists(parentOf(candidate), (parent) => parent === name),
271
+ );
272
+ const descendantsOf = (name: LevelName<S>): ReadonlyArray<LevelName<S>> =>
273
+ Arr.filter(names, (candidate) => Arr.contains(ancestorsFor(candidate), name));
274
+ const subtreeLeavesOf = (name: LevelName<S>): ReadonlyArray<LeafName<S>> =>
275
+ Arr.filter(leaves, (leaf) => leaf === name || Arr.contains(ancestorsFor(leaf), name));
276
+
277
+ Arr.forEach(names, (name) => {
278
+ if (isLeafName(name) && Arr.isReadonlyArrayNonEmpty(childrenOf(name))) {
279
+ treeError(
280
+ `leaf "${name}" has children (${childrenOf(name).join(", ")}); only a marker level can be a parent`,
281
+ );
282
+ }
283
+ if (!isLeafName(name) && !Arr.isReadonlyArrayNonEmpty(subtreeLeavesOf(name))) {
284
+ treeError(
285
+ `marker level "${name}" has no leaf below it, so its discriminator CHECK would admit nothing`,
286
+ );
287
+ }
288
+ });
289
+
290
+ const levelColumnsOf = (name: LevelName<S>): Readonly<Record<string, DbColumn>> => {
291
+ const fields = entryOf(name).columns ?? {};
292
+ const columns = reader.levelColumns(fields);
293
+ const bare = Arr.filter(R.keys(fields), (key) => !R.has(columns, key));
294
+ return Arr.isReadonlyArrayNonEmpty(bare)
295
+ ? treeError(
296
+ `level "${name}" declares level columns with no dbColumn annotation (${bare.join(", ")}); a level column must back a database column`,
297
+ )
298
+ : columns;
299
+ };
300
+ const leafColumnsOf = (name: LevelName<S>): Readonly<Record<string, DbColumn>> =>
301
+ Option.match(leafOf(name), {
302
+ onNone: () => ({}),
303
+ onSome: (leaf) => reader.leafColumns(leaf, name),
304
+ });
305
+ const ownColumnsFor = (name: LevelName<S>) => {
306
+ const levelColumns = levelColumnsOf(name);
307
+ const leafColumns = leafColumnsOf(name);
308
+ const path: ReadonlyArray<PathColumn> = Arr.map(
309
+ [...R.toEntries(levelColumns), ...R.toEntries(leafColumns)],
310
+ ([key, meta]) => ({ level: name, key, column: meta.column }),
311
+ );
312
+ Arr.forEach(path, ({ key, column }) => {
313
+ if (Arr.contains(RESERVED_COLUMN_NAMES, key) || Arr.contains(RESERVED_COLUMN_NAMES, column)) {
314
+ treeError(
315
+ `level "${name}" declares "${key}" (column "${column}"), but ${RESERVED_COLUMN_NAMES.join(", ")} are reserved for the chain`,
316
+ );
317
+ }
318
+ });
319
+ return { levelColumns, leafColumns, path };
320
+ };
321
+ // Read and check every level's own columns once, in declaration order,
322
+ // before any path is compared.
323
+ const ownColumns = Arr.map(names, (name) => ({ name, ...ownColumnsFor(name) }));
324
+ const ownOf = (name: LevelName<S>) =>
325
+ Option.getOrElse(
326
+ Arr.findFirst(ownColumns, (own) => own.name === name),
327
+ () => ({ name, levelColumns: {}, leafColumns: {}, path: [] }),
328
+ );
329
+
330
+ // Every view joins a leaf's whole root-to-leaf path, so a key or column name
331
+ // may appear once per path; siblings may reuse one.
332
+ Arr.forEach(leaves, (leaf) => {
333
+ const path = Arr.flatMap([...ancestorsFor(leaf), leaf], (name) => ownOf(name).path);
334
+ Option.map(firstClash(path), ([earlier, later]) =>
335
+ treeError(
336
+ `"${later.key}" (column "${later.column}") on level "${later.level}" collides with "${earlier.key}" (column "${earlier.column}") on level "${earlier.level}"; both reach leaf "${leaf}"`,
337
+ ),
338
+ );
339
+ });
340
+
341
+ const levels = Arr.map(names, (name): TreeLevel<S> => ({
342
+ name,
343
+ entry: entryOf(name),
344
+ parent: parentOf(name),
345
+ kind: entryOf(name).kind ?? "plain",
346
+ isLeaf: isLeafName(name),
347
+ ancestors: ancestorsFor(name),
348
+ children: childrenOf(name),
349
+ descendants: descendantsOf(name),
350
+ subtreeLeaves: subtreeLeavesOf(name),
351
+ branch: Option.getOrElse(Arr.head(ancestorsFor(name)), () => name),
352
+ levelColumns: ownOf(name).levelColumns,
353
+ leafColumns: ownOf(name).leafColumns,
354
+ }));
355
+ return {
356
+ spec,
357
+ names,
358
+ levels,
359
+ leaves,
360
+ branchRoots: Arr.filter(names, (name) => Option.isNone(parentOf(name))),
361
+ level: (name) =>
362
+ Option.getOrElse(
363
+ Arr.findFirst(levels, (level) => level.name === name),
364
+ () => treeError(`"${name}" is not a level of this tree`),
365
+ ),
366
+ };
367
+ };
package/src/update.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @copyright Thomas Foster
3
+ *
4
+ * **The update tri-state at a single seam.** {@link updateSetMap} builds an
5
+ * `UPDATE … SET` map from a decoded update input's **own keys**:
6
+ *
7
+ * | input | meaning | in the `SET` map |
8
+ * | -------------------- | -------------- | ----------------------- |
9
+ * | `{ col: value }` | set | `col: value` |
10
+ * | `{ col: null }` | clear | `col: null` |
11
+ * | key absent / `undefined` | carry forward | not present at all |
12
+ *
13
+ * An update input schema encodes the three states across key presence and
14
+ * the value — `Schema.optionalKey(Schema.NullOr(X))` for a clearable field,
15
+ * `Schema.optionalKey(X)` for one that may not be cleared — so decoding has
16
+ * already refused `null` for a column that must not be cleared. What this
17
+ * module must never do is fold the first two rows together: `col: input.col
18
+ * ?? null` turns a carry-forward into a clear, which is silent data loss
19
+ * (website #1877).
20
+ *
21
+ * **Nothing here is `Option`.** The `null` / `undefined` split *is* the
22
+ * tri-state; collapsing either side to one `Option` erases the difference
23
+ * between "leave alone" and "clear". The `SET` map is also Drizzle-facing,
24
+ * where `undefined` means "not supplied" and an `Option` would be written as
25
+ * a value.
26
+ *
27
+ * The preferenceflow application's shape (`apps/api/src/operations/
28
+ * update-fields.ts`); the website application's carry-forward merge
29
+ * (`mergeUpdateFields(current, input)`) is the same rule applied to a
30
+ * pre-read row, and needs no helper once the write is a `SET` map.
31
+ *
32
+ * @module update
33
+ */
34
+
35
+ import * as Array from "effect/Array";
36
+ import * as HashSet from "effect/HashSet";
37
+ import * as Record from "effect/Record";
38
+
39
+ /**
40
+ * The `SET` (or `VALUES`) map {@link updateSetMap} produces from input `I`
41
+ * and writable column keys `K`: every writable key of `I`, optional (it is
42
+ * present only when the input supplied a value), with `undefined` removed
43
+ * from its value type (a present `null` stays).
44
+ */
45
+ export type UpdateSet<I, K extends string> = {
46
+ readonly [P in Extract<keyof I, K>]?: Exclude<I[P], undefined>;
47
+ };
48
+
49
+ const toHashSet = <K extends string>(writable: Iterable<K>): HashSet.HashSet<K> =>
50
+ HashSet.isHashSet(writable) ? writable : HashSet.fromIterable(writable);
51
+
52
+ /**
53
+ * The `SET` map for an update: every key of `input` that names a writable
54
+ * column and carries a **present** value (`null` included), in the input's
55
+ * own key order. Keys outside `writable` — the routing `id`, a
56
+ * server-owned column, a relationship the table does not carry, an unknown
57
+ * key — are dropped; an `undefined`-valued key (an explicit carry-forward)
58
+ * is dropped too, since the tri-state's first two rows must stay apart.
59
+ *
60
+ * `writable` is any iterable of column keys; pass a module-scope `HashSet`
61
+ * to build it once.
62
+ */
63
+ export const updateSetMap = <I extends Readonly<Record<string, unknown>>, K extends string>(
64
+ input: I,
65
+ writable: Iterable<K>,
66
+ ): UpdateSet<I, K> => {
67
+ const columns: HashSet.HashSet<string> = toHashSet(writable);
68
+ // SAFETY: the entries kept are exactly the writable keys of `input` whose
69
+ // value is not `undefined`, which is what `UpdateSet<I, K>` describes.
70
+ return Record.fromEntries(
71
+ Array.filter(
72
+ Record.toEntries(input),
73
+ ([key, value]) => value !== undefined && HashSet.has(columns, key),
74
+ ),
75
+ ) as UpdateSet<I, K>;
76
+ };
77
+
78
+ /**
79
+ * The `VALUES` map for a create: every key of `input` naming a writable
80
+ * column with a present value. An absent optional is left to the column
81
+ * default rather than written as `undefined`, and a key the table does not
82
+ * carry is dropped — the same projection as {@link updateSetMap}, without
83
+ * the tri-state reading (a create has nothing to carry forward).
84
+ */
85
+ export const insertValues = <I extends Readonly<Record<string, unknown>>, K extends string>(
86
+ input: I,
87
+ writable: Iterable<K>,
88
+ ): UpdateSet<I, K> => updateSetMap(input, writable);