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:
- 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.
- 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.
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'"
|
Description
PUT /_emdash/api/schema/collections/{slug}silently discardstitleFieldanddateField. The request returns200 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 notitleField/dateFieldkeys, so Zod's default strip mode removes them beforethe 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
UpdateCollectionInputand passes it straight toSchemaRegistry.updateCollection.SchemaRegistry.updateCollectionwritestitle_field/date_fieldandvalidates them (
validateDisplayFields: the field must exist,titleFieldmust be text-like,
dateFieldmust bedatetime).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.tsorpackages/core/src/mcp/server.ts.The implementation plan in #1133 explicitly included "make the create and update
code accept
displayFieldanddateField, save them, and return them when acollection is read back"; the in-process path does this, the REST path does not.
Still present on
maintoday.Three things follow from that, in decreasing severity:
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.
PUT {"titleField": "totally_not_a_field"}returns
200 OKinstead of theSchemaErrorthe registry would raise —the invalid value never reaches the validator.
collectionSchema(theCollectionresponse schema, same file) alsoomits 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 OpenAPIcontract 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_collectiontool enumerates its input keys from
updateCollectionBody.shapeand so omitsthem as well; the CLI has no collection-update command at all
(
emdash schemacovers list/get/create/delete plus field add/remove); and theadmin UI reads
titleField/dateFieldfrom the manifest but offers no editorfor them. The only writer is
SchemaRegistry.updateCollection, reachablein-process — in practice via
emdash seed --on-conflict update, whoseapplyDisplayDateFieldssecond-write step sets them. That flag re-syncs everyfield 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 thesame endpoint — round-trips fine, because
adminis inupdateCollectionBody.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(mirroringUpdateCollectionInput's nullable clearing semantics), and tocollectionSchemafor the response contract:
createCollectionBody/CreateCollectionInputomit both consistently, so thecreate 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
datetimefield (below:
import_runs, withrun_status(string) andstarted_at(datetime)):
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 responding200with the old values echoed back.Expected:
titleField/dateFieldare persisted (and validated) by the PUT,matching
UpdateCollectionInputand the in-processSchemaRegistrypath.Actual: both keys are stripped by
updateCollectionBodybefore the handlersees them;
200 OK, nothing written, no signal.Environment
emdash: 0.34.0 (gap also present onmainas of 2026-08-19)Use case: a site with a
cardsproduct collection and animport_runsjob-logcollection, both provisioned well before 0.34.0.
import_runswants Title =run_statusand Date =started_at(when the job ran) rather than theCMS-generated title and
published_at(when the row was written) — the sameshape as the
blog_posts.pub_datecase in #1133.cardswants Title =nameand Date =
last_synced.admin.listColumnsfor both could be scripted overthis endpoint;
titleField/dateFieldcould not, so the collections are stuckhalf-configured on every database that predates the feature.
Logs / error output