Skip to content

Persistence ​

SideDoc stores nothing on a server. There are two storage layers:

  1. The working copy in the browser's IndexedDB. Every edit is written there through the active workspace.
  2. Snapshot providers that save and load whole-workspace snapshots ("backups" in the UI) to storage the user controls: Google Drive, an S3-compatible bucket, a local folder, or the local disk in a Tauri desktop build. The standalone editor wires them up in src/standalone/composables/useStandaloneStorage.ts (see The standalone editor's backups); the demo app (index.html, BackendDemo.vue) through useStoragePersistenceControls.ts.

Operator-facing configuration (environment variables, OAuth clients, CORS, CSP) is in the deployment guide: Where documents are stored and Configuration. This page covers the code.

IndexedDB ​

src/lib/infrastructure/IndexedDBServices.ts implements the domain's WorkspaceService and DocumentService contracts:

  • database sidedoc, store workspace (one workspace snapshot) and store documents (keyed by document id);
  • IndexedDBWorkspaceService.getWorkspace() loads the snapshot and its documents in one read transaction; updateWorkspace(snapshot) writes both in one transaction;
  • clearIndexedDB() deletes everything (the "reset" action).

The Workspace entity calls updateWorkspace after each change (see Active entities). Tests use InMemoryWorkspaceService and InMemoryDocumentService with the same contracts.

The IndexedDB copy is tied to one browser profile and one origin. When you change how documents are stored, keep in mind that existing users have data in the old shape: the standalone controller re-normalizes stored documents against the current schema on load and warns when the stored workspace was made with an older schema bundle.

Snapshot providers ​

All providers implement WorkspacePersistenceProvider (services/persistence/workspace-persistence-service.ts):

MemberMeaning
id, labelProvider id (see PERSISTENCE_PROVIDER_IDS) and display name.
getCapabilities()Flags: requiresAuth, supportsListing, supportsDelete, supportsDirectorySelection, supportsConflictDetection, supportsRealtime.
getConnectionState(){ connected, status?, lastError? }.
connect(options?), disconnect()Sign in or connect; options are provider specific.
tryRestoreConnection()Reuse a stored session without user interaction.
listSnapshots(), saveSnapshot(payload), loadSnapshot(id?), deleteSnapshot?(id)Snapshot I/O. Without an id, loadSnapshot loads the default or latest snapshot.

WorkspacePersistenceService is a registry with one active provider. It checks that a registered provider has all required methods and forwards calls to the active one. listSnapshotsOf(id) and loadSnapshotOf(id, snapshotId) read from a provider that is not active, for copying backups between stores.

PersistenceUiController (persistence-ui-controller.ts) is the piece a UI uses. It creates every provider from an env object (create*PersistenceServiceFromEnv), registers them, keeps the status text and snapshot list for the UI (getViewModel()), and connects the snapshot calls to the host app through callbacks: getWorkspaceSnapshot, hydrateWorkspaceSnapshot (writes a loaded snapshot into IndexedDB), loadPersistedData and beforeWorkspaceHydrate.

  • saveWorkspaceSnapshot(kind = 'manual') writes { workspace, kind, savedAt } and, for auto and daily, prunes older snapshots of the same kind (see Naming and retention).
  • loadWorkspaceSnapshot() loads the selected snapshot, or the latest.
  • copyBackupsFrom(providerId) copies every snapshot of another registered provider into the active one, oldest first, skipping snapshots the target already has (same workspace, kind and time). Nothing is deleted from the source.

Naming and retention ​

snapshot-retention.ts is shared by the Drive, S3 and local-folder providers. Every save writes a new file, <prefix><workspace>--<kind>--<UTC stamp><extension> (stamp 20261022T153005Z, taken from the payload's savedAt so copied backups keep their time). The provider owns prefix and extension. After an auto or daily save, selectSnapshotsToPrune returns the snapshots of the same workspace and kind beyond SNAPSHOT_RETENTION (auto 20, daily 14); manual snapshots are never pruned. Files written before this naming have no kind and are never pruned either.

The providers ​

IdFileStorageNotes
google-drivegoogle-drive-persistence-provider.tsDrive app data folder, prefix sidedoc-workspace-OAuth scope drive.appdata. Google Identity Services token client in the browser; desktop flow with a separate client id under Tauri.
s3-compatibles3-persistence-provider.tsObjects with prefix sidedoc-workspace-Requests signed with AWS Signature V4 (aws4fetch). Keys are held in memory, or in sessionStorage if persistCredentials is on. Needs CORS on the bucket.
local-fslocal-fs-persistence-provider.tsA folder the user picks, files ending in .datasheet.jsonFile System Access API (showDirectoryPicker), so Chromium-based browsers only. The directory handle is kept in IndexedDB.
tauri-localtauri-local-persistence-provider.tsLocal diskOnly inside the Tauri desktop shell (uses its invoke bridge).

Each provider has a *-persistence-env.ts companion that reads its settings from an env record. The Drive settings read SIDEDOC_* keys first and fall back to VITE_*; S3 reads only SIDEDOC_*; the others read only VITE_*. Both apps pass import.meta.env, which Vite fills with VITE_* variables only, so the SIDEDOC_* names have no effect (see the deployment guide).

The standalone editor's backups ​

useStandaloneStorage.ts builds a PersistenceUiController for the standalone editor and adds what the Settings dialog (components/panels/SettingsDialog.vue) needs:

  • Choice of store. EXTERNAL_STORAGE_OPTIONS lists Google Drive, S3 and local folder; '' means browser storage only. The choice is not persisted, so after a reload the user connects again. Switching from one store to another sets migrateFrom, and the dialog offers copyBackupsFromPrevious().
  • Automatic backups. A timer runs copyIfChanged() every AUTO_INTERVAL_MS (5 minutes) while the component is mounted. It writes only when connected and when the workspace JSON differs from the last copy written or loaded. It writes a daily snapshot when a day has passed since the last one (time kept in localStorage under sidedoc-autosave-daily-at), otherwise an auto one. The two switches are stored under sidedoc-autosave; both default to on.
  • Restore. loadSnapshot() runs the loaded documents through prepareDocumentsForLoad (src/config/documentLoad.ts): documents from an older datamodel are migrated, documents without a safe migration are kept read-only, and loadNotice reports both counts.
  • S3 settings and secret. Endpoint, bucket, region, key id and path style are kept in localStorage under sidedoc-s3-settings. The secret never is. security/s3SecretVault.ts encrypts it with AES-GCM under a key derived (HKDF) from a passkey's WebAuthn PRF output and stores only the ciphertext, salt and credential id (sidedoc-s3-vault). The derived key is non-extractable and lives in memory for the tab, so the passkey is asked once per tab. Without PRF support (or on an IP address such as 127.0.0.1, which WebAuthn refuses) the secret is used for the tab only. After a successful connect the secret is cleared from the form state; the provider keeps its own copy to sign requests.

Adding a provider ​

  1. Write services/persistence/<name>-persistence-provider.ts with a class implementing WorkspacePersistenceProvider, plus create<Name>Provider and create<Name>PersistenceService factories in the style of the existing ones. Name saved files with buildSnapshotStem from snapshot-retention.ts, so that retention, the kind labels in the restore list and copying between stores work.
  2. Write <name>-persistence-env.ts with create<Name>PersistenceServiceFromEnv(env, overrides).
  3. Add the id to PERSISTENCE_PROVIDER_IDS and register the provider in the PersistenceUiController constructor.
  4. Export the new symbols from services/index.ts with TSDoc comments, so they show up in the API reference.
  5. Add unit tests under __tests__/unit/services/persistence/ with a mocked fetch or browser API.
  6. To offer it in the standalone editor, add it to EXTERNAL_STORAGE_OPTIONS in useStandaloneStorage.ts, and add its settings to docker/settings.json (then run npm run docs:settings).
  7. If the provider talks to a new origin, document the CSP sources it needs.