fix(lists): the schema wire contract was a string client-side and an object server-side - #87
Conversation
The client modelled a list's schema as a DSL string. The API models it as an
object, and always has — `POST /api/lists` with a string schema answers a flat
`400 {"error":"Invalid schema: DSL must be an object"}`. Every route in the
family was therefore wrong, in both directions, and the tests that said otherwise
were asserting against fixtures they had invented.
What was broken, confirmed by live probe against the .env test account:
- `GET /api/lists/{id}` answers `{data}` and was decoded as a bare ListDTO, so
`detail(listId:)` could never decode a real response. That is #75, and it was
the least consequential member of the family — no shipped path called it.
- `GET /api/lists/{id}/schema` answers `{data:{name, description?, fields[]}}`
and was decoded as `{schema: String}`. This one is not dead code:
ListRowsViewModel and SchemaEditorView both call it, so the row table and the
schema editor could not load a schema at all.
- `PUT /api/lists/{id}/schema` wants an object body and answers `{message, data}`
— note that contradicts the OpenAPI 200 example, which shows a bare
`{properties}`; the capture wins.
- `POST /api/lists` and `PUT /api/lists/{id}` answer `{message, data}`.
- `GET /api/users/{u}/lists/{id}` answers `{list, ancestors}` — a third envelope
convention on the same resource — where the client decoded a bare ListDTO.
The deeper fix is the key/label split. Row data is keyed by the column's `key`;
`label` is only what the header renders. `SchemaField` had one `name` doing both
jobs, which was harmless only because the client could not create a schema at
all. The moment that was fixed, a column labelled "Publication Year" over a key
of `year` would have rendered every cell empty. `key` and `label` are now
separate, `ListColumn` carries both through the table, and the DSL initialiser
sets them equal — which is exactly what the DSL means.
`ListDTO.schema` was documented as "present on detail and create responses" and
is present on no captured payload, so `OwnedList.schemaDescription` has always
been empty wherever it was shown. It is now derived from the real columns.
Also lands the destructive-change guard the client knew nothing about: dropping a
column that still holds row data is refused with a 400 until `?force=true`. That
is a question, not a malfunction, so it surfaces as its own error and the editor
asks it. `force` is never set on a first attempt — there is no path to a forced
write the server did not ask for.
The per-column metadata this exposes — helpText, placeholder, isRequired,
isVisible, displayOrder, defaultValue and validationRules {min, max, minLength,
maxLength, pattern, options} — confirms work-consolidation.md P2-G, which is what
blocked item 1 of #50. The row inspector renders help text and placeholders now;
the editor UI for the rules is #50's.
Every fixture in this change is a captured response body, recorded in
docs/spikes/list-schema-wire-shapes.md, and the two regressions are pinned
directly: decoding the real payload as the old shape must throw. StubAPIClient
records the encoded request body so "sends a string where an object is required"
is testable at the domain layer at all.
One limitation is flagged rather than worked around: the server's
`propertiesWithData` column list cannot be surfaced, because APIError.badRequest
keeps only the decoded `{error}` string. ListSchemaConflictDTO models the full
shape so that becomes a one-line change once the kit keeps the body.
Refs #75, #85
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9
Merge order — verified by actually integrating the stackI built a throwaway branch off Result after resolving: The three conflicts, and what each actually is1. → Keep one copy, delete the other. Do not merge the hunks — concatenating them produces one function with two 2. 3.
→ They compose. Take #101's TableColumnForEach(columns) { column in
TableColumn(column.label) { (row: ListRow) in
Text(row.fields[column.key]?.displayText ?? "")
.lineLimit(cellLineLimit)
}
}Taking either side wholesale loses something real — #105's side reintroduces the key/label bug, #101's side drops the density control. After the stack lands#106 left its two consumers unwired to avoid a three-way stack. Once #87, #102 and #106 are on
|
Two files conflicted. One was a true duplicate, and resolving it by keeping both sides would have compiled and been wrong. StubAPIClient.swift: this branch and #87 (now on dev) independently added request- body recording to the test stub, for the same reason, within days of each other. Near-identical code, different local names, different comments. The resolution is one implementation, not two — but each side had a piece the other lacked, so it is not simply "take one". dev's encode also handles `.raw` bodies, which this branch's did not; strictly more complete, so that is the one kept. This branch's `bodyJSON` accessor is what its own AppSettingsServiceTests read (four call sites), and dev has no equivalent, so that is kept too. Both motivating examples stay in the comment because both are real shipped defects: a schema sent as a string where the server demands an object (#85), and `{"name":…}` where the server demands `{"deviceName":…}` (#56). In both cases every path-and-method assertion passed the whole time, which is the argument for recording bodies at all. SettingsRootView.swift auto-merged, and this time the auto-merge is genuinely correct — verified rather than assumed, because the same file merged cleanly and wrongly on #92. All eleven tabs carry a `.tag(SettingsTab...)`, including this branch's rename of Devices to Applications, which kept its `.devices` tag. Under `TabView(selection:)` an untagged tab cannot be selected at all, so the check is worth making by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three conflicts, and only one of them was mechanical. ListMappers.swift was a true duplicate: this branch and #87 independently moved `ListJSONValue.init(from: ListCellValue)` out of `ListsService.swift`, for the same reason, within days of each other. One copy survives — concatenating them produces a function with two `case .array` bodies that does not compile. The comment keeps this branch's framing, which names the second writer that prompted the move, and dev's body, which uses explicit closures because `ListJSONValue.init(from:)` is ambiguous against `Decodable.init(from:)`. ListsService.swift was additive on both sides — one new `ListsError` case from each branch, plus their `description` arms. Both kept. ListRowsView.swift was neither. Both branches changed the same `TableColumnForEach` line: dev moved `columns` from `[String]` to `[ListColumn]` so the header reads `label` and the cell subscript uses `key`, and this branch added a density-driven `cellLineLimit`. They compose, and taking either side alone loses something real — dropping `ListColumn` reintroduces the empty-cell bug for any column whose key differs from its label, and dropping `cellLineLimit` silently ignores the saved view's density. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
Closes #75 and #85. One root cause, so one PR.
The client modelled a list's schema as a DSL string. The API models it as an object, and always has.
That is what
CreateListRequest.schema: String?was sending. Creating a list with columns from macOS was a hard failure, not a degradation.#75 as filed — the
{data}envelope onGET /api/lists/{id}— turned out to be the least consequential member of the family: nothing shipped callsdetail(listId:). The sweep it asked for found the rest.The envelope map
Probed live against the
.envtest account. Transcripts indocs/spikes/list-schema-wire-shapes.md.GET /api/lists/{id}{data:{…, properties[]}}ListDTOdetail()never decoded — #75GET /api/lists/{id}/schema{data:{name, description?, fields[]}}{schema:String}PUT /api/lists/{id}/schema{message, data}{schema:String}POST /api/lists201 {message, data}ListDTOPUT /api/lists/{id}{message, data}ListDTOGET /api/users/{u}/lists/{id}{list, ancestors}ListDTOFour envelope conventions on one resource family.
OPTIONSproves the verb and says nothing about any of this.PUT …/schemaresponse contradicts the OpenAPI 200 example, which shows a bare{properties:[…]}. The capture wins.Unlike
detail(),ListsService.schema(of:)is live code —ListRowsViewModel.swift:162andSchemaEditorView.swift:268both call it.The key/label split is the deeper fix
Row data is keyed by the column's
key;labelis only the header text:SchemaFieldhad onenamedoing both jobs. That was harmless only because the client could not create a schema at all — so every schema in the wild hadkey == label. The moment #85 is fixed, a column labelled "Publication Year" over a key ofyearrenders every cell empty.So
keyandlabelare separate,ListColumn { key, label }carries both through the table (header fromlabel, subscript fromkey), and the DSL initialiser sets them equal — which is precisely what the DSL means.ListDTO.schemawas always nilDocumented as "present on detail and create responses". It is present on no captured payload.
OwnedList.schemaDescription— shown in the list detail header and in the Markdown export — has therefore always been empty. Now derived from the real columns.The destructive-change guard
PUT …/schemarefuses to drop a column that still holds row data:400plus apropertiesWithDataarray,?force=trueconfirms. The client knew nothing about it, so it presented as an unexplained failure.It now surfaces as
ListsError.schemaChangeWouldLoseData, andSchemaEditorViewModelgainspendingDestructiveSave+confirmDestructiveSave().forceis never set on a first attempt — there is no path to a forced write the server did not ask for, and a test asserts the call order is[false, true].This unblocks #50
work-consolidation.mdP2-Grecorded the validation/help-text encoding as API-unconfirmed, which is what blocked item 1 of #50. It is confirmed — and it is not DSL syntax at all, just fields:The row inspector renders
helpTextandplaceholderhere; the editor UI for the rules stays #50's scope.visibilityCondition— conditional column visibility — wasnullon every capture, so it is decoded type-erased rather than guessed at. Modelling an unseen shape is exactly how the G21 and G25 defects happened.Every fixture is captured
Three silent decode defects have now shipped in this client (G21 link metadata, G25 org members, and these two), and all of them had green tests written against invented fixtures.
ListSchemaWireShapeTestsuses verbatim response bodies, and pins both regressions directly:StubAPIClientnow records the encoded request body, without which "sends a string where an object is required" was untestable at the domain layer.Known limitation, flagged not worked around
The server's
propertiesWithDatacolumn list cannot be surfaced:APIError.badRequestkeeps only the decoded{error}string and the rest of the body is discarded inAPIClient.ListSchemaConflictDTOmodels the full shape, so naming the columns becomes a one-line change once the kit keeps the body. The user still gets the server's own accurate sentence in the meantime.Verification
xcodebuild build→** BUILD SUCCEEDED **xcodebuild test(App) →Executed 978 tests, with 0 failures·** TEST SUCCEEDED **(was 975)swift test InterlinedDomain→Executed 1017 tests, with 0 failures(was 1012)swift test InterlinedPersistence→Executed 140 tests, with 0 failuresswift test InterlinedKit→Executed 483 tests, offline suites green;ContractTestsfailed with"Too many attempts. Please try again later."— the account was rate-limited by this PR's own recon probing, not a regression.Live account hygiene: one throwaway list was created, schema'd twice, given a row, made public for the public-route probe, and deleted. The account is back to its prior state.
🤖 Generated with Claude Code
https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9