@thomasfosterau/effect-drizzle-ext 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +102 -4
  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,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
+ };