@gentleduck/upload 0.2.5 → 0.3.0

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 (129) hide show
  1. package/README.md +297 -0
  2. package/dist/core/index.d.ts +2 -3
  3. package/dist/core/index.js +1 -1
  4. package/dist/core-BVgloTiP.js +10 -0
  5. package/dist/index-DCqyBVcx.d.ts +1441 -0
  6. package/dist/index-DsZHPmJm.d.ts +225 -0
  7. package/dist/index-KROXtLQh.d.ts +281 -0
  8. package/dist/index.d.ts +4 -5
  9. package/dist/index.js +1 -1
  10. package/dist/react/index.d.ts +2 -90
  11. package/dist/react/index.js +1 -1
  12. package/dist/react-euPrDv24.js +1 -0
  13. package/dist/strategies/index.d.ts +2 -56
  14. package/dist/strategies/index.js +1 -1
  15. package/dist/strategies-CUF0s68j.js +1 -0
  16. package/package.json +3 -2
  17. package/src/__tests__/emitter.test.ts +85 -0
  18. package/src/__tests__/fingerprint.test.ts +49 -0
  19. package/src/__tests__/guards.test.ts +40 -0
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/__tests__/persistence.test.ts +191 -0
  23. package/src/__tests__/sanitize-filename.test.ts +109 -0
  24. package/src/__tests__/sleep.test.ts +34 -0
  25. package/src/__tests__/static-analysis.test.ts +145 -0
  26. package/src/__tests__/store-libs.test.ts +192 -0
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/__tests__/transport-libs.test.ts +42 -0
  29. package/src/__tests__/transport.test.ts +182 -0
  30. package/src/core/__tests__/errors.test.ts +152 -0
  31. package/src/core/client.ts +3 -0
  32. package/src/core/contracts/contracts.types.ts +373 -0
  33. package/src/core/contracts/index.ts +3 -8
  34. package/src/core/contracts/transport/index.ts +3 -2
  35. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  36. package/src/core/contracts/transport/transport.libs.ts +13 -39
  37. package/src/core/contracts/transport/transport.ts +96 -90
  38. package/src/core/contracts/transport/transport.types.ts +71 -35
  39. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  40. package/src/core/engine/engine.libs.ts +35 -0
  41. package/src/core/engine/engine.types.ts +490 -0
  42. package/src/core/engine/index.ts +3 -8
  43. package/src/core/engine/reducer.ts +93 -53
  44. package/src/core/engine/store/handlers/add-file.ts +43 -48
  45. package/src/core/engine/store/handlers/cancel.ts +13 -8
  46. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  47. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  48. package/src/core/engine/store/handlers/finalize.ts +30 -13
  49. package/src/core/engine/store/handlers/index.ts +7 -7
  50. package/src/core/engine/store/handlers/pause.ts +8 -6
  51. package/src/core/engine/store/handlers/run-upload.ts +54 -39
  52. package/src/core/engine/store/index.ts +27 -21
  53. package/src/core/engine/store/store.dispatch.ts +10 -8
  54. package/src/core/engine/store/store.libs.ts +197 -94
  55. package/src/core/engine/store/store.runtime.ts +52 -53
  56. package/src/core/engine/store/store.schedule.ts +27 -21
  57. package/src/core/engine/store/store.types.ts +177 -227
  58. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  59. package/src/core/engine/validation/file.ts +8 -4
  60. package/src/core/engine/validation/index.ts +1 -0
  61. package/src/core/engine/validation/intent.ts +49 -27
  62. package/src/core/engine/validation/list.ts +14 -15
  63. package/src/core/engine/validation/mime.ts +39 -0
  64. package/src/core/errors.ts +271 -0
  65. package/src/core/index.ts +28 -5
  66. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  67. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  68. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  69. package/src/core/persistence/adapters.local.ts +9 -3
  70. package/src/core/persistence/adapters.memory.ts +35 -13
  71. package/src/core/persistence/index.ts +5 -5
  72. package/src/core/persistence/persistence.ts +73 -60
  73. package/src/core/persistence/persistence.types.ts +140 -60
  74. package/src/core/utils/__tests__/guards.test.ts +93 -0
  75. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  76. package/src/core/utils/async.ts +7 -2
  77. package/src/core/utils/constants.ts +3 -9
  78. package/src/core/utils/emitter/emitter.ts +126 -0
  79. package/src/core/utils/emitter/emitter.types.ts +33 -16
  80. package/src/core/utils/emitter/index.ts +2 -74
  81. package/src/core/utils/fingerprint.ts +16 -5
  82. package/src/core/utils/guards.ts +91 -1
  83. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  84. package/src/core/utils/hash/index.ts +126 -0
  85. package/src/core/utils/hash/sha256.ts +179 -0
  86. package/src/core/utils/id.ts +33 -1
  87. package/src/core/utils/index.ts +15 -6
  88. package/src/core/utils/mime-sniff.ts +119 -0
  89. package/src/core/utils/sanitize-filename.ts +85 -0
  90. package/src/core/utils/url-safety.ts +156 -0
  91. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  92. package/src/react/index.ts +5 -2
  93. package/src/react/upload-provider.tsx +59 -95
  94. package/src/react/uploader.types.ts +80 -0
  95. package/src/react/use-dropzone.tsx +226 -0
  96. package/src/react/use-uploader.tsx +97 -93
  97. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  98. package/src/strategies/_shared/retry.ts +79 -0
  99. package/src/strategies/index.ts +9 -15
  100. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  101. package/src/strategies/multipart/index.ts +211 -116
  102. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  103. package/src/strategies/post/index.ts +113 -71
  104. package/src/strategies/put/__tests__/put.test.ts +132 -0
  105. package/src/strategies/put/index.ts +139 -0
  106. package/src/strategies/registry/index.ts +12 -9
  107. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  108. package/src/strategies/tus/index.ts +211 -0
  109. package/dist/async-DxRWu4le.js +0 -1
  110. package/dist/core-BNxznRif.js +0 -2
  111. package/dist/index-BXg9WIce.d.ts +0 -987
  112. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  113. package/src/core/client/config.types.ts +0 -77
  114. package/src/core/client/index.ts +0 -4
  115. package/src/core/client/plugins.types.ts +0 -46
  116. package/src/core/client/retry.types.ts +0 -6
  117. package/src/core/client/uploadClient.ts +0 -19
  118. package/src/core/contracts/backendApi.ts +0 -62
  119. package/src/core/contracts/cursor.types.ts +0 -23
  120. package/src/core/contracts/errors.types.ts +0 -67
  121. package/src/core/contracts/fingerprint.types.ts +0 -18
  122. package/src/core/contracts/intent.types.ts +0 -39
  123. package/src/core/contracts/result.types.ts +0 -11
  124. package/src/core/contracts/strategy.types.ts +0 -45
  125. package/src/core/engine/commands.types.ts +0 -17
  126. package/src/core/engine/event-map.types.ts +0 -45
  127. package/src/core/engine/internal-events.types.ts +0 -222
  128. package/src/core/engine/outcome.types.ts +0 -9
  129. package/src/core/engine/progress.types.ts +0 -15
