Repository navigation
Commit e9eb224
fix(metadata): TypeScriptSerializer annotates each item with its own metadata type's spec type, never ServiceObject for a non-object (#19865)
Fixes #19852
Clause-②: no
## What changed
`TypeScriptSerializer.serialize()` annotated every `typescript`-format
item `ServiceObject`, so a saved view (or any other non-object item) was
a `.ts` file that `tsc` refused with TS2353. Files that
`FilesystemLoader.save()` writes with the built-in serializer the
package wires in are now annotated with the spec type of the item's own
metadata type, or not annotated at all. They never carry `any`,
`unknown` or another type's shape.
- **How the metadata type reaches the annotation (A1, per the seat's
ruling on this PR).** It goes through a package-internal channel, and
the public surface does not change.
- `FilesystemLoader.save()` calls
`serializeTypeScriptForMetadataType(item, type, options)` only when the
serializer's `serialize` is this package's own, un-overridden
`TypeScriptSerializer.prototype.serialize` and its format is
`typescript`.
- Every other serializer is called through its own `serialize()` exactly
as before: a custom one, a subclass that overrides `serialize()`, or a
`TypeScriptSerializer` class copy from the package's other entry bundle.
- The predicate tests the method, not the class. The earlier
`instanceof` also matched an overriding subclass and bypassed its
override, which the contract review measured.
- The function is module-level in
`serializers/typescript-serializer.ts`, and no `exports` entry
re-exports it (`.`, `./node`, `./migrations`, `./errors`,
`./view-container`). A test pins that.
- `SerializeOptions` and `serializer-interface.ts` are byte-identical to
the base.
- The public `TypeScriptSerializer.serialize(item, options)` keeps its
exact signature and writes **no** annotation. It cannot know the item's
metadata type, and the `ServiceObject` it used to write was false for
every non-object. This output change is the correction, not a new
surface.
- **Which type (A2).** No existing table maps a metadata type to a type
name:
- `getMetadataTypeSchema()` maps a metadata type to a Zod schema
**value**.
- The `define*` helpers are value factories, so emitting one would turn
the annotation into a runtime parse.
- The CLI's `generate` templates are per-type code templates, not a
lookup.
So the table is private: `ANNOTATION_BY_METADATA_TYPE`, 28 rows. A row
exists only when the spec exports a type **identical** to the `z.input`
type of the schema `getMetadataTypeSchema()` binds for that metadata
type. That rule is now pinned in the in-test `tsc`:
- One strict type-identity assertion per row, with a `Book` /
`BookSchema` control line that must be the one error. `Book` is
assignable both ways to `z.input` of `BookSchema`, so mutual
assignability alone would not refuse it; identity is stronger.
- A run-time pin that each row's schema export on the annotation's
subpath **is** the object `getMetadataTypeSchema()` returns.
- **Deliberately unannotated.**
- `view`: `ViewMetadataSchema` is a `z.preprocess`, so `ViewMetadata` is
`unknown`. It type-checks anything, including the card's own repro body,
which `ViewMetadataSchema.safeParse` rejects.
- `book`: the hand-written `Book` lacks the `_packageId` / `_provenance`
keys `BookSchema` accepts. A stamped book fails `tsc` against it
(TS2353) even though it parses.
- `external_catalog`, a plugin's own type, and a plural spelling also
get no annotation.
- **The `object` file written by the built-in serializer the package
wires in is byte-identical to the base (A4).** An exact-string test pins
it.
- **`deserialize` is unchanged.** A legacy file (a view annotated
`ServiceObject`) still reads back, pinned.
- **The annotation is a static claim, not a runtime verdict.** `tsc` now
checks each annotated file against its own type instead of
`ServiceObject`. It is stricter than a strip-mode schema at run time:
`DeclarativeConnectorEntrySchema` drops an undeclared key that `tsc`
refuses as TS2353. Among the 28 representative bodies with one
undeclared key added, only `connector` parses at run time (the key is
dropped).
## Premise: no declaration reachable from an `exports` entry changes
I built `@objectstack/metadata` on the base and on the head, with the
deps from `turbo run build --filter='@objectstack/metadata^...'` and
then `pnpm --filter @objectstack/metadata build`. I copied every emitted
`*.d.ts` / `*.d.cts` under `dist/` (the same 10-file set on both sides:
`index`, `node`, `errors`, `view-container` and `migrations/index`, ESM
and CJS) and ran `diff -r`:
- merge base `2bbb462335` vs head `ee7faa27ca`: 8/10 files
byte-identical, and 10/10 identical once comments are stripped. The only
difference is one added JSDoc block, 4 lines, on
`TypeScriptSerializer.serialize` in `index.d.ts` and `index.d.cts`.
- These are the same hunks as at `8b51f96280`. The predicate change is
implementation only.
- A control holds: an injected `metadataType?: string;` in
`SerializeOptions` is detected as a difference.
## Routing, measured on the built bundles
At `ee7faa27ca`, with `FilesystemLoader` from `dist/node.js` saving an
`object`, the first line of each file is:
- built-in `TypeScriptSerializer` from `./node`: `import type {
ServiceObject } …`
- a `TypeScriptSerializer` from `.` (a distinct class copy; `splitting:
false`): `export const metadata = {` (no annotation, never a false one)
- a subclass overriding `serialize()` to prepend `// header`: `//
header`
- a plain-object serializer: its own output
- `NodeMetadataManager`: `object` is annotated `ServiceObject`, and
`view` is unannotated
## A3: annotation and `tsc` exit per metadata type
- **Setup:** `tsc --noEmit --strict` (typescript 6.0.3), with
`@objectstack/spec/*` mapped to the built declarations. Head
`8b51f96280` includes `main`'s zod 4.6.1.
- **Bodies:** each body went through the loader's path,
`serializeTypeScriptForMetadataType(item, type)`, which `ee7faa27ca`
leaves unchanged.
- "showcase" means every `examples/app-showcase` item of that type, both
raw and after `getMetadataTypeSchema(type).safeParse`.
- "minimal" means one spec-valid body plus its parsed form.
- **Card repro:** the `view` row includes it (`all_accounts`).
| metadata type | annotation | files | tsc exit |
|:--|:--|:--|:--|
| `action` | Action (@objectstack/spec/ui) | 140 (showcase) | 0 |
| `agent` | Agent (@objectstack/spec/ai) | 2 (minimal) | 0 |
| `analytics_cube` | Cube (@objectstack/spec/data) | 2 (showcase) | 0 |
| `api` | ApiEndpoint (@objectstack/spec/api) | 4 (showcase) | 0 |
| `app` | App (@objectstack/spec/ui) | 2 (showcase) | 0 |
| `book` | none | 2 (showcase) | 0 |
| `capability` | CapabilityDeclarationInput (@objectstack/spec/security)
| 4 (showcase) | 0 |
| `connector` | DeclarativeConnectorEntry
(@objectstack/spec/integration) | 8 (showcase) | 0 |
| `dashboard` | Dashboard (@objectstack/spec/ui) | 6 (showcase) | 0 |
| `dataset` | Dataset (@objectstack/spec/ui) | 8 (showcase) | 0 |
| `datasource` | Datasource (@objectstack/spec/data) | 2 (showcase) | 0
|
| `doc` | Doc (@objectstack/spec/system) | 2 (minimal) | 0 |
| `email_template` | EmailTemplateDefinition (@objectstack/spec/system)
| 2 (showcase) | 0 |
| `field` | Field (@objectstack/spec/data) | 2 (minimal) | 0 |
| `flow` | Flow (@objectstack/spec/automation) | 60 (showcase) | 0 |
| `hook` | Hook (@objectstack/spec/data) | 8 (showcase) | 0 |
| `job` | Job (@objectstack/spec/system) | 2 (showcase) | 0 |
| `mapping` | Mapping (@objectstack/spec/data) | 2 (showcase) | 0 |
| `object` | ServiceObject (@objectstack/spec/data) | 48 (showcase) | 0
|
| `page` | Page (@objectstack/spec/ui) | 56 (showcase) | 0 |
| `permission` | PermissionSet (@objectstack/spec/security) | 18
(showcase) | 0 |
| `position` | Position (@objectstack/spec/identity) | 20 (showcase) | 0
|
| `report` | Report (@objectstack/spec/ui) | 8 (showcase) | 0 |
| `seed` | Seed (@objectstack/spec/data) | 38 (showcase) | 0 |
| `sharing_rule` | SharingRule (@objectstack/spec/security) | 4
(showcase) | 0 |
| `skill` | Skill (@objectstack/spec/ai) | 2 (minimal) | 0 |
| `tool` | Tool (@objectstack/spec/ai) | 2 (minimal) | 0 |
| `translation` | TranslationItem (@objectstack/spec/system) | 2
(minimal) | 0 |
| `view` | none | 13 (showcase) | 0 |
| `webhook` | Webhook (@objectstack/spec/automation) | 2 (showcase) | 0
|
- **Before the fix, at base `2548ba57de`:** the card's repro through
`NodeMetadataManager.save('view', …)` makes `tsc` exit 2 with TS2353.
`object/account.ts` is clean.
- **Negative control, every annotation (in-test):** the same valid body
plus one undeclared key fails with exactly TS2353 for all 28 annotated
types.
## Tests
- `serializers.test.ts` covers these cases:
- The public `serialize()` writes no annotation, an object included.
- The loader path writes a `view` with no `ServiceObject` and no `import
type`.
- The `object` output is byte-identical to the base.
- No annotation for `external_catalog` / `book` / plugin / plural types.
- The `javascript` format never annotates.
- The legacy file still reads back.
- Round trips.
- `NodeMetadataManager.save()` end to end.
- A `FilesystemLoader` wired by hand with each of these:
- a plain-object custom serializer, called with `{ prettify, indent,
sortKeys }`
- **a subclass overriding `serialize()`**, called: its `// header`
survives
- the built-in serializer, and a subclass that keeps the inherited
`serialize()`: both annotated
- **a `TypeScriptSerializer` from a second module copy**, obtained with
`vi.resetModules()`, with the control that it is a distinct class:
called through its own `serialize()`, so no annotation
- `typescript-serializer-annotation.test.ts` covers these cases:
- Each of the 28 representative bodies is spec-valid.
- The annotated set is exactly those 28.
- The public `serialize()` annotates none of them.
- No `exports` entry re-exports the internal function, with a spelling
control.
- **Run-time binding pin:** each row's schema export on the annotation's
subpath `===` `getMetadataTypeSchema(type)`.
- Round trips.
- **In-test `tsc`:** per-row strict type identity (spec type vs
`z.input` of the bound schema), with exactly one error allowed, TS2322
on the `Book` control line. Every valid body type-checks clean, and
every undeclared-key body fails with exactly `[2353]`.
- **Package suite, at `ee7faa27ca`:** `pnpm --filter
@objectstack/metadata exec vitest run --maxWorkers=2` gives **54 files /
814 tests passed**, and `pnpm --filter @objectstack/metadata typecheck`
exits 0.
- **Ablation, routing predicate, at `ee7faa27ca`.** Through
`scripts/ablation-replace.mjs`, `serializer.serialize ===
TypeScriptSerializer.prototype.serialize &&` was changed back to
`serializer instanceof TypeScriptSerializer &&` (anchor x1 -> x0, blob
`b2313f272f2e` -> `9c909b67b590`).
- Result: **1 failed / 29 passed**. The failure is
`FilesystemLoader.save() calls a subclass that overrides serialize(), as
it always did`, received `import type { ServiceObject } …` instead of
`// header …`.
- Restored: blob == HEAD `b2313f272f2e`, and `git diff HEAD` is empty.
- **Earlier ablation, annotation choice, at `3ac5f81003`.**
`serializeTypeScriptForMetadataType` was forced to an unconditional
`['ServiceObject', 'data']`: **8 failed / 18 passed**, including the
loader-wired view test and the in-test `tsc`. Restored to HEAD.
## Gates
- **Derived at `8b51f96280`:** `node scripts/pm/dispatch-gates.mjs
--commands` gave 59 commands, all exit 0, and `--ran` reported 59 run, 0
NOT-MEASURED. `ee7faa27ca` touches the same 6 paths.
- **Re-run at `ee7faa27ca`:**
- metadata tests, exit 0
- metadata typecheck, exit 0
- `node scripts/check-issue-citations.mjs`, exit 0
- `node scripts/check-changeset-no-major.mjs --base origin/main`, exit 0
- `pnpm check:nul-bytes`, exit 0
## Acceptance notes
- **`main` merged** at `2bbb462335` as a fast-forward merge commit, with
no rebase and no force. It brings zod 4.6.1. The type-identity and
`unknown` checks and the A3 table were re-read after it.
- **`Clause-②: no`** stands, per the seat's ruling. No exported
declaration changes (see Premise).
- **Findings handed to the seat, not fixed here:**
- `ViewMetadata` is `unknown` (`z.preprocess`).
- The `typescript` format ignores the declared `sortKeys` save option.
- `DeclarativeConnectorEntrySchema` strips an undeclared key.
Written by session `session_01TEhopqrWQYBycZzyJHpAZr`, the dev for PM
seat `domain:engine#1`, round 21, patch round 2.
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 04a160d commit e9eb224
6 files changed
Lines changed: 532 additions & 22 deletions
File tree
- .changeset
- packages/metadata
- src
- loaders
- serializers
Lines changed: 15 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
74 | 74 | | |
75 | 75 | | |
76 | 76 | | |
77 | | - | |
| 77 | + | |
78 | 78 | | |
79 | 79 | | |
80 | 80 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
22 | 22 | | |
23 | 23 | | |
24 | 24 | | |
| 25 | + | |
25 | 26 | | |
26 | 27 | | |
27 | 28 | | |
| |||
459 | 460 | | |
460 | 461 | | |
461 | 462 | | |
462 | | - | |
463 | | - | |
464 | | - | |
465 | | - | |
466 | | - | |
467 | | - | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
468 | 482 | | |
469 | 483 | | |
470 | 484 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | | - | |
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
2 | 6 | | |
3 | 7 | | |
4 | | - | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
5 | 12 | | |
6 | 13 | | |
7 | 14 | | |
| |||
55 | 62 | | |
56 | 63 | | |
57 | 64 | | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
58 | 77 | | |
59 | 78 | | |
60 | 79 | | |
61 | | - | |
62 | 80 | | |
63 | 81 | | |
64 | 82 | | |
65 | 83 | | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
66 | 202 | | |
67 | 203 | | |
68 | 204 | | |
| |||
0 commit comments