@gentleduck/upload 0.2.6 → 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.
- package/README.md +170 -193
- package/dist/core/index.d.ts +2 -3
- package/dist/core/index.js +1 -1
- package/dist/core-BVgloTiP.js +10 -0
- package/dist/index-DCqyBVcx.d.ts +1441 -0
- package/dist/index-DsZHPmJm.d.ts +225 -0
- package/dist/index-KROXtLQh.d.ts +281 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +2 -51
- package/dist/react/index.js +1 -1
- package/dist/react-euPrDv24.js +1 -0
- package/dist/strategies/index.d.ts +2 -56
- package/dist/strategies/index.js +1 -1
- package/dist/strategies-CUF0s68j.js +1 -0
- package/package.json +3 -2
- package/src/{__test__ → __tests__}/emitter.test.ts +4 -4
- package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
- package/src/{__test__ → __tests__}/guards.test.ts +15 -5
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
- package/src/__tests__/sanitize-filename.test.ts +109 -0
- package/src/__tests__/sleep.test.ts +34 -0
- package/src/__tests__/static-analysis.test.ts +145 -0
- package/src/{__test__ → __tests__}/store-libs.test.ts +57 -3
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/{__test__ → __tests__}/transport.test.ts +5 -5
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +335 -141
- package/src/core/contracts/index.ts +3 -8
- package/src/core/contracts/transport/index.ts +3 -2
- package/src/core/contracts/transport/transport.fetch.ts +203 -0
- package/src/core/contracts/transport/transport.libs.ts +7 -8
- package/src/core/contracts/transport/transport.ts +91 -83
- package/src/core/contracts/transport/transport.types.ts +71 -35
- package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
- package/src/core/engine/engine.libs.ts +35 -0
- package/src/core/engine/engine.types.ts +367 -241
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +89 -43
- package/src/core/engine/store/handlers/add-file.ts +39 -29
- package/src/core/engine/store/handlers/cancel.ts +13 -8
- package/src/core/engine/store/handlers/clean-up.ts +11 -8
- package/src/core/engine/store/handlers/create-intent.ts +82 -27
- package/src/core/engine/store/handlers/finalize.ts +30 -13
- package/src/core/engine/store/handlers/index.ts +7 -7
- package/src/core/engine/store/handlers/pause.ts +8 -6
- package/src/core/engine/store/handlers/run-upload.ts +49 -34
- package/src/core/engine/store/index.ts +27 -21
- package/src/core/engine/store/store.dispatch.ts +10 -8
- package/src/core/engine/store/store.libs.ts +184 -41
- package/src/core/engine/store/store.runtime.ts +45 -36
- package/src/core/engine/store/store.schedule.ts +27 -21
- package/src/core/engine/store/store.types.ts +177 -227
- package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
- package/src/core/engine/validation/file.ts +7 -3
- package/src/core/engine/validation/index.ts +1 -0
- package/src/core/engine/validation/intent.ts +49 -27
- package/src/core/engine/validation/list.ts +14 -15
- package/src/core/engine/validation/mime.ts +39 -0
- package/src/core/errors.ts +271 -0
- package/src/core/index.ts +28 -5
- package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
- package/src/core/persistence/__tests__/persistence.test.ts +85 -0
- package/src/core/persistence/adapters.indexeddb.ts +9 -3
- package/src/core/persistence/adapters.local.ts +9 -3
- package/src/core/persistence/adapters.memory.ts +35 -13
- package/src/core/persistence/index.ts +5 -5
- package/src/core/persistence/persistence.ts +73 -60
- package/src/core/persistence/persistence.types.ts +140 -60
- package/src/core/utils/__tests__/guards.test.ts +93 -0
- package/src/core/utils/__tests__/url-safety.test.ts +112 -0
- package/src/core/utils/async.ts +7 -2
- package/src/core/utils/constants.ts +3 -9
- package/src/core/utils/emitter/emitter.ts +7 -24
- package/src/core/utils/emitter/emitter.types.ts +33 -16
- package/src/core/utils/emitter/index.ts +2 -74
- package/src/core/utils/fingerprint.ts +16 -5
- package/src/core/utils/guards.ts +91 -1
- package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
- package/src/core/utils/hash/index.ts +126 -0
- package/src/core/utils/hash/sha256.ts +179 -0
- package/src/core/utils/id.ts +33 -1
- package/src/core/utils/index.ts +15 -6
- package/src/core/utils/mime-sniff.ts +119 -0
- package/src/core/utils/sanitize-filename.ts +85 -0
- package/src/core/utils/url-safety.ts +156 -0
- package/src/react/__tests__/use-dropzone.test.ts +64 -0
- package/src/react/index.ts +5 -2
- package/src/react/upload-provider.tsx +68 -53
- package/src/react/uploader.types.ts +58 -61
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +98 -91
- package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
- package/src/strategies/_shared/retry.ts +79 -0
- package/src/strategies/index.ts +9 -15
- package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
- package/src/strategies/multipart/index.ts +206 -108
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +115 -33
- package/src/strategies/put/__tests__/put.test.ts +132 -0
- package/src/strategies/put/index.ts +139 -0
- package/src/strategies/registry/index.ts +12 -9
- package/src/strategies/tus/__tests__/tus.test.ts +159 -0
- package/src/strategies/tus/index.ts +211 -0
- package/dist/async-DxRWu4le.js +0 -1
- package/dist/core-CMkrP9DX.js +0 -2
- package/dist/index-C38Jmizu.d.ts +0 -1005
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/__test__/effect-pool.test.ts +0 -5
- package/src/__test__/id.test.ts +0 -14
- package/src/__test__/store-integration.test.ts +0 -5
- package/src/core/client/client.ts +0 -50
- package/src/core/client/client.types.ts +0 -184
- package/src/core/client/config.types.ts +0 -77
- package/src/core/client/index.ts +0 -4
- package/src/core/client/plugins.types.ts +0 -46
- package/src/core/client/retry.types.ts +0 -6
- package/src/core/client/uploadClient.ts +0 -19
- package/src/core/contracts/backendApi.ts +0 -62
- package/src/core/contracts/cursor.types.ts +0 -23
- package/src/core/contracts/errors.types.ts +0 -67
- package/src/core/contracts/fingerprint.types.ts +0 -18
- package/src/core/contracts/intent.types.ts +0 -39
- package/src/core/contracts/result.types.ts +0 -11
- package/src/core/contracts/strategy.types.ts +0 -45
- package/src/core/engine/commands.types.ts +0 -17
- package/src/core/engine/event-map.types.ts +0 -45
- package/src/core/engine/internal-events.types.ts +0 -202
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
- package/src/core/engine/store/handlers.ts +0 -819
- package/src/core/engine/validation.ts +0 -216
- /package/src/{__test__ → __tests__}/transport-libs.test.ts +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
3
|
-
export
|
|
4
|
-
export
|
|
5
|
-
export
|
|
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 {
|
|
2
|
-
import type {
|
|
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 {
|
|
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
|
|
15
|
-
C extends
|
|
14
|
+
M extends Contracts.Intent.Map,
|
|
15
|
+
C extends Contracts.Cursor.Map<M>,
|
|
16
16
|
P extends string,
|
|
17
|
-
R extends
|
|
18
|
-
>(state:
|
|
19
|
-
const items: Record<string,
|
|
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
|
-
|
|
26
|
-
|
|
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:
|
|
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
|
|
75
|
-
C extends
|
|
73
|
+
M extends Contracts.Intent.Map,
|
|
74
|
+
C extends Contracts.Cursor.Map<M>,
|
|
76
75
|
P extends string,
|
|
77
|
-
R extends
|
|
78
|
-
>(
|
|
79
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
139
|
-
if (
|
|
140
|
-
return isRecord(value
|
|
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?:
|
|
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
|
|
155
|
-
const purpose = typeof value
|
|
156
|
-
const status = typeof value
|
|
157
|
-
const intent = value
|
|
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
|
-
|
|
163
|
+
const fileObj = value['file']
|
|
164
|
+
if (!isRecord(fileObj)) return null
|
|
162
165
|
|
|
163
|
-
const name = typeof
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
const
|
|
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 ||
|
|
174
|
+
if (!name || size === null || type === null || lastModified === null) return null
|
|
170
175
|
|
|
171
|
-
const progress = parseProgress(value
|
|
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
|
|
185
|
+
cursor,
|
|
180
186
|
progress,
|
|
181
187
|
}
|
|
182
188
|
}
|
|
183
189
|
|
|
184
|
-
function parseProgress(
|
|
190
|
+
function parseProgress(
|
|
191
|
+
value: unknown,
|
|
192
|
+
): { uploadedBytes: number; totalBytes: number; pct?: number | undefined } | undefined {
|
|
185
193
|
if (!isRecord(value)) return undefined
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
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
|
|
206
|
+
): value is Contracts.Cursor.Any<C> {
|
|
195
207
|
if (!isRecord(value)) return false
|
|
196
|
-
|
|
197
|
-
if (
|
|
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
|
-
*
|
|
3
|
-
*
|
|
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
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
*/
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
})
|