Skip to content

fix(core): generate row types for repeater fields - #2469

Merged
ascorbic merged 5 commits into
emdash-cms:mainfrom
helio-cf:fix/repeater-field-types
Aug 18, 2026
Merged

fix(core): generate row types for repeater fields#2469
ascorbic merged 5 commits into
emdash-cms:mainfrom
helio-cf:fix/repeater-field-types

Conversation

@helio-cf

@helio-cf helio-cf commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Type generation emitted unknown for every repeater field, so consuming code could not index or iterate a repeater and had to declare row shapes by hand and cast to them.

fieldTypeToTypeScript had no repeater case and fell through to default: return "unknown", even though the zod side derives a precise row schema from the same validation.subFields metadata. This PR adds the missing case, so a repeater emits an inline row type built from its sub-fields.

Given a products collection with a specs repeater (name required, value optional) and a gallery repeater (photo required, caption optional):

Before

export interface Product {
  specs?: unknown;
  gallery?: unknown;
}

After

export interface Product {
  specs?: { name: string; value?: string | null }[];
  gallery?: { photo: { id: string; src?: string; alt?: string; /* …media literal… */ }; caption?: string | null }[];
}

Why this was a gap rather than a decision

The neighbouring case "json": return "unknown" is deliberate — json is untyped by design. repeater was only sharing that fallback by accident:

Type generation was the last consumer of validation.subFields that discarded it — media-usage extraction (content-fields.ts, projection-fingerprint.ts) and the admin FieldEditor all read it.

Why the casts were the real cost

as silences the compiler. When a sub-field is renamed or retyped in the schema, the generated type stays unknown, the hand-written cast keeps asserting the old shape, and astro check still reports 0 errors. The component then reads a property that no longer exists, gets undefined, and renders empty markup instead of failing — so a rename becomes a silently blank section in production rather than a build error.

Design decisions worth reviewing

  • A sub-field that is not required is typed T | null, because generateRepeaterRowSchema applies .nullish() to it, so null is a legal stored value. A bare T would trade an honest unknown for an unsound type.
  • subFields absent or empty still emits unknown. A repeater with no declared rows is schema-valid (subFields: z.array(...).min(1).optional()), and {}[] would be falsely permissive.
  • No index signature, despite rows being .passthrough() at runtime. TS object types are not sealed for reads, and an index signature would make misspelled property access legal again — defeating the purpose of the change.
  • fieldTypeToTypeScript now takes a structural subset of Field ({ type; validation? }) so a sub-field can reuse the mapping without a type assertion. Sub-field types are a subset of field types, so a select sub-field enumerates its options and an image sub-field emits the same media literal as a top-level image field, by delegation rather than duplication.
  • Sub-field slugs match /^[a-z][a-z0-9_]*$/, so they are valid bare identifiers and need no quoting in the emitted literal.

Why minor and not patch

No runtime behaviour changes, but this narrows unknown to a concrete type, which revokes unknown's universal-assertion privilege. x as SomethingUnrelated and assignment to a hand-written row type whose optional members are not nullable will now error. Consumers do not opt in either: the writer runs in the astro:server:setup hook and rewrites emdash-env.d.ts whenever content differs, so breakage lands on the next dev-server start or CI astro check.

