@gentleduck/upload 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/index.d.ts +1417 -0
  2. package/dist/index.js +6 -0
  3. package/package.json +43 -0
  4. package/src/core/client/config.types.ts +77 -0
  5. package/src/core/client/index.ts +4 -0
  6. package/src/core/client/plugins.types.ts +46 -0
  7. package/src/core/client/retry.types.ts +6 -0
  8. package/src/core/client/uploadClient.ts +19 -0
  9. package/src/core/contracts/backendApi.ts +62 -0
  10. package/src/core/contracts/cursor.types.ts +23 -0
  11. package/src/core/contracts/errors.types.ts +67 -0
  12. package/src/core/contracts/fingerprint.types.ts +18 -0
  13. package/src/core/contracts/index.ts +8 -0
  14. package/src/core/contracts/intent.types.ts +39 -0
  15. package/src/core/contracts/result.types.ts +11 -0
  16. package/src/core/contracts/strategy.types.ts +45 -0
  17. package/src/core/contracts/transport/index.ts +2 -0
  18. package/src/core/contracts/transport/transport.libs.ts +100 -0
  19. package/src/core/contracts/transport/transport.ts +166 -0
  20. package/src/core/contracts/transport/transport.types.ts +44 -0
  21. package/src/core/engine/commands.types.ts +17 -0
  22. package/src/core/engine/event-map.types.ts +45 -0
  23. package/src/core/engine/index.ts +8 -0
  24. package/src/core/engine/internal-events.types.ts +222 -0
  25. package/src/core/engine/outcome.types.ts +9 -0
  26. package/src/core/engine/progress.types.ts +15 -0
  27. package/src/core/engine/reducer.ts +435 -0
  28. package/src/core/engine/store/handlers/add-file.ts +103 -0
  29. package/src/core/engine/store/handlers/cancel.ts +56 -0
  30. package/src/core/engine/store/handlers/clean-up.ts +66 -0
  31. package/src/core/engine/store/handlers/create-intent.ts +76 -0
  32. package/src/core/engine/store/handlers/finalize.ts +59 -0
  33. package/src/core/engine/store/handlers/index.ts +7 -0
  34. package/src/core/engine/store/handlers/pause.ts +16 -0
  35. package/src/core/engine/store/handlers/run-upload.ts +192 -0
  36. package/src/core/engine/store/index.ts +123 -0
  37. package/src/core/engine/store/store.dispatch.ts +78 -0
  38. package/src/core/engine/store/store.libs.ts +169 -0
  39. package/src/core/engine/store/store.runtime.ts +336 -0
  40. package/src/core/engine/store/store.schedule.ts +116 -0
  41. package/src/core/engine/store/store.types.ts +250 -0
  42. package/src/core/engine/validation/file.ts +77 -0
  43. package/src/core/engine/validation/index.ts +3 -0
  44. package/src/core/engine/validation/intent.ts +55 -0
  45. package/src/core/engine/validation/list.ts +50 -0
  46. package/src/core/index.ts +5 -0
  47. package/src/core/persistence/adapters.indexeddb.ts +92 -0
  48. package/src/core/persistence/adapters.local.ts +22 -0
  49. package/src/core/persistence/adapters.memory.ts +15 -0
  50. package/src/core/persistence/index.ts +5 -0
  51. package/src/core/persistence/persistence.ts +194 -0
  52. package/src/core/persistence/persistence.types.ts +66 -0
  53. package/src/core/utils/async.ts +3 -0
  54. package/src/core/utils/constants.ts +32 -0
  55. package/src/core/utils/emitter/emitter.types.ts +23 -0
  56. package/src/core/utils/emitter/index.ts +68 -0
  57. package/src/core/utils/fingerprint.ts +20 -0
  58. package/src/core/utils/guards.ts +3 -0
  59. package/src/core/utils/id.ts +3 -0
  60. package/src/core/utils/index.ts +6 -0
  61. package/src/index.ts +3 -0
  62. package/src/react/index.ts +2 -0
  63. package/src/react/upload-provider.tsx +96 -0
  64. package/src/react/use-uploader.tsx +115 -0
  65. package/src/strategies/index.ts +15 -0
  66. package/src/strategies/multipart/index.ts +243 -0
  67. package/src/strategies/post/index.ts +93 -0
  68. package/src/strategies/registry/index.ts +30 -0
@@ -0,0 +1,250 @@
1
+ import type { UploadConfig, UploadConfigInput, UploadHooks, UploadPlugin } from '../../client'
2
+ import type {
3
+ CursorMap,
4
+ FileFingerprint,
5
+ IntentMap,
6
+ RejectReason,
7
+ StrategyRegistry,
8
+ UploadApi,
9
+ UploadError,
10
+ UploadResultBase,
11
+ UploadTransport,
12
+ } from '../../contracts'
13
+ import type { PersistedSnapshot, PersistenceAdapter } from '../../persistence'
14
+ import type { TypedEmitter } from '../../utils/emitter'
15
+ import type { UploadCommand } from '../commands.types'
16
+ import type { UploadEventMap } from '../event-map.types'
17
+ import type { InternalEvent } from '../internal-events.types'
18
+ import type { UploadOutcome } from '../outcome.types'
19
+ import type { createReducer, UploadState } from '../reducer'
20
+
21
+ /**
22
+ * Upload store interface used by UI adapters and application code.
23
+ *
24
+ * The store is intentionally minimal:
25
+ * - state is read through {@link UploadStore.getSnapshot}
26
+ * - updates are observed through {@link UploadStore.subscribe} and {@link UploadStore.on}
27
+ * - actions are performed through {@link UploadStore.dispatch}
28
+ *
29
+ * @template M - Intent map type (keyed by strategy id)
30
+ * @template C - Cursor map type (keyed by strategy id)
31
+ * @template P - Purpose string union type
32
+ */
33
+ export interface UploadStore<
34
+ M extends IntentMap,
35
+ C extends CursorMap<M>,
36
+ P extends string,
37
+ R extends UploadResultBase = UploadResultBase,
38
+ > {
39
+ /**
40
+ * Returns the current immutable state snapshot.
41
+ * Useful for React loops or debugging.
42
+ */
43
+ getSnapshot(): UploadState<M, C, P, R>
44
+ /**
45
+ * Subscribes to ANY state change.
46
+ * The listener is called after the reducer has run and effects have been scheduled.
47
+ * @param listener - Listener function to call on state change
48
+ */
49
+ subscribe(listener: () => void): () => void
50
+ /**
51
+ * Main entry point for user actions.
52
+ * Converts external commands into internal state transitions and side effects.
53
+ *
54
+ * @param cmd The command to execute (e.g. 'addFiles', 'start', 'pause')
55
+ */
56
+ dispatch(cmd: UploadCommand<P>): void
57
+ /**
58
+ * Subscribes to specific events (like 'file.added', 'upload.progress').
59
+ * Wraps the internal typed emitter.
60
+ */
61
+ on: <K extends keyof UploadEventMap<M, C, P, R> & string>(
62
+ type: K,
63
+ cb: (payload: UploadEventMap<M, C, P, R>[K]) => void,
64
+ ) => () => void
65
+
66
+ /**
67
+ * Unsubscribes from specific events (like 'file.added', 'upload.progress').
68
+ * Wraps the internal typed emitter.
69
+ */
70
+ off: <K extends keyof UploadEventMap<M, C, P, R> & string>(
71
+ type: K,
72
+ cb: (payload: UploadEventMap<M, C, P, R>[K]) => void,
73
+ ) => () => void
74
+
75
+ /**
76
+ * Waits for the given localIds to reach a terminal state.
77
+ */
78
+ waitFor(localIds: string[]): Promise<Array<UploadOutcome<R>>>
79
+ }
80
+
81
+ /**
82
+ * Options used to construct a store runtime.
83
+ *
84
+ * In practice you provide:
85
+ * - a backend adapter ({@link UploadApi}) that creates intents and finalizes uploads
86
+ * - a transport implementation (XHR, fetch, etc.) used by strategies
87
+ * - a strategy registry describing what upload strategies exist and how to run them
88
+ * - configuration rules for validation, concurrency, retries, and progress throttling
89
+ *
90
+ * Hooks and plugins are optional and let you observe or extend behavior without forking
91
+ * the engine.
92
+ *
93
+ * @template M - Intent map type (keyed by strategy id)
94
+ * @template C - Cursor map type (keyed by strategy id)
95
+ * @template P - Purpose string union type
96
+ */
97
+ export interface StoreOptions<
98
+ M extends IntentMap,
99
+ C extends CursorMap<M>,
100
+ P extends string,
101
+ R extends UploadResultBase = UploadResultBase,
102
+ > {
103
+ /** Initial state to hydrate from (e.g. from localStorage) */
104
+ initialState?: UploadState<M, C, P, R>
105
+ /** Static configuration rules (defaults are applied). */
106
+ config?: UploadConfigInput<P>
107
+ /** Persistence adapter and options */
108
+ persistence?: PersistenceOptions<M, C, P, R>
109
+ /** Backend API adapter for creating intents and finalizing uploads */
110
+ api: UploadApi<M, P, R>
111
+ /** Network transport (XHR/Fetch) for the actual file data transfer */
112
+ transport?: UploadTransport
113
+ /** Registry of available upload strategies (multipart, simple, etc.) */
114
+ strategies: StrategyRegistry<M, C, P, R>
115
+ /** Optional plugins to extend functionality (e.g. debugging, metrics) */
116
+ plugins?: Array<UploadPlugin<M, C, P, R>>
117
+ /** Lifecycle hooks for observing internal events */
118
+ hooks?: UploadHooks<M, C, P, R>
119
+ /**
120
+ * Optional custom fingerprinting (e.g. include sha256).
121
+ * Must be synchronous to keep addFiles fast.
122
+ */
123
+ fingerprint?: (file: File) => FileFingerprint
124
+ /**
125
+ * If returns non-null, it rejects the file.
126
+ * Runs after built-in config validation.
127
+ */
128
+ validateFile?: (file: File, purpose: P) => RejectReason | null
129
+ /**
130
+ * Optional custom error normalizer.
131
+ * Convert raw errors (from fetch, XHR, etc.) into your custom UploadError shape.
132
+ */
133
+ errorNormalizer?: (err: unknown) => UploadError
134
+ }
135
+
136
+ /**
137
+ * Persistence options.
138
+ *
139
+ * Used to configure the upload store persistence layer.
140
+ */
141
+ export type DeserializeContext<M extends IntentMap, C extends CursorMap<M>, P extends string> = {
142
+ isPurpose?: (value: string) => value is P
143
+ isIntent?: (value: unknown) => value is M[keyof M]
144
+ hasStrategy: (value: string) => boolean
145
+ }
146
+
147
+ export type PersistenceOptions<
148
+ M extends IntentMap,
149
+ C extends CursorMap<M>,
150
+ P extends string,
151
+ R extends UploadResultBase,
152
+ > = {
153
+ /** Persistence key (e.g. localStorage key) */
154
+ key: string
155
+ /** Persistence schema version (not app version) */
156
+ version: number
157
+ /** Debounce delay in milliseconds (default: 200) */
158
+ debounceMs?: number
159
+ /** Persistence adapter */
160
+ adapter: PersistenceAdapter
161
+
162
+ /**
163
+ * Optional custom snapshot serializer.
164
+ * Convert the upload state into a JSON-safe snapshot.
165
+ */
166
+ serialize?: (state: UploadState<M, C, P, R>, version: number) => PersistedSnapshot<M, C, P>
167
+ /**
168
+ * Optional custom snapshot deserializer.
169
+ * Convert a JSON-safe snapshot into the upload state.
170
+ */
171
+ deserialize?: (raw: unknown, ctx: DeserializeContext<M, C, P>) => UploadState<M, C, P, R> | null
172
+
173
+ /**
174
+ * Runtime guard for purpose strings when using the default deserializer.
175
+ */
176
+ isPurpose?: (value: string) => value is P
177
+
178
+ /**
179
+ * Runtime guard for intent objects when using the default deserializer.
180
+ */
181
+ isIntent?: (value: unknown) => value is M[keyof M]
182
+ }
183
+
184
+ /**
185
+ * Internal record describing a currently running upload request.
186
+ *
187
+ * The store keeps one inflight entry per `localId` while the strategy's `start()` is running.
188
+ * The {@link AbortController} is used to pause/cancel, while `mode` tracks which user intent
189
+ * caused the abort so the handler can map it back into a correct state transition.
190
+ */
191
+ export type InflightUpload = {
192
+ controller: AbortController
193
+ mode: 'normal' | 'pause' | 'cancel'
194
+ started: boolean
195
+ }
196
+
197
+ /**
198
+ * Internal runtime container shared across dispatch, scheduler, and handlers.
199
+ *
200
+ * @template M - Intent map type
201
+ * @template C - Cursor map type
202
+ * @template P - Purpose string union type
203
+ */
204
+ export type StoreRuntime<
205
+ M extends IntentMap,
206
+ C extends CursorMap<M>,
207
+ P extends string,
208
+ R extends UploadResultBase = UploadResultBase,
209
+ > = {
210
+ /** Store options */
211
+ opts: StoreOptions<M, C, P, R> & { config: UploadConfig<P>; transport: UploadTransport }
212
+ /** Current state */
213
+ state: UploadState<M, C, P, R>
214
+
215
+ /** Listeners for external events */
216
+ listeners: Set<() => void>
217
+ /** Typed event emitter */
218
+ emitter: TypedEmitter<UploadEventMap<M, C, P, R>>
219
+ /** Reducer */
220
+ reduce: ReturnType<typeof createReducer<M, C, P, R>>
221
+
222
+ /** In-flight uploads */
223
+ inflightUploads: Map<string, InflightUpload>
224
+ /** In-flight intents */
225
+ inflightIntents: Map<string, AbortController>
226
+ /** In-flight completes */
227
+ inflightCompletes: Map<string, AbortController>
228
+
229
+ /** Effect queue */
230
+ effectQueue: Array<() => Promise<void>>
231
+ /** Effect queue processing */
232
+ processingEffects: boolean
233
+ /** Effect queue scheduling */
234
+ scheduling: boolean
235
+
236
+ /** Notifies listeners */
237
+ notify: () => void
238
+ /** Schedules work */
239
+ scheduleWork: () => void
240
+ /** Applies an internal event */
241
+ applyInternal: (event: InternalEvent<M, C, P, R>) => void
242
+ /** Applies a command */
243
+ applyCommand: (cmd: UploadCommand<P>) => void
244
+ /** Enqueues an effect */
245
+ enqueueEffect: (effect: () => Promise<void>) => void
246
+ /** Processes effects */
247
+ processEffects: () => Promise<void>
248
+ /** Set by createUploadStore after construction; used by handlers for retries etc. */
249
+ dispatch: (cmd: UploadCommand<P>) => void
250
+ }
@@ -0,0 +1,77 @@
1
+ import type { UploadConfig } from '../../client'
2
+ import type { RejectReason } from '../../contracts'
3
+
4
+ export function validateFile<P extends string>(file: File, purpose: P, config: UploadConfig<P>): RejectReason | null {
5
+ // Get rules for this purpose, skip validation if no rules defined
6
+ const rules = config.validation?.[purpose]
7
+ if (!rules) return null
8
+
9
+ // Check for empty file
10
+ if (file.size === 0) return { code: 'empty_file' }
11
+
12
+ // Check maximum size limit
13
+ if (rules.maxSizeBytes !== undefined && file.size > rules.maxSizeBytes) {
14
+ return { code: 'file_too_large', maxBytes: rules.maxSizeBytes, size: file.size }
15
+ }
16
+
17
+ // Check minimum size limit (treated as empty for simplicity)
18
+ if (rules.minSizeBytes !== undefined && file.size < rules.minSizeBytes) {
19
+ return { code: 'empty_file' }
20
+ }
21
+
22
+ // Check file type and extension restrictions
23
+ if (rules.allowedTypes || rules.allowedExtensions) {
24
+ const allowed = rules.allowedTypes || []
25
+ const extensions = rules.allowedExtensions || []
26
+
27
+ // Extract file extension (lowercase, without dot)
28
+ const fileExt = file.name.split('.').pop()?.toLowerCase()
29
+ // File MIME type (may be empty string for unknown types)
30
+ const mimeType = (file.type || '').toLowerCase()
31
+
32
+ const typeMatches =
33
+ allowed.length === 0
34
+ ? false
35
+ : allowed.some((type) => {
36
+ if (type.endsWith('/*')) {
37
+ // Wildcard match: 'image/*' matches 'image/jpeg', 'image/png', etc.
38
+ const prefix = type.slice(0, -2).toLowerCase()
39
+ return mimeType.startsWith(prefix + '/')
40
+ }
41
+ // Exact match
42
+ return mimeType === type.toLowerCase()
43
+ })
44
+
45
+ const extMatches =
46
+ !!fileExt && extensions.length > 0 ? extensions.some((ext) => ext.toLowerCase() === fileExt) : false
47
+
48
+ // Determine if file passes type/extension check
49
+ const hasTypeRules = allowed.length > 0
50
+ const hasExtRules = extensions.length > 0
51
+
52
+ // If both rules exist, allow either to match (OR logic)
53
+ // If only one exists, require it to match
54
+ const ok = (hasTypeRules ? typeMatches : true) && (hasExtRules ? extMatches : true)
55
+ const okEither = hasTypeRules && hasExtRules ? typeMatches || extMatches : ok
56
+
57
+ if (!okEither) {
58
+ return {
59
+ code: 'type_not_allowed',
60
+ allowed: [...allowed, ...extensions],
61
+ got: file.type || file.name,
62
+ }
63
+ }
64
+ }
65
+
66
+ // All checks passed
67
+ return null
68
+ }
69
+
70
+ /**
71
+ * Validates a batch of files, filtering them into 'valid' and 'rejected' lists.
72
+ * Also enforces the `maxFiles` limit relative to the existing count.
73
+ *
74
+ * @param files Array of files to validate
75
+ * @param purpose Upload purpose
76
+ * @param existingCount Number of files already uploaded (to enforce maxFiles)
77
+ */
@@ -0,0 +1,3 @@
1
+ export { validateFile } from './file'
2
+ export { validateIntent } from './intent'
3
+ export { validateFileList } from './list'
@@ -0,0 +1,55 @@
1
+ import { isRecord } from '../../utils/guards'
2
+
3
+ export function validateIntent(intent: unknown, strategy: string): Error | null {
4
+ if (!isRecord(intent)) {
5
+ return new Error('Invalid intent: must be an object')
6
+ }
7
+
8
+ // Check required fields
9
+ if (typeof intent.strategy !== 'string') {
10
+ return new Error('Invalid intent: missing or invalid strategy field')
11
+ }
12
+
13
+ if (intent.strategy !== strategy) {
14
+ return new Error(`Invalid intent: strategy mismatch (expected ${strategy}, got ${intent.strategy})`)
15
+ }
16
+
17
+ if (typeof intent.fileId !== 'string' || !intent.fileId) {
18
+ return new Error('Invalid intent: missing or invalid fileId')
19
+ }
20
+
21
+ // Strategy-specific validation
22
+ if (strategy === 'post') {
23
+ // POST strategy requires url and fields
24
+ if (typeof intent.url !== 'string' || !intent.url) {
25
+ return new Error('Invalid post intent: missing or invalid url')
26
+ }
27
+ try {
28
+ const url = new URL(intent.url)
29
+ if (!['http:', 'https:'].includes(url.protocol)) {
30
+ return new Error('Invalid post intent: url must use http or https protocol')
31
+ }
32
+ } catch {
33
+ return new Error('Invalid post intent: url is not a valid URL')
34
+ }
35
+ if (!intent.fields || typeof intent.fields !== 'object') {
36
+ return new Error('Invalid post intent: missing or invalid fields')
37
+ }
38
+ } else if (strategy === 'multipart') {
39
+ // Multipart strategy requires uploadId and partSize
40
+ if (typeof intent.uploadId !== 'string' || !intent.uploadId) {
41
+ return new Error('Invalid multipart intent: missing or invalid uploadId')
42
+ }
43
+ if (typeof intent.partSize !== 'number' || intent.partSize <= 0) {
44
+ return new Error('Invalid multipart intent: missing or invalid partSize')
45
+ }
46
+ // Parts array is optional (can be fetched later)
47
+ if ('parts' in intent && intent.parts !== undefined) {
48
+ if (!Array.isArray(intent.parts)) {
49
+ return new Error('Invalid multipart intent: parts must be an array if provided')
50
+ }
51
+ }
52
+ }
53
+
54
+ return null
55
+ }
@@ -0,0 +1,50 @@
1
+ import type { UploadConfig } from '../../client'
2
+ import type { RejectReason } from '../../contracts'
3
+ import { validateFile } from './file'
4
+
5
+ export function validateFileList<P extends string>(
6
+ files: File[],
7
+ purpose: P,
8
+ config: UploadConfig<P>,
9
+ existingCount: number = 0,
10
+ ): { valid: File[]; rejected: Array<{ file: File; reason: RejectReason }> } {
11
+ const rules = config.validation?.[purpose]
12
+ const valid: File[] = []
13
+ const rejected: Array<{ file: File; reason: RejectReason }> = []
14
+
15
+ // Calculate remaining slots based on maxFiles limit
16
+ const maxFiles = rules?.maxFiles
17
+ let remaining = maxFiles !== undefined ? Math.max(0, maxFiles - existingCount) : Number.POSITIVE_INFINITY
18
+
19
+ for (const file of files) {
20
+ // Check if we've reached the file limit
21
+ if (remaining <= 0) {
22
+ const limit = maxFiles ?? 0
23
+ rejected.push({ file, reason: { code: 'too_many_files', max: limit } })
24
+ continue
25
+ }
26
+
27
+ // Run individual file validation
28
+ const reason = validateFile(file, purpose, config)
29
+ if (reason) {
30
+ rejected.push({ file, reason })
31
+ continue
32
+ }
33
+
34
+ // File is valid, add to list and decrement remaining slots
35
+ valid.push(file)
36
+ remaining -= 1
37
+ }
38
+
39
+ return { valid, rejected }
40
+ }
41
+
42
+ /**
43
+ * Validates an intent object from the backend.
44
+ * Ensures required fields are present and URLs are valid.
45
+ *
46
+ * @param intent - Intent object to validate
47
+ * @param strategy - Expected strategy type (from the intent itself)
48
+ *
49
+ * @returns {Error | null} Error if validation fails, null if valid
50
+ */
@@ -0,0 +1,5 @@
1
+ export * from './client'
2
+ export * from './contracts'
3
+ export * from './engine'
4
+ export * from './persistence'
5
+ export * from './utils'
@@ -0,0 +1,92 @@
1
+ import type { PersistenceAdapter } from './persistence.types'
2
+
3
+ const DB_NAME = 'upload-engine'
4
+ const STORE = 'snapshots'
5
+ const VERSION = 1
6
+
7
+ function openDB(): Promise<IDBDatabase> {
8
+ return new Promise((resolve, reject) => {
9
+ const req = indexedDB.open(DB_NAME, VERSION)
10
+ req.onupgradeneeded = () => {
11
+ const db = req.result
12
+ if (!db.objectStoreNames.contains(STORE)) {
13
+ db.createObjectStore(STORE)
14
+ }
15
+ }
16
+ req.onsuccess = () => resolve(req.result)
17
+ req.onerror = () => reject(req.error)
18
+ })
19
+ }
20
+
21
+ function reqToPromise<T>(req: IDBRequest<T>): Promise<T> {
22
+ return new Promise((resolve, reject) => {
23
+ req.onsuccess = () => resolve(req.result)
24
+ req.onerror = () => reject(req.error)
25
+ })
26
+ }
27
+
28
+ function txDone(tx: IDBTransaction): Promise<void> {
29
+ return new Promise((resolve, reject) => {
30
+ tx.oncomplete = () => resolve()
31
+ tx.onabort = () => reject(tx.error ?? new Error('IndexedDB transaction aborted'))
32
+ tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction error'))
33
+ })
34
+ }
35
+
36
+ export const IndexedDBAdapter: PersistenceAdapter = {
37
+ async load(key) {
38
+ if (typeof indexedDB === 'undefined') return null
39
+ const db = await openDB()
40
+ const tx = db.transaction(STORE, 'readonly')
41
+ const store = tx.objectStore(STORE)
42
+
43
+ try {
44
+ const value = await reqToPromise(store.get(key))
45
+ await txDone(tx)
46
+ return value ?? null
47
+ } catch {
48
+ try {
49
+ await txDone(tx)
50
+ } catch {
51
+ // ignore
52
+ }
53
+ return null
54
+ }
55
+ },
56
+
57
+ async save(key, snapshot) {
58
+ if (typeof indexedDB === 'undefined') return
59
+ const db = await openDB()
60
+ const tx = db.transaction(STORE, 'readwrite')
61
+ const store = tx.objectStore(STORE)
62
+
63
+ try {
64
+ store.put(snapshot, key)
65
+ await txDone(tx)
66
+ } catch {
67
+ try {
68
+ tx.abort()
69
+ } catch {
70
+ // ignore
71
+ }
72
+ }
73
+ },
74
+
75
+ async clear(key) {
76
+ if (typeof indexedDB === 'undefined') return
77
+ const db = await openDB()
78
+ const tx = db.transaction(STORE, 'readwrite')
79
+ const store = tx.objectStore(STORE)
80
+
81
+ try {
82
+ store.delete(key)
83
+ await txDone(tx)
84
+ } catch {
85
+ try {
86
+ tx.abort()
87
+ } catch {
88
+ // ignore
89
+ }
90
+ }
91
+ },
92
+ }
@@ -0,0 +1,22 @@
1
+ import type { PersistenceAdapter } from './persistence.types'
2
+
3
+ export const LocalStorageAdapter: PersistenceAdapter = {
4
+ load(key) {
5
+ if (typeof localStorage === 'undefined') return null
6
+ const raw = localStorage.getItem(key)
7
+ if (!raw) return null
8
+ try {
9
+ return JSON.parse(raw)
10
+ } catch {
11
+ return null
12
+ }
13
+ },
14
+ save(key, snapshot) {
15
+ if (typeof localStorage === 'undefined') return
16
+ localStorage.setItem(key, JSON.stringify(snapshot))
17
+ },
18
+ clear(key) {
19
+ if (typeof localStorage === 'undefined') return
20
+ localStorage.removeItem(key)
21
+ },
22
+ }
@@ -0,0 +1,15 @@
1
+ import type { PersistenceAdapter } from './persistence.types'
2
+
3
+ const store = new Map<string, unknown>()
4
+
5
+ export const MemoryAdapter: PersistenceAdapter = {
6
+ load(key) {
7
+ return store.get(key) ?? null
8
+ },
9
+ save(key, snapshot) {
10
+ store.set(key, snapshot)
11
+ },
12
+ clear(key) {
13
+ store.delete(key)
14
+ },
15
+ }
@@ -0,0 +1,5 @@
1
+ export * from './adapters.indexeddb'
2
+ export * from './adapters.local'
3
+ export * from './adapters.memory'
4
+ export * from './persistence'
5
+ export * from './persistence.types'