Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .changeset/docs-fieldschema-extend-rot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
"@objectstack/spec": patch
---

fix(docs): `FieldSchema.extend()` does not exist — the FAQ was recommending a call that throws

#3882 brought `content/docs` under the example type-check gate and left an open
question: of the blocks still unmarked, is any of it **genuine rot** rather than a
fragment? Swept them. One real one:

`content/docs/deployment/troubleshooting.mdx` answered *"How do I extend a
built-in schema?"* with

```ts
const CustomFieldSchema = FieldSchema.extend({ … }); // TypeError
```

`FieldSchema` carries a `.transform()` that lowers author-facing sugar at parse
time, which makes it a **`ZodPipe`, not a `ZodObject`** — `.extend` is `undefined`
on it (verified against the built package). Anyone following the FAQ got a
`not a function` throw. The example also used `z` without importing it.

Rewritten to **compose** — parse with `FieldSchema`, validate your additions
alongside it — verified to both type-check and run. `.in.extend()` is mentioned in
prose as the merged-schema route, with the caveat that it skips the transform.

**A divergence worth knowing about, found while fixing this.** The first fix used
`FieldSchema.in.extend(…)` in the checked block. It passed locally and failed in
CI with `Property 'in' does not exist on type 'ZodObject<…>'` — the two builds
emit **different declarations for the same source**: locally
`z.ZodPipe<z.ZodObject<…>>`, in CI a plain `z.ZodObject`. The runtime is
unambiguous (`bound ZodPipe`, `.extend === undefined`, verified after a clean
`rm -rf dist && pnpm build`), so **CI's declaration contradicts the value it
describes** — `.extend()` type-checks there and throws at runtime. Probably
inference instability in the DTS bundling of these very large zod types; worth its
own investigation. The example now uses only `.parse()`, so it is correct under
either declaration and the doc is not hostage to which one you get.

**The sweep's method is recorded in the gate's docstring**, because two traps make
a naive pass report a confident "nothing found":

1. **`tsc` stops after syntactic diagnostics** — it never reaches the semantic
pass. Marking all 780 blocks at once let ~200 broken fragments suppress
type-checking for every other block; the run came back with only TS1xxx codes,
which reads exactly like "no rot" and proves nothing. Verified by injecting a
deliberate type error and watching it go unreported.
2. **Unimported type names resolve against the DOM lib.** `Plugin`, `Event`,
`Response`, `Storage` all exist there, so a block missing its import reports
*"'version' does not exist in type 'Plugin'"* against `lib.dom`'s `Plugin` —
an artefact, not drift. Three of the strongest-looking candidates were this.

After both corrections the remaining blocks are fragments, plus
`protocol/kernel/config-resolution.mdx`, whose aspirational snippets are already
labelled as design intent by a callout on the page.
27 changes: 23 additions & 4 deletions content/docs/deployment/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -359,16 +359,35 @@ Yes. All schemas work with plain JavaScript. You lose compile-time type checking

### How do I extend a built-in schema?

Use Zod's `.extend()` or `.merge()`:
**Not with `.extend()` on the schema itself.** Several built-in schemas —
`FieldSchema`, `ObjectSchema`, `ActionSchema` — carry a `.transform()` that lowers
author-facing sugar at parse time, which makes the exported value a **`ZodPipe`,
not a `ZodObject`**. `FieldSchema.extend` is `undefined`, so calling it throws
`is not a function`.

Compose instead: parse with the built-in schema, and validate your additions
alongside it. This keeps the transform running, which `.extend()` would discard.

{/* os:check */}
```typescript
import { z } from 'zod';
import { FieldSchema } from '@objectstack/spec/data';

const CustomFieldSchema = FieldSchema.extend({
customProperty: z.string().optional()
});
const CustomProps = z.object({ customProperty: z.string().optional() });

function parseCustomField(input: unknown) {
return { ...FieldSchema.parse(input), ...CustomProps.parse(input) };
}

parseCustomField({ name: 'code', type: 'text', customProperty: 'x' });
```

If you genuinely need one merged schema object, `FieldSchema.in` is the
`ZodObject` the pipe wraps, so `FieldSchema.in.extend({ … })` builds one — but
the result **skips the transform**, so author-facing sugar the pipe would have
lowered stays raw. Prefer the composition above unless you specifically want the
untransformed shape.

### Where are the JSON Schemas for IDE autocomplete?

The bundled JSON Schema is at `packages/spec/json-schema/objectstack.json` (its `x-schema-count` field reports the total number of definitions). Per-domain schemas are in subfolders under `packages/spec/json-schema/` (e.g. `data/`, `ui/`, `api/`).
20 changes: 20 additions & 0 deletions packages/spec/scripts/check-skill-examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,26 @@
* still sees the block. A fence-meta tag like ` ```ts check ` would have punched
* a hole in that gate.
*
* ── Sweeping the UNMARKED blocks: two traps ─────────────────────────────────
* Periodically it is worth asking whether any unmarked block is genuine ROT
* rather than a fragment. Two things make a naive sweep report a confident
* "nothing found", and both bit a real attempt:
*
* 1. `tsc` reports syntactic diagnostics and then STOPS — it never runs the
* semantic pass for the program. Mark every block at once and the ~200
* syntactically-broken fragments suppress type-checking for ALL the rest,
* so a run producing only TS1xxx codes proves nothing. Exclude the
* syntax failures and re-run before concluding anything about rot.
* 2. A block that omits its imports resolves bare type names against the DOM
* lib: `Plugin`, `Event`, `Response`, `Storage`, `Selection` all exist
* there. `const p: Plugin = { version, init }` then reports "version does
* not exist in type Plugin" against lib.dom's Plugin — an artefact, not
* drift. Check what the name actually resolved to before filing it.
*
* The 2026-07 sweep that applied both corrections found exactly one real rot in
* `content/docs` (a FAQ recommending `FieldSchema.extend()`, impossible since
* FieldSchema became a ZodPipe); everything else was fragments.
*
* Each marked block is written verbatim to a throwaway build dir and type-checked
* with `tsc --noEmit` against the built `@objectstack/spec` declarations — the
* exact surface a consumer's `import { … } from '@objectstack/spec'` resolves to.
Expand Down
Loading