diff --git a/.changeset/19581-zod-formatter-proto-path-bump.md b/.changeset/19581-zod-formatter-proto-path-bump.md new file mode 100644 index 00000000000..6ae342dfe28 --- /dev/null +++ b/.changeset/19581-zod-formatter-proto-path-bump.md @@ -0,0 +1,70 @@ +--- +"@objectstack/cli": patch +"@objectstack/core": patch +"@objectstack/driver-turso": patch +"@objectstack/mcp": patch +"@objectstack/metadata": patch +"@objectstack/metadata-core": patch +"@objectstack/metadata-protocol": patch +"@objectstack/objectql": patch +"@objectstack/rest": patch +"@objectstack/runtime": patch +"@objectstack/spec": patch +--- + +**The declared `zod` floor moves from `^4.4.3` to `^4.6.1`**, because on zod below 4.6.1 the three standard error formatters — `z.treeifyError()`, `error.format()` and `error.flatten()` — cannot render a refusal these packages actually emit (#19581). + +Clause-②: no + +**What breaks below the new floor.** All three formatters walked an issue's `path` by reading `curr[el]` and testing it for truthiness before creating a node, so a path element naming a member of `Object.prototype` was answered by the prototype and no node was ever created. Two different failures follow: + +| path shape | what happened on `^4.4.3` | +|:---|:---| +| terminal element (`['assignments','__proto__']`, `['x','toString']`) | the inherited member is adopted as the node, then `node._errors.push(...)` runs on it — `TypeError: Cannot read properties of undefined (reading 'push')` | +| non-terminal element (`['__proto__', …]`) | the walk continues **into** `Object.prototype` and writes the next segment onto it — the message is silently dropped from the returned tree and the process gains a global prototype key | + +**Why it reached this platform's consumers.** `@objectstack/spec` refuses a `__proto__` key on its open-key authoring surfaces, and that refusal's issue path is `['assignments','__proto__']` — precisely the terminal shape. Anything that formatted one of these refusals for display crashed on it, and the crash was in the formatter, not in the guard. The guards themselves are unchanged and still necessary: 4.6.1 still drops a `__proto__` key from `z.record()` and `.catchall()` output, which is what they exist to refuse. + +**What an upgrading consumer must do.** Nothing, if `zod` is resolved through these packages — the floor does it. A consumer that pins `zod` itself must move that pin to `^4.6.1` or higher; a pin below it reintroduces the crash on any refusal whose path names an `Object.prototype` member, including the ones these packages emit. + +`@objectstack/lint` also moves, but only in `devDependencies`, so nothing it publishes changes for a consumer and it takes no release here. + +## The second half the floor move needs: an unknown key refuses TERMINALLY again + +From zod 4.5.0 an `unrecognized_keys` issue carries `continue: true`, so it no +longer aborts the shape that raised it. Two things follow, and both were +measured on this package with the same bodies on 4.4.3 and 4.6.1: + +1. **A closed shape's own refinements now run after the refusal**, adding a + second complaint that contradicts the first. +2. **A union containing that shape loses its envelope.** zod's + `handleUnionResults` returns a single non-aborted member's issues + *unwrapped* instead of raising `invalid_union`, so the union's message + becomes whichever branch zod judged closest. + +At `PUT /api/v1/meta/view` that turned a retired-value refusal into the wrong +branch's prescription. Writing `type: 'page'` on a ViewItem answered: + +``` +Unrecognized key(s) on this view container: `viewKind`, `config`. + • `viewKind` belongs to a single VIEW, not to the container. Wrap it: … +``` + +— naming neither `page` nor its removal. It now answers, as it did before: + +``` +config.type: 'page' was removed from the list-view `type` enum in +@objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … +``` + +**What an upgrading consumer must do.** Nothing. No key or value changed +status: everything this package accepted before it accepts now, and everything +it refused it still refuses. What changed is which of several competing +complaints an author reads, and that a refusal behind a union is again +reported as `invalid_union` with its branches, which is what `z.treeifyError()` +and this package's own `formatZodError` expand. + +⚠️ A closed shape declared with a bare `z.object(…).strict()` or +`z.strictObject(…)` — zod's own, not this package's `strictObject` — does NOT +get this and will still collapse its union. Build closed authoring shapes with +`strictObject`, or re-declare an existing one through `closedObject`. diff --git a/apps/docs/package.json b/apps/docs/package.json index 96f1699b405..f80058eeaa6 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -37,7 +37,7 @@ "postcss": "^8.5.26", "tailwindcss": "^4.3.3", "typescript": "^6.0.3", - "zod": "4.4.3" + "zod": "^4.6.1" }, "engines": { "node": ">=22.0.0" diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 9695b71653a..d2eeb72a61d 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1665,7 +1665,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **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 \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ 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 | @@ -1750,7 +1750,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **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 \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ 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 | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 4b61d98733e..7e4ddde94f6 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -364,7 +364,7 @@ const result = ApiMethod.parse(data); | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **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 \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ 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 | diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 98811cc478c..aa57dc249e5 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -216,7 +216,7 @@ View filter rule | **source** | `string` | optional | Source object name for the page | | **columns** | `string[] \| { field: string; label?: string \| Record; 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' \| 'icontains' \| …>; 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' \| 'icontains' \| …>; 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 | @@ -274,7 +274,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object to query | | **view** | `string` | optional | Named view to apply | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. 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 | Sort order | | **limit** | `integer` | optional | Max records to display | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index f416cf00e9f..4d92266aeb7 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -279,7 +279,7 @@ Form-view select option — the object-field option shape minus the per-option ` | :--- | :--- | :--- | :--- | | **displayFields** | `string[]` | optional | Fields projected into each picker result (with `id`); the visitor's search matches `contains` on the first entry. At most 5 (the route projects no more); omitted → ['name']. | | **maxResults** | `integer` | optional | Maximum rows a lookup returns (default 20, hard ceiling 50 — the route clamps; anonymous visitors cannot paginate past it). | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Static pre-filter rows ANDed ahead of the visitor's search (e.g. only active records are searchable). Same `{ field, operator, value }` dialect as list-view filters. | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Static pre-filter rows ANDed ahead of the visitor's search (e.g. only active records are searchable). Same `{ field, operator, value }` dialect as list-view filters. | | **object** | `string` | optional | Referenced-object override for the picker search; omitted → resolved from the `reference` key on the field definition. | ### Nested Shape: `FormField.keyField` @@ -793,7 +793,7 @@ Map view configuration | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **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 \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ 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 | @@ -895,7 +895,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | -| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | optional | Filter operator | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ListView.userFilters` @@ -1129,7 +1129,7 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | 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' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | | **order** | `integer` | optional | Tab display order | | **pinned** | `boolean` | optional (default: `false`) | Pin tab (cannot be removed by users) | | **isDefault** | `boolean` | optional (default: `false`) | Set as the default active tab | @@ -1205,7 +1205,7 @@ Tab configuration for multi-tab view interface | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **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 \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ 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 | @@ -1307,7 +1307,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | -| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | optional | Filter operator | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectListView.selection` @@ -1531,7 +1531,7 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | 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' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | | **order** | `integer` | optional | Tab display order | | **pinned** | `boolean` | optional (default: `false`) | Pin tab (cannot be removed by users) | | **isDefault** | `boolean` | optional (default: `false`) | Set as the default active tab | @@ -1808,7 +1808,7 @@ Tab configuration for multi-tab view interface | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **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 \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ 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 | @@ -1893,7 +1893,7 @@ Tab configuration for multi-tab view interface | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **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 \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ 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 | @@ -2133,7 +2133,7 @@ This schema accepts one of the following structures: | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **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 \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ 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 | @@ -2309,7 +2309,7 @@ This schema accepts one of the following structures: | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **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 \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ 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 | diff --git a/packages/cli/package.json b/packages/cli/package.json index 5d84eb3ea81..0d8330cba3a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -119,7 +119,7 @@ "ts-morph": "^28.0.0", "tsx": "^4.23.12", "yaml": "^2.9.0", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "peerDependencies": { "@objectstack/driver-turso": "workspace:^" diff --git a/packages/cli/test/format-zod-union.test.ts b/packages/cli/test/format-zod-union.test.ts index 42e2ef9127d..3446dbd120e 100644 --- a/packages/cli/test/format-zod-union.test.ts +++ b/packages/cli/test/format-zod-union.test.ts @@ -35,6 +35,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { z } from 'zod'; import { ObjectStackDefinitionSchema } from '@objectstack/spec'; +import { NormalizedFilterSchema } from '@objectstack/spec/data'; import { formatZodErrors } from '../src/utils/format'; import { childEnv } from './helpers/serve-process.js'; @@ -58,15 +59,29 @@ function render(error: z.ZodError): string { return stripAnsi(captured.join('\n')); } -/** The campaign's shape: a string form OR a strict object form. */ -const ACTION_REF = z.union([ - z.string(), - z.strictObject({ type: z.string(), params: z.record(z.string(), z.unknown()).optional() }), -]); +/** + * The campaign's shape: a string form OR a closed object form. + * + * [#19581] The closed arm is a REAL PRODUCT DOOR — `NormalizedFilterSchema`, + * whose `closedObject` seal makes its unknown-key refusal terminal — and not a + * `z.strictObject` declared here. From zod 4.5.0 an `unrecognized_keys` issue + * carries `continue: true`, so a bare `z.strictObject` arm whose only complaint + * is an unknown key is the union's lone NON-ABORTED member: zod's + * `handleUnionResults` short-circuits and returns that arm's issues unwrapped, + * no `invalid_union` is raised, and every assertion below passes over a shape it + * was never built to see. A fixture that cannot produce the code under test is + * not a weaker pin, it is no pin at all. + * + * ⛔ Do NOT re-declare this arm from raw zod primitives. What keeps these + * assertions honest is that the arm's refusal is TERMINAL, and terminal is a + * property the product's closed-object entry confers — not one a test file can + * assert about itself. + */ +const ACTION_REF = z.union([z.string(), NormalizedFilterSchema]); describe('[#5341] formatZodErrors expands invalid_union branches', () => { it('prints the failing branch prose under the union line', () => { - const out = render(ACTION_REF.safeParse({ type: 'log', args: { a: 1 } }).error!); + const out = render(ACTION_REF.safeParse({ args: { a: 1 } }).error!); // The union's own two lines are PRESERVED — they are what says "no branch // matched", and keeping them makes this change strictly additive: nothing // that printed before #5341 stopped printing. @@ -76,7 +91,7 @@ describe('[#5341] formatZodErrors expands invalid_union branches', () => { }); it('drops the kind-mismatch branch that carries no prescription', () => { - const out = render(ACTION_REF.safeParse({ type: 'log', args: 1 }).error!); + const out = render(ACTION_REF.safeParse({ args: 1 }).error!); // Paired deliberately: the `not` alone would also pass if the expansion // produced NOTHING — a green for the empty reason. The positive assertion // is what makes the negative one mean "selected against", not "absent". @@ -88,7 +103,7 @@ describe('[#5341] formatZodErrors expands invalid_union branches', () => { it('resolves branch paths against the union, not relative to it', () => { const schema = z.object({ actions: z.array(ACTION_REF) }); - const out = render(schema.safeParse({ actions: [{ type: 'log', args: { a: 1 } }] }).error!); + const out = render(schema.safeParse({ actions: [{ args: { a: 1 } }] }).error!); expect(out).toContain('✗ actions.0: Unrecognized key: "args"'); // Never the bare relative path a naive splice would print. expect(out).not.toContain('✗ (root): Unrecognized key'); @@ -96,7 +111,7 @@ describe('[#5341] formatZodErrors expands invalid_union branches', () => { it('expands a union nested inside a union', () => { const schema = z.object({ on: z.union([z.string(), z.object({ actions: z.array(ACTION_REF) })]) }); - const out = render(schema.safeParse({ on: { actions: [{ type: 'log', args: { a: 1 } }] } }).error!); + const out = render(schema.safeParse({ on: { actions: [{ args: { a: 1 } }] } }).error!); expect(out).toContain('✗ on.actions.0: Invalid input'); expect(out).toContain('✗ on.actions.0: Unrecognized key: "args"'); }); @@ -133,7 +148,7 @@ describe('[#5341] formatZodErrors expands invalid_union branches', () => { }); it('counts a union as one issue however many lines explain it', () => { - const out = render(ACTION_REF.safeParse({ type: 'log', args: { a: 1 } }).error!); + const out = render(ACTION_REF.safeParse({ args: { a: 1 } }).error!); expect(out).toContain('1 validation error(s) total'); }); }); diff --git a/packages/core/package.json b/packages/core/package.json index b901e014003..c5f35c78666 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -38,7 +38,7 @@ "dependencies": { "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "keywords": [ "objectstack", diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json index 571c9abb9c0..ef5bfff1a87 100644 --- a/packages/drivers/driver-turso/package.json +++ b/packages/drivers/driver-turso/package.json @@ -34,7 +34,7 @@ "@objectstack/driver-sql": "workspace:*", "@objectstack/spec": "workspace:*", "nanoid": "^6.0.1", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "peerDependencies": { "better-sqlite3": "^13.0.3" diff --git a/packages/lint/package.json b/packages/lint/package.json index 9f9fedda1fd..1cb16c08427 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -50,7 +50,7 @@ "devDependencies": { "@types/node": "^26.2.0", "vitest": "^4.1.11", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "keywords": [ "objectstack", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 85d4ee15de3..5daf0eeda42 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -31,7 +31,7 @@ "@objectstack/formula": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "devDependencies": { "@objectstack/lint": "workspace:*", diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index fd114e45379..6c88cb5ec0c 100644 --- a/packages/metadata-core/package.json +++ b/packages/metadata-core/package.json @@ -43,7 +43,7 @@ ], "dependencies": { "@objectstack/spec": "workspace:*", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "peerDependencies": { "vitest": "^4.0.0" diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index ecc9c7d23c3..240180e615b 100644 --- a/packages/metadata-protocol/package.json +++ b/packages/metadata-protocol/package.json @@ -45,7 +45,7 @@ "@objectstack/metadata-core": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "devDependencies": { "@types/node": "^26.2.0", diff --git a/packages/metadata/package.json b/packages/metadata/package.json index 6dac4f471b1..c3400c410b6 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -88,7 +88,7 @@ "chokidar": "^5.0.0", "glob": "^13.0.6", "js-yaml": "^5.2.3", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "devDependencies": { "@objectstack/driver-sqlite-wasm": "workspace:*", diff --git a/packages/objectql/package.json b/packages/objectql/package.json index 7b2710d80d5..1e11b2e67b1 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -35,7 +35,7 @@ "@objectstack/types": "workspace:*", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "devDependencies": { "tsx": "^4.23.12", diff --git a/packages/rest/package.json b/packages/rest/package.json index eacac0afc67..5885f560b75 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -36,7 +36,7 @@ "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", "exceljs": "^4.4.0", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "devDependencies": { "@objectstack/driver-sql": "workspace:*", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 806bd2c1de3..17779c9e707 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -46,7 +46,7 @@ "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", "quickjs-emscripten": "^0.32.0", - "zod": "^4.4.3", + "zod": "^4.6.1", "@objectstack/metadata-core": "workspace:*" }, "peerDependencies": { diff --git a/packages/spec/package.json b/packages/spec/package.json index 5e8e860225c..b1a0efc7a0d 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -321,7 +321,7 @@ }, "dependencies": { "pg-connection-string": "^2.14.0", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "peerDependencies": { "ai": "^7.0.0" diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index c8e69de2093..f44efc43bb8 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; +import { closedObject } from '../shared/strict-object'; import { assertListComparandShapes } from './filter-comparand-shape'; import { normalizeFilterComparandTypes } from './filter-comparand-type'; import { bareDateRangePresetComparandMessage, isDateRangePresetName } from './date-range-presets'; @@ -2060,14 +2061,22 @@ const normalizedMemberSchema = (position: string) => * package's own tests (swept with `FilterConditionSchema`'s 20+ call sites as * the positive control), so the narrowing has no measured internal caller. */ +/* + * [#19581] `closedObject` states for the GROUP branch what `abort: true` states + * for the field branch above: its refusal is terminal, so it cannot become the + * member union's lone continuable spokesman. Same lesson, same union, other + * arm — from zod 4.5.0 an `unrecognized_keys` issue is continuable, so without + * this a bad operand inside a legitimate group reports the group's raw + * `Unrecognized key: "c"` instead of the union's own `Not a valid $and member`. + */ export const NormalizedFilterSchema: z.ZodType = z.lazy(() => - z.object({ + closedObject(z.object({ $and: z.array(normalizedMemberSchema('$and member')).optional(), $or: z.array(normalizedMemberSchema('$or member')).optional(), $not: normalizedMemberSchema('$not operand').optional(), - }).strict() + }).strict()) ); // ============================================================================ diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 94991396c20..cda76438ea4 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -12,7 +12,7 @@ import { ObjectListViewSchema } from '../ui/view.zod'; import { EvaluatedExpressionInputSchema, TemplateExpressionInputSchema, type EvaluatedExpression, type EvaluatedExpressionInput } from '../shared/expression.zod'; import { lazySchema } from '../shared/lazy-schema'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; -import { strictObject } from '../shared/strict-object'; +import { closedObject, strictObject } from '../shared/strict-object'; import { ProtectionSchema } from '../shared/protection.zod'; import { retiredKey } from '../shared/retired-key'; import { refuseRecordProtoKey } from '../shared/record-proto-key-guard'; @@ -837,8 +837,15 @@ const lifecycleOnlyWhenSchema = z.record( z.string(), z.number(), z.boolean(), - z.object({ $in: z.array(z.union([z.string(), z.number()])).min(1) }).strict(), - z.object({ $null: z.boolean() }).strict(), + // [#19581] `closedObject` — not `strictObject` — because the curated + // message these two arms render is pinned as written + // (`union-author-message-pins.test.ts`, "class B: bare `.strict()`"), and + // routing them through the `strictObject` error map would reword it. All + // the wrapper does is make the unknown-key refusal terminal for the arm + // that raised it, which is what keeps this union reporting `invalid_union` + // instead of collapsing onto whichever arm zod judged closest. + closedObject(z.object({ $in: z.array(z.union([z.string(), z.number()])).min(1) }).strict()), + closedObject(z.object({ $null: z.boolean() }).strict()), ]), ); diff --git a/packages/spec/src/shared/error-map.test.ts b/packages/spec/src/shared/error-map.test.ts index 7d5bb57b6b6..2e9625a8537 100644 --- a/packages/spec/src/shared/error-map.test.ts +++ b/packages/spec/src/shared/error-map.test.ts @@ -7,6 +7,7 @@ import { safeParsePretty, } from './error-map.zod'; import { FieldType } from '../data/field.zod'; +import { NormalizedFilterSchema } from '../data/filter.zod'; describe('objectStackErrorMap', () => { describe('FieldType enum errors', () => { @@ -187,14 +188,26 @@ describe('formatZodError', () => { // `submitBehavior` reached for `discriminatedUnion` in the first place. Both // directions are pinned below. describe('[#4971] formatZodError expands invalid_union branches', () => { - // The campaign's shape: a string form OR a strict object form. - const ACTION_REF = z.union([ - z.string(), - z.strictObject({ type: z.string(), params: z.record(z.string(), z.unknown()).optional() }), - ]); + // The campaign's shape: a string form OR a closed object form. + // + // [#19581] The closed arm is a REAL PRODUCT DOOR — `NormalizedFilterSchema`, + // whose `closedObject` seal this PR's `strict-object.ts` half put there — and + // not a `z.strictObject` declared here. From zod 4.5.0 an `unrecognized_keys` + // issue carries `continue: true`, so a bare `z.strictObject` arm whose only + // complaint is an unknown key is the union's lone NON-ABORTED member: zod's + // `handleUnionResults` short-circuits and returns that arm's issues unwrapped, + // no `invalid_union` is raised, and every assertion below passes over a shape + // it was never built to see. A fixture that cannot produce the code under test + // is not a weaker pin, it is no pin at all. + // + // ⛔ Do NOT re-declare this arm from raw zod primitives. What keeps these + // assertions honest is that the arm's refusal is TERMINAL, and terminal is a + // property the product's closed-object entry confers — not one a test file can + // assert about itself. + const ACTION_REF = z.union([z.string(), NormalizedFilterSchema]); it('renders the failing branch prose under the union line', () => { - const result = ACTION_REF.safeParse({ type: 'log', args: { a: 1 } }); + const result = ACTION_REF.safeParse({ args: { a: 1 } }); expect(result.success).toBe(false); const formatted = formatZodError(result.error!); @@ -205,7 +218,7 @@ describe('[#4971] formatZodError expands invalid_union branches', () => { }); it('drops the kind-mismatch branch that carries no prescription', () => { - const formatted = formatZodError(ACTION_REF.safeParse({ type: 'log', args: 1 }).error!); + const formatted = formatZodError(ACTION_REF.safeParse({ args: 1 }).error!); // `expected string, received object` is the string branch complaining that // the author did not write a string. They never meant to. expect(formatted).not.toContain('expected string'); @@ -214,7 +227,7 @@ describe('[#4971] formatZodError expands invalid_union branches', () => { it('resolves nested paths against the union, not relative to it', () => { const schema = z.object({ actions: z.array(ACTION_REF) }); const formatted = formatZodError( - schema.safeParse({ actions: [{ type: 'log', args: { a: 1 } }] }).error!, + schema.safeParse({ actions: [{ args: { a: 1 } }] }).error!, ); expect(formatted).toContain('✗ actions.0: Invalid input'); expect(formatted).toContain('✗ actions.0: Unrecognized key: "args"'); @@ -225,7 +238,7 @@ describe('[#4971] formatZodError expands invalid_union branches', () => { it('expands a union nested inside a union', () => { const schema = z.object({ on: z.union([z.string(), z.object({ actions: z.array(ACTION_REF) })]) }); const formatted = formatZodError( - schema.safeParse({ on: { actions: [{ type: 'log', args: { a: 1 } }] } }).error!, + schema.safeParse({ on: { actions: [{ args: { a: 1 } }] } }).error!, ); expect(formatted).toContain(' ✗ on: Invalid input'); expect(formatted).toContain(' ✗ on.actions.0: Invalid input'); @@ -299,21 +312,25 @@ describe('[#4971] formatZodError expands invalid_union branches', () => { it('counts the union as the one issue zod raised', () => { // The header must keep agreeing with `error.issues` / the REST body, or // the CLI and the API would disagree about how many things are wrong. - const result = ACTION_REF.safeParse({ type: 'log', args: { a: 1 } }); + const result = ACTION_REF.safeParse({ args: { a: 1 } }); expect(result.error!.issues).toHaveLength(1); expect(formatZodError(result.error!)).toContain('(1 issue)'); }); it('stops expanding after three levels of nesting', () => { // Bounded output: unions nest arbitrarily, the terminal does not. - const leaf = z.strictObject({ ok: z.string() }); + // [#19581] Same real product door as `ACTION_REF`, and for the same reason: + // a `z.strictObject` leaf declared here is the innermost union's lone + // non-aborted arm, so no `invalid_union` is raised at ANY level and the + // depth this measures is the depth of an expansion that never happened. + const leaf = NormalizedFilterSchema; const l1 = z.union([z.string(), leaf]); const l2 = z.union([z.string(), z.object({ n: l1 })]); const l3 = z.union([z.string(), z.object({ n: l2 })]); const l4 = z.union([z.string(), z.object({ n: l3 })]); const formatted = formatZodError( - l4.safeParse({ n: { n: { n: { ok: 'x', bogus: 1 } } } }).error!, + l4.safeParse({ n: { n: { n: { bogus: 1 } } } }).error!, ); const depths = formatted .split('\n') diff --git a/packages/spec/src/shared/strict-object.ts b/packages/spec/src/shared/strict-object.ts index 24c9e672f39..96bc167d2e7 100644 --- a/packages/spec/src/shared/strict-object.ts +++ b/packages/spec/src/shared/strict-object.ts @@ -266,6 +266,29 @@ export function strictObjectDeclarations(): readonly StrictObjectDeclaration[] { return declarationStore(); } +/** + * [#19581] A {@link strictObjectError} map, plus the handle that BUILDS its + * unknown-key half without invoking it. + * + * The map is deliberately deferred (see the comment inside `strictObjectError`), + * and until zod 4.5 "deferred" and "built the moment a key is rejected" were the + * same instant: `safeParse` finalized every issue through the schema's error map + * before it returned. From 4.6 the failure result carries `error` as a lazy + * getter (`failure()` in `v4/core/parse.js`) whose stated purpose is to stop the + * result pinning the parsed value, so finalization — and with it this build — + * slides to whenever some consumer first reads `.error`. + * + * ⛔ That is not a message change: the prescription an author reads is + * byte-identical on both lines, measured. What moves is WHEN this module reaches + * across the `field → strict-object → suggestions → field` import cycle, from + * "inside the parse that refused the key" to "wherever the error object is first + * read". The cycle is the whole reason the build is deferred at all, so the + * moment it happens is a property worth keeping, not an implementation detail — + * which is why {@link markUnknownKeyRefusalTerminal} primes it on the refusal + * path and `strict-object.test.ts` pins that it has happened by then. + */ +type PrimableErrorMap = z.core.$ZodErrorMap & { prime: () => void }; + /** * Register an authoring surface and build its unknown-key error map, **without * closing the shape** — the half of {@link strictObject} that a schema whose @@ -310,16 +333,7 @@ export function strictObjectError( // than making each of them prove it is not in a loop. Same shape as the // deferred map `data/object.zod.ts` already carries for its TDZ problem. let build: z.core.$ZodErrorMap | undefined; - const error: z.core.$ZodErrorMap = (issue) => { - // A retired VALUE FORM is rejected before the unknown-key map is even - // consulted: `issue.code` here is `invalid_type` (the input is not an - // object), so `strictUnknownKeyError` would return undefined and zod's - // bare "expected object, received string" would be all the author sees. - if (retiredForms && issue.code === 'invalid_type') { - const prescription = - typeof issue.input === 'string' ? retiredForms[issue.input] : undefined; - if (prescription) return prescription; - } + const buildMap = (): z.core.$ZodErrorMap => // The table is recorded ONCE, below, with its `shape` — the strong record // the audit reads. Between #5483 and #5593 a second, transcription-shaped // registry existed for the 44 call sites that predated this helper, and @@ -327,7 +341,7 @@ export function strictObjectError( // `strictObject` surface would not be judged twice (the second time against // `knownKeys` rather than the shape). #5593 migrated the last of those call // sites and deleted the registry, so the suppression went with it. - return (build ??= strictUnknownKeyError({ + (build ??= strictUnknownKeyError({ surface, // Declared-but-unwritable keys (tombstones) are excluded — see // `acceptsNothing`. They stay in the SHAPE, so writing one still raises @@ -341,7 +355,26 @@ export function strictObjectError( aliases, guidance, guidanceSets, - }))(issue); + })); + + const error: z.core.$ZodErrorMap = (issue) => { + // A retired VALUE FORM is rejected before the unknown-key map is even + // consulted: `issue.code` here is `invalid_type` (the input is not an + // object), so `strictUnknownKeyError` would return undefined and zod's + // bare "expected object, received string" would be all the author sees. + if (retiredForms && issue.code === 'invalid_type') { + const prescription = + typeof issue.input === 'string' ? retiredForms[issue.input] : undefined; + if (prescription) return prescription; + } + return buildMap()(issue); + }; + + // [#19581] The handle that settles WHEN the reach across the import cycle + // happens — see `PrimableErrorMap`. Building is idempotent, so a caller may + // prime as often as it likes; the map is still constructed once. + (error as PrimableErrorMap).prime = () => { + buildMap(); }; declarationStore().push({ options, shape }); @@ -349,11 +382,159 @@ export function strictObjectError( return error; } +/** + * [#19581] An unknown key is a TERMINAL refusal of the surface that raised it — + * restored here because zod stopped treating it as one. + * + * ## What moved, measured on both lines + * + * From zod 4.5.0 the `unrecognized_keys` issue carries `continue: true` + * (`v4/core/schemas.js`, both the shape-phase and the `handleCatchall` push; + * absent on 4.4.3). The vendor states the intent in the line above it: the + * issue *"describes the shape of the input, not the validity of the parsed + * value, so it never aborts. The parse still fails; the schema's own checks + * just get to run first, and an enclosing intersection can reconcile the key + * against a sibling operand."* + * + * That is a reasonable general-purpose posture and it is not this repo's. + * Here a closed shape is a contract (Prime Directive #12): a key the surface + * does not declare is not shape information awaiting reconciliation, it is the + * refusal. Two things follow from the flag, and both were measured on this + * codebase with the same bodies and `packages/spec/node_modules/zod` repointed + * per line, the swap proven on disk before either reading was believed: + * + * 1. **The surface's own checks now run after it.** `runChecks` reads + * `util.aborted(payload)` once, on entry, so a continuable unknown-key issue + * lets every `.refine()` / `.superRefine()` on the same object fire and add + * a SECOND, contradictory complaint about a body that was already refused. + * 2. **Every union containing that surface loses its envelope.** + * `handleUnionResults` — byte-identical on 4.4.3, 4.5.0 and 4.6.1, so not + * itself the change — returns a single non-aborted member's issues + * UNWRAPPED instead of pushing `invalid_union`: + * + * ```js + * const nonaborted = results.filter((r) => !util.aborted(r)); + * if (nonaborted.length === 1) { final.value = nonaborted[0].value; return nonaborted[0]; } + * ``` + * + * A member whose only complaint is an unknown key is now that one member, so + * the union's message becomes THAT branch's prescription — chosen by zod's + * "which branch got furthest" heuristic rather than by the door's own branch + * rule. On `ViewMetadataSchema` the observable effect was a `viewKind` + + * `config` ViewItem body being answered with the CONTAINER branch's + * *"wrap it: `defineView({ list: … })`"*, and a retirement prescription that + * names the retired member never being reached at all. + * + * ## Why it is applied at `_zod.parse` and not as a check + * + * The flag has to be settled BEFORE `runChecks` computes `isAborted`, which + * rules out a `.check()` — a check runs after the parse and cannot retroactively + * gate its siblings. A check would also be worse in a second way: `util.extend` + * refuses to overwrite a key on any object carrying checks, so adding one here + * would break `.extend()` on every closed shape in the package. + * + * ⛔ **It cannot move the acceptance face.** It only rewrites a flag on an issue + * that has already been raised; a body with no issues never reaches it, and no + * issue is added, removed or re-coded. What changes is which competing + * complaint an author reads, and whether the union keeps its envelope. + * + * ⚠️ **A hoisted `function` declaration, for the reason `declarationStore()` + * above is one** — it runs from inside a constructor `strictObject` can reach + * while this module is still initializing, so a `const` arrow would be in its + * temporal dead zone exactly when it is first needed. + * + * It also primes the surface's unknown-key map, because from zod 4.6 a failed + * `safeParse` finalizes its issues only when `.error` is read — see + * {@link PrimableErrorMap}. Priming is confined to the refusal path: a clean + * parse raises no `unrecognized_keys` issue, so a shape nobody has written a + * bad key on still never reaches across the import cycle. + */ +function markUnknownKeyRefusalTerminal

}>( + inst: { _zod: { def: { error?: unknown } } }, + payload: P, +): P { + let refused = false; + for (const issue of payload.issues) { + if (issue.code === 'unrecognized_keys') { + (issue as { continue?: boolean }).continue = false; + refused = true; + } + } + if (refused) { + const prime = (inst._zod.def.error as Partial | undefined)?.prime; + if (typeof prime === 'function') prime(); + } + return payload; +} + +/** + * [#19581] The `ZodObject` variant {@link closedObject} builds, owned by a + * HOISTED function declaration. + * + * It is a constructor rather than an instance-level patch because + * `util.clone()` rebuilds through `inst._zod.constr`: `.strict()`, `.strip()`, + * `.extend()`, `.refine()` and `.omit()` all clone, so an instance-level wrap + * would survive exactly until the first derived schema. Declaring it here makes + * every descendant carry it by construction. + * + * `_zod.def` is untouched, so the emitted JSON Schema, the authorable surface + * and `instanceof z.ZodObject` (trait-based) are all byte-identical. + * + * ⚠️ **Deliberately not a module-level `const` — the same load-bearing reason + * {@link declarationStore} is not one, and it is not a hypothetical here.** + * `strictObject` runs at MODULE SCOPE for schemas inside the + * `field → strict-object → suggestions → field` cycle, and it now reaches this + * constructor on every call. Held in a `const`, that call lands in the + * constructor's temporal dead zone whenever the loader enters this module + * second: under `OS_EAGER_SCHEMAS=1` — how `build-schemas.ts` runs — importing + * the package root died with `ReferenceError: Cannot access 'ZodClosedObject' + * before initialization`, raised from `data/field-value.zod.ts`'s own + * module-scope `strictObject(…)` before a single schema was built. Built on + * first use behind a hoisted function, it is reachable from the first + * instruction of module evaluation instead. + * + * ⚠️ Nothing in an ordinary `vitest run` sees this: `lazySchema` defers every + * schema body behind a Proxy, so no schema is constructed at import time and + * the whole suite stays GREEN — the same instrument gap `declarationStore` + * documents. The eager import is the instrument that answers it, and + * `strict-object.test.ts` runs one in a subprocess. + */ +function closedObjectConstructor(): new (def: unknown) => unknown { + const self = closedObjectConstructor as unknown as { ctor?: new (def: unknown) => unknown }; + return (self.ctor ??= z.core.$constructor('ZodClosedObject', (inst: any, def: any) => { + (z.ZodObject as unknown as { init: (i: unknown, d: unknown) => void }).init(inst, def); + const parse = inst._zod.parse; + inst._zod.parse = (payload: any, ctx: any) => { + const done = parse(payload, ctx); + return done instanceof Promise + ? done.then((settled: any) => markUnknownKeyRefusalTerminal(inst, settled)) + : markUnknownKeyRefusalTerminal(inst, done); + }; + }) as unknown as new (def: unknown) => unknown); +} + +/** + * [#19581] Re-declare a closed object schema so its unknown-key refusal is + * terminal — see {@link markUnknownKeyRefusalTerminal}. + * + * Exported for the closed shapes that do NOT come through {@link strictObject} + * — a bare `z.object(…).strict()` whose curated message is pinned as written + * and must not be re-routed through the `strictObject` error map. + */ +export function closedObject(schema: S): S { + return new (closedObjectConstructor() as unknown as new (def: unknown) => S)( + (schema as unknown as { _zod: { def: unknown } })._zod.def, + ); +} + /** * A `.strict()` object whose unknown-key error names the surface, echoes the * offending key, and suggests the closest declared key — with the candidate * list read from `shape` rather than transcribed alongside it. + * + * [#19581] Built as a {@link closedObject}, so the refusal is terminal for this + * surface on every zod line. */ export function strictObject(options: StrictObjectOptions, shape: T) { - return z.object(shape, { error: strictObjectError(options, shape) }).strict(); + return closedObject(z.object(shape, { error: strictObjectError(options, shape) }).strict()); } diff --git a/packages/spec/src/shared/zod-error-formatter-proto-path.pin.test.ts b/packages/spec/src/shared/zod-error-formatter-proto-path.pin.test.ts new file mode 100644 index 00000000000..100bfd70d8a --- /dev/null +++ b/packages/spec/src/shared/zod-error-formatter-proto-path.pin.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { AssignmentConfigSchema } from '../automation/builtin-node-config.zod'; + +/** + * A VERSION FLOOR pin on zod's three error formatters — `treeifyError`, + * `formatError` (`error.format()`) and `flattenError` (`error.flatten()`). + * + * Unlike `record-proto-key-guard.test.ts` next door, which pins the BEHAVIOUR + * of our own guards and is deliberately version-agnostic, this file exists to + * fail on a zod below the fixed line. It is the regression pin for the bump + * away from 4.4.3, and it is the only thing in the tree that notices a + * downgrade. + * + * ## The defect it pins (measured on zod 4.4.3, both directions) + * + * All three formatters walked an issue's `path` by reading `curr[el]` and + * testing it for truthiness (`curr[el] = curr[el] || {...}`, or `??=`) before + * creating a node. A path element naming a member of `Object.prototype` is + * therefore answered BY THE PROTOTYPE, and no node is ever created. Two + * distinct failure modes follow, and they need separate assertions because + * they present in opposite ways: + * + * - TERMINAL element (`['assignments','__proto__']`, `['x','toString']`): + * the inherited member — a function — is adopted as the node, and the very + * next statement does `node._errors.push(...)` on it. It CRASHES: + * `TypeError: Cannot read properties of undefined (reading 'push')`. + * + * - NON-TERMINAL element (`['__proto__','anything']`): the walk continues + * INTO `Object.prototype` and writes the next segment onto it. The + * refusal message is silently lost from the returned tree, and the process + * gets a global prototype key it never asked for. Nothing throws, nothing + * logs; the caller renders an empty error list for a document that was + * refused. This is the mode nothing else in the tree would catch. + * + * 4.6.1 fixed it with a `node()` helper that guards the read with + * `Object.prototype.hasOwnProperty.call(obj, key)` and creates a `__proto__` + * node through `Object.defineProperty` (a bare assignment would hit the + * prototype SETTER instead of creating an own key). + * + * ## Why this repo is exposed, and not hypothetically + * + * The landed `__proto__` refusals (#17852 / #18847 / #19151) exist precisely + * because zod's open-key branches silently DROP a `__proto__` key. Those + * guards turned a silent drop into a loud refusal — and the loud refusal's + * issue path is `['assignments','__proto__']`, which is exactly the terminal + * shape the formatters crashed on. A consumer that formatted our own refusal + * crashed on it. The first test below drives that real schema rather than a + * modelled one. + * + * ## Reading these assertions + * + * Every lookup of a prototype-member key goes through {@link ownValue}, never + * `node.__proto__` or `node[key]`: on a bare read `node.__proto__` is answered + * by the prototype accessor and `node.toString` by the inherited function, so + * a plain read cannot tell "the formatter created this node" from "JavaScript + * answered from the prototype" — which is the very confusion the defect is + * made of. `getOwnPropertyDescriptor` asks the only question that + * discriminates: is there an OWN key here? + */ + +/** The refusal message the process must never be able to invent on its own. */ +const MESSAGE = 'pin: the refusal text that must survive formatting'; + +/** + * `Object.prototype`'s own keys before any formatter in this file has run. + * + * Captured as a SNAPSHOT rather than as a guessed list of key names, because + * the defect's pollution is not limited to the path element: walking into + * `Object.prototype` also makes the walker install its own container key + * (`properties` for `treeifyError`) there. A fixed roster missed that one, and + * the leak then made a LATER test fail for a reason having nothing to do with + * its own subject — a polluted `Object.prototype.properties` is inherited, so + * the next `curr.properties ??= {}` finds it non-nullish, creates no own + * container, and quietly shares one object across every tree the process + * builds afterwards. + */ +const PRISTINE_PROTOTYPE_KEYS = new Set(Object.getOwnPropertyNames(Object.prototype)); + +afterEach(() => { + // Restore, so one test's pollution cannot cascade into the next test or the + // next file in this worker. + for (const key of prototypePollution()) { + delete (Object.prototype as Record)[key]; + } +}); + +/** + * Read `key` as an OWN property of `node`, or `undefined` if it is not one. + * + * @param node - the formatter output node to read. + * @param key - the path element, including one naming an `Object.prototype` + * member. + * @returns the own value, never an inherited one. + */ +function ownValue(node: unknown, key: string): unknown { + if (node === null || typeof node !== 'object') return undefined; + return Object.getOwnPropertyDescriptor(node, key)?.value; +} + +/** Own keys `Object.prototype` has gained since this file was loaded. */ +function prototypePollution(): string[] { + return Object.getOwnPropertyNames(Object.prototype).filter( + (key) => !PRISTINE_PROTOTYPE_KEYS.has(key), + ); +} + +/** A `ZodError` carrying exactly one issue at `path`, for path shapes no landed schema emits yet. */ +function errorAtPath(path: (string | number)[]): z.ZodError { + return new z.ZodError([{ code: 'custom', path, message: MESSAGE, input: undefined }]); +} + +describe("zod renders a refusal whose path names an Object.prototype member (the bump's regression pin)", () => { + it("formats the repo's OWN landed `__proto__` refusal instead of crashing on it", () => { + // `JSON.parse` is what makes `__proto__` an own enumerable key — an object + // literal's `{ __proto__: ... }` sets the actual prototype instead, and the + // guard would never see a key at all. + const config = JSON.parse('{"assignments":{"__proto__":"x"}}') as unknown; + + const result = AssignmentConfigSchema.safeParse(config); + expect(result.success).toBe(false); + if (result.success) return; + + // The shape this pin exists for: a TERMINAL `__proto__`, emitted by a + // guard that is live in this package today. + const [issue] = result.error.issues; + expect(issue.path).toEqual(['assignments', '__proto__']); + const refusal = issue.message; + expect(refusal).toContain('__proto__'); + + // Each of the three formatters must RETURN, and must carry the refusal at + // the path the issue named. On 4.4.3 every one of these three throws + // `TypeError: Cannot read properties of undefined (reading 'push')`. + const tree = z.treeifyError(result.error); + const assignments = ownValue(ownValue(tree, 'properties'), 'assignments'); + expect(ownValue(ownValue(assignments, 'properties'), '__proto__')).toEqual({ + errors: [refusal], + }); + + const formatted = result.error.format(); + expect(ownValue(ownValue(formatted, 'assignments'), '__proto__')).toEqual({ + _errors: [refusal], + }); + + const flattened = result.error.flatten(); + expect(ownValue(flattened.fieldErrors, 'assignments')).toEqual([refusal]); + + expect(prototypePollution()).toEqual([]); + }); + + it('keeps the message and leaves `Object.prototype` alone on a NON-TERMINAL `__proto__` — the silent mode', () => { + // The mode with no crash to notice: on 4.4.3 the walk steps INTO + // `Object.prototype`, writes `polluted` onto it, and returns a tree with + // the refusal missing. Both halves are asserted, because a fix that + // stopped the pollution while still dropping the message would be no fix. + const error = errorAtPath(['__proto__', 'polluted']); + + const tree = z.treeifyError(error); + const treeNode = ownValue(ownValue(tree, 'properties'), '__proto__'); + expect(ownValue(ownValue(treeNode, 'properties'), 'polluted')).toEqual({ + errors: [MESSAGE], + }); + + const formatted = z.formatError(error); + const formattedNode = ownValue(formatted, '__proto__'); + expect(ownValue(formattedNode, 'polluted')).toEqual({ _errors: [MESSAGE] }); + + expect(prototypePollution()).toEqual([]); + }); + + it.each(['toString', 'constructor', 'valueOf', 'hasOwnProperty'])( + 'formats a terminal path element named `%s` as an own key', + (member) => { + const error = errorAtPath(['config', member]); + + const tree = z.treeifyError(error); + const treeParent = ownValue(ownValue(tree, 'properties'), 'config'); + expect(ownValue(ownValue(treeParent, 'properties'), member)).toEqual({ + errors: [MESSAGE], + }); + + const formatted = z.formatError(error); + expect(ownValue(ownValue(formatted, 'config'), member)).toEqual({ + _errors: [MESSAGE], + }); + + // `flattenError` reads only the FIRST path element, so it is pinned on a + // single-element path rather than through `config` above. + const flattened = z.flattenError(errorAtPath([member])); + expect(ownValue(flattened.fieldErrors, member)).toEqual([MESSAGE]); + + expect(prototypePollution()).toEqual([]); + }, + ); + + it('positive control — an ORDINARY path renders the same way, so the assertions above are not vacuous', () => { + // Every assertion in this file is `ownValue(...) === {errors:[MESSAGE]}`. + // If that spelling were simply wrong, the tests would fail for a reason + // having nothing to do with zod's version. This leg proves the spelling + // reads a real formatter node on a path the defect never touched. + const error = errorAtPath(['assignments', 'ordinary']); + + const tree = z.treeifyError(error); + const parent = ownValue(ownValue(tree, 'properties'), 'assignments'); + expect(ownValue(ownValue(parent, 'properties'), 'ordinary')).toEqual({ + errors: [MESSAGE], + }); + + expect(z.formatError(error)).toMatchObject({ + assignments: { ordinary: { _errors: [MESSAGE] } }, + }); + expect(z.flattenError(errorAtPath(['ordinary'])).fieldErrors).toEqual({ + ordinary: [MESSAGE], + }); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 00e9dc3f155..e77aa7fbdc3 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -51,7 +51,7 @@ import { z } from 'zod'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; -import { strictObject, strictObjectError } from '../shared/strict-object'; +import { closedObject, strictObject, strictObjectError } from '../shared/strict-object'; import { SnakeCaseIdentifierSchema, QUALIFIED_ITEM_NAME_PATTERN } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; import { normalizeVisibleWhen, VISIBILITY_STRICT_OPTIONS } from '../shared/visibility'; @@ -3408,7 +3408,7 @@ const FormFieldBaseSchema = lazySchema(() => { visibleOn: EvaluatedExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'), disclosure: z.enum(['inline', 'popover']).optional().describe('Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure).'), }; - return z.object(shape, { + const base = z.object(shape, { error: strictObjectError({ ...VISIBILITY_STRICT_OPTIONS, // #8202 — this shape names ITSELF. The shared table's `'this view/page @@ -3444,6 +3444,22 @@ const FormFieldBaseSchema = lazySchema(() => { aliases: { disabled: 'readonly' }, }, shape), }); + // [#19581] Sealed HERE, on the open base, rather than on the `.strict()` + // extension below. Three instruments read these two declarations and all + // three keep reading them unchanged this way: the literal `z.object(shape, + // { error: strictObjectError(…) })` above is still the expression the + // strictness ledger's AST reader counts as this file's one `authorable` + // strip site (see this schema's docblock), `FormFieldSchema`'s expression + // below is still the composition `declaration-map` unwinds to reach + // `FormFieldBaseSchema`, and `closedObject` reaches the extension anyway — + // `util.clone()` rebuilds through `_zod.constr`, so `.extend()` and + // `.strict()` both carry the seal forward. Sealing an OPEN shape is a no-op + // by itself (it has no unknown key to refuse); the extension is where it + // bites, and without it that member is the only non-aborted arm of the + // `z.union([z.string(), FormFieldSchema])` a section's `fields` uses, so zod + // returns its issues unwrapped instead of the `invalid_union` this door's + // diagnosis is built on. + return closedObject(base); }); /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06d41005bad..6cd22a12977 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,13 +85,13 @@ importers: dependencies: fumadocs-core: specifier: 16.14.4 - version: 16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + version: 16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.1) fumadocs-mdx: specifier: 15.2.3 - version: 15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.0.3)(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.1))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.0.3)(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) fumadocs-ui: specifier: 16.14.4 - version: 16.14.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + version: 16.14.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.1))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -151,8 +151,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 zod: - specifier: 4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 examples/app-crm: dependencies: @@ -616,8 +616,8 @@ importers: specifier: ^2.9.0 version: 2.9.0 zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@objectstack/driver-turso': specifier: workspace:* @@ -873,8 +873,8 @@ importers: specifier: workspace:* version: link:../types zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@objectstack/metadata-core': specifier: workspace:* @@ -1093,8 +1093,8 @@ importers: specifier: ^6.0.1 version: 6.0.1 zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@types/node': specifier: ^26.2.0 @@ -1159,14 +1159,14 @@ importers: specifier: ^4.1.11 version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 packages/mcp: dependencies: '@modelcontextprotocol/sdk': specifier: ^1.30.0 - version: 1.30.0(zod@4.4.3) + version: 1.30.0(zod@4.6.1) '@objectstack/core': specifier: workspace:* version: link:../core @@ -1180,8 +1180,8 @@ importers: specifier: workspace:* version: link:../types zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@objectstack/lint': specifier: workspace:* @@ -1226,8 +1226,8 @@ importers: specifier: ^5.2.3 version: 5.2.3 zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@objectstack/driver-sqlite-wasm': specifier: workspace:* @@ -1251,8 +1251,8 @@ importers: specifier: workspace:* version: link:../spec zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@types/node': specifier: ^26.2.0 @@ -1316,8 +1316,8 @@ importers: specifier: workspace:* version: link:../types zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@types/node': specifier: ^26.2.0 @@ -1371,8 +1371,8 @@ importers: specifier: ^3.0.1 version: 3.0.1(ajv@8.20.0) zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: tsx: specifier: ^4.23.12 @@ -2229,8 +2229,8 @@ importers: specifier: ^4.4.0 version: 4.4.0 zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@objectstack/driver-sql': specifier: workspace:* @@ -2332,8 +2332,8 @@ importers: specifier: ^0.32.0 version: 0.32.0 zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@objectstack/platform-objects': specifier: workspace:* @@ -2863,8 +2863,8 @@ importers: specifier: ^2.14.0 version: 2.14.0 zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@types/node': specifier: ^26.2.0 @@ -2874,7 +2874,7 @@ importers: version: 4.1.11(vitest@4.1.11) ai: specifier: ^7.0.66 - version: 7.0.66(zod@4.4.3) + version: 7.0.66(zod@4.6.1) tsx: specifier: ^4.23.12 version: 4.23.12 @@ -9378,9 +9378,6 @@ packages: peerDependencies: zod: ^3.25.28 || ^4 - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - zod@4.6.1: resolution: {integrity: sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==} @@ -9389,21 +9386,21 @@ packages: snapshots: - '@ai-sdk/gateway@4.0.52(zod@4.4.3)': + '@ai-sdk/gateway@4.0.52(zod@4.6.1)': dependencies: '@ai-sdk/provider': 4.0.7 - '@ai-sdk/provider-utils': 5.0.27(zod@4.4.3) + '@ai-sdk/provider-utils': 5.0.27(zod@4.6.1) '@vercel/oidc': 3.2.0 - zod: 4.4.3 + zod: 4.6.1 - '@ai-sdk/provider-utils@5.0.27(zod@4.4.3)': + '@ai-sdk/provider-utils@5.0.27(zod@4.6.1)': dependencies: '@ai-sdk/provider': 4.0.7 '@standard-schema/spec': 1.1.0 '@workflow/serde': 4.1.0 eventsource-parser: 3.1.1 undici: 7.29.0 - zod: 4.4.3 + zod: 4.6.1 '@ai-sdk/provider@4.0.7': dependencies: @@ -10598,28 +10595,6 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 - '@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)': - dependencies: - '@hono/node-server': 2.0.12(hono@4.13.7) - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.1.0 - express: 5.2.1 - express-rate-limit: 8.6.1(express@5.2.1) - hono: 4.13.7 - jose: 6.2.7 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 4.4.3 - zod-to-json-schema: 3.25.2(zod@4.4.3) - transitivePeerDependencies: - - supports-color - '@modelcontextprotocol/sdk@1.30.0(zod@4.6.1)': dependencies: '@hono/node-server': 2.0.12(hono@4.13.7) @@ -11902,12 +11877,12 @@ snapshots: agent-base@7.1.4: {} - ai@7.0.66(zod@4.4.3): + ai@7.0.66(zod@4.6.1): dependencies: - '@ai-sdk/gateway': 4.0.52(zod@4.4.3) + '@ai-sdk/gateway': 4.0.52(zod@4.6.1) '@ai-sdk/provider': 4.0.7 - '@ai-sdk/provider-utils': 5.0.27(zod@4.4.3) - zod: 4.4.3 + '@ai-sdk/provider-utils': 5.0.27(zod@4.6.1) + zod: 4.6.1 ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: @@ -13100,7 +13075,7 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.7.1 - fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3): + fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.1): dependencies: '@fumari/image-size': 0.1.0 estree-util-value-to-estree: 3.5.0 @@ -13132,18 +13107,18 @@ snapshots: next: 16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - zod: 4.4.3 + zod: 4.6.1 transitivePeerDependencies: - supports-color - fumadocs-mdx@15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.0.3)(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): + fumadocs-mdx@15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.1))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.0.3)(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.2 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + fumadocs-core: 16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.1) github-slugger: 2.0.0 magic-string: 1.2.0 mdast-util-mdx: 3.0.0 @@ -13157,7 +13132,7 @@ snapshots: vfile: 6.0.3 yaml: 2.9.0 yuku-analyzer: 0.8.7 - zod: 4.4.3 + zod: 4.6.1 optionalDependencies: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 @@ -13169,7 +13144,7 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-ui@16.14.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): + fumadocs-ui@16.14.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.1))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3) @@ -13185,7 +13160,7 @@ snapshots: '@radix-ui/react-tabs': 1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: 0.7.1 cnfast: 0.1.0 - fumadocs-core: 16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + fumadocs-core: 16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.3(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.1) lucide-react: 1.31.0(react@19.2.8) motion: 13.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -16125,16 +16100,10 @@ snapshots: compress-commons: 4.1.2 readable-stream: 3.6.2 - zod-to-json-schema@3.25.2(zod@4.4.3): - dependencies: - zod: 4.4.3 - zod-to-json-schema@3.25.2(zod@4.6.1): dependencies: zod: 4.6.1 - zod@4.4.3: {} - zod@4.6.1: {} zwitch@2.0.4: {}