diff --git a/.changeset/27627-console-record-page-payload.md b/.changeset/27627-console-record-page-payload.md new file mode 100644 index 00000000000..3f5f7899cec --- /dev/null +++ b/.changeset/27627-console-record-page-payload.md @@ -0,0 +1,24 @@ +--- +'@object-ui/types': minor +'@object-ui/data-objectstack': minor +'@object-ui/components': minor +'@object-ui/plugin-detail': minor +--- + +feat(console): a record page opens from one request, and the record a drawer step shows next is asked ahead (#27627) + +Clause-②: yes (widening: optional `DataSource.getRecordPage` / `prefetchRecordPage`, new exports; no existing contract changes) +Metadata-protocol: added + +The record page (full page, split pane and drawer) asks `DataSource.getRecordPage` once when it opens, before any of its readers runs, and hands each part of the answer to the reader that used to ask for it: + +- the record, with the lookups the page resolves; +- each related list's total and first rows: the tab badges, collapsed and compact related cards and reference rail cards take them, so no `top=1` count probe and no separate first-rows read goes out; +- the header's `update` / `delete` verdicts and Claim's `transfer` decision, the in-place edit blocks' field projection, and the approval rows and lock. + +Readers still read for themselves after the open (a refresh, a write, a change on the data-invalidation bus), and a page on an adapter without `getRecordPage` reads as before. Lists the page cannot name in the request stay their own read: a list declared through a list view, a list whose filter carries a context token, and lists past the fiftieth. In the drawer, the record a ↑ / ↓ step shows next is asked ahead (`DataSource.prefetchRecordPage`), so stepping opens from an answer already on its way; on such an adapter that replaces the drawer's warm-up of the next record's related counts. + +- `@object-ui/types`: `DataSource.getRecordPage?` / `prefetchRecordPage?` and the `RecordPageRequest` / `RecordPagePayload` shapes. +- `@object-ui/data-objectstack`: both methods; concurrent identical reads share one request, a prefetched answer is taken once, and a write drops them all. +- `@object-ui/components`: `RelatedCountStore.seed` / `takeRows`. +- `@object-ui/plugin-detail`: `seedRecordVerdict`, `seedRecordFieldAccess`, `seedQueueClaimDecision`, `normalizeRelatedListSort`, `COMPACT_RELATED_ROWS`; a compact related card takes its first rows from the record page's answer. Its `sideEffects` array now names `queueClaim` and `recordFieldAccess` too: each drops a seeded answer on a data change through a bus subscription made at load. diff --git a/.changeset/27627-record-page-payload.md b/.changeset/27627-record-page-payload.md new file mode 100644 index 00000000000..119c6865f97 --- /dev/null +++ b/.changeset/27627-record-page-payload.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': minor +'@objectstack/rest': minor +'@objectstack/client': minor +--- + +feat(rest): `POST /api/v1/data/{object}/{id}/page` answers everything a record page asks when it opens, in one request (#27627) + +Clause-②: yes (widening: a new route and its request and response shapes; no existing route, key or answer changes) +Metadata-protocol: added + +A record page used to open in two or three stages of requests: the record, the approval list, the access verdicts and each related list's definition first, then the approval lock and one count probe per related list. The new route answers all of it from one request: + +- **The request names the parts** (`RecordPageRequestSchema`, `@objectstack/spec/api`): `expand` (the record's lookups to resolve), `related` (each related list the page draws: the child object, its relationship field to this record, an optional declared `filter`, `top` first rows, an optional `sort`), `explain` (`update` / `delete` / `transfer`), `fieldAccess` and `approvals`. The body is strict and the query-parameter set is closed and empty. +- **Every part is the read it replaces, as the same caller** (`RecordPageResponseSchema`): the record is `GET /data/{object}/{id}`; a related list is `GET /data/{child}` with the parent condition AND the declared filter, and `$count`; `fieldAccess` is `GET /data/{object}/{id}/field-access`; a verdict is `POST /security/explain` with the record id; `approvals` is `GET /approvals/requests` for the record (the pending row enriched as `GET /approvals/requests/{id}`) and the record lock. Object permissions, row-level security, sharing and field-level security therefore apply exactly as they apply to those reads. +- **A refusal stays where it belongs.** A record the caller cannot read refuses the whole request with the record read's own answer. A part whose read refuses carries that refusal (`{ code, status, message }`) in place of its answer, and the other parts are answered; a related list the caller may not read carries neither a count nor rows. +- **A related entry must be a relationship to this record**: a lookup or master-detail field of the child that references the page's object, or the request is a located `400` before anything is read. +- **Server-Timing**: the route's metadata reads are timed as a `meta` span beside `db` and `total`, under the existing gate (`OS_SERVER_TIMING=true`, or `X-OS-Debug-Timing: 1` from an admin). +- `@objectstack/client` gains `data.recordPage(object, id, request)`. diff --git a/content/docs/references/api/index.mdx b/content/docs/references/api/index.mdx index c3fb44b2172..668533eddd1 100644 --- a/content/docs/references/api/index.mdx +++ b/content/docs/references/api/index.mdx @@ -1,7 +1,7 @@ --- title: API Protocol — complete schema reference navTitle: API Protocol -description: "The ObjectStack API Protocol in 32 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." +description: "The ObjectStack API Protocol in 33 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -35,6 +35,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 3a31ec00c46..a5e7903f7d4 100644 --- a/content/docs/references/api/meta.json +++ b/content/docs/references/api/meta.json @@ -36,6 +36,7 @@ "misc", "package-api-assembled", "package-lifecycle", + "record-page", "sortability" ] } \ No newline at end of file diff --git a/content/docs/references/api/record-page.mdx b/content/docs/references/api/record-page.mdx new file mode 100644 index 00000000000..14fb3a9dd7d --- /dev/null +++ b/content/docs/references/api/record-page.mdx @@ -0,0 +1,378 @@ +--- +title: Record Page schema — API Protocol reference +navTitle: Record Page +description: "Record page payload — POST /api/v1/data/{object}/{id}/page. Reference for RecordPageApprovals and 10 more: every property with its type and default." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Record page payload — `POST /api/v1/data/{object}/{id}/page`. + +One request that answers everything a record page asks of the server when it +opens: the record (with the lookups it names resolved), the count and first +rows of every related list the page draws, the caller's field projection of +the record, the approval state that locks it, and the record-grained access +verdicts its header actions are gated on. + +The request NAMES the parts. Which related lists a page draws (their order, +their declared scope, which of them the reader may see) is the page's own +derivation, so the page sends the lists and the server answers each one; the +server never derives a list of its own. What the server owns is the parent +condition: each list is the child records whose `field` points at this +record, compiled to the field's arity, and AND-composed with the list's +declared `filter` — a declared filter can only narrow it. + +Every part is the answer of the read it replaces, asked as the same caller: +the record is `GET /data/{object}/{id}`, a related list is +`GET /data/{child}` with that filter and `$count`, `fieldAccess` is +`GET /data/{object}/{id}/field-access`, `explain` is +`POST /security/explain` with the record id, and `approvals` is +`GET /approvals/requests` for the record plus +`GET /approvals/records/{object}/{id}/lock`. Object permissions, row-level +security, sharing and field-level security therefore apply exactly as they +apply to those reads. A part whose read refuses carries that refusal in place +of an answer — a related list the caller may not read carries neither a +count nor rows — and the other parts are answered. A record the caller +cannot read refuses the whole request with the record read's own answer, so +no part discloses a record the caller could not open. + + +**Source:** `packages/spec/src/api/record-page.zod.ts` + + +## TypeScript Usage + +```typescript +import { RecordPageApprovalsSchema, RecordPageExplainAnswerSchema, RecordPageExplainOperationSchema, RecordPagePartErrorSchema, RecordPagePartRefusalSchema, RecordPageRelatedAnswerSchema, RecordPageRelatedRefusalSchema, RecordPageRelatedRequestSchema, RecordPageRelatedRowsSchema, RecordPageRequestSchema, RecordPageResponseSchema } from '@objectstack/spec/api'; +import type { RecordPageApprovals, RecordPageExplainAnswer, RecordPageExplainOperation, RecordPagePartError, RecordPagePartRefusal, RecordPageRelatedAnswer, RecordPageRelatedRefusal, RecordPageRelatedRequest, RecordPageRelatedRows, RecordPageRequest, RecordPageResponse } from '@objectstack/spec/api'; + +// Validate data +const result = RecordPageApprovalsSchema.parse(data); +``` + +--- + +## RecordPageApprovals + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **requests** | `Record[]` | ✅ | The record's approval requests the caller may see, as `GET /approvals/requests` lists them for the record; empty when the deployment runs no approval service. | +| **lock** | `{ locked: boolean } \| null` | ✅ | Whether a locking approval holds the record; null when the deployment answers no lock. | + + +--- + +## RecordPageExplainAnswer + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **allowed** | `boolean` | ✅ | | +| **object** | `string` | ✅ | | +| **operation** | `Enum<'read' \| 'create' \| 'update' \| 'delete' \| 'transfer' \| 'restore' \| 'purge' \| 'export'>` | ✅ | | +| **principal** | `{ userId: string \| null; positions?: string[]; permissionSets?: string[]; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>; … }` | ✅ | | +| **layers** | `{ layer: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …>; kernelTier?: Enum<'layer_0_tenant' \| 'layer_1_business'>; verdict: Enum<'grants' \| 'denies' \| 'narrows' \| 'widens' \| 'neutral' \| 'not_applicable'>; detail: string; … }[]` | ✅ | | +| **readFilter** | `any` | optional | The composed row filter the caller would be served with — the machine artifact behind the prose (null = unrestricted). Two shapes mean zero rows — `{ id: "__deny_all__" }` and the fail-closed RLS denial (`__rls_deny__` plus a colon and a UUID-shaped suffix, which can also ride inside an $and composite) — so a consumer pattern-matching this payload alone must match both; the decision itself is allowed plus the rls layer verdict. | +| **record** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …> }` | optional | Row-level verdict for the specific record; set only for singular record-grained requests. An id the asker (the caller of explain) cannot read answers as missing, like a filtered, deleted or never-existed id: visible false, decidedBy omitted. Explaining another principal, the asker cannot read an id its own by-id read finds no row for or refuses; a principal explaining itself cannot read an id its read finds no row for. | +| **records** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …> }[]` | optional | Per-record verdicts for a batch request — records[i] answers recordIds[i]; set only when the request carried recordIds. | + +### Nested Shape: `RecordPageExplainAnswer[option 1].principal` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **userId** | `string \| null` | ✅ | | +| **positions** | `string[]` | optional (default: `[]`) | | +| **permissionSets** | `string[]` | optional (default: `[]`) | | +| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | +| **onBehalfOf** | `{ userId: string }` | optional | | +| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL (an external organization member) reaches explicit grants only — shares, sharing rules, and grant RLS — never the OWD baseline. | + +### Nested Shape: `RecordPageExplainAnswer[option 1].layers[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **layer** | `Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …>` | ✅ | | +| **kernelTier** | `Enum<'layer_0_tenant' \| 'layer_1_business'>` | optional | ADR-0095 kernel layer: layer_0_tenant = the always-first org wall; layer_1_business = business RLS/sharing/ownership. | +| **verdict** | `Enum<'grants' \| 'denies' \| 'narrows' \| 'widens' \| 'neutral' \| 'not_applicable'>` | ✅ | | +| **detail** | `string` | ✅ | | +| **contributors** | `{ kind: Enum<'permission_set' \| 'position' \| 'system'>; name: string; via?: string; state?: Enum<'active' \| 'expired' \| 'deactivated'> }[]` | optional (default: `[]`) | | +| **record** | `{ outcome: Enum<'admitted' \| 'excluded' \| 'not_evaluated'>; rowFilter?: any; matchesRecord?: boolean; rules?: object[]; … }` | optional | Row-level determination for the specific record under explanation; set only for record-grained requests. | + +### Nested Shape: `RecordPageExplainAnswer[option 1].record` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **recordId** | `string` | ✅ | The concrete record id this verdict is about (echoes the request recordId or recordIds[i]). | +| **visible** | `boolean` | ✅ | Whether the operation is permitted on this specific record after all layers. | +| **decidedBy** | `Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …>` | optional | The pipeline layer that decided the record-level outcome (excluded it, or last admitted it); omitted for a missing record. | + +### Nested Shape: `RecordPageExplainAnswer[option 1].records[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **recordId** | `string` | ✅ | The concrete record id this verdict is about (echoes the request recordId or recordIds[i]). | +| **visible** | `boolean` | ✅ | Whether the operation is permitted on this specific record after all layers. | +| **decidedBy** | `Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …>` | optional | The pipeline layer that decided the record-level outcome (excluded it, or last admitted it); omitted for a missing record. | + +--- + +#### Option 2 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **error** | `{ code: string; status: integer; message: string }` | ✅ | The refusal the replaced read answers, in place of this part's answer. | + +### Nested Shape: `RecordPageExplainAnswer[option 2].error` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `string` | ✅ | The error code the replaced read answers. | +| **status** | `integer` | ✅ | The HTTP status the replaced read answers. | +| **message** | `string` | ✅ | The message the replaced read answers. | + +--- + + +--- + +## RecordPageExplainOperation + +### Allowed Values + +* `update` +* `delete` +* `transfer` + + +--- + +## RecordPagePartError + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **error** | `{ code: string; status: integer; message: string }` | ✅ | The refusal the replaced read answers, in place of this part's answer. | + +### Nested Shape: `RecordPagePartError.error` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `string` | ✅ | The error code the replaced read answers. | +| **status** | `integer` | ✅ | The HTTP status the replaced read answers. | +| **message** | `string` | ✅ | The message the replaced read answers. | + + +--- + +## RecordPagePartRefusal + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `string` | ✅ | The error code the replaced read answers. | +| **status** | `integer` | ✅ | The HTTP status the replaced read answers. | +| **message** | `string` | ✅ | The message the replaced read answers. | + + +--- + +## RecordPageRelatedAnswer + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | The child object, as the request named it. | +| **field** | `string` | ✅ | The relationship field, as the request named it. | +| **total** | `integer` | ✅ | How many child records the list holds for this caller. | +| **records** | `Record[]` | ✅ | The list's first rows, as the caller may read them. | + +--- + +#### Option 2 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | The child object, as the request named it. | +| **field** | `string` | ✅ | The relationship field, as the request named it. | +| **error** | `{ code: string; status: integer; message: string }` | ✅ | The refusal of the list's read. | + +### Nested Shape: `RecordPageRelatedAnswer[option 2].error` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `string` | ✅ | The error code the replaced read answers. | +| **status** | `integer` | ✅ | The HTTP status the replaced read answers. | +| **message** | `string` | ✅ | The message the replaced read answers. | + +--- + + +--- + +## RecordPageRelatedRefusal + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | The child object, as the request named it. | +| **field** | `string` | ✅ | The relationship field, as the request named it. | +| **error** | `{ code: string; status: integer; message: string }` | ✅ | The refusal of the list's read. | + +### Nested Shape: `RecordPageRelatedRefusal.error` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `string` | ✅ | The error code the replaced read answers. | +| **status** | `integer` | ✅ | The HTTP status the replaced read answers. | +| **message** | `string` | ✅ | The message the replaced read answers. | + + +--- + +## RecordPageRelatedRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | The child object whose records the list shows. | +| **field** | `string` | ✅ | The relationship field on the child object that points at this record (a lookup or master-detail field referencing the page's object). The list is the child records whose field holds this record's id. | +| **filter** | `any` | optional | The list's own declared scope, in the filter syntax `GET /data/{object}` accepts. AND-composed with the parent condition, so it can only narrow the list. A filter that syntax refuses refuses the whole request (a located 400 at `related.N.filter`) before anything is read. | +| **top** | `integer` | ✅ | How many of the list's first rows to return with its count; 0 returns the count alone. | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | The order of the list's first rows, as `GET /data/{object}` orders them by `$orderby`. Absent for the default order. | + +### Nested Shape: `RecordPageRelatedRequest.sort[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | A field of the child object. | +| **order** | `Enum<'asc' \| 'desc'>` | ✅ | The direction. | + + +--- + +## RecordPageRelatedRows + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | The child object, as the request named it. | +| **field** | `string` | ✅ | The relationship field, as the request named it. | +| **total** | `integer` | ✅ | How many child records the list holds for this caller. | +| **records** | `Record[]` | ✅ | The list's first rows, as the caller may read them. | + + +--- + +## RecordPageRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **expand** | `string[]` | optional | Lookup fields of the record to resolve, as `GET /data/{object}/{id}?expand=` resolves them. | +| **related** | `{ object: string; field: string; filter?: any; top: integer; … }[]` | optional | The related lists the page draws, in the order it draws them. Each is answered with its count and first rows. | +| **explain** | `Enum<'update' \| 'delete' \| 'transfer'>[]` | optional | The operations to answer a record-grained access verdict for, as `POST /security/explain` answers them with this record id. | +| **fieldAccess** | `boolean` | optional | Return the caller's field projection of the record, as `GET /data/{object}/{id}/field-access` does. | +| **approvals** | `boolean` | optional | Return the record's approval requests and its approval lock. | + +### Nested Shape: `RecordPageRequest.related[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | The child object whose records the list shows. | +| **field** | `string` | ✅ | The relationship field on the child object that points at this record (a lookup or master-detail field referencing the page's object). The list is the child records whose field holds this record's id. | +| **filter** | `any` | optional | The list's own declared scope, in the filter syntax `GET /data/{object}` accepts. AND-composed with the parent condition, so it can only narrow the list. A filter that syntax refuses refuses the whole request (a located 400 at `related.N.filter`) before anything is read. | +| **top** | `integer` | ✅ | How many of the list's first rows to return with its count; 0 returns the count alone. | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | The order of the list's first rows, as `GET /data/{object}` orders them by `$orderby`. Absent for the default order. | + + +--- + +## RecordPageResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | The record's object. | +| **id** | `string` | ✅ | The record id. | +| **record** | `Record` | ✅ | The record, as `GET /data/{object}/{id}` answers it. | +| **related** | `({ object: string; field: string; total: integer; records: Record[] } \| { object: string; field: string; error: object })[]` | ✅ | One answer per requested related list, in request order. | +| **fieldAccess** | `{ object: string; recordId: string; readable: string[]; editable: string[]; … } \| { error: object }` | optional | The field projection, when the request asked for it. | +| **explain** | `{ update?: object \| object; delete?: object \| object; transfer?: object \| object }` | optional | One access decision per requested operation, keyed by operation, when the request asked for any. | +| **approvals** | `{ requests: Record[]; lock: object \| null } \| { error: object }` | optional | The approval part, when the request asked for it. | + +### Nested Shape: `RecordPageResponse.related[number][option 1]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | The child object, as the request named it. | +| **field** | `string` | ✅ | The relationship field, as the request named it. | +| **total** | `integer` | ✅ | How many child records the list holds for this caller. | +| **records** | `Record[]` | ✅ | The list's first rows, as the caller may read them. | + +### Nested Shape: `RecordPageResponse.related[number][option 2]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | The child object, as the request named it. | +| **field** | `string` | ✅ | The relationship field, as the request named it. | +| **error** | `{ code: string; status: integer; message: string }` | ✅ | The refusal of the list's read. | + +### Nested Shape: `RecordPageResponse.fieldAccess[option 1]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | Object name | +| **recordId** | `string` | ✅ | The record the projection was judged on | +| **readable** | `string[]` | ✅ | Fields the caller may read on this object. Reads are judged row-blind, so this equals the object-wide read projection | +| **editable** | `string[]` | ✅ | Fields the caller may write on this record, as field-level security judges an update of the stored row: a row-conditioned field permission locks a field only on the rows its condition names. A field absent here is refused by the write gate. The object grant, the record's row-level write scope (sharing, row-level security, ownership; see `writable`) and a field's own rules (readonly, computed types) are not part of it | +| **writable** | `boolean` | ✅ | Whether the caller may update this record at all, judged by the write gate on the stored row: the object's edit permission, record sharing and ownership, row-level security, and a parent record's edit access for a record controlled by its parent. False means an update that leaves the record as it is stored is refused: either the caller may not edit this record, or the stored record fails a row-level security check that every save must satisfy, which a save moving the checked value back inside that check can still pass. True does not promise that a save succeeds: a pending approval that locks the record, conditional read-only and validation rules on the values sent, a data source that refuses writes, the platform's own rules for its own records (comments, attachments, mail messages, approval requests) and an object the API serves read-only are judged when the record is saved | + +### Nested Shape: `RecordPageResponse.fieldAccess[option 2]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **error** | `{ code: string; status: integer; message: string }` | ✅ | The refusal the replaced read answers, in place of this part's answer. | + +### Nested Shape: `RecordPageResponse.approvals[option 1]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **requests** | `Record[]` | ✅ | The record's approval requests the caller may see, as `GET /approvals/requests` lists them for the record; empty when the deployment runs no approval service. | +| **lock** | `{ locked: boolean } \| null` | ✅ | Whether a locking approval holds the record; null when the deployment answers no lock. | + +### Nested Shape: `RecordPageResponse.approvals[option 2]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **error** | `{ code: string; status: integer; message: string }` | ✅ | The refusal the replaced read answers, in place of this part's answer. | + + +--- + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index e81f4bfa13e..0f682071360 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1555 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1566 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/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 32 | 440 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 33 | 451 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 31 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -34,7 +34,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) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 172 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **202** | **1555** | 14 protocol modules | +| **Total** | **203** | **1566** | 14 protocol modules | --- @@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 440 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **33 pages, 451 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -95,6 +95,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`query-adapter.zod.ts`](/docs/references/api/query-adapter) | `ODataQueryAdapter`, `OperatorMapping`, `QueryAdapterConfig`, `QueryAdapterTarget`, `RestQueryAdapter` | | [`realtime.zod.ts`](/docs/references/api/realtime) | `RealtimeConfig`, `RealtimeEvent`, `RealtimeEventType`, `RealtimePresence`, `Subscription`, `SubscriptionEvent`, `TransportProtocol` | | [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` | +| [`record-page.zod.ts`](/docs/references/api/record-page) | `RecordPageApprovals`, `RecordPageExplainAnswer`, `RecordPageExplainOperation`, `RecordPagePartError`, `RecordPagePartRefusal`, `RecordPageRelatedAnswer`, `RecordPageRelatedRefusal`, `RecordPageRelatedRequest`, `RecordPageRelatedRows`, `RecordPageRequest`, `RecordPageResponse` | | [`rest-server.zod.ts`](/docs/references/api/rest-server) | `BatchEndpointsConfig`, `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` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index 9ebf534c149..0012bb0eb42 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `api/` | 449 | +| `api/` | 460 | diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 27764d8fbf1..ae28eef1c93 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -5,6 +5,8 @@ import { BatchUpdateRequest, BatchUpdateResponse, GetRecordFieldAccessResponse, + RecordPageRequest, + RecordPageResponse, UpdateManyRequest, DeleteManyRequest, BatchOptions, @@ -7464,6 +7466,26 @@ export class ObjectStackClient { return this.unwrapResponse(res); }, + /** + * [#27627] The record page payload — `POST /data/:object/:id/page`, + * declared by `RecordPageRequestSchema` / `RecordPageResponseSchema` + * (`@objectstack/spec/api`): the record, the count and first rows of each + * related list `request.related` names, and — when asked — the record's + * field projection, its record-grained access decisions and its approval + * state, in one round trip. Each part is the answer of the read it replaces + * for the same caller; a part whose read refused carries `error` instead. + * A record that is absent or that the caller cannot read rejects with the + * record read's 404. + */ + recordPage: async (object: string, id: string, request: RecordPageRequest = {}): Promise => { + const route = this.getRoute('data'); + const res = await this.fetch( + `${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(id)}/page`, + { method: 'POST', body: JSON.stringify(request) }, + ); + return this.unwrapResponse(res); + }, + /** * Streaming export (M10.9): CSV / JSON / XLSX file download. Returns the * raw `Response` — the body is a file stream (`Content-Disposition` @@ -8403,6 +8425,8 @@ export type { GetObjectPermissionsResponse, GetEffectivePermissionsResponse, GetRecordFieldAccessResponse, + RecordPageRequest, + RecordPageResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeSubscribeRequest, diff --git a/packages/qa/dogfood/test/fixtures/record-page-fixture.ts b/packages/qa/dogfood/test/fixtures/record-page-fixture.ts new file mode 100644 index 00000000000..4442c0df1c1 --- /dev/null +++ b/packages/qa/dogfood/test/fixtures/record-page-fixture.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The record page payload (`POST /data/:object/:id/page`) under a member who +// is NOT an admin, with every read gate the page's parts pass through in play: +// +// - `rp_account` is the page's record: wide open at the row level, so the +// record read answers and the parts are what is under test. Its `rating` +// is unreadable to the member (field-level security on the record itself). +// - `rp_note` is a related list under row-level security: an owner policy on +// `created_by`, so the member counts and sees only the notes they wrote. +// Its `secret` is unreadable to the member (field-level security on rows). +// - `rp_task` is a related list the member reads whole, scoped by a declared +// filter the page sends. +// - `rp_ledger` is a related list the member may not read at all (no object +// grant), so its list contributes neither a count nor rows. +// - `rp_private` is a record the member cannot read (owner-private sharing), +// for the whole-request refusal. + +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { PermissionSetSchema, RLS, type PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; + +export const RecordPageAccount = ObjectSchema.create({ + name: 'rp_account', + // [ADR-0090 D1] grandfather stamp: the record itself is readable, so the + // parts below are the gates under test. + sharingModel: 'public_read_write', + label: 'RP Account', + pluralLabel: 'RP Accounts', + fields: { + name: Field.text({ label: 'Name', required: true }), + rating: Field.text({ label: 'Rating' }), + }, +}); + +export const RecordPageNote = ObjectSchema.create({ + name: 'rp_note', + // Row scope comes from the member set's owner policy below. + sharingModel: 'public_read_write', + label: 'RP Note', + pluralLabel: 'RP Notes', + fields: { + title: Field.text({ label: 'Title', required: true }), + secret: Field.text({ label: 'Secret' }), + account: Field.lookup('rp_account', { label: 'Account' }), + }, +}); + +export const RecordPageTask = ObjectSchema.create({ + name: 'rp_task', + sharingModel: 'public_read_write', + label: 'RP Task', + pluralLabel: 'RP Tasks', + fields: { + title: Field.text({ label: 'Title', required: true }), + status: Field.select({ + label: 'Status', + options: [ + { label: 'Open', value: 'open' }, + { label: 'Done', value: 'done' }, + ], + }), + account: Field.lookup('rp_account', { label: 'Account' }), + }, +}); + +export const RecordPageLedger = ObjectSchema.create({ + name: 'rp_ledger', + sharingModel: 'public_read_write', + label: 'RP Ledger', + pluralLabel: 'RP Ledger Entries', + fields: { + title: Field.text({ label: 'Title', required: true }), + account: Field.lookup('rp_account', { label: 'Account' }), + }, +}); + +export const RecordPagePrivate = ObjectSchema.create({ + name: 'rp_private', + sharingModel: 'private', + label: 'RP Private', + pluralLabel: 'RP Privates', + fields: { + title: Field.text({ label: 'Title', required: true }), + }, +}); + +export const recordPageFixtureStack = defineStack({ + manifest: { + id: 'com.dogfood.record-page-fixture', + namespace: 'rp', + version: '0.0.0', + type: 'app', + name: 'Record Page Fixture', + description: 'A record page whose related lists sit behind row-level security, field-level security and an object grant the reader lacks.', + }, + objects: [RecordPageAccount, RecordPageNote, RecordPageTask, RecordPageLedger, RecordPagePrivate], +} as any); + +const crud = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } as const; + +/** The member: CRUD on everything but the ledger, notes owner-scoped, two fields unreadable. */ +export const recordPageMemberSet: PermissionSet = PermissionSetSchema.parse({ + name: 'rp_fixture_member', + label: 'RP Fixture Member — owner-scoped notes, no ledger', + objects: { rp_account: crud, rp_note: crud, rp_task: crud, rp_private: crud }, + fields: { + 'rp_account.rating': { readable: false, editable: false }, + 'rp_note.secret': { readable: false, editable: false }, + }, + rowLevelSecurity: [RLS.ownerPolicy('rp_note', 'created_by')], +}); + +export function recordPageFixtureSecurity(): SecurityPlugin { + return new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, recordPageMemberSet], + fallbackPermissionSet: recordPageMemberSet.name, + }); +} diff --git a/packages/qa/dogfood/test/record-page.dogfood.test.ts b/packages/qa/dogfood/test/record-page.dogfood.test.ts new file mode 100644 index 00000000000..231c3808d16 --- /dev/null +++ b/packages/qa/dogfood/test/record-page.dogfood.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#27627] The record page payload (`POST /data/:object/:id/page`) answers a +// member who is not an admin exactly what the separate reads it replaces +// answer that member, part by part, through the real HTTP + security stack: +// row-level security on a related list (owner-scoped notes), field-level +// security on the record and on related rows, a related list behind an object +// grant the member lacks (neither a count nor rows), and a record the member +// cannot read (the whole request refused as the record read refuses it). + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { RecordPageResponseSchema } from '@objectstack/spec/api'; +import { recordPageFixtureStack, recordPageFixtureSecurity } from './fixtures/record-page-fixture.js'; + +const SYS = { isSystem: true } as const; +const MEMBER = 'record-page@verify.test'; + +describe('[#27627] the record page payload, as a non-admin member', () => { + let stack: VerifyStack; + let ql: any; + let adminTok: string; + let memberTok: string; + let accountId: string; + let privateId: string; + + beforeAll(async () => { + stack = await bootStack(recordPageFixtureStack, { security: recordPageFixtureSecurity() }); + adminTok = await stack.signIn(); + memberTok = await stack.signUp(MEMBER); + ql = await stack.kernel.getServiceAsync('objectql'); + accountId = (await ql.insert('rp_account', { name: 'Acme', rating: 'hot' }, { context: SYS })).id; + // Notes: two the member writes (visible to them), three someone else + // writes (owner policy hides them). Each carries a secret, written by the + // system after the fact (nobody's field grant admits writing it). + for (const title of ['mine 1', 'mine 2']) { + const res = await stack.apiAs(memberTok, 'POST', '/data/rp_note', { title, account: accountId }); + expect(res.status, await res.clone().text()).toBeLessThan(300); + } + for (const title of ['theirs 1', 'theirs 2', 'theirs 3']) { + const res = await stack.apiAs(adminTok, 'POST', '/data/rp_note', { title, account: accountId }); + expect(res.status, await res.clone().text()).toBeLessThan(300); + } + await ql.update('rp_note', { secret: 's' }, { where: { account: accountId }, multi: true, context: SYS }); + const stored: any[] = await ql.find('rp_note', { where: { account: accountId } }, { context: SYS }); + expect(stored.map((r) => r.secret)).toEqual(['s', 's', 's', 's', 's']); + for (const [title, status] of [['t1', 'open'], ['t2', 'open'], ['t3', 'done'], ['t4', 'open'], ['t5', 'open']]) { + await ql.insert('rp_task', { title, status, account: accountId }, { context: SYS }); + } + for (const title of ['l1', 'l2']) await ql.insert('rp_ledger', { title, account: accountId }, { context: SYS }); + // A task of another account: the parent condition keeps it out. + const other = (await ql.insert('rp_account', { name: 'Other' }, { context: SYS })).id; + await ql.insert('rp_task', { title: 'elsewhere', status: 'open', account: other }, { context: SYS }); + const priv = await stack.apiAs(adminTok, 'POST', '/data/rp_private', { title: 'admin only' }); + expect(priv.status, await priv.clone().text()).toBeLessThan(300); + const privBody: any = await priv.json(); + privateId = privBody.id ?? privBody.record?.id ?? privBody.data?.id; + expect(privateId, 'private id').toBeTruthy(); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + const send = async (path: string, method = 'GET', body?: unknown) => { + const res = await stack.apiAs(memberTok, method, path, body); + return { status: res.status, body: (await res.json().catch(() => undefined)) as any }; + }; + const list = (object: string, filter: unknown, top: number) => + send(`/data/${object}?$filter=${encodeURIComponent(JSON.stringify(filter))}&$top=${top}&$count=true`); + const rowsOf = (body: any) => body?.data?.records ?? body?.records ?? body?.data ?? []; + const totalOf = (body: any) => body?.data?.total ?? body?.total; + + const REQUEST = { + related: [ + { object: 'rp_note', field: 'account', top: 3 }, + { object: 'rp_task', field: 'account', filter: { status: 'open' }, top: 3 }, + { object: 'rp_ledger', field: 'account', top: 3 }, + ], + fieldAccess: true, + explain: ['update', 'delete', 'transfer'], + approvals: true, + }; + + it('answers every part as the read it replaces answers it, under RLS, FLS and a missing object grant', async () => { + const page = await send(`/data/rp_account/${accountId}/page`, 'POST', REQUEST); + expect(page.status, JSON.stringify(page.body)).toBe(200); + const data = RecordPageResponseSchema.parse(page.body.data); + + // The record: the record read's answer, the unreadable field absent. + const read = await send(`/data/rp_account/${accountId}`); + expect(read.status).toBe(200); + expect(data.record).toEqual(read.body.data?.record ?? read.body.record); + expect(data.record).not.toHaveProperty('rating'); + + // A related answer is one of two arms: rows (`total`, `records`) or a refusal (`error`). + const rowsArm = (i: number) => { + const part = data.related[i]; + if (!('total' in part)) throw new Error(`related[${i}] is a refusal: ${JSON.stringify(part)}`); + return part; + }; + const refusalArm = (i: number) => { + const part = data.related[i]; + if (!('error' in part)) throw new Error(`related[${i}] carries rows: ${JSON.stringify(part)}`); + return part; + }; + + // Row-level security: the member's two notes, counted and listed; the + // three they cannot read neither counted nor listed; `secret` stripped. + const notes = await list('rp_note', { account: accountId }, 3); + expect(notes.status).toBe(200); + expect(data.related[0]).toEqual({ object: 'rp_note', field: 'account', total: totalOf(notes.body), records: rowsOf(notes.body) }); + expect(rowsArm(0).total).toBe(2); + expect(rowsArm(0).records.map((r: any) => r.title).sort()).toEqual(['mine 1', 'mine 2']); + for (const row of rowsArm(0).records) expect(row).not.toHaveProperty('secret'); + + // A declared filter narrows the parent condition: four open tasks of + // this account (the other account's task and the done one are out). + const tasks = await list('rp_task', { account: accountId, status: 'open' }, 3); + expect(tasks.status).toBe(200); + expect(data.related[1]).toEqual({ object: 'rp_task', field: 'account', total: totalOf(tasks.body), records: rowsOf(tasks.body) }); + expect(rowsArm(1).total).toBe(4); + expect(rowsArm(1).records).toHaveLength(3); + + // A list the member may not read: the list read's refusal, no count, no rows. + const ledger = await list('rp_ledger', { account: accountId }, 3); + expect(ledger.status).toBe(403); + expect(data.related[2]).not.toHaveProperty('total'); + expect(data.related[2]).not.toHaveProperty('records'); + expect(refusalArm(2).error).toMatchObject({ status: 403, code: ledger.body?.error?.code ?? ledger.body?.code }); + + // Field access: the record-scoped projection's answer. + const access = await send(`/data/rp_account/${accountId}/field-access`); + expect(access.status).toBe(200); + expect(data.fieldAccess).toEqual(access.body.data); + expect((data.fieldAccess as any).readable).not.toContain('rating'); + + // Explain: the record-grained verdict per operation. + for (const operation of ['update', 'delete', 'transfer'] as const) { + const verdict = await send('/security/explain', 'POST', { object: 'rp_account', operation, recordId: accountId }); + expect(verdict.status).toBe(200); + expect(data.explain?.[operation], operation).toEqual(verdict.body); + } + + // Approvals: the record's requests as the list route answers them. + const requests = await send(`/approvals/requests?object=rp_account&recordId=${accountId}`); + expect(data.approvals).toMatchObject({ requests: requests.body?.data ?? [] }); + }, 120_000); + + it('a record the member cannot read refuses the whole request exactly as the record read does', async () => { + for (const id of [privateId, 'rp_no_such_row']) { + const read = await send(`/data/rp_private/${id}`); + const page = await send(`/data/rp_private/${id}/page`, 'POST', { fieldAccess: true, explain: ['update'], approvals: true }); + expect(read.status).toBe(404); + expect(page).toEqual(read); + } + }, 60_000); + + it('refuses an entry that is not a relationship to this record, and any query parameter', async () => { + const wrong = await send(`/data/rp_account/${accountId}/page`, 'POST', { related: [{ object: 'rp_task', field: 'title', top: 1 }] }); + expect(wrong.status).toBe(400); + expect(wrong.body?.error?.details?.fields?.[0]).toMatchObject({ field: 'related.0.field', code: 'invalid_relationship' }); + const query = await send(`/data/rp_account/${accountId}/page?top=1`, 'POST', {}); + expect(query.status).toBe(400); + expect(query.body?.error?.code).toBe('VALIDATION_ERROR'); + }, 60_000); +}); diff --git a/packages/rest/src/record-page-routes.test.ts b/packages/rest/src/record-page-routes.test.ts new file mode 100644 index 00000000000..d4ee530ce44 --- /dev/null +++ b/packages/rest/src/record-page-routes.test.ts @@ -0,0 +1,337 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27627] `POST /data/:object/:id/page` — the record page payload. + * + * What the route owes, each pinned below: + * + * - every part is the call the read it replaces makes, asked as the same + * caller: the record is `getData` as `GET /data/:object/:id` asks it, a + * related list is `findData` on the child with the parent condition AND the + * list's declared filter, its declared order and `count`, the projection is + * `getRecordFieldAccess`, a verdict is `explain` with the record id, the + * approval part is `listRequests` + `getRecordLock`; + * - the answer parses against `RecordPageResponseSchema` (the route ledger's + * `responseSchema` names it); + * - a record refusal is the whole request's answer, and no part is asked; + * - a part whose read refuses carries the refusal and the others answer; a + * related list the caller may not read carries neither a count nor rows; + * - an entry that is not a relationship of the child pointing at this object + * is a located 400 before anything is read; + * - the body is strict, and the query-parameter set is closed and empty. + * + * The end-to-end half, under row-level and field-level security as a member who + * is not an admin, is `record-page.dogfood.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RecordPageResponseSchema } from '@objectstack/spec/api'; +import { RestServer } from './rest-server.js'; +import { RECORD_PAGE_PARAMS } from './record-page-routes.js'; +import { unknownQueryParamMessage } from './query-allowlist.js'; + +const ACCOUNT = { + name: 'account', + label: 'Account', + fields: { id: { name: 'id', type: 'text' }, name: { name: 'name', type: 'text' } }, +}; +const NOTE = { + name: 'note', + label: 'Note', + fields: { + id: { name: 'id', type: 'text' }, + title: { name: 'title', type: 'text' }, + account: { name: 'account', type: 'lookup', reference: 'account' }, + watchers: { name: 'watchers', type: 'lookup', reference: 'account', multiple: true }, + owner: { name: 'owner', type: 'lookup', reference: 'user' }, + }, +}; +const HIDDEN = { + name: 'hidden', + label: 'Hidden', + enable: { apiEnabled: false }, + fields: { account: { name: 'account', type: 'lookup', reference: 'account' } }, +}; +const ITEMS: Record = { account: ACCOUNT, note: NOTE, hidden: HIDDEN }; + +const CTX = { userId: 'u1', permissions: ['member_default'] }; +const RECORD = { id: 'a1', name: 'Acme' }; +const ACCESS = { object: 'account', recordId: 'a1', readable: ['id', 'name'], editable: ['name'], writable: true }; +const DECISION = (operation: string) => ({ + allowed: true, + object: 'account', + operation, + principal: { userId: 'u1', positions: [], permissionSets: ['member_default'] }, + layers: [], + record: { recordId: 'a1', visible: true, decidedBy: 'rls' }, +}); + +function mockServer() { + const noop = () => { /* routes are driven directly, never through the adapter */ }; + return { + get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, + listen: async () => { /* never started */ }, + close: async () => { /* never started */ }, + }; +} + +function mockRes() { + const res: any = { + statusCode: 200, + header: vi.fn(function (this: any) { return this; }), + setHeader: vi.fn(function (this: any) { return this; }), + json: vi.fn(function (this: any, body: any) { this._body = body; return this; }), + status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }), + }; + return res; +} + +interface BootOpts { + getData?: (req: any) => Promise; + findData?: (req: any) => Promise; + security?: unknown; + approvals?: unknown; + context?: unknown; +} + +function boot(opts: BootOpts = {}) { + const getData = vi.fn(opts.getData ?? (async () => ({ object: 'account', id: 'a1', record: RECORD }))); + const findData = vi.fn(opts.findData ?? (async (req: any) => ({ + object: req.object, + records: [{ id: `${req.object}-1` }], + total: 7, + }))); + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue({ items: Object.values(ITEMS) }), + getMetaItem: vi.fn(async ({ name }: any) => (ITEMS[name] ? { type: 'object', name, item: ITEMS[name] } : null)), + getData, + findData, + }; + const rest = new RestServer(mockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ('context' in opts ? opts.context : CTX); + (rest as any).resolveSecurityService = async () => opts.security; + (rest as any).approvalsServiceProvider = opts.approvals === undefined ? undefined : async () => opts.approvals; + const apiGate = vi.spyOn(rest as any, 'enforceApiAccess'); + rest.registerRoutes(); + const route = (rest as any).getRoutes().find( + (r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/:id/page', + ); + const call = async (body: unknown = {}, query: Record = {}, params = { object: 'account', id: 'a1' }) => { + if (!route) throw new Error('route not registered: POST /api/v1/data/:object/:id/page'); + const res = mockRes(); + await route.handler({ method: 'POST', params, query, headers: {}, body } as any, res); + return { status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] }; + }; + return { getData, findData, call, apiGate }; +} + +const recordNotFound = () => Object.assign(new Error('Record a1 not found in account'), { + code: 'RECORD_NOT_FOUND', status: 404, object: 'account', +}); +const permissionDenied = () => Object.assign(new Error('[Security] Access denied: read on note'), { + code: 'PERMISSION_DENIED', status: 403, +}); + +describe('[#27627] POST /data/:object/:id/page', () => { + it('asks the object-exposure gate as GET /data/:object/:id does, with the caller\'s context (#27692)', async () => { + const { call, apiGate } = boot(); + expect((await call({})).status).toBe(200); + expect(apiGate).toHaveBeenCalledTimes(1); + const [, , , environmentId, operation, context] = apiGate.mock.calls[0]; + expect([environmentId, operation, context]).toEqual([undefined, 'get', CTX]); + }); + + it("answers every part with the call the read it replaces makes, as the caller, in the schema's shape", async () => { + const getRecordFieldAccess = vi.fn().mockResolvedValue(ACCESS); + const explain = vi.fn(async (req: any) => DECISION(req.operation)); + const pending = { id: 'ar1', status: 'pending', object: 'account', record_id: 'a1' }; + const approvals = { + listRequests: vi.fn().mockResolvedValue([pending, { id: 'ar0', status: 'approved' }]), + getRequest: vi.fn().mockResolvedValue({ id: 'ar1', approvers: ['u2'] }), + getRecordLock: vi.fn().mockResolvedValue({ locked: true }), + }; + const { call, getData, findData } = boot({ security: { getRecordFieldAccess, explain }, approvals }); + const answer = await call({ + expand: ['owner'], + related: [ + { object: 'note', field: 'account', top: 3 }, + { object: 'note', field: 'account', filter: { title: 'x' }, top: 0 }, + { object: 'note', field: 'watchers', top: 1, sort: [{ field: 'title', order: 'desc' }] }, + ], + fieldAccess: true, + explain: ['update', 'delete'], + approvals: true, + }); + expect(answer.status).toBe(200); + expect(answer.body.success).toBe(true); + const data = RecordPageResponseSchema.parse(answer.body.data); + + // The record, as `GET /data/:object/:id?expand=` reads it. + expect(getData).toHaveBeenCalledWith({ object: 'account', id: 'a1', expand: ['owner'], context: CTX }); + expect(data.record).toEqual(RECORD); + + // Each related list: the parent condition (to the field's arity), AND + // the declared scope, with `count` — as `GET /data/note` reads it. + expect(findData.mock.calls.map(([req]) => req)).toEqual([ + { object: 'note', query: { object: 'note', where: { account: 'a1' }, limit: 3, count: true }, context: CTX }, + { object: 'note', query: { object: 'note', where: { $and: [{ account: 'a1' }, { title: 'x' }] }, limit: 0, count: true }, context: CTX }, + { object: 'note', query: { object: 'note', where: { watchers: { $contains: 'a1' } }, limit: 1, count: true, orderBy: [{ field: 'title', order: 'desc' }] }, context: CTX }, + ]); + expect(data.related.map((r) => ('total' in r ? [r.field, r.total, r.records] : [r.field, r.error]))).toEqual([ + ['account', 7, [{ id: 'note-1' }]], + ['account', 7, [{ id: 'note-1' }]], + ['watchers', 7, [{ id: 'note-1' }]], + ]); + + expect(getRecordFieldAccess).toHaveBeenCalledWith('account', 'a1', CTX); + expect(data.fieldAccess).toEqual(ACCESS); + expect(explain.mock.calls).toEqual([ + [{ object: 'account', operation: 'update', recordId: 'a1' }, CTX], + [{ object: 'account', operation: 'delete', recordId: 'a1' }, CTX], + ]); + expect(data.explain).toEqual({ update: DECISION('update'), delete: DECISION('delete') }); + + expect(approvals.listRequests).toHaveBeenCalledWith({ object: 'account', recordId: 'a1' }, CTX); + expect(approvals.getRequest).toHaveBeenCalledWith('ar1', CTX); + expect(approvals.getRecordLock).toHaveBeenCalledWith('account', 'a1', CTX); + expect(data.approvals).toEqual({ + requests: [{ ...pending, id: 'ar1', approvers: ['u2'] }, { id: 'ar0', status: 'approved' }], + lock: { locked: true }, + }); + }); + + it('asks only for the parts the body names', async () => { + const security = { getRecordFieldAccess: vi.fn(), explain: vi.fn() }; + const approvals = { listRequests: vi.fn(), getRecordLock: vi.fn() }; + const { call, findData } = boot({ security, approvals }); + const answer = await call({}); + expect(answer.status).toBe(200); + expect(answer.body.data).toEqual({ object: 'account', id: 'a1', record: RECORD, related: [] }); + expect(findData).not.toHaveBeenCalled(); + expect(security.getRecordFieldAccess).not.toHaveBeenCalled(); + expect(security.explain).not.toHaveBeenCalled(); + expect(approvals.listRequests).not.toHaveBeenCalled(); + }); + + it('a record the read path refuses answers that refusal whole, and no part is asked', async () => { + const security = { getRecordFieldAccess: vi.fn(), explain: vi.fn() }; + const { call, findData } = boot({ getData: async () => { throw recordNotFound(); }, security }); + const answer = await call({ related: [{ object: 'note', field: 'account', top: 3 }], fieldAccess: true, explain: ['update'] }); + expect(answer.status).toBe(404); + expect(answer.body?.error?.code ?? answer.body?.code).toBe('RECORD_NOT_FOUND'); + expect(findData).not.toHaveBeenCalled(); + expect(security.getRecordFieldAccess).not.toHaveBeenCalled(); + expect(security.explain).not.toHaveBeenCalled(); + }); + + it('a related list whose read refuses carries the refusal and neither a count nor rows; the other parts answer', async () => { + const { call } = boot({ + findData: async () => { throw permissionDenied(); }, + security: { getRecordFieldAccess: vi.fn().mockResolvedValue(ACCESS) }, + }); + const answer = await call({ related: [{ object: 'note', field: 'account', top: 3 }], fieldAccess: true }); + expect(answer.status).toBe(200); + const data = RecordPageResponseSchema.parse(answer.body.data); + expect(data.related[0]).toEqual({ + object: 'note', field: 'account', + error: { code: 'PERMISSION_DENIED', status: 403, message: expect.any(String) }, + }); + expect(data.fieldAccess).toEqual(ACCESS); + }); + + it('a related list on an object the API does not expose is refused as its list read is, and never read', async () => { + const { call, findData } = boot(); + const answer = await call({ related: [{ object: 'hidden', field: 'account', top: 3 }] }); + expect(answer.status).toBe(200); + const [list] = RecordPageResponseSchema.parse(answer.body.data).related; + expect(list).not.toHaveProperty('total'); + expect(list).not.toHaveProperty('records'); + expect('error' in list ? list.error : undefined).toMatchObject({ code: 'OBJECT_API_DISABLED', status: 404 }); + expect(findData).not.toHaveBeenCalled(); + }); + + it('a field projection, verdict or approval read that refuses carries its refusal in its part', async () => { + const { call } = boot({ + security: { + getRecordFieldAccess: vi.fn().mockResolvedValue(null), + explain: vi.fn(async () => { throw permissionDenied(); }), + }, + approvals: { listRequests: vi.fn(async () => { throw permissionDenied(); }) }, + }); + const answer = await call({ fieldAccess: true, explain: ['transfer'], approvals: true }); + expect(answer.status).toBe(200); + const data = RecordPageResponseSchema.parse(answer.body.data); + expect(data.fieldAccess).toMatchObject({ error: { code: 'RECORD_NOT_FOUND', status: 404 } }); + expect(data.explain?.transfer).toMatchObject({ error: { code: 'PERMISSION_DENIED', status: 403 } }); + expect(data.approvals).toMatchObject({ error: { code: 'PERMISSION_DENIED', status: 403 } }); + }); + + it('no service answers a loud 501 part for the projection and the verdicts, and no approvals as an empty part', async () => { + const answer = await boot({ security: {} }).call({ fieldAccess: true, explain: ['update'], approvals: true }); + expect(answer.status).toBe(200); + const data = RecordPageResponseSchema.parse(answer.body.data); + expect(data.fieldAccess).toMatchObject({ error: { code: 'NOT_IMPLEMENTED', status: 501 } }); + expect(data.explain?.update).toMatchObject({ error: { code: 'NOT_IMPLEMENTED', status: 501 } }); + expect(data.approvals).toEqual({ requests: [], lock: null }); + }); + + it('a caller without a user id (a system context) gets no verdict, as the explain route refuses them', async () => { + const explain = vi.fn(); + const answer = await boot({ security: { explain }, context: { isSystem: true, permissions: [] } }).call({ explain: ['update'] }); + expect(answer.status).toBe(200); + expect(answer.body.data.explain.update).toMatchObject({ error: { code: 'UNAUTHORIZED', status: 401 } }); + expect(explain).not.toHaveBeenCalled(); + }); + + it('an entry that is not a relationship of the child to this object is a located 400 before anything is read', async () => { + const { call, getData, findData } = boot(); + for (const field of ['title', 'owner', 'nope']) { + const answer = await call({ related: [{ object: 'note', field: 'account', top: 1 }, { object: 'note', field, top: 1 }] }); + expect(answer.status, field).toBe(400); + expect(answer.body.error.code).toBe('VALIDATION_FAILED'); + expect(answer.body.error.details.fields).toEqual([expect.objectContaining({ field: 'related.1.field', code: 'invalid_relationship' })]); + } + expect(getData).not.toHaveBeenCalled(); + expect(findData).not.toHaveBeenCalled(); + }); + + it('a declared filter the list read refuses is a located 400 before anything is read', async () => { + const { call, getData, findData } = boot(); + const answer = await call({ related: [{ object: 'note', field: 'account', top: 1, filter: { title: { $in: 'x' } } }] }); + expect(answer.status).toBe(400); + expect(answer.body.error.code).toBe('VALIDATION_FAILED'); + expect(answer.body.error.details.fields).toEqual([expect.objectContaining({ field: 'related.0.filter', code: 'invalid_filter' })]); + expect(getData).not.toHaveBeenCalled(); + expect(findData).not.toHaveBeenCalled(); + }); + + it('the body is strict: an unknown key or an out-of-range top is a 400 and nothing is read', async () => { + const { call, getData } = boot(); + for (const body of [ + { relatedLists: [] }, + { related: [{ object: 'note', field: 'account', top: 3, extra: 1 }] }, + { related: [{ object: 'note', field: 'account', top: 51 }] }, + { related: [{ object: 'note', field: 'account', top: 3, sort: [{ field: 'title', order: 'up' }] }] }, + { explain: ['read'] }, + ]) { + const answer = await call(body); + expect(answer.status, JSON.stringify(body)).toBe(400); + expect(answer.body.error.code).toBe('VALIDATION_FAILED'); + } + expect(getData).not.toHaveBeenCalled(); + }); + + it('takes no query parameter: a name is a located 400 and nothing is read', async () => { + expect(RECORD_PAGE_PARAMS).toEqual([]); + const { call, getData } = boot(); + const answer = await call({}, { top: '3' }); + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toBe(unknownQueryParamMessage(['top'], [])); + expect(getData).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/rest/src/record-page-routes.ts b/packages/rest/src/record-page-routes.ts new file mode 100644 index 00000000000..36f0790c186 --- /dev/null +++ b/packages/rest/src/record-page-routes.ts @@ -0,0 +1,312 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27627] `POST {basePath}/data/:object/:id/page` — the record page payload + * (`RecordPageRequestSchema` → `RecordPageResponseSchema`, `@objectstack/spec/api`). + * + * One request answers what a record page asked of the server in up to three + * serial stages when it opened. Every part is the answer of the read it + * replaces, asked as the same caller through the same call, and the route adds + * no judgement of its own (the part-to-read table is the spec module's header): + * the record is `getData` after the object-exposure gate, and its refusal is + * the whole answer, before any other part is asked; a related list is + * `findData` with `count` after the child's own exposure gate, filtered by the + * parent condition this route composes AND the list's declared scope; then + * `getRecordFieldAccess`, the security service's `explain` and the approval + * service's `listRequests` / `getRequest` / `getRecordLock`. Object + * permissions, row-level security, sharing and field-level security are + * therefore whatever those calls apply, by construction. + * + * The page names the lists (which ones it draws is its own derivation); this + * route never derives one. It owns the meaning of an entry: the child field + * must be a relationship that points at this object, or the request is refused. + * The closed query-parameter set is empty (AGENTS.md, Route & surface + * ownership 5), and the body is parsed strictly. + */ + +import { + RecordPageRequestSchema, + zodIssuesToFields, + type GetDataRequest, + type FindDataRequest, + type RecordPageRequest, +} from '@objectstack/spec/api'; +import { isMultiValueField, parseFilterAST } from '@objectstack/spec/data'; +import { measureServerTiming } from '@objectstack/observability'; +import { sendError, sendOk } from '@objectstack/types'; +import { recordNotFoundError } from '@objectstack/core'; +import { refuseUnknownQueryParams } from './query-allowlist.js'; +import { logUnexpectedRouteError, mapDataError, nestedRefusalEnvelope } from './error-response.js'; +import type { ServerScopedDataRequest } from './data-action-routes.js'; +import { apiAccessDenialFromEnable, type RestServer } from './rest-server.js'; + +/** The closed query-parameter set of `POST {basePath}/data/:object/:id/page`: EMPTY. */ +export const RECORD_PAGE_PARAMS: readonly string[] = []; + +/** The relationship field types a related list follows back to its parent. */ +const RELATIONSHIP_TYPES = new Set(['lookup', 'master_detail']); + +/** + * The `RestServer` members {@link registerRecordPageEndpoints} reads — every + * one of them, and no other (`data-action-routes.ts`' header carries why each + * entry is shaped the way it is). `approvalsServiceProvider` is a getter: it is + * read at request time and the server may assign it after registration. + */ +export interface RecordPageRouteHost { + readonly config: RestServer['config']; + readonly routeManager: RestServer['routeManager']; + readonly approvalsServiceProvider: ((environmentId?: string) => Promise) | undefined; + resolveProtocol: RestServer['resolveProtocol']; + resolveExecCtx: RestServer['resolveExecCtx']; + enforceAuth: RestServer['enforceAuth']; + enforceApiAccess: RestServer['enforceApiAccess']; + resolveSecurityService: RestServer['resolveSecurityService']; + /** The object's metadata item as the exposure gate reads it, or `undefined`. */ + loadObjectItem: (p: any, environmentId: string | undefined, name: string) => Promise; +} + +/** A part's refusal, as the replaced read answers it. */ +interface PartRefusal { + code: string; + status: number; + message: string; +} + +/** The data door's own classification of a thrown error, as a part refusal. */ +function refusalOf(error: unknown, object: string): PartRefusal { + const mapped = mapDataError(error, object); + logUnexpectedRouteError(error, mapped); + const nested = nestedRefusalEnvelope(mapped); + return { code: nested.code, status: nested.status, message: nested.message }; +} + +/** A field definition by name, from either served shape of an object's `fields`. */ +function fieldDefOf(item: any, name: string): any | undefined { + const fields = item?.fields; + if (Array.isArray(fields)) return fields.find((f: any) => f?.name === name); + if (fields && typeof fields === 'object') { + return Object.prototype.hasOwnProperty.call(fields, name) ? fields[name] : undefined; + } + return undefined; +} + +/** The target object a relationship field declares (`reference`, the spec's one spelling). */ +function referenceOf(def: any): string | undefined { + const ref = def?.reference; + return typeof ref === 'string' && ref ? ref : undefined; +} + +/** + * Register the record page payload route — called by + * `RestServer#registerRecordPageEndpoints` with a {@link RecordPageRouteHost} + * as `this`. + */ +export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: string): void { + const isScoped = basePath.includes('/environments/:environmentId'); + const dataPath = `${basePath}${this.config.crud.dataPrefix}`; + + this.routeManager.register({ + method: 'POST', + path: `${dataPath}/:object/:id/page`, + handler: async (req: any, res: any) => { + const object = String(req.params?.object ?? ''); + const id = String(req.params?.id ?? ''); + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + if (await this.enforceApiAccess(req, res, p, environmentId, 'get', context)) return; + if (refuseUnknownQueryParams(req, res, RECORD_PAGE_PARAMS)) return; + + const rawBody = req.body ?? {}; + const parsed = (RecordPageRequestSchema as any).safeParse(rawBody); + if (!parsed.success) { + sendError(res, 400, 'VALIDATION_FAILED', 'Invalid record page request', { + details: { object, fields: zodIssuesToFields(parsed.error?.issues, rawBody) }, + }); + return; + } + const body = parsed.data as RecordPageRequest; + const scope = { + ...(environmentId ? { environmentId } : {}), + ...(context ? { context } : {}), + }; + + // Each related entry must name a relationship of the child that + // points at THIS object, and a declared filter must be one the + // list read's own lowering (`parseFilterAST`) takes. Judged + // before anything is read, so a malformed entry refuses the + // request without a single read. An entry whose child the + // caller may not reach through the API is not judged on its + // field: its read below answers that refusal. + const related = body.related ?? []; + const childItems = await measureServerTiming('meta', () => Promise.all( + related.map((entry) => this.loadObjectItem(p, environmentId, entry.object)), + ), 'Metadata reads'); + const malformed: Array<{ field: string; code: string; message: string }> = []; + const declaredFilters = related.map((entry, index) => { + if (entry.filter === undefined) return undefined; + try { + return parseFilterAST(entry.filter); + } catch (error: any) { + malformed.push({ field: `related.${index}.filter`, code: 'invalid_filter', message: String(error?.message ?? error) }); + return undefined; + } + }); + related.forEach((entry, index) => { + const item = childItems[index]; + if (!item || apiAccessDenialFromEnable(item.enable, entry.object, 'list')) return; + const def = fieldDefOf(item, entry.field); + if (!def || !RELATIONSHIP_TYPES.has(String(def.type)) || referenceOf(def) !== object) { + malformed.push({ + field: `related.${index}.field`, + code: 'invalid_relationship', + message: `'${entry.field}' on '${entry.object}' is not a lookup or master-detail field that references '${object}'.`, + }); + } + }); + if (malformed.length > 0) { + sendError(res, 400, 'VALIDATION_FAILED', 'Invalid record page request', { + details: { object, fields: malformed }, + }); + return; + } + + // The record, as `GET /data/:object/:id` reads it. Its refusal + // is the whole request's answer (the catch below). + const got: any = await p.getData({ + object, + id, + ...(body.expand && body.expand.length > 0 ? { expand: body.expand } : {}), + ...scope, + } satisfies ServerScopedDataRequest); + const record = got?.record ?? got; + if (!record || typeof record !== 'object') throw recordNotFoundError(object, id); + + const answerRelated = async (entry: NonNullable[number], index: number) => { + const head = { object: entry.object, field: entry.field }; + const item = childItems[index]; + const denial = item ? apiAccessDenialFromEnable(item.enable, entry.object, 'list') : null; + if (denial) { + const nested = nestedRefusalEnvelope(denial); + return { ...head, error: { code: nested.code, status: nested.status, message: nested.message } }; + } + try { + const def = fieldDefOf(item, entry.field); + const parentScope = { [entry.field]: def && isMultiValueField(def) ? { $contains: id } : id }; + const declared = declaredFilters[index]; + const where = declared === undefined ? parentScope : { $and: [parentScope, declared] }; + const found: any = await p.findData({ + object: entry.object, + // Canonical QueryAST (#16337), with the list's + // `$count` as the request declares it. + query: { + object: entry.object, + where, + limit: entry.top, + count: true, + ...(entry.sort && entry.sort.length > 0 ? { orderBy: entry.sort } : {}), + }, + ...scope, + } satisfies ServerScopedDataRequest); + const records = Array.isArray(found?.records) ? found.records : []; + const total = typeof found?.total === 'number' ? found.total : records.length; + return { ...head, total, records }; + } catch (error) { + return { ...head, error: refusalOf(error, entry.object) }; + } + }; + + const answerFieldAccess = async () => { + try { + const security = await this.resolveSecurityService(environmentId, req); + if (typeof security?.getRecordFieldAccess !== 'function') { + return { error: { code: 'NOT_IMPLEMENTED', status: 501, message: 'The record field projection is not available on this deployment.' } }; + } + const access = await security.getRecordFieldAccess(object, id, context); + if (access === null) throw recordNotFoundError(object, id); + if (access === undefined) { + return { error: { code: 'NOT_IMPLEMENTED', status: 501, message: `The record field projection has no answer for '${object}'.` } }; + } + return access; + } catch (error) { + return { error: refusalOf(error, object) }; + } + }; + + const answerExplain = async () => { + const out: Record = {}; + const security = await this.resolveSecurityService(environmentId, req); + for (const operation of new Set(body.explain ?? [])) { + try { + // `POST /security/explain` answers only an + // identified caller; so does this part. + if (!context?.userId) { + out[operation] = { error: { code: 'UNAUTHORIZED', status: 401, message: 'The access-explanation endpoint requires an authenticated caller.' } }; + continue; + } + if (typeof security?.explain !== 'function') { + out[operation] = { error: { code: 'NOT_IMPLEMENTED', status: 501, message: 'Access explanation is not available on this deployment.' } }; + continue; + } + out[operation] = await security.explain({ object, operation, recordId: id } as any, context); + } catch (error) { + out[operation] = { error: refusalOf(error, object) }; + } + } + return out; + }; + + const answerApprovals = async () => { + try { + const provider = this.approvalsServiceProvider; + const svc = provider ? await provider(environmentId).catch(() => undefined) : undefined; + if (!svc) return { requests: [], lock: null }; + const rows: any[] = (await svc.listRequests({ object, recordId: id }, context ?? {})) ?? []; + const pending = rows.find((r) => r?.status === 'pending'); + let enriched: any = null; + if (pending && typeof svc.getRequest === 'function') { + const full = await svc.getRequest(pending.id, context ?? {}).catch(() => null); + if (full && full.id === pending.id) enriched = { ...pending, ...full }; + } + const requests = enriched ? rows.map((r) => (r === pending ? enriched : r)) : rows; + let lock: { locked: boolean } | null = null; + if (typeof svc.getRecordLock === 'function') { + const answer = await svc.getRecordLock(object, id, context ?? {}); + lock = answer ? { locked: answer.locked === true } : null; + } + return { requests, lock }; + } catch (error) { + return { error: refusalOf(error, object) }; + } + }; + + const [relatedAnswers, fieldAccess, explain, approvals] = await Promise.all([ + Promise.all(related.map(answerRelated)), + body.fieldAccess ? answerFieldAccess() : Promise.resolve(undefined), + body.explain && body.explain.length > 0 ? answerExplain() : Promise.resolve(undefined), + body.approvals ? answerApprovals() : Promise.resolve(undefined), + ]); + + sendOk(res, { + object, + id, + record, + related: relatedAnswers, + ...(fieldAccess !== undefined ? { fieldAccess } : {}), + ...(explain !== undefined ? { explain } : {}), + ...(approvals !== undefined ? { approvals } : {}), + }); + } catch (error: any) { + const mapped = mapDataError(error, object); + logUnexpectedRouteError(error, mapped); + res.status(mapped.status).json(mapped.body); + } + }, + metadata: { + summary: 'Record page payload: the record, its related lists\' counts and first rows, its field projection, access verdicts and approval state in one request', + tags: ['data'], + }, + }); +} diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 9177cfc724a..32bd22b857f 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -364,6 +364,9 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'GET /api/v1/data/:object/:id/field-access', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.fieldAccess', responseSchema: 'GetRecordFieldAccessResponseSchema', note: 'The field projection of ONE record, for its edit form: `editable` is the write gate\'s own verdict (SecurityPlugin computeForbiddenFieldWrites) on an update of the stored row, so a row-conditioned field permission (`when`) locks only the rows it names; `/auth/me/permissions` stays row-less. Reads the record through getData first, so an absent or unreadable row answers the record read\'s 404. Enveloped ({ success, data }); record-field-access-when.dogfood.test.ts parses `data` against the named schema on the real mount' }, + { route: 'POST /api/v1/data/:object/:id/page', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.recordPage', + responseSchema: 'RecordPageResponseSchema', + note: 'The record page payload: the record, the count and first rows of each related list the body names, the field projection, record-grained explain decisions and the approval state, each part the answer of the read it replaces asked as the same caller (getData, findData with count on the child, getRecordFieldAccess, explain, listRequests + getRecordLock). A refused record read refuses the whole request; a refused part carries its refusal and no answer. Enveloped ({ success, data }); record-page-routes.test.ts parses `data` against the named schema on the mount' }, { route: 'POST /api/v1/data/:object/import', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.import' }, { route: 'POST /api/v1/data/:object/import/jobs', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.createImportJob' }, { route: 'POST /api/v1/data/import/jobs/:jobId/cancel', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.cancelImportJob' }, diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index e77c74d7e27..383b7ad8251 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -198,6 +198,10 @@ interface CensusEntry { * unchanged: neither the object state-machine read (`meta-object-state-routes.ts`) * nor the published read (now in `meta-item-history-routes.ts`) builds a `query:` * slot, so the new module's floor is 0 and the history module's stays 0. + * + * [#27627] 5 = 0 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1 + 0 + 0 + 0 + 0 + 0 + 1: + * the record page (`record-page-routes.ts`) builds one `query:` slot, the + * related list read, so the new module's floor is 1. */ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly> = { 'rest-server.ts': 0, @@ -218,6 +222,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly { // [#23050 slice 13e] Length 17 → 18, the sum unchanged (neither the object // state-machine read nor the published read carries a slot). // [#26433] The list's length is no longer pinned: the list is the authority. + // [#27627] The sum 4 → 5: the record page's related list read. expect(Object.keys(REGISTRAR_QUERY_SLOT_FLOORS).sort()).toEqual([...REST_SERVER_REGISTRAR_FILES].sort()); expect(CENSUS.filter(({ file }) => REGISTRAR_SOURCES.has(file)).map(({ file }) => file)) .toEqual([...REST_SERVER_REGISTRAR_FILES]); - expect([...REGISTRAR_SOURCES.values()].reduce((n, source) => n + querySlots(source).length, 0)).toBe(4); + expect([...REGISTRAR_SOURCES.values()].reduce((n, source) => n + querySlots(source).length, 0)).toBe(5); }); it('dropping comment lines leaves the code that is being censused', () => { diff --git a/packages/rest/src/rest-server-docblock-position.test.ts b/packages/rest/src/rest-server-docblock-position.test.ts index 4fb14946df2..5d3dab6b2c6 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -248,6 +248,7 @@ const MOVED_FILE_FLOORS: Readonly = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -296,8 +297,12 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { // over seventeen, length 17 → 18 (`meta-item-history-routes.ts` 3 → 5: the // published read's host interface and function). // [#26433] The list's length is no longer pinned: the list is the authority. + // [#27627] 70 + 10 = 80: `record-page-routes.ts`, a new registrar rather + // than a moved slice, carries ten (the module header, the closed + // parameter set, the relationship types, the host interface and its + // member, five module-level helpers' blocks and the function's). expect(Object.keys(MOVED_FILE_FLOORS).sort()) .toEqual(REST_SERVER_REGISTRAR_FILES.filter((file) => file !== FILE).sort()); - expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(70); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(80); }); }); diff --git a/packages/rest/src/rest-server-registrar-files.ts b/packages/rest/src/rest-server-registrar-files.ts index 020a438009c..b9cdb67130f 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -69,6 +69,9 @@ export const REST_SERVER_REGISTRAR_FILES = [ // [#23050 slice 13e] The seventh `/meta` group of `registerMetadataEndpointsInner`' // body, the object state-machine read — see {@link REST_SERVER_META_ROUTE_FILES}. 'meta-object-state-routes.ts', + // [#27627] `registerRecordPageEndpoints`' body, the record page payload. New, not a + // moved slice: a registrar added beside the others joins the list in its own PR. + 'record-page-routes.ts', ] as const; /** One file on {@link REST_SERVER_REGISTRAR_FILES}. */ diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index e7edfcf3873..b78bc2119c3 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -302,6 +302,8 @@ import { } from './export-format.js'; // [#23050 slice 1] The data-action routes' body moved to its own module — see its header. import { registerDataActionEndpoints, type DataActionRouteHost } from './data-action-routes.js'; +// [#27627] The record page payload route — see its module header. +import { registerRecordPageEndpoints, type RecordPageRouteHost } from './record-page-routes.js'; // [#23050 slice 2] The batch routes' body moved to its own module — see its header. import { registerBatchEndpoints, type BatchRouteHost } from './batch-routes.js'; // [#23050 slice 3] The approval routes' body moved to its own module — see its header. @@ -4896,6 +4898,9 @@ export class RestServer { // deeper `:id/clone`, `:id/shares` paths), so it cannot shadow any // CRUD literal. this.registerDataActionEndpoints(bp); + // [#27627] `POST /data/:object/:id/page` — a deeper literal under + // `:id`, like `:id/clone`, so it shadows no CRUD route either way. + this.registerRecordPageEndpoints(bp); if (this.config.api.enableCrud) { this.registerCrudEndpoints(bp); } @@ -5643,6 +5648,32 @@ export class RestServer { registerDataActionEndpoints.call(host, basePath); } + /** + * [#27627] `POST {basePath}/data/:object/:id/page` — the record page + * payload: the record, its related lists' counts and first rows, its field + * projection, access verdicts and approval state in one request, each part + * the answer of the read it replaces (`record-page-routes.ts`). + */ + private registerRecordPageEndpoints(basePath: string): void { + const readApprovalsServiceProvider = () => this.approvalsServiceProvider; + const host: RecordPageRouteHost = { + config: this.config, + routeManager: this.routeManager, + get approvalsServiceProvider() { return readApprovalsServiceProvider(); }, + resolveProtocol: (...a) => this.resolveProtocol(...a), + resolveExecCtx: (...a) => this.resolveExecCtx(...a), + enforceAuth: (...a) => this.enforceAuth(...a), + enforceApiAccess: (...a) => this.enforceApiAccess(...a), + resolveSecurityService: (...a) => this.resolveSecurityService(...a), + loadObjectItem: (p, environmentId, name) => { + const listSource = this.metaListSource(p, environmentId); + const getObjectItem = this.objectItemReader(p, environmentId); + return metaReadGate.loadObjectItem(getObjectItem ? { ...listSource, getObjectItem } : listSource, name); + }, + }; + registerRecordPageEndpoints.call(host, basePath); + } + /** * [#18386] `GET {basePath}/data/:object/export?template=true` — the IMPORT * template: an xlsx workbook with a header row of the columns an import can diff --git a/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts b/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts index c83fbd317ce..f95ab08c12a 100644 --- a/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts +++ b/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts @@ -335,6 +335,15 @@ const DISPOSITIONS: Record = { return call(b, 'POST', `${D}/:object/query`, { params: { object: 'vault' }, body: {} }); }, }, + // [#27627] A READ over POST as well: the record page payload's `record` is + // `getData`'s answer, so the same read-path strip is asserted here. + [`POST ${D}/:object/:id/page`]: { + kind: 'driven', expectRecord: true, + invoke: async (b) => { + const id = await seed(b); + return call(b, 'POST', `${D}/:object/:id/page`, { params: { object: 'vault', id }, body: {} }); + }, + }, // ── NO RECORD ECHO: no user-object record in the body ───────────────────── // [#22437] This route used to 201 `createData`'s whole answer and was a diff --git a/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts b/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts index c0ffa77a342..a3524dbdc06 100644 --- a/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts +++ b/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts @@ -300,6 +300,12 @@ const ANSWERS_THE_SENTENCE: Record = { called: 'protocol.findData', body: {}, protocol: (error) => ({ findData: async () => { throw error(); } }), }, + // [#27627] A read on a write verb too: a `beforeFind` hook refusing the + // record read is the record page payload's whole answer. + 'POST /api/v1/data/:object/:id/page': { + called: 'protocol.getData', body: {}, + protocol: (error) => ({ getData: async () => { throw error(); } }), + }, 'PATCH /api/v1/data/:object/:id': { called: 'protocol.updateData', body: { name: 'x' } }, 'DELETE /api/v1/data/:object/:id': { called: 'protocol.deleteData' }, 'POST /api/v1/data/:object/:id/clone': { called: 'protocol.cloneData', body: {} }, diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 1b1751dbcd7..c2aa5fdb219 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -813,6 +813,9 @@ "QueryOptimizationConfig (type)", "QueryOptimizationConfigParsed (type)", "QueryOptimizationConfigSchema (const)", + "RECORD_PAGE_MAX_RELATED (const)", + "RECORD_PAGE_MAX_RELATED_ROWS (const)", + "RECORD_PAGE_MAX_RELATED_SORT (const)", "REGISTERED_ERROR_CODES (const)", "RawUploadResponse (type)", "RawUploadResponseParsed (type)", @@ -850,6 +853,39 @@ "ReassignOrphanedMetadataResponseSchema (const)", "RecordData (type)", "RecordDataSchema (const)", + "RecordPageApprovals (type)", + "RecordPageApprovalsParsed (type)", + "RecordPageApprovalsSchema (const)", + "RecordPageExplainAnswer (type)", + "RecordPageExplainAnswerParsed (type)", + "RecordPageExplainAnswerSchema (const)", + "RecordPageExplainOperation (type)", + "RecordPageExplainOperationParsed (type)", + "RecordPageExplainOperationSchema (const)", + "RecordPagePartError (type)", + "RecordPagePartErrorParsed (type)", + "RecordPagePartErrorSchema (const)", + "RecordPagePartRefusal (type)", + "RecordPagePartRefusalParsed (type)", + "RecordPagePartRefusalSchema (const)", + "RecordPageRelatedAnswer (type)", + "RecordPageRelatedAnswerParsed (type)", + "RecordPageRelatedAnswerSchema (const)", + "RecordPageRelatedRefusal (type)", + "RecordPageRelatedRefusalParsed (type)", + "RecordPageRelatedRefusalSchema (const)", + "RecordPageRelatedRequest (type)", + "RecordPageRelatedRequestParsed (type)", + "RecordPageRelatedRequestSchema (const)", + "RecordPageRelatedRows (type)", + "RecordPageRelatedRowsParsed (type)", + "RecordPageRelatedRowsSchema (const)", + "RecordPageRequest (type)", + "RecordPageRequestParsed (type)", + "RecordPageRequestSchema (const)", + "RecordPageResponse (type)", + "RecordPageResponseParsed (type)", + "RecordPageResponseSchema (const)", "RefreshTokenRequest (type)", "RefreshTokenRequestSchema (const)", "RegisterDeviceRequest (type)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 4b81acba36b..bd0af1506bb 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1424,6 +1424,36 @@ "api/ReassignOrphanedMetadataResponse:reassignedCount", "api/ReassignOrphanedMetadataResponse:success", "api/ReassignOrphanedMetadataResponse:targetPackageId", + "api/RecordPageApprovals:lock", + "api/RecordPageApprovals:requests", + "api/RecordPagePartError:error", + "api/RecordPagePartRefusal:code", + "api/RecordPagePartRefusal:message", + "api/RecordPagePartRefusal:status", + "api/RecordPageRelatedRefusal:error", + "api/RecordPageRelatedRefusal:field", + "api/RecordPageRelatedRefusal:object", + "api/RecordPageRelatedRequest:field", + "api/RecordPageRelatedRequest:filter", + "api/RecordPageRelatedRequest:object", + "api/RecordPageRelatedRequest:sort", + "api/RecordPageRelatedRequest:top", + "api/RecordPageRelatedRows:field", + "api/RecordPageRelatedRows:object", + "api/RecordPageRelatedRows:records", + "api/RecordPageRelatedRows:total", + "api/RecordPageRequest:approvals", + "api/RecordPageRequest:expand", + "api/RecordPageRequest:explain", + "api/RecordPageRequest:fieldAccess", + "api/RecordPageRequest:related", + "api/RecordPageResponse:approvals", + "api/RecordPageResponse:explain", + "api/RecordPageResponse:fieldAccess", + "api/RecordPageResponse:id", + "api/RecordPageResponse:object", + "api/RecordPageResponse:record", + "api/RecordPageResponse:related", "api/RefreshTokenRequest:refreshToken", "api/RegisterDeviceRequest:deviceId", "api/RegisterDeviceRequest:name", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index 855999bd636..787d2f41137 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -625,6 +625,28 @@ "ReassignOrphanedMetadataResponseSchema": "api/ReassignOrphanedMetadataResponse", "RecordData": "api/RecordData", "RecordDataSchema": "api/RecordData", + "RecordPageApprovals": "api/RecordPageApprovals", + "RecordPageApprovalsSchema": "api/RecordPageApprovals", + "RecordPageExplainAnswer": "api/RecordPageExplainAnswer", + "RecordPageExplainAnswerSchema": "api/RecordPageExplainAnswer", + "RecordPageExplainOperation": "api/RecordPageExplainOperation", + "RecordPageExplainOperationSchema": "api/RecordPageExplainOperation", + "RecordPagePartError": "api/RecordPagePartError", + "RecordPagePartErrorSchema": "api/RecordPagePartError", + "RecordPagePartRefusal": "api/RecordPagePartRefusal", + "RecordPagePartRefusalSchema": "api/RecordPagePartRefusal", + "RecordPageRelatedAnswer": "api/RecordPageRelatedAnswer", + "RecordPageRelatedAnswerSchema": "api/RecordPageRelatedAnswer", + "RecordPageRelatedRefusal": "api/RecordPageRelatedRefusal", + "RecordPageRelatedRefusalSchema": "api/RecordPageRelatedRefusal", + "RecordPageRelatedRequest": "api/RecordPageRelatedRequest", + "RecordPageRelatedRequestSchema": "api/RecordPageRelatedRequest", + "RecordPageRelatedRows": "api/RecordPageRelatedRows", + "RecordPageRelatedRowsSchema": "api/RecordPageRelatedRows", + "RecordPageRequest": "api/RecordPageRequest", + "RecordPageRequestSchema": "api/RecordPageRequest", + "RecordPageResponse": "api/RecordPageResponse", + "RecordPageResponseSchema": "api/RecordPageResponse", "RefreshTokenRequest": "api/RefreshTokenRequest", "RefreshTokenRequestSchema": "api/RefreshTokenRequest", "RegisterDeviceRequest": "api/RegisterDeviceRequest", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index bcf94723730..4b86af0ac78 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -784,6 +784,9 @@ "QueryOptimizationConfig": "src/api/contract.zod.ts#QueryOptimizationConfig (type)", "QueryOptimizationConfigParsed": "src/api/contract.zod.ts#QueryOptimizationConfigParsed (type)", "QueryOptimizationConfigSchema": "src/api/contract.zod.ts#QueryOptimizationConfigSchema (const)", + "RECORD_PAGE_MAX_RELATED": "src/api/record-page.zod.ts#RECORD_PAGE_MAX_RELATED (const)", + "RECORD_PAGE_MAX_RELATED_ROWS": "src/api/record-page.zod.ts#RECORD_PAGE_MAX_RELATED_ROWS (const)", + "RECORD_PAGE_MAX_RELATED_SORT": "src/api/record-page.zod.ts#RECORD_PAGE_MAX_RELATED_SORT (const)", "REGISTERED_ERROR_CODES": "src/api/error-code-ledger.zod.ts#REGISTERED_ERROR_CODES (const)", "RawUploadResponse": "src/api/storage.zod.ts#RawUploadResponse (type)", "RawUploadResponseParsed": "src/api/storage.zod.ts#RawUploadResponseParsed (type)", @@ -819,6 +822,39 @@ "ReassignOrphanedMetadataResponseSchema": "src/api/package-lifecycle.zod.ts#ReassignOrphanedMetadataResponseSchema (const)", "RecordData": "src/api/contract.zod.ts#RecordData (type)", "RecordDataSchema": "src/api/contract.zod.ts#RecordDataSchema (const)", + "RecordPageApprovals": "src/api/record-page.zod.ts#RecordPageApprovals (type)", + "RecordPageApprovalsParsed": "src/api/record-page.zod.ts#RecordPageApprovalsParsed (type)", + "RecordPageApprovalsSchema": "src/api/record-page.zod.ts#RecordPageApprovalsSchema (const)", + "RecordPageExplainAnswer": "src/api/record-page.zod.ts#RecordPageExplainAnswer (type)", + "RecordPageExplainAnswerParsed": "src/api/record-page.zod.ts#RecordPageExplainAnswerParsed (type)", + "RecordPageExplainAnswerSchema": "src/api/record-page.zod.ts#RecordPageExplainAnswerSchema (const)", + "RecordPageExplainOperation": "src/api/record-page.zod.ts#RecordPageExplainOperation (type)", + "RecordPageExplainOperationParsed": "src/api/record-page.zod.ts#RecordPageExplainOperationParsed (type)", + "RecordPageExplainOperationSchema": "src/api/record-page.zod.ts#RecordPageExplainOperationSchema (const)", + "RecordPagePartError": "src/api/record-page.zod.ts#RecordPagePartError (type)", + "RecordPagePartErrorParsed": "src/api/record-page.zod.ts#RecordPagePartErrorParsed (type)", + "RecordPagePartErrorSchema": "src/api/record-page.zod.ts#RecordPagePartErrorSchema (const)", + "RecordPagePartRefusal": "src/api/record-page.zod.ts#RecordPagePartRefusal (type)", + "RecordPagePartRefusalParsed": "src/api/record-page.zod.ts#RecordPagePartRefusalParsed (type)", + "RecordPagePartRefusalSchema": "src/api/record-page.zod.ts#RecordPagePartRefusalSchema (const)", + "RecordPageRelatedAnswer": "src/api/record-page.zod.ts#RecordPageRelatedAnswer (type)", + "RecordPageRelatedAnswerParsed": "src/api/record-page.zod.ts#RecordPageRelatedAnswerParsed (type)", + "RecordPageRelatedAnswerSchema": "src/api/record-page.zod.ts#RecordPageRelatedAnswerSchema (const)", + "RecordPageRelatedRefusal": "src/api/record-page.zod.ts#RecordPageRelatedRefusal (type)", + "RecordPageRelatedRefusalParsed": "src/api/record-page.zod.ts#RecordPageRelatedRefusalParsed (type)", + "RecordPageRelatedRefusalSchema": "src/api/record-page.zod.ts#RecordPageRelatedRefusalSchema (const)", + "RecordPageRelatedRequest": "src/api/record-page.zod.ts#RecordPageRelatedRequest (type)", + "RecordPageRelatedRequestParsed": "src/api/record-page.zod.ts#RecordPageRelatedRequestParsed (type)", + "RecordPageRelatedRequestSchema": "src/api/record-page.zod.ts#RecordPageRelatedRequestSchema (const)", + "RecordPageRelatedRows": "src/api/record-page.zod.ts#RecordPageRelatedRows (type)", + "RecordPageRelatedRowsParsed": "src/api/record-page.zod.ts#RecordPageRelatedRowsParsed (type)", + "RecordPageRelatedRowsSchema": "src/api/record-page.zod.ts#RecordPageRelatedRowsSchema (const)", + "RecordPageRequest": "src/api/record-page.zod.ts#RecordPageRequest (type)", + "RecordPageRequestParsed": "src/api/record-page.zod.ts#RecordPageRequestParsed (type)", + "RecordPageRequestSchema": "src/api/record-page.zod.ts#RecordPageRequestSchema (const)", + "RecordPageResponse": "src/api/record-page.zod.ts#RecordPageResponse (type)", + "RecordPageResponseParsed": "src/api/record-page.zod.ts#RecordPageResponseParsed (type)", + "RecordPageResponseSchema": "src/api/record-page.zod.ts#RecordPageResponseSchema (const)", "RefreshTokenRequest": "src/api/auth.zod.ts#RefreshTokenRequest (type)", "RefreshTokenRequestSchema": "src/api/auth.zod.ts#RefreshTokenRequestSchema (const)", "RegisterDeviceRequest": "src/api/protocol.zod.ts#RegisterDeviceRequest (type)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 9fa67298c08..b5e02e95fee 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -341,6 +341,17 @@ "api/RealtimeUnsubscribeResponse", "api/ReassignOrphanedMetadataResponse", "api/RecordData", + "api/RecordPageApprovals", + "api/RecordPageExplainAnswer", + "api/RecordPageExplainOperation", + "api/RecordPagePartError", + "api/RecordPagePartRefusal", + "api/RecordPageRelatedAnswer", + "api/RecordPageRelatedRefusal", + "api/RecordPageRelatedRequest", + "api/RecordPageRelatedRows", + "api/RecordPageRequest", + "api/RecordPageResponse", "api/RefreshTokenRequest", "api/RegisterDeviceRequest", "api/RegisterDeviceResponse", diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index 70c1f893bf4..5dbcbf952a3 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -77,7 +77,7 @@ const query = { --- -## 3. Schema Inventory by Domain (210 schemas) +## 3. Schema Inventory by Domain (211 schemas) Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number @@ -88,7 +88,7 @@ here is verifiable from the installed package. | system | 37 | Auth, Cache, Demo Staff, Dev Login, Email / SMS Config, Encryption, Fate Supplement (organization ownership), HTTP Server, License, Logging, Metrics | | kernel | 30 | Plugin, Manifest, Events (6 sub-modules), Feature, Context, Package Registry | | data | 32 | Object, Field, Picklist, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube, Seed | -| api | 31 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage) | +| api | 32 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage), Record Page | | ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation | | automation | 15 | Flow, Approval, Assignment Rule, BPMN Interop, Control Flow, Webhook, Schedule Organization | | shared | 16 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Conditional Formatting, Value Domain, Epoch Instant (EpochMs), Duration (DurationMs / DurationSeconds) | diff --git a/packages/spec/src/api/index.ts b/packages/spec/src/api/index.ts index efd1fcc221f..2ddabca24d0 100644 --- a/packages/spec/src/api/index.ts +++ b/packages/spec/src/api/index.ts @@ -51,6 +51,7 @@ export * from './websocket.zod'; export * from './router.zod'; export * from './odata.zod'; export * from './batch.zod'; +export * from './record-page.zod'; export * from './http-cache.zod'; export * from './errors.zod'; // The ADR-0114 D3 boundary mapper — the ONE implementation of Zod issue code → diff --git a/packages/spec/src/api/record-page.test.ts b/packages/spec/src/api/record-page.test.ts new file mode 100644 index 00000000000..245233ca92d --- /dev/null +++ b/packages/spec/src/api/record-page.test.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { + RecordPageExplainOperationSchema, + RecordPageRelatedAnswerSchema, + RecordPageResponseSchema, +} from './record-page.zod'; + +const refusal = { code: 'PERMISSION_DENIED', status: 403, message: 'No.' }; + +describe('[#27627] a related list answers its rows or its refusal, never both and never neither', () => { + it('takes a count with rows, and a refusal alone', () => { + expect(RecordPageRelatedAnswerSchema.safeParse({ object: 'note', field: 'account', total: 2, records: [{ id: 'n1' }] }).success).toBe(true); + expect(RecordPageRelatedAnswerSchema.safeParse({ object: 'note', field: 'account', error: refusal }).success).toBe(true); + }); + + it('refuses a refusal carrying a count or rows, and an answer carrying neither', () => { + for (const answer of [ + { object: 'note', field: 'account', total: 0, error: refusal }, + { object: 'note', field: 'account', records: [], error: refusal }, + { object: 'note', field: 'account', total: 2, records: [], error: refusal }, + { object: 'note', field: 'account' }, + { object: 'note', field: 'account', total: 2 }, + ]) { + expect(RecordPageRelatedAnswerSchema.safeParse(answer).success, JSON.stringify(answer)).toBe(false); + } + }); +}); + +describe('[#27627] the explain part carries exactly the operations the request admits', () => { + it('has one optional key per request operation, and no other', () => { + const explain = (RecordPageResponseSchema as any).shape.explain.unwrap(); + expect(Object.keys(explain.shape).sort()).toEqual([...RecordPageExplainOperationSchema.options].sort()); + const base = { object: 'account', id: 'a1', record: { id: 'a1' }, related: [] }; + expect(RecordPageResponseSchema.safeParse({ ...base, explain: {} }).success).toBe(true); + for (const operation of RecordPageExplainOperationSchema.options) { + const answer = { ...base, explain: { [operation]: { error: refusal } } }; + expect(RecordPageResponseSchema.safeParse(answer).success, operation).toBe(true); + } + }); +}); diff --git a/packages/spec/src/api/record-page.zod.ts b/packages/spec/src/api/record-page.zod.ts new file mode 100644 index 00000000000..160e306ec6b --- /dev/null +++ b/packages/spec/src/api/record-page.zod.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; +import { ExplainDecisionSchema } from '../security/explain.zod'; +import { GetRecordFieldAccessResponseSchema } from './protocol.zod'; + +/** + * Record page payload — `POST /api/v1/data/{object}/{id}/page`. + * + * One request that answers everything a record page asks of the server when it + * opens: the record (with the lookups it names resolved), the count and first + * rows of every related list the page draws, the caller's field projection of + * the record, the approval state that locks it, and the record-grained access + * verdicts its header actions are gated on. + * + * The request NAMES the parts. Which related lists a page draws (their order, + * their declared scope, which of them the reader may see) is the page's own + * derivation, so the page sends the lists and the server answers each one; the + * server never derives a list of its own. What the server owns is the parent + * condition: each list is the child records whose `field` points at this + * record, compiled to the field's arity, and AND-composed with the list's + * declared `filter` — a declared filter can only narrow it. + * + * Every part is the answer of the read it replaces, asked as the same caller: + * the record is `GET /data/{object}/{id}`, a related list is + * `GET /data/{child}` with that filter and `$count`, `fieldAccess` is + * `GET /data/{object}/{id}/field-access`, `explain` is + * `POST /security/explain` with the record id, and `approvals` is + * `GET /approvals/requests` for the record plus + * `GET /approvals/records/{object}/{id}/lock`. Object permissions, row-level + * security, sharing and field-level security therefore apply exactly as they + * apply to those reads. A part whose read refuses carries that refusal in place + * of an answer — a related list the caller may not read carries neither a + * count nor rows — and the other parts are answered. A record the caller + * cannot read refuses the whole request with the record read's own answer, so + * no part discloses a record the caller could not open. + */ + +/** The most related lists one request may name. */ +export const RECORD_PAGE_MAX_RELATED = 50; +/** The most rows one related list may ask for. */ +export const RECORD_PAGE_MAX_RELATED_ROWS = 50; +/** The most sort keys one related list may order its rows by. */ +export const RECORD_PAGE_MAX_RELATED_SORT = 8; + +/** The record-grained operations a record page's header asks about. */ +export const RecordPageExplainOperationSchema = z.enum(['update', 'delete', 'transfer']); +export type RecordPageExplainOperation = z.input; +export type RecordPageExplainOperationParsed = z.infer; + +/** One related list a record page draws. */ +export const RecordPageRelatedRequestSchema = lazySchema(() => z.object({ + object: z.string().min(1).describe('The child object whose records the list shows.'), + field: z.string().min(1).describe( + 'The relationship field on the child object that points at this record (a lookup or master-detail field ' + + 'referencing the page\'s object). The list is the child records whose field holds this record\'s id.', + ), + filter: z.unknown().optional().describe( + 'The list\'s own declared scope, in the filter syntax `GET /data/{object}` accepts. AND-composed with the ' + + 'parent condition, so it can only narrow the list. A filter that syntax refuses refuses the whole request ' + + '(a located 400 at `related.N.filter`) before anything is read.', + ), + top: z.number().int().min(0).max(RECORD_PAGE_MAX_RELATED_ROWS).describe( + 'How many of the list\'s first rows to return with its count; 0 returns the count alone.', + ), + sort: z.array(z.object({ + field: z.string().min(1).describe('A field of the child object.'), + order: z.enum(['asc', 'desc']).describe('The direction.'), + }).strict()).max(RECORD_PAGE_MAX_RELATED_SORT).optional().describe( + 'The order of the list\'s first rows, as `GET /data/{object}` orders them by `$orderby`. Absent for the default order.', + ), +}).strict()); +export type RecordPageRelatedRequest = z.input; +export type RecordPageRelatedRequestParsed = z.infer; + +/** The request body of `POST /data/{object}/{id}/page`. */ +export const RecordPageRequestSchema = lazySchema(() => z.object({ + expand: z.array(z.string().min(1)).optional().describe( + 'Lookup fields of the record to resolve, as `GET /data/{object}/{id}?expand=` resolves them.', + ), + related: z.array(RecordPageRelatedRequestSchema).max(RECORD_PAGE_MAX_RELATED).optional().describe( + 'The related lists the page draws, in the order it draws them. Each is answered with its count and first rows.', + ), + explain: z.array(RecordPageExplainOperationSchema).optional().describe( + 'The operations to answer a record-grained access verdict for, as `POST /security/explain` answers them ' + + 'with this record id.', + ), + fieldAccess: z.boolean().optional().describe( + 'Return the caller\'s field projection of the record, as `GET /data/{object}/{id}/field-access` does.', + ), + approvals: z.boolean().optional().describe( + 'Return the record\'s approval requests and its approval lock.', + ), +}).strict()); +export type RecordPageRequest = z.input; +export type RecordPageRequestParsed = z.infer; + +/** The refusal a replaced read answers: its code, status and message. */ +export const RecordPagePartRefusalSchema = lazySchema(() => z.object({ + code: z.string().describe('The error code the replaced read answers.'), + status: z.number().int().describe('The HTTP status the replaced read answers.'), + message: z.string().describe('The message the replaced read answers.'), +})); +export type RecordPagePartRefusal = z.input; +export type RecordPagePartRefusalParsed = z.infer; + +/** A part whose read refused, carried in place of that part's answer. */ +export const RecordPagePartErrorSchema = lazySchema(() => z.object({ + error: RecordPagePartRefusalSchema.describe('The refusal the replaced read answers, in place of this part\'s answer.'), +})); +export type RecordPagePartError = z.input; +export type RecordPagePartErrorParsed = z.infer; + +/** A related list the caller may read: its count and first rows. */ +export const RecordPageRelatedRowsSchema = lazySchema(() => z.object({ + object: z.string().describe('The child object, as the request named it.'), + field: z.string().describe('The relationship field, as the request named it.'), + total: z.number().int().describe('How many child records the list holds for this caller.'), + records: z.array(z.record(z.string(), z.unknown())).describe('The list\'s first rows, as the caller may read them.'), +}).strict()); +export type RecordPageRelatedRows = z.input; +export type RecordPageRelatedRowsParsed = z.infer; + +/** A related list whose read refused: the refusal, and neither a count nor rows. */ +export const RecordPageRelatedRefusalSchema = lazySchema(() => z.object({ + object: z.string().describe('The child object, as the request named it.'), + field: z.string().describe('The relationship field, as the request named it.'), + error: RecordPagePartRefusalSchema.describe('The refusal of the list\'s read.'), +}).strict()); +export type RecordPageRelatedRefusal = z.input; +export type RecordPageRelatedRefusalParsed = z.infer; + +/** + * One related list's answer, in the order the request named the lists: its + * count and rows, or its refusal. Never both, and never neither, so a refused + * list cannot be read as an empty one. + */ +export const RecordPageRelatedAnswerSchema = lazySchema(() => z.union([RecordPageRelatedRowsSchema, RecordPageRelatedRefusalSchema])); +export type RecordPageRelatedAnswer = z.input; +export type RecordPageRelatedAnswerParsed = z.infer; + +/** The approval part: the record's requests and its lock. */ +export const RecordPageApprovalsSchema = lazySchema(() => z.object({ + requests: z.array(z.record(z.string(), z.unknown())).describe( + 'The record\'s approval requests the caller may see, as `GET /approvals/requests` lists them for the record; ' + + 'empty when the deployment runs no approval service.', + ), + lock: z.object({ locked: z.boolean() }).nullable().describe( + 'Whether a locking approval holds the record; null when the deployment answers no lock.', + ), +})); +export type RecordPageApprovals = z.input; +export type RecordPageApprovalsParsed = z.infer; + +/** One requested operation's access decision, or the refusal of the explain read. */ +export const RecordPageExplainAnswerSchema = lazySchema(() => z.union([ExplainDecisionSchema, RecordPagePartErrorSchema])); +export type RecordPageExplainAnswer = z.input; +export type RecordPageExplainAnswerParsed = z.infer; + +/** + * The explain part's keys are the request enum's own options, so an operation + * the request admits is always one the answer can carry. + */ +const explainAnswerShape = () => Object.fromEntries( + RecordPageExplainOperationSchema.options.map((operation) => [operation, RecordPageExplainAnswerSchema.optional()]), +) as { [K in z.infer]: z.ZodOptional }; + +/** The `data` of the answer to `POST /data/{object}/{id}/page`. */ +export const RecordPageResponseSchema = lazySchema(() => z.object({ + object: z.string().describe('The record\'s object.'), + id: z.string().describe('The record id.'), + record: z.record(z.string(), z.unknown()).describe('The record, as `GET /data/{object}/{id}` answers it.'), + related: z.array(RecordPageRelatedAnswerSchema).describe('One answer per requested related list, in request order.'), + fieldAccess: z.union([GetRecordFieldAccessResponseSchema, RecordPagePartErrorSchema]).optional().describe( + 'The field projection, when the request asked for it.', + ), + explain: z.object(explainAnswerShape()).optional().describe('One access decision per requested operation, keyed by operation, when the request asked for any.'), + approvals: z.union([RecordPageApprovalsSchema, RecordPagePartErrorSchema]).optional().describe( + 'The approval part, when the request asked for it.', + ), +})); +export type RecordPageResponse = z.input; +export type RecordPageResponseParsed = z.infer; diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index a1038278a45..9615deee184 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -591,6 +591,21 @@ const MODULES = { ratchet: '#9559 (option 1: convert onto the shared sendOk/sendError)', }, + // [#27627] The record page payload (`POST /data/:object/:id/page`), declared + // in the PR that created it. The answer is `sendOk` and the two request + // refusals are `sendError`. The one write site is the catch, which relays + // `mapDataError`'s `{ status, body }` exactly as `GET /data/:object/:id` + // (`crud-routes.ts`) does, because a record the caller cannot read must + // refuse this request with the record read's own answer, byte for byte + // (`record-page.dogfood.test.ts` compares the two). It moves to the shared + // pair with the rest of the data family under #9559 option 1, never alone. + 'packages/rest/src/record-page-routes.ts': { + responses: 1, + ok: 0, + err: 0, + ratchet: '#9559 (option 1: convert onto the shared sendOk/sendError)', + }, + // [#23050 slice 1] `RestServer#registerDataActionEndpoints`' body — record // clone, the import doors and import-job reads, the export — moved out of // `rest-server.ts` whole. A MOVE, no wire answer changed, so it is held to diff --git a/ui/packages/app-shell/src/hooks/useRecordApprovals.ts b/ui/packages/app-shell/src/hooks/useRecordApprovals.ts index 84a17d5e659..8f1f195e286 100644 --- a/ui/packages/app-shell/src/hooks/useRecordApprovals.ts +++ b/ui/packages/app-shell/src/hooks/useRecordApprovals.ts @@ -578,8 +578,43 @@ async function readRecordLock(objectName: string, recordId: string): Promise<{ l } } +/** + * What the record page's ONE read answered for this record's approvals + * (objectstack#27627): the request rows, the pending one already enriched by + * `getRequest`, and the record lock (`null` when the deployment answers no + * lock). A refused part rejects with the refusal's `status`, as `fetchJson` + * throws a refused read. + */ +export interface SeededRecordApprovals { + requests: ApprovalRequestLite[]; + lock: { locked: boolean } | null; +} + +/** + * Hand the record's approvals read to the payload the record page asked + * before any consumer mounted (objectstack#27627): it becomes the read on the + * wire for this record, which every consumer that mounts while it is out + * joins, and its answer is published exactly as a read's. A read already on + * the wire for the record is left as it is. `refresh()` still reads. + */ +export function seedRecordApprovals( + objectName: string, + recordId: string, + answer: Promise, +): void { + const key = JSON.stringify([objectName, recordId]); + if (inFlight.has(key)) return; + void startRead(key, objectName, recordId, true, answer); +} + /** Send the record's read and publish its answer to every consumer of the key. */ -function startRead(key: string, objectName: string, recordId: string, readsLock: boolean): Promise { +function startRead( + key: string, + objectName: string, + recordId: string, + readsLock: boolean, + seeded?: Promise, +): Promise { const seq = ++readSeq; latestSeq.set(key, seq); const entry = entries.get(key); @@ -587,6 +622,23 @@ function startRead(key: string, objectName: string, recordId: string, readsLock: publish(key, { loading: true }); const promise = (async () => { try { + if (seeded) { + // The payload asked the list, the pending row's `getRequest` and the + // lock as one read; the rows come enriched and the lock is answered + // whether or not a pending row decides it, so it is read as below. + const answer = await seeded; + const rows = answer.requests ?? []; + const pending = rows.find((r) => r.status === 'pending'); + if (!answer.lock) recordLockAbsent = true; + if (latestSeq.get(key) !== seq) return; + publish(key, { + rows, + available: true, + answered: true, + recordLock: !pending && answer.lock ? { locked: answer.lock.locked === true } : null, + }); + return; + } const reqResp = await fetchJson<{ data: ApprovalRequestLite[] }>( `/approvals/requests?object=${encodeURIComponent(objectName)}&recordId=${encodeURIComponent(recordId)}`, ); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.afterActionExplain-13341.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.afterActionExplain-13341.test.tsx index d08af1d2f45..22b1ff66a6a 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.afterActionExplain-13341.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.afterActionExplain-13341.test.tsx @@ -28,6 +28,9 @@ * The transport HOLDS every request until the test releases it, so "on the * wire" is literal and every count is a count of requests sent. * + * Since objectstack#27627 the open asks its questions inside the record page + * payload (`POST /data/OBJ/ID/page`); a question it carries counts as asked. + * * Controls, each against the hole a fix could leave: * - the re-check is still sent (one fresh request, not zero), and the * header follows its answer: a save that revokes the edit hides Edit; @@ -91,7 +94,23 @@ function json(body: unknown): Response { return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); } +const PAGE = `/api/v1/data/${OBJECT}/${REC}/page`; +const isPayload = (h: Held) => h.method === 'POST' && h.path === PAGE; + function answerFor(h: Held): unknown { + if (isPayload(h)) { + const asked = JSON.parse(h.body ?? '{}') as { explain?: string[] }; + return { + success: true, + data: { + object: OBJECT, + id: REC, + record: RECORD, + related: [], + explain: Object.fromEntries((asked.explain ?? []).map((op) => [op, { allowed: true, record: { recordId: REC, visible: verdicts[op] } }])), + }, + }; + } if (h.path.endsWith('/security/explain')) { const { operation } = JSON.parse(h.body ?? '{}') as { operation: string }; return { allowed: true, record: { recordId: REC, visible: verdicts[operation] } }; @@ -155,13 +174,22 @@ async function drain(held: Held[]) { const isExplain = (h: Held) => h.method === 'POST' && h.path.endsWith('/security/explain'); -/** How many requests asked each `object/recordId/operation`, from `from` on. */ +/** + * How many requests asked each `object/recordId/operation`, from `from` on: an + * explain request, or a record page payload carrying the question. + */ function perQuestion(held: Held[], from: number): Record { const out: Record = {}; - for (const h of held.slice(from).filter(isExplain)) { - const q = JSON.parse(h.body ?? '{}') as { object: string; recordId: string; operation: string }; - const k = `${q.object}/${q.recordId}/${q.operation}`; - out[k] = (out[k] ?? 0) + 1; + for (const h of held.slice(from)) { + const asked = isExplain(h) + ? [JSON.parse(h.body ?? '{}') as { object: string; recordId: string; operation: string }] + : isPayload(h) + ? ((JSON.parse(h.body ?? '{}').explain ?? []) as string[]).map((operation) => ({ object: OBJECT, recordId: REC, operation })) + : []; + for (const q of asked) { + const k = `${q.object}/${q.recordId}/${q.operation}`; + out[k] = (out[k] ?? 0) + 1; + } } return out; } diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordOpenRequests-11699.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenRequests-11699.test.tsx index 4e7c6e70e76..b288e0370ed 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.recordOpenRequests-11699.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenRequests-11699.test.tsx @@ -64,6 +64,11 @@ * Two live controls keep the key from being merely sticky: a definition whose * relations change, and a permission answer that denies one (objectui#7230), * each read the record again with the new expansion. + * + * Since objectstack#27627 the open's record read is the record page payload + * (`POST /data/OBJ/ID/page`), which also asks the explain questions, so the + * page's read below is that request, its expansion is the payload's `expand`, + * and each question is counted once whichever request carried it. */ import * as React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -188,18 +193,38 @@ function json(body: unknown): Response { } const RECORD_GET = `/api/v1/data/${OBJECT}/${REC}`; -const isRecordRead = (h: Held) => h.method === 'GET' && h.path.startsWith(`/api/v1/data/${OBJECT}`); -/** The page's own read: `findOne` with `$expand` goes out as a one-row `populate` list read. */ +const PAGE = `${RECORD_GET}/page`; +const isPayloadRead = (h: Held) => h.method === 'POST' && h.path === PAGE; +const isRecordRead = (h: Held) => isPayloadRead(h) || (h.method === 'GET' && h.path.startsWith(`/api/v1/data/${OBJECT}`)); +/** + * The page's own read: the open's payload, or `findOne` with `$expand`, which + * goes out as a one-row `populate` list read. + */ const isPageRecordRead = (h: Held) => - h.method === 'GET' && h.path.startsWith(`/api/v1/data/${OBJECT}?`) && h.path.includes('populate='); + isPayloadRead(h) || (h.method === 'GET' && h.path.startsWith(`/api/v1/data/${OBJECT}?`) && h.path.includes('populate=')); /** The relations a page read asked the server to expand, sorted. */ const expansionOf = (h: Held) => - (new URLSearchParams(h.path.slice(h.path.indexOf('?') + 1)).get('populate') ?? '') - .split(',').filter(Boolean).sort(); + (isPayloadRead(h) + ? [...(JSON.parse(h.body ?? '{}').expand ?? [])] + : (new URLSearchParams(h.path.slice(h.path.indexOf('?') + 1)).get('populate') ?? '').split(',').filter(Boolean) + ).sort(); const pageReads = (held: Held[]) => held.filter(isPageRecordRead); /** Answer a held request the way the platform would. */ function answerFor(h: Held): unknown { + if (isPayloadRead(h)) { + const asked = JSON.parse(h.body ?? '{}'); + return { + success: true, + data: { + object: OBJECT, + id: REC, + record: RECORD, + related: [], + explain: Object.fromEntries((asked.explain ?? []).map((op: string) => [op, { allowed: true, record: { recordId: REC, visible: true } }])), + }, + }; + } if (h.path.endsWith('/security/explain')) return { allowed: true, record: { recordId: REC, visible: true } }; if (h.path === RECORD_GET) return { success: true, data: { object: OBJECT, id: REC, record: RECORD } }; if (h.path.startsWith(`/api/v1/data/${OBJECT}?`)) return { success: true, data: { records: [RECORD], total: 1 } }; @@ -386,12 +411,16 @@ describe('record page open: one record GET and one explain per distinct question expect(pageReads(held)).toHaveLength(1); expect(expansionOf(pageReads(held)[0])).toEqual(['assignee', 'project']); + // objectstack#27627 — the questions ride the page's read; none is asked + // on its own. const explains = held.filter((h) => h.method === 'POST' && h.path.endsWith('/security/explain')); + expect(explains).toHaveLength(0); const perQuestion = new Map(); - for (const h of explains) { - const q = JSON.parse(h.body ?? '{}'); - const k = `${q.object}/${q.recordId}/${q.operation}`; - perQuestion.set(k, (perQuestion.get(k) ?? 0) + 1); + for (const h of pageReads(held)) { + for (const operation of JSON.parse(h.body ?? '{}').explain ?? []) { + const k = `${OBJECT}/${REC}/${operation}`; + perQuestion.set(k, (perQuestion.get(k) ?? 0) + 1); + } } expect(Object.fromEntries(perQuestion)).toEqual({ [`${OBJECT}/${REC}/update`]: 1, diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordOpenRequests-12701.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenRequests-12701.test.tsx index 30f421de091..b2e94a9c094 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.recordOpenRequests-12701.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenRequests-12701.test.tsx @@ -28,6 +28,10 @@ * counts requests on the wire. The transport HOLDS every request until the test * releases it, the page's side reads first, so every consumer has mounted * while the reads it would share are still pending. + * + * Since objectstack#27627 the open's ONE read is the record page payload + * (`POST /data/{object}/{id}/page`), which carries the record and the approval + * state together, so the open sends no approvals read of its own at all. */ import * as React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -127,6 +131,7 @@ const RECORD_PAGE = { interface Held { method: string; path: string; + body?: any; released: boolean; release: () => void; } @@ -137,16 +142,34 @@ function json(body: unknown): Response { const APPROVALS_LIST = `/api/v1/approvals/requests?object=${OBJECT}&recordId=${REC}`; const APPROVAL_SINGLE = `/api/v1/approvals/requests/${REQUEST_ID}`; -/** Every read of the record: the page's `populate` read, or `GET /data/OBJ/ID`. */ -const isRecordRead = (h: Held) => h.method === 'GET' && h.path.startsWith(`/api/v1/data/${OBJECT}`); +const PAGE = `/api/v1/data/${OBJECT}/${REC}/page`; +const isPageRead = (h: Held) => h.method === 'POST' && h.path === PAGE; +/** Every read of the record: the open's payload, the `populate` read, or `GET /data/OBJ/ID`. */ +const isRecordRead = (h: Held) => isPageRead(h) || (h.method === 'GET' && h.path.startsWith(`/api/v1/data/${OBJECT}`)); const isApprovalsRead = (h: Held) => h.path === APPROVALS_LIST; const isActivityRead = (h: Held) => h.method === 'GET' && h.path.startsWith('/api/v1/data/sys_activity'); /** The relations a record read asked the server to expand, sorted. */ const expansionOf = (h: Held) => - (new URLSearchParams(h.path.slice(h.path.indexOf('?') + 1)).get('populate') ?? '') - .split(',').filter(Boolean).sort(); + (isPageRead(h) + ? [...(h.body?.expand ?? [])] + : (new URLSearchParams(h.path.slice(h.path.indexOf('?') + 1)).get('populate') ?? '').split(',').filter(Boolean) + ).sort(); function answerFor(h: Held): unknown { + if (isPageRead(h)) { + return { + success: true, + data: { + object: OBJECT, + id: REC, + record: RECORD, + related: [], + explain: Object.fromEntries((h.body?.explain ?? []).map((op: string) => [op, { allowed: true, record: { recordId: REC, visible: true } }])), + // The pending row comes enriched by its single read, as the route folds it. + approvals: { requests: [{ ...PENDING, viewer: { can_act: false, is_submitter: true } }], lock: { locked: true } }, + }, + }; + } if (h.path.endsWith('/security/explain')) return { allowed: true, record: { recordId: REC, visible: true } }; if (h.path === APPROVALS_LIST) return { data: [PENDING] }; if (h.path === APPROVAL_SINGLE) return { ...PENDING, viewer: { can_act: false, is_submitter: true } }; @@ -170,6 +193,7 @@ function heldTransport() { const h: Held = { method, path, + body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, released: false, release: () => { if (h.released) return; @@ -269,16 +293,18 @@ afterEach(() => { }); describe('a record open sends one approvals read and one record read (objectui#12701)', () => { - it('counts the requests by URL: approvals 1, record 1, no activity read on a page that draws none', async () => { + it('counts the requests by URL: one page read carrying the record and the approvals, no activity read on a page that draws none', async () => { const { held } = await openRecordPage(); await waitFor(() => expect(screen.getAllByText(RECORD.stage).length).toBeGreaterThan(0)); - expect(held.filter(isApprovalsRead), 'approvals list reads').toHaveLength(1); - // The pending row's single-read enrichment rides the one list read. - expect(held.filter((h) => h.path === APPROVAL_SINGLE), 'approvals single reads').toHaveLength(1); + // objectstack#27627 — the approval list, its pending row's single read and + // the lock ride the open's one page read. + expect(held.filter(isApprovalsRead), 'approvals list reads').toHaveLength(0); + expect(held.filter((h) => h.path === APPROVAL_SINGLE), 'approvals single reads').toHaveLength(0); const recordReads = held.filter(isRecordRead); - expect(recordReads.map((h) => h.path), 'record reads').toHaveLength(1); + expect(recordReads.map((h) => h.path), 'record reads').toEqual([PAGE]); + expect(recordReads[0].body?.approvals).toBe(true); // The one read carries every relation the page's sections draw, and more. expect(expansionOf(recordReads[0])).toEqual(['account', 'created_by', 'owner']); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx index 18476c35700..cb73e4572c0 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx @@ -22,6 +22,11 @@ * the WAVE it went out in. A wave is one round of the held transport: every * request the page has issued is answered together, then the page is allowed * to settle, and whatever it asks for next is the next wave. + * + * Since objectstack#27627 the page asks all of it in ONE record page payload + * (`POST /data/OBJ/ID/page`): the record, each related list's count and first + * rows, and the access verdicts. The pins below read that request's body for + * the questions the separate reads used to ask. */ import * as React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -205,11 +210,35 @@ const topOf = (h: Held): number | undefined => { return top === null ? undefined : Number(top); }; const objectOf = (h: Held): string | undefined => /\/api\/v1\/data\/([^/?]+)/.exec(h.path)?.[1]; -const isRecordRead = (h: Held) => h.method === 'GET' && objectOf(h) === OBJECT; +const PAGE = `/api/v1/data/${OBJECT}/${REC}/page`; +const isPageRead = (h: Held) => h.method === 'POST' && h.path === PAGE; +/** The record read: the page payload carries it (objectstack#27627). */ +const isRecordRead = (h: Held) => isPageRead(h) || (h.method === 'GET' && objectOf(h) === OBJECT); +interface PageList { object: string; field: string; filter?: unknown; top: number } +const pageBody = (h: Held) => JSON.parse(h.body || '{}') as { related?: PageList[]; explain?: string[] }; +/** The related lists the open's page payload asked for. */ +const pageLists = (held: Held[]): PageList[] => held.filter(isPageRead).flatMap((h) => pageBody(h).related ?? []); const isChildRead = (h: Held) => h.method === 'GET' && [QUOTE, LINE, TASK].includes(objectOf(h) ?? ''); const isExplain = (h: Held) => h.path.endsWith('/security/explain'); function answerFor(h: Held): unknown { + if (isPageRead(h)) { + const body = pageBody(h); + return { + success: true, + data: { + object: OBJECT, + id: REC, + record: RECORD, + related: (body.related ?? []).map((list) => { + const total = TOTALS[list.object] ?? 0; + return { object: list.object, field: list.field, total, records: childRows(list.object, Math.min(list.top, total)) }; + }), + explain: Object.fromEntries((body.explain ?? []).map((op) => [op, { allowed: true, record: { recordId: REC, visible: true } }])), + approvals: { requests: [], lock: null }, + }, + }; + } if (isExplain(h)) return { allowed: true, record: { recordId: REC, visible: true } }; if (h.path.startsWith(`/api/v1/data/${OBJECT}/${REC}`)) return { success: true, data: { object: OBJECT, id: REC, record: RECORD } }; const object = objectOf(h); @@ -373,19 +402,19 @@ describe('the record page open waves (objectui#13010)', () => { // explain questions (one `update`, one `delete` — two different bodies, not // one question sent twice) and the rail's three `top=3` card reads in wave // 1; the tab strip's three `top=1` badge probes in wave 2, because the probe - // keyed on the record BODY's id. After it: all thirteen in wave 1. + // keyed on the record BODY's id. After it: all thirteen in wave 1. Since + // objectstack#27627 all of them are ONE page payload request. it('the related cards and the tab badge probes go out in the first wave, beside the record read', async () => { const { held } = await openRecordPage(); const table = waveTable(held); expect(held.filter(isRecordRead).map((h) => h.wave), table).toEqual([1]); - const cards = held.filter((h) => isChildRead(h) && topOf(h) === 3); - expect(cards.map((h) => objectOf(h)).sort(), table).toEqual([LINE, QUOTE, TASK].sort()); - expect(cards.every((h) => h.wave === 1), table).toBe(true); - - const probes = held.filter((h) => isChildRead(h) && topOf(h) === 1); - expect(probes.length, table).toBeGreaterThan(0); - expect(probes.every((h) => h.wave === 1), table).toBe(true); + // Neither a card read nor a badge probe goes out on its own. + expect(held.filter(isChildRead), table).toEqual([]); + // The payload asks every card's rows: three each. + const cards = pageLists(held).filter((l) => l.filter === undefined); + expect(cards.map((l) => l.object).sort(), table).toEqual([LINE, QUOTE, TASK].sort()); + expect(cards.every((l) => l.top === 3), table).toBe(true); // One wave in all: nothing waits on the record body any more. expect(Math.max(...held.map((h) => h.wave)), table).toBe(1); @@ -403,8 +432,9 @@ describe('the record page open waves (objectui#13010)', () => { const { held } = await openRecordPage(); const table = waveTable(held); for (const object of [QUOTE, LINE]) { - const reads = held.filter((h) => objectOf(h) === object); - expect(reads.map((h) => topOf(h)), `${object}\n${table}`).toEqual([3]); + expect(held.filter((h) => objectOf(h) === object), `${object}\n${table}`).toEqual([]); + const asked = pageLists(held).filter((l) => l.object === object); + expect(asked.map((l) => l.top), `${object}\n${table}`).toEqual([3]); } // The badge total is drawn from that read: quote 6 + line 7 + the // filtered task probe's own 4. @@ -423,19 +453,23 @@ describe('the record page open waves (objectui#13010)', () => { it('CONTROL: a badge whose declared filter differs from its card still probes, with its own filter', async () => { const { held } = await openRecordPage(); const table = waveTable(held); - const taskProbes = held.filter((h) => objectOf(h) === TASK && topOf(h) === 1); - expect(taskProbes, table).toHaveLength(1); - expect(taskProbes[0].path, table).toContain('not_equals'); - // The card's read carries no declared scope, so its total answers a + const tasks = pageLists(held).filter((l) => l.object === TASK); + expect(tasks, table).toHaveLength(2); + const taskProbe = tasks.filter((l) => l.filter !== undefined); + expect(taskProbe, table).toHaveLength(1); + expect(JSON.stringify(taskProbe[0].filter), table).toContain('not_equals'); + // The card's list carries no declared scope, so its total answers a // different question and never stands in for the badge's. - const taskCard = held.filter((h) => objectOf(h) === TASK && topOf(h) === 3); - expect(taskCard, table).toHaveLength(1); - expect(taskCard[0].path, table).not.toContain('not_equals'); + expect(tasks.filter((l) => l.filter === undefined), table).toHaveLength(1); }); it('two explain questions on a record open, two different bodies', async () => { const { held } = await openRecordPage(); - expect(held.filter(isExplain).map((h) => JSON.parse(h.body).operation).sort(), waveTable(held)).toEqual(['delete', 'update']); + const asked = [ + ...held.filter(isExplain).map((h) => JSON.parse(h.body).operation as string), + ...held.filter(isPageRead).flatMap((h) => pageBody(h).explain ?? []), + ]; + expect(asked.sort(), waveTable(held)).toEqual(['delete', 'update']); }); }); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx new file mode 100644 index 00000000000..b90d5b46e70 --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx @@ -0,0 +1,313 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27627 — a record open is ONE data request. + * + * Measured on origin/main with this file's own transport (the default record + * page over four related lists, StrictMode, the real adapter): a cold open sent + * 13 requests in two waves — the record, the approval list, two access + * verdicts and four related definitions, then the approval lock and four + * `top=1` count probes — and a warm open 9, also in two waves. + * + * The page now asks `POST /data/{object}/{id}/page` once, before any reader's + * effect runs, and every reader takes its part from it. Pinned here: one page + * request per open, no count probe, no separate verdict / field-access / + * approval / lock / record read, one wave of data requests; the badges draw the + * payload's totals; and the record a drawer step shows next is asked ahead, so stepping to it + * asks nothing for the record it opens. + */ +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), +})); +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +import { MetadataCtx } from '@object-ui/react'; +import { RelatedCountStore } from '@object-ui/components'; +import { ObjectStackAdapter } from '@object-ui/data-objectstack'; +import { __clearRecordEditableCache } from '../../../plugin-detail/src/useRecordEditable'; +import { __resetRecordApprovalsStore } from '../hooks/useRecordApprovals'; +import { RecordDetailView } from './RecordDetailView'; +import { RecordDrawerUpcomingContext } from './recordDrawerStack'; + +const BASE = 'http://test.local'; +const OBJECT = 'os_27627_contact'; +const ACCOUNT = 'os_27627_account'; +const CHILDREN = ['os_27627_task', 'os_27627_case', 'os_27627_deal', 'os_27627_event']; + +const contactDef = { + name: OBJECT, + label: 'Contact', + fields: { + id: { label: 'Id', type: 'text' }, + name: { label: 'Name', type: 'text' }, + email: { label: 'Email', type: 'email' }, + account: { label: 'Account', type: 'lookup', reference: ACCOUNT }, + }, +}; +const accountDef = { name: ACCOUNT, label: 'Account', fields: { id: { label: 'Id', type: 'text' }, name: { label: 'Name', type: 'text' } } }; +const childDef = (name: string) => ({ + name, + label: name.replace('os_27627_', ''), + fields: { + id: { label: 'Id', type: 'text' }, + name: { label: 'Name', type: 'text' }, + status: { label: 'Status', type: 'text' }, + contact: { label: 'Contact', type: 'lookup', reference: OBJECT }, + }, +}); +const ALL = [contactDef, accountDef, ...CHILDREN.map(childDef)]; +/** Each related list's total, distinct per child so a badge names its own. */ +const TOTAL: Record = { os_27627_task: 7, os_27627_case: 4, os_27627_deal: 9, os_27627_event: 2 }; + +interface Held { wave: number; method: string; path: string; body: any; released: boolean; release: () => void } + +const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); + +/** The page payload the route answers for `body`, for record `id`. */ +function pageAnswer(id: string, body: any) { + return { + success: true, + data: { + object: OBJECT, + id, + record: { id, name: `Contact ${id}`, email: 'a@b.c', account: 'a1' }, + related: (body.related ?? []).map((r: any) => ({ + object: r.object, + field: r.field, + total: TOTAL[r.object] ?? 0, + records: Array.from({ length: Math.min(r.top, TOTAL[r.object] ?? 0) }, (_, i) => ({ id: `${r.object}-${i}`, name: `${r.object} ${i}`, status: 'open' })), + })), + ...(body.fieldAccess ? { fieldAccess: { object: OBJECT, recordId: id, readable: ['name', 'email', 'account'], editable: ['name', 'email'], writable: true } } : {}), + ...(body.explain ? { explain: Object.fromEntries(body.explain.map((op: string) => [op, { allowed: true, record: { recordId: id, visible: true } }])) } : {}), + ...(body.approvals ? { approvals: { requests: [], lock: { locked: false } } } : {}), + }, + }; +} + +function answerFor(h: Held): unknown { + const meta = /\/api\/v1\/meta\/object\/([^/?]+)/.exec(h.path); + if (meta) return { success: true, data: ALL.find((o) => o.name === meta[1]) ?? null }; + const page = /\/api\/v1\/data\/[^/?]+\/([^/?]+)\/page$/.exec(h.path); + if (page) return pageAnswer(page[1], h.body); + // The record read of an adapter without the payload (`findOne`). + const one = new RegExp(`/api/v1/data/${OBJECT}\\?.*\\["id","=","([^"]+)"\\]`).exec(h.path); + if (one) return { success: true, data: { records: [{ id: one[1], name: `Contact ${one[1]}`, email: 'a@b.c', account: 'a1' }], total: 1 } }; + return { success: true, data: { records: [], total: 0 } }; +} + +function heldTransport() { + const held: Held[] = []; + const state = { wave: 1 }; + const fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const method = (init?.method ?? 'GET').toUpperCase(); + const path = decodeURIComponent(url.replace(BASE, '')); + if (path.endsWith('/api/v1/discovery')) return Promise.resolve(json({ success: true, data: { capabilities: {}, routes: {} } })); + return new Promise((resolve) => { + const h: Held = { + wave: state.wave, method, path, + body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, + released: false, + release: () => { if (h.released) return; h.released = true; resolve(json(answerFor(h))); }, + }; + held.push(h); + }); + }); + return { fetch, held, state }; +} + +async function idle() { + await act(async () => { for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0)); }); +} +async function runWaves(held: Held[], state: { wave: number }) { + await idle(); + for (let round = 0; round < 12; round++) { + const pending = held.filter((h) => !h.released); + if (pending.length === 0) return; + state.wave += 1; + await act(async () => { for (const h of pending) h.release(); await new Promise((r) => setTimeout(r, 0)); }); + await idle(); + } +} + +const isMeta = (h: Held) => h.path.startsWith('/api/v1/meta/'); +const isPage = (h: Held) => h.method === 'POST' && /\/api\/v1\/data\/[^/?]+\/[^/?]+\/page$/.test(h.path); +const dataRequests = (held: Held[]) => held.filter((h) => !isMeta(h)); + +beforeEach(() => { + cleanup(); + __clearRecordEditableCache(); + __resetRecordApprovalsStore(); + RelatedCountStore._reset(); + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 }); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +async function boot() { + const { fetch, held, state } = heldTransport(); + vi.stubGlobal('fetch', fetch); + const ds = new ObjectStackAdapter({ baseUrl: BASE, fetch, autoReconnect: false }); + await ds.connect(); + const metadata = { + objects: ALL, pages: [], loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, + ensureType: async () => [], getItem: async () => null, getItemsByType: () => [], + } as never; + const view = (rec: string, upcoming: string | readonly string[] | null, embedded: boolean) => ( + + + + ({ objectName: OBJECT, recordId }))}> + {}} objectNameOverride={OBJECT} recordIdOverride={rec} embedded={embedded} /> + + + + + ); + const open = (rec: string, upcoming: string | readonly string[] | null = null, embedded = false) => { + const mounted = render(view(rec, upcoming, embedded)); + // The pane swaps the record in place (`recordDrawerStack`'s level key): + // a step or a row click re-renders the same view with the next id. + const step = (next: string, nextUpcoming: string | readonly string[] | null = null) => + mounted.rerender(view(next, nextUpcoming, embedded)); + return Object.assign(mounted, { step }); + }; + return { held, state, open, ds }; +} + +describe('objectstack#27627 — one request opens a record page', () => { + it('a cold open sends one page request, no count probe and no separate part read, in one wave', async () => { + const { held, state, open } = await boot(); + open('c1'); + await runWaves(held, state); + + const data = dataRequests(held); + expect(data.map((h) => `${h.method} ${h.path}`)).toEqual([`POST /api/v1/data/${OBJECT}/c1/page`]); + expect(held.filter((h) => /[?&](\$?top)=1\b/.test(h.path))).toEqual([]); + // Every DATA request goes out in the first wave. The object definitions the + // blocks read (`/meta/object/…`) are the app bundle's to serve once per + // session; until then they are read as before, beside it. + expect(data.every((h) => h.wave === 1)).toBe(true); + + // The request names what the page draws: its four related lists, the + // header's verdicts, the field projection and the approval state. + const [page] = data; + expect(page.body.related.map((r: any) => [r.object, r.field])).toEqual( + expect.arrayContaining(CHILDREN.map((c) => [c, 'contact'])), + ); + expect(page.body.related).toHaveLength(CHILDREN.length); + expect(page.body).toMatchObject({ explain: ['update', 'delete'], approvals: true, expand: ['account'] }); + + // Every badge's total is the payload's. + for (const c of CHILDREN) expect(RelatedCountStore.get(c, 'contact', 'c1')).toBe(TOTAL[c]); + }); + + it('a warm open of another record sends one page request too', async () => { + const { held, state, open } = await boot(); + const first = open('c1'); + await runWaves(held, state); + first.unmount(); + held.length = 0; + state.wave = 1; + open('c2'); + await runWaves(held, state); + expect(held.map((h) => `${h.method} ${h.path}`)).toEqual([`POST /api/v1/data/${OBJECT}/c2/page`]); + }); + + it("asks the record a drawer step shows next ahead, so stepping to it sends nothing", async () => { + const { held, state, open } = await boot(); + const first = open('c1', 'c2'); + await runWaves(held, state); + const pages = held.filter(isPage).map((h) => h.path); + expect(pages).toEqual([ + `/api/v1/data/${OBJECT}/c1/page`, + `/api/v1/data/${OBJECT}/c2/page`, + ]); + const asked = held.filter(isPage).map((h) => JSON.stringify(h.body)); + expect(new Set(asked).size).toBe(1); + first.unmount(); + held.length = 0; + open('c2', 'c3'); + await runWaves(held, state); + // The step's own page is the prefetched answer; only the next one is asked. + expect(held.filter(isPage).map((h) => h.path)).toEqual([`/api/v1/data/${OBJECT}/c3/page`]); + expect(dataRequests(held).filter((h) => !isPage(h))).toEqual([]); + }); + // Merge resolution with objectstack#27056 (#27636): in a drawer, that card + // warms the next record's related counts; the payload's prefetch carries + // them, so only one of the two runs. + const countProbesFor = (held: Held[], rec: string) => + held.filter((h) => h.method === 'GET' && CHILDREN.some((c) => h.path.startsWith(`/api/v1/data/${c}?`)) && h.path.includes(`"contact","=","${rec}"`)); + + it('in a drawer, an adapter that answers the payload asks the next record once, as a page, and sends no count warm-up', async () => { + const { held, state, open } = await boot(); + open('c1', 'c2', true); + await runWaves(held, state); + expect(held.filter(isPage).map((h) => h.path)).toEqual([ + `/api/v1/data/${OBJECT}/c1/page`, + `/api/v1/data/${OBJECT}/c2/page`, + ]); + expect(countProbesFor(held, 'c2')).toEqual([]); + }); + + // Record pane v4 (objectstack#27545): the split pane beside the list and the + // drawer draw the same embedded view, now handed BOTH neighbours, and a step, + // ↑ ↓ or a click on another row swaps the record in place (maximize only + // restyles the pane). Each neighbour is asked once, as a page, and the swap + // to either one sends nothing for the record it opens. + it('in the v4 pane, both neighbours are asked ahead as pages, and an in-place step to either opens from that answer', async () => { + const { held, state, open } = await boot(); + const pane = open('c2', ['c3', 'c1'], true); + await runWaves(held, state); + expect(held.filter(isPage).map((h) => h.path)).toEqual([ + `/api/v1/data/${OBJECT}/c2/page`, + `/api/v1/data/${OBJECT}/c3/page`, + `/api/v1/data/${OBJECT}/c1/page`, + ]); + expect(countProbesFor(held, 'c3')).toEqual([]); + expect(countProbesFor(held, 'c1')).toEqual([]); + + // ↑ to c1 (or a click on its row): its page is the prefetched answer. The + // one neighbour asked is the record just left, whose answer the open took. + held.length = 0; + await act(async () => { pane.step('c1', ['c0', 'c2']); }); + await runWaves(held, state); + expect(held.filter(isPage).map((h) => h.path)).toEqual([ + `/api/v1/data/${OBJECT}/c0/page`, + `/api/v1/data/${OBJECT}/c2/page`, + ]); + expect(dataRequests(held).filter((h) => !isPage(h))).toEqual([]); + }); + + it('in a drawer, an adapter without the payload keeps the count warm-up: each of the next record\'s counts is asked once', async () => { + const { held, state, open, ds } = await boot(); + Object.assign(ds, { getRecordPage: undefined, prefetchRecordPage: undefined }); + open('c1', 'c2', true); + await runWaves(held, state); + expect(held.filter(isPage)).toEqual([]); + const warm = countProbesFor(held, 'c2').map((h) => /\/api\/v1\/data\/([^/?]+)/.exec(h.path)![1]).sort(); + expect(warm).toEqual([...CHILDREN].sort()); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordReadFailure-11902.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordReadFailure-11902.test.tsx index c81d3e3dc71..3b6f1355955 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.recordReadFailure-11902.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordReadFailure-11902.test.tsx @@ -30,6 +30,9 @@ * the one whose non-404 failure the adapter retries as a by-id GET before it * rejects (two wire requests for ONE page read). The pins count the page's * reads at the adapter, not at the wire, so they say how often the PAGE asked. + * Since objectstack#27627 the open's read is the record page payload + * (`getRecordPage`, which answers each status as the record read does) and a + * Retry is the record read alone (`findOne`); both are counted. * * Copy is asserted in `en` and `zh`, from the packs (no `defaultValue` path): * each answer renders its own state, a refusal names the object and never says @@ -131,13 +134,14 @@ const NOT_FOUND_EN = 'Record not found'; const NOT_FOUND_DESCRIPTION_EN = 'The record you are looking for does not exist, may have been deleted, or you may not have access to it.'; const NOT_FOUND_ZH = '未找到记录'; -/** Open the record page; returns the adapter's `findOne`, spied, to count the page's reads. */ +/** Open the record page; returns the adapter's two record reads, spied, to count the page's reads. */ async function openRecordPage(config: I18nConfig = EN) { const ds = new ObjectStackAdapter({ baseUrl: BASE, fetch: transport, autoReconnect: false }); await ds.connect(); // Object metadata is the metadata service's business, not this file's. (ds as unknown as { getObjectSchema: () => Promise }).getObjectSchema = async () => objectDef; const findOne = vi.spyOn(ds, 'findOne'); + const getRecordPage = vi.spyOn(ds, 'getRecordPage'); const metadata = { objects: [objectDef], pages: [], loading: false, error: null, refresh: async () => {}, invalidate: () => {}, @@ -158,11 +162,12 @@ async function openRecordPage(config: I18nConfig = EN) { , ); - return { findOne }; + return { findOne, getRecordPage }; } -const pageReads = (findOne: { mock: { calls: unknown[][] } }) => - findOne.mock.calls.filter(([object, id]) => object === OBJECT && id === REC).length; +type Spied = { mock: { calls: unknown[][] } }; +const pageReads = ({ findOne, getRecordPage }: { findOne: Spied; getRecordPage: Spied }) => + [...findOne.mock.calls, ...getRecordPage.mock.calls].filter(([object, id]) => object === OBJECT && id === REC).length; const absent = (text: string | RegExp) => expect(screen.queryAllByText(text)).toHaveLength(0); @@ -174,7 +179,7 @@ afterEach(() => { describe('a refused read renders a no-access state that names the object (objectui#11902)', () => { it('en: 403 PERMISSION_DENIED → "You don’t have access to Invoice records", never not-found', async () => { answer = 'forbidden'; - const { findOne } = await openRecordPage(EN); + const reads = await openRecordPage(EN); const state = await screen.findByTestId('record-access-denied', undefined, { timeout: 5000 }); expect(state).toHaveTextContent('You don’t have access to Invoice records'); @@ -186,7 +191,7 @@ describe('a refused read renders a no-access state that names the object (object // A permission decision is not retryable: no Retry is offered. expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull(); // The page asked once; the refusal set off no re-read of its own. - expect(pageReads(findOne)).toBe(1); + expect(pageReads(reads)).toBe(1); }); it('zh: 403 → "无权访问发票记录", naming the object by its translated label', async () => { @@ -203,23 +208,26 @@ describe('a refused read renders a no-access state that names the object (object describe('a failed load renders a retryable load error, not not-found (objectui#11902)', () => { it('en: a 5xx → "Couldn’t load this record" with Retry; a Retry that succeeds renders the record', async () => { answer = 'serverError'; - const { findOne } = await openRecordPage(EN); + const reads = await openRecordPage(EN); + const { findOne, getRecordPage } = reads; const state = await screen.findByTestId('record-load-failed', undefined, { timeout: 5000 }); expect(state).toHaveTextContent('Couldn’t load this record'); expect(state).toHaveTextContent('Something went wrong while loading it. Check your connection and try again.'); absent(NOT_FOUND_EN); absent(/may have been deleted/); - expect(pageReads(findOne)).toBe(1); + expect(pageReads(reads)).toBe(1); // The server recovers; Retry re-runs the page's own read, once. The click // handler calls the load synchronously, so the count is read right after // it — before the recovered page mounts blocks that read on their own. answer = 'ok'; fireEvent.click(screen.getByRole('button', { name: /Retry/ })); - expect(pageReads(findOne)).toBe(2); - // The retry is the SAME read: the same object, id and expansion as the first. - expect(findOne.mock.calls[1]).toEqual(findOne.mock.calls[0]); + expect(pageReads(reads)).toBe(2); + // The retry is the SAME record read: the same object, id and expansion as + // the open's payload asked. + expect(getRecordPage.mock.calls[0].slice(0, 2)).toEqual([OBJECT, REC]); + expect(findOne.mock.calls).toEqual([[OBJECT, REC, { $expand: (getRecordPage.mock.calls[0][2] as any).expand }]]); expect((await screen.findAllByText(RECORD.name, undefined, { timeout: 5000 })).length).toBeGreaterThan(0); expect(screen.queryByTestId('record-load-failed')).toBeNull(); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index da9115968bb..8a3516ad8e5 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -8,7 +8,7 @@ * (`buildDefaultPageSchema`). */ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useState, useEffect, useLayoutEffect, useCallback, useContext, useMemo, useRef } from 'react'; import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; import { activityRowToFeedItem, ArrangedRelatedTab, auditEntriesToFeedItems, bodyTableIdsOf, claimQueuedRecord, RecordFieldChangesContext, DefaultRecordPageContext, EMPTY_RELATED_ARRANGEMENT, InlineEditSaveBar, RecordBodyTablesContext, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageColumnDefaults, recordSitsInQueue, resolveTitleField, useQueueClaimOffered, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions, PageHeaderTrailingContext, PanelToggles, type PanelToggleItem } from '@object-ui/components'; @@ -33,7 +33,8 @@ import { ActionResultDialog, type ResultDialogState } from './ActionResultDialog import { FlowRunner, type ScreenFlowState, type ScreenSpec } from './FlowRunner.js'; import { FlowRefusalNotice, type FlowRefusalState } from './FlowRefusalNotice.js'; import { RelatedRecordActionsBridge } from './RelatedRecordActionsBridge.js'; -import { PrefetchUpcomingRelatedCounts, RecordDrawerReturnLink, RecordPageDrawerHost, useRecordPane } from './recordDrawerStack.js'; +import { PrefetchUpcomingRelatedCounts, RecordDrawerReturnLink, RecordDrawerUpcomingContext, RecordPageDrawerHost, useRecordPane } from './recordDrawerStack.js'; +import { recordPageRelatedLists, recordPageRequestOf, seedRecordPageReaders } from './recordPagePayload.js'; import { withPageTabsUrlSync } from '../utils/pageTabsUrlSync.js'; import { RECORD_DETAIL_TAB_PARAM, RECORD_RELATED_ENLARGE_PARAM, RECORD_TRAIL_PARAM, decodeRecordTrail, buildRecordTrailHref } from '../urlParams.js'; @@ -54,7 +55,7 @@ import { useNavigationContext, useRecordBreadcrumbTitle } from '../context/Navig // framework-injected bookkeeping columns are hidden from the body outright. // Both sets are derived, not restated — see record-detail-system-fields.ts. import { deriveRecordDetailSections } from './recordDetailSections.js'; -import type { DataSource, FeedItem, Reaction } from '@object-ui/types'; +import type { DataSource, FeedItem, Reaction, RecordPageOperation, RecordPagePayload } from '@object-ui/types'; import type { ActionDef, ActionParamDef, ConfirmationHandler, ParamCollectionHandler } from '@object-ui/core'; import type { ConsoleActionDispatch } from '../consoleActionDispatch.js'; import { useRecordApprovals, openApprovalOf, isSubmitterOf } from '../hooks/useRecordApprovals.js'; @@ -1037,6 +1038,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // same request, same cancellation guard — rather than a second read path. // Cleared with the effect, so a Retry can never run a superseded load. const reloadPageRecordRef = useRef<(() => void) | null>(null); + // objectstack#27627 — the record page payload the open asked (the layout + // effect after the header gates below), for the record-load effect to take + // the record from: `recordKey` is the read it answers (object, record and + // `$expand`), `generation` the invalidation count when it was asked, so a + // load after any change reads the record again instead. + const recordPageRead = useRef<{ + recordKey: string; + generation: number; + payload: Promise; + } | null>(null); // objectstack#26589 — the record this viewer just decided an approval on, // as `objectName` + `recordId`. A pending approver with no standing access // reads the record only while their step waits (objectstack#26180): the @@ -1138,11 +1149,23 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const decidedKey = `${objectName}:${pureRecordId}`; const expandFields: string[] = JSON.parse(pageRecordExpand); const params = expandFields.length > 0 ? { $expand: expandFields } : undefined; - const loadRecord = () => { + // objectstack#27627 — the open's record comes with the page payload when + // one was asked for this very read and nothing has changed since; a load + // after a change, a Retry and a `record-changed` signal read it alone. + const opened = recordPageRead.current; + const openPayload = + opened + && opened.recordKey === JSON.stringify([objectName, pureRecordId, pageRecordExpand]) + && opened.generation === recordInvalidationNonce + childInvalidationNonce + ? opened.payload + : null; + const loadRecord = (fresh = false) => { setPageRecordStatus('loading'); - const findOnePromise = params - ? dataSource.findOne(objectName, pureRecordId, params) - : dataSource.findOne(objectName, pureRecordId); + const findOnePromise = !fresh && openPayload + ? openPayload.then((page) => page?.record ?? null) + : params + ? dataSource.findOne(objectName, pureRecordId, params) + : dataSource.findOne(objectName, pureRecordId); findOnePromise .then((rec: any) => { if (cancelled) return; @@ -1164,7 +1187,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri setPageRecordStatus(classifyLoadError(err) === 'forbidden' ? 'forbidden' : 'failed'); }); }; - reloadPageRecordRef.current = loadRecord; + const reload = () => loadRecord(true); + reloadPageRecordRef.current = reload; loadRecord(); // Re-sync when any descendant signals the record changed (e.g. @@ -1174,12 +1198,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const onChanged = (ev: Event) => { const detail = (ev as CustomEvent).detail || {}; if (detail.objectName !== objectName || String(detail.recordId) !== String(pureRecordId)) return; - loadRecord(); + loadRecord(true); }; window.addEventListener('objectui:record-changed', onChanged as EventListener); return () => { cancelled = true; - if (reloadPageRecordRef.current === loadRecord) reloadPageRecordRef.current = null; + if (reloadPageRecordRef.current === reload) reloadPageRecordRef.current = null; window.removeEventListener('objectui:record-changed', onChanged as EventListener); }; // #2269: recordInvalidationNonce re-runs this fetch in place whenever the @@ -2131,6 +2155,74 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // claimed it", never pre-judged here. const sittingInQueue = recordSitsInQueue(pageRecord); const claimOffered = useQueueClaimOffered(objectDef?.name, pureRecordId, sittingInQueue !== null); + + // ─── The record page payload (objectstack#27627) ───────────────────── + // Everything this page asks of the server when it opens, in ONE read + // (`DataSource.getRecordPage`): the record with the `$expand` above, each + // related list's total and first rows, the field projection, the header's + // verdicts (the ones the gates above ask) and the approval state. It goes out + // in a LAYOUT effect, which React runs before any passive effect of the + // commit, so every reader of a part — this view's own hooks, a tab badge, a + // related card, the details block — finds its part on the wire and joins it + // (`seedRecordPageReaders`) instead of asking. The record-load effect above + // takes the record from it. Keyed on the plan's CONTENT (AGENTS.md #10). + // An adapter without `getRecordPage` leaves every reader asking on its own. + const recordPagePlan = useMemo(() => { + if (!hasPage || !objectName || !pureRecordId || typeof dataSource?.getRecordPage !== 'function') return ''; + const explain: RecordPageOperation[] = []; + if (objectDef?.name && !editOffByObject) explain.push('update'); + if (objectDef?.name && !deleteOffByObject) explain.push('delete'); + // Claim asks a transfer verdict only of a record sitting in a queue; the + // payload asks it of every record of an object that has the column. + if (objectDef?.fields && Object.prototype.hasOwnProperty.call(objectDef.fields, 'queue')) explain.push('transfer'); + return JSON.stringify({ + expand: JSON.parse(pageRecordExpand) as string[], + related: recordPageRelatedLists(effectivePage, assignedPage ? undefined : childRelations), + explain, + fieldAccess: typeof dataSource.getRecordFieldAccess === 'function', + approvals: objectName !== SYS_APPROVAL_REQUEST_OBJECT, + }); + }, [hasPage, objectName, pureRecordId, dataSource, objectDef, editOffByObject, deleteOffByObject, pageRecordExpand, effectivePage, assignedPage, childRelations]); + const recordPagePrincipal = perms.userId ?? null; + const recordPageGeneration = recordInvalidationNonce + childInvalidationNonce; + const recordPageGenerationRef = useRef(recordPageGeneration); + recordPageGenerationRef.current = recordPageGeneration; + useLayoutEffect(() => { + if (!recordPagePlan || !objectName || !pureRecordId || typeof dataSource?.getRecordPage !== 'function') { + recordPageRead.current = null; + return; + } + const plan = JSON.parse(recordPagePlan); + const payload = dataSource.getRecordPage(objectName, pureRecordId, recordPageRequestOf(plan)); + recordPageRead.current = { + recordKey: JSON.stringify([objectName, pureRecordId, JSON.stringify(plan.expand)]), + generation: recordPageGenerationRef.current, + payload, + }; + seedRecordPageReaders({ payload, objectName, recordId: pureRecordId, principal: recordPagePrincipal, ...plan }); + }, [recordPagePlan, objectName, pureRecordId, dataSource, recordPagePrincipal]); + + // The records a pane step shows (objectstack#27056's `upcoming`; both + // neighbours since objectstack#27545): their payloads are asked once this + // record's has answered, so a step either way opens from an answer already + // on its way. Keyed on the ids' CONTENT (AGENTS.md #10). + const upcoming = useContext(RecordDrawerUpcomingContext); + const upcomingKey = upcoming.filter((u) => u.objectName === objectName).map((u) => u.recordId).join('\n'); + useEffect(() => { + const opened = recordPageRead.current; + if (!opened || !recordPagePlan || !upcomingKey || !objectName) return; + if (typeof dataSource?.prefetchRecordPage !== 'function') return; + let cancelled = false; + const request = recordPageRequestOf(JSON.parse(recordPagePlan)); + const prefetch = () => { + if (cancelled) return; + for (const upcomingId of upcomingKey.split('\n')) dataSource.prefetchRecordPage!(objectName, upcomingId, request); + }; + opened.payload.then(prefetch, prefetch); + return () => { + cancelled = true; + }; + }, [recordPagePlan, upcomingKey, objectName, dataSource]); const [claiming, setClaiming] = useState(false); const claimingRef = useRef(false); const handleClaim = useCallback(async () => { @@ -4562,18 +4654,22 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // of the drawer its related rows and lookups open over it // (objectstack#26679): the same in-place stack, drawn by this same view. // In a drawer, the record ↑ ↓ shows next has its related counts warmed - // once this one has loaded (objectstack#27056, Target 4). + // once this one has loaded (objectstack#27056, Target 4). An adapter that + // answers the record page payload asks that record's whole payload instead + // (above), so the counts are not asked twice (objectstack#27627). if (embedded) { return ( <> {pageTree} - + {typeof dataSource?.prefetchRecordPage === 'function' ? null : ( + + )} ); } diff --git a/ui/packages/app-shell/src/views/recordPagePayload.ts b/ui/packages/app-shell/src/views/recordPagePayload.ts new file mode 100644 index 00000000000..f654be9659b --- /dev/null +++ b/ui/packages/app-shell/src/views/recordPagePayload.ts @@ -0,0 +1,265 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The record page's ONE read when it opens (objectstack#27627). + * + * A record page used to ask the server in two or three stages: the record, the + * approval list, two access verdicts and every related list's definition went + * out first; the approval lock and a `$top: 1` count probe per related list + * (or a compact card's rows) went out once those answered. The page now asks + * `DataSource.getRecordPage` once, before any reader's effect runs, and hands + * each part of the answer to the reader that used to ask for it, as that + * reader's read on the wire: + * + * - the record → the page's own record state (`RecordDetailView`); + * - each related list's total and first rows → `RelatedCountStore.seed`, which + * a tab badge, a collapsed card, a rail card and a compact card join; + * - the `update` / `delete` verdicts → `seedRecordVerdict` (the header's and + * the details block's `useRecordEditable`); + * - the `transfer` decision → `seedQueueClaimDecision` (Claim); + * - the field projection → `seedRecordFieldAccess` (the in-place edit blocks); + * - the approval rows and lock → `seedRecordApprovals`. + * + * Each part passed the permission, row-level and field-level filters of the + * read it replaces, on the server. A part the server refused reaches its + * reader as that read's refusal. Every reader still reads for itself after + * the open: a refresh, a write or a change on the data-invalidation bus. + * + * ⛔ What the page cannot name stays its reader's own read: a list declared by + * a list view (`listView`), a list whose declared filter carries a context + * token (the reader resolves it against the session first), a list whose + * filter cannot be lowered, and the lists past the fiftieth. + */ + +import { RelatedCountStore } from '@object-ui/components'; +import { toFilterNode } from '@object-ui/core'; +import { + COMPACT_RELATED_ROWS, + normalizeRelatedListSort, + seedQueueClaimDecision, + seedRecordFieldAccess, + seedRecordVerdict, +} from '@object-ui/plugin-detail'; +import type { + RecordFieldAccess, + RecordPageOperation, + RecordPagePayload, + RecordPagePartRefusal, + RecordPageRequest, +} from '@object-ui/types'; +import { seedRecordApprovals, type ApprovalRequestLite } from '../hooks/useRecordApprovals.js'; + +/** The most related lists one payload names (the route's own cap). */ +const MAX_RELATED = 50; + +/** One related list the page draws, by the related-count store's key. */ +export interface RecordPageRelatedList { + object: string; + field: string; + /** The list's declared scope, as authored (the store keys on it). */ + filter?: unknown; + /** The most first rows any reader of the list takes. */ + top: number; + /** The order of those rows; absent for the default order. */ + sort?: Array<{ field: string; order: 'asc' | 'desc' }>; +} + +/** A relation `deriveRelatedLists` derived, as the synthesized page lists it. */ +interface DerivedRelation { + childObject: string; + referenceField: string; + filter?: unknown; + sort?: unknown; +} + +const CONTEXT_TOKEN = /\{[A-Za-z_][\w.]*\}/; + +const propsOf = (node: Record): Record => + node.properties && typeof node.properties === 'object' ? { ...node, ...node.properties } : node; + +/** + * The related lists a record page draws: the `record:related_list` nodes and + * `record:reference_rail` entries of its schema, plus the lists the console + * derives for a synthesized page. One entry per store key, in first-seen order. + */ +export function recordPageRelatedLists( + page: unknown, + derived?: ReadonlyArray, +): RecordPageRelatedList[] { + const byKey = new Map(); + const add = (list: RecordPageRelatedList, rail: boolean) => { + if (list.filter !== undefined) { + if (CONTEXT_TOKEN.test(JSON.stringify(list.filter))) return; + try { + if (toFilterNode(list.filter) === undefined) return; + } catch { + return; + } + } + const k = JSON.stringify([list.object, list.field, list.filter ?? null]); + const prev = byKey.get(k); + if (!prev) { + byKey.set(k, { ...list, rail }); + return; + } + prev.top = Math.max(prev.top, list.top); + // A rail card takes rows only in the default order, so a key a rail + // shares asks for that order; a list's declared order then reads itself. + if (rail || prev.rail) { + prev.rail = true; + delete prev.sort; + } + }; + const visit = (node: unknown) => { + if (Array.isArray(node)) { + node.forEach(visit); + return; + } + if (!node || typeof node !== 'object') return; + const p = propsOf(node as Record); + if (p.type === 'record:related_list' && typeof p.objectName === 'string' && typeof p.relationshipField === 'string') { + if (typeof p.listView !== 'string' || p.listView === '') { + const sort = normalizeRelatedListSort(p.sort); + add({ + object: p.objectName, + field: p.relationshipField, + ...(p.filter === undefined || p.filter === null ? {} : { filter: p.filter }), + top: COMPACT_RELATED_ROWS, + ...(sort.length > 0 ? { sort } : {}), + }, false); + } + } else if (p.type === 'record:reference_rail' && Array.isArray(p.entries)) { + for (const entry of p.entries) { + if (typeof entry?.objectName !== 'string' || typeof entry?.relationshipField !== 'string') continue; + const limit = Number.isInteger(entry.limit) && entry.limit > 0 ? entry.limit : COMPACT_RELATED_ROWS; + add({ object: entry.objectName, field: entry.relationshipField, top: limit }, true); + } + } + for (const value of Object.values(node as Record)) { + if (value && typeof value === 'object') visit(value); + } + }; + visit(page); + for (const relation of derived ?? []) { + const sort = normalizeRelatedListSort(relation.sort as any); + add({ + object: relation.childObject, + field: relation.referenceField, + ...(relation.filter === undefined || relation.filter === null ? {} : { filter: relation.filter }), + top: COMPACT_RELATED_ROWS, + ...(sort.length > 0 ? { sort } : {}), + }, false); + } + return Array.from(byKey.values()) + .slice(0, MAX_RELATED) + .map(({ rail: _rail, ...list }) => list); +} + +/** The request body for a page's plan: its lists lowered to the route's filter syntax. */ +export function recordPageRequestOf(plan: { + expand: string[]; + related: RecordPageRelatedList[]; + explain: RecordPageOperation[]; + fieldAccess: boolean; + approvals: boolean; +}): RecordPageRequest { + return { + ...(plan.expand.length > 0 ? { expand: plan.expand } : {}), + related: plan.related.map((list) => ({ + object: list.object, + field: list.field, + ...(list.filter === undefined ? {} : { filter: toFilterNode(list.filter) }), + top: list.top, + ...(list.sort ? { sort: list.sort } : {}), + })), + ...(plan.explain.length > 0 ? { explain: plan.explain } : {}), + ...(plan.fieldAccess ? { fieldAccess: true } : {}), + ...(plan.approvals ? { approvals: true } : {}), + }; +} + +/** A refused part, thrown as the read it replaces throws its refusal. */ +function refusal(error: RecordPagePartRefusal | undefined, fallback: string): Error { + return Object.assign(new Error(error?.message ?? fallback), { + status: error?.status ?? 404, + code: error?.code ?? 'RECORD_NOT_FOUND', + }); +} + +const isRefused = (part: unknown): part is { error: RecordPagePartRefusal } => + !!part && typeof part === 'object' && 'error' in (part as object); + +/** + * Hand each part of the payload to the reader that asks it. Call it before any + * reader's effect runs (a layout effect of the page), so each reader joins its + * part instead of reading. Idempotent for one payload. + */ +export function seedRecordPageReaders(args: { + payload: Promise; + objectName: string; + recordId: string; + principal: string | null; + related: RecordPageRelatedList[]; + explain: RecordPageOperation[]; + fieldAccess: boolean; + approvals: boolean; +}): void { + const { payload, objectName, recordId, principal } = args; + // A seeded part nobody asks for (a reader already reading, or none mounted) + // must not surface its refusal as an unhandled rejection; a reader that + // joins it still sees the refusal. + const quiet = (part: Promise): Promise => { + part.catch(() => undefined); + return part; + }; + args.related.forEach((list, index) => { + const read = payload.then((page) => { + const answer = page?.related?.[index]; + if (!page || !answer || answer.error) throw refusal(answer?.error, `No answer for ${list.object}`); + return { total: answer.total ?? 0, rows: answer.records ?? [] }; + }); + RelatedCountStore.seed( + list.object, + list.field, + recordId, + list.filter as any, + list.top, + read, + list.sort ? JSON.stringify(list.sort) : undefined, + ); + }); + for (const operation of args.explain) { + const decision = quiet(payload.then((page) => { + const answer = page?.explain?.[operation]; + return isRefused(answer) ? undefined : answer; + })); + if (operation === 'transfer') seedQueueClaimDecision(principal, objectName, recordId, decision); + else seedRecordVerdict(principal, objectName, recordId, operation, decision); + } + if (args.fieldAccess) { + seedRecordFieldAccess(objectName, recordId, quiet(payload.then((page): RecordFieldAccess | null => { + const answer = page?.fieldAccess; + if (!page || !answer) return null; + if (isRefused(answer)) { + // As `getRecordFieldAccess` answers: no answer for a 404 / 501. + if (answer.error.status === 404 || answer.error.status === 501) return null; + throw refusal(answer.error, 'Field access refused'); + } + return answer; + }))); + } + if (args.approvals) { + seedRecordApprovals(objectName, recordId, quiet(payload.then((page) => { + const answer = page?.approvals; + if (!page || !answer) throw refusal(undefined, 'No approvals answer'); + if (isRefused(answer)) throw refusal(answer.error, 'Approvals refused'); + return { requests: answer.requests as ApprovalRequestLite[], lock: answer.lock }; + }))); + } +} diff --git a/ui/packages/components/src/hooks/related-count-store.ts b/ui/packages/components/src/hooks/related-count-store.ts index 7df337ca943..0cb828a1274 100644 --- a/ui/packages/components/src/hooks/related-count-store.ts +++ b/ui/packages/components/src/hooks/related-count-store.ts @@ -101,6 +101,12 @@ interface InflightRead { read: Promise; top: number; fromRows: boolean; + /** + * objectstack#27627 — the row order a seeded read's rows come in, as the + * content of the list's declared sort; absent for the default order, which + * is the only order a badge's or a rail card's own read asks for. + */ + order?: string; } const counts = new Map(); @@ -109,7 +115,7 @@ const inflight = new Map(); // the rows a badge's read fetched for that claim, held until the rail takes // them. See the file header. const claims = new Map(); -const heldRows = new Map(); +const heldRows = new Map(); const listeners = new Set(); // Monotonic store version — the `useSyncExternalStore` snapshot. Bumped on // every change so React actually re-renders subscribers (returning the @@ -374,12 +380,12 @@ async function fetchRows( ): Promise { const k = key(objectName, relField, parentId); const held = heldRows.get(k); - if (held && held.top >= limit) { + if (held && held.top >= limit && held.order === undefined) { heldRows.delete(k); return { total: held.total, rows: held.rows.slice(0, limit) }; } const pending = inflight.get(k); - if (pending && pending.top >= limit) { + if (pending && pending.top >= limit && pending.order === undefined) { const result = await pending.read; heldRows.delete(k); return { total: result.total, rows: result.rows.slice(0, limit) }; @@ -390,6 +396,83 @@ async function fetchRows( return own.read; } +/** + * objectstack#27627 — a related list the record page's ONE read answers. The + * page asks its payload (`DataSource.getRecordPage`) before any consumer's + * effect runs, and hands each list's answer here as the read of its key, so + * every consumer that asks after it joins it instead of reading: a badge or a + * collapsed card (`fetch`) takes its total, a rail card (`fetchRows`) and a + * compact card (`takeRows`) its rows. + * + * Contract: `read` resolves with the list's total and its first `top` rows as + * the server answered them for this key's question (the parent condition AND + * `filter`), in `order` (the content of the list's declared sort; absent for + * the default order), or rejects with the list's refusal, which consumers see + * as a failed read of their own. A rail card takes rows only in the default + * order, a compact card only in the order it asks for. The seeded read REPLACES whatever the key held: a + * read in flight before it, rows held from an earlier open. Its answer installs + * the count and holds the rows for one `fetchRows` / `takeRows` to take, unless + * an invalidation or a newer seed overtook it. + */ +function seed( + objectName: string, + relField: string, + parentId: string, + filter: CountScopeFilter | undefined, + top: number, + read: Promise, + order?: string, +): void { + const k = key(objectName, relField, parentId, filter); + heldRows.delete(k); + const entry: InflightRead = { read: Promise.resolve({ total: 0, rows: [] }), top, fromRows: false, order }; + entry.read = (async (): Promise => { + try { + const result = await read; + if (inflight.get(k) === entry) { + setCount(objectName, relField, parentId, result.total, filter); + heldRows.set(k, { ...result, top, order }); + } + return result; + } finally { + if (inflight.get(k) === entry) inflight.delete(k); + } + })(); + // Consumers that join it see the refusal; nobody may be waiting yet. + entry.read.catch(() => undefined); + inflight.set(k, entry); +} + +/** + * objectstack#27627 — the first `limit` rows of a list a seeded read answers, + * for a compact card that would otherwise read them itself. Joins the read on + * the wire, or takes the rows it holds (once); `undefined` when neither carries + * enough rows, and the caller reads for itself. Rejects as the seeded read did. + */ +function takeRows( + objectName: string, + relField: string, + parentId: string, + filter: CountScopeFilter | undefined, + limit: number, + order?: string, +): Promise | undefined { + const k = key(objectName, relField, parentId, filter); + const pending = inflight.get(k); + if (pending && pending.top >= limit && pending.order === order) { + return pending.read.then((result) => { + heldRows.delete(k); + return { total: result.total, rows: result.rows.slice(0, limit) }; + }); + } + const held = heldRows.get(k); + if (held && held.top >= limit && held.order === order) { + heldRows.delete(k); + return Promise.resolve({ total: held.total, rows: held.rows.slice(0, limit) }); + } + return undefined; +} + /** * objectui#13010 — a rail card says, while it renders, that it will list the * first `limit` rows of this unfiltered related list. Idempotent, so a render @@ -512,6 +595,9 @@ export const RelatedCountStore = { fetch: fetchCount, // objectui#13010 — the rail card's half of the shared read (file header). fetchRows, + // objectstack#27627 — the record page payload's half (`seed`, `takeRows`). + seed, + takeRows, claimRows, releaseRows, invalidate, diff --git a/ui/packages/data-objectstack/src/index.ts b/ui/packages/data-objectstack/src/index.ts index b22865c6f5d..828de509a9d 100644 --- a/ui/packages/data-objectstack/src/index.ts +++ b/ui/packages/data-objectstack/src/index.ts @@ -48,6 +48,8 @@ import type { AnalyticsResult, DatasetSelection } from '@objectstack/spec/contra import type { DataSource, RecordFieldAccess, + RecordPagePayload, + RecordPageRequest, ConnectionState, // #4564 — the canonical `deleteView` receipt shapes, declared beside the // `DataSource` interface they belong to. Imported for the local uses below @@ -1023,6 +1025,17 @@ export function readTransactionalBatchCapability( * any of these as a 404 so callers can degrade gracefully instead of * tripping on the property-name mismatch. */ +/** + * How many prefetched record page payloads an adapter holds (objectstack#27627): + * the previous and the next record of the one open, and the pair before a step. + */ +const PREFETCHED_RECORD_PAGES = 4; + +/** The identity of one record page read: resource, record id and request. */ +function recordPageKey(resource: string, id: string, request: RecordPageRequest): string { + return `${resource}::${JSON.stringify(String(id))}::${stableStringify(request)}`; +} + export function is404Error(error: unknown): boolean { if (!error || typeof error !== 'object') return false; const err = error as Record; @@ -3310,6 +3323,14 @@ export class ObjectStackAdapter implements DataSource { // write to a resource drops that resource's entries (see `emitMutation`), // so a read asked after a save is never answered by one sent before it. private inflightFindOnes = new Map>(); + // objectstack#27627 — record page payloads (`getRecordPage`) on the wire, + // keyed by resource + record id + serialized request, shared the same way; + // and the ones a stepper asked for ahead of the open (`prefetchRecordPage`), + // each taken once by the open it was asked for. A write to ANY resource + // drops both (see `dropInflightReads`): a payload carries the counts and + // rows of other objects' records, so no write leaves it current. + private inflightRecordPages = new Map>(); + private prefetchedRecordPages = new Map>(); // objectstack#27626 — `getRecordFieldAccess` asks, keyed by resource + id. // One record open asked the same projection four times in the same // millisecond (`record:details`, `record:highlights`, the form and the @@ -3947,6 +3968,8 @@ export class ObjectStackAdapter implements DataSource { for (const key of Array.from(this.inflightFindOnes.keys())) { if (key.startsWith(prefix)) this.inflightFindOnes.delete(key); } + this.inflightRecordPages.clear(); + this.prefetchedRecordPages.clear(); // objectstack#27626 — the record field projection's shared asks and its // remembered 404s, by the same rule. for (const key of Array.from(this.inflightFieldAccess.keys())) { @@ -7315,6 +7338,57 @@ export class ObjectStackAdapter implements DataSource { return ask; } + /** + * Everything a record page asks when it opens, in one read + * (objectstack#27627): `POST /data/{object}/{id}/page` through the client's + * `data.recordPage`. A payload a stepper prefetched for the same arguments + * is taken (once); a read already on the wire for them is joined. + * + * `null` for a record the caller cannot see (the route answers the record + * read's own 404), as {@link findOne} answers it. Any other failure is + * thrown, as a read's. + */ + async getRecordPage(resource: string, id: string, request: RecordPageRequest): Promise { + const key = recordPageKey(resource, id, request); + const prefetched = this.prefetchedRecordPages.get(key); + if (prefetched) { + // Taken once, and shared like a read of its own while it is out, so a + // second asker of the same open (StrictMode's replayed effect) joins it. + this.prefetchedRecordPages.delete(key); + return this.shareInFlight(this.inflightRecordPages, key, () => prefetched); + } + return this.shareInFlight(this.inflightRecordPages, key, () => this.readRecordPage(resource, id, request)); + } + + /** + * Ask {@link getRecordPage} ahead of the open it serves — the previous and + * next record of a stepper. Holds at most {@link PREFETCHED_RECORD_PAGES} + * answers, the newest; a write to any resource drops them all. + */ + prefetchRecordPage(resource: string, id: string, request: RecordPageRequest): void { + const key = recordPageKey(resource, id, request); + if (this.prefetchedRecordPages.has(key) || this.inflightRecordPages.has(key)) return; + const read = this.readRecordPage(resource, id, request); + // Nobody may be waiting on it; a failure is the open's to see, when it asks. + read.catch(() => undefined); + this.prefetchedRecordPages.set(key, read); + while (this.prefetchedRecordPages.size > PREFETCHED_RECORD_PAGES) { + const oldest = this.prefetchedRecordPages.keys().next().value as string; + this.prefetchedRecordPages.delete(oldest); + } + } + + /** The one round trip behind {@link getRecordPage}, unshared. */ + private async readRecordPage(resource: string, id: string, request: RecordPageRequest): Promise { + await this.connect(); + try { + return (await this.client.data.recordPage(resource, String(id), request as any)) as unknown as RecordPagePayload; + } catch (error: unknown) { + if (is404Error(error)) return null; + throw error; + } + } + /** * Cancel (recall) the active pending approval request for a given record. * diff --git a/ui/packages/plugin-detail/package.json b/ui/packages/plugin-detail/package.json index 7d39e7abe44..a2a00ea7fa9 100644 --- a/ui/packages/plugin-detail/package.json +++ b/ui/packages/plugin-detail/package.json @@ -19,8 +19,12 @@ "sideEffects": [ "./dist/index.js", "./dist/index.umd.cjs", + "./dist/queueClaim.js", + "./dist/recordFieldAccess.js", "./dist/useRecordEditable.js", "./src/index.tsx", + "./src/queueClaim.ts", + "./src/recordFieldAccess.tsx", "./src/useRecordEditable.ts" ], "exports": { diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx index b40f177ff1c..c0d3ad3cb7e 100644 --- a/ui/packages/plugin-detail/src/RelatedList.tsx +++ b/ui/packages/plugin-detail/src/RelatedList.tsx @@ -29,6 +29,7 @@ import { resolveIcon, Skeleton, useIsMobile, + RelatedCountStore, } from '@object-ui/components'; import { SchemaRenderer, useCapabilityGate, useCondition, toPredicateInput, useSettledSchema, useFilterScope, useResolvedFilter, usePredicateScope, classifyLoadError, declaredUserMessage, extractWriteErrorMessage, type LoadErrorKind, type RelatedRowActionDef } from '@object-ui/react'; import { @@ -371,7 +372,12 @@ function resolveRelatedLookupLabel(record: any, refSchema: any): string | undefi * Normalize the spec `sort` union (`'field'` / `'-field'` string or * `[{field, order}]`) into the object-array form fed to `$orderby`. */ -function normalizeSortSpec( +/** + * A related list's declared sort as the row read sends it (`$orderby`). The + * record page's payload asks a list's first rows in this order + * (objectstack#27627), so a compact card can take them. + */ +export function normalizeSortSpec( sort: RelatedListProps['defaultSort'], ): Array<{ field: string; order: 'asc' | 'desc' }> { if (!sort) return []; @@ -1333,6 +1339,17 @@ export const RelatedList: React.FC = ({ ); }, [add, pickerObject, api, objectName]); + // objectstack#27627 — a compact card's first rows arrive with the record + // page's ONE read (`DataSource.getRecordPage`), which the page hands to the + // related-count store before any card mounts. The card takes them there + // rather than reading them again after its child definition settles: that + // read was the page's second stage. Taken only where the payload asked the + // card's own question — the first page in the list's declared order, with no + // reader sort and no refresh since the mount — and held in this ref, so the + // definition settling re-runs the effect without reading. Any other read is + // the card's own, as before. + const seededRead = React.useRef<{ key: string; read: Promise<{ total: number; rows: unknown[] }> } | null>(null); + React.useEffect(() => { // Stale-response guard: page flips re-run this effect while an earlier // window may still be in flight — a slow page-2 response must not @@ -1371,6 +1388,49 @@ export const RelatedList: React.FC = ({ return; } setLoading(true); + if ( + compact + && windowed + && fetchPage === 0 + && !fetchSortField + && refreshNonce === 0 + ) { + const seedKey = JSON.stringify([api, referenceField, String(parentId), filter ?? null]); + if (seededRead.current?.key !== seedKey) { + const read = RelatedCountStore.takeRows( + api, + referenceField!, + String(parentId), + filter as any, + COMPACT_RELATED_ROWS, + defaultSortSpec.length > 0 ? JSON.stringify(defaultSortSpec) : undefined, + ); + seededRead.current = read ? { key: seedKey, read } : null; + } + const adopted = seededRead.current; + if (adopted) { + adopted.read.then( + (result) => { + if (cancelled) return; + setRelatedData(result.rows as any[]); + setTotal(result.total); + setHasMore(result.total > result.rows.length); + setLoading(false); + }, + (err) => { + if (cancelled) return; + setRelatedData([]); + setTotal(null); + setHasMore(false); + setLoadFailure({ error: err }); + setLoading(false); + }, + ); + return () => { + cancelled = true; + }; + } + } // ⭐ objectui#10690: the child definition GATES this query; it does not // refine it afterwards. The arity, `$expand` and `$select` below all read // it, so a query sent before it settles is one this effect would send @@ -1565,9 +1625,10 @@ export const RelatedList: React.FC = ({ // `selectKey` and the arity flag, never named here by identity // (commandment #10). `wireExpandKey` / `wireSelectKey` are those two keys // as a compact card narrows them (objectstack#27056), and `rowsWanted` the - // card's in-view / non-zero gate, a boolean. + // card's in-view / non-zero gate, a boolean. `compact` decides whether the + // record page's payload may serve the read (objectstack#27627), a boolean. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [api, dataProvided, dataSource, referenceField, referenceFieldIsMultiValue, parentId, refreshNonce, windowed, effectivePageSize, fetchPage, fetchSortField, fetchSortDirection, defaultSortKey, filterKey, expandKey, selectKey, wireExpandKey, wireSelectKey, rowsWanted, objectSchemaReady]); + }, [api, dataProvided, dataSource, referenceField, referenceFieldIsMultiValue, parentId, refreshNonce, windowed, effectivePageSize, fetchPage, fetchSortField, fetchSortDirection, defaultSortKey, filterKey, expandKey, selectKey, wireExpandKey, wireSelectKey, rowsWanted, objectSchemaReady, compact]); // Windowed mode: a page beyond the (shrunken) collection — e.g. the last // row of the last page was just deleted — comes back empty. Step back one diff --git a/ui/packages/plugin-detail/src/index.tsx b/ui/packages/plugin-detail/src/index.tsx index 38d6c8d83fd..3f954431b83 100644 --- a/ui/packages/plugin-detail/src/index.tsx +++ b/ui/packages/plugin-detail/src/index.tsx @@ -127,12 +127,15 @@ export type { } from './ConcurrentUpdateDialog'; // `__clearRecordEditableCache` is test-only and stays off the barrel // (objectui#13453); tests import it from `useRecordEditable` directly. -export { useRecordEditable } from './useRecordEditable'; +export { useRecordEditable, seedRecordVerdict } from './useRecordEditable'; +// objectstack#27627 — the record page payload's field projection, for the +// in-place edit blocks' first ask. +export { seedRecordFieldAccess } from './recordFieldAccess'; export type { RecordOperation } from './useRecordEditable'; // The Claim affordance on a record sitting in an ownership queue (objectstack // ADR-0142 D4): the server's `queue_claim` answer decides the button, the // claim is `{ owner_id: }` through the normal update. -export { recordSitsInQueue, queueClaimAdmitted, useQueueClaimOffered, claimQueuedRecord } from './queueClaim'; +export { recordSitsInQueue, queueClaimAdmitted, useQueueClaimOffered, claimQueuedRecord, seedQueueClaimDecision } from './queueClaim'; export type { QueueClaimOutcome, ClaimWriter } from './queueClaim'; export { SectionGroup } from './SectionGroup'; export { HeaderHighlight } from './HeaderHighlight'; @@ -159,6 +162,10 @@ export type { DetailViewProps } from './DetailView'; export type { DetailSectionProps, VirtualScrollOptions } from './DetailSection'; export type { DetailTabsProps } from './DetailTabs'; export type { RelatedListProps } from './RelatedList'; +// objectstack#27627 — the order a related list's row read asks, for the record +// page's payload to ask a compact card's first rows in. +export { normalizeSortSpec as normalizeRelatedListSort } from './RelatedList'; +export { COMPACT_RELATED_ROWS } from './relatedListCompact'; export type { SectionGroupProps } from './SectionGroup'; export type { HeaderHighlightProps } from './HeaderHighlight'; export type { RecordCommentsProps } from './RecordComments'; diff --git a/ui/packages/plugin-detail/src/queueClaim.ts b/ui/packages/plugin-detail/src/queueClaim.ts index cd2405130f9..0433a0b215b 100644 --- a/ui/packages/plugin-detail/src/queueClaim.ts +++ b/ui/packages/plugin-detail/src/queueClaim.ts @@ -39,7 +39,7 @@ * the server says it applies, so no answer means no button. */ import * as React from 'react'; -import { SchemaRendererContext, useDataInvalidation } from '@object-ui/react'; +import { SchemaRendererContext, dataChangeMatches, subscribeDataChanges, useDataInvalidation } from '@object-ui/react'; import { usePermissions } from '@object-ui/permissions'; import { isConcurrentUpdateError } from './ConcurrentUpdateDialog'; @@ -75,6 +75,43 @@ export function queueClaimAdmitted(decision: unknown): boolean { }); } +/** + * objectstack#27627 — transfer decisions the record page's ONE read + * (`DataSource.getRecordPage`) answered, by principal, object and record, so + * {@link useQueueClaimOffered} reads them instead of asking. A data change on + * the record drops its entry (the bus's own matching rule), so the re-ask that + * change triggers goes to the server; the next open of the record seeds anew. + */ +const seededClaimDecisions = new Map>(); + +subscribeDataChanges((change) => { + for (const key of Array.from(seededClaimDecisions.keys())) { + const [, object, recordId] = JSON.parse(key) as [string | null, string, string]; + if (dataChangeMatches(change, object, recordId)) seededClaimDecisions.delete(key); + } +}); + +/** + * Hand {@link useQueueClaimOffered} the transfer decision the record page's + * payload carries for this principal and record: the body + * `POST /security/explain` answers, or `undefined` for a refused part (no + * admission, as a refused read). + */ +export function seedQueueClaimDecision( + principal: string | null, + objectName: string, + recordId: string, + decision: Promise, +): void { + decision.catch(() => undefined); + seededClaimDecisions.set(JSON.stringify([principal, objectName, recordId]), decision); +} + +/** Test seam — forgets every seeded transfer decision. */ +export function __clearSeededClaimDecisions(): void { + seededClaimDecisions.clear(); +} + /** * Whether to offer Claim on this record: asked of the server only for a record * that sits in a queue, with a known acting user, and answered `true` only @@ -96,8 +133,14 @@ export function useQueueClaimOffered( setOffered(false); if (!sitting || !objectName || !recordId || !principal) return; let cancelled = false; + const seeded = seededClaimDecisions.get(JSON.stringify([principal, objectName, recordId])); (async () => { try { + if (seeded) { + const decision = await seeded; + if (!cancelled) setOffered(queueClaimAdmitted(decision)); + return; + } const doFetch = apiFetch ?? fetch; const res = await doFetch('/api/v1/security/explain', { method: 'POST', diff --git a/ui/packages/plugin-detail/src/recordFieldAccess.tsx b/ui/packages/plugin-detail/src/recordFieldAccess.tsx index 2cbc16ec79d..2f52f99d535 100644 --- a/ui/packages/plugin-detail/src/recordFieldAccess.tsx +++ b/ui/packages/plugin-detail/src/recordFieldAccess.tsx @@ -45,8 +45,42 @@ import React, { useEffect, useState } from 'react'; import { RecordFieldAccessScope } from '@object-ui/permissions'; -import { useDataInvalidation, useInlineEdit } from '@object-ui/react'; -import type { DataSource } from '@object-ui/types'; +import { dataChangeMatches, subscribeDataChanges, useDataInvalidation, useInlineEdit } from '@object-ui/react'; +import type { DataSource, RecordFieldAccess } from '@object-ui/types'; + +/** + * objectstack#27627 — field projections the record page's ONE read + * (`DataSource.getRecordPage`) answered, by object and record. The blocks' + * first ask of a record (revision 0) reads them instead of the data source; a + * data change on the record drops its entry (the bus's own matching rule), and + * every later ask (a write or a change raised the revision) goes to the server. + */ +const seededFieldAccess = new Map>(); + +subscribeDataChanges((change) => { + for (const key of Array.from(seededFieldAccess.keys())) { + const [object, recordId] = JSON.parse(key) as [string, string]; + if (dataChangeMatches(change, object, recordId)) seededFieldAccess.delete(key); + } +}); + +/** + * Hand the in-place edit blocks the field projection the record page's payload + * carries: what `getRecordFieldAccess` answers (`null` for no answer). + */ +export function seedRecordFieldAccess( + objectName: string, + recordId: string, + access: Promise, +): void { + access.catch(() => undefined); + seededFieldAccess.set(JSON.stringify([objectName, recordId]), access); +} + +/** Test seam — forgets every seeded field projection. */ +export function __clearSeededFieldAccess(): void { + seededFieldAccess.clear(); +} export interface RecordFieldAccessForRecordProps { objectName?: string | null; @@ -90,7 +124,9 @@ export function RecordFieldAccessForRecord({ (load as NonNullable).call(dataSource, object, recordKey)} + load={(object, recordKey) => + (busRevision + writeRevision === 0 ? seededFieldAccess.get(JSON.stringify([object, recordKey])) : undefined) + ?? (load as NonNullable).call(dataSource, object, recordKey)} revision={busRevision + writeRevision} > {children} diff --git a/ui/packages/plugin-detail/src/useRecordEditable.ts b/ui/packages/plugin-detail/src/useRecordEditable.ts index 1c62d6f2f69..935bff0e30a 100644 --- a/ui/packages/plugin-detail/src/useRecordEditable.ts +++ b/ui/packages/plugin-detail/src/useRecordEditable.ts @@ -274,7 +274,30 @@ function askVerdict( ): Promise { const pending = pendingVerdicts.get(key); if (pending) return pending.answer; + return startVerdict(key, objectName, recordId, async () => { + const doFetch = apiFetch ?? fetch; + const res = await doFetch('/api/v1/security/explain', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ object: objectName, operation, recordId }), + }); + if (!res.ok) return undefined; // 401 / 403 / 501 → fail open + return res.json(); + }); +} +/** + * Put one question on the wire as {@link pendingVerdicts}' entry for `key`: + * `readDecision` yields the explain engine's decision (or `undefined` for no + * answer), and its record verdict is cached for every asker sharing it. + */ +function startVerdict( + key: string, + objectName: string, + recordId: string, + readDecision: () => Promise, +): Promise { const probe: InFlightProbe = { object: objectName, recordIds: [recordId], @@ -285,15 +308,7 @@ function askVerdict( inFlight.add(probe); const answer = (async (): Promise => { try { - const doFetch = apiFetch ?? fetch; - const res = await doFetch('/api/v1/security/explain', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ object: objectName, operation, recordId }), - }); - if (!res.ok) return undefined; // 401 / 403 / 501 → fail open - const decision = await res.json(); + const decision = (await readDecision()) as { record?: { visible?: unknown } } | undefined; const verdict = decision?.record?.visible; if (typeof verdict !== 'boolean') return undefined; // no record verdict → fail open if (probe.staled.size > 0) return undefined; // asked before the record changed → not an answer @@ -316,6 +331,32 @@ function askVerdict( export type RecordOperation = 'update' | 'delete'; +/** + * objectstack#27627 — the record page's ONE read (`DataSource.getRecordPage`) + * answered this question alongside the record, so the page hands its answer + * here as the question's read on the wire, before any asker's effect runs. An + * asker that arrives while it is out joins it; its answer is cached exactly as + * {@link askVerdict}'s, and staled by a data change exactly as that read is. + * + * `decision` resolves with the explain engine's decision for this record and + * operation (the body `POST /security/explain` answers), or with `undefined` + * when the payload carries none for it — a refused part, which fails open as + * a refused read does. A question already answered or already on the wire is + * left as it is. + */ +export function seedRecordVerdict( + principal: string | null, + objectName: string, + recordId: string, + operation: RecordOperation, + decision: Promise, +): void { + retainForPrincipal(principal); + const key = JSON.stringify([principal, objectName, recordId, operation] satisfies VerdictKey); + if (verdictCache.has(key) || pendingVerdicts.has(key)) return; + void startVerdict(key, objectName, recordId, () => decision); +} + export function useRecordEditable( objectName: string | undefined, recordId: string | undefined, diff --git a/ui/packages/types/src/data.ts b/ui/packages/types/src/data.ts index e89071d764b..492c58d294e 100644 --- a/ui/packages/types/src/data.ts +++ b/ui/packages/types/src/data.ts @@ -24,6 +24,9 @@ import type { ImportJobStatus, ImportRowResult, ImportWriteMode, + RecordPageExplainOperation, + RecordPagePartRefusal, + RecordPageRequest, UndoImportJobResponse, } from '@objectstack/spec/api'; import type { EngineAggregateOptions, FilterArray } from '@objectstack/spec/data'; @@ -374,6 +377,38 @@ export interface RecordFieldAccess { writable?: boolean; } +/** A record-grained operation a record page's header asks a verdict for. */ +export type RecordPageOperation = RecordPageExplainOperation; + +// What a record page asks when it opens (`POST /data/{object}/{id}/page`) and +// the refusal a replaced read answers are the spec's own shapes. +export type { + RecordPagePartRefusal, + RecordPageRelatedRequest, + RecordPageRequest, +} from '@objectstack/spec/api'; + +/** + * The answer to {@link RecordPageRequest} (`RecordPageResponse` in + * `@objectstack/spec`): each part is what the read it replaces answers the same + * caller, or that read's refusal. + */ +export interface RecordPagePayload { + object: string; + id: string; + record: Record; + related: Array<{ + object: string; + field: string; + total?: number; + records?: Record[]; + error?: RecordPagePartRefusal; + }>; + fieldAccess?: RecordFieldAccess | { error: RecordPagePartRefusal }; + explain?: Partial>; + approvals?: { requests: Record[]; lock: { locked: boolean } | null } | { error: RecordPagePartRefusal }; +} + /** * The backend-agnostic data contract every adapter implements. * @@ -635,6 +670,29 @@ export interface DataSource { */ getRecordFieldAccess?(resource: string, id: string): Promise; + /** + * Everything a record page asks of the server when it opens, in ONE read + * (objectstack#27627): the record, each related list's count and first rows, + * the field projection, the header's access verdicts and the approval state. + * Every part passes the permission, row-level and field-level filters of the + * read it replaces. + * + * `null` when the record is absent or hidden from the caller, as + * {@link findOne} answers it. A refused read is thrown. + * + * Optional — adapters that don't speak to an ObjectStack server omit it, and + * the page then asks each part on its own. + */ + getRecordPage?(resource: string, id: string, request: RecordPageRequest): Promise; + + /** + * Start {@link getRecordPage} for a record the reader is likely to open next + * (the previous / next record of a stepper), so its open is served from the + * answer already on its way. Fire-and-forget; a later `getRecordPage` with + * the same arguments takes it, and a write to the object drops it. + */ + prefetchRecordPage?(resource: string, id: string, request: RecordPageRequest): void; + /** * Get object schema/metadata. * Used by ObjectQL-aware components to auto-generate UI from object metadata. diff --git a/ui/packages/types/src/index.ts b/ui/packages/types/src/index.ts index a232627534f..358342012f0 100644 --- a/ui/packages/types/src/index.ts +++ b/ui/packages/types/src/index.ts @@ -458,6 +458,11 @@ export type { DeleteViewResult, ViewHomeDeleteOutcome, RecordFieldAccess, + RecordPageOperation, + RecordPageRelatedRequest, + RecordPageRequest, + RecordPagePartRefusal, + RecordPagePayload, GlobalSearchHit, GlobalSearchResult, BatchRef,