Data Transfer - the lolly-backup bundle

Everything a Lolly user accumulates lives on their device - no account, no cloud. The data-transfer bundle is how that value moves: export it on one install, carry the file by any means (USB, AirDrop, email-to-self, a network share) and import it on another. The file is the transport. The target can be offline or online. It makes no difference, because nothing ever talks to a server.

The two buttons that move a whole install: Export my data writes one zip, Import data reads it backsigned by Lollyvector SVGCheck it yourselfGet the signed file12 paths~5.1k nodes12 groups60 KBThe two buttons that move a whole install: Export my data writes one zip, Import data reads it backsigned by Lollyvector SVGCheck it yourselfGet the signed file12 paths~5.1k nodes12 groups60 KB

This page is the format spec. For the end-user walkthrough see Using Lolly → Moving to another device. The implementation is shells/web/src/data-transfer.ts, and tests/data-transfer.test.ts pins the round-trip contract.

Scope. A bundle carries user data, not tools. Tools and catalog assets are synced separately and are assumed to already be present on the target (worst case at a higher version). Importing never installs or upgrades a tool.

Goals

The envelope

A bundle is a plain .zip. The download is named for the person it belongs to - LollyTools-<First>-<Last>-<YYYY-MM-DD>-<n>.zip (for example LollyTools-Ada-Lovelace-2026-06-26-1.zip) - so a Downloads folder of backups stays legible. The first and last parts come from the profile and are omitted when unset. No profile gives LollyTools-2026-06-26-1.zip, and a first name alone gives LollyTools-Ada-2026-06-26-1.zip. Each part is sanitised to a filename-safe token (Unicode letters/digits kept, spaces/punctuation stripped, capped at 32 chars). <n> is a per-day, per-device sequence, so repeat exports on the same day do not collide and stay in order. backupFilename() in shells/web/src/data-transfer.ts builds the name. The zip's contents are identical regardless of name. Inside:

PathRequiredContents
manifest.jsonyesFormat id, versions, counts and per-part integrity. The first thing a reader looks at.
profile.jsonwhen setThe user's me record (name, contact, headshot ref, flags). Read via host.profile.
sessions.jsonyesEvery saved session: slot, tool id/version, label, thumbnail (data-URL) and full input data. Read via host.state.
assets.jsonyesMetadata for each uploaded asset (images, fonts, brand tokens), each pointing at its bytes under assets/blobs/.
assets/blobs/<n>.<ext>per assetThe raw asset bytes (image and font files). Stored uncompressed (already-compressed formats). The extension is cosmetic. The MIME in assets.json is authoritative.
assets/blobs/<n>.c2pawhen presentExtracted Content Credentials as exact binary bytes, referenced by _credentialFile in the asset record. These are not device signing keys.
file-history.jsonoptionalVersioned asset snapshots, terminal file-operation reports and complete batch manifests. The history part has its own version; provided by the shell's internal fileHistory backup adapter.
revision-history.jsonoptional, manual backupsStable creation IDs, retained checkpoints, thumbnails and rolling recovery drafts. Provided by host.state.history.backup where supported.
file-history/versions/per snapshotPrevious asset bytes and extracted credentials, independent of whether the current asset still exists.
file-history/results/per completed operationExact output bytes. No original selected-for-conversion file is retained or included.
prefs.jsonyesUser-owned local preferences: theme, sidebarWidth and the ct-metrics activity tally.
lolly.txtyesA human-readable summary of the bundle (counts, profile, filename) for anyone who opens the zip without Lolly. Regenerated on every export and recognised on import, so it never counts as a skipped part. It is written after the integrity map, so it stays outside it.

The bundle is a plain zip on purpose: it survives any transport intact, and any unzip tool can inspect it.

profile.json is the smallest part and the one a reader sees first in the app: the details a producer fills in once, plus the opt-in that lets tools use them.

