Skip to content

fix(schema): match field types, options and QA fixtures to the real wire formats - #756

Open
maoberlehner wants to merge 7 commits into
mainfrom
fix/wire-format-ground-truth
Open

fix(schema): match field types, options and QA fixtures to the real wire formats#756
maoberlehner wants to merge 7 commits into
mainfrom
fix/wire-format-ground-truth

Conversation

@maoberlehner

@maoberlehner maoberlehner commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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.

  1. Sibling-repo sourcestoryfront for what the editor writes and reads, storyrails for what the backend normalizes and enforces.
  2. Operator-authored data — a block with one field of every type, a block per restriction shape, and stories covering every value shape, created by hand in a QA space and read back via MAPI.

What's here

fix(schema): declare the field options the editor writes
schema init passes 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: richtext link_scope/max_length/rtl, and options exclude_empty_option (declared on option but not its multi-select sibling).

feat(schema): support the image and file field types
image and file predate 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, so schema init emitted defineField('x', {type: 'image'}), which failed on the discriminant.

add_https and the crop options (image_crop, image_width, image_height, keep_image_size) belong to image/file alone. An asset and a multiasset created in the editor stay bare, and storyfront agrees: FieldTypeAsset computes a protocol from add_https, but its only consumer, processFilename, runs behind a deprecated prop that only FieldTypeImage and FieldTypeFile pass. On an asset field the key is inert. image_width/image_height are integer | string: clearing the input writes an empty string rather than removing the key.

fix(migrations): remap only the option sources that hold a reference
optionsFieldRefMapper keyed on internal_users, internal_tags and internal_datasources. None are real option sources — the editor offers internal_stories, internal, external, internal_languages and inline options — so those branches were unreachable. Of the real ones only internal_stories holds a cross-space reference; the others hold values that mean the same thing in every space, so remapping them would corrupt content. The singular option type had no mapper at all.

test(repo): match seed fixtures to the wire formats the editor writes
The corpus held shapes the editor never writes, and every one passed QA:

  • Asset sidecars were named <file>.png.meta.json while the CLI resolves <basename>.json and swallows ENOENT. Every seeded asset in every scenario had always uploaded with no metadata, silently.
  • Story uuids were "1""10" and a multilink's id was the numeric story id. mapRefs keys on the uuid, so the one scenario built to exercise remapping could not have caught a broken remap.
  • A component declared source: "internal_datasources" — the fixture was shaped to match the map-refs.ts bug above, so QA confirmed the bug.
  • Folder stories carried {component: "page"} instead of content_types/default_root.
  • blog.json paired source: "internal_stories" with datasource_slug, which are mutually exclusive, and it seeds into every scenario.

New coverage: has-restrictions holds the first fixtures in the repo containing a component_denylist, a restrict_type, or any group or tag list — while both stacked PRs are about exactly those. has-diverse-components gains kitchen_sink for the field types nothing exercised. has-rich-content gains a linktype: "story" link mark and an embedded type: "blok" node, both of which mapRefs walks.

docs(repo): record that the Management API never validates a shape
AGENTS.md and 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 carry
conditional_settings was an untyped array of objects, so every key inside a rule was unknown: a typo in a condition produced no error. The shape comes from storyfront, 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_sink gains the first conditions in the corpus.

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Cross-package changes detected

This PR is titled as a fix(schema) commit but modifies files across 6 packages:

  • packages/capi-client/
  • packages/cli/
  • packages/live-preview/
  • packages/mapi-client/
  • packages/migrations/
  • packages/schema/

When this PR is squash-merged, all changes become a single fix(schema) commit.
Nx release uses file changes (not the commit scope) to determine which packages to bump,
so every package listed above will get a version bump on the next release.

If the changes to other packages are cosmetic (README fixes, dependency bumps, config cleanup),
please split them into a separate PR with a chore: title. chore commits don't trigger version bumps.

Comment thread tools/openapi-codegen/specs/mapi/components/field-types/asset-field.yaml Outdated
Comment thread packages/migrations/src/map-refs.ts
Comment thread AGENTS.md Outdated
`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.
@pkg-pr-new

pkg-pr-new Bot commented Aug 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

@storyblok/angular

npm i https://pkg.pr.new/@storyblok/angular@756

@storyblok/astro

npm i https://pkg.pr.new/@storyblok/astro@756

@storyblok/api-client

npm i https://pkg.pr.new/@storyblok/api-client@756

storyblok

npm i https://pkg.pr.new/storyblok@756

@storyblok/experiments

npm i https://pkg.pr.new/@storyblok/experiments@756

@storyblok/js

npm i https://pkg.pr.new/@storyblok/js@756

storyblok-js-client

npm i https://pkg.pr.new/storyblok-js-client@756

@storyblok/lint-config

npm i https://pkg.pr.new/@storyblok/lint-config@756

@storyblok/live-preview

npm i https://pkg.pr.new/@storyblok/live-preview@756

@storyblok/management-api-client

npm i https://pkg.pr.new/@storyblok/management-api-client@756

@storyblok/migrations

npm i https://pkg.pr.new/@storyblok/migrations@756

@storyblok/nuxt

npm i https://pkg.pr.new/@storyblok/nuxt@756

@storyblok/react

npm i https://pkg.pr.new/@storyblok/react@756

@storyblok/region-helper

npm i https://pkg.pr.new/@storyblok/region-helper@756

@storyblok/richtext

npm i https://pkg.pr.new/@storyblok/richtext@756

@storyblok/schema

npm i https://pkg.pr.new/@storyblok/schema@756

@storyblok/svelte

npm i https://pkg.pr.new/@storyblok/svelte@756

@storyblok/vue

npm i https://pkg.pr.new/@storyblok/vue@756

commit: 41afd7d

`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant