Skip to content

bug(lists): the schema wire contract is a DSL string client-side and an object server-side — create, read and save are all broken #85

Description

@Adron

Found while probing #75. More severe than #75 and the same root cause, so the two should be fixed together — but filed separately because #75 describes a dead method and this describes shipped, reachable, broken features.

The root cause

The client models a list's schema as a DSL string ("Title:text, Year:number"). The API models it as a structured object. Every route in the family is therefore wrong, in both directions.

Live evidence, captured 2026-09-15 against the .env test account (GET probes plus one create/update/delete cycle on a throwaway list).

1. Creating a list with a schema is a hard 400

What the macOS client sends today (CreateListRequest.schema: String?):

POST /api/lists  {"title":"…","schema":"Title:text, Year:number"}
→ 400 {"error":"Invalid schema: DSL must be an object","code":"bad_request"}

The server wants schema as { name, description?, fields[] }:

POST /api/lists  {"title":"probe","schema":{"name":"probe","fields":[
    {"key":"title","type":"text","label":"Title","required":true}, … ]}}
→ 201 {"message":"List created successfully","data":{…,"properties":[…]}}

So ListsService.create(…) cannot create a schema-bearing list at all, and its response is {message, data} where Lists.create decodes a bare ListDTO — wrong twice over, same as POST /api/lists/{id}/data was before PR #24.

2. Reading a schema cannot decode

ListSchemaDTO is { schema: String } (ListDTO.swift:197). Live:

GET /api/lists/{id}/schema
{"data":{"name":"probe-object-schema","description":"recon probe","fields":[
  {"key":"title","type":"text","label":"Title","displayOrder":0,"required":true,
   "helpText":"What is it called?","placeholder":"e.g. Dune","visible":true,
   "validation":{"pattern":"^[A-Za-z].*$","maxLength":80,"minLength":2}},
  {"key":"status","type":"select","label":"Status","displayOrder":5,"required":false,
   "visible":true,"defaultValue":"todo",
   "validation":{"options":["todo","doing","done"]},"options":["todo","doing","done"]}]}}

Wrapped in data, and the inner object has name/description/fields[], not schema: String.

ListsService.schema(of:) is not dead code — it is called by App/Features/Lists/ListRowsViewModel.swift:162 and App/Features/Lists/SchemaEditorView.swift:268. The row table and the schema editor cannot load a schema.

3. Saving a schema sends the wrong body

UpdateListSchemaRequest sends {schema: String}; the server wants {schema: {name, description?, fields[]}} (or, alternatively, a properties array for a non-destructive per-column update). The live response is {message, data:{…, properties[]}} — note this contradicts the OpenAPI 200 example, which shows a bare {properties: […]}.

There is also a destructive-change guard the client knows nothing about: deleting a column that still holds row data returns 400 with a propertiesWithData[] array, and requires ?force=true to confirm the data loss.

4. rowData is keyed by key, not by the label

"rowData":{"due":"2026-01-02","year":1965,"title":"Dune","status":"done"}

SchemaField has a single name (ListSchema.swift:11) used as both the display label and the cell key. The server keeps them separate — propertyKey vs propertyName. This has not bitten only because macOS cannot create a schema today; it will bite the moment #75 and this are fixed, and it would present as every cell reading empty.

5. Same-family envelope mismatches

Route Client decodes Live sends
GET /api/lists/{id} bare ListDTO {"data":{…}} — this is #75
POST /api/lists bare ListDTO {"message","data"}
PUT /api/lists/{id} bare ListDTO {"message","data"}
PUT /api/lists/{id}/schema {schema:String} {"message","data"}

This also unblocks #50

work-consolidation.md P2-G records the schema DSL's validation/help-text encoding as API-unconfirmed, which is why item 1 of #50 is blocked. It is now confirmed, and it is not DSL syntax at all — they are first-class fields:

validationRules: { min, max, minLength, maxLength, pattern, options }
helpText, placeholder, isRequired, isVisible, visibilityCondition,
defaultValue, displayOrder

visibilityCondition is a further field nothing in the client models — conditional column visibility, worth its own look.

Fix

