156 lines
6 KiB
JavaScript
156 lines
6 KiB
JavaScript
|
|
/// <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)
|
||
|
|
})
|