Skip to content

Commit 95fb417

Browse files
os-warrenclaude
andauthored
fix(deps): bump zod to the fixed line (4.6.1+) workspace-wide, with a regression pin (#19658)
Fixes #19581 Clause-②: no <sub>Rewritten short by the `domain:spec#5` seat (2026-09-23T11:24Z), which took this PR over from seat 2. The earlier long body is in the PR's edit history.</sub> Moves the `zod` floor to `^4.6.1` in the 13 `package.json` files that declare it (ruling `5770530634`, letter B), and repairs the union diagnosis the bump would otherwise break (ruling `5774631464`, letter A). ## Why On zod 4.4.3, `z.treeifyError()`, `error.format()` and `error.flatten()` read a path element such as `__proto__` or `toString` off `Object.prototype`: they throw, or drop the message and pollute the prototype. zod 4.5.0 guards that read. The declared floor moves, not only the lockfile, so a downstream install of these packages cannot resolve 4.4.3. ## What changed - **Manifests and lockfile.** 12 carets move `^4.4.3` → `^4.6.1`; `apps/docs` moves from exact `4.4.3` to `^4.6.1`. The lockfile holds one zod package, `zod@4.6.1`. No override was needed. - **The union diagnosis.** zod 4.5.0 marks an unknown-key issue `continue: true`. A union member whose only complaint is an unknown key then counts as not aborted, the union returns that member's issues unwrapped, and no `invalid_union` is raised. A retired value such as `type: 'page'` was then answered with "Wrap it: `defineView({ list: … })`" instead of the message that names it. `closedObject` (`packages/spec/src/shared/strict-object.ts`) makes that issue terminal again; `view.zod.ts`, `filter.zod.ts` and `object.zod.ts` use it for closed shapes not built through `strictObject`. `strict-object.ts` also declares its constructor before first use and primes its map, so `OS_EAGER_SCHEMAS=1` imports load. - **Tests.** New regression pin `packages/spec/src/shared/zod-error-formatter-proto-path.pin.test.ts`: 7 pass on zod 4.6.1; on 4.4.3, 6 fail and the positive control passes. `error-map.test.ts` and `cli/test/format-zod-union.test.ts` built their fixtures from raw zod primitives, which raise no `invalid_union` on 4.5+; their fixtures now go through `NormalizedFilterSchema`, and their assertions are unchanged. - **Generated docs.** `ViewFilterRule.operator` now renders as required, matching what the schema already refuses. The tracked JSON Schemas do not move. - **Changeset.** `patch` on the 11 published packages that declare zod in `dependencies`. `@objectstack/lint` (devDependency only) and `apps/docs` (private) take no release. ## Review At-tier contract review `5793905962`: **PASS** at `dbd78c2d8a`. Its measurements back the claims above. ## Not in this PR - `closedObject()` rebuilds from `_zod.def`, so a `.describe()` / `.meta()` applied before wrapping is dropped (record ③). None of the four sites here does that. #19731 (the bare `.strict()` sweep) must carry the registry entry across. - `packages/spec/dropped-refinements.baseline.json` still records `"measured": { "zod": "4.4.3" }`. Nothing gates it, and its counts did not move. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 48c91e9 commit 95fb417

26 files changed

Lines changed: 652 additions & 149 deletions
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
"@objectstack/cli": patch
3+
"@objectstack/core": patch
4+
"@objectstack/driver-turso": patch
5+
"@objectstack/mcp": patch
6+
"@objectstack/metadata": patch
7+
"@objectstack/metadata-core": patch
8+
"@objectstack/metadata-protocol": patch
9+
"@objectstack/objectql": patch
10+
"@objectstack/rest": patch
11+
"@objectstack/runtime": patch
12+
"@objectstack/spec": patch
13+
---
14+
15+
**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).
16+
17+
Clause-②: no
18+
19+
**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:
20+
21+
| path shape | what happened on `^4.4.3` |
22+
|:---|:---|
23+
| 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')` |
24+
| 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 |
25+
26+
**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.
27+
28+
**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.
29+
30+
`@objectstack/lint` also moves, but only in `devDependencies`, so nothing it publishes changes for a consumer and it takes no release here.
31+
32+
## The second half the floor move needs: an unknown key refuses TERMINALLY again
33+
34+
From zod 4.5.0 an `unrecognized_keys` issue carries `continue: true`, so it no
35+
longer aborts the shape that raised it. Two things follow, and both were
36+
measured on this package with the same bodies on 4.4.3 and 4.6.1:
37+
38+
1. **A closed shape's own refinements now run after the refusal**, adding a
39+
second complaint that contradicts the first.
40+
2. **A union containing that shape loses its envelope.** zod's
41+
`handleUnionResults` returns a single non-aborted member's issues
42+
*unwrapped* instead of raising `invalid_union`, so the union's message
43+
becomes whichever branch zod judged closest.
44+
45+
At `PUT /api/v1/meta/view` that turned a retired-value refusal into the wrong
46+
branch's prescription. Writing `type: 'page'` on a ViewItem answered:
47+
48+
```
49+
Unrecognized key(s) on this view container: `viewKind`, `config`.
50+
• `viewKind` belongs to a single VIEW, not to the container. Wrap it: …
51+
```
52+
53+
— naming neither `page` nor its removal. It now answers, as it did before:
54+
55+
```
56+
config.type: 'page' was removed from the list-view `type` enum in
57+
@objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — …
58+
```
59+
60+
**What an upgrading consumer must do.** Nothing. No key or value changed
61+
status: everything this package accepted before it accepts now, and everything
62+
it refused it still refuses. What changed is which of several competing
63+
complaints an author reads, and that a refusal behind a union is again
64+
reported as `invalid_union` with its branches, which is what `z.treeifyError()`
65+
and this package's own `formatZodError` expand.
66+
67+
⚠️ A closed shape declared with a bare `z.object(…).strict()` or
68+
`z.strictObject(…)` — zod's own, not this package's `strictObject` — does NOT
69+
get this and will still collapse its union. Build closed authoring shapes with
70+
`strictObject`, or re-declare an existing one through `closedObject`.

‎apps/docs/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"postcss": "^8.5.26",
3838
"tailwindcss": "^4.3.3",
3939
"typescript": "^6.0.3",
40-
"zod": "4.4.3"
40+
"zod": "^4.6.1"
4141
},
4242
"engines": {
4343
"node": ">=22.0.0"

‎content/docs/references/api/protocol.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1665,7 +1665,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
16651665
| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | |
16661666
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
16671667
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; 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). |
1668-
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
1668+
| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
16691669
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
16701670
| **searchableFields** | `string[]` | optional | Fields enabled for search |
16711671
| **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
17501750
| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | |
17511751
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
17521752
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; 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). |
1753-
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
1753+
| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
17541754
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
17551755
| **searchableFields** | `string[]` | optional | Fields enabled for search |
17561756
| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ const result = ApiMethod.parse(data);
364364
| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | |
365365
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
366366
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; 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). |
367-
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
367+
| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
368368
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
369369
| **searchableFields** | `string[]` | optional | Fields enabled for search |
370370
| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |

‎content/docs/references/ui/page.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ View filter rule
216216
| **source** | `string` | optional | Source object name for the page |
217217
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance). |
218218
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Default sort order for the page, defined directly on the page. |
219-
| **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). |
219+
| **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). |
220220
| **levels** | `integer` | optional | Number of hierarchy levels to display |
221221
| **sourceView** | `string` | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. |
222222
| **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
274274
| :--- | :--- | :--- | :--- |
275275
| **object** | `string` | ✅ | Object to query |
276276
| **view** | `string` | optional | Named view to apply |
277-
| **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` |
277+
| **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` |
278278
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order |
279279
| **limit** | `integer` | optional | Max records to display |
280280

0 commit comments

Comments
 (0)