From 9310b98f45213e62ebaa4370540b60a59dd4525e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 05:44:20 +0000 Subject: [PATCH] fix(spec): parenthesize union elements before `[]` in reference type cells (#5338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `format-type.ts` appended `[]` straight onto the rendered element, and `[]` binds tighter than `|` — so an array of `string | number` printed as `string | number[]`, which states "a string, OR an array of numbers". The cell and the schema were different types, on the one line metadata authors copy. Widen #4912's depth scan from `&` to `& | |`: the two operators share one rule (`[]` is not distributive over either), so `hasTopLevelIntersection` becomes `hasTopLevelUnionOrIntersection`. Nested operators inside `{}` / `<>` / `[]` / `()` are still ignored, so `Enum<'a' | 'b'>[]` and `Record[]` gain no stray brackets. Regenerated `content/docs/references/**`: 19 pages, 42 cells, 47 brackets added; no page created, resurrected or deleted; `check:docs` reports all 240 generated files in sync. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01559M8FVm6W6vDLABL3jvdW --- .../format-type-union-array-brackets.md | 25 ++++ content/docs/references/ai/conversation.mdx | 4 +- content/docs/references/api/contract.mdx | 2 +- content/docs/references/api/protocol.mdx | 2 +- .../docs/references/automation/approval.mdx | 2 +- .../references/automation/state-machine.mdx | 14 +- content/docs/references/data/field.mdx | 4 +- content/docs/references/data/object.mdx | 2 +- content/docs/references/data/query.mdx | 2 +- content/docs/references/identity/scim.mdx | 2 +- content/docs/references/kernel/manifest.mdx | 2 +- content/docs/references/system/book.mdx | 2 +- .../docs/references/system/collaboration.mdx | 4 +- content/docs/references/system/migration.mdx | 4 +- content/docs/references/ui/action.mdx | 4 +- content/docs/references/ui/app.mdx | 6 +- content/docs/references/ui/component.mdx | 4 +- content/docs/references/ui/dashboard.mdx | 2 +- content/docs/references/ui/page.mdx | 4 +- content/docs/references/ui/view.mdx | 18 +-- packages/spec/scripts/format-type.test.ts | 129 ++++++++++++++++++ packages/spec/scripts/lib/format-type.ts | 41 +++--- 22 files changed, 219 insertions(+), 60 deletions(-) create mode 100644 .changeset/format-type-union-array-brackets.md diff --git a/.changeset/format-type-union-array-brackets.md b/.changeset/format-type-union-array-brackets.md new file mode 100644 index 0000000000..34be2f1905 --- /dev/null +++ b/.changeset/format-type-union-array-brackets.md @@ -0,0 +1,25 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): 参考页给「元素是联合类型」的数组补上括号 —— `(string | number)[]`,不再是 `string | number[]` (#5338) + +参考页类型单元格由 `packages/spec/scripts/lib/format-type.ts` 渲染,数组分支此前直接把 +`[]` 拼在元素渲染结果之后。TypeScript 里 `[]` 的结合优先级高于 `|`,所以 +`string | number[]` 表达的是「一个 string,**或者**一个 number 数组」,而 schema 说的是 +「一个数组,元素是 string 或 number」—— **单元格印出的类型和 schema 声明的不是同一个**。 +参考页的类型单元格正是元数据作者(尤其是 AI 作者)直接照抄的那一行:照着 +`string | number[]` 写下一个裸 string,schema 会当场拒绝,而页面看起来是允许的。 + +修法只有一处:#4912 为交叉类型引入的深度扫描 `hasTopLevelIntersection` 放宽成 +`hasTopLevelUnionOrIntersection`,同时识别顶层 `&` 与 `|`。两者本来就是同一条规则—— +`[]` 对这两个运算符都不分配律(`A & B[]` 是 `A & (B[])`,`A | B[]` 是 `A | (B[])`)—— +所以合用一次扫描。深度扫描原本就正确忽略 `{}` / `< >` / `[]` / `()` 内部的运算符, +因此 `Enum<'a' | 'b'>[]`、`Record[]`、`{ k?: string | number }[]` +以及 markdown 链接都保持原样,不会多出括号。 + +重新生成 `content/docs/references/**` 后共 19 个参考页、42 行单元格得到修正 +(47 处补括号),没有新增页,也没有页面被复活;`check:docs` 报告 240 个生成文件全部同步。 + +#4912 的交叉类型侧行为不变:`({ label: string; value: … } & Record)[]` +仍然带括号,该 PR 的全部 pin 用例保持绿。 diff --git a/content/docs/references/ai/conversation.mdx b/content/docs/references/ai/conversation.mdx index 19436a8da6..07ca32e7dc 100644 --- a/content/docs/references/ai/conversation.mdx +++ b/content/docs/references/ai/conversation.mdx @@ -93,7 +93,7 @@ const result = CodeContentSchema.parse(data); | **id** | `string` | ✅ | Unique message ID | | **timestamp** | `string` | ✅ | ISO 8601 timestamp | | **role** | `Enum<'system' \| 'user' \| 'assistant' \| 'function' \| 'tool'>` | ✅ | | -| **content** | `{ type: 'text'; text: string; metadata?: Record } \| { type: 'image'; imageUrl: string; detail: Enum<'low' \| 'high' \| 'auto'>; metadata?: Record } \| { type: 'file'; fileUrl: string; mimeType: string; fileName?: string; … } \| { type: 'code'; text: string; language: string; metadata?: Record }[]` | ✅ | Message content (multimodal array) | +| **content** | `({ type: 'text'; text: string; metadata?: Record } \| { type: 'image'; imageUrl: string; detail: Enum<'low' \| 'high' \| 'auto'>; metadata?: Record } \| { type: 'file'; fileUrl: string; mimeType: string; fileName?: string; … } \| { type: 'code'; text: string; language: string; metadata?: Record })[]` | ✅ | Message content (multimodal array) | | **functionCall** | `{ name: string; arguments: string; result?: string }` | optional | Legacy function call | | **toolCalls** | `{ id: string; type: Enum<'function'>; function: object }[]` | optional | Tool calls | | **toolCallId** | `string` | optional | Tool call ID this message responds to | @@ -119,7 +119,7 @@ const result = CodeContentSchema.parse(data); | **context** | `{ sessionId: string; userId?: string; agentId?: string; object?: string; … }` | ✅ | | | **modelId** | `string` | optional | AI model ID | | **tokenBudget** | `{ maxTokens: integer; maxPromptTokens?: integer; maxCompletionTokens?: integer; reserveTokens: integer; … }` | ✅ | | -| **messages** | `{ id: string; timestamp: string; role: Enum<'system' \| 'user' \| 'assistant' \| 'function' \| 'tool'>; content: { type: 'text'; text: string; metadata?: Record } \| { type: 'image'; imageUrl: string; detail: Enum<'low' \| 'high' \| 'auto'>; metadata?: Record } \| { type: 'file'; fileUrl: string; mimeType: string; fileName?: string; … } \| { type: 'code'; text: string; language: string; metadata?: Record }[]; … }[]` | ✅ | | +| **messages** | `{ id: string; timestamp: string; role: Enum<'system' \| 'user' \| 'assistant' \| 'function' \| 'tool'>; content: ({ type: 'text'; text: string; metadata?: Record } \| { type: 'image'; imageUrl: string; detail: Enum<'low' \| 'high' \| 'auto'>; metadata?: Record } \| { type: 'file'; fileUrl: string; mimeType: string; fileName?: string; … } \| { type: 'code'; text: string; language: string; metadata?: Record })[]; … }[]` | ✅ | | | **tokens** | `{ promptTokens: integer; completionTokens: integer; totalTokens: integer; budgetLimit: integer; … }` | optional | | | **totalTokens** | `{ promptTokens: integer; completionTokens: integer; totalTokens: integer }` | optional | Total tokens across all messages | | **totalCost** | `number` | optional | Total cost for this session in USD | diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index a20f394422..509e05b81b 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -158,7 +158,7 @@ const result = ApiErrorSchema.parse(data); | **cursor** | `any` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | | **joins** | `any` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand` — `expand: { owner: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and a single related column is a dotted `fields` path (`fields: ['owner.name']`). | | **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'array_agg' \| 'string_agg'>; field?: string; alias: string; distinct?: boolean; … }[]` | optional | Aggregation functions | -| **groupBy** | `string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string }[]` | optional | GROUP BY targets (strings or `{field, dateGranularity?}` objects for date bucketing) | +| **groupBy** | `(string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string })[]` | optional | GROUP BY targets (strings or `{field, dateGranularity?}` objects for date bucketing) | | **having** | `any` | optional | HAVING — filter over the AGGREGATED rows (aggregation aliases + groupBy projections); applied engine-side after aggregation | | **windowFunctions** | `any` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (#4286, ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. | | **distinct** | `any` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index cab71694fc..cb385e1655 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -164,7 +164,7 @@ const result = AiAgentCapabilitiesSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **models** | `string \| { id: string; label: string; default: boolean }[]` | ✅ | Models this environment offers | +| **models** | `(string \| { id: string; label: string; default: boolean })[]` | ✅ | Models this environment offers | | **defaultModel** | `string` | optional | Default model id, when the service reports one | diff --git a/content/docs/references/automation/approval.mdx b/content/docs/references/automation/approval.mdx index 8ad8202c7b..1633e04e26 100644 --- a/content/docs/references/automation/approval.mdx +++ b/content/docs/references/automation/approval.mdx @@ -81,7 +81,7 @@ const result = ApprovalDecision.parse(data); | **lockRecord** | `boolean` | ✅ | Lock the record from editing while pending | | **approvalStatusField** | `string` | optional | Business-object field to mirror request status onto | | **onEmptyApprovers** | `Enum<'admin_rescue' \| 'fail' \| 'auto_approve'>` | ✅ | Behavior when no concrete approver resolves at node entry | -| **decisionOutputs** | `string \| { key: string; label?: string; type?: Enum<'text' \| 'user' \| 'department' \| 'position' \| 'team'>; multiple?: boolean; … }[]` | optional | Author-declared decision outputs — bare keys or typed `{ key, type, multiple }` declarations | +| **decisionOutputs** | `(string \| { key: string; label?: string; type?: Enum<'text' \| 'user' \| 'department' \| 'position' \| 'team'>; multiple?: boolean; … })[]` | optional | Author-declared decision outputs — bare keys or typed `{ key, type, multiple }` declarations | | **escalation** | `{ enabled: boolean; timeoutHours: number; action: Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>; escalateTo?: string; … }` | optional | Per-node SLA escalation | | **maxRevisions** | `integer` | ✅ | Max send-backs for revision before auto-reject (0 = send-back disabled) | diff --git a/content/docs/references/automation/state-machine.mdx b/content/docs/references/automation/state-machine.mdx index 5fc9f8845c..35cb6a12c9 100644 --- a/content/docs/references/automation/state-machine.mdx +++ b/content/docs/references/automation/state-machine.mdx @@ -179,8 +179,8 @@ Type: `string` | **description** | `string` | optional | | | **contextSchema** | `Record` | optional | Zod Schema for the machine context/memory | | **initial** | `string` | ✅ | Initial State ID | -| **states** | `Record; entry?: string \| { type: string; params?: Record }[]; exit?: string \| { type: string; params?: Record }[]; on?: Record }; actions?: string \| { type: string; params?: Record }[]; description?: string } \| { target?: string; cond?: string \| { type: string; params?: Record }; actions?: string \| { type: string; params?: Record }[]; description?: string }[]>; … }>` | ✅ | State Nodes | -| **on** | `Record }; actions?: string \| { type: string; params?: Record }[]; description?: string } \| { target?: string; cond?: string \| { type: string; params?: Record }; actions?: string \| { type: string; params?: Record }[]; description?: string }[]>` | optional | | +| **states** | `Record; entry?: (string \| { type: string; params?: Record })[]; exit?: (string \| { type: string; params?: Record })[]; on?: Record }; actions?: (string \| { type: string; params?: Record })[]; description?: string } \| { target?: string; cond?: string \| { type: string; params?: Record }; actions?: (string \| { type: string; params?: Record })[]; description?: string }[]>; … }>` | ✅ | State Nodes | +| **on** | `Record }; actions?: (string \| { type: string; params?: Record })[]; description?: string } \| { target?: string; cond?: string \| { type: string; params?: Record }; actions?: (string \| { type: string; params?: Record })[]; description?: string }[]>` | optional | | --- @@ -192,10 +192,10 @@ Type: `string` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'atomic' \| 'compound' \| 'parallel' \| 'final' \| 'history'>` | ✅ | | -| **entry** | `string \| { type: string; params?: Record }[]` | optional | Actions to run when entering this state | -| **exit** | `string \| { type: string; params?: Record }[]` | optional | Actions to run when leaving this state | -| **on** | `Record }; actions?: string \| { type: string; params?: Record }[]; description?: string } \| { target?: string; cond?: string \| { type: string; params?: Record }; actions?: string \| { type: string; params?: Record }[]; description?: string }[]>` | optional | Map of Event Type -> Transition Definition | -| **always** | `{ target?: string; cond?: string \| { type: string; params?: Record }; actions?: string \| { type: string; params?: Record }[]; description?: string }[]` | optional | | +| **entry** | `(string \| { type: string; params?: Record })[]` | optional | Actions to run when entering this state | +| **exit** | `(string \| { type: string; params?: Record })[]` | optional | Actions to run when leaving this state | +| **on** | `Record }; actions?: (string \| { type: string; params?: Record })[]; description?: string } \| { target?: string; cond?: string \| { type: string; params?: Record }; actions?: (string \| { type: string; params?: Record })[]; description?: string }[]>` | optional | Map of Event Type -> Transition Definition | +| **always** | `{ target?: string; cond?: string \| { type: string; params?: Record }; actions?: (string \| { type: string; params?: Record })[]; description?: string }[]` | optional | | | **initial** | `string` | optional | Initial child state (if compound) | | **states** | `Record` | optional | | | **meta** | `{ label?: string; description?: string; color?: string; aiInstructions?: string }` | optional | | @@ -211,7 +211,7 @@ Type: `string` | :--- | :--- | :--- | :--- | | **target** | `string` | optional | Target State ID | | **cond** | `string \| { type: string; params?: Record }` | optional | Condition (Guard) required to take this path | -| **actions** | `string \| { type: string; params?: Record }[]` | optional | Actions to execute during transition | +| **actions** | `(string \| { type: string; params?: Record })[]` | optional | Actions to execute during transition | | **description** | `string` | optional | Human readable description of this rule | diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 519127aa53..74f76ecaab 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -102,10 +102,10 @@ const result = AddressSchema.parse(data); | **relatedListColumns** | `any[]` | optional | Explicit columns for the detail-page related list (derived from the child object when omitted) | | **displayField** | `string` | optional | Field shown as each candidate's label in the picker/popover (defaults to the referenced object's name/title). | | **descriptionField** | `string` | optional | Secondary field shown under the label in the quick-select popover. | -| **lookupColumns** | `string \| { field: string; label?: string; width?: string; type?: string }[]` | optional | Explicit columns for the record-picker table; auto-derived from the referenced object when omitted. | +| **lookupColumns** | `(string \| { field: string; label?: string; width?: string; type?: string })[]` | optional | Explicit columns for the record-picker table; auto-derived from the referenced object when omitted. | | **lookupPageSize** | `integer` | optional | Rows per page in the record-picker dialog (default 10). | | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | -| **dependsOn** | `string \| { field: string; param?: string }[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | +| **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Formula expression (CEL). e.g. F`record.amount * 0.1` | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date'>` | optional | Inferred value type of a formula field (number/text/boolean/date) | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 617eaa2d2f..178cc6c957 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -81,7 +81,7 @@ const result = ApiMethod.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **class** | `Enum<'record' \| 'audit' \| 'telemetry' \| 'transient' \| 'event'>` | ✅ | Persistence contract: record (business truth, permanent) \| audit (compliance ledger) \| telemetry (high-freq log) \| transient (ephemeral state) \| event (bus messages). | -| **retention** | `{ maxAge: string; onlyWhen?: Record }` | optional | Age-based retention window enforced by the LifecycleService Reaper. | +| **retention** | `{ maxAge: string; onlyWhen?: Record }` | optional | Age-based retention window enforced by the LifecycleService Reaper. | | **ttl** | `{ field: string; expireAfter: string }` | optional | Per-row TTL auto-expiry (transient/event classes). | | **storage** | `{ strategy: 'rotation'; shards: integer; unit: Enum<'day' \| 'week' \| 'month'> }` | optional | Physical storage strategy for high-frequency telemetry (LifecycleService Rotator). | | **archive** | `{ after: string; to: string; keep?: string }` | optional | Cold-store archival (LifecycleService Archiver) — audit-class hot→cold hand-off. | diff --git a/content/docs/references/data/query.mdx b/content/docs/references/data/query.mdx index 320ee03145..946427ed0e 100644 --- a/content/docs/references/data/query.mdx +++ b/content/docs/references/data/query.mdx @@ -137,7 +137,7 @@ Type: `string` | **cursor** | `any` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | | **joins** | `any` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand` — `expand: { owner: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and a single related column is a dotted `fields` path (`fields: ['owner.name']`). | | **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'array_agg' \| 'string_agg'>; field?: string; alias: string; distinct?: boolean; … }[]` | optional | Aggregation functions | -| **groupBy** | `string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string }[]` | optional | GROUP BY targets (strings or `{field, dateGranularity?}` objects for date bucketing) | +| **groupBy** | `(string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string })[]` | optional | GROUP BY targets (strings or `{field, dateGranularity?}` objects for date bucketing) | | **having** | `any` | optional | HAVING — filter over the AGGREGATED rows (aggregation aliases + groupBy projections); applied engine-side after aggregation | | **windowFunctions** | `any` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (#4286, ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. | | **distinct** | `any` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | diff --git a/content/docs/references/identity/scim.mdx b/content/docs/references/identity/scim.mdx index 1e08ae08ce..bd7689499a 100644 --- a/content/docs/references/identity/scim.mdx +++ b/content/docs/references/identity/scim.mdx @@ -246,7 +246,7 @@ const result = SCIMAddressSchema.parse(data); | :--- | :--- | :--- | :--- | | **schemas** | `string[]` | ✅ | SCIM schema URIs | | **totalResults** | `integer` | ✅ | Total results count | -| **Resources** | `{ schemas: string[]; id?: string; externalId?: string; userName: string; … } \| { schemas: string[]; id?: string; externalId?: string; displayName: string; … } \| Record[]` | ✅ | Resources array (Users, Groups, or custom resources) | +| **Resources** | `({ schemas: string[]; id?: string; externalId?: string; userName: string; … } \| { schemas: string[]; id?: string; externalId?: string; displayName: string; … } \| Record)[]` | ✅ | Resources array (Users, Groups, or custom resources) | | **startIndex** | `integer` | optional | Start index (1-based) | | **itemsPerPage** | `integer` | optional | Items per page | diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index a5f3549c13..da975bcc01 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -64,7 +64,7 @@ const result = ManifestSchema.parse(data); | **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Initial seed data (prefer top-level data field) | | **capabilities** | `{ implements?: { protocol: object; conformance?: Enum<'full' \| 'partial' \| 'experimental' \| 'deprecated'>; implementedFeatures?: string[]; features?: { name: string; enabled?: boolean; description?: string; sinceVersion?: string; … }[]; … }[]; provides?: { id: string; name: string; description?: string; version: object; … }[]; requires?: { pluginId: string; version: string; optional?: boolean; reason?: string; … }[]; extensionPoints?: { id: string; name: string; description?: string; type: Enum<'action' \| 'hook' \| 'widget' \| 'provider' \| 'transformer' \| 'validator' \| 'decorator'>; … }[]; … }` | optional | Plugin capability declarations for interoperability | | **extensions** | `Record` | optional | Extension points and contributions | -| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| { id: string; label: string; icon?: string; order?: number; … }[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | +| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: ({ id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| { id: string; label: string; icon?: string; order?: number; … })[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **loading** | `{ strategy?: Enum<'eager' \| 'lazy' \| 'parallel' \| 'deferred' \| 'on-demand'>; preload?: object; codeSplitting?: object; dynamicImport?: object; … }` | optional | Plugin loading and runtime behavior configuration | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | diff --git a/content/docs/references/system/book.mdx b/content/docs/references/system/book.mdx index d73a33c0ce..31c13fc8e9 100644 --- a/content/docs/references/system/book.mdx +++ b/content/docs/references/system/book.mdx @@ -119,7 +119,7 @@ Type: `'public'` | **order** | `number` | optional | Order of THIS group within the book | | **include** | `string \| { tag: string }` | optional | Rule that derives membership (glob or tag) | | **package** | `string` | optional | Scope the rule to a package id (default: the book package; cross-package via ADR-0048) | -| **pages** | `string \| { doc?: string; href?: string; label?: string; badge?: string; … }[]` | optional | OPTIONAL explicit override — hand-pin a curated order; wins over `include` | +| **pages** | `(string \| { doc?: string; href?: string; label?: string; badge?: string; … })[]` | optional | OPTIONAL explicit override — hand-pin a curated order; wins over `include` | --- diff --git a/content/docs/references/system/collaboration.mdx b/content/docs/references/system/collaboration.mdx index d8e3eb0f4f..43a1857310 100644 --- a/content/docs/references/system/collaboration.mdx +++ b/content/docs/references/system/collaboration.mdx @@ -233,7 +233,7 @@ This schema accepts one of the following structures: | **users** | `{ userId: string; sessionId: string; userName: string; userAvatar?: string; … }[]` | ✅ | Active users | | **cursors** | `{ userId: string; sessionId: string; documentId: string; userName: string; … }[]` | ✅ | Active cursors | | **version** | `integer` | ✅ | Current document version | -| **operations** | `{ operationId: string; documentId: string; userId: string; sessionId: string; … } \| { operationId: string; replicaId: string; position: integer; insert?: string; … }[]` | optional | Recent operations | +| **operations** | `({ operationId: string; documentId: string; userId: string; sessionId: string; … } \| { operationId: string; replicaId: string; position: integer; insert?: string; … })[]` | optional | Recent operations | | **createdAt** | `string` | ✅ | ISO 8601 datetime when session was created | | **lastActivity** | `string` | ✅ | ISO 8601 datetime of last activity | | **status** | `Enum<'active' \| 'idle' \| 'ended'>` | ✅ | Session status | @@ -467,7 +467,7 @@ This schema accepts one of the following structures: | **documentId** | `string` | ✅ | Document identifier | | **userId** | `string` | ✅ | User who created the operation | | **sessionId** | `string` | ✅ | Session identifier | -| **components** | `{ type: 'insert'; text: string; attributes?: Record } \| { type: 'delete'; count: integer } \| { type: 'retain'; count: integer; attributes?: Record }[]` | ✅ | Operation components | +| **components** | `({ type: 'insert'; text: string; attributes?: Record } \| { type: 'delete'; count: integer } \| { type: 'retain'; count: integer; attributes?: Record })[]` | ✅ | Operation components | | **baseVersion** | `integer` | ✅ | Document version this operation is based on | | **timestamp** | `string` | ✅ | ISO 8601 datetime when operation was created | | **metadata** | `Record` | optional | Additional operation metadata | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index a88030d7a8..15e1ffc3e2 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -73,8 +73,8 @@ A versioned set of atomic schema migration operations | **author** | `string` | optional | Author who created this migration | | **createdAt** | `string` | optional | ISO 8601 timestamp when the migration was created | | **dependencies** | `{ migrationId: string; package?: string }[]` | optional | Migrations that must run before this one | -| **operations** | `{ type: 'add_field'; objectName: string; fieldName: string; field: object } \| { type: 'modify_field'; objectName: string; fieldName: string; changes: Record } \| { type: 'remove_field'; objectName: string; fieldName: string } \| { type: 'create_object'; object: object } \| { type: 'rename_object'; oldName: string; newName: string } \| { type: 'delete_object'; objectName: string } \| { type: 'execute_sql'; sql: string; description?: string }[]` | ✅ | Ordered list of atomic migration operations | -| **rollback** | `{ type: 'add_field'; objectName: string; fieldName: string; field: object } \| { type: 'modify_field'; objectName: string; fieldName: string; changes: Record } \| { type: 'remove_field'; objectName: string; fieldName: string } \| { type: 'create_object'; object: object } \| { type: 'rename_object'; oldName: string; newName: string } \| { type: 'delete_object'; objectName: string } \| { type: 'execute_sql'; sql: string; description?: string }[]` | optional | Operations to reverse this migration | +| **operations** | `({ type: 'add_field'; objectName: string; fieldName: string; field: object } \| { type: 'modify_field'; objectName: string; fieldName: string; changes: Record } \| { type: 'remove_field'; objectName: string; fieldName: string } \| { type: 'create_object'; object: object } \| { type: 'rename_object'; oldName: string; newName: string } \| { type: 'delete_object'; objectName: string } \| { type: 'execute_sql'; sql: string; description?: string })[]` | ✅ | Ordered list of atomic migration operations | +| **rollback** | `({ type: 'add_field'; objectName: string; fieldName: string; field: object } \| { type: 'modify_field'; objectName: string; fieldName: string; changes: Record } \| { type: 'remove_field'; objectName: string; fieldName: string } \| { type: 'create_object'; object: object } \| { type: 'rename_object'; oldName: string; newName: string } \| { type: 'delete_object'; objectName: string } \| { type: 'execute_sql'; sql: string; description?: string })[]` | optional | Operations to reverse this migration | --- diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 7cdaf259d1..3f1c147929 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -103,7 +103,7 @@ const result = ActionSchema.parse(data); | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | | **shortcut** | `any` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. | | **bulkEnabled** | `any` | optional | [REMOVED] `action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — the multi-select toolbar is driven by the LIST VIEW's `bulkActions` / `bulkActionDefs`, never by this flag, so setting it changed nothing. Delete the key and declare the action in the view's `bulkActions` instead. | -| **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | +| **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | | **recordIdField** | `string` | optional | Row field whose value seeds recordIdParam. Defaults to "id". | | **bodyShape** | `'flat' \| { wrap: string }` | optional | Body wrapping: flat (default) or `{ wrap: key }` to nest user-collected params under a key. | @@ -133,7 +133,7 @@ const result = ActionSchema.parse(data); | **exposed** | `boolean` | ✅ | Expose this action to AI agents. Requires `description` when true. | | **description** | `string` | optional | LLM-facing description (≥40 chars). Required when exposed. | | **category** | `Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>` | optional | Tool category override (defaults to "action"). | -| **paramHints** | `Record` | optional | Per-parameter AI hints keyed by param name. | +| **paramHints** | `Record` | optional | Per-parameter AI hints keyed by param name. | | **outputSchema** | `Record` | optional | JSON Schema for the action return value. | | **requiresConfirmation** | `boolean` | optional | Override HITL confirmation for AI invocations. | diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index ba3d7d03e4..026d2b3300 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -80,7 +80,7 @@ const result = ActionNavItemSchema.parse(data); | **active** | `boolean` | optional | Whether the app is enabled | | **isDefault** | `boolean` | optional | Is default app | | **hidden** | `boolean` | optional | Hide from the App Switcher; the shell surfaces hidden apps via the avatar menu instead | -| **navigation** | `{ id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| { id: string; label: string; icon?: string; order?: number; … }[]` | optional | Full navigation tree for the app sidebar | +| **navigation** | `({ id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| { id: string; label: string; icon?: string; order?: number; … })[]` | optional | Full navigation tree for the app sidebar | | **areas** | `{ id: string; label: string; icon?: string; description?: string; … }[]` | optional | Navigation areas for partitioning navigation by business domain | | **contextSelectors** | `{ id: string; label: string; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | | **homePageId** | `any` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, #4709, ADR-0049). objectui's console did read it before v17 (`resolveLandingRoute`), so this key had a consumer — it was retired because the capability is better expressed on the navigation item itself than as an ID cross-reference that silently falls back when it dangles. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to rewrite existing sources automatically. | @@ -211,7 +211,7 @@ const result = ActionNavItemSchema.parse(data); | **label** | `string` | ✅ | Area display label | | **icon** | `string` | optional | Area icon name | | **description** | `string` | optional | Area description | -| **navigation** | `{ id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| { id: string; label: string; icon?: string; order?: number; … }[]` | ✅ | Navigation items within this area | +| **navigation** | `({ id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| { id: string; label: string; icon?: string; order?: number; … })[]` | ✅ | Navigation items within this area | --- @@ -227,7 +227,7 @@ A navigation contribution: a package injecting nav items into an app it does not | **app** | `string` | ✅ | Target app name to contribute navigation into (e.g. "setup") | | **group** | `string` | optional | Target group nav-item id to append into (e.g. "group_integrations"); omit to append at the app top level | | **priority** | `integer` | optional | Merge priority within the target group — lower applied first (matches object extender priority) | -| **items** | `{ id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| { id: string; label: string; icon?: string; order?: number; … }[]` | ✅ | Navigation items contributed into the target app/group | +| **items** | `({ id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| { id: string; label: string; icon?: string; order?: number; … })[]` | ✅ | Navigation items contributed into the target app/group | --- diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 1856be8c7d..d2ad647b4b 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -336,7 +336,7 @@ Type: `string` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **fields** | `string \| { name: string; label?: string; icon?: string; type?: string; … }[]` | ✅ | Key fields to highlight (1-7 fields max, typically displayed as prominent cards). Each item may be a bare field name or `{name, label?, icon?, type?, readonly?}` for inline overrides. | +| **fields** | `(string \| { name: string; label?: string; icon?: string; type?: string; … })[]` | ✅ | Key fields to highlight (1-7 fields max, typically displayed as prominent cards). Each item may be a bare field name or `{name, label?, icon?, type?, readonly?}` for inline overrides. | | **layout** | `Enum<'horizontal' \| 'vertical'>` | ✅ | Layout orientation for highlight fields | | **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | @@ -368,7 +368,7 @@ Type: `string` | **columns** | `string[]` | optional | Fields to display in the related list. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns → this inline list. | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order for related records | | **limit** | `integer` | ✅ | Number of records to display initially | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| string \| number[] }[]` | optional | Additional filter criteria for related records | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria for related records | | **title** | `string` | optional | Custom title for the related list | | **showViewAll** | `boolean` | ✅ | Show "View All" link to see all related records | | **actions** | `string[]` | optional | Action IDs available for related records | diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index 9a84bba575..0033be0d44 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -128,7 +128,7 @@ Widget configuration — declared query keys + open renderer extras | **sortBy** | `string` | optional | Dimension/measure name to order by | | **sortOrder** | `Enum<'asc' \| 'desc'>` | optional | Sort direction for sortBy | | **limit** | `integer` | optional | Max rows (applied after ordering) | -| **stageOrder** | `string \| number \| boolean[]` | optional | Explicit category order for funnel/pyramid stages (stored values) | +| **stageOrder** | `(string \| number \| boolean)[]` | optional | Explicit category order for funnel/pyramid stages (stored values) | --- diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index ed5cfe4595..6c2f894a1d 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -51,7 +51,7 @@ Interface-level page configuration (Airtable parity) | **source** | `string` | optional | Source object name for the page | | **columns** | `string[] \| { field: string; label?: string; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance). | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Default sort order for the page, defined directly on the page. | -| **filterBy** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| string \| number[] }[]` | optional | Always-on page filter (base filter). | +| **filterBy** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Always-on page filter (base filter). | | **levels** | `integer` | optional | Number of hierarchy levels to display | | **sourceView** | `string` | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. | | **appearance** | `{ showDescription: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | @@ -83,7 +83,7 @@ Interface-level page configuration (Airtable parity) | **regions** | `{ name: string; width?: Enum<'small' \| 'medium' \| 'large' \| 'full'>; components: { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … }[] }[]` | optional | Layout regions (header, main, sidebar, footer) with their components. Optional — list pages use interfaceConfig, slotted pages use slots, and an empty full page falls back to the synthesized default layout. | | **isDefault** | `boolean` | optional | | | **assignedProfiles** | `string[]` | optional | | -| **interfaceConfig** | `{ source?: string; columns?: string[] \| { field: string; label?: string; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]; sort?: { field: string; order: Enum<'asc' \| 'desc'> }[]; filterBy?: { field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| string \| number[] }[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | +| **interfaceConfig** | `{ source?: string; columns?: string[] \| { field: string; label?: string; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]; sort?: { field: string; order: Enum<'asc' \| 'desc'> }[]; filterBy?: { field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | | **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | | **kind** | `Enum<'full' \| 'slotted' \| 'html' \| 'react' \| 'jsx'>` | optional | Page override mode. full \| slotted = structured authoring; html = author-written constrained JSX/HTML+Tailwind compiled (parsed, never executed) to the tree (ADR-0080; the legacy value 'jsx' is a deprecated alias); react = real-React source executed at render by the runtime (ADR-0081); it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via the OS_PAGE_REACT=off env toggle. | | **slots** | `{ header?: { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … }[]; actions?: { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … }[]; alerts?: { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … }[]; highlights?: { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … }[]; … }` | optional | Slot override map for slotted pages | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 5a3467fc41..97d6176430 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -183,7 +183,7 @@ Column footer summary configuration | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| '1' \| '2' \| '3' \| '4'` | optional | | | **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. | -| **fields** | `string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>; options?: { label: string; value: string; color?: string; default?: boolean; … }[]; reference?: string; … }[]` | ✅ | | +| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>; options?: { label: string; value: string; color?: string; default?: boolean; … }[]; reference?: string; … })[]` | ✅ | | --- @@ -261,8 +261,8 @@ Gallery/card view configuration | **assigneeField** | `string` | optional | Resource field to bucket load by (resource view) | | **effortField** | `string` | optional | Per-task load units (resource view; default 1) | | **capacity** | `number` | optional | Per-resource capacity ceiling; loads above this flag overload | -| **tooltipFields** | `string \| { field: string; label?: string }[]` | optional | Fields to surface in the hover tooltip, in display order | -| **quickFilters** | `{ field: string; label?: string; options?: string \| { value: string \| number; label?: string }[] }[]` | optional | Multi-select filter dropdowns rendered above the chart | +| **tooltipFields** | `(string \| { field: string; label?: string })[]` | optional | Fields to surface in the hover tooltip, in display order | +| **quickFilters** | `{ field: string; label?: string; options?: (string \| { value: string \| number; label?: string })[] }[]` | optional | Multi-select filter dropdowns rendered above the chart | | **autoZoomToFilter** | `boolean` | optional | When true (default), filtering zooms the range to the filtered tasks | @@ -276,7 +276,7 @@ Gallery/card view configuration | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Record field / dot-path the dimension filters on | | **label** | `string` | optional | Trigger label (falls back to the field label) | -| **options** | `string \| { value: string \| number; label?: string }[]` | optional | Explicit option override for fixed enums | +| **options** | `(string \| { value: string \| number; label?: string })[]` | optional | Explicit option override for fixed enums | --- @@ -371,7 +371,7 @@ List chart view configuration | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| string \| number[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -460,7 +460,7 @@ List chart view configuration | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| string \| number[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -636,7 +636,7 @@ Quick-filter field configuration | **type** | `Enum<'select' \| 'multi-select' \| 'boolean' \| 'date-range' \| 'text'>` | optional | Filter control type. Omit to infer from the field definition | | **options** | `{ value: string \| number \| boolean; label: string; color?: string }[]` | optional | Static options. Omit to derive from the field definition (select options / lookup records) | | **showCount** | `boolean` | optional | Show per-option record counts | -| **defaultValues** | `string \| number \| boolean[]` | optional | Pre-selected values when the view loads | +| **defaultValues** | `(string \| number \| boolean)[]` | optional | Pre-selected values when the view loads | --- @@ -748,7 +748,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| string \| number[]` | optional | Filter value | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value | --- @@ -937,7 +937,7 @@ Tab configuration for multi-tab view interface | **label** | `string` | optional | Display label | | **icon** | `string` | optional | Tab icon name | | **view** | `string` | optional | Referenced list view name from listViews | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| string \| number[] }[]` | optional | Tab-specific filter criteria | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | | **order** | `integer` | optional | Tab display order | | **pinned** | `boolean` | ✅ | Pin tab (cannot be removed by users) | | **isDefault** | `boolean` | ✅ | Set as the default active tab | diff --git a/packages/spec/scripts/format-type.test.ts b/packages/spec/scripts/format-type.test.ts index 05f10b9da4..b2c38d9bf5 100644 --- a/packages/spec/scripts/format-type.test.ts +++ b/packages/spec/scripts/format-type.test.ts @@ -133,6 +133,135 @@ describe('formatType — open objects keep their declared shape (#4912)', () => }); }); +/** + * The real `AiModelsResponse.models` node, as `gen:schema` emits it — + * `z.union([z.string(), z.object({ id, label, default })]).array()`. + */ +const AI_MODELS = { + type: 'array', + items: { + anyOf: [ + { type: 'string' }, + { + type: 'object', + properties: { + id: { type: 'string' }, + label: { type: 'string' }, + default: { type: 'boolean' }, + }, + required: ['id', 'label', 'default'], + additionalProperties: false, + }, + ], + }, +}; + +/** The real `View.list.filter[].value` node — a union that CONTAINS an array. */ +const VIEW_FILTER_VALUE = { + anyOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'null' }, + { type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'number' }] } }, + ], +}; + +/** + * Pin for arrays whose ELEMENT is a union — #5338. + * + * Same associativity fact as the intersection half above, one operator over: + * `[]` binds tighter than `|`, so `string | number[]` is `string | (number[])` + * — "a string, OR an array of numbers" — while the schema said "an array whose + * elements are string or number". An author copying that cell writes a bare + * string and the schema rejects it. This half is OLDER than #4912 (it predates + * the passthrough fix entirely); #4912 scoped its scan to `&` on purpose so its + * ~12-line regeneration wouldn't be buried under this one's ~200. + * + * MEASURED (reverse verification): narrowing the scan back to `&` alone — the + * pre-#5338 `hasTopLevelIntersection` — turns 5 of this block's 7 cases red + * with the unbracketed spelling (`string | { id: string; … }[]` etc.), while + * every case in the `&` block above stays green. The direction is the ordinary + * one because these assert a POSITIVE shape the fix produces, not the absence + * of a finding. The two that stay green under the narrow scan are honest + * non-regressions rather than dead pins: the mixed union+intersection case was + * ALREADY bracketed by the `&` half (it carries both operators), and the last + * case asserts the parens are NOT added, which is the half of the rule this + * change must not break. + */ +describe('formatType — union elements are parenthesized before `[]` (#5338)', () => { + it('brackets a union of primitives instead of letting `[]` claim the last variant', () => { + expect(formatType({ type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'number' }] } }, ctx())) + .toBe('(string | number)[]'); + expect( + formatType( + { type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }] } }, + ctx(), + ), + ).toBe('(string | number | boolean)[]'); + // `oneOf` is the same node class and must render identically. + expect(formatType({ type: 'array', items: { oneOf: [{ type: 'string' }, { type: 'number' }] } }, ctx())) + .toBe('(string | number)[]'); + }); + + it('brackets a union whose variants are objects (the AiModelsResponse specimen)', () => { + expect(formatType(AI_MODELS, ctx())) + .toBe('(string | { id: string; label: string; default: boolean })[]'); + }); + + it('brackets a JSON Schema type-array element (`type: [a, b]`), which also renders a union', () => { + expect(formatType({ type: 'array', items: { type: ['string', 'null'] } }, ctx())) + .toBe('(string | null)[]'); + }); + + it('brackets the ARRAY VARIANT inside a union, leaving the outer union unbracketed', () => { + // The `View.list.filter[].value` cell. The outer union is not suffixed by + // `[]`, so it needs no parens; the inner array element does. + expect(formatType(VIEW_FILTER_VALUE, ctx())) + .toBe('string | number | boolean | null | (string | number)[]'); + }); + + it('brackets once per array level, so nested arrays stay readable', () => { + expect( + formatType( + { type: 'array', items: { type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'number' }] } } }, + ctx(), + ), + ).toBe('(string | number)[][]'); + }); + + it('brackets a union that also carries an intersection variant', () => { + const open = { type: 'object', properties: { a: { type: 'string' } }, additionalProperties: {} }; + expect(formatType({ type: 'array', items: { anyOf: [{ type: 'string' }, open] } }, ctx())) + .toBe('(string | { a?: string } & Record)[]'); + }); + + it('adds NO parens when the element has no top-level operator', () => { + // A single-variant union renders as one type — bracketing it would be noise. + expect(formatType({ type: 'array', items: { anyOf: [{ type: 'string' }] } }, ctx())).toBe('string[]'); + // `|` inside `Enum<…>`, a shape, a `Record<…>` argument or a link target is + // nested, and the depth scan must keep ignoring it. + expect(formatType({ type: 'array', items: { enum: ['a', 'b'] } }, ctx())).toBe("Enum<'a' | 'b'>[]"); + expect( + formatType( + { type: 'array', items: { type: 'object', properties: { k: { anyOf: [{ type: 'string' }, { type: 'number' }] } } } }, + ctx(), + ), + ).toBe('{ k?: string | number }[]'); + expect( + formatType( + { type: 'array', items: { type: 'object', additionalProperties: { anyOf: [{ type: 'string' }, { type: 'number' }] } } }, + ctx(), + ), + ).toBe('Record[]'); + expect(formatType({ type: 'array', items: { $ref: '#/$defs/Field' } }, { + defs: {}, + currentSchema: 'Probe', + schemaHref: () => '/docs/references/data/field#field', + })).toBe('[Field](/docs/references/data/field#field)[]'); + }); +}); + describe('formatType — the shapes that were already right stay right', () => { it('renders a pure record (no declared keys) as a bare Record', () => { expect(formatType({ type: 'object', additionalProperties: { type: 'number' } }, ctx())) diff --git a/packages/spec/scripts/lib/format-type.ts b/packages/spec/scripts/lib/format-type.ts index e2b9a14726..792a7253cb 100644 --- a/packages/spec/scripts/lib/format-type.ts +++ b/packages/spec/scripts/lib/format-type.ts @@ -48,28 +48,32 @@ export const anchorFor = (schemaName: string) => `#${schemaName.toLowerCase()}`; const INLINE_KEY_LIMIT = 4; /** - * Does this rendered type carry a top-level `&`, i.e. would suffixing `[]` - * re-associate it? + * Does this rendered type carry a top-level `&` or `|`, i.e. would suffixing + * `[]` re-associate it? * - * `A & B[]` is `A & (B[])` in TypeScript, not `(A & B)[]` — so an array whose - * element is an intersection MUST be parenthesized or the cell states a - * different type than the schema. Depth is tracked across `{}`, `<>`, `[]` and - * `()` so operators nested inside a shape, a `Record<…>` type argument, an - * `Enum<'a' | 'b'>` or a markdown link target are correctly ignored. + * `[]` binds tighter than both operators: `A & B[]` is `A & (B[])` and + * `A | B[]` is `A | (B[])`, never `(A & B)[]` / `(A | B)[]`. So an array whose + * element renders as a top-level intersection OR union MUST be parenthesized, + * or the cell states a different type than the schema — `string | number[]` + * reads as "a string, or an array of numbers", while the schema said "an array + * whose elements are string or number". Depth is tracked across `{}`, `<>`, + * `[]` and `()` so operators nested inside a shape, a `Record<…>` type + * argument, an `Enum<'a' | 'b'>` or a markdown link target are correctly + * ignored. * - * Scoped to `&` deliberately. Arrays whose element is a top-level UNION have - * the identical defect (`string | number[]` for an array of `string | number`) - * on 164 sites, but that one PREDATES this renderer change and is filed as - * #5338 — bundling its ~170-line regeneration in here would bury the #4912 fix - * this function exists for. Widening to `|` is the whole of that fix; the depth - * scan below already ignores nested operators correctly. + * The `&` half arrived with #4912, which had *introduced* intersection + * elements (`{ declared keys } & Record`) and so fixed only what + * it caused. The `|` half is the older defect, filed separately as #5338 and + * fixed here: it predated that renderer change and its regeneration diff would + * have buried the passthrough fix. Both halves are one rule — `[]` is not + * distributive over either operator — so they share one scan. */ -function hasTopLevelIntersection(rendered: string): boolean { +function hasTopLevelUnionOrIntersection(rendered: string): boolean { let depth = 0; for (const ch of rendered) { if (ch === '{' || ch === '<' || ch === '[' || ch === '(') depth++; else if (ch === '}' || ch === '>' || ch === ']' || ch === ')') depth--; - else if (depth === 0 && ch === '&') return true; + else if (depth === 0 && (ch === '&' || ch === '|')) return true; } return false; } @@ -102,9 +106,10 @@ export function formatType(prop: any, ctx?: TypeContext): string { if (prop.type === 'array') { const element = formatType(prop.items, ctx); - // An open object element renders as an intersection, which `[]` would - // re-associate — parenthesize so the cell keeps meaning "array of that". - return hasTopLevelIntersection(element) ? `(${element})[]` : `${element}[]`; + // An open object element renders as an intersection and a multi-variant + // element as a union — `[]` would re-associate either — so parenthesize + // and the cell keeps meaning "array of that". + return hasTopLevelUnionOrIntersection(element) ? `(${element})[]` : `${element}[]`; } if (prop.enum) {