The Profile details form that becomes profile.json - name, contact, headshot and the opt-in beside themsigned by Lollyvector SVGCheck it yourselfGet the signed file18 paths~2.0k nodes41 groups30 KBThe Profile details form that becomes profile.json - name, contact, headshot and the opt-in beside themsigned by Lollyvector SVGCheck it yourselfGet the signed file18 paths~2.0k nodes41 groups30 KB

manifest.json

{
  "format": "lolly-backup",
  "formatVersion": 3,
  "minReader": 1,
  "app": "lolly",
  "exportedAt": "2026-06-22T09:30:00.000Z",
  "counts": { "profile": true, "sessions": 2, "userAssets": 4, "prefs": 3, "assetVersions": 1, "fileOperations": 1 },
  "integrity": {
    "profile.json": "sha256-…",
    "sessions.json": "sha256-…",
    "assets.json": "sha256-…",
    "assets/blobs/0.bin": "sha256-…",
    "file-history.json": "sha256-…",
    "file-history/versions/0.bin": "sha256-…",
    "file-history/results/0.bin": "sha256-…",
    "prefs.json": "sha256-…"
  }
}
FieldMeaning
formatAlways lolly-backup. A file without it is rejected as "not a Lolly backup".
formatVersionThe layout this bundle was written with. Bumped on any change to the part set or shapes. Readers do not gate on it.
minReaderThe minimum reader version required to import this bundle safely. This is the field readers gate on.
appProducing app id, for diagnostics.
exportedAtISO timestamp the bundle was created.
countsWhat the writer put in, for display and sanity-checking.
integrityOptional. Maps every part except manifest.json to an SRI-style sha256-<base64> digest of its uncompressed bytes.

Version policy (forward compatibility)

The split between formatVersion and minReader is what lets the format grow without orphaning older installs:

Rule of thumb for authors: if every existing reader would still do the right thing by ignoring your addition, it is additive - bump formatVersion, leave minReader. Otherwise raise minReader.

Integrity

When manifest.integrity is present, a reader verifies each listed part's SHA-256 before writing anything. A mismatch ("failed its integrity check") or a missing part ("incomplete") aborts the whole import - there is no partial restore. This catches the corruption a file transport can introduce (a truncated AirDrop, an email gateway that re-encoded the attachment, a bad USB sector).

Integrity is best-effort by design: it is written only where Web Crypto is available (every secure browser context and modern Node), and verified only when both the map and Web Crypto are present. A bundle without the map - for example one from before integrity existed - imports unchanged. "Cannot verify" is never treated as "corrupt".

The manifest lists neither itself nor the regenerated lolly.txt README. The digests cover the parts the manifest vouches for.

Import semantics

Import is merge-overwrite, never replace-all:

Saved sessions re-link to their images automatically: asset references are kept by id, and the bridge re-resolves them after the uploaded images are restored (it must anyway, because blob: URLs do not survive a reload).

The import summary reports { profile, sessions, userAssets, prefs, skipped, failedAssets }. failedAssets counts uploaded assets that could not be restored (device storage full, say). It is distinct from skipped, which counts parts from a forward-compatible newer writer that this build did not recognise. The UI surfaces skipped ("… · N newer items skipped"), so the restore is honest about what it left behind.

When file history is present, the summary also carries assetVersions, fileOperations and failedHistory. Storage exhaustion or immutable-ID conflicts can cause a partial restore; the UI tells the user to retain the source backup. Cloud sync does not advance its applied revision after a partial or unsupported restore, so the snapshot remains available for retry. Restore is not a single transaction across all profile/session/asset/history stores.

Creation history (v3)

Manual backups from a history-capable web host include revision-history.json with its own { version: 1, documents, revisions, recoveries } schema. It carries the retained IDs, canonical input snapshots, version stamps, raster previews and separate writer drafts. The history adapter captures current sessions and their heads in one read transaction; sessions.json uses those same current snapshots for older readers.

Restore checks payload SHA-256 and byte counts, unique identities, document/head relationships, ancestry, timestamps, preview types and limits before committing the archive in one transaction. Compacted parent references may be absent. Existing current work must match the imported document; conflicts are refused rather than silently replacing it. The archive's 384 MiB transfer limit is checked explicitly, and storage limits are enforced without truncating retained checkpoints. The overall backup still uses an in-memory ZIP implementation and is not a streaming archive.

The summary adds revisions and recoveryDrafts. A shell without this capability restores ordinary sessions and reports the history part as skipped. Native filesystem history remains unsupported until its adapter supplies durable history transactions. P2P guest state has no durable history or recovery archive.

Personal snapshot sync explicitly excludes creation history. Applying a snapshot to a history-bearing local document preserves its previous working state as a separate recovery draft and invalidates any open editor's write token. Its immutable checkpoints remain on the device. This protects local history during snapshot replacement; it does not merge concurrent device histories.

Historical asset references are retained, while rendering still resolves assets through the destination's existing library. This archive does not yet guarantee exact old asset bytes or old tool renders. Asset-version and file-result bytes continue to travel through their existing separate backup part.

Saved versions and file results (v2)

The optional history part contains { version: 2, assetVersions: [...], operations: [...], batches: [...] }; readers also accept the earlier history-v1 shape without batches. Each snapshot identifies the stable asset ID and exact version, its save time, byte length and hex SHA-256, plus an asset record whose _file and optional _credentialFile point to binary parts. Operations carry the original file facts, request, report, timestamps and optional result _file; storage backend names, OPFS handles and execution leases do not travel. Older history-v1-only readers refuse the new history version before importing, rather than silently dropping batch membership.

Batch manifests record every selected source before processing, including files never read, cancelled members, failures to reserve result space and interrupted work. Each member has a stable operation ID, source reference/facts, requested output name and terminal report. An unread source has declared facts, not an invented digest. Import validates member identity and consistency with any carried operation report. Batch reports remain available when individual results have been explicitly removed, but a receipt does not imply that its output bytes are still stored.

Older readers still accept the v2 envelope (minReader: 1) and restore familiar parts, counting unsupported history parts as skipped. Full history recovery requires a shell with the fileHistory adapter; this is a shell-internal seam, not a new tool-facing HostV1 capability. Real two-device restore is covered by the local Chromium gate; installed Tauri/iOS/Android recovery acceptance remains separate.

What does not travel

The storage meter itemises the same split. Saved sessions, My images and File results & versions ride in a bundle. The asset cache, tool previews and offline pins below them are all re-derivable, so they stay behind.

The storage meter breaking this device's data into named categories, with Saved sessions and My images tracked separately from the Asset cache, here on a fresh install where every category is still emptysigned by Lollyvector SVGCheck it yourselfGet the signed file25 paths~4.8k nodes46 groups59 KBThe storage meter breaking this device's data into named categories, with Saved sessions and My images tracked separately from the Asset cache, here on a fresh install where every category is still emptysigned by Lollyvector SVGCheck it yourselfGet the signed file25 paths~4.8k nodes46 groups59 KB

Cross-shell guarantee

data-transfer.ts reads and writes exclusively through the capability bridge (host.profile, host.state, host.assets) and the shared localStorage prefs. The same module reads and writes the common envelope on web and Tauri, over IndexedDB or filesystem storage. Optional history parts appear only where the corresponding adapter is available; an unsupported part is reported as skipped on import. The headless suite exercises the common parts against an in-memory bridge, while history transactions also have real-browser tests.

Two shells sit outside that guarantee, for different reasons:

Reserved extension points

The envelope is a manifest plus a set of named parts by design, so new kinds of portable data can ride it later without a breaking change. They slot in as additive parts (new formatVersion, same minReader), and today's reader skips what it does not recognise. These are on the roadmap, not yet implemented. The names are reserved here so the format stays coherent when they land.

Anything outside these reserved names and the parts above is, to a reader, an unknown part: left untouched and counted in skipped.

Reference