Search selfstore
v1.8.21

API: backups manager

Several named backups on one destination: a personal one, ones the user created, ones other people share with them. This is the headless engine; <selfstore-backups> is its panel.

You need it when a destination holds more than one backup file. A store with a single durable home does not.

import { createBackupsManager } from 'selfstore/backups';

const manager = createBackupsManager({
  store: store.advanced,
  kv: store.flowHost.kv,
  host: myDriveHost,
  naming: { canonicalName: 'my-app.zip' }
});
await manager.hydrate();

createBackupsManager

function createBackupsManager(deps: {
  store: LocalStore;
  kv: KV;
  host: BackupsHost;
  naming: BackupsNaming;
  keys?: BackupsKeys;
}): BackupsManager;

Drive, without implementing anything

driveBackupsHost is a ready-made BackupsHost for Google Drive, so an app storing there writes none of the contract below:

import { driveBackupsHost } from 'selfstore/backups';

const host = driveBackupsHost({ auth, kv, fileName: 'acme.zip', nameContains: 'acme' });

DriveBackupsHostOptions extends the usual DriveOptions with one field: nameContains, which narrows the listing server-side. selfstore ships no naming convention, so without it every file the app can see is a candidate.

Implement the contract below only for a destination selfstore does not ship.

BackupsHost: what you implement

The manager knows nothing about your destination. It asks this contract for everything, so the same panel drives Drive, a WebDAV folder or your own service.

Member Signature Required
kind string yes
activeIdKey string yes - the KV key holding the active file id
list () => Promise<BackupFileInfo[]> yes
open (fileId: string) => BackupTarget yes
create (fileName: string) => Promise<{ fileId: string }> yes
remove (fileId: string) => Promise<void> yes
findOrCreatePersonal () => Promise<{ fileId, created } | null> yes
rename (fileId, fileName) => Promise<void> optional
ensureSession () => Promise<boolean> optional
fileOwner (fileId) => Promise<{ email, name } | null> optional
excludedFileIds () => Promise<(string | null | undefined)[]> optional

The optional ones are capabilities, not niceties: a host without rename simply has no rename action, rather than a button that fails.

interface BackupFileInfo {
  id: string;
  name: string;
  modifiedTime: string | null;
}

BackupsNaming

interface BackupsNaming {
  canonicalName: string;                          // the personal backup's file name
  namedFileFor?(label: string): string;           // label -> file name
  parseLabel?(name: string): string | null | undefined;  // file name -> label
}

Supply the pair when your users name their backups. Without them every backup uses the canonical name and only one exists.

BackupsKeys

interface BackupsKeys {
  registry?: string;
  encrypted?: string;
  shared?: string;
  joined?: string;
  owner?: string;
}

The KV keys the manager bookkeeps under. Override them only when two managers share one KV.

BackupsSnapshot

interface BackupsSnapshot {
  registry: KnownBackups;
  activeFileId: string | null;
  joined: boolean;
  owner: BackupOwner | null;
  lastError: BackupsErrorCode | null;
}

interface KnownBackups {
  personalFileId: string | null;
  shared: { fileId: string; ownerEmail: string | null; ownerName: string | null }[];
}

type BackupsErrorCode = 'cancelled' | 'gone' | 'failed';

'cancelled' is a user gesture, not a failure - a picker they closed. Treat it as nothing happened.

BackupRow

What list() returns, and what a panel renders:

interface BackupRow {
  fileId: string;
  name: string;
  label: string | null;
  modifiedAt: number | null;
  encrypted: boolean | null;
  shared: boolean | null;
}

encrypted and shared are three-state. null means not yet known: the listing is cheap, the encryption probe is not, so a row appears immediately and learns what it is afterwards. Render null as “unknown”, never as “no”.

Methods

Method Signature Notes
hydrate () => Promise<void> Load the registry. Call once at boot.
refresh () => Promise<void> Re-read from the destination.
list () => Promise<BackupRow[]>
markActive (fileId: string) => Promise<void>
probeEncryption (fileId: string) => Promise<boolean | null> Cheap header read; no decrypt.
noteShared (fileId: string, shared: boolean) => Promise<void> Record what your app knows.
openBackup (fileId, passphrase?) => Promise<'ok' | 'encrypted' | 'failed'> 'encrypted' means ask for a password and call again.
openPersonal (passphrase?) => Promise<'ok' | 'encrypted' | 'failed'>
createNamed (label: string) => Promise<'ok' | 'failed'>
createShared (fileName: string, owner: BackupOwner) => Promise<'ok' | 'failed'>
registerShared (fileId: string, owner: BackupOwner) => Promise<void>
renameBackup (fileId: string, label: string) => Promise<'ok' | 'failed'>
deleteBackup (fileId: string) => Promise<boolean>
forgetShared (fileId: string) => Promise<void> Drop it locally; the file stays.
fileNameFor (label: string) => string

The three-way return of openBackup is the shape worth copying: 'encrypted' is not an error, it is the journey asking for the next input. A boolean would force you to inspect an error code to tell “wrong password” from “needs one”.

The manager is a FlowStore<BackupsSnapshot>, so snapshot and subscribe work exactly as in flows.

Forgetting is not deleting

forgetShared drops a row from this device’s registry. deleteBackup removes the file. The panel keeps them as separate gestures with separate confirmations, because one is reversible by re-adding the share and the other is not.