Ferry
Client

Transactions and undo

A transaction groups writes: they apply together, sync together, and undo together.

Client
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 tasks

What a transaction gives you

  • All or nothing. If the callback throws (a ValidationError from 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> has create, update, delete, get and all(), and its reads include what the transaction wrote so far.
Client
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

Client
sync.transaction(
  (tx) => {
    tx.tasks.update(taskId, { done: true });
  },
  { undoable: false, name: 'tasks.complete' },
);
OptionDefaultWhat it does
undoabletruefalse leaves it out of the undo history
nametransaction.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

Client
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
CallReturnsWhat it does
undo()booleanReverts the last undoable write; false when there is none
redo()booleanApplies again the last undone write
canUndo()booleanWhether undo() has something to revert
canRedo()booleanWhether redo() has something to apply
  • Every write is undoable by default: a create, an update, a delete or 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 connect and reset, and after an update that changes the schema version.
  • In React, useUndo gives the same calls plus canUndo and canRedo that re-render when they change.

Options

Client
import { createSync } from 'ferrysync';

import { schema } from './schema';

export const sync = createSync({
  schema,
  undo: { limit: 50, persist: true },
});
OptionDefaultWhat it does
limit100Most steps remembered, undo and redo together; the oldest are forgotten first. 0: no undo
persisttrueKeeps 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 async callback throws a TypeError and 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, not sync. sync.tasks.create inside 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 returns false only 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.

On this page