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()andwatch()): 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:
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:
ifVersion: 0means "only if the key doesn't exist yet", for creating something without overwriting it.remove()anddelete()takeifVersiontoo: delete only if unchanged.- With
throwOnError, a conflict rejects with an error whosecodeisCONFLICTand whosecurrentholds the value now. - One key per call:
ifVersiondoesn't combine withkeys([...]), 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):
- When one answer can't hold every change,
moreistrue: ask again from itschangeSeqstraight away. since: 0lists every value there is, without deletions. It's a good way to start.- Deleted keys are reported for 30 days. A
sinceolder than that gets the errorCHANGES_EXPIRED: read the whole map again and continue from that read'schangeSeq. - 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.