Schema: fields the client needs to sync watch state / watchlist / prefs #1

Merged
richiexec merged 1 commit from feature/sync-fields into main 2026-07-17 16:40:27 +00:00
Collaborator

Backend half of richiexec/myanime-app#11 (cloud sync). The #9 schema sketched these collections before the Flutter client existed; wiring up the client turned up three gaps that block the acceptance criteria.

What changed

  • meta (json) on watch_state and watchlist. The client's local rows carry display fields — episode, title, cover art, MAL id, completed/dismissed — with nowhere to live server-side. Without them a freshly-signed-in device pulls resume points it can't render (untitled, art-less tiles). Opaque to the server.
  • A client-owned clock. updatedAt/addedAt were autodate, i.e. stamped on server receipt. That breaks last-write-wins: a device that edits offline Monday and pushes Friday would beat a device that edited Tuesday and synced at once. They're now client-set date fields (the time the user acted). The old server autodate lives on as updated and becomes the pull cursor — that one must stay server-side, or a device with a skewed clock could write rows behind another device's cursor and stay invisible to it forever.
  • deletedAt tombstone on watchlist. A hard delete is an absence, and an absence isn't pullable — the next device to sync just re-adds the title from its own copy. Soft-delete makes a removal a state other devices can pull; re-adding clears it on the same row (the unique (profile,itemId) index guarantees no duplicate).

Migration safety

The type change means dropping and re-adding the timestamp fields, which drops their column data. Live is believed empty, but blanking updated would leave any existing row invisible to every future pull — too quiet a failure to risk on an assumption — so the migration snapshots the old timestamps and writes them back.

Verification

scripts/verify.py grows the new field-shape assertions plus behavioural checks: the client clock round-trips un-restamped, the server stamps its own cursor, a duplicate (profile,itemId) is rejected, and delete → re-add reuses the row instead of duplicating. Ran against a local docker compose up --build on both paths:

  • fresh install (both migrations from empty) — 36/36 pass
  • upgrade over the init-only schema with rows already seeded — timestamps preserved (previously both blanked), 36/36 pass

Deploy note: redeploy the Coolify PocketBase after merge so the live schema picks this up before the client PR ships.

🤖 Generated with Claude Code

Backend half of **richiexec/myanime-app#11** (cloud sync). The #9 schema sketched these collections before the Flutter client existed; wiring up the client turned up three gaps that block the acceptance criteria. ## What changed - **`meta` (json)** on `watch_state` and `watchlist`. The client's local rows carry display fields — episode, title, cover art, MAL id, completed/dismissed — with nowhere to live server-side. Without them a freshly-signed-in device pulls resume points it can't render (untitled, art-less tiles). Opaque to the server. - **A client-owned clock.** `updatedAt`/`addedAt` were `autodate`, i.e. stamped on server *receipt*. That breaks last-write-wins: a device that edits offline Monday and pushes Friday would beat a device that edited Tuesday and synced at once. They're now client-set `date` fields (the time the user acted). The old server autodate lives on as **`updated`** and becomes the **pull cursor** — that one *must* stay server-side, or a device with a skewed clock could write rows behind another device's cursor and stay invisible to it forever. - **`deletedAt` tombstone** on `watchlist`. A hard delete is an absence, and an absence isn't pullable — the next device to sync just re-adds the title from its own copy. Soft-delete makes a removal a state other devices can pull; re-adding clears it on the same row (the unique `(profile,itemId)` index guarantees no duplicate). ## Migration safety The type change means dropping and re-adding the timestamp fields, which drops their column data. Live is believed empty, but blanking `updated` would leave any existing row invisible to every future pull — too quiet a failure to risk on an assumption — so the migration snapshots the old timestamps and writes them back. ## Verification `scripts/verify.py` grows the new field-shape assertions plus behavioural checks: the client clock round-trips un-restamped, the server stamps its own cursor, a duplicate `(profile,itemId)` is rejected, and delete → re-add reuses the row instead of duplicating. Ran against a local `docker compose up --build` on **both** paths: - fresh install (both migrations from empty) — **36/36 pass** - upgrade over the init-only schema with rows already seeded — timestamps preserved (previously both blanked), **36/36 pass** Deploy note: redeploy the Coolify PocketBase after merge so the live schema picks this up before the client PR ships. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
claude added 1 commit 2026-07-17 14:31:06 +00:00
The #9 schema sketched these collections before the client existed. Reconciling
two devices turned up three gaps:

- `meta` (json) on watch_state/watchlist. Local rows carry display fields —
  episode, title, cover art, MAL id, completed/dismissed — with nowhere to live
  server-side, so a freshly-signed-in device pulled resume points it couldn't
  render.
- A client-owned clock. `updatedAt`/`addedAt` were autodate, i.e. stamped on
  server receipt, so a device that edited offline and pushed a day later beat a
  device that edited afterwards and synced at once. LWW needs the time the user
  acted. The server autodate lives on as `updated` and is now the pull cursor —
  that one must be server-side, or a skewed client clock would write rows behind
  another device's cursor and stay invisible to it.
