From bb18ebd8155143ded16ee52c27753cee4248d53b Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:14:45 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(lists):=20viewing=20a=20list=20with=20r?= =?UTF-8?q?ows=20threw=20=E2=80=94=20ListId=20was=20required=20but=20absen?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any list holding at least one row failed to load. GET /api/lists/{id}/data rows do not include `listId`, but ListDataRow.ListId was declared `required`, so System.Text.Json threw before the rows ever reached the view: JsonException: JSON deserialization for type 'InterlinedList.Models.ListDataRow' was missing required properties including: 'listId'. Live row keys (captured 2026-09-16) are exactly: createdAt, createdByUser, id, lastEditedByUser, rowData, updatedAt, version Found while verifying a side-note from #17's service work, not from the backlog. Why it survived this long: the test account's only list has ZERO rows, so every manual pass over the Lists view exercised the empty path. There is also an asymmetry that hides it — POST /api/lists/{id}/data DOES return `listId` in its {message, data:{...}} envelope, so a write-path verification looks fine. Only the read path omits it. Fix: ListId is now nullable, with the reasoning recorded on the property so it does not get "tidied" back to required. The caller already knows which list it asked for. Also models the fields the payload carries that the app was dropping: - version — monotonic per-row version, the hook for optimistic concurrency on row edits so two clients editing one row can be detected instead of silently last-writer-wins. - rowNumber — server ordinal, null on a schema-less list. - createdByUser / lastEditedByUser — who added and last changed the row, which is what makes a shared list legible (see #65). Verified by deserializing both real shapes in a net10.0 harness — the read payload now parses (ListId null, version 1, createdByUser @messenger) and the create-shape payload still populates ListId. No other code reads .ListId, so the nullability change has no call-site fallout. Test-account hygiene: two throwaway lists were created to obtain a row payload at all (the account had none), then both deleted and the account confirmed back to its single pre-existing list. Closes #144 Co-Authored-By: Claude Opus 5 --- InterlinedList/Models/ListDataRow.cs | 53 ++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/InterlinedList/Models/ListDataRow.cs b/InterlinedList/Models/ListDataRow.cs index fd5b2f7..f659083 100644 --- a/InterlinedList/Models/ListDataRow.cs +++ b/InterlinedList/Models/ListDataRow.cs @@ -2,15 +2,64 @@ namespace InterlinedList.Models; +/// +/// One row of a list's data, from GET /api/lists/{id}/data. +/// +/// +/// +/// Field set reconciled against the live read payload 2026-09-16. Row keys are +/// exactly: id, rowData, version, createdAt, updatedAt, createdByUser, +/// lastEditedByUser. +/// +/// +/// is deliberately NOT required. The read +/// endpoint does not send it — declaring it required made +/// System.Text.Json throw +/// "missing required properties including: 'listId'", so any list +/// holding at least one row failed to load. Watch for the asymmetry that hid +/// this: POST /api/lists/{id}/data does return listId in +/// its {message, data:{…}} envelope; only the read path omits it. The +/// caller already knows which list it asked for. +/// +/// public sealed class ListDataRow { public required string Id { get; init; } - public required string ListId { get; init; } + + /// + /// Owning list. Absent on the read path — see the remarks. Populated when a + /// row comes back from a create/update response. + /// + public string? ListId { get; init; } + public required Dictionary RowData { get; init; } + + /// + /// Monotonic row version. Incremented per edit — the hook for optimistic + /// concurrency on row updates, so two clients editing one row can be + /// detected rather than silently last-writer-wins. + /// + public int Version { get; init; } + + /// Server-assigned ordinal. Null on a schema-less list. + public int? RowNumber { get; init; } + public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset UpdatedAt { get; init; } - /// Read-only "key: value, key2: value2" preview — good enough since rows are freeform JSON with no schema. + /// Who added the row. Useful on a shared list — see the contributors work (#65). + public ApiUser? CreatedByUser { get; init; } + + /// Who last edited it; null when never edited since creation. + public ApiUser? LastEditedByUser { get; init; } + + /// + /// Read-only "key: value, key2: value2" preview. Adequate while rows are + /// freeform; the typed, schema-driven renderer is #21. + /// public string DisplaySummary => string.Join(", ", RowData.Select(kv => $"{kv.Key}: {kv.Value}")); + + /// Edited since creation, per . + public bool HasBeenEdited => LastEditedByUser is not null; } From c9b1f25db5c5f51b08a72a81526685f2f856e2e7 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 14:22:19 -0700 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20correct=20my=20own=20claim=20?= =?UTF-8?q?=E2=80=94=20`version`=20is=20NOT=20optimistic=20concurrency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit on this branch described ListDataRow.Version as "the hook for optimistic concurrency on row updates, so two clients editing one row can be detected rather than silently last-writer-wins." That was an assumption, not a verified fact, and #18/#21's live probing contradicted it. I re-checked it myself on a throwaway list: row at version 2, PUT {"data":{…},"version":1} -> 200, version becomes 3, the write lands The server does not compare-and-swap on `version`. Row writes ARE last-writer-wins and a concurrent edit is silently lost. It is a display/audit value only. The comment now says so, and points at the app-settings store (#41) as the contrast — that one does real CAS via `baseVersion` and returns 409 version_conflict. Also records a second live finding from the same probe, which matters to #21: PUT /api/lists/{id}/data/{rowId} REPLACES rowData rather than merging it. A row holding {a,b} PUT with only {a} came back as {a} — `b` silently dropped. So a row editor has to re-send every key it knows about, echoing untouched values. No behaviour change; both are doc corrections on a model whose accuracy other issues are now relying on. Refs #144, #21 Co-Authored-By: Claude Opus 5 --- InterlinedList/Models/ListDataRow.cs | 30 +++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/InterlinedList/Models/ListDataRow.cs b/InterlinedList/Models/ListDataRow.cs index f659083..30134b5 100644 --- a/InterlinedList/Models/ListDataRow.cs +++ b/InterlinedList/Models/ListDataRow.cs @@ -35,10 +35,27 @@ public sealed class ListDataRow public required Dictionary RowData { get; init; } /// - /// Monotonic row version. Incremented per edit — the hook for optimistic - /// concurrency on row updates, so two clients editing one row can be - /// detected rather than silently last-writer-wins. + /// Monotonic row version, incremented on each edit. /// + /// + /// + /// This is NOT optimistic concurrency, despite looking like it. An + /// earlier revision of this comment claimed it was; probing disproved that + /// (2026-09-16). Sending a deliberately stale version on + /// PUT /api/lists/{id}/data/{rowId} is accepted: + /// + /// + /// row at version 2, PUT with {"data":{…},"version":1} + /// -> 200, version becomes 3, the write lands + /// + /// + /// So the server does not compare-and-swap on it — row writes are + /// last-writer-wins and a concurrent edit is silently lost. Treat this as a + /// display/audit value only. (Contrast the app-settings store, which DOES + /// do real CAS via baseVersion and returns 409 + /// version_conflict — see #41.) + /// + /// public int Version { get; init; } /// Server-assigned ordinal. Null on a schema-less list. @@ -57,6 +74,13 @@ public sealed class ListDataRow /// Read-only "key: value, key2: value2" preview. Adequate while rows are /// freeform; the typed, schema-driven renderer is #21. /// + /// + /// Note for anyone writing rows: PUT /api/lists/{id}/data/{rowId} + /// replaces rowData rather than merging it. Verified live — + /// a row holding {a,b} PUT with only {a} came back as + /// {a}, silently dropping b. So an editor must re-send every + /// key it knows about, echoing untouched values. + /// public string DisplaySummary => string.Join(", ", RowData.Select(kv => $"{kv.Key}: {kv.Value}"));