This follows the 0.23.0 precedent (#1349), where regenerating interface names was a minor with "users should regenerate and update any direct interface references". The 0.10.0 precedent that aligned generated image/file types was a patch, but that change was purely additive — new optional properties could not break existing access.

The changeset tells consumers to regenerate types and warns that casts which have drifted from the schema may now report errors.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes — one pre-existing error on main, unrelated to this change; see notes below
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

Not applicable: no admin UI strings, and this is a bug fix rather than a feature.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5 (anthropic/claude-opus-5) via OpenCode

Screenshots / test output

Eight tests added to packages/core/tests/unit/schema/zod-generator.test.ts, written before the fix. Six failed against unfixed source; the two asserting the unknown fallback passed, as they should:

× builds a row object array from subFields
× types a sub-field that is not required as nullable
× enumerates the options of a select sub-field
× emits the media literal for an image sub-field
× maps every allowed sub-field type
× keeps a required repeater non-optional
✓ falls back to unknown when subFields is absent
✓ falls back to unknown when subFields is empty

Tests  6 failed | 29 passed (35)

After the fix:

Test Files  1 passed (1)
     Tests  35 passed (35)

Full packages/core suite on Node 22: 455/459 files, 5677 tests passing.

`fieldTypeToTypeScript` had no `repeater` case, so every repeater field
fell through to `default: return "unknown"` -- while the zod side derives
a precise row schema from the same `validation.subFields` metadata.

This was an oversight rather than a decision. The emitter's switch,
including its deliberate `case "json": return "unknown"`, dates from
43fcb9a and predates the field type: emdash-cms#111 added `repeater` without
touching this file at all, so neither the zod case nor the TS case
arrived with the feature. emdash-cms#2458 then added the zod case and left the TS
side as it was. Type generation is now the only consumer of `subFields`
that discards it -- media-usage extraction and the admin field editor
both read it.

Consumers cannot index or iterate `unknown`, so they declare row shapes
by hand and cast to them. Those casts silence the compiler: when a
sub-field is renamed or retyped, the generated type stays `unknown`, the
cast keeps asserting the old shape, and the drift surfaces as a silently
blank render rather than a type error.

- Build an inline row type from `subFields`, reusing the existing
  per-type mapping so a `select` sub-field enumerates its options and an
  `image` sub-field emits the same media literal as a top-level image.
- Type a sub-field that is not required as `T | null`, since
  `generateRepeaterRowSchema` applies `.nullish()` to it. A bare `T`
  would trade an honest `unknown` for an unsound type.
- Keep emitting `unknown` when `subFields` is absent or empty. A repeater
  with no declared rows is schema-valid, and `{}[]` would be falsely
  permissive.

`fieldTypeToTypeScript` now takes a structural subset of `Field` so a
sub-field can reuse the mapping without a type assertion.

Sub-field slugs match `/^[a-z][a-z0-9_]*$/`, so they are valid bare
identifiers and need no quoting in the emitted literal.
@changeset-bot

changeset-bot Bot commented Aug 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: dc61d74

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Major
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@helio-cf

Copy link
Copy Markdown
Contributor Author

recheck

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet cla: signed labels Aug 14, 2026
@pkg-pr-new

pkg-pr-new Bot commented Aug 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2469

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2469

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2469

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2469

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2469

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2469

emdash

npm i https://pkg.pr.new/emdash@2469

create-emdash

npm i https://pkg.pr.new/create-emdash@2469

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2469

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2469

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2469

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2469

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2469

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2469

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2469

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2469

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2469

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2469

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2469

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2469

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2469

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2469

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2469

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2469

commit: dc61d74

@helio-cf

Copy link
Copy Markdown
Contributor Author

1) [chromium] › e2e/tests/invite-flow.spec.ts:227:2 › Full invite flow with passkey registration › invited user appears in the users list

this test failing but my changes dont really touch any of this? 🤔

@MA2153

MA2153 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Our CI pipeline has been a bit too flaky recently 😬

@ascorbic
ascorbic requested a review from MA2153 August 17, 2026 11:15
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Aug 17, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right change: repeater fields were the only validation.subFields consumer that still discarded the metadata and fell through to unknown, leaving generated types out of sync with the runtime zod schema. The PR aligns the TypeScript emitter with generateRepeaterRowSchema, emits the inline row type with correct nullability, and keeps the empty/absent subFields fallback to unknown so the type stays honest. The minor bump and changeset are appropriate because narrowing unknown can break downstream casts.

I read the diff, the full zod-generator.ts/types.ts source, the typegen/schema export routes, the test file, and existing changesets. The implementation is functionally correct and matches the runtime schema shape (nullish optional sub-fields, option enumeration for selects, media literal for images, no index signature).

Two non-blocking AGENTS.md/quality notes:

  • One of the new tests is a tautological config-pin that asserts the implementation's exact concatenated output back at itself.
  • A couple of new comments justify design decisions by referencing the current implementation rather than stating an invariant. The codebase has similar existing comments, but these are easy to tighten.

No blocking issues.

Comment on lines +761 to +777
it("maps every allowed sub-field type", () => {
const ts = generateTypeScript(
makeRepeaterCollection([
{ slug: "a", label: "A", type: "string", required: true },
{ slug: "b", label: "B", type: "text", required: true },
{ slug: "c", label: "C", type: "url", required: true },
{ slug: "d", label: "D", type: "number", required: true },
{ slug: "e", label: "E", type: "integer", required: true },
{ slug: "f", label: "F", type: "boolean", required: true },
{ slug: "g", label: "G", type: "datetime", required: true },
]),
);

expect(ts).toContain(
"specs?: { a: string; b: string; c: string; d: number; e: number; f: boolean; g: string }[];",
);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This test is a tautological config-pin. It asserts the exact concatenated string that the implementation happens to produce, so it passes only because the test constant matches the current switch ordering and output formatting. The surrounding tests already verify the real behaviors (row object shape, optional-nullability, select enumeration, image literal, required fallback). If a new allowed sub-field type is added or the emitted spacing changes, this test will fail even though the mapping is correct.

Consider deleting it or replacing it with per-type assertions that don't pin the exact combined output.

Comment on lines +467 to +468
// `generateRepeaterRowSchema` applies `.nullish()` to a sub-field that
// is not required, so `null` is a legal stored value.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This comment justifies a design decision by referencing the current implementation of generateRepeaterRowSchema ("applies .nullish()"). Per AGENTS.md, comments should explain non-obvious invariants, not justify decisions or couple to another function's implementation. If generateRepeaterRowSchema later stops using .nullish(), this comment becomes stale.

If you keep a comment here, state the invariant for this emitter ("Optional sub-fields may be null at runtime") instead of describing how the zod schema achieves it.

Comment on lines +458 to +459
// A repeater carrying no sub-fields describes no rows, so `{}[]` would
// be falsely permissive.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This comment explains the unknown fallback, but the previous line already reads if (!subFields || subFields.length === 0) return "unknown";, which is clear enough. The justification for the design belongs in the PR description/changeset; the code's intent is already plain here.

Consider removing this comment or replacing it with a shorter invariant if you think future readers will otherwise expect {}[].

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Aug 17, 2026

@MA2153 MA2153 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approach looks right: the missing case reads as a gap rather than a decision, the nullability matches generateRepeaterRowSchema, and delegating sub-fields back through the same mapper is the right call. Checked out the branch, tests pass.

Three things worth fixing first, all in the new repeater case, left inline. Two of them turn a previously inert unknown into a generated file that doesn't compile.

Smaller notes:

  • "maps every allowed sub-field type" doesn't actually cover select or image (both covered separately, fine) and won't fail when a new entry is added to REPEATER_SUB_FIELD_TYPES. Driving it from that const would make it a real guard. A select sub-field with no options is untested.
  • The `{}[]` would be falsely permissive comment is justification for a decision, which the comment rules exclude. The .nullish() one is worth keeping, it's a cross-file invariant a reader would otherwise get wrong.
  • Pre-existing and out of scope, but the sub-field path inherits it: select options are interpolated into string literals unescaped, and unlike slugs they have no character constraint even on the API path.

Comment on lines +469 to +471
return subField.required
? `${subField.slug}: ${type}`
: `${subField.slug}?: ${type} | null`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sub-field slugs go in as bare identifiers, but the /^[a-z][a-z0-9_]*$/ guarantee only holds on the admin API route (repeaterSubFieldSchema, api/schemas/schema.ts:59). Seed files bypass it: SeedField.validation is Record<string, unknown> (seed/types.ts:111), validateSeed checks pattern and uniqueness for top-level field slugs only, and apply.ts:224 hands validation straight to registry.createField, which stringifies it without looking. Rows written before that route schema existed are unchecked too.

Ran generateTypeScript on this branch with such a field:

specs?: { first-name: string }[];            // slug "first-name"
specs?: { a: any }[] | { evil: string }[];   // slug 'a: any }[] | { evil'

The first is TS1131, so emdash-env.d.ts stops parsing and every collection loses its types. On main both emit unknown. It also doesn't self-heal: the integration rewrites the file on each dev server start whenever content differs (astro/integration/index.ts:660), so fixing it by hand doesn't stick, and the committed file is what astro check reads in CI.

JSON.stringify(subField.slug) as the member name covers any string. Falling back to unknown when a slug fails the pattern works too.

// be falsely permissive.
if (!subFields || subFields.length === 0) return "unknown";

const members = subFields.map((subField) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate sub-field slugs give TS2300, and this one is reachable from the admin without a seed file. FieldEditor derives each sub-field slug from its label with no uniqueness check (FieldEditor.tsx:583), and subFields: z.array(repeaterSubFieldSchema).min(1) has no uniqueness refinement. Two sub-fields labelled "Name" both get name:

specs?: { name: string; name: number }[];   // TS2300 x2

generateRepeaterRowSchema tolerates this silently, since shape[subField.slug] = schema is last wins, so the emitter is the only thing that breaks. Deduping last wins here would match the zod side. Rejecting duplicates in the API schema is the better fix but a wider change.

const subFields = field.validation?.subFields;
// A repeater carrying no sub-fields describes no rows, so `{}[]` would
// be falsely permissive.
if (!subFields || subFields.length === 0) return "unknown";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this assumes an array, but on the seed/registry path validation is unvalidated JSON. subFields: {} gets past the length check and throws in the .map below. That degrades to a logged typegen warning rather than a broken file, so much milder than the other two, but the other consumer of this data guards it with Array.isArray (media/usage/content-fields.ts:117).

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Aug 17, 2026
Quote member names, keep the last declaration of a duplicated slug, and
fall back to `unknown` when `subFields` is not an array.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review size/L and removed review/awaiting-author Reviewed; waiting on the author to respond size/M labels Aug 17, 2026

@MA2153 MA2153 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@helio-cf

helio-cf commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Thanks!

no prob! and thanks for reviewing, that array assumpption shouldnt have made it through, idk how i missed it, noticed itd also allow strings to pass through

@ascorbic
ascorbic enabled auto-merge (squash) August 18, 2026 08:40
@ascorbic
ascorbic merged commit ef32567 into emdash-cms:main Aug 18, 2026
48 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core cla: signed review/needs-rereview Author pushed changes since the last review size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants