Persistence
SideDoc stores nothing on a server. There are two storage layers:
- The working copy in the browser's IndexedDB. Every edit is written there through the active workspace.
- 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) throughuseStoragePersistenceControls.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, storeworkspace(one workspace snapshot) and storedocuments(keyed by documentid); 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):
| Member | Meaning |
|---|---|
id, label | Provider 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, forautoanddaily, 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
| Id | File | Storage | Notes |
|---|---|---|---|
google-drive | google-drive-persistence-provider.ts | Drive 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-compatible | s3-persistence-provider.ts | Objects 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-fs | local-fs-persistence-provider.ts | A folder the user picks, files ending in .datasheet.json | File System Access API (showDirectoryPicker), so Chromium-based browsers only. The directory handle is kept in IndexedDB. |
tauri-local | tauri-local-persistence-provider.ts | Local disk | Only 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_OPTIONSlists 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 setsmigrateFrom, and the dialog offerscopyBackupsFromPrevious(). - Automatic backups. A timer runs
copyIfChanged()everyAUTO_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 adailysnapshot when a day has passed since the last one (time kept inlocalStorageundersidedoc-autosave-daily-at), otherwise anautoone. The two switches are stored undersidedoc-autosave; both default to on. - Restore.
loadSnapshot()runs the loaded documents throughprepareDocumentsForLoad(src/config/documentLoad.ts): documents from an older datamodel are migrated, documents without a safe migration are kept read-only, andloadNoticereports both counts. - S3 settings and secret. Endpoint, bucket, region, key id and path style are kept in
localStorageundersidedoc-s3-settings. The secret never is.security/s3SecretVault.tsencrypts 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 as127.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
- Write
services/persistence/<name>-persistence-provider.tswith a class implementingWorkspacePersistenceProvider, pluscreate<Name>Providerandcreate<Name>PersistenceServicefactories in the style of the existing ones. Name saved files withbuildSnapshotStemfromsnapshot-retention.ts, so that retention, the kind labels in the restore list and copying between stores work. - Write
<name>-persistence-env.tswithcreate<Name>PersistenceServiceFromEnv(env, overrides). - Add the id to
PERSISTENCE_PROVIDER_IDSand register the provider in thePersistenceUiControllerconstructor. - Export the new symbols from
services/index.tswith TSDoc comments, so they show up in the API reference. - Add unit tests under
__tests__/unit/services/persistence/with a mockedfetchor browser API. - To offer it in the standalone editor, add it to
EXTERNAL_STORAGE_OPTIONSinuseStandaloneStorage.ts, and add its settings todocker/settings.json(then runnpm run docs:settings). - If the provider talks to a new origin, document the CSP sources it needs.