Skip to content

Several Devices

When the same user has your app open on a laptop and a phone, two things matter: a change made on one device shouldn't silently overwrite a change made on the other, and each device should find out about the other's changes without a reload. PrefsDB has a tool for each:

  • Conditional writes (ifVersion): change a value only if nobody else has changed it since you read it.
  • The change feed (changes() and watch()): find out what changed since you last looked, deletions included.

Both work for single keys and for maps. Lists aren't covered: their entries are only ever added or removed, never changed.

Versions

Every value has a version, a number that changes every time the value is written. Reads return it, and so does every write:

const todos = db.map("todos");
const w = await todos.put("t1", { text: "milk", done: false });
w.version;                              // e.g. 41

const items = await todos.entries();
items[0].version;                       // the same number, until the value changes again
(await todos.get("t1")).version;
# "version" is in the answer of a write and of a read
curl -s -b cookies.txt -H "X-Authorization: Token $KEY" -G https://prefsdb.com/api/v1/read/ \
  -d __map=todos -d t1=

Versions don't count 1, 2, 3 for each value: they come from one counter for the whole API key, so a value's version can jump. Only compare them for being equal.

Change it only if nobody else did: ifVersion

Pass the version you read as ifVersion. The change is made only if the value still has that version. If another device changed it (or deleted it) in the meantime, nothing is changed and the answer is a CONFLICT that carries the value as it is now, in current:

const todo = (await todos.entries()).find((t) => t.key === "t1");

const res = await todos.put("t1", { ...todo.value, done: true }, { ifVersion: todo.version });
if (res.error === "CONFLICT") {
    // res.current: { exists, value, version } -- the value now
    showMerge(todo.value, res.current.value);
}
# 409 CONFLICT, with "current", if t1's version is no longer 41
curl -s -b cookies.txt -H "X-Authorization: Token $KEY" -H "Content-Type: text/plain" \
  -X POST "https://prefsdb.com/api/v1/write/?__map=todos&t1=&__ifVersion=41" \
  --data-raw '{"text":"milk","done":true}'
  • ifVersion: 0 means "only if the key doesn't exist yet", for creating something without overwriting it.
  • remove() and delete() take ifVersion too: delete only if unchanged.
  • With throwOnError, a conflict rejects with an error whose code is CONFLICT and whose current holds the value now.
  • One key per call: ifVersion doesn't combine with keys([...]), a wildcard, or a list.

A conditional write works offline too. It's saved in the browser like any other write, and the condition is checked when it reaches the server. If the value changed there in the meantime, the write is dropped and its settled Promise rejects with CONFLICT. Its current is the server's value, which is also what this browser shows from then on.

Keeping a list up to date: changes() and watch()

Every whole-map read (entries(), readAll()) carries a changeSeq. Ask changes({ since: changeSeq }) later, and you get everything that changed after that read, oldest first. Each change is either a value written (with its new value and version) or a key deleted (deleted: true):

const items = await todos.entries({ max: Infinity });
let since = items.changeSeq;

// later
const res = await todos.changes({ since });
for (const c of res.changes) {
    if (c.deleted) list.delete(c.key);
    else list.set(c.key, c.value);
}
since = res.changeSeq;                  // ask from here next time
# Everything that changed in the map "todos" after changeSeq 120
curl -s -b cookies.txt -H "X-Authorization: Token $KEY" -G https://prefsdb.com/api/v1/read/ \
  -d __map=todos -d "*=" -d __since=120
  • When one answer can't hold every change, more is true: ask again from its changeSeq straight away.
  • since: 0 lists every value there is, without deletions. It's a good way to start.
  • Deleted keys are reported for 30 days. A since older than that gets the error CHANGES_EXPIRED: read the whole map again and continue from that read's changeSeq.
  • In the browser, the changes are also applied to the offline copy of the map, so it's up to date when the device goes offline.

watch() asks for changes on a timer for you, every 10 seconds by default:

const stop = todos.watch((changes, { reset }) => {
    if (reset) list.clear();                // changes is then every value there is
    for (const c of changes) {
        if (c.deleted) list.delete(c.key);
        else list.set(c.key, c.value);
    }
    render();
}, { since: items.changeSeq, interval: 10000, onError: (err) => console.warn(err.code) });

// later: stop();   (db.logout() stops every watch too)

Without since, the first call delivers every value with reset: true. If the server can no longer list the deletions since since, watch() starts over the same way. While the device is offline it skips its turn and carries on when the connection is back. The callback also hears about this device's own changes; applying them a second time does no harm.

Recipe: a to-do list on several devices

const todos = db.map("todos");
const list = new Map();

async function start() {
    const items = await todos.entries({ max: Infinity });
    for (const { key, value, version } of items) list.set(key, { ...value, version });
    render();
    todos.watch((changes, { reset }) => {
        if (reset) list.clear();
        for (const c of changes) {
            if (c.deleted) list.delete(c.key);
            else list.set(c.key, { ...c.value, version: c.version });
        }
        render();
    }, { since: items.changeSeq });
}

async function toggle(id) {
    const { version, ...todo } = list.get(id);
    const res = await todos.put(id, { ...todo, done: !todo.done }, { ifVersion: version });
    if (res.error === "CONFLICT") {
        showMessage("This item was changed on another device; here's the latest.");
        if (res.current.exists) list.set(id, { ...res.current.value, version: res.current.version });
        else list.delete(id);
    } else if (res.success) {
        list.set(id, { ...todo, done: !todo.done, version: res.version });
    }
    render();
}

start();

For changes that show at once and still work offline, combine this with optimistic writes.