|
| 1 | +--- |
| 2 | +title: Sortability |
| 3 | +description: Sortability protocol schemas |
| 4 | +--- |
| 5 | + |
| 6 | +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} |
| 7 | + |
| 8 | +[#10235] The per-column SORTABILITY projection served with object metadata — |
| 9 | +the one signal a grid reads to decide whether a column header offers a sort |
| 10 | +click, ruled 2026-08-23 (option A on #10235): the platform serves an explicit |
| 11 | +signal, and no consumer re-derives "virtual ⇒ unsortable" from field type. |
| 12 | + |
| 13 | +## Why a served projection, and why it is NOT an authorable key |
| 14 | + |
| 15 | +The shipped grids offered sort clicks the platform cannot honor: a sort on a |
| 16 | +`formula` column returned `asc` and `desc` in byte-identical order under a |
| 17 | +200 (#6994's measurement), and since #9313/#10234 the same click is refused |
| 18 | +loudly (`400 INVALID_SORT`) when the console persists it. The grid needs to |
| 19 | +know *before offering the click* — but teaching it "formula means |
| 20 | +unsortable" would re-implement the runtime's predicate one repo away, the |
| 21 | +shadow-copy drift this ruling exists to end. |
| 22 | + |
| 23 | +So the signal is COMPUTED at serve time from the same spec predicates the |
| 24 | +runtime doors and the authoring linter already read, and served beside the |
| 25 | +document on the `GET /meta/:type/:name` envelope. It is deliberately not a |
| 26 | +key inside the document: `FieldSchema` is `strictObject`, so an undeclared |
| 27 | +key on a served field is rejected by name — and declaring it would make it |
| 28 | +AUTHORABLE, handing authors a switch the runtime does not read (the exact |
| 29 | +declared-≠-enforced shape `resolveInjectedColumnProvenance`'s docblock |
| 30 | +records as deliberately rejected). |
| 31 | + |
| 32 | +## The category set, enumerated (closed against the runtime doors) |
| 33 | + |
| 34 | +"Unsortable" is judged by what the two runtime doors — |
| 35 | +`assertSortFieldsExist` (`@objectstack/metadata-protocol`, #6994) and |
| 36 | +`assertOrderByIsMaterializable` (`@objectstack/objectql`, #7095) — actually |
| 37 | +do with an `orderBy` over the name. Three verdicts are REFUSALS, and one |
| 38 | +measured degradation is not refused; the projection covers all four: |
| 39 | + |
| 40 | +1. **Unknown name** — not a field of the object. Refused (`400 |
| 41 | + INVALID_SORT`). Encoded as ABSENCE: the projection's domain is exactly |
| 42 | + the served field map plus the always-provisioned `id`, so a name with no |
| 43 | + entry has no platform sort behind it and gets no affordance. |
| 44 | +2. **Dotted path** (`account.name`) — crosses into a related record no |
| 45 | + driver joins for. Refused. Encoded as absence too: entries are keyed by |
| 46 | + whole-column field names, and a dotted name can never appear as one. |
| 47 | +3. **Virtual type** (`isVirtualSearchField` / `SEARCH_VIRTUAL_TYPES` |
| 48 | + — exactly `formula` today) — computed on read, no driver materialises a |
| 49 | + column. Refused at both doors. Encoded as `sortable: false` with |
| 50 | + `reason: 'virtual-type'`. This is the ONE per-field refusal fact, and it |
| 51 | + is judged by the same spec predicate the linter |
| 52 | + (`validate-sortable-fields`) and the search axis read — never by a local |
| 53 | + type list. |
| 54 | +4. **Unprovisioned injected anchor** (`unprovisionedInjectedColumns` |
| 55 | + — the platform's own injected columns on an ADR-0015 `external` object, |
| 56 | + which registers them and provisions no storage). NOT refused: both doors |
| 57 | + key on `formula` alone, so the sort reaches the driver, finds no column, |
| 58 | + and is silently dropped — measured (#10474) as `asc` === `desc` under a |
| 59 | + 200 while a real column reverses. The platform cannot *prove* the verdict |
| 60 | + either way (the remote table may genuinely carry a column of that name), |
| 61 | + so the entry stays `sortable: true` — the enforcement fact — and carries |
| 62 | + `caveat: 'unprovisioned-anchor'` so a consumer can choose a conservative |
| 63 | + affordance without re-deriving federation from the document. |
| 64 | + |
| 65 | +## Considered and deliberately NOT members |
| 66 | + |
| 67 | +- `summary` / `autonumber` — the other two `COMPUTED_VALUE_TYPES`. They sort |
| 68 | + CORRECTLY (`summary` is an engine-maintained `table.float`, `autonumber` |
| 69 | + an engine-assigned `table.string`; measured on #6924), which is exactly |
| 70 | + why virtuality is judged by the storage predicate and never by the write |
| 71 | + contract — widening would refuse the two types that work. |
| 72 | +- `encrypted` / `secret` / `json` / `vector` and the other heavy or masked |
| 73 | + types — every one has a stored column, neither door refuses an ORDER BY |
| 74 | + over one, and the drivers execute it. Marking them unsortable here would |
| 75 | + invent a refusal the runtime does not enforce: the mirror image of the |
| 76 | + declared-≠-enforced drift this signal exists to end. If any of them should |
| 77 | + be refused, that is a runtime-door decision first, and this projection |
| 78 | + follows it automatically through the shared predicate. |
| 79 | +- `created_at` / `updated_at` on an object that opted out of audit injection |
| 80 | + (`systemFields: false`): the ingress gate hard-admits both names, but no |
| 81 | + column is provisioned. They are simply absent from the served field map, |
| 82 | + so they get no entry — the projection is allowed to be NARROWER than the |
| 83 | + gate where the gate itself is known to degrade. |
| 84 | + |
| 85 | +## Contract for consumers (the objectui grid is the first) |
| 86 | + |
| 87 | +Offer a sort affordance on a column iff the projection has an entry for the |
| 88 | +column's field name and that entry says `sortable: true`. Absence means "no |
| 89 | +platform sort behind this name" — never "assume sortable". Do not recompute |
| 90 | +any of this from field `type` client-side; the verdicts here are derived |
| 91 | +from the same predicates the runtime enforces with, which is the whole |
| 92 | +point (the same "derived verdict — do not recompute" contract the |
| 93 | +protection envelope's `editable` carries). |
| 94 | + |
| 95 | +<Callout type="info"> |
| 96 | +**Source:** `packages/spec/src/api/sortability.zod.ts` |
| 97 | +</Callout> |
| 98 | + |
| 99 | +## TypeScript Usage |
| 100 | + |
| 101 | +```typescript |
| 102 | +import { FieldSortabilitySchema, ObjectSortabilitySchema } from '@objectstack/spec/api'; |
| 103 | +import type { FieldSortability, ObjectSortability } from '@objectstack/spec/api'; |
| 104 | + |
| 105 | +// Validate data |
| 106 | +const result = FieldSortabilitySchema.parse(data); |
| 107 | +``` |
| 108 | + |
| 109 | +--- |
| 110 | + |
| 111 | +## FieldSortability |
| 112 | + |
| 113 | +### Properties |
| 114 | + |
| 115 | +| Property | Type | Required | Description | |
| 116 | +| :--- | :--- | :--- | :--- | |
| 117 | +| **sortable** | `boolean` | ✅ | Whether the platform honors an ORDER BY over this field. `false` means the runtime REFUSES the sort (`400 INVALID_SORT`) — render no sort affordance. `true` means the sort is accepted; see `caveat` for the one accepted-but-degradable case. A derived verdict: do not recompute it from field `type` client-side. | |
| 118 | +| **reason** | `'virtual-type'` | optional | Present exactly when `sortable` is false: the field's type is virtual (computed on read, no stored column), so no driver materialises anything to ORDER BY. | |
| 119 | +| **caveat** | `'unprovisioned-anchor'` | optional | Present only with `sortable: true`: the field is a platform-injected anchor on an ADR-0015 `external` object with no storage provisioned behind it. The runtime accepts the sort, but when the remote table carries no such column the ORDER BY is silently dropped (asc === desc under a 200). Consumers may choose a conservative affordance for these entries. | |
| 120 | + |
| 121 | + |
| 122 | +--- |
| 123 | + |
| 124 | +## ObjectSortability |
| 125 | + |
| 126 | +### Properties |
| 127 | + |
| 128 | +| Property | Type | Required | Description | |
| 129 | +| :--- | :--- | :--- | :--- | |
| 130 | +| **fields** | `Record<string, { sortable: boolean; reason?: 'virtual-type'; caveat?: 'unprovisioned-anchor' }>` | ✅ | Verdict per sortable-addressable column, keyed by field name. The domain is the served field map plus the always-provisioned `id`; a name absent from this map (an unknown field, a dotted path, an unprovisioned audit column) has no platform sort behind it and must get no sort affordance. | |
| 131 | + |
| 132 | + |
| 133 | +--- |
| 134 | + |
0 commit comments