## Goal

A user drops 20 photos, the tab crashes, they reopen — the uploads should still be there,
paused with progress intact. Add **persistence** so in-flight uploads survive reloads, and wire
up **rebind** so users can re-select files and resume.

## Choose an adapter

**Pick an adapter**

All adapters ship from `@gentleduck/upload/core` (there are no `/persistence/*` subpaths):

```typescript title="src/upload.ts"
import { IndexedDBAdapter, LocalStorageAdapter, MemoryAdapter } from '@gentleduck/upload/core'
```

| Adapter | Storage | Sync? | Best for |
| --- | --- | --- | --- |
| `IndexedDBAdapter` | IndexedDB | async | Production, large/many uploads |
| `LocalStorageAdapter` | `localStorage` | sync | Small apps (~5 MB cap) |
| `MemoryAdapter` | RAM | sync | Tests, SSR |

Each implements `UploadPersistence.Adapter`:

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

**Configure persistence**

The default deserializer reads untrusted storage, so it needs `isPurpose` and `isIntent`
guards:

```typescript title="src/upload.ts"
import { IndexedDBAdapter } from '@gentleduck/upload/core'

export const store = createUploadStore<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>({
  api,
  strategies,
  persistence: {
    key: 'photoduck-uploads',
    version: 1,
    adapter: IndexedDBAdapter,
    debounceMs: 200,
    isPurpose: (v): v is PhotoPurpose => v === 'photo',
    isIntent: (v): v is PhotoIntents[keyof PhotoIntents] =>
      typeof v === 'object' && v !== null && 'strategy' in v && 'fileId' in v,
  },
})
```

| Option | Required | Description |
| --- | --- | --- |
| `key` | Yes | Storage key |
| `version` | Yes | Schema version (bump on shape changes) |
| `adapter` | Yes | The `UploadPersistence.Adapter` |
| `debounceMs` | No | Delay before writing (batches progress bursts) |
| `isPurpose` / `isIntent` | With default deserializer | Guards for untrusted data |
| `serialize` / `deserialize` | No | Custom mappers |

**What restores**

On load, the store hydrates from the snapshot. Restored items come back **paused** with no
`File`:

```typescript
{
  phase: 'paused',
  localId: 'abc-123',
  purpose: 'photo',
  fingerprint: { name: 'sunset.jpg', size: 5_242_880, type: 'image/jpeg', lastModified: 1710000000000 },
  intent: { strategy: 'multipart', fileId: 'file-456', uploadId: 'upl-789', partSize: 5_242_880, partCount: 2 },
  // The cursor surfaces wrapped: { strategy, value: <raw cursor> }
  cursor: { strategy: 'multipart', value: { done: [{ partNumber: 1, etag: '"abc"', size: 5_242_880 }] } },
  progress: { uploadedBytes: 5_242_880, totalBytes: 10_485_760, pct: 50 },
  pausedAt: 1710000000000,
  createdAt: 1709999000000,
  file: undefined, // File objects can't be serialized
}
```

**Rebind to resume**

`File` is missing after restore, so the user re-selects it. `rebind` computes the new file's
fingerprint and compares `name`/`size`/`lastModified` against the stored one (not `type`). A
mismatch is a silent no-op — the item stays paused without a file.

```tsx title="src/RebindPrompt.tsx"
import { useUploaderActions } from '@gentleduck/upload/react'
import type { Engine } from '@gentleduck/upload/core'

type Item = Engine.Item<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>

function RebindPrompt({ item }: { item: Item }) {
  const { dispatch } = useUploaderActions<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>()
  if (item.phase !== 'paused' || item.file) return null

  return (
    <div>
      <strong>{item.fingerprint.name}</strong> was interrupted at{' '}
      {Math.round(item.progress.pct)}%. Re-select it to continue.
      <input
        type="file"
        onChange={(e) => {
          const file = e.target.files?.[0]
          if (file) dispatch({ type: 'rebind', localId: item.localId, file })
        }}
      />
    </div>
  )
}
```

