Schema: fields the client needs to sync watch state / watchlist / prefs #1
3 changed files with 245 additions and 6 deletions
30
README.md
30
README.md
|
|
@ -29,9 +29,9 @@ users ─┬─* profiles ─┬─* watch_state (resume points; 1 row per pro
|
||||||
|---------------|------|------------|
|
|---------------|------|------------|
|
||||||
| `users` | auth | `username` (unique when set), `email`, `password`, **`nsfwEnabled`** (admin-only) |
|
| `users` | auth | `username` (unique when set), `email`, `password`, **`nsfwEnabled`** (admin-only) |
|
||||||
| `profiles` | base | `user`→users, `name`, `avatar` (file), `isChild`, `pinHash` (hidden) |
|
| `profiles` | base | `user`→users, `name`, `avatar` (file), `isChild`, `pinHash` (hidden) |
|
||||||
| `watch_state` | base | `profile`→profiles, `itemId`, `itemType`, `position`, `duration`, `updatedAt` |
|
| `watch_state` | base | `profile`→profiles, `itemId`, `itemType`, `position`, `duration`, `meta` (json), `updatedAt` (client), `updated` (server) |
|
||||||
| `watchlist` | base | `profile`→profiles, `itemId`, `itemType`, `addedAt` |
|
| `watchlist` | base | `profile`→profiles, `itemId`, `itemType`, `meta` (json), `addedAt` (client), `updated` (server), `deletedAt` (tombstone) |
|
||||||
| `prefs` | base | `profile`→profiles, `data` (json) |
|
| `prefs` | base | `profile`→profiles, `data` (json), `updatedAt` (client), `updated` (server) |
|
||||||
|
|
||||||
**Access rules.** A user only ever reads/writes their own `users` row, their own
|
**Access rules.** A user only ever reads/writes their own `users` row, their own
|
||||||
`profiles`, and rows whose `profile.user` is them. `nsfwEnabled` is never
|
`profiles`, and rows whose `profile.user` is them. `nsfwEnabled` is never
|
||||||
|
|
@ -41,6 +41,30 @@ update) — only a **superuser** sets it, from the admin UI. Relations
|
||||||
Unique indexes keep one resume/watchlist row per `(profile, itemId)` and one
|
Unique indexes keep one resume/watchlist row per `(profile, itemId)` and one
|
||||||
`prefs` row per profile.
|
`prefs` row per profile.
|
||||||
|
|
||||||
|
### The two clocks (issue #11)
|
||||||
|
|
||||||
|
Sync rows carry **two** timestamps, and they are not interchangeable:
|
||||||
|
|
||||||
|
- **`updatedAt` / `addedAt` — set by the client.** When the user actually acted.
|
||||||
|
This is what resolves conflicts (last-write-wins). It has to be the client's,
|
||||||
|
because a device that edits offline on Monday and pushes on Friday must not
|
||||||
|
beat a device that edited on Tuesday and synced immediately.
|
||||||
|
- **`updated` — a server autodate.** When we heard about it. This is the **pull
|
||||||
|
cursor** (`filter=updated > {lastCursor}`). It has to be the server's: a device
|
||||||
|
with a skewed clock would otherwise stamp rows *behind* another device's cursor
|
||||||
|
and stay invisible to it forever.
|
||||||
|
|
||||||
|
`itemId` encodes the client's local key — `anime:<anilistId>:<episode>`,
|
||||||
|
`movie:<tmdbId>:0`, `tv:<tmdbId>:<seasonEpisodeKey>` — so the unique
|
||||||
|
`(profile, itemId)` index is what makes a push idempotent. `meta` is an opaque
|
||||||
|
JSON blob of the client's display fields (title, cover art, episode, flags); the
|
||||||
|
server never reads into it.
|
||||||
|
|
||||||
|
**Deletes are soft.** A watchlist removal sets `deletedAt` rather than deleting
|
||||||
|
the row, because an absence is not something another device can pull — it would
|
||||||
|
just re-push the title and resurrect it. Re-adding clears `deletedAt` on the same
|
||||||
|
row, so a delete/re-add round-trip can never leave a duplicate.
|
||||||
|
|
||||||
## Local run / verify
|
## Local run / verify
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
155
pb_migrations/1784250000_sync_fields.js
Normal file
155
pb_migrations/1784250000_sync_fields.js
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
|
||||||
|
// Fields the client needs to actually sync watch state / watchlist / prefs
|
||||||
|
// (epic #6, issue #11). The init schema (#9) sketched the collections before the
|
||||||
|
// client existed; three things were missing once it came to reconciling two
|
||||||
|
// devices:
|
||||||
|
//
|
||||||
|
// 1. `meta` — the app's local rows carry display fields (episode, title, cover
|
||||||
|
// art, MAL id, completed/dismissed flags) with nowhere to live here. Without
|
||||||
|
// them a freshly-signed-in device pulls resume points it can't render.
|
||||||
|
// 2. A client-owned clock. `updatedAt`/`addedAt` were autodate, i.e. stamped on
|
||||||
|
// server *receipt*, so a device that edited while offline and pushed a day
|
||||||
|
// later would beat a device that edited afterwards but synced immediately.
|
||||||
|
// Last-write-wins needs the time the user acted, not the time we heard it.
|
||||||
|
// 3. Tombstones. Deleting a watchlist row outright means the next device to
|
||||||
|
// push simply re-creates it — a removal on one device rebounds from another.
|
||||||
|
//
|
||||||
|
// Both clocks now exist and mean different things: `updatedAt` (client) resolves
|
||||||
|
// conflicts, `updated` (server autodate) is the pull cursor. The cursor must be
|
||||||
|
// server-side — a device with a skewed clock would otherwise write rows that sort
|
||||||
|
// before another device's cursor and stay invisible to it forever.
|
||||||
|
|
||||||
|
// Dropping a field drops its column data with it, so any timestamp already on a
|
||||||
|
// row is snapshotted here and written back afterwards. Against an empty
|
||||||
|
// collection this is a no-op; against a populated one it's the difference
|
||||||
|
// between a clean upgrade and blanking every timestamp. A row left with an empty
|
||||||
|
// `updated` cursor would be worse than merely wrong — it'd be invisible, skipped
|
||||||
|
// by every `updated > cursor` pull forever.
|
||||||
|
function snapshot(app, table, column) {
|
||||||
|
const rows = arrayOf(new DynamicModel({ id: "", value: "" }))
|
||||||
|
app.db()
|
||||||
|
.newQuery(`SELECT id, ${column} as value FROM ${table} WHERE ${column} != ''`)
|
||||||
|
.all(rows)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw SQL rather than app.save(), so the autodate hooks don't immediately
|
||||||
|
// restamp the very columns being restored.
|
||||||
|
function restore(app, table, rows, columns) {
|
||||||
|
for (const row of rows) {
|
||||||
|
const sets = columns.map((c) => `${c} = {:value}`).join(", ")
|
||||||
|
app.db()
|
||||||
|
.newQuery(`UPDATE ${table} SET ${sets} WHERE id = {:id}`)
|
||||||
|
.bind({ value: row.value, id: row.id })
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate((app) => {
|
||||||
|
// ---- watch_state -------------------------------------------------------
|
||||||
|
const watchState = app.findCollectionByNameOrId("watch_state")
|
||||||
|
const wsStamps = snapshot(app, "watch_state", "updatedAt")
|
||||||
|
|
||||||
|
// autodate -> client-set date. Recreated rather than mutated: PocketBase
|
||||||
|
// treats type as immutable, so the field has to be dropped and re-added.
|
||||||
|
watchState.fields.removeByName("updatedAt")
|
||||||
|
watchState.fields.add(new Field({
|
||||||
|
type: "date",
|
||||||
|
name: "updatedAt",
|
||||||
|
required: true,
|
||||||
|
}))
|
||||||
|
watchState.fields.add(new Field({
|
||||||
|
type: "autodate",
|
||||||
|
name: "updated",
|
||||||
|
onCreate: true,
|
||||||
|
onUpdate: true,
|
||||||
|
}))
|
||||||
|
// episode, title, coverUrl, malId, isMovie, completed, dismissedAt — whatever
|
||||||
|
// the client needs to rebuild its local row verbatim. Opaque to the server.
|
||||||
|
watchState.fields.add(new Field({
|
||||||
|
type: "json",
|
||||||
|
name: "meta",
|
||||||
|
maxSize: 100000,
|
||||||
|
}))
|
||||||
|
app.save(watchState)
|
||||||
|
// The old autodate value is the best evidence we have of when the row last
|
||||||
|
// changed, so it seeds both clocks.
|
||||||
|
restore(app, "watch_state", wsStamps, ["updatedAt", "updated"])
|
||||||
|
|
||||||
|
// ---- watchlist ---------------------------------------------------------
|
||||||
|
const watchlist = app.findCollectionByNameOrId("watchlist")
|
||||||
|
const wlStamps = snapshot(app, "watchlist", "addedAt")
|
||||||
|
|
||||||
|
// Client-set so a title migrated from local storage keeps the day it was
|
||||||
|
// actually added — the list is ordered newest-first off this.
|
||||||
|
watchlist.fields.removeByName("addedAt")
|
||||||
|
watchlist.fields.add(new Field({
|
||||||
|
type: "date",
|
||||||
|
name: "addedAt",
|
||||||
|
required: true,
|
||||||
|
}))
|
||||||
|
watchlist.fields.add(new Field({
|
||||||
|
type: "autodate",
|
||||||
|
name: "updated",
|
||||||
|
onCreate: true,
|
||||||
|
onUpdate: true,
|
||||||
|
}))
|
||||||
|
watchlist.fields.add(new Field({
|
||||||
|
type: "json",
|
||||||
|
name: "meta",
|
||||||
|
maxSize: 100000,
|
||||||
|
}))
|
||||||
|
// Soft delete: removals have to be a *visible* state other devices can pull,
|
||||||
|
// not an absence. Re-adding clears this back to null; the unique
|
||||||
|
// (profile, itemId) index means that reuses the same row, never a duplicate.
|
||||||
|
watchlist.fields.add(new Field({
|
||||||
|
type: "date",
|
||||||
|
name: "deletedAt",
|
||||||
|
}))
|
||||||
|
app.save(watchlist)
|
||||||
|
restore(app, "watchlist", wlStamps, ["addedAt", "updated"])
|
||||||
|
|
||||||
|
// ---- prefs -------------------------------------------------------------
|
||||||
|
// Already has `updated` (autodate) to serve as the cursor; it just needs the
|
||||||
|
// client-owned clock to resolve two devices editing prefs while offline.
|
||||||
|
const prefs = app.findCollectionByNameOrId("prefs")
|
||||||
|
prefs.fields.add(new Field({
|
||||||
|
type: "date",
|
||||||
|
name: "updatedAt",
|
||||||
|
required: true,
|
||||||
|
}))
|
||||||
|
app.save(prefs)
|
||||||
|
// prefs rows predate the client clock; seed it from the server's own so the
|
||||||
|
// first sync has something ordered to compare against.
|
||||||
|
restore(app, "prefs", snapshot(app, "prefs", "updated"), ["updatedAt"])
|
||||||
|
}, (app) => {
|
||||||
|
// ---- Down migration ----------------------------------------------------
|
||||||
|
const watchState = app.findCollectionByNameOrId("watch_state")
|
||||||
|
watchState.fields.removeByName("meta")
|
||||||
|
watchState.fields.removeByName("updated")
|
||||||
|
watchState.fields.removeByName("updatedAt")
|
||||||
|
watchState.fields.add(new Field({
|
||||||
|
type: "autodate",
|
||||||
|
name: "updatedAt",
|
||||||
|
onCreate: true,
|
||||||
|
onUpdate: true,
|
||||||
|
}))
|
||||||
|
app.save(watchState)
|
||||||
|
|
||||||
|
const watchlist = app.findCollectionByNameOrId("watchlist")
|
||||||
|
watchlist.fields.removeByName("deletedAt")
|
||||||
|
watchlist.fields.removeByName("meta")
|
||||||
|
watchlist.fields.removeByName("updated")
|
||||||
|
watchlist.fields.removeByName("addedAt")
|
||||||
|
watchlist.fields.add(new Field({
|
||||||
|
type: "autodate",
|
||||||
|
name: "addedAt",
|
||||||
|
onCreate: true,
|
||||||
|
}))
|
||||||
|
app.save(watchlist)
|
||||||
|
|
||||||
|
const prefs = app.findCollectionByNameOrId("prefs")
|
||||||
|
prefs.fields.removeByName("updatedAt")
|
||||||
|
app.save(prefs)
|
||||||
|
})
|
||||||
|
|
@ -64,6 +64,30 @@ st, ws = req("GET", "/api/collections/watch_state", admin)
|
||||||
check("watch_state has unique (profile,itemId) index", st,
|
check("watch_state has unique (profile,itemId) index", st,
|
||||||
any("idx_watch_state_profile_item" in i for i in ws["indexes"]))
|
any("idx_watch_state_profile_item" in i for i in ws["indexes"]))
|
||||||
|
|
||||||
|
# --- sync fields (issue #11) ---
|
||||||
|
def field(coll, name):
|
||||||
|
return next((f for f in coll["fields"] if f["name"] == name), None)
|
||||||
|
|
||||||
|
check("watch_state.updatedAt is a client-set date, not autodate", st,
|
||||||
|
(field(ws, "updatedAt") or {}).get("type") == "date")
|
||||||
|
check("watch_state.updated is a server autodate (pull cursor)", st,
|
||||||
|
(field(ws, "updated") or {}).get("type") == "autodate")
|
||||||
|
check("watch_state has meta json", st,
|
||||||
|
(field(ws, "meta") or {}).get("type") == "json")
|
||||||
|
|
||||||
|
st, wl = req("GET", "/api/collections/watchlist", admin)
|
||||||
|
check("watchlist.addedAt is a client-set date, not autodate", st,
|
||||||
|
(field(wl, "addedAt") or {}).get("type") == "date")
|
||||||
|
check("watchlist.updated is a server autodate (pull cursor)", st,
|
||||||
|
(field(wl, "updated") or {}).get("type") == "autodate")
|
||||||
|
check("watchlist has meta json", st, (field(wl, "meta") or {}).get("type") == "json")
|
||||||
|
check("watchlist has deletedAt tombstone", st,
|
||||||
|
(field(wl, "deletedAt") or {}).get("type") == "date")
|
||||||
|
|
||||||
|
st, pf = req("GET", "/api/collections/prefs", admin)
|
||||||
|
check("prefs.updatedAt is a client-set date", st,
|
||||||
|
(field(pf, "updatedAt") or {}).get("type") == "date")
|
||||||
|
|
||||||
# --- create two users (superuser create; nsfwEnabled set by admin) ---
|
# --- create two users (superuser create; nsfwEnabled set by admin) ---
|
||||||
def make_user(email, uname):
|
def make_user(email, uname):
|
||||||
st, r = req("POST", "/api/collections/users/records", admin, {
|
st, r = req("POST", "/api/collections/users/records", admin, {
|
||||||
|
|
@ -89,11 +113,46 @@ bob = login("bob@example.com")
|
||||||
st, pa = req("POST", "/api/collections/profiles/records", alice,
|
st, pa = req("POST", "/api/collections/profiles/records", alice,
|
||||||
{"user": ua["id"], "name": "Alice Main"})
|
{"user": ua["id"], "name": "Alice Main"})
|
||||||
check("alice creates her own profile", st, st == 200)
|
check("alice creates her own profile", st, st == 200)
|
||||||
|
CLIENT_TS = "2026-01-02 03:04:05.000Z"
|
||||||
st, wsrow = req("POST", "/api/collections/watch_state/records", alice,
|
st, wsrow = req("POST", "/api/collections/watch_state/records", alice,
|
||||||
{"profile": pa["id"], "itemId": "tt0111161", "itemType": "movie",
|
{"profile": pa["id"], "itemId": "movie:278:0", "itemType": "movie",
|
||||||
"position": 120, "duration": 8520})
|
"position": 120, "duration": 8520, "updatedAt": CLIENT_TS,
|
||||||
|
"meta": {"title": "The Shawshank Redemption", "episode": 0}})
|
||||||
check("alice creates watch_state under her profile", st, st == 200)
|
check("alice creates watch_state under her profile", st, st == 200)
|
||||||
|
|
||||||
|
# The client's clock must survive the round-trip untouched — LWW is decided on
|
||||||
|
# it, so a server that restamped it would silently break conflict resolution.
|
||||||
|
check("watch_state.updatedAt round-trips the client's value", st,
|
||||||
|
wsrow.get("updatedAt", "").startswith("2026-01-02 03:04:05"))
|
||||||
|
check("watch_state.meta round-trips", st,
|
||||||
|
(wsrow.get("meta") or {}).get("title") == "The Shawshank Redemption")
|
||||||
|
check("watch_state.updated (cursor) is stamped by the server", st,
|
||||||
|
bool(wsrow.get("updated")) and not wsrow["updated"].startswith("2026-01-02"))
|
||||||
|
|
||||||
|
# A second push of the same item must collide, not duplicate.
|
||||||
|
st, dup = req("POST", "/api/collections/watch_state/records", alice,
|
||||||
|
{"profile": pa["id"], "itemId": "movie:278:0", "itemType": "movie",
|
||||||
|
"position": 500, "duration": 8520, "updatedAt": CLIENT_TS})
|
||||||
|
check("duplicate (profile,itemId) watch_state is rejected", st, st == 400)
|
||||||
|
|
||||||
|
# --- watchlist tombstone: remove then re-add must reuse the row -------------
|
||||||
|
st, wlrow = req("POST", "/api/collections/watchlist/records", alice,
|
||||||
|
{"profile": pa["id"], "itemId": "movie:278", "itemType": "movie",
|
||||||
|
"addedAt": CLIENT_TS, "meta": {"title": "Shawshank"}})
|
||||||
|
check("alice creates watchlist row", st, st == 200)
|
||||||
|
check("watchlist.addedAt round-trips the client's value", st,
|
||||||
|
wlrow.get("addedAt", "").startswith("2026-01-02 03:04:05"))
|
||||||
|
st, r = req("PATCH", f"/api/collections/watchlist/records/{wlrow['id']}", alice,
|
||||||
|
{"deletedAt": "2026-01-03 00:00:00.000Z"})
|
||||||
|
check("watchlist row soft-deletes", st, st == 200 and bool(r.get("deletedAt")))
|
||||||
|
st, r = req("PATCH", f"/api/collections/watchlist/records/{wlrow['id']}", alice,
|
||||||
|
{"deletedAt": None, "addedAt": "2026-01-04 00:00:00.000Z"})
|
||||||
|
check("re-add clears the tombstone on the same row (no duplicate)", st,
|
||||||
|
st == 200 and not r.get("deletedAt") and r["id"] == wlrow["id"])
|
||||||
|
st, r = req("GET", "/api/collections/watchlist/records", alice)
|
||||||
|
check("exactly one watchlist row after delete+re-add", st,
|
||||||
|
st == 200 and r.get("totalItems") == 1)
|
||||||
|
|
||||||
# --- ISOLATION: bob must not see or touch alice's data ---
|
# --- ISOLATION: bob must not see or touch alice's data ---
|
||||||
st, r = req("GET", f"/api/collections/profiles/records/{pa['id']}", bob)
|
st, r = req("GET", f"/api/collections/profiles/records/{pa['id']}", bob)
|
||||||
check("bob CANNOT view alice's profile (404)", st, st == 404)
|
check("bob CANNOT view alice's profile (404)", st, st == 404)
|
||||||
|
|
@ -118,7 +177,8 @@ check("alice's nsfwEnabled still True after attempt", st, me.get("nsfwEnabled")
|
||||||
# --- alice cannot create watch_state under bob's (nonexistent-to-her) profile ---
|
# --- alice cannot create watch_state under bob's (nonexistent-to-her) profile ---
|
||||||
st, pb = req("POST", "/api/collections/profiles/records", bob, {"user": ub["id"], "name": "Bob Main"})
|
st, pb = req("POST", "/api/collections/profiles/records", bob, {"user": ub["id"], "name": "Bob Main"})
|
||||||
st, r = req("POST", "/api/collections/watch_state/records", alice,
|
st, r = req("POST", "/api/collections/watch_state/records", alice,
|
||||||
{"profile": pb["id"], "itemId": "tt0068646", "itemType": "movie"})
|
{"profile": pb["id"], "itemId": "movie:238:0", "itemType": "movie",
|
||||||
|
"updatedAt": CLIENT_TS})
|
||||||
check("alice CANNOT attach a row to bob's profile", st, st in (400, 403, 404))
|
check("alice CANNOT attach a row to bob's profile", st, st in (400, 403, 404))
|
||||||
|
|
||||||
print("\n" + ("ALL CHECKS PASSED" if allpass else "SOME CHECKS FAILED"))
|
print("\n" + ("ALL CHECKS PASSED" if allpass else "SOME CHECKS FAILED"))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue