Skip to content

PUT /schema/collections/{slug} silently drops titleField/dateField: updateCollectionBody omits both keys #2573

Description

@cyface

Description

PUT /_emdash/api/schema/collections/{slug} silently discards titleField and
dateField. The request returns 200 OK, but neither field is ever written —
the route validates the body with updateCollectionBody
(packages/core/src/api/schemas/schema.ts), and that Zod object has no
titleField/dateField keys, so Zod's default strip mode removes them before
the handler runs.

Everything on either side of that one schema already supports the fields:

  • UpdateCollectionInput (packages/core/src/schema/types.ts) declares both,
    documented down to the clearing semantics — titleField?: string | null
    ("null/"" clears back to the default").
  • handleSchemaCollectionUpdate (packages/core/src/api/handlers/schema.ts)
    takes exactly that UpdateCollectionInput and passes it straight to
    SchemaRegistry.updateCollection.
  • SchemaRegistry.updateCollection writes title_field/date_field and
    validates them (validateDisplayFields: the field must exist, titleField
    must be text-like, dateField must be datetime).

So the route's own parameter type says the fields are accepted, and the layer
below is fully built to accept them; only the Zod body schema in between is
missing them.

This looks like a leftover from #1973 (the PR that shipped #1133). That PR
touched the types, registry, migration, seed engine, search and admin — but not
packages/core/src/api/schemas/schema.ts or packages/core/src/mcp/server.ts.
The implementation plan in #1133 explicitly included "make the create and update
code accept displayField and dateField, save them, and return them when a
collection is read back"; the in-process path does this, the REST path does not.
Still present on main today.

Three things follow from that, in decreasing severity:

  1. The write is a silent no-op. No error, no partial-success signal. On a
    collection where the fields are already set, the response even echoes the
    stored values back, so a caller cannot distinguish "written" from
    "ignored" by reading the response.
  2. Validation is bypassed too. PUT {"titleField": "totally_not_a_field"}
    returns 200 OK instead of the SchemaError the registry would raise —
    the invalid value never reaches the validator.
  3. collectionSchema (the Collection response schema, same file) also
    omits both fields, while the handler does return them. Runtime responses
    are correct — that schema is only consumed by
    packages/core/src/api/openapi/document.ts — but the published OpenAPI
    contract under-documents the response.

There is currently no way to set these fields on an existing collection.
The REST endpoint drops them (this bug); the MCP schema_update_collection
tool enumerates its input keys from updateCollectionBody.shape and so omits
them as well; the CLI has no collection-update command at all
(emdash schema covers list/get/create/delete plus field add/remove); and the
admin UI reads titleField/dateField from the manifest but offers no editor
for them. The only writer is SchemaRegistry.updateCollection, reachable
in-process — in practice via emdash seed --on-conflict update, whose
applyDisplayDateFields second-write step sets them. That flag re-syncs every
field of every collection in the seed file against live data, which is a much
larger blast radius than "set one display column", so it is not a usable
workaround on a provisioned database. Fresh databases get the fields correctly;
already-provisioned ones cannot get them at all.

Note that admin.listColumns — the sibling admin-list display setting, on the
same endpoint — round-trips fine, because admin is in updateCollectionBody.
That is what makes the failure surprising in practice: two halves of the same
admin-list configuration, one written and one dropped, in a single 200 response.

Suggested fix: add the two keys to updateCollectionBody (mirroring
UpdateCollectionInput's nullable clearing semantics), and to collectionSchema
for the response contract:

// updateCollectionBody
titleField: z.string().min(1).max(63).regex(slugPattern).nullish(),
dateField: z.string().min(1).max(63).regex(slugPattern).nullish(),

// collectionSchema
titleField: z.string().nullable(),
dateField: z.string().nullable(),

createCollectionBody/CreateCollectionInput omit both consistently, so the
create path needs no change unless you want parity there.

Steps to reproduce

Against any 0.34.0 instance, on a collection with a text field and a datetime
field (below: import_runs, with run_status (string) and started_at
(datetime)):

API=http://localhost:4321/_emdash/api

# 1. Set admin.listColumns (control) and titleField/dateField in one PUT.
curl -X PUT "$API/schema/collections/import_runs" \
  -H 'Content-Type: application/json' \
  -d '{"admin":{"listColumns":["run_status","started_at"]},
       "titleField":"run_status","dateField":"started_at"}'
# 200 OK. admin.listColumns is written. titleField/dateField are not.

# 2. Read it back.
curl "$API/schema/collections/import_runs"
# admin.listColumns -> the new value. titleField/dateField -> still null.

# 3. The write is not validated either — a field slug that does not exist
#    still returns 200 instead of a SchemaError.
curl -X PUT "$API/schema/collections/import_runs" \
  -H 'Content-Type: application/json' \
  -d '{"titleField":"totally_not_a_field"}'
# 200 OK

Confirming it is the request schema and not the handler: set the columns
directly in the database and read them back — the GET response returns both
fields correctly, and a subsequent PUT {"titleField":null, "dateField":"finished_at"} leaves the stored values untouched while responding
200 with the old values echoed back.

UPDATE _emdash_collections
   SET title_field = 'run_status', date_field = 'started_at'
 WHERE slug = 'import_runs';

Expected: titleField/dateField are persisted (and validated) by the PUT,
matching UpdateCollectionInput and the in-process SchemaRegistry path.
Actual: both keys are stripped by updateCollectionBody before the handler
sees them; 200 OK, nothing written, no signal.

Environment

  • emdash: 0.34.0 (gap also present on main as of 2026-08-19)
  • astro: 7.2.2
  • Runtime: Cloudflare Workers (wrangler/miniflare dev; D1 + R2)
  • Node 24.16.0, pnpm 11.21.0
  • OS: macOS

Use case: a site with a cards product collection and an import_runs job-log
collection, both provisioned well before 0.34.0. import_runs wants Title =
run_status and Date = started_at (when the job ran) rather than the
CMS-generated title and published_at (when the row was written) — the same
shape as the blog_posts.pub_date case in #1133. cards wants Title = name
and Date = last_synced. admin.listColumns for both could be scripted over
this endpoint; titleField/dateField could not, so the collections are stuck
half-configured on every database that predates the feature.

Logs / error output

No error output — that is the bug. The request succeeds:

$ curl -sS -X PUT .../schema/collections/import_runs \
    -d '{"titleField":"run_status","dateField":"started_at"}' | jq '.success'
true

$ sqlite3 …/d1.sqlite \
    "select title_field, date_field from _emdash_collections where slug='import_runs'"
|

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions