/// // 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) })