diff --git a/.changeset/meta-object-sortability-projection.md b/.changeset/meta-object-sortability-projection.md new file mode 100644 index 0000000000..8bab42f5ab --- /dev/null +++ b/.changeset/meta-object-sortability-projection.md @@ -0,0 +1,26 @@ +--- +"@objectstack/spec": minor +"@objectstack/rest": minor +--- + +`GET /api/v1/meta/:type/:name` serves a per-column sortability projection +beside every object document (#10235, 2026-08-23 ruling, option A: the +platform serves an explicit signal; grids never re-derive "virtual ⇒ +unsortable" from field type). The envelope gains an optional `sortability` +key — present exactly when the served type is `object`, on every branch, +cached included — declared by the new `ObjectSortabilitySchema` / +`resolveObjectSortability` in `@objectstack/spec/api` and computed at serve +time from the served (post-masking) document via the spec's own storage +predicates, so the signal cannot drift from what the runtime doors +(#6994/#7095) refuse. The closed category set: an unknown name and a dotted +path are encoded as ABSENCE from the projection (both refused `400 +INVALID_SORT`), a virtual-type field (`SEARCH_VIRTUAL_TYPES` — `formula` +today) answers `sortable: false, reason: 'virtual-type'` (refused at both +doors), and a platform-injected anchor on an ADR-0015 `external` object with +no storage behind it stays `sortable: true` with +`caveat: 'unprovisioned-anchor'` (accepted by the doors, measured to degrade +silently when the remote lacks the column — #10474). `summary` and +`autonumber` stay sortable: virtuality is the storage predicate, never the +write contract. The projection is deliberately NOT an authorable key — the +served document is untouched. Consumer leg (the grid dropping the sort +affordance where the signal says unsortable) is objectui#5729. diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index 119f888674..8f56d9de8f 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -129,7 +129,7 @@ AI/ML capabilities - agents, skills, tools, MCP exposure, RAG, and cost tracking | **[Usage](/docs/references/ai/usage)** | `usage.zod.ts` | AIUsageRecord, TokenUsage | AI usage and cost tracking | | **[Solution Blueprint](/docs/references/ai/solution-blueprint)** | `solution-blueprint.zod.ts` | BlueprintObject, BlueprintApp | Blueprint format for AI app generation | -## API Protocol (17 of 28 schemas) +## API Protocol (17 of 29 schemas) REST endpoints, real-time subscriptions, and discovery. diff --git a/content/docs/references/api/index.mdx b/content/docs/references/api/index.mdx index 8ed3b4062a..080770244d 100644 --- a/content/docs/references/api/index.mdx +++ b/content/docs/references/api/index.mdx @@ -31,6 +31,7 @@ This section contains all protocol schemas for the api layer of ObjectStack. + diff --git a/content/docs/references/api/meta.json b/content/docs/references/api/meta.json index 07a1304afe..e333f891f8 100644 --- a/content/docs/references/api/meta.json +++ b/content/docs/references/api/meta.json @@ -32,6 +32,7 @@ "plugin-rest-api", "storage", "---More---", - "error-code-ledger" + "error-code-ledger", + "sortability" ] } \ No newline at end of file diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 3a11679176..476c33afb1 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -768,6 +768,7 @@ Enable package response | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name | | **item** | `any` | ✅ | Metadata item definition | +| **sortability** | `{ fields: Record }` | optional | Per-column sortability projection (#10235) — present exactly when `type` is `object`, on every serving branch. Computed at serve time from the served document via the spec's own storage predicates; consumers render sort affordances from this signal and never re-derive it from field `type`. See `ObjectSortabilitySchema` for the closed category set. | | **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; `no-overlay` / `no-delete` / `full` refuse the corresponding write with 403 `ITEM_LOCKED`. Resolved from the document's `_lock`, with the packaged artifact winning over any org overlay. | | **lockReason** | `string` | optional | Human-readable explanation shown next to a refused write. Present only when the resolved item declares `_lockReason`. | | **lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Which layer asserted the lock. Present only when the resolved item declares `_lockSource`. | diff --git a/content/docs/references/api/sortability.mdx b/content/docs/references/api/sortability.mdx new file mode 100644 index 0000000000..e265ff4117 --- /dev/null +++ b/content/docs/references/api/sortability.mdx @@ -0,0 +1,134 @@ +--- +title: Sortability +description: Sortability protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +[#10235] The per-column SORTABILITY projection served with object metadata — +the one signal a grid reads to decide whether a column header offers a sort +click, ruled 2026-08-23 (option A on #10235): the platform serves an explicit +signal, and no consumer re-derives "virtual ⇒ unsortable" from field type. + +## Why a served projection, and why it is NOT an authorable key + +The shipped grids offered sort clicks the platform cannot honor: a sort on a +`formula` column returned `asc` and `desc` in byte-identical order under a +200 (#6994's measurement), and since #9313/#10234 the same click is refused +loudly (`400 INVALID_SORT`) when the console persists it. The grid needs to +know *before offering the click* — but teaching it "formula means +unsortable" would re-implement the runtime's predicate one repo away, the +shadow-copy drift this ruling exists to end. + +So the signal is COMPUTED at serve time from the same spec predicates the +runtime doors and the authoring linter already read, and served beside the +document on the `GET /meta/:type/:name` envelope. It is deliberately not a +key inside the document: `FieldSchema` is `strictObject`, so an undeclared +key on a served field is rejected by name — and declaring it would make it +AUTHORABLE, handing authors a switch the runtime does not read (the exact +declared-≠-enforced shape `resolveInjectedColumnProvenance`'s docblock +records as deliberately rejected). + +## The category set, enumerated (closed against the runtime doors) + +"Unsortable" is judged by what the two runtime doors — +`assertSortFieldsExist` (`@objectstack/metadata-protocol`, #6994) and +`assertOrderByIsMaterializable` (`@objectstack/objectql`, #7095) — actually +do with an `orderBy` over the name. Three verdicts are REFUSALS, and one +measured degradation is not refused; the projection covers all four: + +1. **Unknown name** — not a field of the object. Refused (`400 + INVALID_SORT`). Encoded as ABSENCE: the projection's domain is exactly + the served field map plus the always-provisioned `id`, so a name with no + entry has no platform sort behind it and gets no affordance. +2. **Dotted path** (`account.name`) — crosses into a related record no + driver joins for. Refused. Encoded as absence too: entries are keyed by + whole-column field names, and a dotted name can never appear as one. +3. **Virtual type** (`isVirtualSearchField` / `SEARCH_VIRTUAL_TYPES` + — exactly `formula` today) — computed on read, no driver materialises a + column. Refused at both doors. Encoded as `sortable: false` with + `reason: 'virtual-type'`. This is the ONE per-field refusal fact, and it + is judged by the same spec predicate the linter + (`validate-sortable-fields`) and the search axis read — never by a local + type list. +4. **Unprovisioned injected anchor** (`unprovisionedInjectedColumns` + — the platform's own injected columns on an ADR-0015 `external` object, + which registers them and provisions no storage). NOT refused: both doors + key on `formula` alone, so the sort reaches the driver, finds no column, + and is silently dropped — measured (#10474) as `asc` === `desc` under a + 200 while a real column reverses. The platform cannot *prove* the verdict + either way (the remote table may genuinely carry a column of that name), + so the entry stays `sortable: true` — the enforcement fact — and carries + `caveat: 'unprovisioned-anchor'` so a consumer can choose a conservative + affordance without re-deriving federation from the document. + +## Considered and deliberately NOT members + +- `summary` / `autonumber` — the other two `COMPUTED_VALUE_TYPES`. They sort + CORRECTLY (`summary` is an engine-maintained `table.float`, `autonumber` + an engine-assigned `table.string`; measured on #6924), which is exactly + why virtuality is judged by the storage predicate and never by the write + contract — widening would refuse the two types that work. +- `encrypted` / `secret` / `json` / `vector` and the other heavy or masked + types — every one has a stored column, neither door refuses an ORDER BY + over one, and the drivers execute it. Marking them unsortable here would + invent a refusal the runtime does not enforce: the mirror image of the + declared-≠-enforced drift this signal exists to end. If any of them should + be refused, that is a runtime-door decision first, and this projection + follows it automatically through the shared predicate. +- `created_at` / `updated_at` on an object that opted out of audit injection + (`systemFields: false`): the ingress gate hard-admits both names, but no + column is provisioned. They are simply absent from the served field map, + so they get no entry — the projection is allowed to be NARROWER than the + gate where the gate itself is known to degrade. + +## Contract for consumers (the objectui grid is the first) + +Offer a sort affordance on a column iff the projection has an entry for the +column's field name and that entry says `sortable: true`. Absence means "no +platform sort behind this name" — never "assume sortable". Do not recompute +any of this from field `type` client-side; the verdicts here are derived +from the same predicates the runtime enforces with, which is the whole +point (the same "derived verdict — do not recompute" contract the +protection envelope's `editable` carries). + + +**Source:** `packages/spec/src/api/sortability.zod.ts` + + +## TypeScript Usage + +```typescript +import { FieldSortabilitySchema, ObjectSortabilitySchema } from '@objectstack/spec/api'; +import type { FieldSortability, ObjectSortability } from '@objectstack/spec/api'; + +// Validate data +const result = FieldSortabilitySchema.parse(data); +``` + +--- + +## FieldSortability + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **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. | +| **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. | +| **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. | + + +--- + +## ObjectSortability + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **fields** | `Record` | ✅ | 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. | + + +--- + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 0241c62d32..7d7d76d704 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1583 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1585 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -20,7 +20,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 28 | 415 | REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 29 | 417 | REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 68 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. | | [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 156 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **198** | **1583** | 14 protocol modules | +| **Total** | **199** | **1585** | 14 protocol modules | --- @@ -61,7 +61,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **28 pages, 415 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **29 pages, 417 schemas** REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. @@ -92,6 +92,7 @@ REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. | [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` | | [`rest-server.zod.ts`](/docs/references/api/rest-server) | `BatchEndpointsConfig`, `CrudEndpointPattern`, `CrudEndpointsConfig`, `CrudOperation`, `EndpointRegistry`, `GeneratedEndpoint`, `MetadataEndpointsConfig`, `RestApiConfig`, `RestServerConfig`, `RouteGenerationConfig` | | [`router.zod.ts`](/docs/references/api/router) | `ConflictResolutionStrategy`, `HttpMethod`, `RouteCategory`, `RouteDefinition`, `RouterConfig` | +| [`sortability.zod.ts`](/docs/references/api/sortability) | `FieldSortability`, `ObjectSortability` | | [`storage.zod.ts`](/docs/references/api/storage) | `CompleteChunkedUploadRequest`, `CompleteChunkedUploadResponse`, `CompleteUploadRequest`, `FileDownloadUrlResponse`, `FileTypeValidation`, `FileUploadResponse`, `GetPresignedUrlRequest`, `InitiateChunkedUploadRequest`, `InitiateChunkedUploadResponse`, `PresignedUrlResponse`, `RawUploadResponse`, `UploadChunkRequest`, `UploadChunkResponse`, `UploadProgress` | | [`versioning.zod.ts`](/docs/references/api/versioning) | `VersionDefinition`, `VersionNegotiationResponse`, `VersionStatus`, `VersioningConfig`, `VersioningStrategy` | | [`websocket.zod.ts`](/docs/references/api/websocket) | `AckMessage`, `CursorMessage`, `CursorPosition`, `DocumentState`, `EditMessage`, `EditOperation`, `EditOperationType`, `ErrorMessage`, `EventMessage`, `EventPattern`, `EventSubscription`, `PingMessage`, `PongMessage`, `PresenceMessage`, `PresenceState`, `PresenceUpdate`, `SimpleCursorPosition`, `SimplePresenceState`, `SubscribeMessage`, `UnsubscribeMessage`, `UnsubscribeRequest`, `WebSocketConfig`, `WebSocketEvent`, `WebSocketMessage`, `WebSocketMessageType`, `WebSocketPresenceStatus`, `WebSocketServerConfig` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index a7a538038d..e0165a3208 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -257,7 +257,7 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 77 | -| `api/` | 407 | +| `api/` | 409 | | `cloud/` | 83 | | `identity/` | 32 | | `integration/` | 10 | diff --git a/examples/app-crm/test/sortability-projection-oracle.test.ts b/examples/app-crm/test/sortability-projection-oracle.test.ts new file mode 100644 index 0000000000..e5884bca0d --- /dev/null +++ b/examples/app-crm/test/sortability-projection-oracle.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#10235] The measured oracle, pinned against the SHIPPED corpus: the two + * `crm_opportunity.expected_revenue` refusal cells (#9313/#10234's harness — + * the only column-sort PUTs over this app's displayed columns that answer + * `422 sort-field-unsortable`) must come out UNSORTABLE in the served + * sortability projection, so the downstream grid (objectui leg) can make the + * click unofferable. + * + * Pinned here — in the app that ships the cells — rather than against an + * inlined copy, so the oracle cannot drift from the corpus: if the view stops + * displaying the formula column, or the field stops being a formula, the + * premise assertions below go red rather than the pin going vacuously green. + */ + +import { describe, it, expect } from 'vitest'; +import stack from '../objectstack.config.js'; +import { resolveObjectSortability } from '@objectstack/spec/api'; + +const readColumns = (view: any): string[] => + (view?.columns ?? []).map((c: any) => (typeof c === 'string' ? c : c?.field)).filter(Boolean); + +describe('#10235 sortability oracle — crm_opportunity.expected_revenue', () => { + const opportunity = (stack.objects ?? []).find((o: any) => o.name === 'crm_opportunity'); + const aggregate: any = (stack.views ?? []).find( + (v: any) => (v as any)?.list?.data?.object === 'crm_opportunity', + ); + + it('premise: the formula column is DISPLAYED in both measured views', () => { + // The two refusal cells from the #9313/#10234 measurement: the aggregate's + // default list and its named `all` list view both ship the column. + expect(aggregate).toBeDefined(); + expect(readColumns(aggregate.list)).toContain('expected_revenue'); + expect(readColumns(aggregate.listViews?.all)).toContain('expected_revenue'); + // And it IS a formula field on the shipped object — the category the + // runtime doors refuse (#6994/#7095). + expect((opportunity as any)?.fields?.expected_revenue?.type).toBe('formula'); + }); + + it('the served projection marks the cell unsortable', () => { + const { fields } = resolveObjectSortability(opportunity); + expect(fields.expected_revenue).toEqual({ sortable: false, reason: 'virtual-type' }); + }); + + it('anti-vacuity: the ordinary persisted columns beside it stay sortable', () => { + const { fields } = resolveObjectSortability(opportunity); + expect(fields.amount).toEqual({ sortable: true }); + expect(fields.close_date).toEqual({ sortable: true }); + expect(fields.name).toEqual({ sortable: true }); + }); +}); diff --git a/examples/app-showcase/test/sortability-projection-oracle.test.ts b/examples/app-showcase/test/sortability-projection-oracle.test.ts new file mode 100644 index 0000000000..80dec1a78f --- /dev/null +++ b/examples/app-showcase/test/sortability-projection-oracle.test.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#10235] The measured oracle, pinned against the SHIPPED corpus: the + * `showcase_project.budget_remaining` refusal cell (#9313/#10234's harness — + * the third of the 3 column-sort PUTs over shipped displayed columns that + * answer `422 sort-field-unsortable`) must come out UNSORTABLE in the served + * sortability projection, so the downstream grid (objectui leg) can make the + * click unofferable. The CRM twin pins the other two cells. + */ + +import { describe, it, expect } from 'vitest'; +import stack from '../objectstack.config.js'; +import { resolveObjectSortability } from '@objectstack/spec/api'; + +const readColumns = (view: any): string[] => + (view?.columns ?? []).map((c: any) => (typeof c === 'string' ? c : c?.field)).filter(Boolean); + +describe('#10235 sortability oracle — showcase_project.budget_remaining', () => { + const project = (stack.objects ?? []).find((o: any) => o.name === 'showcase_project'); + const aggregate: any = (stack.views ?? []).find( + (v: any) => (v as any)?.list?.data?.object === 'showcase_project', + ); + + it('premise: the formula column is DISPLAYED in the measured view', () => { + expect(aggregate).toBeDefined(); + expect(readColumns(aggregate.list)).toContain('budget_remaining'); + expect((project as any)?.fields?.budget_remaining?.type).toBe('formula'); + }); + + it('the served projection marks the cell unsortable', () => { + const { fields } = resolveObjectSortability(project); + expect(fields.budget_remaining).toEqual({ sortable: false, reason: 'virtual-type' }); + }); + + it('anti-vacuity: the persisted budget column beside it stays sortable', () => { + const { fields } = resolveObjectSortability(project); + expect(fields.budget).toEqual({ sortable: true }); + expect(fields.spent).toEqual({ sortable: true }); + }); +}); diff --git a/packages/rest/src/meta-object-sortability-projection.test.ts b/packages/rest/src/meta-object-sortability-projection.test.ts new file mode 100644 index 0000000000..53a5937c26 --- /dev/null +++ b/packages/rest/src/meta-object-sortability-projection.test.ts @@ -0,0 +1,173 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#10235] `GET /meta/:type/:name` serves the per-column sortability + * projection beside the object document — on EVERY branch (2026-08-23 ruling, + * option A: the platform serves the signal; the grid never re-derives + * "virtual ⇒ unsortable" from field type). + * + * Why the branch coverage is the load-bearing part: the ADR-0010 protection + * keys are famously absent on the cached branch (THE DEFAULT — `enableCache` + * defaults to `true`), because they come from the lock resolver the cached + * path never consults. A sortability signal with that presence profile would + * be a half-signal: the default deployment's grid would never see it. The + * projection is therefore computed in `translateMetaEnvelope` — the one seam + * every single-item exit passes through (#5563) — from the served document + * itself, and this file pins it on both branches. + * + * AGREEMENT over hardcoding (the `meta-object-search-companion-agreement` + * posture): each host also asserts the served projection deep-equals + * `resolveObjectSortability()` — the spec resolver the runtime + * doors' predicate feeds — so the pin follows the predicate rather than a + * copied verdict table. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { resolveObjectSortability } from '@objectstack/spec/api'; +import { RestServer } from './rest-server.js'; + +const ANON_API = { api: { requireAuth: false } }; + +/** + * The #10235 oracle shape: a grid-displayed formula column beside ordinary + * persisted ones — `crm_opportunity.expected_revenue`'s structure, inlined. + */ +const OPPORTUNITY = { + name: 'crm_opportunity', + label: 'Opportunity', + fields: { + name: { type: 'text' }, + amount: { type: 'currency' }, + expected_revenue: { type: 'formula', expression: 'amount * probability / 100' }, + }, +}; + +const LIST_VIEW = { + name: 'opportunity_all', + object: 'crm_opportunity', + viewKind: 'list', + columns: ['name', 'amount', 'expected_revenue'], +}; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + return { json: vi.fn(), status: vi.fn().mockReturnThis(), header: vi.fn(), send: vi.fn() }; +} + +/** Protocol double speaking the producer's real envelope (see meta-item-envelope.test.ts). */ +function baseProtocol(overrides: Record = {}) { + return { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', + routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn(async ({ type, name }: any) => ({ + // The real producer folds the plural URL spelling to the canonical + // singular (#4432) — mirror it, so the plural-spelling host below + // exercises the server against the envelope the producer actually + // speaks rather than against a double that skipped the fold. + type: ({ objects: 'object', views: 'view' } as Record)[type] ?? type, + name, + item: name === 'opportunity_all' ? LIST_VIEW : OPPORTUNITY, + lock: 'none', editable: true, deletable: true, resettable: false, + })), + getMetaItemCached: undefined as any, + findData: vi.fn().mockResolvedValue([]), + getData: vi.fn().mockResolvedValue({}), + createData: vi.fn().mockResolvedValue({ id: '1' }), + updateData: vi.fn().mockResolvedValue({}), + deleteData: vi.fn().mockResolvedValue({ success: true }), + ...overrides, + }; +} + +function itemRoute(rest: RestServer) { + return (rest as any).getRoutes().find( + (r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name', + ); +} + +async function dispatch(protocol: any, params: any, query: any = {}, config: any = ANON_API) { + const rest = new RestServer(mockServer() as any, protocol as any, config as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] }); + rest.registerRoutes(); + const res = mockRes(); + await itemRoute(rest)!.handler({ params, query, headers: {} }, res); + // Indexed rather than `.at(-1)`: this package's TEST_DEBT re-measure runs + // under the repo tsconfig's lib, where `Array.prototype.at` is not typed — + // the ledger is shrink-only, so new test code stays off the untyped API. + const calls = res.json.mock.calls; + return { res, body: calls[calls.length - 1]?.[0] }; +} + +describe('#10235 GET /meta/object/:name — the sortability projection rides the envelope', () => { + it('non-cached branch: formula unsortable, persisted columns sortable, id appended', async () => { + const { body } = await dispatch(baseProtocol(), { type: 'object', name: 'crm_opportunity' }); + + expect(body.type).toBe('object'); + expect(body.sortability).toBeDefined(); + const fields = body.sortability.fields; + // The refusal-backed cell (#6994/#7095: 400 INVALID_SORT). + expect(fields.expected_revenue).toEqual({ sortable: false, reason: 'virtual-type' }); + // Anti-vacuity: the projection must not go green by refusing everything. + expect(fields.name).toEqual({ sortable: true }); + expect(fields.amount).toEqual({ sortable: true }); + // The driver-provisioned primary key is addressable and sortable. + expect(fields.id).toEqual({ sortable: true }); + // AGREEMENT: the wire projection IS the spec resolver's verdict over + // the served document — no second verdict table anywhere. + expect(body.sortability).toEqual(resolveObjectSortability(body.item)); + }); + + it('cached branch (THE DEFAULT) serves the same projection — unlike the lock keys', async () => { + const protocol = baseProtocol({ + getMetaItemCached: vi.fn().mockResolvedValue({ + // `metadata-protocol.getMetaItemCached` hands back the + // already-unwrapped document; the REST layer rebuilds the envelope. + data: OPPORTUNITY, + etag: { value: 'abc', weak: false }, + cacheControl: { directives: ['private', 'no-cache'] }, + notModified: false, + }), + }); + + const { body } = await dispatch(protocol, { type: 'object', name: 'crm_opportunity' }); + + expect(protocol.getMetaItemCached).toHaveBeenCalled(); + // The cached branch deliberately serves no `lock` — establishing that + // this test really is on the cached branch... + expect(body.lock).toBeUndefined(); + // ...where the sortability projection must STILL be present: it is + // computed from the served document, not from the lock resolver. + expect(body.sortability).toEqual(resolveObjectSortability(OPPORTUNITY)); + expect(body.sortability.fields.expected_revenue).toEqual({ + sortable: false, reason: 'virtual-type', + }); + expect(body.sortability.fields.amount).toEqual({ sortable: true }); + }); + + it('plural URL spelling reaches the same projection (type folding, #4432)', async () => { + const { body } = await dispatch(baseProtocol(), { type: 'objects', name: 'crm_opportunity' }); + expect(body.type).toBe('object'); + expect(body.sortability.fields.expected_revenue).toEqual({ + sortable: false, reason: 'virtual-type', + }); + }); + + it('a non-object read carries NO sortability key', async () => { + // The projection is an OBJECT-schema fact; a view read must not grow a + // vacuous `sortability: { fields: {} }` that consumers then read as + // "this view's columns are all unknown". + const { body } = await dispatch(baseProtocol(), { type: 'view', name: 'opportunity_all' }); + expect(body.item).toMatchObject({ name: 'opportunity_all' }); + expect(body.sortability).toBeUndefined(); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 1a02afaf33..94599a44ca 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -78,6 +78,12 @@ import type { ErrorCode } from '@objectstack/spec/api'; // TSDoc is its public statement (#6535). rest is the only enforcer, so it reads // that export rather than re-declaring the literal beside a "mirrors spec" comment. import { IMPORT_JOB_MAX_ROWS } from '@objectstack/spec/api'; +// [#10235] The per-column sortability projection the object read serves on its +// envelope (2026-08-23 ruling, option A): computed HERE, at the one seam every +// single-item read path rebuilds its body through, from the spec's own storage +// predicates — so the signal the grid reads cannot drift from what the runtime +// doors (#6994/#7095) refuse. +import { resolveObjectSortability } from '@objectstack/spec/api'; import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; import { PLURAL_TO_SINGULAR, canonicalMetaUrlType, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared'; import { stripReadDecorations } from '@objectstack/spec/kernel'; @@ -2592,8 +2598,22 @@ export class RestServer { document: any, i18nService?: any, ): Promise { + // [#10235] The per-column sortability projection, served beside the + // document whenever the document IS an object schema. Computed here — + // the one seam every single-item exit passes through (#5563: cached, + // uncached, compound-name) — so the signal cannot be a property of + // which branch answered; and computed from the FINAL document (post + // ADR-0106 masking), so its domain is exactly the field set this + // caller is served. Never inside `item`: `FieldSchema` is strict and + // the key must stay un-authorable (see `sortability.zod.ts`). + const sortability = + RestServer.metaTypeSingular(type) === 'object' + && document && typeof document === 'object' && !Array.isArray(document) + ? { sortability: resolveObjectSortability(document) } + : {}; return { ...envelope, + ...sortability, item: await this.translateMetaItem(req, type, environmentId, document, i18nService), }; } diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index af93d4b8f3..9633dad573 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -342,12 +342,16 @@ "ExportRequest (type)", "ExportRequestParsed (type)", "ExportRequestSchema (const)", + "FIELD_SORTABLE_UNPROVISIONED_ANCHOR (const)", + "FIELD_UNSORTABLE_VIRTUAL_TYPE (const)", "FieldError (type)", "FieldErrorCode (type)", "FieldErrorSchema (const)", "FieldMappingEntry (type)", "FieldMappingEntryParsed (type)", "FieldMappingEntrySchema (const)", + "FieldSortability (type)", + "FieldSortabilitySchema (const)", "FileDownloadUrlResponse (type)", "FileDownloadUrlResponseParsed (type)", "FileDownloadUrlResponseSchema (const)", @@ -679,6 +683,8 @@ "ObjectDefinitionResponse (type)", "ObjectDefinitionResponseParsed (type)", "ObjectDefinitionResponseSchema (const)", + "ObjectSortability (type)", + "ObjectSortabilitySchema (const)", "OpenApiGenerationConfig (type)", "OpenApiGenerationConfigParsed (type)", "OpenApiGenerationConfigSchema (const)", @@ -1005,6 +1011,7 @@ "normalizeEndpointPath (function)", "readServiceSelfInfo (function)", "resolveDiscoveryEnvironment (function)", + "resolveObjectSortability (function)", "standardErrorCodeForHttpStatus (function)", "standardSynonymOf (function)", "standardSynonymViolations (function)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index f6233734f2..feafaf7eb4 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -620,6 +620,9 @@ "api/FieldMappingEntry:targetField", "api/FieldMappingEntry:targetLabel", "api/FieldMappingEntry:transform", + "api/FieldSortability:caveat", + "api/FieldSortability:reason", + "api/FieldSortability:sortable", "api/FileDownloadUrlResponse:data", "api/FileDownloadUrlResponse:error", "api/FileDownloadUrlResponse:meta", @@ -756,6 +759,7 @@ "api/GetMetaItemResponse:packageVersion", "api/GetMetaItemResponse:provenance", "api/GetMetaItemResponse:resettable", + "api/GetMetaItemResponse:sortability", "api/GetMetaItemResponse:type", "api/GetMetaItemsRequest:organizationId", "api/GetMetaItemsRequest:packageId", @@ -1179,6 +1183,7 @@ "api/ObjectDefinitionResponse:error", "api/ObjectDefinitionResponse:meta", "api/ObjectDefinitionResponse:success", + "api/ObjectSortability:fields", "api/OpenApiGenerationConfig:apiVersion", "api/OpenApiGenerationConfig:contact", "api/OpenApiGenerationConfig:description", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index ac851d53c6..785387e75c 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -342,12 +342,16 @@ "ExportRequest": "src/api/contract.zod.ts#ExportRequest (type)", "ExportRequestParsed": "src/api/contract.zod.ts#ExportRequestParsed (type)", "ExportRequestSchema": "src/api/contract.zod.ts#ExportRequestSchema (const)", + "FIELD_SORTABLE_UNPROVISIONED_ANCHOR": "src/api/sortability.zod.ts#FIELD_SORTABLE_UNPROVISIONED_ANCHOR (const)", + "FIELD_UNSORTABLE_VIRTUAL_TYPE": "src/api/sortability.zod.ts#FIELD_UNSORTABLE_VIRTUAL_TYPE (const)", "FieldError": "src/api/errors.zod.ts#FieldError (type)", "FieldErrorCode": "src/api/errors.zod.ts#FieldErrorCode (type)", "FieldErrorSchema": "src/api/errors.zod.ts#FieldErrorSchema (const)", "FieldMappingEntry": "src/api/export.zod.ts#FieldMappingEntry (type)", "FieldMappingEntryParsed": "src/api/export.zod.ts#FieldMappingEntryParsed (type)", "FieldMappingEntrySchema": "src/api/export.zod.ts#FieldMappingEntrySchema (const)", + "FieldSortability": "src/api/sortability.zod.ts#FieldSortability (type)", + "FieldSortabilitySchema": "src/api/sortability.zod.ts#FieldSortabilitySchema (const)", "FileDownloadUrlResponse": "src/api/storage.zod.ts#FileDownloadUrlResponse (type)", "FileDownloadUrlResponseParsed": "src/api/storage.zod.ts#FileDownloadUrlResponseParsed (type)", "FileDownloadUrlResponseSchema": "src/api/storage.zod.ts#FileDownloadUrlResponseSchema (const)", @@ -679,6 +683,8 @@ "ObjectDefinitionResponse": "src/api/metadata.zod.ts#ObjectDefinitionResponse (type)", "ObjectDefinitionResponseParsed": "src/api/metadata.zod.ts#ObjectDefinitionResponseParsed (type)", "ObjectDefinitionResponseSchema": "src/api/metadata.zod.ts#ObjectDefinitionResponseSchema (const)", + "ObjectSortability": "src/api/sortability.zod.ts#ObjectSortability (type)", + "ObjectSortabilitySchema": "src/api/sortability.zod.ts#ObjectSortabilitySchema (const)", "OpenApiGenerationConfig": "src/api/plugin-rest-api.zod.ts#OpenApiGenerationConfig (type)", "OpenApiGenerationConfigParsed": "src/api/plugin-rest-api.zod.ts#OpenApiGenerationConfigParsed (type)", "OpenApiGenerationConfigSchema": "src/api/plugin-rest-api.zod.ts#OpenApiGenerationConfigSchema (const)", @@ -1005,6 +1011,7 @@ "normalizeEndpointPath": "src/api/endpoint.zod.ts#normalizeEndpointPath (function)", "readServiceSelfInfo": "src/api/discovery.zod.ts#readServiceSelfInfo (function)", "resolveDiscoveryEnvironment": "src/api/discovery.zod.ts#resolveDiscoveryEnvironment (function)", + "resolveObjectSortability": "src/api/sortability.zod.ts#resolveObjectSortability (function)", "standardErrorCodeForHttpStatus": "src/api/errors.zod.ts#standardErrorCodeForHttpStatus (function)", "standardSynonymOf": "src/api/error-code-ledger.zod.ts#standardSynonymOf (function)", "standardSynonymViolations": "src/api/error-code-ledger.zod.ts#standardSynonymViolations (function)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index d2d8bbef4f..9ad4ca4f19 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -146,6 +146,7 @@ "api/FieldError", "api/FieldErrorCode", "api/FieldMappingEntry", + "api/FieldSortability", "api/FileDownloadUrlResponse", "api/FileTypeValidation", "api/FileUploadResponse", @@ -282,6 +283,7 @@ "api/ODataQueryAdapter", "api/ODataResponse", "api/ObjectDefinitionResponse", + "api/ObjectSortability", "api/OpenApiGenerationConfig", "api/OpenApiSecurityScheme", "api/OpenApiServer", diff --git a/packages/spec/src/api/index.ts b/packages/spec/src/api/index.ts index 49c1374cee..d17481a7bf 100644 --- a/packages/spec/src/api/index.ts +++ b/packages/spec/src/api/index.ts @@ -46,6 +46,10 @@ export * from './errors.zod'; export { zodIssuesToFields } from './zod-issues-to-fields'; export * from './error-code-ledger.zod'; export * from './protocol.zod'; +// [#10235] Per-column sortability projection: the serve-time signal the object +// metadata read publishes so grids never re-derive "virtual ⇒ unsortable" from +// field type (2026-08-23 ruling, option A). +export * from './sortability.zod'; export * from './rest-server.zod'; // `./registry.zod` (the `ApiRegistry` / `ApiEndpointRegistration` family) was // RETIRED in #4939 — ADR-0049 enforce-or-remove. It was assembled only in diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 7557ab413a..333bcf526e 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -32,6 +32,9 @@ import { MetadataProvenanceSchema, } from '../kernel/metadata-protection.zod'; import { MetadataValidationResultSchema } from '../kernel/metadata-plugin.zod'; +// [#10235] The per-column sortability projection the object read serves on its +// envelope — computed at serve time, never authorable; see sortability.zod.ts. +import { ObjectSortabilitySchema } from './sortability.zod'; import { ListPackagesRequestSchema, ListPackagesResponseSchema, @@ -395,11 +398,25 @@ const MetadataProtectionEnvelopeFields = { * ⚠️ This is a DECLARATION change only — zero runtime behaviour is altered. That * lock presence depends on a server-side cache setting is a separate, larger * question (#5950 says so explicitly) and is deliberately NOT decided here. + * + * [#10235] `sortability` is the one envelope key that is NOT part of the + * protection family: the per-column sortability projection, present exactly + * when the served type is `object` (every branch — cached included, unlike the + * protection keys — because it is computed from the served document itself, + * never from the lock resolver). See `sortability.zod.ts` for the category + * set and the consumer contract. */ export const GetMetaItemResponseSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), name: z.string().describe('Item name'), item: z.unknown().describe('Metadata item definition'), + sortability: ObjectSortabilitySchema.optional().describe( + 'Per-column sortability projection (#10235) — present exactly when `type` ' + + 'is `object`, on every serving branch. Computed at serve time from the ' + + 'served document via the spec\'s own storage predicates; consumers render ' + + 'sort affordances from this signal and never re-derive it from field ' + + '`type`. See `ObjectSortabilitySchema` for the closed category set.', + ), ...MetadataProtectionEnvelopeFields, })); diff --git a/packages/spec/src/api/sortability.test.ts b/packages/spec/src/api/sortability.test.ts new file mode 100644 index 0000000000..a2dc3b9b6b --- /dev/null +++ b/packages/spec/src/api/sortability.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#10235] The per-column sortability projection — the serve-time signal that + * replaces every consumer-side "virtual ⇒ unsortable" re-derivation (2026-08-23 + * ruling, option A). + * + * What is pinned, and why each pin is load-bearing: + * + * - **The projection tracks the predicate** — the unsortable verdict is looped + * over `SEARCH_VIRTUAL_TYPES` itself, so a driver growing a second virtual + * type widens the projection with NO edit here; and the two computed types + * that sort correctly (`summary` / `autonumber`, measured #6924) are pinned + * `sortable: true`, the over-widening that would refuse working sorts. + * - **Anti-vacuity** — ordinary persisted columns answer `sortable: true`, so + * the projection cannot go green by marking everything unsortable. + * - **The #7865 anchor caveat** — unprovisioned injected anchors on an + * ADR-0015 `external` object stay `sortable: true` (the doors ACCEPT the + * sort) and carry the caveat (the measured silent drop); an author-declared + * column of the same name is the author's and carries none. + * - **Wire validity** — every resolved projection parses under + * `ObjectSortabilitySchema`, the schema the REST envelope declares. + */ + +import { describe, it, expect } from 'vitest'; +import { + resolveObjectSortability, + ObjectSortabilitySchema, + FIELD_UNSORTABLE_VIRTUAL_TYPE, + FIELD_SORTABLE_UNPROVISIONED_ANCHOR, +} from './sortability.zod'; +import { SEARCH_VIRTUAL_TYPES } from '../data/search-fields'; + +/** The #10235 oracle shape: a formula column displayed in a shipped grid. */ +const OPPORTUNITY_LIKE = { + name: 'crm_opportunity', + fields: { + name: { type: 'text' }, + amount: { type: 'currency' }, + expected_revenue: { type: 'formula', expression: 'amount * probability / 100' }, + close_date: { type: 'date' }, + }, +}; + +describe('#10235 resolveObjectSortability — the closed category set', () => { + it('marks a formula column unsortable with the refusal-backed reason', () => { + const { fields } = resolveObjectSortability(OPPORTUNITY_LIKE); + expect(fields.expected_revenue).toEqual({ + sortable: false, + reason: FIELD_UNSORTABLE_VIRTUAL_TYPE, + }); + }); + + it('anti-vacuity: ordinary persisted columns are sortable', () => { + const { fields } = resolveObjectSortability(OPPORTUNITY_LIKE); + expect(fields.name).toEqual({ sortable: true }); + expect(fields.amount).toEqual({ sortable: true }); + expect(fields.close_date).toEqual({ sortable: true }); + }); + + it('tracks the spec predicate: EVERY virtual type is unsortable, with no local type list', () => { + // Loop over the predicate's own vocabulary, so widening + // `SEARCH_VIRTUAL_TYPES` widens this pin — and the projection — together. + expect(SEARCH_VIRTUAL_TYPES.size).toBeGreaterThan(0); + for (const vtype of SEARCH_VIRTUAL_TYPES) { + const { fields } = resolveObjectSortability({ + name: 'x', + fields: { v: { type: vtype } }, + }); + expect(fields.v).toEqual({ sortable: false, reason: FIELD_UNSORTABLE_VIRTUAL_TYPE }); + } + }); + + it('does NOT widen to the write contract: summary and autonumber sort correctly', () => { + // The `COMPUTED_VALUE_TYPES` trap `validate-sortable-fields` and the + // ingress door both document: `summary` is an engine-maintained float + // column, `autonumber` an engine-assigned string column — refusing them + // is the false finding that makes the signal untrustworthy. + const { fields } = resolveObjectSortability({ + name: 'x', + fields: { + total: { type: 'summary' }, + seq: { type: 'autonumber' }, + }, + }); + expect(fields.total).toEqual({ sortable: true }); + expect(fields.seq).toEqual({ sortable: true }); + }); + + it('appends the driver-provisioned id, and never clobbers a declared one', () => { + const { fields } = resolveObjectSortability(OPPORTUNITY_LIKE); + expect(fields.id).toEqual({ sortable: true }); + // Degenerate but declared: an author field named `id` keeps its own verdict. + const declared = resolveObjectSortability({ + name: 'x', + fields: { id: { type: 'formula' } }, + }); + expect(declared.fields.id).toEqual({ sortable: false, reason: FIELD_UNSORTABLE_VIRTUAL_TYPE }); + }); + + it('reads both served field-map shapes (record and array)', () => { + const arrayShape = resolveObjectSortability({ + name: 'x', + fields: [ + { name: 'plain', type: 'text' }, + { name: 'virtual', type: 'formula' }, + ], + }); + expect(arrayShape.fields.plain).toEqual({ sortable: true }); + expect(arrayShape.fields.virtual).toEqual({ sortable: false, reason: FIELD_UNSORTABLE_VIRTUAL_TYPE }); + }); +}); + +describe('#10235 the #7865 anchor category — accepted, caveated, never refused', () => { + /** An ADR-0015 external object as the registry serves it: injected anchors present. */ + const EXTERNAL = { + name: 'ext_customer', + external: { datasource: 'remote_pg', table: 'customers' }, + fields: { + name: { type: 'text' }, + // The platform's own injected anchors, byte-identical to the shipped + // definitions is not required for THIS derivation: absence from the + // authored map is the common pre-injection shape, and + // `unprovisionedInjectedColumns` answers from the document either way. + }, + }; + + it('caveats the unprovisioned injected anchors on an external object', () => { + const { fields } = resolveObjectSortability(EXTERNAL); + // The audit family and owner/tenant anchors are registered with no + // storage behind them (#7865): accepted by both doors, silently dropped + // by the driver when the remote lacks the column (#10474's measurement). + expect(fields.created_at).toEqual({ + sortable: true, + caveat: FIELD_SORTABLE_UNPROVISIONED_ANCHOR, + }); + expect(fields.owner_id).toEqual({ + sortable: true, + caveat: FIELD_SORTABLE_UNPROVISIONED_ANCHOR, + }); + // The author's own mapped column is plainly sortable — no caveat. + expect(fields.name).toEqual({ sortable: true }); + }); + + it('an author-DECLARED anchor name on an external object is the author\'s — no caveat', () => { + // #7859's direction: declaring the column vouches for the remote schema. + const { fields } = resolveObjectSortability({ + name: 'ext_customer', + external: { datasource: 'remote_pg', table: 'customers' }, + fields: { + created_at: { type: 'datetime', label: 'Remote creation time' }, + }, + }); + expect(fields.created_at).toEqual({ sortable: true }); + }); + + it('platform-provisioned objects carry no caveat on their anchors', () => { + const { fields } = resolveObjectSortability({ + name: 'local_obj', + fields: { + created_at: { type: 'datetime' }, + owner_id: { type: 'lookup' }, + }, + }); + expect(fields.created_at).toEqual({ sortable: true }); + expect(fields.owner_id).toEqual({ sortable: true }); + }); +}); + +describe('#10235 wire validity — the projection parses under its own schema', () => { + it.each([ + ['oracle-shaped object', OPPORTUNITY_LIKE], + ['external object', { + name: 'ext_customer', + external: { datasource: 'remote_pg', table: 'customers' }, + fields: { name: { type: 'text' } }, + }], + ['fieldless document', { name: 'bare' }], + ])('%s', (_label, doc) => { + const projection = resolveObjectSortability(doc); + const parsed = ObjectSortabilitySchema.safeParse(projection); + expect(parsed.success).toBe(true); + }); +}); diff --git a/packages/spec/src/api/sortability.zod.ts b/packages/spec/src/api/sortability.zod.ts new file mode 100644 index 0000000000..a454a928a4 --- /dev/null +++ b/packages/spec/src/api/sortability.zod.ts @@ -0,0 +1,221 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#10235] The per-column SORTABILITY projection served with object metadata — + * the one signal a grid reads to decide whether a column header offers a sort + * click, ruled 2026-08-23 (option A on #10235): the platform serves an explicit + * signal, and no consumer re-derives "virtual ⇒ unsortable" from field type. + * + * ## Why a served projection, and why it is NOT an authorable key + * + * The shipped grids offered sort clicks the platform cannot honor: a sort on a + * `formula` column returned `asc` and `desc` in byte-identical order under a + * 200 (#6994's measurement), and since #9313/#10234 the same click is refused + * loudly (`400 INVALID_SORT`) when the console persists it. The grid needs to + * know *before offering the click* — but teaching it "formula means + * unsortable" would re-implement the runtime's predicate one repo away, the + * shadow-copy drift this ruling exists to end. + * + * So the signal is COMPUTED at serve time from the same spec predicates the + * runtime doors and the authoring linter already read, and served beside the + * document on the `GET /meta/:type/:name` envelope. It is deliberately not a + * key inside the document: `FieldSchema` is `strictObject`, so an undeclared + * key on a served field is rejected by name — and declaring it would make it + * AUTHORABLE, handing authors a switch the runtime does not read (the exact + * declared-≠-enforced shape `resolveInjectedColumnProvenance`'s docblock + * records as deliberately rejected). + * + * ## The category set, enumerated (closed against the runtime doors) + * + * "Unsortable" is judged by what the two runtime doors — + * `assertSortFieldsExist` (`@objectstack/metadata-protocol`, #6994) and + * `assertOrderByIsMaterializable` (`@objectstack/objectql`, #7095) — actually + * do with an `orderBy` over the name. Three verdicts are REFUSALS, and one + * measured degradation is not refused; the projection covers all four: + * + * 1. **Unknown name** — not a field of the object. Refused (`400 + * INVALID_SORT`). Encoded as ABSENCE: the projection's domain is exactly + * the served field map plus the always-provisioned `id`, so a name with no + * entry has no platform sort behind it and gets no affordance. + * 2. **Dotted path** (`account.name`) — crosses into a related record no + * driver joins for. Refused. Encoded as absence too: entries are keyed by + * whole-column field names, and a dotted name can never appear as one. + * 3. **Virtual type** ({@link isVirtualSearchField} / `SEARCH_VIRTUAL_TYPES` + * — exactly `formula` today) — computed on read, no driver materialises a + * column. Refused at both doors. Encoded as `sortable: false` with + * `reason: 'virtual-type'`. This is the ONE per-field refusal fact, and it + * is judged by the same spec predicate the linter + * (`validate-sortable-fields`) and the search axis read — never by a local + * type list. + * 4. **Unprovisioned injected anchor** ({@link unprovisionedInjectedColumns} + * — the platform's own injected columns on an ADR-0015 `external` object, + * which registers them and provisions no storage). NOT refused: both doors + * key on `formula` alone, so the sort reaches the driver, finds no column, + * and is silently dropped — measured (#10474) as `asc` === `desc` under a + * 200 while a real column reverses. The platform cannot *prove* the verdict + * either way (the remote table may genuinely carry a column of that name), + * so the entry stays `sortable: true` — the enforcement fact — and carries + * `caveat: 'unprovisioned-anchor'` so a consumer can choose a conservative + * affordance without re-deriving federation from the document. + * + * ## Considered and deliberately NOT members + * + * - `summary` / `autonumber` — the other two `COMPUTED_VALUE_TYPES`. They sort + * CORRECTLY (`summary` is an engine-maintained `table.float`, `autonumber` + * an engine-assigned `table.string`; measured on #6924), which is exactly + * why virtuality is judged by the storage predicate and never by the write + * contract — widening would refuse the two types that work. + * - `encrypted` / `secret` / `json` / `vector` and the other heavy or masked + * types — every one has a stored column, neither door refuses an ORDER BY + * over one, and the drivers execute it. Marking them unsortable here would + * invent a refusal the runtime does not enforce: the mirror image of the + * declared-≠-enforced drift this signal exists to end. If any of them should + * be refused, that is a runtime-door decision first, and this projection + * follows it automatically through the shared predicate. + * - `created_at` / `updated_at` on an object that opted out of audit injection + * (`systemFields: false`): the ingress gate hard-admits both names, but no + * column is provisioned. They are simply absent from the served field map, + * so they get no entry — the projection is allowed to be NARROWER than the + * gate where the gate itself is known to degrade. + * + * ## Contract for consumers (the objectui grid is the first) + * + * Offer a sort affordance on a column iff the projection has an entry for the + * column's field name and that entry says `sortable: true`. Absence means "no + * platform sort behind this name" — never "assume sortable". Do not recompute + * any of this from field `type` client-side; the verdicts here are derived + * from the same predicates the runtime enforces with, which is the whole + * point (the same "derived verdict — do not recompute" contract the + * protection envelope's `editable` carries). + */ + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; +import { isVirtualSearchField, type SearchFieldMeta } from '../data/search-fields'; +import { unprovisionedInjectedColumns } from '../data/injected-system-column-provenance'; + +/** + * The one refusal-backed per-field reason: the field's type is virtual + * (computed on read, no stored column — `SEARCH_VIRTUAL_TYPES`), so both + * runtime doors refuse an ORDER BY over it with `400 INVALID_SORT`. + */ +export const FIELD_UNSORTABLE_VIRTUAL_TYPE = 'virtual-type' as const; + +/** + * The one advisory caveat: the field is a platform-injected anchor on an + * ADR-0015 `external` object, registered with no storage behind it (#7865 / + * #10474). The sort is ACCEPTED by the runtime but is silently dropped when + * the remote table carries no such column — a degradation the platform cannot + * prove either way at serve time. + */ +export const FIELD_SORTABLE_UNPROVISIONED_ANCHOR = 'unprovisioned-anchor' as const; + +/** + * Sortability verdict for ONE field of a served object document. + */ +export const FieldSortabilitySchema = lazySchema(() => z.object({ + sortable: z.boolean().describe( + '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.', + ), + reason: z.literal(FIELD_UNSORTABLE_VIRTUAL_TYPE).optional().describe( + '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.', + ), + caveat: z.literal(FIELD_SORTABLE_UNPROVISIONED_ANCHOR).optional().describe( + '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.', + ), +})); + +// ADR-0122: the bare alias names the AUTHOR (input) state. This schema has no +// defaults or transforms, so input and parsed coincide — the bare name is the +// only alias the surface needs. +export type FieldSortability = z.input; + +/** + * The per-column sortability projection for one object — served on the + * `GET /meta/:type/:name` envelope (beside `item`, never inside it) when the + * type is `object`. See the module docblock for the closed category set and + * the consumer contract. + */ +export const ObjectSortabilitySchema = lazySchema(() => z.object({ + fields: z.record(z.string(), FieldSortabilitySchema).describe( + '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.', + ), +})); + +// ADR-0122: bare = input state; no transforms here, so it is also the wire shape. +export type ObjectSortability = z.input; + +/** Tolerant field-map reader — accepts both served `fields` shapes. */ +function fieldEntriesOf(doc: unknown): Array<[string, SearchFieldMeta | undefined]> { + if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return []; + const fields = (doc as { fields?: unknown }).fields; + if (Array.isArray(fields)) { + // The array shape carries the name inside each entry. + const entries: Array<[string, SearchFieldMeta | undefined]> = []; + for (const f of fields) { + if (!f || typeof f !== 'object') continue; + const name = (f as { name?: unknown }).name; + if (typeof name === 'string' && name.length > 0) { + entries.push([name, f as SearchFieldMeta]); + } + } + return entries; + } + if (!fields || typeof fields !== 'object') return []; + return Object.entries(fields as Record).map( + ([name, meta]) => [name, (meta && typeof meta === 'object' ? meta : undefined) as SearchFieldMeta | undefined], + ); +} + +/** + * Derive the sortability projection for one object document. + * + * PURE and tolerant of bare / un-parsed metadata records (the same contract + * every derivation in `data/injected-system-column-provenance.ts` carries), so + * the REST serving layer can run it on the exact document it is about to serve + * — post-masking, post-injection — and the projection's domain equals what the + * caller can see. Judged by the spec's own predicates and nothing else: + * + * - virtuality by {@link isVirtualSearchField} (`SEARCH_VIRTUAL_TYPES`) — the + * same storage fact the runtime doors (#6994/#7095) and the authoring linter + * (`validate-sortable-fields`) key on; + * - anchor provenance by {@link unprovisionedInjectedColumns} — the #7865 + * derivation the runtime guards and the linter converge on. + * + * `id` is appended when the document declares no field of that name: the + * primary key is provisioned by the DRIVER (never authored, never injected), + * and the ingress gate admits it unconditionally, so a grid column over it is + * genuinely sortable. + */ +export function resolveObjectSortability(doc: unknown): ObjectSortability { + const fields: Record = {}; + for (const [name, meta] of fieldEntriesOf(doc)) { + fields[name] = isVirtualSearchField(meta) + ? { sortable: false, reason: FIELD_UNSORTABLE_VIRTUAL_TYPE } + : { sortable: true }; + } + // The #7865 anchors with no storage: still accepted by the doors, so the + // enforcement fact stays `sortable: true` — the caveat is the measured + // degradation. `unprovisionedInjectedColumns` already excludes an + // author-declared column of the same name (the author vouches for the + // remote column — #7859's direction), and an injected anchor is never a + // virtual type, so this only ever annotates a `sortable: true` entry. + for (const name of unprovisionedInjectedColumns(doc)) { + fields[name] = { sortable: true, caveat: FIELD_SORTABLE_UNPROVISIONED_ANCHOR }; + } + if (fields.id === undefined) fields.id = { sortable: true }; + return { fields }; +} diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 38a7ecf190..8bbe89c848 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -263,9 +263,11 @@ import type * as M167 from './ui/view.zod.js'; // one. #5775 is the first entry from this file — `component.zod.ts` had no bare // `X = z.infer` alias until `PageContainerProps` arrived. import type * as M170 from './ui/component.zod.js'; +// [#10235] The served sortability projection — new module, next free index. +import type * as M183 from './api/sortability.zod.js'; // --------------------------------------------------------------------------- -// 834 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 836 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1590,6 +1592,12 @@ export type Iso851 = Assert, z.in export type Iso830 = Assert, z.infer< typeof M167.NavigationModeSchema > >>; export type Iso831 = Assert, z.infer< typeof M167.TreeConfigSchema > >>; export type Iso832 = Assert, z.infer< typeof M167.ViewItemNameSchema > >>; + +// [#10235] api/sortability.zod.ts — the served projection carries no defaults +// or transforms by design (it is a serve-time computation, never parsed from +// an author), so input and parsed coincide and the bare aliases stand alone. +export type Iso854 = Assert, z.infer< typeof M183.FieldSortabilitySchema > >>; +export type Iso855 = Assert, z.infer< typeof M183.ObjectSortabilitySchema > >>; // --------------------------------------------------------------------------- // Representative spot-checks on the phase-2 FLIP. // @@ -1658,7 +1666,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 834 isomorphic pins', () => { + it('still declares all 836 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -1932,7 +1940,7 @@ describe('ADR-0122 type-alias convention', () => { // The Iso numbers are positional and stay vacant. const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert