## Overview

`createUploadStore` is the single entry point for building a store. It normalizes config,
installs the default transport, wires plugins and hooks, optionally hydrates from persistence,
and returns a `Store.UploadStore<M, C, P, R>`.

```ts
import { createUploadStore } from '@gentleduck/upload/core'

export const store = createUploadStore<Intents, Cursors, Purpose, Result>({
  api,
  strategies,
  config: {
    maxConcurrentUploads: 3,
    maxAttempts: 3,
    progressThrottleMs: 100,
    autoStart: ['avatar'],
    maxItems: 100,
    validation: {
      avatar: { maxSizeBytes: 5 * 1024 * 1024, allowedTypes: ['image/*'] },
    },
  },
})
```

## Store options

`Store.Options<M, C, P, R>` accepts:

| Option | Required | Description |
| --- | --- | --- |
| `api` | Yes | Backend contract — `Contracts.Api.Me<M, P, R>` |
| `strategies` | Yes | Strategy registry with your strategies registered |
| `config` | No | Engine config (defaults applied by `resolveUploadConfig`) |
| `transport` | No | HTTP transport. Defaults to `createXHRTransport()` |
| `persistence` | No | Adapter + options for crash-safe resume |
| `plugins` | No | Array of `Engine.Plugin` modules |
| `hooks` | No | `Engine.Hooks` for low-level observation |
| `fingerprint` | No | Custom, synchronous `(file) => Contracts.FingerprintFile` |
| `validateFile` | No | Extra validator run after built-in rules |
| `errorNormalizer` | No | Map raw thrown values into `Contracts.Errors.Error` |
| `initialState` | No | Pre-hydrated `Engine.State` (e.g. from persistence) |

## Config

Everything under `config` is optional. `resolveUploadConfig()` fills the rest at construction
time, so the runtime always has a fully-specified `Engine.Config`.

| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `maxConcurrentUploads` | `number` | `3` | Files transferring at once (queued items wait) |
| `progressThrottleMs` | `number` | `100` | Minimum gap between progress events |
| `maxAttempts` | `number` | `3` | Hard cap on attempts per phase |
| `maxItems` | `number` | `100` | Items kept in state before oldest terminal ones evict |
| `autoStart` | `readonly P[] \| (purpose: P) => boolean` | `undefined` | Auto-queue matching purposes after intent |
| `validation` | `Partial<Record<P, Contracts.ValidationRules>>` | `{}` | Per-purpose rules |
| `retryPolicy` | `(ctx) => Engine.RetryDecision` | `undefined` | Override default backoff |
| `completedItemTTL` | `number` | `undefined` | Evict completed items after N ms |
| `strictMimeMatch` | `boolean` | `false` | Sniff magic bytes and reject spoofed types |
| `checksumMaxSize` | `number \| null` | `null` | Size cap above which client checksums are skipped |

### Validation rules

Rules are keyed by purpose and run during `addFiles`. Extensions are matched **without a
leading dot** and case-insensitively; MIME types support an `image/*` wildcard prefix.

```ts
const config = {
  validation: {
    avatar: {
      maxFiles: 1,
      maxSizeBytes: 5 * 1024 * 1024,
      minSizeBytes: 1024,
      allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
      allowedExtensions: ['jpg', 'jpeg', 'png', 'webp'],
    },
    document: {
      maxSizeBytes: 100 * 1024 * 1024,
      allowedTypes: ['application/pdf'],
    },
  },
}
```

When both `allowedTypes` and `allowedExtensions` are given, a file passes if it matches
**either**. Rejected files never enter the state machine — they emit `file.rejected` with a
`Contracts.Validation.Rejection`.

### Retry policy

The default retries up to `maxAttempts` with exponential backoff. Override per-error with
`retryPolicy`, which returns an `Engine.RetryDecision`:

```ts
const config = {
  retryPolicy: ({ phase, attempt, error }) => {
    if (error.code === 'auth') return { retryable: false }
    if (error.code === 'rate_limit' && 'retryAfterMs' in error && typeof error.retryAfterMs === 'number') {
      return { retryable: true, delayMs: error.retryAfterMs }
    }
    return { retryable: true, delayMs: Math.min(500 * 2 ** (attempt - 1), 10_000) }
  },
}
```

`phase` is `'intent' | 'upload' | 'complete'`. Return `{ retryable: false }` or
`{ retryable: true, delayMs }`. `maxAttempts` still caps everything.

## Plugins

Plugins extend behavior without forking the engine. Each `Engine.Plugin` has a `name` and a
`setup` that receives a minimal proxy — `on`, `off`, `dispatch`, `getSnapshot` — enough to
observe and react, not enough to corrupt state.

```ts
import type { Engine } from '@gentleduck/upload/core'

const analytics: Engine.Plugin<Intents, Cursors, Purpose, Result> = {
  name: 'analytics',
  setup({ on, getSnapshot }) {
    on('upload.completed', ({ localId, result }) => {
      const item = getSnapshot().items.get(localId)
      track('upload_completed', { fileId: result.fileId, purpose: item?.purpose })
    })
    on('upload.error', ({ error }) => {
      track('upload_error', { code: error.code, message: error.message })
    })
  },
}

const store = createUploadStore({ api, strategies, plugins: [analytics] })
```

A plugin that throws in `setup` is caught and logged in development; it never breaks the store,
and later plugins still initialize.

## Hooks

Hooks are lower-level than plugins. `onInternalEvent` fires after every reducer event with the
event and the resulting state — ideal for devtools and logging:

```ts
const store = createUploadStore({
  api,
  strategies,
  hooks: {
    onInternalEvent(event, state) {
      if (process.env.NODE_ENV === 'development') console.log('[upload]', event.type, event)
    },
  },
})
```

## Custom fingerprinting

By default the engine fingerprints on `name + size + type + lastModified`. Supply a
**synchronous** function for stronger identity (a precomputed checksum enables dedupe):

```ts
const store = createUploadStore({
  api,
  strategies,
  fingerprint: (file) => ({
    name: file.name,
    size: file.size,
    type: file.type,
    lastModified: file.lastModified,
    checksum: precomputedChecksums.get(file), // must be available synchronously
  }),
})
```

It must stay synchronous so `addFiles` never blocks. To hash large files, compute the checksum
before adding and look it up here.

## Custom error normalization

`errorNormalizer` converts raw throws (fetch `TypeError`, XHR errors, backend shapes) into a
`Contracts.Errors.Error` with a stable `code`, so `retryPolicy` and your UI always see one
shape. Without it, the engine's built-in normalizer classifies common HTTP/network failures.

## Next

* [Engine](/duck-upload/core/engine) — phases, scheduling, retry mechanics.
* [Contracts](/duck-upload/core/contracts) — the API, transport, and strategy shapes.
* [Persistence](/duck-upload/core/persistence) — configure resume across reloads.

***

## FAQ

Is there a createUploadClient?

No. <code className="rounded bg-muted px-2 py-1">createUploadStore</code> is the only
constructor. Older drafts referenced a client alias that no longer exists.

When are config defaults applied?

Once, at construction, by
<code className="rounded bg-muted px-2 py-1">resolveUploadConfig()</code>. After that the
runtime holds a fully-specified config — no optional fields to check per operation.

What if a plugin throws during setup?

It is caught and logged in development. The store keeps working and the remaining plugins
still initialize.

When should I supply a custom transport?

The default XHR transport covers browsers. Provide your own for Node, fetch-based
uploads, mock transports in tests, or bespoke header/auth handling. It must implement
<code className="rounded bg-muted px-2 py-1">Transport.Options</code>.

How does validateFile combine with built-in rules?

Built-in rules (size, type, extension, count) run first. If they pass but
<code className="rounded bg-muted px-2 py-1">validateFile</code> returns a
<code className="rounded bg-muted px-2 py-1">Contracts.Validation.Rejection</code>, the
file is still rejected. Return <code className="rounded bg-muted px-2 py-1">null</code> to
accept it.