@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.
- 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 +109 -3
- 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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Thomas Foster
|
|
3
|
+
*
|
|
4
|
+
* **FK-reference resolution for annotation-driven columns.**
|
|
5
|
+
*
|
|
6
|
+
* The derivation ({@link ./derive.ts}) reads a column's `references`
|
|
7
|
+
* annotation ({@link DbColumnReference}) as a Drizzle-free **name** pair
|
|
8
|
+
* (`{ table, column }`) and hands it to a `resolveReference` hook that returns
|
|
9
|
+
* the Drizzle column thunk `.references()` needs. This module is one
|
|
10
|
+
* implementation of that hook.
|
|
11
|
+
*
|
|
12
|
+
* Resolution is by **table name**, against a small registry FK-target tables
|
|
13
|
+
* opt into via {@link ReferenceRegistry.register}. Only a table that is
|
|
14
|
+
* actually pointed at by a derived FK column need register. The registry
|
|
15
|
+
* sidesteps the import cycle a static `import { targetTable }` would create
|
|
16
|
+
* (the deriver is imported *by* the table modules): {@link
|
|
17
|
+
* ReferenceRegistry.resolve} returns a **lazy** thunk, and `.references()`
|
|
18
|
+
* invokes it only when the FK is materialised (migration generation / query
|
|
19
|
+
* build) — long after every table module, and its `register` call, has loaded.
|
|
20
|
+
* A reference to an unregistered table fails loudly at that point rather than
|
|
21
|
+
* silently emitting no constraint.
|
|
22
|
+
*
|
|
23
|
+
* **A factory, not a singleton.** The consuming applications each carried one
|
|
24
|
+
* module-scope registry. A library cannot: two independent schemas in one
|
|
25
|
+
* process (an app and its test fixtures, two tenants' table sets) would share
|
|
26
|
+
* one name space and resolve each other's tables. {@link makeReferenceRegistry}
|
|
27
|
+
* hands each derivation its own; a consumer that wants exactly one exports the
|
|
28
|
+
* instance it made.
|
|
29
|
+
*
|
|
30
|
+
* @module references
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { getColumns, getTableName } from "drizzle-orm";
|
|
34
|
+
import type { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
35
|
+
import * as MutableHashMap from "effect/MutableHashMap";
|
|
36
|
+
import * as Option from "effect/Option";
|
|
37
|
+
import { DerivationError } from "./derive.ts";
|
|
38
|
+
import type { DbColumnReference } from "./index.ts";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A name-keyed registry of FK-reference targets, plus the lazy resolver the
|
|
42
|
+
* derivation's `resolveReference` hook expects.
|
|
43
|
+
*
|
|
44
|
+
* The invariant is the laziness: `resolve` never touches the registry, so a
|
|
45
|
+
* reference may be resolved before its target table module has loaded;
|
|
46
|
+
* **forcing** the returned thunk is what reads the registry, and that happens
|
|
47
|
+
* only when Drizzle materialises the foreign key.
|
|
48
|
+
*/
|
|
49
|
+
export interface ReferenceRegistry {
|
|
50
|
+
/**
|
|
51
|
+
* Register a table as a possible FK-reference target, keyed by its SQL table
|
|
52
|
+
* name (matching a `references.table` annotation). Idempotent; call it once,
|
|
53
|
+
* right after the target table is defined, in the table module that owns it.
|
|
54
|
+
*/
|
|
55
|
+
readonly register: (table: PgTable) => void;
|
|
56
|
+
/**
|
|
57
|
+
* Resolve a `dbColumn` `references` annotation to the target Drizzle column,
|
|
58
|
+
* as the lazy thunk `.references()` consumes. Wire it into the derivation's
|
|
59
|
+
* {@link DeriveOptions.resolveReference}; it is invoked only when a derived
|
|
60
|
+
* column declares a reference, so leaves with no FK annotations never touch
|
|
61
|
+
* the registry.
|
|
62
|
+
*
|
|
63
|
+
* Forcing the thunk against an unregistered table, or against a table with
|
|
64
|
+
* no such column, throws {@link DerivationError} — a schema-definition
|
|
65
|
+
* programmer error surfaced the first time Drizzle forces the thunk, not a
|
|
66
|
+
* runtime condition.
|
|
67
|
+
*/
|
|
68
|
+
readonly resolve: (reference: DbColumnReference) => () => AnyPgColumn;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build a fresh {@link ReferenceRegistry}. Each call owns its own name → table
|
|
73
|
+
* map, so independent schemas never resolve into each other.
|
|
74
|
+
*/
|
|
75
|
+
export const makeReferenceRegistry = (): ReferenceRegistry => {
|
|
76
|
+
/** Name → table registry of FK-reference targets, populated at module load. */
|
|
77
|
+
const targets = MutableHashMap.empty<string, PgTable>();
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
register: (table: PgTable): void => {
|
|
81
|
+
MutableHashMap.set(targets, getTableName(table), table);
|
|
82
|
+
},
|
|
83
|
+
resolve: (reference: DbColumnReference) => (): AnyPgColumn => {
|
|
84
|
+
const table = MutableHashMap.get(targets, reference.table);
|
|
85
|
+
if (Option.isNone(table)) {
|
|
86
|
+
throw new DerivationError({
|
|
87
|
+
message:
|
|
88
|
+
`no FK-reference target registered for table '${reference.table}' ` +
|
|
89
|
+
`— call register(${reference.table}) in its table module`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// SAFETY: `getColumns` returns the table's column map keyed by property
|
|
93
|
+
// name; every value is a Drizzle column, and a miss is checked below.
|
|
94
|
+
const column = (getColumns(table.value) as Record<string, AnyPgColumn>)[reference.column];
|
|
95
|
+
if (column === undefined) {
|
|
96
|
+
throw new DerivationError({
|
|
97
|
+
message: `table '${reference.table}' has no column '${reference.column}'`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return column;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
};
|
package/src/sort.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Thomas Foster
|
|
3
|
+
*
|
|
4
|
+
* **The `?sort=` resolution seam: which fields a list may be sorted by, and
|
|
5
|
+
* the `ORDER BY` terms a decoded sort list becomes.**
|
|
6
|
+
*
|
|
7
|
+
* The wire grammar (`?sort=-publishedAt,name`) and its bounds are the
|
|
8
|
+
* request decoder's: by the time anything here runs, a malformed or
|
|
9
|
+
* over-wide `?sort=` has already been refused, and what arrives is a list of
|
|
10
|
+
* {@link SortKey}s. What this module owns is the half that needs to know
|
|
11
|
+
* about columns — **which** fields a view may be sorted by, and what SQL each
|
|
12
|
+
* one becomes.
|
|
13
|
+
*
|
|
14
|
+
* ## The allow-list is derived, not hand-listed
|
|
15
|
+
*
|
|
16
|
+
* {@link sortableColumnsFor} derives a view's sortable set from the view
|
|
17
|
+
* itself, filtered to columns of a scalar, orderable type and published under
|
|
18
|
+
* stable public names:
|
|
19
|
+
*
|
|
20
|
+
* - every column whose Drizzle `dataType` is a string / number / bigint /
|
|
21
|
+
* boolean / timestamp, under the column's own key;
|
|
22
|
+
* - the {@link SortableColumnsOptions.aliased | aliased} expressions it is
|
|
23
|
+
* told to admit by name (a `SQL.Aliased` carries no `dataType`) —
|
|
24
|
+
* by default `createdAt`, the birth-row timestamp a function `merge` of
|
|
25
|
+
* `./versioned-chain`'s `makeCurrentVersionView` merges in;
|
|
26
|
+
* - the {@link SortableColumnsOptions.renamed | renamed} columns under their
|
|
27
|
+
* public names — by default the current version's `timestamp` as
|
|
28
|
+
* `updatedAt`.
|
|
29
|
+
*
|
|
30
|
+
* Everything else is refused with {@link UnsupportedSortError}: a field that
|
|
31
|
+
* does not exist, a column whose type has no useful order (a document
|
|
32
|
+
* column, an embedding), and the version-chain internals
|
|
33
|
+
* ({@link SortableColumnsOptions.internal}, by default `action` and the raw
|
|
34
|
+
* `timestamp` key) that are not part of any resource's vocabulary. Deriving
|
|
35
|
+
* is what makes the surface uniform: a resource gains its sort surface by
|
|
36
|
+
* existing, and a new attribute is sortable the moment its column lands in
|
|
37
|
+
* the view. A resource whose sortable set the view cannot express passes an
|
|
38
|
+
* explicit `sortable` map instead.
|
|
39
|
+
*
|
|
40
|
+
* ## Cost
|
|
41
|
+
*
|
|
42
|
+
* Sorting here is bounded but not necessarily index-backed: a current-version
|
|
43
|
+
* view is a projection over an append-only chain, so the database
|
|
44
|
+
* materialises the current set and sorts it whatever the `ORDER BY` is. A
|
|
45
|
+
* client `?sort=` changes *which* column is sorted, not the cost class of the
|
|
46
|
+
* query, and the page stays `LIMIT`-bounded exactly as before. If a view
|
|
47
|
+
* grows past the point where sorting its whole current set is cheap, the fix
|
|
48
|
+
* is an indexed ordering column, not a narrower allow-list.
|
|
49
|
+
*
|
|
50
|
+
* ## Total order (website #972, #1231)
|
|
51
|
+
*
|
|
52
|
+
* `LIMIT` / `OFFSET` over a non-total order lets a row repeat across pages or
|
|
53
|
+
* fall through the gap between them. Every order {@link resolveSortOrder}
|
|
54
|
+
* returns therefore ends in the view's `id`, mirroring the direction of the
|
|
55
|
+
* last sort key, unless the caller already sorted by `id`.
|
|
56
|
+
*
|
|
57
|
+
* Ported from the website and gardeneria applications'
|
|
58
|
+
* `apps/api/src/operations/sort.ts`.
|
|
59
|
+
*
|
|
60
|
+
* @module sort
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
import { asc, desc, getColumns, SQL } from "drizzle-orm";
|
|
64
|
+
import type { Table, View } from "drizzle-orm";
|
|
65
|
+
import type { PgColumn } from "drizzle-orm/pg-core";
|
|
66
|
+
import * as Arr from "effect/Array";
|
|
67
|
+
import * as Effect from "effect/Effect";
|
|
68
|
+
import * as HashSet from "effect/HashSet";
|
|
69
|
+
import * as Predicate from "effect/Predicate";
|
|
70
|
+
import * as R from "effect/Record";
|
|
71
|
+
import * as Schema from "effect/Schema";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One decoded `?sort=` key: a public field name and its direction. The
|
|
75
|
+
* structural shape every JSON:API sort decoder produces (`-name` →
|
|
76
|
+
* `{ field: "name", direction: "desc" }`); nothing here depends on which
|
|
77
|
+
* decoder that is.
|
|
78
|
+
*/
|
|
79
|
+
export interface SortKey {
|
|
80
|
+
readonly field: string;
|
|
81
|
+
readonly direction: "asc" | "desc";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* An `ORDER BY` term: a column reference or a SQL expression (including a
|
|
86
|
+
* `SQL.Aliased` view column) — exactly what a Postgres select's `.orderBy()`
|
|
87
|
+
* spreads, and what Drizzle's `asc` / `desc` take.
|
|
88
|
+
*/
|
|
89
|
+
export type OrderTerm = PgColumn | SQL | SQL.Aliased;
|
|
90
|
+
|
|
91
|
+
/** Public sort-field name → the `ORDER BY` term it resolves to. */
|
|
92
|
+
export type SortableColumns = Readonly<Record<string, OrderTerm>>;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A relation {@link resolveSortOrder} can sort: a Drizzle table or view whose
|
|
96
|
+
* selection carries the `id` column the total-order tiebreak closes on.
|
|
97
|
+
*/
|
|
98
|
+
export type SortableRelation = (Table | View) & { readonly id: PgColumn };
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A caller asked to sort by a field the resource does not expose.
|
|
102
|
+
*
|
|
103
|
+
* `allowed` is carried so the error document can tell the caller what it
|
|
104
|
+
* *could* have asked for — the discovery mechanism for a set that is derived
|
|
105
|
+
* rather than published in the endpoint schema. `code` is the stable
|
|
106
|
+
* machine-readable name (`unsupported.sort-field`, the sort-axis counterpart
|
|
107
|
+
* of an include walk's `unsupported.include-path`); a JSON:API boundary maps
|
|
108
|
+
* the error to a 400 with `source: { parameter: "sort" }`.
|
|
109
|
+
*/
|
|
110
|
+
export class UnsupportedSortError extends Schema.TaggedError<UnsupportedSortError>()(
|
|
111
|
+
"UnsupportedSortError",
|
|
112
|
+
{
|
|
113
|
+
field: Schema.String,
|
|
114
|
+
allowed: Schema.Array(Schema.String),
|
|
115
|
+
},
|
|
116
|
+
) {
|
|
117
|
+
/** The stable machine-readable error code. */
|
|
118
|
+
get code(): "unsupported.sort-field" {
|
|
119
|
+
return "unsupported.sort-field";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
override get message(): string {
|
|
123
|
+
return `unsupported.sort-field: ${this.field}. Sortable fields: ${Arr.match(this.allowed, {
|
|
124
|
+
onEmpty: () => "(none)",
|
|
125
|
+
onNonEmpty: (allowed) => allowed.join(", "),
|
|
126
|
+
})}`;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* oxlint-disable effect/prefer-option-over-null -- an options bag a caller forwards its own optionals into (a decoded `?sort=` is absent when the request carried none); `?: T | undefined` is what lets it pass `{ sort: query.sort }` under `exactOptionalPropertyTypes`, and an `Option` there would only be unwrapped on the next line. Same shape as the applications' signature. */
|
|
131
|
+
/**
|
|
132
|
+
* How {@link sortableColumnsFor} names the version-chain columns of a view.
|
|
133
|
+
* Every member defaults to the convention `./versioned-chain`'s
|
|
134
|
+
* `makeCurrentVersionView` documents (an `action` soft-delete gate, the
|
|
135
|
+
* current version's `timestamp`, a birth-row `createdAt`).
|
|
136
|
+
*/
|
|
137
|
+
export interface SortableColumnsOptions {
|
|
138
|
+
/** Column keys never published as sort fields. Default `["action", "timestamp"]`. */
|
|
139
|
+
readonly internal?: ReadonlyArray<string> | undefined;
|
|
140
|
+
/**
|
|
141
|
+
* Aliased expressions (`SQL.Aliased`, which carry no `dataType`) admitted by
|
|
142
|
+
* name. Default `["createdAt"]`.
|
|
143
|
+
*/
|
|
144
|
+
readonly aliased?: ReadonlyArray<string> | undefined;
|
|
145
|
+
/**
|
|
146
|
+
* Public name → the view key it publishes. Applied after `internal`, so an
|
|
147
|
+
* internal key can still be published under its resource-facing name.
|
|
148
|
+
* Default `{ updatedAt: "timestamp" }`.
|
|
149
|
+
*/
|
|
150
|
+
readonly renamed?: Readonly<Record<string, string>> | undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* oxlint-enable effect/prefer-option-over-null */
|
|
154
|
+
|
|
155
|
+
const DEFAULT_INTERNAL: ReadonlyArray<string> = ["action", "timestamp"];
|
|
156
|
+
const DEFAULT_ALIASED: ReadonlyArray<string> = ["createdAt"];
|
|
157
|
+
const DEFAULT_RENAMED = { updatedAt: "timestamp" } satisfies Readonly<Record<string, string>>;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Drizzle `dataType` base tokens that order usefully. The descriptor is
|
|
161
|
+
* space-separated with the base type first (`string`, `string uuid`,
|
|
162
|
+
* `number int32`, `object date`, …); timestamps' base token is the useless
|
|
163
|
+
* `object`, so they are matched in full. What this excludes is the point:
|
|
164
|
+
* `custom` columns and `array vector` embeddings have no meaningful scalar
|
|
165
|
+
* order.
|
|
166
|
+
*/
|
|
167
|
+
const SORTABLE_BASE_TYPES: HashSet.HashSet<string> = HashSet.make(
|
|
168
|
+
"string",
|
|
169
|
+
"number",
|
|
170
|
+
"bigint",
|
|
171
|
+
"boolean",
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const isSortableColumn = (term: OrderTerm): boolean => {
|
|
175
|
+
if (!Predicate.hasProperty(term, "dataType") || !Predicate.isString(term.dataType)) return false;
|
|
176
|
+
if (term.dataType === "object date") return true;
|
|
177
|
+
return HashSet.has(SORTABLE_BASE_TYPES, term.dataType.split(" ")[0] ?? "");
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Derived maps, memoised per relation and options object. A view is a
|
|
182
|
+
* module-scope value and the derivation walks every column, so recomputing
|
|
183
|
+
* it per list request would be pure waste. Only the default-options
|
|
184
|
+
* derivation is memoised: an options object is usually a fresh literal, and
|
|
185
|
+
* keying on it would only grow the cache.
|
|
186
|
+
*/
|
|
187
|
+
const derived = new WeakMap<object, SortableColumns>();
|
|
188
|
+
|
|
189
|
+
/** {@link SortableColumnsOptions} with every default applied. */
|
|
190
|
+
interface ResolvedNames {
|
|
191
|
+
readonly internal: HashSet.HashSet<string>;
|
|
192
|
+
readonly aliased: HashSet.HashSet<string>;
|
|
193
|
+
readonly renamed: Readonly<Record<string, string>>;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const resolveNames = (options: SortableColumnsOptions = {}): ResolvedNames => ({
|
|
197
|
+
internal: HashSet.fromIterable(options.internal ?? DEFAULT_INTERNAL),
|
|
198
|
+
aliased: HashSet.fromIterable(options.aliased ?? DEFAULT_ALIASED),
|
|
199
|
+
renamed: options.renamed ?? DEFAULT_RENAMED,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Whether one view entry is published under its own key. A `SQL.Aliased`
|
|
204
|
+
* view column carries no `dataType`, so it is admitted by name; Drizzle
|
|
205
|
+
* renders it in `ORDER BY` as `"<view>"."<alias>"`, which is exactly right in
|
|
206
|
+
* the outer query.
|
|
207
|
+
*/
|
|
208
|
+
const admits = (names: ResolvedNames, key: string, term: OrderTerm): boolean => {
|
|
209
|
+
if (HashSet.has(names.internal, key)) return false;
|
|
210
|
+
if (term instanceof SQL.Aliased) return HashSet.has(names.aliased, key);
|
|
211
|
+
return isSortableColumn(term);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const deriveSortable = (relation: Table | View, names: ResolvedNames): SortableColumns => {
|
|
215
|
+
// SAFETY: `getColumns` returns the relation's column / selected-field map;
|
|
216
|
+
// its conditional return type does not resolve for the `Table | View`
|
|
217
|
+
// union, but every value is a column or a SQL expression.
|
|
218
|
+
const columns = getColumns(relation) as Record<string, OrderTerm>;
|
|
219
|
+
const published = Arr.filter(R.toEntries(columns), ([key, term]) => admits(names, key, term));
|
|
220
|
+
const renamed = Arr.flatMap(R.toEntries(names.renamed), ([name, key]) => {
|
|
221
|
+
const term = columns[key];
|
|
222
|
+
return term === undefined ? [] : [[name, term] as const];
|
|
223
|
+
});
|
|
224
|
+
return R.fromEntries([...published, ...renamed]);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The sortable field map for a view (or table) — see the module doc for what
|
|
229
|
+
* lands in it and why.
|
|
230
|
+
*/
|
|
231
|
+
export const sortableColumnsFor = (
|
|
232
|
+
relation: Table | View,
|
|
233
|
+
options?: SortableColumnsOptions,
|
|
234
|
+
): SortableColumns => {
|
|
235
|
+
if (options !== undefined) return deriveSortable(relation, resolveNames(options));
|
|
236
|
+
const cached = derived.get(relation);
|
|
237
|
+
if (cached !== undefined) return cached;
|
|
238
|
+
const map = deriveSortable(relation, resolveNames());
|
|
239
|
+
derived.set(relation, map);
|
|
240
|
+
return map;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/** Apply a direction to one `ORDER BY` term. */
|
|
244
|
+
const directed = (term: OrderTerm, direction: "asc" | "desc"): SQL =>
|
|
245
|
+
direction === "desc" ? desc(term) : asc(term);
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Whether an ordering already ends in the relation's bare `id` column, and
|
|
249
|
+
* so needs no tiebreak appended.
|
|
250
|
+
*
|
|
251
|
+
* Compared by **column name**, not by reference: Drizzle hands back a fresh
|
|
252
|
+
* alias proxy on every view-column access, so `view.id === view.id` is
|
|
253
|
+
* `false` and an identity check silently never matches. A directed term
|
|
254
|
+
* (`desc(view.id)`) is a `SQL`, not a column, and is deliberately not
|
|
255
|
+
* matched: appending an ascending `id` after a descending one would fight it,
|
|
256
|
+
* so a caller spelling the tiebreak that way passes `tiebreak` instead.
|
|
257
|
+
*/
|
|
258
|
+
const endsInIdColumn = (order: ReadonlyArray<OrderTerm>, idColumn: PgColumn): boolean => {
|
|
259
|
+
const last = order.at(-1);
|
|
260
|
+
if (last === undefined || last instanceof SQL || last instanceof SQL.Aliased) return false;
|
|
261
|
+
return last.name === idColumn.name;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/* oxlint-disable effect/prefer-option-over-null -- an options bag a caller forwards its own optionals into (a decoded `?sort=` is absent when the request carried none); `?: T | undefined` is what lets it pass `{ sort: query.sort }` under `exactOptionalPropertyTypes`, and an `Option` there would only be unwrapped on the next line. Same shape as the applications' signature. */
|
|
265
|
+
/** The options {@link resolveSortOrder} takes. */
|
|
266
|
+
export interface ResolveSortOrderOptions {
|
|
267
|
+
/** The relation sorted — the source of both the allow-list and the `id` tiebreak. */
|
|
268
|
+
readonly view: SortableRelation;
|
|
269
|
+
/** The caller's decoded sort keys; absent or empty when they asked for no order. */
|
|
270
|
+
readonly sort?: ReadonlyArray<SortKey> | undefined;
|
|
271
|
+
/**
|
|
272
|
+
* The resource's intrinsic display order, used when the caller supplied no
|
|
273
|
+
* `sort`. Need not be total: the `id` tiebreak is appended unless its last
|
|
274
|
+
* term already *is* the relation's `id` column.
|
|
275
|
+
*/
|
|
276
|
+
readonly fallback?: ReadonlyArray<OrderTerm> | undefined;
|
|
277
|
+
/**
|
|
278
|
+
* Direction of the `id` tiebreak appended to a non-total `fallback`.
|
|
279
|
+
* Defaults to the bare (ascending) column. A newest-first fallback passes
|
|
280
|
+
* `"desc"` so the tiebreak mirrors it rather than fighting it.
|
|
281
|
+
*/
|
|
282
|
+
readonly tiebreak?: "asc" | "desc" | undefined;
|
|
283
|
+
/** Overrides the derived allow-list for a resource whose sortable set the view can't express. */
|
|
284
|
+
readonly sortable?: SortableColumns | undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* oxlint-enable effect/prefer-option-over-null */
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Resolve a list request's ordering: the caller's `?sort=` when present, the
|
|
291
|
+
* resource's own `fallback` order otherwise, always closed with an `id`
|
|
292
|
+
* tiebreak so the page window is total-ordered.
|
|
293
|
+
*
|
|
294
|
+
* Fails with {@link UnsupportedSortError} when `sort` names a field outside
|
|
295
|
+
* the allow-list. Every field is checked before any is mapped, so a
|
|
296
|
+
* partially-valid `?sort=` is rejected rather than silently half-applied.
|
|
297
|
+
* The allow-list check is an own-property test, so `constructor` or
|
|
298
|
+
* `toString` are never sort fields.
|
|
299
|
+
*/
|
|
300
|
+
export const resolveSortOrder = (
|
|
301
|
+
options: ResolveSortOrderOptions,
|
|
302
|
+
): Effect.Effect<ReadonlyArray<OrderTerm>, UnsupportedSortError> =>
|
|
303
|
+
Effect.suspend(() => {
|
|
304
|
+
const idColumn = options.view.id;
|
|
305
|
+
return Arr.match(options.sort ?? [], {
|
|
306
|
+
onEmpty: (): Effect.Effect<ReadonlyArray<OrderTerm>, UnsupportedSortError> => {
|
|
307
|
+
const fallback = options.fallback ?? [];
|
|
308
|
+
if (endsInIdColumn(fallback, idColumn)) return Effect.succeed(fallback);
|
|
309
|
+
return Effect.succeed([
|
|
310
|
+
...fallback,
|
|
311
|
+
options.tiebreak === undefined ? idColumn : directed(idColumn, options.tiebreak),
|
|
312
|
+
]);
|
|
313
|
+
},
|
|
314
|
+
onNonEmpty: (sort) => {
|
|
315
|
+
const sortable = options.sortable ?? sortableColumnsFor(options.view);
|
|
316
|
+
const unsupported = sort.find(({ field }) => !Object.hasOwn(sortable, field));
|
|
317
|
+
if (unsupported !== undefined) {
|
|
318
|
+
return Effect.fail(
|
|
319
|
+
new UnsupportedSortError({
|
|
320
|
+
field: unsupported.field,
|
|
321
|
+
allowed: R.keys(sortable).toSorted(),
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Every field was checked as an own key of `sortable` above.
|
|
327
|
+
const terms = sort.map(({ field, direction }) => directed(sortable[field], direction));
|
|
328
|
+
// A caller who sorted by `id` has already asked for a total order; a
|
|
329
|
+
// second `id` term would be redundant SQL.
|
|
330
|
+
if (sort.some(({ field }) => field === "id")) return Effect.succeed(terms);
|
|
331
|
+
return Effect.succeed([...terms, directed(idColumn, Arr.lastNonEmpty(sort).direction)]);
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
/* oxlint-disable effect/prefer-option-over-null -- an options bag a caller forwards its own optionals into (a decoded `?sort=` is absent when the request carried none); `?: T | undefined` is what lets it pass `{ sort: query.sort }` under `exactOptionalPropertyTypes`, and an `Option` there would only be unwrapped on the next line. Same shape as the applications' signature. */
|
|
337
|
+
/** The options {@link chronologicalOrder} takes. */
|
|
338
|
+
export interface ChronologicalOrderOptions {
|
|
339
|
+
/** The relation sorted; its `timestamp` column is the chronological axis. */
|
|
340
|
+
readonly view: SortableRelation & { readonly timestamp: PgColumn };
|
|
341
|
+
/** The caller's decoded sort keys, as for {@link resolveSortOrder}. */
|
|
342
|
+
readonly sort?: ReadonlyArray<SortKey> | undefined;
|
|
343
|
+
/** Chronological direction of the default order. Defaults to newest first. */
|
|
344
|
+
readonly direction?: "asc" | "desc" | undefined;
|
|
345
|
+
/** As for {@link resolveSortOrder}. */
|
|
346
|
+
readonly sortable?: SortableColumns | undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/* oxlint-enable effect/prefer-option-over-null */
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* {@link resolveSortOrder} for the commonest default a list has:
|
|
353
|
+
* chronological by the current version's `timestamp`, newest first unless
|
|
354
|
+
* told otherwise, with a tiebreak that mirrors it — one decision, spelled
|
|
355
|
+
* once, so the tiebreak cannot go missing at a call site.
|
|
356
|
+
*/
|
|
357
|
+
export const chronologicalOrder = (
|
|
358
|
+
options: ChronologicalOrderOptions,
|
|
359
|
+
): Effect.Effect<ReadonlyArray<OrderTerm>, UnsupportedSortError> => {
|
|
360
|
+
const direction = options.direction ?? "desc";
|
|
361
|
+
return resolveSortOrder({
|
|
362
|
+
view: options.view,
|
|
363
|
+
sort: options.sort,
|
|
364
|
+
fallback: [directed(options.view.timestamp, direction)],
|
|
365
|
+
tiebreak: direction,
|
|
366
|
+
sortable: options.sortable,
|
|
367
|
+
});
|
|
368
|
+
};
|