- `deletedAt` tombstones on watchlist. Hard deletes are an absence, and an
  absence isn't pullable — the next device to push just resurrects the title.

The type change means dropping and re-adding the fields, which drops their column
data, so the migration snapshots the old timestamps and writes them back. Live is
believed empty, but blanking `updated` would leave rows invisible to every future
pull, which is too quiet a failure to risk on an assumption.

Verified against a local docker compose on both paths — a fresh install, and an
upgrade over the init-only schema with rows already in it (timestamps preserved).
scripts/verify.py grows the field-shape assertions plus behavioural checks: the
client clock round-trips unrestamped, duplicate (profile,itemId) is rejected, and
delete/re-add reuses the row instead of duplicating. 36/36 pass.

Refs richiexec/myanime-app#11
Author
Collaborator

Review: approve — safe to deploy

Reviewed from a local checkout of the branch. The migration is correct and the design is right.

Two clocks, correctly assigned. Client updatedAt/addedAt for last-write-wins (the time the user acted, so an offline edit pushed late doesn't beat a later edit synced promptly), server updated autodate as the pull cursor (monotonic in server time, so a skewed device clock can't stamp a row behind another device's cursor and vanish). This is the correct resolution of a subtle problem, and the client PR (#27 in the app repo) implements exactly the matching half.

Verified the one load-bearing assumption: the migration only adds updatedAt to prefs and relies on prefs.updated already existing as the cursor — and it does, from the init schema (1752600000_init_accounts_schema.js:140). So prefs ships with a working cursor.

The autodate→date swap is handled safely. Type is immutable in PocketBase, so drop-and-re-add is necessary; snapshot/restore (raw SQL, so autodate hooks don't restamp) preserves existing timestamps and seeds both clocks. On the live backend it's a no-op anyway — these collections are empty (no client has synced yet), so there's nothing to preserve. Safe to deploy.

verify.py earns its keep — it writes client-shaped rows (itemId:"movie:278:0", client updatedAt, meta, tombstone) and asserts the client clock round-trips un-restamped, the duplicate (profile,itemId) is rejected, and delete→re-add reuses the one row.

Minor notes (non-blocking)

  1. verify.py checks prefs.updatedAt but not prefs.updated — I confirmed the latter by hand; a one-line assert would close the gap.
  2. JS migrations aren't transactional across the multiple app.save() calls. Near-impossible against empty collections, but if the deploy errors mid-migration, inspect collection state before re-deploying rather than blindly retrying — a half-applied run could trip a duplicate-field error on re-run.

Deploy this, then merge app PR #27 — the client writes updatedAt/meta/deletedAt, which the live schema won't have until this lands, and the failure is silent (see my review on #27).

## Review: approve ✅ — safe to deploy Reviewed from a local checkout of the branch. The migration is correct and the design is right. **Two clocks, correctly assigned.** Client `updatedAt`/`addedAt` for last-write-wins (the time the *user* acted, so an offline edit pushed late doesn't beat a later edit synced promptly), server `updated` autodate as the pull cursor (monotonic in server time, so a skewed device clock can't stamp a row behind another device's cursor and vanish). This is the correct resolution of a subtle problem, and the client PR (#27 in the app repo) implements exactly the matching half. **Verified the one load-bearing assumption:** the migration only *adds* `updatedAt` to `prefs` and relies on `prefs.updated` already existing as the cursor — and it does, from the init schema (`1752600000_init_accounts_schema.js:140`). So prefs ships with a working cursor. ✅ **The autodate→date swap is handled safely.** Type is immutable in PocketBase, so drop-and-re-add is necessary; `snapshot`/`restore` (raw SQL, so autodate hooks don't restamp) preserves existing timestamps and seeds both clocks. On the **live backend it's a no-op anyway** — these collections are empty (no client has synced yet), so there's nothing to preserve. Safe to deploy. **verify.py earns its keep** — it writes client-shaped rows (`itemId:"movie:278:0"`, client `updatedAt`, `meta`, tombstone) and asserts the client clock round-trips un-restamped, the duplicate `(profile,itemId)` is rejected, and delete→re-add reuses the one row. ### Minor notes (non-blocking) 1. verify.py checks `prefs.updatedAt` but not `prefs.updated` — I confirmed the latter by hand; a one-line assert would close the gap. 2. JS migrations aren't transactional across the multiple `app.save()` calls. Near-impossible against empty collections, but **if the deploy errors mid-migration, inspect collection state before re-deploying** rather than blindly retrying — a half-applied run could trip a duplicate-field error on re-run. **Deploy this, then merge app PR #27** — the client writes `updatedAt`/`meta`/`deletedAt`, which the live schema won't have until this lands, and the failure is silent (see my review on #27).
richiexec merged commit f9300ceec5 into main 2026-07-17 16:40:27 +00:00
richiexec deleted branch feature/sync-fields 2026-07-17 16:40:27 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: richiexec/amber-backend#1
No description provided.