Files
bookhoard/docs/contract_pins.md
T
John O'Keefe 7d698ea0d8 fix(annotations): bookmark and note PUTs land through the sync service (LWW by id)
H3 census bugs #2 and #3: both web PUTs used plain queries that
bypassed the sync-aware layer entirely — no LWW, no source bookkeeping,
and locator fields the client couldn't even send. The highlight PUT was
the only one already routed (via HighlightID); the other two now mirror
it:

- SaveBookmarkRequest gains BookmarkID, SaveNoteRequest gains NoteID:
  id-targeted resolution reads the exact row, verifies ownership,
  LWWs under its stored dedup key, and resurrects tombstones when the
  edit is newer (same doctrine and comment structure as the highlight
  path).
- Fields the PUT omits are coalesced from the stored row before the
  full-column sync update — the legacy web PUT sends only
  title/notes/position, and zeroing cfi/percentage/page on every edit
  would have been data loss. Verified both directions by
  TestH3_BookmarkPUTPreservesLocators (omitted survives, explicit
  lands).
- The bookmark PUT's wire DTO gains the create endpoint's locator keys
  as optional fields (documented additive request keys in
  contract_pins.md); the note PUT keeps its content+position wire.

UpdateMediaBookmark/UpdateMediaNote plain queries are now uncalled
(the generated queries stay until the H4 query sweep).
2026-10-17 15:53:12 -04:00

90 lines
7.0 KiB
Markdown

# Wire-contract pins (hardening batches)
These are the response SHAPES (JSON key sets, not values) frozen by the
`TestContractPin_*` suite in `cmd/server/tests/contract_pins_test.go`
(batch H0). The plan they serve is `~/.opencode/plan/hardening-canonical-types.md`.
## Rules
1. **Frozen**: no existing response key may be RENAMED or REMOVED on any
endpoint below (JSON or KOReader/Kobo adapters). Requests are frozen too —
no parsing changes.
2. **Additive keys are allowed and expected** — but a batch must widen the
`allowed` slice in the test AND record the addition in the changelog
below, in the same commit.
3. KOReader / Kobo / progress surfaces are the hard freeze: the KOReader
plugin is an external client whose handlers keep response shapes
byte-identical (only their internals may route through shared funnels).
4. A behavior fix that would require narrowing or REMOVING a pinned key
needs the user's explicit sign-off before the pin is touched — a pin
freezing buggy behavior is a known-bugs ledger, not a license.
## Pinned surfaces
| Test | Surface | Shape |
|---|---|---|
| `ProgressWeb` | `GET /api/media-items/:id/progress` (percentage-only write) | flat map: `id, media_item_id, user_id, current_page, total_pages, last_read_at, percentage, character_offset, epubcfi, chapter, chapter_progress, format_group, total_characters, chapter_count, last_sync_device, last_sync_source, last_sync_timestamp` |
| | … conditional bolt-ons | `context_text` (when stored valid); `css_selector`, `anchor_href`, `char_offset`, rewritten `epubcfi` (when the anchor verify path fires) |
| | `PUT /api/media-items/:id/progress` response | `database.ReadingProgress` JSON (25 keys incl. `audio_offset_seconds`, viewport/zoom/scroll/panel/reading_mode) |
| | `GET` with no rows | exactly `{current_page, total_pages}` |
| `KOReaderProgressPush` | `POST /api/sync/koreader/progress` (immediate, 202) | `sync_status, books_synced, book_results[]?{sha256, book_uuid, synced}, conflicts[]?{book_uuid, conflict_type, device_progress, server_progress, resolution}, timestamp, device_updated` |
| `KOReaderProgressPull` | `GET /api/sync/koreader/metadata/:uuid` | `uuid, sha256?, title, authors, progress{percentage + optional character/epubcfi/koreader_xpointer/chapter/chapter_progress/page/total_pages}, annotations{highlights?, notes?, bookmarks?, deleted_highlights?, deleted_bookmarks?}, last_sync` |
| `KOReaderLibrary` | `GET /api/sync/koreader/library` | `library_sync[]{uuid, sha256?, title, author, content_type, percent_read, pages_remaining?, bookmark_count, last_modified}, total_books, last_sync` |
| `KOReaderBookmarksPush` | `POST /api/sync/koreader/bookmarks` | `sync_status, bookmarks_synced, notes_synced, highlights_synced, total_synced, timestamp` |
| `KoboInitialization` | `GET /api/sync/kobo/:token/v1/initialization` | `library_sync[]{ContentId, ContentType, Title, Author, PercentRead, PagesRemaining?, BookmarkCount, LastModified, EntitlementId, Revision, MimeType, FileSize, Categories?, BookhoardUUID?}, total_books, last_sync` |
| `KoboMarkupSync` | `POST /api/sync/kobo/:token/markup` | `Status, MarkupsSynced, BookmarksSynced, DeletedAnnotations?` |
| `KoboSyncFromServer` | `POST /api/sync/kobo/:token/sync-from-server` | `BooksSynced, BookmarksSent, HighlightsSent` |
| `CollectionsJSON` | `GET /api/collections/:id` (all 4 branches) | envelope `id, user_id, name, description, color, icon, auto_assign_rules, view_settings, books[], book_count, is_smart, created_at`; books = `BookInfo` |
| | `POST /api/collections/preview` | bare `{items: BookInfo[]}` |
| `SeriesJSON` | `GET /api/series/books?name=&library_id=` | `{name, library_id, books: BookInfo[], total}` |
| `SearchJSON` | `GET /api/media-items/search?q=&library_id=` | top-level array of `SearchMediaItemsUnifiedRow` (47 keys) |
| `DashboardSections` | `GET /api/dashboard/sections` | `{sections[]: {id, collection_id, is_system, title, description, icon, items: BookInfo[], view_all_url, priority}}` |
| `MediaListBothBranches` | `GET /api/media-items` (unscoped) | `{data: [38-key resolved map]}` (intentionally divergent from the scoped branch until the row-shape collapse batch) |
| | `GET /api/media-items?library_id=&sort=` | `{data: [raw ListMediaItemsSortedRow, 70 sqlc keys]}` |
| `SSRPages` | `/bookshelf`, `/collections`, `/collections/:id`, `/series`, `/series/detail`, `/tags/detail` | 200 + stable title/substring markers |
`BookInfo` (every card surface): `media_item_id, title, author,
cover_image_path, has_conflict, format_group, badge`.
## Changelog (additive keys per batch)
- **H0 (initial pin)** — the shapes above, captured on the unmodified tree
at commit `b4ae423`.
- **H1 (progress canonicalization)** — `GET /api/media-items/:id/progress`
Whitelisted ADDITIVE keys (fields the `GetUniversalProgressRow` already
carries, now surfaced by the shared row serializer):
`audio_offset_seconds, reading_mode, viewport_x, viewport_y, zoom_level,
scroll_position_x, scroll_position_y, panel_number`. No key removed or
renamed; the no-rows and PUT-response shapes are untouched. The web PUT
binds `SaveProgressRequest` directly — its parse set for existing
clients is IDENTICAL to the old DTO (verified against web/src
reader.ts:3113-3143 and audio.ts:518-520); keys the web never sent
(viewport_x/y, panel_number) merely become honor-able. All other pins
unchanged.
- **H3-prep (web annotation CRUD freeze)** — NEW pinned surfaces, captured
pre-refactor per the pin-first doctrine: `POST/PUT/DELETE
/api/media-items/:id/{notes,highlights,bookmarks}` — create 201 / update
200 / delete 204, response bodies are the full sqlc row models
(MediaNotes 19 keys, MediaBookmarks 20 keys, MediaHighlights 27 keys —
listed in contract_pins_test.go). No keys removed; nothing was pinned
here before. Capturing these immediately caught a LIVE server bug:
`applyNoteLWW` omitted `UserID` from its
`UpdateMediaNoteForSync` params, so every dedup-hit note re-save
(same content+position ⇒ same key) returned 500
("update note: no rows in result set") — the LWW update could never
land. Fixed in the same commit (server-side fix, freely allowed);
the pins are its regression net.
- **H3 (PUT fixes routed through the sync service)** — `PUT
/api/media-items/:id/bookmarks/:bookmarkId` now parses ADDITIONAL
optional request keys mirroring its create sibling:
`cfi_position, page_number, chapter_number, percentage,
chapter_reference` (all zero-value-preserving: omitted keys coalesce
with the stored row inside the service, so legacy clients that send
only title/notes/position keep every stored locator — verified by
TestH3_BookmarkPUTPreservesLocators). The PUT lands as an id-targeted
LWW edit under the row's stored dedup key (BookmarkID/NoteID mirror
SaveHighlightRequest.HighlightID), so `last_modified_source` /
`last_modified_at` are now maintained. Note PUT unchanged on the wire
(content+position) but routed through the service the same way.
Response shapes unchanged (same row models — pins green).