|
| 1 | +--- |
| 2 | +"@objectstack/spec": minor |
| 3 | +"@objectstack/platform-objects": patch |
| 4 | +"@objectstack/metadata-protocol": patch |
| 5 | +"@objectstack/driver-mongodb": patch |
| 6 | +--- |
| 7 | + |
| 8 | +fix(spec): five exported symbols resolved to `any` — type the recursive schemas and gate it in CI (#4171) |
| 9 | + |
| 10 | +A recursive Zod schema needs an explicit annotation to break its circular |
| 11 | +inference, and five of them took the cheapest one available: |
| 12 | + |
| 13 | +```ts |
| 14 | +export const NavigationItemSchema: z.ZodType<any> = z.lazy(() => …); |
| 15 | +export type NavigationItem = z.infer<typeof NavigationItemSchema>; // → any |
| 16 | +``` |
| 17 | + |
| 18 | +It compiles, it validates correctly at runtime, and it silently throws the type |
| 19 | +away. `NavigationItem`, `FormField`, `JoinNode` and `NormalizedFilter` were all |
| 20 | +`any` on the published surface, plus `FieldNodeSchema` — which had no exported |
| 21 | +type alias yet, so `z.infer<typeof FieldNodeSchema>` was `any` and |
| 22 | +`QueryAST['fields']` with it. |
| 23 | + |
| 24 | +That is worse than a missing export. #4115 tells every consumer that a local |
| 25 | +declaration under a spec export's name must be replaced by a binding to the |
| 26 | +spec — and for these, obeying it **replaced a precise type with `any`**. |
| 27 | +objectui's `NavigationItem` is a 118-line documented interface (`recordId` |
| 28 | +template variables, `requiresObject` / `requiresService` capability gates, |
| 29 | +`filters` precedence); every key of it exists in the spec's version, so by every |
| 30 | +available signal it read as a redundant fork safe to delete. Deleting it swapped |
| 31 | +a fully-typed interface for `any`, with no compile error anywhere to say so. |
| 32 | + |
| 33 | +It is hard to catch by inspection because `any` is mutually assignable with |
| 34 | +everything, so the natural "are these the same type?" check answers *yes* in both |
| 35 | +directions and recommends precisely the wrong action. Same failure family as |
| 36 | +#4075's `[key: string]: any` on `ActionDef`: a type that agrees with everything |
| 37 | +reads as agreement. |
| 38 | + |
| 39 | +**Now annotated with the real type**, using the pattern `QueryAST` already |
| 40 | +follows in `data/query.zod.ts` — infer the non-recursive part, tie the recursive |
| 41 | +knot in the type, so the keys stay derived from the schema instead of being |
| 42 | +hand-maintained beside it: |
| 43 | + |
| 44 | +```ts |
| 45 | +const BaseXSchema = z.object({ …every non-recursive key }); |
| 46 | +export type X = z.infer<typeof BaseXSchema> & { children?: X[] }; |
| 47 | +export const XSchema: z.ZodType<X> = z.lazy(() => BaseXSchema.extend({ |
| 48 | + children: z.array(XSchema).optional(), |
| 49 | +})); |
| 50 | +``` |
| 51 | + |
| 52 | +`z.infer` now resolves to the type it should always have been: `NavigationItem` |
| 53 | +is the nine-branch discriminated union, `FormField` the 30-key form-field |
| 54 | +contract (with `visibleOn` absent by construction — ADR-0089 D2 folds it into |
| 55 | +`visibleWhen` at the boundary), `JoinNode` and the newly exported `FieldNode` |
| 56 | +the query AST nodes, `NormalizedFilter` the normalized filter AST. Runtime |
| 57 | +validation is unchanged: every schema parses exactly what it parsed before. |
| 58 | + |
| 59 | +**What the types immediately caught**, none of it visible while they were `any`: |
| 60 | + |
| 61 | +- `account.app.ts` set `defaultOpen` on three nav groups — a key the spec has |
| 62 | + never declared. It worked only because objectui's `NavigationRenderer` still |
| 63 | + falls back to that legacy alias. Fixed at the producer per Prime Directive |
| 64 | + #12: the canonical key is `expanded`. |
| 65 | +- The MongoDB driver built its projection with `projection[field] = 1` over |
| 66 | + `query.fields`, so a relationship `FieldNode` would have keyed the projection |
| 67 | + on `"[object Object]"`. It now reads the node's field name. |
| 68 | +- `setup.app.ts`, `studio.app.ts` and `setup-nav.contributions.ts` are annotated |
| 69 | + with the PARSED `App` / `NavigationContribution` types but omitted |
| 70 | + `.default()`ed keys (`expanded`, `target`), as did the form fields |
| 71 | + `metadata-protocol` synthesizes for `getUiView` (`span`). Each now states the |
| 72 | + default it was relying on, matching what the surrounding literals already do |
| 73 | + for `active` / `isDefault` / `collapsible` / `collapsed` / `columns`. |
| 74 | + |
| 75 | +**Gated, not just fixed** (`check:exported-any`, wired into the required |
| 76 | +`TypeScript Type Check` job). `api-surface.json` records that an export *exists* |
| 77 | +and never what it *resolves to*, which is how these survived a whole major with |
| 78 | +every gate green. The new scan reads the built `.d.ts` a consumer's import |
| 79 | +actually resolves to and fails on any exported type that resolves to `any` — or |
| 80 | +any exported schema whose output is `any`, the root cause, and the only reason |
| 81 | +`FieldNodeSchema` was visible at all. Its `KNOWN_ANY` ledger is shrink-only and |
| 82 | +currently empty. It self-tests against the real zod first, so if the internals it |
| 83 | +reads are ever renamed the gate fails loudly instead of quietly passing |
| 84 | +everything forever. |
0 commit comments