Skip to main content

Persistence

WIP

Resume uploads across reloads and crashes with IndexedDB, localStorage, or memory adapters.

Persistence snapshots resumable upload state — intent plus cursor plus progress — so a user can refresh the page (or survive a tab crash) and pick up where they left off. Only in-flight, resumable items are stored; terminal ones are not.

Adapters

All adapters implement UploadPersistence.Adapter and are imported from @gentleduck/upload/core:

import {
  LocalStorageAdapter,
  IndexedDBAdapter,
  MemoryAdapter,
  createMemoryAdapter,
} from '@gentleduck/upload/core'
AdapterStorageSync?Best for
IndexedDBAdapterIndexedDBasyncProduction; large/many uploads
LocalStorageAdapterlocalStoragesyncSmall apps, few uploads (~5 MB cap)
MemoryAdapterin-memory MapsyncTests, SSR

MemoryAdapter is a shared singleton; call createMemoryAdapter() for an isolated store per engine (important for multi-tenant setups).

The adapter interface is intentionally tiny:

type Adapter = {
  load(key: string): unknown | null | Promise<unknown | null>
  save(key: string, snapshot: unknown): void | Promise<void>
  clear(key: string): void | Promise<void>
}

Enabling persistence

Pass persistence in UploadPersistence.Options. The default deserializer needs the isPurpose and isIntent guards so it can safely validate untrusted stored data:

import { IndexedDBAdapter } from '@gentleduck/upload/core'

const store = createUploadStore<Intents, Cursors, Purpose, Result>({
  api,
  strategies,
  persistence: {
    key: 'uploads',
    version: 1,
    adapter: IndexedDBAdapter,
    debounceMs: 200,
    isPurpose: (v): v is Purpose => v === 'avatar' || v === 'document',
    isIntent: (v): v is Intents[keyof Intents] =>
      typeof v === 'object' && v !== null && 'strategy' in v && 'fileId' in v,
  },
})
OptionRequiredDescription
keyYesStorage key for the snapshot
versionYesSchema version; bump when intent/cursor shapes change
adapterYesThe UploadPersistence.Adapter to use
debounceMsNoDelay before writing (batches progress bursts)
isPurpose / isIntentWith default deserializerRuntime guards for untrusted data
serialize / deserializeNoCustom mappers (skip the guards if you own both)

What gets stored

The built-in serializer keeps only items that have an intent and are in a non-terminal phase. completed, canceled, and error items are excluded — persistence is for resuming, not history. For each item it stores the fingerprint, the intent, the cursor, and last-known progress. It never stores the browser File (see below).

Loading diagram...

Restore behavior

On construction the store loads the snapshot and hydrates state. Restored items come back in the paused phase with file: undefined — browser File objects can't be serialized. Progress and cursor are restored when present. Each item is only restored if isPurpose/isIntent pass, the intent's strategy is registered, and the cursor's strategy matches. Anything that fails a check is silently skipped, which keeps old snapshots from a previous app version safe.

Rebinding files

Because file is missing after restore, the user must re-select it before resuming. The rebind command computes the new file's fingerprint and compares it against the stored one — the match checks name, size, and lastModified (not type). On a match it attaches the file; on a mismatch it's a silent no-op (the item stays paused without a file).

store.dispatch({ type: 'rebind', localId, file: reselectedFile })
store.dispatch({ type: 'resume', localId })

rebind sets the file synchronously in the reducer, so to auto-resume, check the snapshot right after dispatching — resume is a no-op if the file wasn't attached:

store.dispatch({ type: 'rebind', localId, file: reselectedFile })
if (store.getSnapshot().items.get(localId)?.file) {
  store.dispatch({ type: 'resume', localId })
}

Clearing

To wipe persisted uploads, call the adapter's clear with your key:

await IndexedDBAdapter.clear('uploads')