@@ -1,4 +1,5 @@
1
- import type { PersistenceAdapter } from './persistence.types'
1
+ import { stripDangerousKeys } from '../utils/guards'
2
+ import type { UploadPersistence } from './persistence.types'
2
3
 
3
4
  const DB_NAME = 'upload-engine'
4
5
  const STORE = 'snapshots'
@@ -33,7 +34,12 @@ function txDone(tx: IDBTransaction): Promise<void> {
33
34
  })
34
35
  }
35
36
 
36
- export const IndexedDBAdapter: PersistenceAdapter = {
37
+ /**
38
+ * Persistence adapter backed by IndexedDB, for durable snapshot storage in the
39
+ * browser without the size limits of `localStorage`.
40
+ * @author wildduck2 <https://github.com/wildduck2>
41
+ */
42
+ export const IndexedDBAdapter: UploadPersistence.Adapter = {
37
43
  async load(key) {
38
44
  if (typeof indexedDB === 'undefined') return null
39
45
  const db = await openDB()
@@ -43,7 +49,7 @@ export const IndexedDBAdapter: PersistenceAdapter = {
43
49
  try {
44
50
  const value = await reqToPromise(store.get(key))
45
51
  await txDone(tx)
46
- return value ?? null
52
+ return value === undefined || value === null ? null : stripDangerousKeys(value)
47
53
  } catch {
48
54
  try {
49
55
  await txDone(tx)
@@ -1,12 +1,18 @@
1
- import type { PersistenceAdapter } from './persistence.types'
1
+ import { stripDangerousKeys } from '../utils/guards'
2
+ import type { UploadPersistence } from './persistence.types'
2
3
 
3
- export const LocalStorageAdapter: PersistenceAdapter = {
4
+ /**
5
+ * Persistence adapter backed by `localStorage`. Synchronous and simple; best
6
+ * suited to small snapshots.
7
+ * @author wildduck2 <https://github.com/wildduck2>
8
+ */
9
+ export const LocalStorageAdapter: UploadPersistence.Adapter = {
4
10
  load(key) {
5
11
  if (typeof localStorage === 'undefined') return null
6
12
  const raw = localStorage.getItem(key)
7
13
  if (!raw) return null
8
14
  try {
9
- return JSON.parse(raw)
15
+ return stripDangerousKeys(JSON.parse(raw))
10
16
  } catch {
11
17
  return null
12
18
  }
@@ -1,15 +1,37 @@
1
- import type { PersistenceAdapter } from './persistence.types'
1
+ import type { UploadPersistence } from './persistence.types'
2
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
- },
3
+ /**
4
+ * Create an in-memory {@link PersistenceAdapter} backed by a fresh `Map`.
5
+ *
6
+ * Each call returns an isolated store. Multi-engine and multi-tenant
7
+ * deployments must use the factory so engine instances cannot read or
8
+ * overwrite each other's snapshots.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * const adapter = createMemoryAdapter()
13
+ * createUploadStore({ persistence: { adapter, key: 'tenant-a' } })
14
+ * ```
15
+ * @author wildduck2 <https://github.com/wildduck2>
16
+ */
17
+ export function createMemoryAdapter(): UploadPersistence.Adapter {
18
+ const store = new Map<string, unknown>()
19
+ return {
20
+ load(key) {
21
+ return store.get(key) ?? null
22
+ },
23
+ save(key, snapshot) {
24
+ store.set(key, snapshot)
25
+ },
26
+ clear(key) {
27
+ store.delete(key)
28
+ },
29
+ }
15
30
  }
31
+
32
+ /**
33
+ * Shared in-memory adapter instance. Convenient for single-engine apps; use
34
+ * {@link createMemoryAdapter} when isolation between engines is required.
35
+ * @author wildduck2 <https://github.com/wildduck2>
36
+ */
37
+ export const MemoryAdapter = createMemoryAdapter()
@@ -1,5 +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'
1
+ export { IndexedDBAdapter } from './adapters.indexeddb'
2
+ export { LocalStorageAdapter } from './adapters.local'
3
+ export { createMemoryAdapter, MemoryAdapter } from './adapters.memory'
4
+ export { deserializeSnapshot, serializeSnapshot } from './persistence'
5
+ export type { UploadPersistence } from './persistence.types'
@@ -1,33 +1,31 @@
1
- import type { AnyCursor, AnyIntent, CursorMap, IntentMap, UploadResultBase } from '../contracts'
2
- import type { UploadItem } from '../engine/internal-events.types'
3
- import type { UploadState } from '../engine/reducer'
1
+ import type { Contracts } from '../contracts'
2
+ import type { Engine } from '../engine/engine.types'
4
3
  import { hasCursor, hasIntent } from '../engine/store/store.libs'
5
4
  import { isRecord } from '../utils/guards'
6
- import type { PersistedSnapshot, PersistedUploadItem } from './persistence.types'
5
+ import type { UploadPersistence } from './persistence.types'
7
6
 
8
7
  /**
9
8
  * Serializes the current upload state into a JSON-safe structure.
10
9
  * Only serializes items that have a valid 'intent', as these are the only ones
11
10
  * that can be resumed cleanly.
11
+ * @author wildduck2 <https://github.com/wildduck2>
12
12
  */
13
13
  export function serializeSnapshot<
14
- M extends IntentMap,
15
- C extends CursorMap<M>,
14
+ M extends Contracts.Intent.Map,
15
+ C extends Contracts.Cursor.Map<M>,
16
16
  P extends string,
17
- R extends UploadResultBase,
18
- >(state: UploadState<M, C, P, R>, version: number): PersistedSnapshot<M, C, P> {
19
- const items: Record<string, PersistedUploadItem<M, C, P>> = {}
17
+ R extends Contracts.Result.Base,
18
+ >(state: Engine.State<M, C, P, R>, version: number): UploadPersistence.Snapshot<M, C, P> {
19
+ const items: Record<string, UploadPersistence.PersistedItem<M, C, P>> = {}
20
20
 
21
21
  for (const item of state.items.values()) {
22
22
  if (!hasIntent(item)) continue
23
23
 
24
24
  // Do not persist terminal items. Persistence is for resuming and recovery, not history.
25
- if (
26
- (item.phase as string) === 'completed' ||
27
- (item.phase as string) === 'canceled' ||
28
- (item.phase as string) === 'error'
29
- )
25
+ const phase = item.phase as string
26
+ if (phase === 'completed' || phase === 'canceled' || phase === 'error') {
30
27
  continue
28
+ }
31
29
 
32
30
  const cursor = hasCursor(item) ? item.cursor : undefined
33
31
 
@@ -40,7 +38,7 @@ export function serializeSnapshot<
40
38
  }
41
39
  : undefined
42
40
 
43
- const persisted: PersistedUploadItem<M, C, P> = {
41
+ const persisted: UploadPersistence.PersistedItem<M, C, P> = {
44
42
  id: item.localId,
45
43
  purpose: item.purpose,
46
44
  status: item.phase,
@@ -51,7 +49,7 @@ export function serializeSnapshot<
51
49
  lastModified: item.fingerprint.lastModified,
52
50
  checksum: item.fingerprint.checksum,
53
51
  },
54
- intent: item.intent,
52
+ intent: item.intent as Contracts.Intent.Any<M>,
55
53
  cursor,
56
54
  progress,
57
55
  }
@@ -69,24 +67,18 @@ export function serializeSnapshot<
69
67
  * - Browser `File` objects cannot be restored from persistence.
70
68
  * - We restore resumable items (those with a cursor) into the `paused` phase,
71
69
  * with `file` left undefined. Your UI can ask the user to rebind the file.
70
+ * @author wildduck2 <https://github.com/wildduck2>
72
71
  */
73
72
  export function deserializeSnapshot<
74
- M extends IntentMap,
75
- C extends CursorMap<M>,
73
+ M extends Contracts.Intent.Map,
74
+ C extends Contracts.Cursor.Map<M>,
76
75
  P extends string,
77
- R extends UploadResultBase,
78
- >(
79
- raw: unknown,
80
- opts: {
81
- isPurpose?: (value: string) => value is P
82
- isIntent?: (value: unknown) => value is M[keyof M]
83
- hasStrategy: (value: string) => boolean
84
- },
85
- ): UploadState<M, C, P, R> | null {
86
- if (!opts.isPurpose || !opts.isIntent) return null
76
+ R extends Contracts.Result.Base,
77
+ >(raw: unknown, opts: UploadPersistence.DeserializeContext<M, P>): Engine.State<M, C, P, R> | null {
78
+ if (!opts.isPurpose || !opts.isIntent || !opts.hasStrategy) return null
87
79
  if (!isPersistedSnapshot(raw)) return null
88
80
 
89
- const items = new Map<string, UploadItem<M, C, P, R>>()
81
+ const items = new Map<string, Engine.Item<M, C, P, R>>()
90
82
 
91
83
  for (const value of Object.values(raw.items)) {
92
84
  const parsed = parsePersistedItem(value)
@@ -95,7 +87,9 @@ export function deserializeSnapshot<
95
87
  if (!opts.isPurpose(parsed.purpose)) continue
96
88
  if (!opts.isIntent(parsed.intent)) continue
97
89
 
98
- const strategy = parsed.intent.strategy
90
+ const intentObj = parsed.intent
91
+ if (!isRecord(intentObj)) continue
92
+ const strategy = intentObj.strategy
99
93
  if (typeof strategy !== 'string' || !opts.hasStrategy(strategy)) continue
100
94
 
101
95
  if (!parsed.cursor || !isCursorForRegistry<C>(parsed.cursor, opts.hasStrategy)) continue
@@ -113,7 +107,7 @@ export function deserializeSnapshot<
113
107
  items.set(parsed.id, {
114
108
  phase: 'paused',
115
109
  localId: parsed.id,
116
- purpose: parsed.purpose,
110
+ purpose: parsed.purpose as P,
117
111
  fingerprint: {
118
112
  name: parsed.file.name,
119
113
  size: parsed.file.size,
@@ -121,54 +115,66 @@ export function deserializeSnapshot<
121
115
  lastModified: parsed.file.lastModified,
122
116
  checksum: parsed.file.checksum,
123
117
  },
124
- intent: parsed.intent as AnyIntent<M>,
118
+ intent: parsed.intent as Contracts.Intent.Any<M>,
125
119
  cursor: parsed.cursor,
126
120
  progress: { uploadedBytes, totalBytes, pct },
127
121
  pausedAt: Date.now(),
128
122
  createdAt: raw.createdAt ?? Date.now(),
129
123
  file: undefined,
130
- } as UploadItem<M, C, P, R>)
124
+ steps: [],
125
+ meta: {},
126
+ } as Engine.Item<M, C, P, R>)
131
127
  }
132
128
 
133
129
  return { items }
134
130
  }
135
131
 
136
- function isPersistedSnapshot(value: unknown): value is PersistedSnapshot<unknown, unknown, string> {
132
+ function isFiniteNumber(v: unknown): v is number {
133
+ return typeof v === 'number' && Number.isFinite(v)
134
+ }
135
+
136
+ function isPersistedSnapshot(
137
+ value: unknown,
138
+ ): value is UploadPersistence.Snapshot<Contracts.Intent.Map, Contracts.Cursor.Map<Contracts.Intent.Map>, string> {
137
139
  if (!isRecord(value)) return false
138
- if (typeof value.version !== 'number') return false
139
- if (typeof value.createdAt !== 'number') return false
140
- return isRecord(value.items)
140
+ if (!isFiniteNumber(value['version'])) return false
141
+ if (!isFiniteNumber(value['createdAt'])) return false
142
+ return isRecord(value['items'])
141
143
  }
142
144
 
143
145
  function parsePersistedItem(value: unknown): {
144
146
  id: string
145
147
  purpose: string
146
148
  status: string
147
- file: { name: string; size: number; type: string; lastModified: number; checksum?: string }
149
+ file: { name: string; size: number; type: string; lastModified: number; checksum?: string | undefined }
148
150
  intent: unknown
149
- cursor?: unknown
150
- progress?: { uploadedBytes: number; totalBytes: number; pct?: number }
151
+ cursor?: Contracts.Cursor.Any<Contracts.Cursor.Map<Contracts.Intent.Map>> | undefined
152
+ progress?: { uploadedBytes: number; totalBytes: number; pct?: number | undefined } | undefined
151
153
  } | null {
152
154
  if (!isRecord(value)) return null
153
155
 
154
- const id = typeof value.id === 'string' ? value.id : null
155
- const purpose = typeof value.purpose === 'string' ? value.purpose : null
156
- const status = typeof value.status === 'string' ? value.status : null
157
- const intent = value.intent
156
+ const id = typeof value['id'] === 'string' ? value['id'] : null
157
+ const purpose = typeof value['purpose'] === 'string' ? value['purpose'] : null
158
+ const status = typeof value['status'] === 'string' ? value['status'] : null
159
+ const intent = value['intent']
158
160
 
159
161
  if (!id || !purpose || !status) return null
160
162
 
161
- if (!isRecord(value.file)) return null
163
+ const fileObj = value['file']
164
+ if (!isRecord(fileObj)) return null
162
165
 
163
- const name = typeof value.file.name === 'string' ? value.file.name : null
164
- const size = typeof value.file.size === 'number' ? value.file.size : null
165
- const type = typeof value.file.type === 'string' ? value.file.type : null
166
- const lastModified = typeof value.file.lastModified === 'number' ? value.file.lastModified : null
167
- const checksum = typeof value.file.checksum === 'string' ? value.file.checksum : undefined
166
+ const name = typeof fileObj['name'] === 'string' ? fileObj['name'] : null
167
+ const rawSize = fileObj['size']
168
+ const size = isFiniteNumber(rawSize) && rawSize >= 0 ? rawSize : null
169
+ const type = typeof fileObj['type'] === 'string' ? fileObj['type'] : null
170
+ const rawLastModified = fileObj['lastModified']
171
+ const lastModified = isFiniteNumber(rawLastModified) ? rawLastModified : null
172
+ const checksum = typeof fileObj['checksum'] === 'string' ? fileObj['checksum'] : undefined
168
173
 
169
- if (!name || size === null || !type || lastModified === null) return null
174
+ if (!name || size === null || type === null || lastModified === null) return null
170
175
 
171
- const progress = parseProgress(value.progress)
176
+ const progress = parseProgress(value['progress'])
177
+ const cursor = value['cursor'] as Contracts.Cursor.Any<Contracts.Cursor.Map<Contracts.Intent.Map>> | undefined
172
178
 
173
179
  return {
174
180
  id,
@@ -176,24 +182,31 @@ function parsePersistedItem(value: unknown): {
176
182
  status,
177
183
  file: { name, size, type, lastModified, checksum },
178
184
  intent,
179
- cursor: value.cursor,
185
+ cursor,
180
186
  progress,
181
187
  }
182
188
  }
183
189
 
184
- function parseProgress(value: unknown): { uploadedBytes: number; totalBytes: number; pct?: number } | undefined {
190
+ function parseProgress(
191
+ value: unknown,
192
+ ): { uploadedBytes: number; totalBytes: number; pct?: number | undefined } | undefined {
185
193
  if (!isRecord(value)) return undefined
186
- if (typeof value.uploadedBytes !== 'number' || typeof value.totalBytes !== 'number') return undefined
187
- const pct = typeof value.pct === 'number' ? value.pct : undefined
188
- return { uploadedBytes: value.uploadedBytes, totalBytes: value.totalBytes, pct }
194
+ const uploadedBytes = value['uploadedBytes']
195
+ const totalBytes = value['totalBytes']
196
+ if (!isFiniteNumber(uploadedBytes) || !isFiniteNumber(totalBytes)) return undefined
197
+ if (uploadedBytes < 0 || totalBytes < 0) return undefined
198
+ const rawPct = value['pct']
199
+ const pct = isFiniteNumber(rawPct) ? rawPct : undefined
200
+ return { uploadedBytes, totalBytes, pct }
189
201
  }
190
202
 
191
203
  function isCursorForRegistry<C extends Record<string, unknown>>(
192
204
  value: unknown,
193
205
  hasStrategy: (value: string) => boolean,
194
- ): value is AnyCursor<C> {
206
+ ): value is Contracts.Cursor.Any<C> {
195
207
  if (!isRecord(value)) return false
196
- if (typeof value.strategy !== 'string') return false
197
- if (!hasStrategy(value.strategy)) return false
208
+ const strategy = value['strategy']
209
+ if (typeof strategy !== 'string') return false
210
+ if (!hasStrategy(strategy)) return false
198
211
  return true
199
212
  }
@@ -1,66 +1,146 @@
1
+ import type { Contracts } from '../contracts'
2
+ import type { Engine } from '../engine/engine.types'
3
+
1
4
  /**
2
- * A minimal, serializable representation of an upload item that is safe to store
3
- * in persistence (LocalStorage, IndexedDB, etc).
4
- *
5
- * This type is intentionally not the full {@link UploadItem} state.
6
- * It captures only the fields needed to:
7
- * - restore the UI list after refresh
8
- * - rebind an upload to its backend intent (if already created)
9
- * - resume an upload strategy from a known cursor (when supported)
10
- * - restore last-known progress and phase for UX continuity
11
- *
12
- * @typeParam M - Map of upload intent variants keyed by intent kind (e.g. `direct`, `multipart`).
13
- * @typeParam C - Map of cursor shapes keyed by {@link M}'s intent kinds.
14
- * @typeParam P - Union of allowed `purpose` strings for your app (e.g. `"avatar" | "document"`).
5
+ * Persistence layer interfaces and options.
6
+ * @author wildduck2 <https://github.com/wildduck2>
15
7
  */
16
- export type PersistedSnapshot<M, C, P extends string> = {
17
- /** Persistence schema version (not app version). */
18
- version: number
19
- /** Unix epoch milliseconds when this snapshot was written. */
20
- createdAt: number
21
- /** List of persisted upload items. */
22
- items: Record<string, PersistedUploadItem<M, C, P>>
23
- }
8
+ export namespace UploadPersistence {
9
+ /**
10
+ * Type context handed to deserializers to validate dynamic properties during recovery.
11
+ *
12
+ * @template M - Intent map type
13
+ * @template P - Purpose string union type
14
+ */
15
+ export type DeserializeContext<M extends Contracts.Intent.Map, P extends string> = {
16
+ /** Guard checking if a purpose string is valid. */
17
+ isPurpose?: ((value: string) => value is P) | undefined
18
+ /** Guard checking if an intent matches expected schemas. */
19
+ isIntent?: ((value: unknown) => value is M[keyof M]) | undefined
20
+ /** Query to verify if a strategy discriminant is active in runtime strategies. */
21
+ hasStrategy?: ((value: string) => boolean) | undefined
22
+ }
23
+
24
+ /**
25
+ * Configuration options for the upload store persistence manager.
26
+ *
27
+ * @template M - Intent map type
28
+ * @template C - Cursor map type
29
+ * @template P - Purpose string union type
30
+ * @template R - Backend result shape
31
+ */
32
+ export type Options<
33
+ M extends Contracts.Intent.Map,
34
+ C extends Contracts.Cursor.Map<M>,
35
+ P extends string,
36
+ R extends Contracts.Result.Base,
37
+ > = {
38
+ /** Target key used to store the serialized string representation of snapshots. */
39
+ key: string
40
+
41
+ /** Schema version identifier. Increment when changing models to run safe migrations. */
42
+ version: number
43
+
44
+ /** Milliseconds to wait before writing changes to disk (throttling storage writes). */
45
+ debounceMs?: number
46
+
47
+ /** Target storage adapter (e.g. LocalStorageAdapter, IndexedDBAdapter). */
48
+ adapter: Adapter
49
+
50
+ /**
51
+ * Optional custom serialization mapper.
52
+ */
53
+ serialize?: (state: Engine.State<M, C, P, R>, version: number) => Snapshot<M, C, P>
54
+
55
+ /**
56
+ * Optional custom deserialization mapper.
57
+ */
58
+ deserialize?: (raw: unknown, ctx: DeserializeContext<M, P>) => Engine.State<M, C, P, R> | null
59
+
60
+ /**
61
+ * Runtime validation guard for purpose strings. Required if using the default deserializer.
62
+ */
63
+ isPurpose?: ((value: string) => value is P) | undefined
24
64
 
25
- export type PersistedUploadItem<M, C, P extends string> = {
26
- /** Stable, client-generated ID for this upload in local state. */
27
- id: string
28
- /** File name. */
29
- file: {
30
- /** File name. */
31
- name: string
32
- /** File size in bytes. */
33
- size: number
34
- /** File MIME type. */
35
- type: string
36
- /** File lastModified timestamp (ms). */
37
- lastModified: number
38
- /** Optional checksum (example: SHA-256 hex). */
39
- checksum?: string
65
+ /**
66
+ * Runtime validation guard for intent objects. Required if using the default deserializer.
67
+ */
68
+ isIntent?: ((value: unknown) => value is M[keyof M]) | undefined
40
69
  }
41
- /** Backend-provided "intent" that describes how to upload (strategy, URLs, fields, etc). */
42
- intent: M[keyof M]
43
- /** Strategy cursor used to resume uploads that support continuation. */
44
- cursor?: import('../contracts').AnyCursor<C & Record<string, unknown>>
45
- /** Last-known progress for UX continuity after restoring from persistence. */
46
- progress?: { uploadedBytes: number; totalBytes: number; pct?: number }
47
- /** The last known state-machine phase at the time of persistence. */
48
- status: string
49
- /** Logical purpose for the upload (application-defined). */
50
- purpose: P
51
- }
52
70
 
53
- /**
54
- * A persisted snapshot of the upload store.
55
- *
56
- * Snapshots are versioned to allow safe migrations when the persistence schema evolves.
57
- * They are time-stamped for diagnostics and garbage-collection policies.
58
- */
59
- export interface PersistenceAdapter {
60
- /** Loads a snapshot from persistence. */
61
- load(key: string): unknown | null | Promise<unknown | null>
62
- /** Saves a snapshot to persistence. */
63
- save(key: string, snapshot: unknown): void | Promise<void>
64
- /** Clears a snapshot from persistence. */
65
- clear(key: string): void | Promise<void>
71
+ /**
72
+ * Plain-object representation of a stored upload item.
73
+ * Files cannot be fully serialized; hence their binary metadata is stashed.
74
+ */
75
+ export type PersistedItem<M extends Contracts.Intent.Map, C extends Contracts.Cursor.Map<M>, P extends string> = {
76
+ /** Unique client-side file identifier. */
77
+ id: string
78
+ /** Upload purpose. */
79
+ purpose: P
80
+ /** Target status phase key (e.g. `'uploading'`, `'paused'`). */
81
+ status: string
82
+ /** Serialized metadata of the file. */
83
+ file: {
84
+ /** Base name. */
85
+ name: string
86
+ /** Size in bytes. */
87
+ size: number
88
+ /** Claimed MIME type. */
89
+ type: string
90
+ /** Mod date timestamp in ms. */
91
+ lastModified: number
92
+ /** Checksum fingerprint hash if calculated. */
93
+ checksum?: string | undefined
94
+ }
95
+ /** The active backend intent payload. */
96
+ intent: Contracts.Intent.Any<M>
97
+ /** Strategy-defined resume cursor state. */
98
+ cursor?: Contracts.Cursor.Any<C> | undefined
99
+ /** Current progress measurements. */
100
+ progress?:
101
+ | {
102
+ uploadedBytes: number
103
+ totalBytes: number
104
+ pct?: number | undefined
105
+ }
106
+ | undefined
107
+ }
108
+
109
+ /**
110
+ * A minimal, serializable representation of an upload item that is safe to store
111
+ * in persistence (LocalStorage, IndexedDB, etc).
112
+ *
113
+ * @typeParam M - Map of upload intent variants keyed by intent kind (e.g. `direct`, `multipart`).
114
+ * @typeParam C - Map of cursor shapes keyed by {@link M}'s intent kinds.
115
+ * @typeParam P - Union of allowed `purpose` strings for your app (e.g. `"avatar" | "document"`).
116
+ */
117
+ export type Snapshot<M extends Contracts.Intent.Map, C extends Contracts.Cursor.Map<M>, P extends string> = {
118
+ /** Persistence schema version (not app version). */
119
+ version: number
120
+ /** Unix epoch milliseconds when this snapshot was written. */
121
+ createdAt: number
122
+ /** List of persisted upload items. */
123
+ items: Record<string, PersistedItem<M, C, P>>
124
+ }
125
+
126
+ /**
127
+ * Driver interface for persisting upload store snapshots.
128
+ */
129
+ export type Adapter = {
130
+ /**
131
+ * Resolves and returns the stored snapshot payload.
132
+ * Can return null if no snapshot exists.
133
+ */
134
+ load(key: string): unknown | null | Promise<unknown | null>
135
+
136
+ /**
137
+ * Saves a snapshot payload to the target storage location.
138
+ */
139
+ save(key: string, snapshot: unknown): void | Promise<void>
140
+
141
+ /**
142
+ * Deletes the snapshot record.
143
+ */
144
+ clear(key: string): void | Promise<void>
145
+ }
66
146
  }
@@ -0,0 +1,93 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { isRecord, stripDangerousKeys } from '../guards'
3
+
4
+ describe('stripDangerousKeys', () => {
5
+ test('removes top-level __proto__ / constructor / prototype', () => {
6
+ const raw = JSON.parse('{"__proto__":{"polluted":true},"a":1,"constructor":{"x":1},"prototype":{"y":2}}')
7
+ const out = stripDangerousKeys(raw)
8
+ expect(out).toEqual({ a: 1 })
9
+ expect(Object.prototype.hasOwnProperty.call(out, '__proto__')).toBe(false)
10
+ expect(Object.prototype.hasOwnProperty.call(out, 'constructor')).toBe(false)
11
+ expect(Object.prototype.hasOwnProperty.call(out, 'prototype')).toBe(false)
12
+ })
13
+
14
+ test('removes nested __proto__ inside plain branches', () => {
15
+ const raw = JSON.parse('{"items":{"a":{"__proto__":{"polluted":true},"v":1}}}')
16
+ const out = stripDangerousKeys(raw) as { items: { a: Record<string, unknown> } }
17
+ expect(out.items.a).toEqual({ v: 1 })
18
+ })
19
+
20
+ test('walks arrays', () => {
21
+ const raw = JSON.parse('{"list":[{"__proto__":{"x":1},"v":1},{"v":2}]}')
22
+ const out = stripDangerousKeys(raw) as { list: Array<Record<string, unknown>> }
23
+ expect(out.list[0]).toEqual({ v: 1 })
24
+ expect(out.list[1]).toEqual({ v: 2 })
25
+ })
26
+
27
+ test('depth cap of 16 does not throw on deeply nested input, returns input as-is past the cap', () => {
28
+ type Node = { child?: Node; v?: number; __proto__?: unknown }
29
+ let depth = 0
30
+ let leaf: Node = { v: 0 }
31
+ const root: Node = leaf
32
+ for (let i = 0; i < 20; i++) {
33
+ const next: Node = {}
34
+ leaf.child = next
35
+ leaf = next
36
+ depth++
37
+ }
38
+ // place a dangerous key beyond the depth cap (level 19 ≥ 16 → untouched)
39
+ const dangerousLeaf = JSON.parse('{"__proto__":{"polluted":true},"v":99}')
40
+ leaf.child = dangerousLeaf as Node
41
+ expect(() => stripDangerousKeys(root)).not.toThrow()
42
+ expect(depth).toBe(20)
43
+ })
44
+
45
+ test('does not pollute Object.prototype', () => {
46
+ const raw = JSON.parse('{"__proto__":{"polluted":true}}')
47
+ stripDangerousKeys(raw)
48
+ expect(({} as Record<string, unknown>)['polluted']).toBeUndefined()
49
+ })
50
+
51
+ test('leaves exotic objects (Date, Map) untouched', () => {
52
+ const d = new Date(0)
53
+ const m = new Map([['k', 1]])
54
+ const raw = { d, m, v: 1 } as Record<string, unknown>
55
+ const out = stripDangerousKeys(raw) as Record<string, unknown>
56
+ expect(out['d']).toBe(d)
57
+ expect(out['m']).toBe(m)
58
+ expect(out['v']).toBe(1)
59
+ })
60
+
61
+ test('returns primitives and null unchanged', () => {
62
+ expect(stripDangerousKeys(null as unknown)).toBe(null)
63
+ expect(stripDangerousKeys(42 as unknown)).toBe(42)
64
+ expect(stripDangerousKeys('s' as unknown)).toBe('s')
65
+ })
66
+ })
67
+
68
+ describe('isRecord (plain-record tightening)', () => {
69
+ test('rejects arrays', () => {
70
+ expect(isRecord([])).toBe(false)
71
+ expect(isRecord([1, 2])).toBe(false)
72
+ })
73
+
74
+ test('rejects Date', () => {
75
+ expect(isRecord(new Date())).toBe(false)
76
+ })
77
+
78
+ test('rejects Map', () => {
79
+ expect(isRecord(new Map())).toBe(false)
80
+ })
81
+
82
+ test('rejects class instances', () => {
83
+ class Thing {
84
+ a = 1
85
+ }
86
+ expect(isRecord(new Thing())).toBe(false)
87
+ })
88
+
89
+ test('accepts plain object literal and null-prototype object', () => {
90
+ expect(isRecord({ a: 1 })).toBe(true)
91
+ expect(isRecord(Object.create(null))).toBe(true)
92
+ })
93
+ })