`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):

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

**Prune stale items**

The serializer only persists items that have an intent and are non-terminal. `completed`,
`canceled`, and `error` items are excluded. Tune in-memory retention with config:

```typescript title="src/upload.ts"
config: {
  maxItems: 100,
  completedItemTTL: 60_000, // evict completed items after 60s
}
```

Clear everything manually via the adapter:

```typescript
await IndexedDBAdapter.clear('photoduck-uploads')
```

## What is (and isn't) persisted

The serializer produces a `UploadPersistence.Snapshot` of `UploadPersistence.PersistedItem`s:

```typescript
type Snapshot<M, C, P> = {
  version: number
  createdAt: number
  items: Record<string, PersistedItem<M, C, P>>
}

type PersistedItem<M, C, P> = {
  id: string        // localId
  purpose: P
  status: string    // phase at save time
  file: { name: string; size: number; type: string; lastModified: number; checksum?: string }
  intent: Contracts.Intent.Any<M>
  cursor?: Contracts.Cursor.Any<C>
  progress?: { uploadedBytes: number; totalBytes: number; pct?: number }
}
```

**Persisted:** localId, purpose, phase, fingerprint, intent, cursor, progress.
**Not persisted:** the `File`, in-flight network state, live timestamps (`pausedAt` is reset on
restore).

### Why File objects can't be serialized

A `File` is backed by an OS handle — it JSON-serializes to `{}`. The engine stores the
fingerprint so it can confirm the correct file on rebind.

### Deserialization checks

For each stored item the default deserializer: validates the snapshot shape, checks `isPurpose`,
checks `isIntent`, verifies the intent's `strategy` is registered, and verifies the cursor's
strategy matches. Anything that fails is silently skipped — safe for snapshots from older app
versions.

## Checkpoint

```tsx title="src/RestoredUploads.tsx"
import { useUploader } from '@gentleduck/upload/react'

export function RestoredUploads() {
  const { items, dispatch } = useUploader<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>()
  const needsRebind = items.filter((i) => i.phase === 'paused' && !i.file)
  const canResume = items.filter((i) => i.phase === 'paused' && i.file)

  return (
    <div>
      {needsRebind.length > 0 && (
        <div>
          <h3>{needsRebind.length} upload(s) need re-selection</h3>
          <input
            type="file"
            multiple
            onChange={(e) => {
              const files = Array.from(e.target.files ?? [])
              // rebind checks each file's fingerprint; only matches are accepted
              for (const item of needsRebind) {
                for (const file of files) dispatch({ type: 'rebind', localId: item.localId, file })
              }
            }}
          />
        </div>
      )}
      {canResume.length > 0 && (
        <button onClick={() => dispatch({ type: 'startAll' })}>Resume all ({canResume.length})</button>
      )}
      <ul>
        {items.map((item) => (
          <li key={item.localId}>
            {item.fingerprint.name} — {item.phase}
            {'progress' in item && item.progress && ` ${Math.round(item.progress.pct)}%`}
            {item.phase === 'paused' && !item.file && ' · needs file'}
          </li>
        ))}
      </ul>
    </div>
  )
}
```

***

## Chapter 6 FAQ

When is the snapshot written?

After state changes, debounced by `debounceMs`. During rapid progress only the last state
in the window is written, avoiding excessive I/O.

What if the schema version changes?

The default deserializer checks `version`. Bump it when your intent/cursor shapes change.
For migrations, supply a custom `deserialize`; otherwise a mismatched snapshot is discarded
and the store starts fresh.

LocalStorageAdapter in production?

Possible, but mind the ~5 MB cap — each item stores its intent and cursor. Prefer
`IndexedDBAdapter` for many uploads or large intent payloads.

What if the user rebinds the wrong file?

`rebind` compares the new file's fingerprint (`name`, `size`, `lastModified` — not `type`)
to the stored one. A mismatch is a silent no-op; the item stays paused without a file. After
dispatching `rebind`, check whether `item.file` is now set, and if not, prompt the user to
try again.

Persistence under SSR?

IndexedDB/localStorage are browser-only. Use `MemoryAdapter` on the server or in tests, or
omit persistence — the store works fine without it and just starts empty each load.

***

Next: [Chapter 7: Validation & Plugins](/duck-upload/course/chapter-7)