amber-backend/pb_migrations/1784250000_sync_fields.js

156 lines
6 KiB
JavaScript
Raw Normal View History

feat(schema): fields the client needs to sync watch state / watchlist / prefs 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
2026-07-17 14:20:38 +00:00
/// <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)
})