diff --git a/.changeset/19228-view-row-limit-route-record.md b/.changeset/19228-view-row-limit-route-record.md new file mode 100644 index 00000000000..4693c232b48 --- /dev/null +++ b/.changeset/19228-view-row-limit-route-record.md @@ -0,0 +1,60 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): state the row-cap guard `ElementDataSourceGate` implements, and record where the per-kind view `limit` actually lands (#19228) + +Prose and pins only — zero accept-set movement, zero export movement. The same documents parse +to the same values before and after. ⛔ No `.default()` moves, ⛔ no precedence is picked: which +of the per-kind view `limit`, a view's `pagination.pageSize` and a component's flat `limit` +should win is the open half of #19228 and is not answered here. + +## What the published text said, and what an author can actually reach + +`ObjectKanbanPropsSchema.limit` tells authors that a bound view's `pagination.pageSize` fills it +「only when unset」. Measured first-hand at the objectui pin this repo builds against +(`.objectui-sha` = `87af769e9`), that sentence is exactly right for this face, and the describe +now says WHY rather than leaving it to look narrower than the mechanism. + +The gate's branch is `if (!fromView || !isUsableRowLimit(authored))` +(`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`), and `isUsableRowLimit` is +`typeof v === 'number' && Number.isInteger(v) && v > 0` (`:192-194`). Every cap this key ACCEPTS +is one that predicate already calls usable — the accept set is a subset of the usable set — so +across the whole accept set the guard has exactly two outcomes and 「set but not usable」 is +empty. The extra arm, a cap displaced and reported because it is zero, negative or fractional, +is reachable only for a node this contract refuses, so it is recorded in the docblock rather +than in an author-facing sentence. + +The view half is `pagination.pageSize` ALONE on this face. `savedViewLimit` does fall back to a +flat `view.limit` (`core/src/data-scope/element-data-source.ts:237-241`), but that names a +saved-view RECORD as the adapter's `listViews()` returns it — a third face, not an authored view +document. Measured on this tree: `ListViewSchema` REFUSES a flat `limit` with +`unrecognized_keys: ["limit"]`, the verdict a bogus key gets, while the same minimal document +parses with `pagination.pageSize: 50` and with a per-kind `kanban.limit: 50`. No view document +declares a flat `limit` and none carries a tombstone for one. + +## Where the per-kind VIEW `limit` lands + +⚠️ Two different keys are easy to confuse here, so each statement names its face. The **view +face** is a `ListViewSchema` document's `kanban` / `gallery` / `timeline` block — that is where +this key lives. The **element face** is a page component node's own flat `limit`, declared in +`component.zod.ts`, and that is the key every renderer actually reads. An adapter turns the +first into the second. + +The adapters spread a view's per-kind block FLAT onto the node they generate — `...restKanban` +(`plugin-list/src/ListView.tsx:2979`, `plugin-view/src/ObjectView.tsx:1638`; neither destructure +strips `limit`) and `...(viewOptions.gallery || {})` / `...(viewOptions.timeline || {})` +(`ObjectView.tsx:1697` / `:1725`). So a view's `kanban.limit` — including the 100 the applied +default materializes — becomes the node's flat `limit`, which `ObjectKanban.tsx:553` reads. A +view's `timeline.limit` is route-dependent: `plugin-view` flattens it and `ObjectTimeline.tsx:279` +reads it, while `plugin-list` forwards the block nested, where nothing does. A view's +`gallery.limit` is flattened too and read by nobody — `ObjectGallery.tsx` contains no `limit` at +all. + +Where it is read, the `$top` it would govern is still not issued on either adapter route today, +because both hosts hand rows down as a React `data` prop and both children short-circuit their +own fetch; ⛔ that is a statement about the query, not about the key being unread. + +⚠️ For authors of an `object-timeline` NODE: a `limit` written inside that node's own `timeline` +block is read by no renderer on any route — the rail is capped by the flat `limit` beside it, +which is also the only one a bound `dataSource` lowers into. Write the flat one. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index d40ed33acb2..92d30aa5822 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -617,7 +617,7 @@ Sort field and direction pair | **groupBy** | `string` | optional | Field whose values become the board columns | | **columns** | `any[]` | optional | Swimlane definitions (`{ id, title }` per `groupBy` value, or bare value strings) — NOT a field projection | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter, handed to the wire `$filter` — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | -| **limit** | `integer` | optional | Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills it only when unset | +| **limit** | `integer` | optional | Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills this key only when it is unset — and on this face unset is the whole rule, because every cap this key accepts is one the binding gate already treats as authored | | **data** | `any[]` | optional | Static inline cards — bypasses the object query | | **cardTitle** | `string` | optional | Field rendered as each card title | | **titleField** | `string` | optional | Legacy fallback for `cardTitle` (the board reads `cardTitle \|\| titleField`). Prefer `cardTitle` | @@ -803,7 +803,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **objectName** | `string` | optional | Object this timeline binds to. Optional because the component-level `dataSource` binding can supply the object instead — this block registers through `ElementDataSourceGate`, which lowers the binding onto this key before the renderer sees the node | -| **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: `{ startDateField, endDateField, titleField, groupByField, colorField, scale }`. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling | +| **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: `{ startDateField, endDateField, titleField, groupByField, colorField, scale, limit }`. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling. ⚠️ `limit` is the one member of this block NO renderer reads on any route: the rail is capped by the FLAT `limit` beside this key, which is also the only one a bound `dataSource` lowers into. A `limit` written inside this block is accepted, defaulted to 100, and never read | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order for the fetched entries — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **limit** | `integer` | optional | Maximum number of records loaded onto the rail (row cap); lowered to the query's top-level `$top` (renderer default 100). A timeline renders one rail with no pagination control, so this is the author's window rather than a page size | @@ -828,7 +828,7 @@ View filter rule | **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. | | **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color | | **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ObjectTimelineProps.filter[number]` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 0b98f3d5254..ea800bd3ae5 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -545,7 +545,7 @@ Gallery/card view configuration | **cardSize** | `Enum<'small' \| 'medium' \| 'large'>` | optional (default: `"medium"`) | Card size in gallery view | | **titleField** | `string` | optional | Field to display as card title | | **visibleFields** | `string[]` | optional | Fields to display on card body | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | --- @@ -701,7 +701,7 @@ HTTP methods a view data source may request — the subset of `HttpMethod` witho | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | --- @@ -940,7 +940,7 @@ View filter rule | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ListView.calendar` @@ -995,7 +995,7 @@ View filter rule | **cardSize** | `Enum<'small' \| 'medium' \| 'large'>` | optional (default: `"medium"`) | Card size in gallery view | | **titleField** | `string` | optional | Field to display as card title | | **visibleFields** | `string[]` | optional | Fields to display on card body | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ListView.timeline` @@ -1007,7 +1007,7 @@ View filter rule | **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. | | **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color | | **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ListView.chart` @@ -1342,7 +1342,7 @@ View filter rule | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ObjectListView.calendar` @@ -1397,7 +1397,7 @@ View filter rule | **cardSize** | `Enum<'small' \| 'medium' \| 'large'>` | optional (default: `"medium"`) | Card size in gallery view | | **titleField** | `string` | optional | Field to display as card title | | **visibleFields** | `string[]` | optional | Fields to display on card body | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ObjectListView.timeline` @@ -1409,7 +1409,7 @@ View filter rule | **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. | | **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color | | **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ObjectListView.chart` @@ -1663,7 +1663,7 @@ Timeline view configuration | **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. | | **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color | | **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | --- diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index a4e52411d59..5279794ce70 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -24,7 +24,10 @@ import { ObjectKanbanPropsSchema, } from './component.zod'; import { PageComponentSchema, PageSchema, PageComponentType, ElementDataSourceSchema, RETIRED_PAGE_COMPONENT_TYPES } from './page.zod'; -import { GanttConfigSchema, TreeConfigSchema, ListMapConfigSchema, ListColumnSchema, ListViewSchema } from './view.zod'; +import { + GanttConfigSchema, TreeConfigSchema, ListMapConfigSchema, ListColumnSchema, ListViewSchema, + TimelineConfigSchema, DEFAULT_VIEW_ROW_LIMIT, +} from './view.zod'; import { FieldSchema } from '../data/field.zod'; import { ALL_CONVERSIONS } from '../conversions/registry'; import { strictObjectDeclarations } from '../shared/strict-object'; @@ -3799,3 +3802,111 @@ describe('the three #18305 object blocks — key sets derived from the renderers expect((ComponentPropsMap as Record)['object-chart']).toBeUndefined(); }); }); + +// #19228 — two authorable row bounds land on one `object-timeline` node, and +// the react tier's own precedence sentence was narrower than the guard it +// names. ⛔ This card picks NO precedence and changes no `.default()`; these +// pins only hold the two structural facts the repair rests on, measured +// first-hand at the objectui pin `87af769e9` on 2026-09-21T06:30-06:40Z. +describe('row caps on the object-bound blocks — what #19228 recorded', () => { + const timeline = ComponentPropsMap['object-timeline']; + const kanban = ComponentPropsMap['object-kanban']; + + it('leaves the ELEMENT-face `limit` undefaulted — the fact that keeps the gate arm alive', () => { + // `ElementDataSourceGate` lowers a bound view's cap into this key only + // when it does not already carry a USABLE one + // (`ElementDataSourceGate.tsx:316-331`, `!fromView || !isUsableRowLimit`). + // An applied default here would make every parsed node carry a usable cap + // and kill that arm outright — the failure #19228 feared, on the schema it + // would actually happen to. ⛔ Do not "fix" a red here by deleting the pin. + for (const [label, schema] of [['object-kanban', kanban], ['object-timeline', timeline]] as const) { + const parsed = schema.parse({ objectName: 'task' }) as Record; + expect(Object.prototype.hasOwnProperty.call(parsed, 'limit'), label).toBe(false); + } + + // LIT CONTROL, same instrument (a Zod applied default, observed through + // `parse`): the VIEW-face sibling DOES materialize one, so the zeros above + // are a reading rather than a parse that never ran. + const viewSide = TimelineConfigSchema.parse({ startDateField: 'start_date', titleField: 'name' }) as { limit?: number }; + expect(viewSide.limit).toBe(DEFAULT_VIEW_ROW_LIMIT); + }); + + it('materializes the NESTED `timeline.limit` on a node whose flat `limit` stays absent', () => { + // The shape the record is about: one strictObject, two authorable row + // caps, and an applied default on the nested one. ⚠️ Faces, because this + // card keeps confusing them: the NESTED key asserted below is the ELEMENT + // face, and at the pin no renderer reads it on any route. The + // route-dependent one is a VIEW document's `timeline.limit`, a different + // key on a different document, which `ObjectView.tsx:1725` flattens onto + // a generated node's FLAT `limit`. Neither is asserted here: this pin is + // about the PARSE, which is the only half a schema owns. + const result = timeline.safeParse({ + objectName: 'task', + timeline: { startDateField: 'start_date', titleField: 'name' }, + }); + expect(result.success).toBe(true); + const data = (result.success ? result.data : undefined) as + { limit?: unknown; timeline?: { limit?: unknown } } | undefined; + expect(data?.timeline?.limit).toBe(DEFAULT_VIEW_ROW_LIMIT); + expect(Object.prototype.hasOwnProperty.call(data ?? {}, 'limit')).toBe(false); + + // CONTROL — the node is still strict, so the acceptance above is not the + // verdict of a map that has stopped refusing anything. + const control = timeline.safeParse({ objectName: 'task', zzUnlikelyBogusKey__: 1 }); + expect(control.success).toBe(false); + expect(JSON.stringify(control.error?.issues)).toContain('unrecognized_keys'); + }); + + it('admits only caps the binding gate calls usable — the SUBSET that makes 「unset」 the whole rule', () => { + // ⛔ Not a prose pin. The published sentence says a bound view's + // `pagination.pageSize` fills this key only when it is UNSET, and this is + // the structural fact that makes that true rather than narrow: + // `ElementDataSourceGate`'s guard is `!isUsableRowLimit(authored)` with + // `isUsableRowLimit = typeof v === 'number' && Number.isInteger(v) && v > 0`. + // This key's accept set is a SUBSET of that predicate — ⛔ NOT the same + // set; `2 ** 53 + 2` separates them, and the case below pins it. Subset is + // the direction the sentence needs: it makes 「set but not usable」 empty + // across the whole accept set, so the guard has exactly two outcomes. + // + // ⚠️ What this pin can and cannot catch, because the two sides are not + // symmetric here: + // · SPEC side — reds. A `.nullable()`, a `0` sentinel, dropping `.int()` + // or adding a `.default()` each fail a specific expect below. + // · GATE side — ⛔ CANNOT red. `usableToTheGate` is a TRANSCRIPTION of + // `isUsableRowLimit` as it read at objectui pin `87af769e9`, not an + // import — nothing here resolves into objectui. A rewrite of that + // predicate at objectui HEAD leaves this test green. It is re-read on + // a PIN BUMP, by hand, and that is the only thing that refreshes it. + const usableToTheGate = (v: unknown): boolean => + typeof v === 'number' && Number.isInteger(v) && v > 0; + + // ACCEPTED by the schema ⇒ usable to the gate ⇒ the view's cap does NOT land. + for (const cap of [1, 25, 100, 5000]) { + const r = kanban.safeParse({ objectName: 'x', limit: cap }); + expect(r.success, `accept ${cap}`).toBe(true); + expect(usableToTheGate((r.success ? r.data : {} as never).limit), `usable ${cap}`).toBe(true); + } + + // REFUSED by the schema ⇒ never reaches the gate from a valid document, + // which is why the displaced-and-reported arm is not in the describe. + for (const cap of [0, -1, 2.5, '100', null]) { + expect(kanban.safeParse({ objectName: 'x', limit: cap }).success, `refuse ${JSON.stringify(cap)}`).toBe(false); + expect(usableToTheGate(cap), `gate also rejects ${JSON.stringify(cap)}`).toBe(false); + } + + // ⛔ The sets are NOT equal, and this is the witness. `2 ** 53 + 2` is + // refused here (zod 4's `.int()` enforces SAFE integers, `too_big`) while + // `Number.isInteger` calls it usable. Subset, not coincidence — if this + // case ever flips, the docblock sentence built on the subset direction + // has to be re-derived rather than reworded. + const beyondSafe = 2 ** 53 + 2; + expect(kanban.safeParse({ objectName: 'x', limit: beyondSafe }).success).toBe(false); + expect(usableToTheGate(beyondSafe)).toBe(true); + + // UNSET — accepted, and the one state the gate treats as unauthored. + const unset = kanban.safeParse({ objectName: 'x' }); + expect(unset.success).toBe(true); + expect(Object.prototype.hasOwnProperty.call(unset.success ? unset.data : {}, 'limit')).toBe(false); + expect(usableToTheGate(undefined)).toBe(false); + }); +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 31fb9f59513..ac648bd4b6b 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3092,12 +3092,71 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ * Why the carrier is `limit` and not the bound view's `pagination.pageSize` * (the alternative the card opened): precedence is the `ElementDataSourceGate` * table, not this key's. The component-level `dataSource.limit` overrides - * this key, and a bound named view's `pagination.pageSize` is LOWERED INTO - * it through the `limit: 'limit'` mapping only when the component authored - * none (`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`, + * this key unconditionally; a bound named view's `pagination.pageSize` is + * LOWERED INTO it through the `limit: 'limit'` mapping only when this key is + * unset (`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`, * `readLimit`/`writeLimit` keyed by `ElementDataSourceLimitKey`; the branch * gained objectui#9899's presence-is-not-authorship test and a - * `describeDisplacedRowLimit` report on this hop). The board + * `describeDisplacedRowLimit` report on this hop). + * + * ⚠️ 「only when UNSET」 reads narrower than the guard and is nonetheless + * EXACTLY right on this face — a correction to a correction, measured + * 2026-09-21T10:20Z. The branch is + * `if (!fromView || !isUsableRowLimit(authored))`, and + * `isUsableRowLimit` is `typeof v === 'number' && Number.isInteger(v) && v > 0` + * (`ElementDataSourceGate.tsx:192-194`), and this key's accept set + * (`z.number().int().positive()`) is a SUBSET of it — ⛔ not the same set, + * and the difference is reachable: `2^53 + 2` is refused here (zod 4's + * `.int()` is safe-integer, `too_big`) and `Number.isInteger` calls it + * usable. Subset is the direction that matters, and it is the whole + * argument: for every node this schema ACCEPTS, `authored` is either absent + * (not usable ⇒ the view's cap lands) or a cap the gate already treats as + * authored (⇒ it does not). So unset is the only reachable arm. + * The extra arm — a cap displaced and reported because it is `0`, negative + * or fractional — is reachable ONLY for a node this contract refuses, so + * ⛔ it does not belong in an author-facing describe. Pinned structurally + * beside the parse pins in `component.test.ts` rather than as prose. + * + * ⚠️ And the view half is `pagination.pageSize` ALONE on this face. + * `savedViewLimit` does fall back to a flat `view.limit` + * (`core/src/data-scope/element-data-source.ts:237-241`), but that names a + * THIRD face — a saved-view RECORD as the adapter's `listViews()` returns it + * — not an authored view document. Measured on this tree: `ListViewSchema` + * REFUSES a flat `limit` with `unrecognized_keys: ["limit"]`, the same + * verdict a bogus key gets, while the same minimal document parses with + * `pagination.pageSize: 50` and with a per-kind `kanban.limit: 50`. There is + * no flat `limit` member on any view document and no `retiredKey()` + * tombstone for one. ⛔ So naming that arm here would put a runtime-record + * shape on the author face with no qualifier — the face-merge this card has + * now failed on three times. + * ⛔ This note reports the guard; it picks no precedence. + * + * ⚠️ It takes a THIRD door, and missing it is what the at-tier review of + * #19533 caught. Stated with its faces named, because that is where this + * card keeps going wrong: `kanban.limit` is a VIEW-FACE key (a member of a + * `ListViewSchema` document's `kanban` block) and THIS `limit` is the + * ELEMENT-FACE key on the node; the third door is the one that turns the + * first into the second. The ADAPTERS spread the view's kanban block FLAT + * onto the generated node — `plugin-list/src/ListView.tsx:2979` and + * `plugin-view/src/ObjectView.tsx:1638`, both `...restKanban`, and neither + * destructure (`ListView.tsx:2952`, `ObjectView.tsx:1579`) strips `limit`. + * So a view's `kanban.limit`, INCLUDING the 100 its applied default + * materializes, lands on THIS key, and `ObjectKanban.tsx:553` reads it + * (`describeRefusedRowLimit`, unconditional). The `$top` at `:676` is not + * issued on either route today — both hosts pass rows down as a React `data` + * prop and the board short-circuits at `:559` — so it governs no query + * there, which is ⛔ NOT the same claim as 「no consumer reads it」. A + * spread carries a key without spelling it, so a property-access sweep + * cannot see this and returns a confident zero. + * + * ⭐ The arm is nonetheless REACHABLE, for a reason the spreads do not + * touch: its guard reads THIS key on the node as AUTHORED, and this + * declaration is `.optional()` with no applied default, so an author's + * silence is still silence at parse time. #19228 read the VIEW-face applied + * default as killing this arm; the two are different schemas, and what the + * adapters lower is a rendered node, not the parse of this one. ⛔ Do not add + * a `.default()` here: that — and only that — is what would make it dead. + * The board * has no `pagination` read point, so declaring that spelling here would name * a key the renderer ignores — the accepted-and-dropped defect this section * exists to remove. Same shape as the `element:record_picker` and @@ -3106,7 +3165,7 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ * a schema default would materialize `limit: 100` on every parsed board. */ limit: z.number().int().positive().optional() - .describe("Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills it only when unset"), + .describe("Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills this key only when it is unset — and on this face unset is the whole rule, because every cap this key accepts is one the binding gate already treats as authored"), data: z.array(z.unknown()).optional().describe('Static inline cards — bypasses the object query'), cardTitle: z.string().optional().describe('Field rendered as each card title'), titleField: z.string().optional().describe('Legacy fallback for `cardTitle` (the board reads `cardTitle || titleField`). Prefer `cardTitle`'), @@ -3942,8 +4001,15 @@ const OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ * the canonical nested config every field resolution prefers), `filter` * (`:210`, `:232` — verbatim to `$filter`), `sort` (`:211`, `:233` — through * the shared `convertSortToQueryParams` sink, as `object-calendar`'s does), - * `limit` (`:234`, `:254` — the fetch's top-level `$top`, renderer default - * `DEFAULT_TIMELINE_LIMIT` = 100 at `:28`), `items` (`:170`, `:247`, `:299`, + * `limit` (⭐ re-READ at the CURRENT pin `87af769e9` on 2026-09-21T06:30Z, + * #19228 — the other anchors in this list are still the `53ded82b` readings + * the header names: `:407`, the fetch's one top-level `$top`, through + * `resolveRowLimit(schema.limit, DEFAULT_TIMELINE_LIMIT)` with the default + * `100` at `:29` and the refused-cap diagnostic at `:279`. ⚠️ It is this FLAT + * key that is read — this node's own nested `timeline.limit` is not, on any + * route. What reaches this flat key is a VIEW document's `timeline.limit`, + * flattened onto the generated node by `ObjectView.tsx:1725`; see the + * `timeline` door below), `items` (`:170`, `:247`, `:299`, * `:480` — the authored pass-through that short-circuits the object query), * `data` (`:171`, `:247`, `:254`, `:256` — the pre-fetched record source, * read off REACT PROPS rather than `schema`; the door's own docblock carries @@ -3969,6 +4035,45 @@ const OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ * VALUE posture: `timeline` takes {@link TimelineConfigSchema}, the block * `ListViewSchema.timeline` already declares — one vocabulary, taken by * reference, so this element face cannot fork from the view face. + * ⚠️ Taking it by reference also imported #19226's new `limit` onto THIS + * strictObject, beside the flat `limit` below — two authorable row caps on one + * node, and the nested one carries an APPLIED default, so every parsed node + * with a `timeline` block materializes `timeline.limit: 100` (#19228). + * + * ⛔ THE TWO ARE ON DIFFERENT FACES, and #19228 has now gone wrong on that + * boundary in both directions — once putting a view-face fact on the element + * key, once the reverse. So each statement names its face first. Measured at + * the pin `87af769e9`, 2026-09-21T09:15Z: + * - **ELEMENT FACE — THIS node's own nested `timeline.limit`: read on NO + * route.** `ObjectTimeline` binds `timelineConfig = schema.timeline` + * (`ObjectTimeline.tsx:262`) and reads exactly eight members off it — + * `startDateField`, `dateField`, `titleField`, `endDateField`, + * `groupByField`, `colorField`, `metaFields` (`:520`, through an `as any` + * cast, which is why a `timelineConfig?.x` sweep alone under-counts) and + * `scale`. `limit` is not among them, and NO spread of a node's own + * `timeline` block exists anywhere in objectui (0 hits; lit control, same + * shape, the view-block spreads `...mergedTimeline` / + * `...(viewOptions.timeline || {})`, which do fire). The + * `ElementDataSourceGate` mapping is `limit: 'limit'` + * (`plugin-timeline/src/index.tsx:333`) — FLAT, so it never touches the + * nested key either. + * - **VIEW FACE — a `ListViewSchema` document's `timeline.limit`: that is + * the route-dependent one**, and it is a different key on a different + * document. `ObjectView.tsx:1725` flattens the view block onto the node it + * returns (which then carries no `timeline` block at all), so the value + * arrives as this node's FLAT `limit` and is read; `ListView.tsx:3084` + * forwards it nested instead, where nothing reads it. Recorded on + * `rowLimitKey` in `view.zod.ts`, which is where that key lives. + * + * ⚠️ Flagged, not fixed — a THIRD route neither the card nor the first two + * reviews described: a hand-authored `object-timeline` node reaching + * `ObjectTimeline` through `SchemaRenderer` with no adapter in between. Its + * nested `timeline.limit` is unread there too (it is the same element-face + * key as the first bullet), and that route is the one an AUTHOR face is + * written for — so it is the route the open question most concerns. + * + * ⛔ Recorded, not repaired: which key should carry a timeline's row cap is + * the open half of #19228. * `mapping` stays `z.unknown()`: its contract * (`TimelineMappingSchema`) still lives in objectui, which is the * `object-calendar.calendar` posture this section's header prescribes for @@ -3992,7 +4097,7 @@ export const ObjectTimelinePropsSchema = lazySchema(() => strictObject({ objectName: z.string().optional() .describe('Object this timeline binds to. Optional because the component-level `dataSource` binding can supply the object instead — this block registers through `ElementDataSourceGate`, which lowers the binding onto this key before the renderer sees the node'), timeline: TimelineConfigSchema.optional() - .describe('Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: { startDateField, endDateField, titleField, groupByField, colorField, scale }. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling'), + .describe('Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: { startDateField, endDateField, titleField, groupByField, colorField, scale, limit }. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling. ⚠️ `limit` is the one member of this block NO renderer reads on any route: the rail is capped by the FLAT `limit` beside this key, which is also the only one a bound `dataSource` lowers into. A `limit` written inside this block is accepted, defaulted to 100, and never read'), /** Base query filter — the family's one `ViewFilterRule` array orthography (#15449). */ filter: z.array(ViewFilterRuleSchema, { error: ruleArrayFilterError({ diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 32e4b6b5a8d..860d3ac1e6d 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1216,14 +1216,114 @@ type RowLimitView = keyof typeof ROW_LIMIT_SUBJECT; * as complete, which is worse than the unbounded-and-silent one this key * replaces — the author needs to know the cap is visible, and the renderer * author needs to know it is owed. + * + * ⚠️ WHICH FACE THIS KEY IS ON, and why that has to be said first. There are + * TWO `limit`s a reader can confuse, on two different documents, and three + * rounds of #19228 went wrong on the boundary: + * · **VIEW FACE** — THIS key. A member of a `ListViewSchema` document's + * `kanban` / `gallery` / `timeline` block. An ADAPTER turns that document + * into a rendered node; no renderer reads this document directly. + * · **ELEMENT FACE** — a page component node's OWN `limit` + * (`ObjectKanbanPropsSchema`, `ObjectTimelinePropsSchema`), + * declared in `component.zod.ts`, with no applied default. That is the key + * every renderer and `ElementDataSourceGate` actually read. + * Every sentence below names its face before it says anything else. + * + * ⚠️ WHAT THIS VIEW-FACE KEY REACHES TODAY — recorded, not repaired (#19228). + * Measured first-hand at the pin this repo builds against (`.objectui-sha` = + * `87af769e9`), 2026-09-21T09:15Z, with TWO instruments, because one was not + * enough and the first one's answer was wrong: + * + * 1. PROPERTY-ACCESS spellings. ⛔ Published as its EXPRESSION, not as a + * number — this card exists because a confident count was wrong once, so + * a control nobody can re-derive is not a control. Run at the pin, from + * an objectui checkout, over every tracked file: + * probe: git grep -nIE '\.(kanban|gallery|timeline)(\?)?\.limit\b' + * control: git grep -nIE '\.(kanban|gallery|timeline)(\?)?\.(groupByField|scale|coverField)\b' + * Probe: **0** lines, 0 files. Control: **13** lines across **6** files — + * `app-shell/src/views/ObjectView.galleryBinding-7547.test.tsx:41`, + * `app-shell/src/views/ObjectView.tsx:450`, + * `plugin-list/src/ListView.tsx:2538`, `:2540`, `:2547`, `:3057`, `:3114`, + * `:3116`, + * `plugin-list/src/__tests__/ListView.kanbanOptionsBagCanonical-8193.test.tsx:42`, + * `:99`, `plugin-view/src/ObjectView.tsx:1695`, and + * `types/src/__tests__/object-kanban-group-by-limit-7322.test.ts:146`, `:148`. + * ⚠️ Filtering changes that number and the filter must be stated with it. + * Of the 13: **2 are COMMENTS** (`ObjectView.galleryBinding-7547.test.tsx:41`, + * `ListView.kanbanOptionsBagCanonical-8193.test.tsx:42`), **1 is an + * `it()` TITLE string** (same file, `:99` — ⛔ not a comment), and **2 are + * lines inside a QUOTED source-text pin** + * (`object-kanban-group-by-limit-7322.test.ts:146`, `:148`). So a reader + * counting executable reads only gets **8**. All three readings are of one + * hit set. A live instrument — and a WRONG answer. + * 2. ⭐ SPREADS — a spread carries a key without ever spelling it, so it is + * the hole instrument 1 cannot see by construction. ⛔ Re-take it by its + * PREDICATE, not by its count: **a spread whose target is the object + * literal an adapter RETURNS as the node** — flattening onto the node — + * as against a merge that builds a nested config (`...mergedTimeline` is + * the lit control for the instrument AND the example of what the predicate + * excludes). A grep broad enough to find these also returns the nested + * merges, so the rule, not the number, is what makes it reproducible. + * ⛔ And name what the predicate EXCLUDES, or the next reader re-finds + * it and wonders: `app-shell/src/views/ObjectView.tsx:206` and `:342` + * ARE spreads of a view block, inside `timelineViewOptions` (`:201`) and + * `galleryViewOptions` (`:334`). They build an OPTIONS BAG that feeds + * `ListView`'s nested forward, not the object literal an adapter returns + * as the node, so the predicate excludes them — deliberately, not by + * oversight. Two more the predicate excludes for their own reasons: + * `plugin-list/src/ListView.tsx:3044-3046` (`mergedGallery`) builds a + * NESTED gallery prop, the `...mergedTimeline` family; and + * `app-shell/src/views/ObjectView.tsx:1284` + * (`spec.kanban = { ...(spec.kanban || {}), columns }`) writes back into a + * VIEW document's own block — a metadata write, not a node build. + * Under that predicate, at that pin, the VIEW-face per-kind blocks give: + * `plugin-list/src/ListView.tsx:2979` `...restKanban` + * `plugin-view/src/ObjectView.tsx:1638` `...restKanban` + * `plugin-view/src/ObjectView.tsx:1697` `...(viewOptions.gallery || {})` + * `plugin-view/src/ObjectView.tsx:1725` `...(viewOptions.timeline || {})` + * Neither `restKanban` destructure strips `limit` (`ListView.tsx:2952`, + * `ObjectView.tsx:1579`), so a VIEW's per-kind `limit` — INCLUDING the 100 + * this applied default materializes — becomes the generated node's + * ELEMENT-face flat `limit`, which is the key the renderers read. + * + * ⇒ **A view's `kanban.limit`: flattened on BOTH adapter routes, and read.** + * `ObjectKanban.tsx:553` runs `describeRefusedRowLimit(schema.limit, …)` + * unconditionally. + * ⇒ **A view's `timeline.limit`: ROUTE-DEPENDENT.** `plugin-view` flattens it + * (`ObjectView.tsx:1725`) and the node it returns carries no `timeline` + * block at all, so the value arrives as the node's flat `limit` and + * `ObjectTimeline.tsx:279` reads it. `plugin-list` instead forwards the + * block NESTED (`ListView.tsx:3084`), where nothing reads it. + * ⇒ **A view's `gallery.limit`: flattened by `ObjectView.tsx:1697` and read by + * NOBODY** — `ObjectGallery.tsx` contains no `limit` at all (0 occurrences, + * case-insensitive, against a lit control `schema.imageField` / + * `schema.titleField` at `:340` / `:348`). ⛔ Do not generalise that + * asymmetry to the other two; it is gallery's alone. + * + * ⚠️ Where it IS read, the `$top` it would govern (`ObjectKanban.tsx:676`, + * `ObjectTimeline.tsx:407`) is still not issued on either adapter route today: + * both hosts hand rows down as a React `data` prop (`ListView.tsx:4702`, + * `ObjectView.tsx:2319`) and both children short-circuit their own fetch on it + * (`ObjectKanban.tsx:559`, `ObjectTimeline.tsx:420`). ⛔ That is a statement + * about the QUERY, not about the key being unread. + * + * ⚠️ A consequence of APPLIED that the open decision needs: through those + * spreads a spec-parsed view emits a node carrying an authored-LOOKING + * ELEMENT-face `limit: 100` that no author wrote. ⛔ Flagged, not acted on — + * changing it is a contract direction, not a tidy-up. + * + * ⛔ Which of the row bounds wins is NOT decided here and NOT implied by this + * declaration: #19228 opens that question and picks nothing, and neither does + * this note. What is recorded is only what each key reaches today. */ const rowLimitKey = (view: RowLimitView) => z.number().int().positive().default(DEFAULT_VIEW_ROW_LIMIT).describe( - `Row ceiling — the most ${ROW_LIMIT_SUBJECT[view]}, sent as the query \`$top\`; default ` - + `${DEFAULT_VIEW_ROW_LIMIT} when the key is absent. When the ceiling APPLIES (the filtered ` - + 'set is larger than it), the renderer must show a visible truncation signal saying what is ' - + 'on screen is not the whole set — a bounded view that looks complete is worse than an ' - + 'unbounded one.', + `Row ceiling — the most ${ROW_LIMIT_SUBJECT[view]}; default ` + + `${DEFAULT_VIEW_ROW_LIMIT} when the key is absent. The renderer owes two things: bound its ` + + 'fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), ' + + 'show a visible truncation signal saying what is on screen is not the whole set — a bounded ' + + 'view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a ' + + 'renderer that reads this key yet; which do is recorded on the declaration.', ); /**