One PR with #75, because they are one defect:

  • Kit: ListSchemaDSLDTO { name, description?, fields[] }, ListSchemaFieldDTO, ListValidationDTO, ListPropertyDTO; ListDTO.properties; every envelope above corrected; CreateListRequest.schema becomes the object.
  • Domain: SchemaField gains key separate from label, plus helpText, placeholder, defaultValue, isVisible, displayOrder, validation. The DSL string stays as an authoring convenience that compiles to the object; it stops being the wire format.
  • Contract tests against the captured payloads above, not fabricated ones.

Acceptance

  • A list with columns can be created from macOS.
  • The schema editor and the row table load a real schema.
  • A cell whose key differs from its label renders its value.
  • The destructive-change guard is surfaced rather than presenting as an opaque 400.
  • BDD quartet across Kit / Domain / App.

Implementation plan (added 2026-09-15)

One PR with #75, because they are one defect. Sequenced so each layer is green before the next starts.

1. Kit — model the real shapes (ListSchemaDSLDTO.swift)

  • ListSchemaDSLDTO { name?, description?, fields[] } — one type for read and write; the server uses the same shape both ways.
  • ListSchemaFieldDTO { key, type, label?, displayOrder?, required?, visible?, helpText?, placeholder?, defaultValue?, validation?, options? }, with resolvedOptions reading whichever of the two option spellings the payload carried.
  • ListFieldValidationDTO { min, max, minLength, maxLength, pattern, options }, plus isEmpty so an untouched column never encodes an empty rules object — the server reads that as clear the rules.
  • ListPropertyDTO for the properties[] projection, with asSchemaField reconciling propertyKey/propertyName/validationRules into the DSL spelling so nothing above the kit has to know there are two.
  • Envelopes: ListResponse { message?, data, refreshStatus? }, ListSchemaResponse { data }, PublicListResponse { list, ancestors }.
  • ListSchemaConflictDTO for the destructive-change 400.
  • CreateListRequest.schema becomes the object. ListDTO gains properties; ListDTO.schema stays decodable but is documented as a field the server has never sent.

2. Domain — split key from label

SchemaField gains key (the row-data identity) separate from label (display), plus isRequired, isVisible, displayOrder, helpText, placeholder, defaultValue, validation.

  • name survives as a computed alias for label, and the DSL initialiser init(name:type:) sets key == label — which is what the DSL means. Existing call sites keep compiling and stay honest.
  • ListSchema.orderedFields honours displayOrder; field(key:) is the lookup that matters.
  • An unrecognised type token degrades to .text, never a decode failure — a new server column type must not take out the whole row table.
  • pattern is deliberately not evaluated client-side: pre-validating a server regex against a different engine would reject values the server accepts.
  • OwnedList.schemaDescription is derived from the real columns instead of the phantom wire field.

3. Domain — the service

  • create(…, schema: ListSchema?); an empty schema omits the key entirely rather than sending {fields: []}, which would ask for an explicitly column-less list.
  • updateSchema(of:schema:force:), with the 400 re-badged as ListsError.schemaChangeWouldLoseData so the UI asks the question instead of showing "Bad Request".

4. App — stop keying cells by the label

New ListColumn { key, label }. ListRowsViewModel.columns returns those; the table takes its header from label and its subscript from key, and drops isVisible == false columns. RowInspectorView does the same and finally renders the per-column helpText and placeholder the server has always stored.

SchemaEditorViewModel gains pendingDestructiveSave + confirmDestructiveSave(): force is never set on a first attempt, so there is no path to a forced write the server did not ask for.

5. Tests — captured payloads only

ListSchemaWireShapeTests uses verbatim response bodies, and pins both regressions directly: decoding the real body as the old shape must throw. The Domain fixtures are rebuilt from the same captures, and StubAPIClient now records the encoded request body so a test can assert the shape that goes on the wire — without which "sends a string instead of an object" was untestable at that layer.

Known limitation, filed separately

The propertiesWithData column list is not reachable: APIError.badRequest carries only the decoded {error} string and the rest of the body is discarded in APIClient. ListSchemaConflictDTO models the full shape, so naming the columns becomes a one-line change once the kit keeps the body.

Out of scope

The editor UI for validation rules and help text — that is #50, which this unblocks by confirming P2-G. visibilityCondition was null on every captured payload, so it is decoded type-erased rather than guessed at.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingparityWeb-parity gap with the InterlinedList web app

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions