fix(schema): match field types, options and QA fixtures to the real wire formats - #756
Open
maoberlehner wants to merge 7 commits into
Open
fix(schema): match field types, options and QA fixtures to the real wire formats#756maoberlehner wants to merge 7 commits into
maoberlehner wants to merge 7 commits into
Conversation
Contributor
|
maoberlehner
commented
Aug 14, 2026
maoberlehner
commented
Aug 14, 2026
maoberlehner
commented
Aug 14, 2026
maoberlehner
commented
Aug 14, 2026
`schema init` passes every wire key through verbatim, so any option a space holds but the spec does not declare produces a field the generated types cannot describe. Two were missing. `richtext` takes `link_scope`, `max_length` and `rtl`, all written by its schema form. `options` takes `exclude_empty_option`, which was declared on `option` but not on its multi-select sibling. Checked against a block holding one field of every type, created by hand in the editor and read back: `image` produced the crop options and `add_https`, while `asset` and `multiasset` stayed bare. So neither the crop options nor `add_https` are declared here; they arrive with the `image` and `file` types. `storyfront` agrees: `FieldTypeAsset/index.vue` computes a protocol from `add_https`, but the only consumer of it, `processFilename`, runs behind a `deprecated` prop that only `FieldTypeImage` and `FieldTypeFile` pass. On an `asset` field the key is inert.
`image` and `file` predate the asset object: they store a bare,
protocol-relative URL string. The editor no longer offers them when adding a
field, but existing spaces still hold them, and neither was modelled. So
`schema init` against such a space emitted `defineField('x', {type: 'image'})`,
which failed on the discriminant, and `FieldValue` could not resolve the value.
Both are now `Field` variants. The crop options (`image_crop`, `image_width`,
`image_height`, `keep_image_size`) and `add_https` live on `image`, `add_https`
on `file`, and both map to `string` in `FieldTypeValueMap`, which
`validate-story` now checks.
`image_width` and `image_height` are `integer | string`: clearing the input
writes an empty string rather than removing the key, observed on a field
authored in the editor that came back as `{image_height: 42, image_width: ""}`.
`optionsFieldRefMapper` keyed its lookup on `internal_users`, `internal_tags` and `internal_datasources`. None of those are real option sources, so those branches were unreachable, and the QA fixture that "covered" them had been written to match the table rather than the API. The real sources are `self` (implicit), `internal_stories`, `internal`, `external` and `internal_languages`. Of those only `internal_stories` holds a cross-space reference: a story uuid or id. `internal`/`external` hold a datasource entry's `value` and `self` an inline option's own `value`, all of which mean the same thing in every space, so remapping them would corrupt the content. The singular `option` field type had no mapper at all, so a story-sourced single-select was never remapped. It has one now. `RefMaps` loses `users`, `tags` and `datasources`: nothing could ever read them.
The Management API stores component schemas and story content as opaque
blobs, so a green seed only proves the API accepted the JSON. Every fixture
below was checked against a block or story created by hand in the Storyblok
editor and read back, or against the storyfront/storyrails source.
What was wrong:
- Asset sidecars were named `<file>.png.meta.json`, but the CLI resolves
`<basename>.json` and `loadSidecarAssetData` swallows ENOENT. Every seeded
asset in every scenario has always uploaded with no metadata, silently.
`toAssetUpload` also reads `alt`/`title`/`copyright`/`source` from the top
level, not from `meta_data`.
- Story `uuid`s were `"1"`...`"10"` and a multilink's `id` was the numeric
story id. The wire stores real uuids in both, and `mapRefs` keys on the uuid,
so the one scenario built to exercise remapping could not have caught a
broken remap.
- `internal_datasources` was declared as an option source. It does not exist:
the datasource source is `internal` with a `datasource_slug`. The fixture was
shaped to match a bug in `map-refs.ts`, and QA validated it.
- Folder stories carried `{component: "page"}`. A folder holds
`content_types`/`lock_subfolders_content_types` plus `default_root`.
- Asset values were 4-key stubs with `id: 0`; the wire shape is 11 keys with a
`meta_data` mirror and `id: null` when empty.
- `blog.json` paired `source: "internal_stories"` with `datasource_slug`, which
are mutually exclusive. It seeds into every scenario.
- `hero_banner.json` used the UI-only `source: "self"` sentinel, and inline
option rows had no `_uid`.
- Bloks restrictions carried a whitelist with no `restrict_type` and no paired
empty list. Datasources had no entry ids or `dimension_value`. Nested content
had no `_uid` anywhere.
New coverage:
- `has-restrictions` — the first fixtures in the repo containing a
`component_denylist`, a `restrict_type`, or any group or tag list. All eight
editor-authored `bloks` shapes and four `richtext` ones, with two nested
component groups and a component tag.
- `has-diverse-components` gains `kitchen_sink`, covering the field types
nothing exercised: markdown, multiasset, table, section, tab, custom, the
legacy image and file, and an `is_reference_type` options field.
- `has-rich-content` gains a `linktype: "story"` link mark and an embedded
`type: "blok"` node, both of which `mapRefs` walks and neither of which had
a fixture.
Verified by parsing every schema field in the corpus against the generated
field-type schemas: 105 fields and 32 values, no type errors and no key the
spec does not declare.
The Management API stores component schemas and story content as opaque blobs, so pushing a fixture and reading it back is circular: you authored the input, and the check could not have failed. Both guidance docs now say so and name the two things that can ground a shape. `AGENTS.md` gets the caveat alongside the sibling-repos block, and points at storyfront as the field-shape authority next to its existing visual-editor role. Sibling-repo source comes first, because an agent cannot author editor data itself: that one is a request to the user, and so a last resort. The QA skill gets the same order as an explicit recipe: check the existing seeds first, then storyrails, then storyfront, and only ask the user to author it in the UI when the source does not settle it.
Two things surfaced by seeding all ten scenarios against a real space. `kitchen_sink` declared a `custom` field naming an uninstalled field-type plugin. The Management API rejects the whole component with `422 The following field-type plugin(s) are not available in this space`, so the component never landed and `has-diverse-components` seeded nine of its ten components. A `custom` field cannot be seeded into an arbitrary QA space, so the field is gone and `custom` stays covered by the schema type tests. `count_staged` counted every `*.json` in the components directory, but the CLI classifies staged items by shape, so `groups.json` and `tags.json` become component groups and internal tags rather than components. The seed therefore expected 11 components from `has-restrictions`, found the 9 it actually defines, and reported `Verification FAILED` for a push that fully succeeded. `has-restrictions` is the first scenario to ship either file.
maoberlehner
force-pushed
the
fix/wire-format-ground-truth
branch
from
August 14, 2026 09:40
7283e8d to
41afd7d
Compare
@storyblok/angular
@storyblok/astro
@storyblok/api-client
storyblok
@storyblok/experiments
@storyblok/js
storyblok-js-client
@storyblok/lint-config
@storyblok/live-preview
@storyblok/management-api-client
@storyblok/migrations
@storyblok/nuxt
@storyblok/react
@storyblok/region-helper
@storyblok/richtext
@storyblok/schema
@storyblok/svelte
@storyblok/vue
commit: |
`conditional_settings` was declared as an untyped array of objects, so every
key inside a rule was `unknown`: a typo in a condition produced no error and
`schema init` emitted a shape nothing could describe.
The shape comes from storyfront, which both writes and evaluates it.
`FieldConditions/index.vue` writes the setting, `FieldConditionItem.vue`
writes each condition (`validated_object` is always
`{type: 'field', field_key, field_attr: 'value'}`), and
`utils/validateConditionalField.ts` reads it back — six validations, two rule
matches, and two modifications, of which only the first is applied.
Nothing is required, because the editor persists a setting the moment a rule
row is added: `modifications: [{}]` with `validated_object: null` and
`validation: null`. Requiring any of it would reject data a real space holds.
`kitchen_sink` gains the first fixtures in the corpus with a condition on
them, covering both modifications and both rule matches.
maoberlehner
force-pushed
the
fix/wire-format-ground-truth
branch
from
August 14, 2026 10:01
41afd7d to
6bc1087
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Groundwork extracted from the review of #752 and #753, split out so both of those show only their own change. They are now stacked on top of this branch.
Everything here was checked against one of two sources. Never against a round trip: the Management API stores component schemas and story content as opaque blobs, accepts almost any JSON and echoes it back, so pushing a fixture and reading it back proves storage, not shape.
storyfrontfor what the editor writes and reads,storyrailsfor what the backend normalizes and enforces.What's here
fix(schema): declare the field options the editor writesschema initpasses every wire key through verbatim, so an option a space holds but the spec does not declare produces a field the generated types cannot describe. Two were missing:richtextlink_scope/max_length/rtl, andoptionsexclude_empty_option(declared onoptionbut not its multi-select sibling).feat(schema): support the image and file field typesimageandfilepredate the asset object and store a bare protocol-relative URL string. The editor no longer offers them when adding a field, but spaces still hold them and neither was modelled, soschema initemitteddefineField('x', {type: 'image'}), which failed on the discriminant.add_httpsand the crop options (image_crop,image_width,image_height,keep_image_size) belong toimage/filealone. Anassetand amultiassetcreated in the editor stay bare, andstoryfrontagrees:FieldTypeAssetcomputes a protocol fromadd_https, but its only consumer,processFilename, runs behind adeprecatedprop that onlyFieldTypeImageandFieldTypeFilepass. On anassetfield the key is inert.image_width/image_heightareinteger | string: clearing the input writes an empty string rather than removing the key.fix(migrations): remap only the option sources that hold a referenceoptionsFieldRefMapperkeyed oninternal_users,internal_tagsandinternal_datasources. None are real option sources — the editor offersinternal_stories,internal,external,internal_languagesand inline options — so those branches were unreachable. Of the real ones onlyinternal_storiesholds a cross-space reference; the others hold values that mean the same thing in every space, so remapping them would corrupt content. The singularoptiontype had no mapper at all.test(repo): match seed fixtures to the wire formats the editor writesThe corpus held shapes the editor never writes, and every one passed QA:
<file>.png.meta.jsonwhile the CLI resolves<basename>.jsonand swallows ENOENT. Every seeded asset in every scenario had always uploaded with no metadata, silently.uuids were"1"…"10"and a multilink'sidwas the numeric story id.mapRefskeys on the uuid, so the one scenario built to exercise remapping could not have caught a broken remap.source: "internal_datasources"— the fixture was shaped to match themap-refs.tsbug above, so QA confirmed the bug.{component: "page"}instead ofcontent_types/default_root.blog.jsonpairedsource: "internal_stories"withdatasource_slug, which are mutually exclusive, and it seeds into every scenario.New coverage:
has-restrictionsholds the first fixtures in the repo containing acomponent_denylist, arestrict_type, or any group or tag list — while both stacked PRs are about exactly those.has-diverse-componentsgainskitchen_sinkfor the field types nothing exercised.has-rich-contentgains alinktype: "story"link mark and an embeddedtype: "blok"node, both of whichmapRefswalks.docs(repo): record that the Management API never validates a shapeAGENTS.mdand the QA skill now state the opaque-blob caveat and give an ordered recipe for grounding a shape: existing seeds, then storyrails, then storyfront, and only ask the operator to author it in the UI when the source does not settle it — an agent cannot produce that evidence itself.feat(schema): type the conditional settings a field can carryconditional_settingswas an untyped array of objects, so every key inside a rule wasunknown: a typo in a condition produced no error. The shape comes fromstoryfront, which both writes it (FieldConditions/index.vue,FieldConditionItem.vue) and evaluates it (utils/validateConditionalField.ts) — six validations, two rule matches, two modifications. Nothing is required, because the editor persists a setting the moment a rule row is added, before any of it is filled in.kitchen_sinkgains the first conditions in the corpus.