Ferry
Server

Deleting users

When someone deletes their account, their data has to go: the law says so in many places (the GDPR's right to erasure, for one), and so does their trust. deleteUser removes everything sync keeps of one user, in one transaction:

Server
import { syncServer } from './sync-server';

const deleted = await syncServer.deleteUser(userId);
// { found: true, records: [{ table: 'notes', id: 'n1' }], spaces: ['project:p1'], emptySpaces: [] }

What it deletes, and what it keeps

A user's data lives in two kinds of places. Their private space holds the rows of tables that have no space: their own notes, their settings. Shared spaces hold what they work on with others: a project's tasks, a team's documents.

Deleted, for goodKept
Every row of their private space, deleted ones included, and its rows in your mirror tablesRows of shared spaces, whoever wrote them: they belong to the space, and the other members still use them
The history of their private space: the changes only they could downloadThe history of shared spaces, until retention drops it (90 days by default)
Their memberships: they leave every shared spaceSpaces they were the last member of, with their rows (the result lists them, so you can delete them)
Their devices: the row each app install has on the server, and the answers stored for its changes
Which device made each change in a shared space: the history keeps the change, without its author

Nothing in Ferry's own tables names the user afterwards. Your rows may still do it, in fields of your own: an assigneeId, an author's name. Ferry cannot tell those from other data, so they are yours to clear (see Gotchas).

Note

Your privacy policy can say something like this, adapted to your retention and your fields, and checked by whoever reviews your legal texts: "When you delete your account, we erase your private data and every record of your devices from our sync service right away. What you added to projects shared with others stays there for them, without a link to your account, except where the content itself names you. The history used to bring devices up to date keeps earlier versions of shared content for up to 90 days."

Delete an account, step by step

  1. Stop letting the user in. Delete the account in your auth system, or revoke its sessions, so that auth answers null for their requests. Do this first: the server keeps nothing of a deleted user, so it cannot tell them from a new one, and a device it still let in would write their data again.

    You should see: their devices get a 401 on their next request.

  2. Delete their sync data with syncServer.deleteUser(userId).

    You should see: { found: true, … }. Another user who shared a space with them sees no change at all.

  3. Delete your copies. records lists the private rows it erased, by table and id: delete the files, search entries or caches you keep of them. emptySpaces lists the shared spaces left with no member: delete their rows with write, or give them to someone with spaces.addMember.

  4. Delete again a minute later. A request that got past auth just before step 1 may still save a change after step 2. A second deleteUser erases it. It is safe to run any number of times.

    You should see: found: false, unless such a change slipped in.

  5. Keep the deleted user ids (only the ids) in your own records, and delete them again after restoring a backup: a backup from before step 2 still holds their data.

This is all of it, as one function your account-deletion endpoint calls:

delete-account.tsServer
import { syncServer } from './sync-server';

export const deleteAccount = async (userId: string) => {
  // 1. Your auth lets them in no more.
  await revokeSessions(userId);

  // 2. Their sync data.
  const deleted = await syncServer.deleteUser(userId);

  // 3. What you keep of their rows elsewhere, and the spaces nobody is left in.
  for (const { table, id } of deleted.records) {
    await deleteUploads(table, id);
  }
  for (const space of deleted.emptySpaces) {
    const projectId = space.slice('project:'.length);
    await syncServer.write({ as: 'system' }, async (tx) => {
      for (const id of await taskIdsOf(projectId)) {
        await tx.tasks.delete(id);
      }
      await tx.projects.delete(projectId);
    });
  }

  // 4. Once more, after the requests already in flight have ended.
  runLater(() => syncServer.deleteUser(userId), { inMs: 60_000 });

  // 5. For restores.
  await rememberDeletedUser(userId);
};

On the user's devices

The devices of a deleted user still hold their data, and may hold changes they never sent. The server does not wipe them: your app does, when it signs the user out.

Alice's phoneServer, in your backend
  1. Your backend: Alice's account and sessions are gone
  2. deleteUser('alice'): her private rows, spaces and devices erased
  3. push as alice: 7 add task
  4. 401 unauthenticated
  5. onUnauthorized: the app signs out, sync.reset(local())
  6. A minute later, deleteUser('alice') again: found: false
Bob, who shared a project with Alice, sees no change: the rows she added there stay, and nothing new reaches his devices.
  1. The device sends a change, or asks for news, as usual.
  2. auth refuses it: the server answers 401, as for any expired session.
  3. The client calls your onUnauthorized, where the app signs out: reset wipes the device's rows, its unsent changes and its local-only data.
Client
import { createSync, http, local } from 'ferrysync';

import { schema } from './schema';

export const sync = createSync({
  schema,
  backend: http({
    url: 'https://api.example.com/sync',
    headers: async () => ({ authorization: `Bearer ${await getToken()}` }),
    // A deleted account, or a session that cannot be refreshed: sign out and wipe the device.
    onUnauthorized: () => {
      void sync.reset(local());
    },
  }),
});

Without a sign-out, the client keeps retrying with backoff, and its status shows the error code unauthenticated: it never writes anything again, but the user's data stays on the device.

On everyone else's devices

Nothing changes. What deleteUser erases was visible to the user alone, so no other device has anything new to download, and every device stays where it was in the log. Rows the user added to shared spaces stay, and keep syncing like any other row.

Inside your own transaction

Pass within (your database transaction) to delete your own rows of the user and their sync data together: both commit, or neither does.

Server
import { createSyncServer, nodePostgres, postgresStore } from 'ferrysync/server';
import { Pool } from 'pg';

import { schema } from './schema';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const syncServer = createSyncServer({
  schema,
  store: postgresStore(nodePostgres(pool)),
  auth: async (request) => (await getSession(request))?.userId ?? null,
});

const client = await pool.connect();
try {
  await client.query('BEGIN');
  await client.query('DELETE FROM accounts WHERE id = $1', [userId]);
  await syncServer.deleteUser(userId, { within: client });
  await client.query('COMMIT');
} catch (error) {
  await client.query('ROLLBACK');
  throw error;
} finally {
  client.release();
}

The result

FieldWhat it holds
foundfalse when the server held nothing of the user: one it never saw, or one already deleted
recordsThe rows of their private space it erased, deleted ones included: { table, id }, sorted
spacesThe shared spaces they were a member of, sorted
emptySpacesThose of spaces with no member left: nobody syncs their rows any more
OptionDefaultWhat it does
withinnoneYour open database transaction: the deletion commits or rolls back with it

It throws a TypeError for an empty user id, and on a store that cannot delete a user (a store of your own without deleteUser), and fails like any write when the database does. Either way nothing is deleted.

Gotchas

  • auth must refuse a deleted user. A token checked only by its signature (a JWT from Supabase, Firebase, Clerk…) stays valid until it expires, often an hour. Check that the account still exists, or that the token was not revoked, as Firebase's verifyIdToken(token, true) does. A device let in after the deletion is a new user to the server: what it sends is saved.
  • A request already past auth when you delete can still save a change after it. That is what step 4 is for: run deleteUser again once your requests have ended (a minute is plenty).
  • No hook runs: no validate, onWrite or afterCommit, and no device is told. A hook that sends a notification or an email when a row is deleted would be wrong here. Use records for your own copies.
  • Fields of shared rows that name the user stay as they are: an assigneeId, a createdBy, a name in a comment. Clear them with write before deleting (in the same transaction, with within). Their earlier values stay in the space's history until retention drops it, and a row deleted from a shared space keeps its last values, so clear them before deleting such rows.
  • Spaces they were the last member of keep their rows, with nobody syncing them. Until you delete them, a claimSpace that accepts such a space would let its next writer found it again, rows included.
  • Your own tables (not mirrors) are yours to delete: do it in the same transaction, with within. Mirror rows of the private space are deleted for you, even when the mirror marks deletes with deletedAt, and a foreign key from another table of yours to one of them fails the whole deletion.
  • Restoring a backup taken before the deletion brings the user back. After afterRestore, delete again every user deleted since the backup.
  • Deleting data but keeping the account is not what it is for: the user's devices would keep their copies and could send them again. Sign them out (reset) as well.
  • It scans two tables (the devices and the log), which have no index by user: fine for an occasional deletion, even on a large log, but not something to run in a loop over every user.
  • A store of your own deletes users only if it has deleteUser (see your own database); without it, deleteUser throws and nothing is deleted.

On this page