Transactions and undo
A transaction groups writes: they apply together, sync together, and undo together.
const project = sync.transaction((tx) => {
const created = tx.projects.create({ name: 'Japan' });
tx.tasks.create({ projectId: created.id, title: 'Visa' });
tx.tasks.create({ projectId: created.id, title: 'Flights' });
return created;
});
sync.undo(); // removes the project and both tasksWhat a transaction gives you
- All or nothing. If the callback throws (a
ValidationErrorfrom any write, or your own error), nothing is written, not even the writes before the throw. On the server, the transaction is applied whole or refused whole. - One undo step. The user's "undo" reverts the whole action, not its last write.
- It reads its own writes.
tx.<table>hascreate,update,delete,getandall(), and its reads include what the transaction wrote so far.
sync.transaction((tx) => {
for (const task of tx.tasks.all()) {
if (task.projectId === projectId && task.done) {
tx.tasks.delete(task.id);
}
}
const left = tx.tasks.all().filter((task) => task.projectId === projectId);
if (left.length === 0) {
tx.projects.delete(projectId);
}
});sync.transaction returns what the callback returns. A transaction that writes nothing sends
nothing and adds no undo step.
Options
sync.transaction(
(tx) => {
tx.tasks.update(taskId, { done: true });
},
{ undoable: false, name: 'tasks.complete' },
);| Option | Default | What it does |
|---|---|---|
undoable | true | false leaves it out of the undo history |
name | transaction.v<n> | What the server sees, for server hooks that tell writes apart |
Single writes are named after their table and operation, with the schema version: tasks.create.v1,
tasks.update.v1, tasks.delete.v1. An undo is sent as the name of what it reverts plus .undo
(tasks.update.v1.undo), and a redo under the original name.
Undo and redo
sync.tasks.update(taskId, { done: true });
sync.canUndo(); // true
sync.undo(); // true: the task is not done any more
sync.canRedo(); // true
sync.redo(); // true: done again| Call | Returns | What it does |
|---|---|---|
undo() | boolean | Reverts the last undoable write; false when there is none |
redo() | boolean | Applies again the last undone write |
canUndo() | boolean | Whether undo() has something to revert |
canRedo() | boolean | Whether redo() has something to apply |
- Every write is undoable by default: a
create, anupdate, adeleteor a transaction is one step. Pass{ undoable: false }for writes the user did not make (a migration, a sync of settings). - An undo is a new write. It is stored, synced and seen by other devices like any other. It writes back the fields the step changed, as they were before it: other fields, and other users' later changes to other fields, stay. A later change to the same field from elsewhere is overwritten, as with any write: the last writer wins.
- A new write clears the redo stack, as in any editor.
- The history is per device, and survives a restart. It is kept in the client's storage next to the pending writes, so after the app restarts the user can still undo their recent steps, synced or not. It keeps the last 100 steps, undo and redo together.
- What leaves it: a write the server refuses; the steps of a space the user loses (undoing them
would write to a space they can no longer see); everything on
connectandreset, and after an update that changes the schema version. - In React,
useUndogives the same calls pluscanUndoandcanRedothat re-render when they change.
Options
import { createSync } from 'ferrysync';
import { schema } from './schema';
export const sync = createSync({
schema,
undo: { limit: 50, persist: true },
});| Option | Default | What it does |
|---|---|---|
limit | 100 | Most steps remembered, undo and redo together; the oldest are forgotten first. 0: no undo |
persist | true | Keeps the history in storage. false keeps it in memory, gone after a restart |
Each step stores what it changed, so the rows a user deleted stay on the device, in the history,
until the step is forgotten. Pass persist: false when that matters more than undo after a restart:
it also clears what an earlier run stored.
Gotchas
-
The callback must be synchronous. An
asynccallback throws aTypeErrorand writes nothing. Await what you need first, then write:const weather = await fetchWeather(); sync.transaction((tx) => \{ tx.projects.create(\{ name: `Japan (${weather})` \}); \}); -
Write through
tx, notsync.sync.tasks.createinside a transaction throws: it would escape the transaction. -
Do not keep
tx. Using it after the callback returned throws. -
Undo skips what it cannot revert. If a row changed so that part of a step cannot be reverted (the task was deleted on another device), that part is skipped. A step left with nothing to revert is skipped whole, and
undo()goes on to the step before it, so a click always reverts something while anything is left: it returnsfalseonly when the history is empty.redo()does the same. -
Undo never brings back what another device deleted. Undoing an edit of a row that is gone does nothing. Undoing the user's own delete restores the row, on the server too.
Queries
A query reads the rows of one table that match, in order, and stays up to date: ask it for its result at any time, or subscribe to hear when the result changes.
Status and offline
Ferry is offline-first: the app reads and writes the device's data whether or not there is a network, and syncs when it can. sync.status says where syncing is, for the few places the UI cares.