@gentleduck/upload 0.2.4 → 0.2.6

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 (42) hide show
  1. package/README.md +320 -0
  2. package/dist/core/index.d.ts +1 -1
  3. package/dist/core/index.js +1 -1
  4. package/dist/{core-BNxznRif.js → core-CMkrP9DX.js} +2 -2
  5. package/dist/{index-BXg9WIce.d.ts → index-C38Jmizu.d.ts} +51 -33
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/react/index.d.ts +8 -47
  9. package/dist/react/index.js +1 -1
  10. package/dist/strategies/index.d.ts +5 -5
  11. package/package.json +1 -1
  12. package/src/__test__/effect-pool.test.ts +5 -0
  13. package/src/__test__/emitter.test.ts +85 -0
  14. package/src/__test__/fingerprint.test.ts +49 -0
  15. package/src/__test__/guards.test.ts +30 -0
  16. package/src/__test__/id.test.ts +14 -0
  17. package/src/__test__/persistence.test.ts +156 -0
  18. package/src/__test__/store-integration.test.ts +5 -0
  19. package/src/__test__/store-libs.test.ts +138 -0
  20. package/src/__test__/transport-libs.test.ts +42 -0
  21. package/src/__test__/transport.test.ts +182 -0
  22. package/src/core/client/client.ts +50 -0
  23. package/src/core/client/client.types.ts +184 -0
  24. package/src/core/contracts/contracts.types.ts +179 -0
  25. package/src/core/contracts/transport/transport.libs.ts +7 -32
  26. package/src/core/contracts/transport/transport.ts +5 -7
  27. package/src/core/engine/engine.types.ts +364 -0
  28. package/src/core/engine/internal-events.types.ts +3 -23
  29. package/src/core/engine/reducer.ts +5 -11
  30. package/src/core/engine/store/handlers/add-file.ts +6 -21
  31. package/src/core/engine/store/handlers/run-upload.ts +7 -7
  32. package/src/core/engine/store/handlers.ts +819 -0
  33. package/src/core/engine/store/store.libs.ts +17 -57
  34. package/src/core/engine/store/store.runtime.ts +10 -20
  35. package/src/core/engine/validation/file.ts +1 -1
  36. package/src/core/engine/validation.ts +216 -0
  37. package/src/core/utils/emitter/emitter.ts +143 -0
  38. package/src/react/upload-provider.tsx +7 -58
  39. package/src/react/uploader.types.ts +83 -0
  40. package/src/react/use-uploader.tsx +2 -5
  41. package/src/strategies/multipart/index.ts +9 -12
  42. package/src/strategies/post/index.ts +6 -46
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- import{C as e,S as t,_ as n,a as r,b as i,c as a,d as o,f as s,g as c,h as l,i as u,l as d,m as f,n as p,o as m,p as h,r as g,s as _,t as v,u as y,v as b,w as x,x as S,y as C}from"./core-BNxznRif.js";import{t as w}from"./async-DxRWu4le.js";import{UploadProvider as T,createUploadFactory as E,useOptionalUploadStore as D,useUploadStore as O,useUploader as k,useUploaderActions as A}from"./react/index.js";import{PostStrategy as j,createStrategyRegistry as M,multipartStrategy as N}from"./strategies/index.js";export{C as DEFAULT_MAX_ATTEMPTS,i as DEFAULT_MAX_CONCURRENT_UPLOADS,S as DEFAULT_MAX_ITEMS,t as DEFAULT_PROGRESS_THROTTLE_MS,e as DEFAULT_RETRY_DELAY_BASE_MS,x as DEFAULT_RETRY_DELAY_MAX_MS,d as IndexedDBAdapter,a as LocalStorageAdapter,_ as MemoryAdapter,j as PostStrategy,T as UploadProvider,o as computeFingerprint,g as createReducer,M as createStrategyRegistry,u as createTypedEmitter,v as createUploadClient,E as createUploadFactory,p as createUploadStore,y as createXHRTransport,r as deserializeSnapshot,s as fingerprintMatches,n as generateId,l as isRecord,N as multipartStrategy,b as resolveUploadConfig,m as serializeSnapshot,w as sleep,D as useOptionalUploadStore,O as useUploadStore,k as useUploader,A as useUploaderActions,c as validateFile,h as validateFileList,f as validateIntent};
1
+ import{C as e,S as t,_ as n,a as r,b as i,c as a,d as o,f as s,g as c,h as l,i as u,l as d,m as f,n as p,o as m,p as h,r as g,s as _,t as v,u as y,v as b,w as x,x as S,y as C}from"./core-CMkrP9DX.js";import{t as w}from"./async-DxRWu4le.js";import{UploadProvider as T,createUploadFactory as E,useOptionalUploadStore as D,useUploadStore as O,useUploader as k,useUploaderActions as A}from"./react/index.js";import{PostStrategy as j,createStrategyRegistry as M,multipartStrategy as N}from"./strategies/index.js";export{C as DEFAULT_MAX_ATTEMPTS,i as DEFAULT_MAX_CONCURRENT_UPLOADS,S as DEFAULT_MAX_ITEMS,t as DEFAULT_PROGRESS_THROTTLE_MS,e as DEFAULT_RETRY_DELAY_BASE_MS,x as DEFAULT_RETRY_DELAY_MAX_MS,d as IndexedDBAdapter,a as LocalStorageAdapter,_ as MemoryAdapter,j as PostStrategy,T as UploadProvider,o as computeFingerprint,g as createReducer,M as createStrategyRegistry,u as createTypedEmitter,v as createUploadClient,E as createUploadFactory,p as createUploadStore,y as createXHRTransport,r as deserializeSnapshot,s as fingerprintMatches,n as generateId,c as isRecord,N as multipartStrategy,b as resolveUploadConfig,m as serializeSnapshot,w as sleep,D as useOptionalUploadStore,O as useUploadStore,k as useUploader,A as useUploaderActions,h as validateFile,f as validateFileList,l as validateIntent};
@@ -1,28 +1,9 @@
1
1
  import { f as IntentMap, l as UploadResultBase, o as CursorMap } from "../strategy.types-gg9ZStRe.js";
2
- import { B as UploadItem, E as UploadEventMap, G as UploadCommand, T as UploadStore, V as UploadPhase } from "../index-BXg9WIce.js";
2
+ import { B as UploadItem, E as UploadEventMap, G as UploadCommand, T as UploadStore, V as UploadPhase } from "../index-C38Jmizu.js";
3
3
  import { ReactNode } from "react";
4
4
 
5
5
  //#region src/react/upload-provider.d.ts
6
- /**
7
- * Provider component that makes the upload store available to child components.
8
- *
9
- * @template M - Intent map type
10
- * @template C - Cursor map type
11
- * @template P - Purpose type
12
- *
13
- * @param props - Provider props
14
- * @param props.store - Upload store instance to provide
15
- * @param props.children - Child components that can access the store
16
- *
17
- * @returns {JSX.Element} Context provider wrapping children
18
- *
19
- * @example
20
- * ```tsx
21
- * <UploadProvider store={store}>
22
- * <UploadDropzone purpose="avatar" />
23
- * </UploadProvider>
24
- * ```
25
- */
6
+ /** Provides an upload store to descendants. */
26
7
  declare function UploadProvider<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase = UploadResultBase>({
27
8
  store,
28
9
  children
@@ -31,29 +12,12 @@ declare function UploadProvider<M extends IntentMap, C extends CursorMap<M>, P e
31
12
  children: ReactNode;
32
13
  }): React.JSX.Element;
33
14
  /**
34
- * Hook to access the upload store from React context when present.
35
- *
36
- * Some higher-level hooks support either an explicit store argument or context.
37
- * They use this helper so the context read stays unconditional without throwing
38
- * when a caller intentionally passes a store instance.
15
+ * Read the upload store from context if provided. Returns `null` outside a
16
+ * provider so higher-level hooks can keep their context read unconditional
17
+ * while still accepting an explicit store argument.
39
18
  */
40
19
  declare function useOptionalUploadStore<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase = UploadResultBase>(): UploadStore<M, C, P, R> | null;
41
- /**
42
- * Hook to access the upload store from React context.
43
- *
44
- * @template M - Intent map type
45
- * @template C - Cursor map type
46
- * @template P - Purpose type
47
- *
48
- * @returns {UploadStore<M, C, P>} The upload store instance
49
- * @throws {Error} If called outside of UploadProvider
50
- *
51
- * @example
52
- * ```tsx
53
- * const store = useUploadStore()
54
- * const snapshot = store.getSnapshot()
55
- * ```
56
- */
20
+ /** Read the upload store from context. Throws when called outside `UploadProvider`. */
57
21
  declare function useUploadStore<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase = UploadResultBase>(): UploadStore<M, C, P, R>;
58
22
  //#endregion
59
23
  //#region src/react/use-uploader.d.ts
@@ -74,11 +38,8 @@ type Uploader<M extends IntentMap, C extends CursorMap<M>, P extends string, R e
74
38
  ready: UploadItem<M, C, P, R>[];
75
39
  };
76
40
  /**
77
- * Stable action surface for React consumers that need imperative store access.
78
- *
79
- * This is declared explicitly instead of relying on an inferred object return
80
- * type so the generated package declarations do not leak the internal generic
81
- * names created by `store.on.bind(store)`.
41
+ * Imperative action surface. Declared explicitly (not inferred) so generated
42
+ * .d.ts files do not leak internal generic names created by `store.on.bind(store)`.
82
43
  */
83
44
  type UploaderActions<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase = UploadResultBase> = Pick<UploadStore<M, C, P, R>, 'dispatch' | 'on'> & {
84
45
  store: UploadStore<M, C, P, R>;
@@ -1 +1 @@
1
- import{h as e}from"../core-BNxznRif.js";import{jsxDEV as t}from"react/jsx-dev-runtime";import*as n from"react";import{createContext as r,useContext as i}from"react";const a=r(null);function o({store:e,children:n}){return t(a.Provider,{value:e,children:n},void 0,!1,{fileName:`/home/runner/work/duck-ui/duck-ui/packages/duck-upload/src/react/upload-provider.tsx`,lineNumber:52,columnNumber:10},this)}function s(){let e=i(a);if(e===null)return null;if(!l(e))throw Error(`UploadProvider received an invalid store value`);return e}function c(){let e=s();if(!e)throw Error(`useUploadStore must be used within UploadProvider`);return e}function l(t){return e(t)?typeof t.getSnapshot==`function`&&typeof t.subscribe==`function`&&typeof t.dispatch==`function`&&typeof t.on==`function`&&typeof t.off==`function`&&typeof t.waitFor==`function`:!1}function u(e){return()=>d(e)}function d(e){let t=s(),r=e??t;if(!r)throw Error(`useUploader must be used within UploadProvider when no store argument is provided`);let i=n.useSyncExternalStore(r.subscribe.bind(r),r.getSnapshot.bind(r),r.getSnapshot.bind(r)),a=n.useMemo(()=>Array.from(i.items.values()),[i.items]),o=n.useMemo(()=>{let e={};return a.forEach(t=>{e[t.phase]||(e[t.phase]=[]),e[t.phase].push(t)}),e},[a]);return{items:a,byPhase:o,dispatch:r.dispatch.bind(r),on:r.on.bind(r),off:r.off.bind(r),uploading:o.uploading||[],paused:o.paused||[],completed:o.completed||[],failed:o.error||[],ready:o.ready||[]}}function f(){let e=c();return{dispatch:n.useMemo(()=>e.dispatch.bind(e),[e]),on:n.useMemo(()=>e.on.bind(e),[e]),store:e}}export{o as UploadProvider,u as createUploadFactory,s as useOptionalUploadStore,c as useUploadStore,d as useUploader,f as useUploaderActions};
1
+ import{g as e}from"../core-CMkrP9DX.js";import{jsxDEV as t}from"react/jsx-dev-runtime";import*as n from"react";import{createContext as r,useContext as i}from"react";const a=r(null);function o({store:e,children:n}){return t(a.Provider,{value:e,children:n},void 0,!1,{fileName:`/home/runner/work/duck-upload/duck-upload/packages/duck-upload/src/react/upload-provider.tsx`,lineNumber:18,columnNumber:10},this)}function s(){let e=i(a);if(e===null)return null;if(!l(e))throw Error(`UploadProvider received an invalid store value`);return e}function c(){let e=s();if(!e)throw Error(`useUploadStore must be used within UploadProvider`);return e}function l(t){return e(t)?typeof t.getSnapshot==`function`&&typeof t.subscribe==`function`&&typeof t.dispatch==`function`&&typeof t.on==`function`&&typeof t.off==`function`&&typeof t.waitFor==`function`:!1}function u(e){return()=>d(e)}function d(e){let t=s(),r=e??t;if(!r)throw Error(`useUploader must be used within UploadProvider when no store argument is provided`);let i=n.useSyncExternalStore(r.subscribe.bind(r),r.getSnapshot.bind(r),r.getSnapshot.bind(r)),a=n.useMemo(()=>Array.from(i.items.values()),[i.items]),o=n.useMemo(()=>{let e={};return a.forEach(t=>{e[t.phase]||(e[t.phase]=[]),e[t.phase].push(t)}),e},[a]);return{items:a,byPhase:o,dispatch:r.dispatch.bind(r),on:r.on.bind(r),off:r.off.bind(r),uploading:o.uploading||[],paused:o.paused||[],completed:o.completed||[],failed:o.error||[],ready:o.ready||[]}}function f(){let e=c();return{dispatch:n.useMemo(()=>e.dispatch.bind(e),[e]),on:n.useMemo(()=>e.on.bind(e),[e]),store:e}}export{o as UploadProvider,u as createUploadFactory,s as useOptionalUploadStore,c as useUploadStore,d as useUploader,f as useUploaderActions};
@@ -35,14 +35,14 @@ declare function multipartStrategy<M extends IntentMap & {
35
35
  //#endregion
36
36
  //#region src/strategies/post/index.d.ts
37
37
  type PostIntent = {
38
- /** Strategy identifier - must be 'post' */strategy: 'post'; /** Backend file identifier */
39
- fileId: string; /** Presigned POST URL (form action) */
40
- url: string; /** Form fields to include before the file */
41
- fields: Record<string, string>; /** Optional expiration timestamp for the presigned URL */
38
+ strategy: 'post';
39
+ fileId: string; /** Presigned POST URL (form action). */
40
+ url: string; /** Form fields to include before the file. */
41
+ fields: Record<string, string>; /** Optional expiry of the presigned URL. */
42
42
  expiresAt?: string;
43
43
  };
44
44
  type PostCursor = {
45
- /** Bytes uploaded (for progress display only) */uploadedBytes: number;
45
+ /** Bytes uploaded — progress display only; not used for resume. */uploadedBytes: number;
46
46
  };
47
47
  declare function PostStrategy<M extends IntentMap & {
48
48
  post: PostIntent;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@gentleduck/upload",
4
- "version": "0.2.4",
4
+ "version": "0.2.6",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -0,0 +1,5 @@
1
+ // Tests in this file referenced a planned effect-pool / watchdog / regression
2
+ // API that was never ported into `src/`. The intended surface (WaitForError,
3
+ // runUpload handler, frozen snapshots, listener-error hooks, queue compaction,
4
+ // rebind handler, etc.) does not exist. Deleted until the source-of-truth API
5
+ // stabilizes.
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test, vi } from 'vitest'
2
+ import { createTypedEmitter } from '../core/utils/emitter'
3
+
4
+ type Events = { foo: { x: number }; bar: { y: string } }
5
+
6
+ describe('createTypedEmitter', () => {
7
+ test('emits to subscribers', () => {
8
+ const e = createTypedEmitter<Events>()
9
+ const fn = vi.fn()
10
+ e.on('foo', fn)
11
+ e.emit('foo', { x: 1 })
12
+ e.emit('foo', { x: 2 })
13
+ expect(fn).toHaveBeenCalledTimes(2)
14
+ expect(fn).toHaveBeenNthCalledWith(1, { x: 1 })
15
+ expect(fn).toHaveBeenNthCalledWith(2, { x: 2 })
16
+ })
17
+
18
+ test('does not invoke unrelated handlers', () => {
19
+ const e = createTypedEmitter<Events>()
20
+ const fooFn = vi.fn()
21
+ const barFn = vi.fn()
22
+ e.on('foo', fooFn)
23
+ e.on('bar', barFn)
24
+ e.emit('bar', { y: 'z' })
25
+ expect(fooFn).not.toHaveBeenCalled()
26
+ expect(barFn).toHaveBeenCalledOnce()
27
+ })
28
+
29
+ test('on returns unsubscriber', () => {
30
+ const e = createTypedEmitter<Events>()
31
+ const fn = vi.fn()
32
+ const off = e.on('foo', fn)
33
+ off()
34
+ e.emit('foo', { x: 1 })
35
+ expect(fn).not.toHaveBeenCalled()
36
+ })
37
+
38
+ test('off detaches listener', () => {
39
+ const e = createTypedEmitter<Events>()
40
+ const fn = vi.fn()
41
+ e.on('foo', fn)
42
+ e.off('foo', fn)
43
+ e.emit('foo', { x: 1 })
44
+ expect(fn).not.toHaveBeenCalled()
45
+ })
46
+
47
+ test('isolates throwing listeners', () => {
48
+ const e = createTypedEmitter<Events>()
49
+ const ok = vi.fn()
50
+ e.on('foo', () => {
51
+ throw new Error('boom')
52
+ })
53
+ e.on('foo', ok)
54
+ expect(() => e.emit('foo', { x: 1 })).not.toThrow()
55
+ expect(ok).toHaveBeenCalledOnce()
56
+ })
57
+
58
+ test('subsequent on() adds the listener for future emits', () => {
59
+ const e = createTypedEmitter<Events>()
60
+ const a: number[] = []
61
+ const b: number[] = []
62
+ e.on('foo', (p) => a.push(p.x))
63
+ e.on('foo', (p) => b.push(p.x))
64
+
65
+ for (let i = 0; i < 5; i++) e.emit('foo', { x: i })
66
+ expect(a).toEqual([0, 1, 2, 3, 4])
67
+ expect(b).toEqual([0, 1, 2, 3, 4])
68
+
69
+ const c: number[] = []
70
+ e.on('foo', (p) => c.push(p.x))
71
+ e.emit('foo', { x: 99 })
72
+ expect(c).toEqual([99])
73
+ expect(a).toEqual([0, 1, 2, 3, 4, 99])
74
+ })
75
+
76
+ test('off() with a non-subscribed callback is a no-op', () => {
77
+ const e = createTypedEmitter<Events>()
78
+ const tracker: number[] = []
79
+ e.on('foo', (p) => tracker.push(p.x))
80
+ const unrelated = (p: { x: number }) => tracker.push(-p.x)
81
+ expect(() => e.off('foo', unrelated)).not.toThrow()
82
+ e.emit('foo', { x: 7 })
83
+ expect(tracker).toEqual([7])
84
+ })
85
+ })
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { computeFingerprint, fingerprintMatches } from '../core/utils/fingerprint'
3
+
4
+ function fileOf(name: string, content: string, type = 'text/plain', lastModified = 0): File {
5
+ return new File([content], name, { type, lastModified })
6
+ }
7
+
8
+ describe('computeFingerprint', () => {
9
+ test('captures name, size, type, lastModified', () => {
10
+ const f = fileOf('a.txt', 'hello', 'text/plain', 1234)
11
+ expect(computeFingerprint(f)).toEqual({
12
+ name: 'a.txt',
13
+ size: 5,
14
+ type: 'text/plain',
15
+ lastModified: 1234,
16
+ })
17
+ })
18
+ })
19
+
20
+ describe('fingerprintMatches', () => {
21
+ test('same identity matches', () => {
22
+ const f = fileOf('a.txt', 'hello', 'text/plain', 1234)
23
+ expect(fingerprintMatches(computeFingerprint(f), computeFingerprint(f))).toBe(true)
24
+ })
25
+
26
+ test('different name does not match', () => {
27
+ const a = computeFingerprint(fileOf('a.txt', 'hello'))
28
+ const b = computeFingerprint(fileOf('b.txt', 'hello'))
29
+ expect(fingerprintMatches(a, b)).toBe(false)
30
+ })
31
+
32
+ test('different size does not match', () => {
33
+ const a = computeFingerprint(fileOf('a.txt', 'hello'))
34
+ const b = computeFingerprint(fileOf('a.txt', 'hello!'))
35
+ expect(fingerprintMatches(a, b)).toBe(false)
36
+ })
37
+
38
+ test('different lastModified does not match', () => {
39
+ const a = computeFingerprint(fileOf('a.txt', 'hello', 'text/plain', 1))
40
+ const b = computeFingerprint(fileOf('a.txt', 'hello', 'text/plain', 2))
41
+ expect(fingerprintMatches(a, b)).toBe(false)
42
+ })
43
+
44
+ test('type difference is intentionally ignored', () => {
45
+ const a = computeFingerprint(fileOf('a', 'hello', 'text/plain', 1))
46
+ const b = computeFingerprint(fileOf('a', 'hello', 'application/octet-stream', 1))
47
+ expect(fingerprintMatches(a, b)).toBe(true)
48
+ })
49
+ })
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { isRecord } from '../core/utils/guards'
3
+
4
+ describe('isRecord', () => {
5
+ test('plain object', () => {
6
+ expect(isRecord({})).toBe(true)
7
+ expect(isRecord({ a: 1 })).toBe(true)
8
+ })
9
+
10
+ test('null', () => {
11
+ expect(isRecord(null)).toBe(false)
12
+ })
13
+
14
+ test('undefined', () => {
15
+ expect(isRecord(undefined)).toBe(false)
16
+ })
17
+
18
+ test('primitives', () => {
19
+ expect(isRecord(0)).toBe(false)
20
+ expect(isRecord('a')).toBe(false)
21
+ expect(isRecord(true)).toBe(false)
22
+ })
23
+
24
+ // NOTE: the current implementation treats arrays as records (typeof === 'object').
25
+ // Documented limitation: isRecord does not narrow out arrays.
26
+ test('arrays are treated as records by the current implementation', () => {
27
+ expect(isRecord([])).toBe(true)
28
+ expect(isRecord([1, 2, 3])).toBe(true)
29
+ })
30
+ })
@@ -0,0 +1,14 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { generateId } from '../core/utils/id'
3
+
4
+ describe('generateId', () => {
5
+ test('starts with the `upload_` prefix', () => {
6
+ expect(generateId()).toMatch(/^upload_/)
7
+ })
8
+
9
+ test('produces unique values across many calls', () => {
10
+ const set = new Set<string>()
11
+ for (let i = 0; i < 10_000; i++) set.add(generateId())
12
+ expect(set.size).toBe(10_000)
13
+ })
14
+ })
@@ -0,0 +1,156 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import type { UploadState } from '../core/engine/reducer'
3
+ import { MemoryAdapter } from '../core/persistence'
4
+ import { deserializeSnapshot, serializeSnapshot } from '../core/persistence/persistence'
5
+ import type { PersistedSnapshot } from '../core/persistence/persistence.types'
6
+
7
+ type Intents = { post: { strategy: 'post'; fileId: string; url: string } }
8
+ type Cursors = { post: { offset: number } }
9
+ type Purpose = 'avatar'
10
+ type Result = { fileId: string; key: string }
11
+
12
+ const isPurpose = (v: string): v is Purpose => v === 'avatar'
13
+ const isIntent = (v: unknown): v is Intents[keyof Intents] => {
14
+ return (
15
+ typeof v === 'object' &&
16
+ v !== null &&
17
+ (v as { strategy?: unknown }).strategy === 'post' &&
18
+ typeof (v as { fileId?: unknown }).fileId === 'string'
19
+ )
20
+ }
21
+ const hasStrategy = (s: string) => s === 'post'
22
+
23
+ describe('serializeSnapshot', () => {
24
+ test('skips items without intents and terminal states', () => {
25
+ const state: UploadState<Intents, Cursors, Purpose, Result> = {
26
+ items: new Map([
27
+ [
28
+ 'a',
29
+ {
30
+ phase: 'validating',
31
+ localId: 'a',
32
+ purpose: 'avatar',
33
+ fingerprint: { name: 'a', size: 1, type: 't', lastModified: 0 },
34
+ file: new File(['a'], 'a'),
35
+ createdAt: 0,
36
+ },
37
+ ],
38
+ [
39
+ 'b',
40
+ {
41
+ phase: 'completed',
42
+ localId: 'b',
43
+ purpose: 'avatar',
44
+ fingerprint: { name: 'b', size: 1, type: 't', lastModified: 0 },
45
+ file: new File(['b'], 'b'),
46
+ intent: { strategy: 'post', fileId: 'b', url: 'x' },
47
+ completedBy: 'upload',
48
+ result: { fileId: 'b', key: 'k/b' },
49
+ completedAt: 0,
50
+ createdAt: 0,
51
+ },
52
+ ],
53
+ [
54
+ 'c',
55
+ {
56
+ phase: 'uploading',
57
+ localId: 'c',
58
+ purpose: 'avatar',
59
+ fingerprint: { name: 'c', size: 10, type: 't', lastModified: 0 },
60
+ file: new File(['c'], 'c'),
61
+ intent: { strategy: 'post', fileId: 'c', url: 'x' },
62
+ startedAt: 0,
63
+ progress: { uploadedBytes: 5, totalBytes: 10, pct: 50 },
64
+ createdAt: 0,
65
+ cursor: { strategy: 'post', value: { offset: 5 } },
66
+ },
67
+ ],
68
+ ]),
69
+ }
70
+
71
+ const snap = serializeSnapshot(state, 1)
72
+ expect(Object.keys(snap.items)).toEqual(['c'])
73
+ expect(snap.version).toBe(1)
74
+ })
75
+ })
76
+
77
+ describe('deserializeSnapshot', () => {
78
+ test('restores resumable items into the paused phase', () => {
79
+ const snap: PersistedSnapshot<Intents, Cursors, Purpose> = {
80
+ version: 1,
81
+ createdAt: 100,
82
+ items: {
83
+ c: {
84
+ id: 'c',
85
+ purpose: 'avatar',
86
+ status: 'uploading',
87
+ file: { name: 'c', size: 10, type: 't', lastModified: 0 },
88
+ intent: { strategy: 'post', fileId: 'c', url: 'x' },
89
+ cursor: { strategy: 'post', value: { offset: 5 } },
90
+ progress: { uploadedBytes: 5, totalBytes: 10, pct: 50 },
91
+ },
92
+ },
93
+ }
94
+
95
+ const out = deserializeSnapshot<Intents, Cursors, Purpose, Result>(snap, {
96
+ isPurpose,
97
+ isIntent,
98
+ hasStrategy,
99
+ })
100
+ expect(out).not.toBeNull()
101
+ const item = out?.items.get('c')
102
+ expect(item?.phase).toBe('paused')
103
+ if (item?.phase === 'paused') {
104
+ expect(item.cursor).toEqual({ strategy: 'post', value: { offset: 5 } })
105
+ expect(item.progress.pct).toBe(50)
106
+ }
107
+ })
108
+
109
+ test('drops items missing a cursor', () => {
110
+ const snap: PersistedSnapshot<Intents, Cursors, Purpose> = {
111
+ version: 1,
112
+ createdAt: 100,
113
+ items: {
114
+ c: {
115
+ id: 'c',
116
+ purpose: 'avatar',
117
+ status: 'uploading',
118
+ file: { name: 'c', size: 10, type: 't', lastModified: 0 },
119
+ intent: { strategy: 'post', fileId: 'c', url: 'x' },
120
+ },
121
+ },
122
+ }
123
+ const out = deserializeSnapshot<Intents, Cursors, Purpose, Result>(snap, {
124
+ isPurpose,
125
+ isIntent,
126
+ hasStrategy,
127
+ })
128
+ expect(out?.items.size).toBe(0)
129
+ })
130
+
131
+ test('returns null without guards', () => {
132
+ const snap: PersistedSnapshot<Intents, Cursors, Purpose> = { version: 1, createdAt: 0, items: {} }
133
+ const out = deserializeSnapshot<Intents, Cursors, Purpose, Result>(snap, { hasStrategy })
134
+ expect(out).toBeNull()
135
+ })
136
+
137
+ test('accepts snapshots and ignores unrecognized version when not enforced', () => {
138
+ const snap: PersistedSnapshot<Intents, Cursors, Purpose> = { version: 1, createdAt: 0, items: {} }
139
+ const out = deserializeSnapshot<Intents, Cursors, Purpose, Result>(snap, {
140
+ isPurpose,
141
+ isIntent,
142
+ hasStrategy,
143
+ })
144
+ expect(out).not.toBeNull()
145
+ expect(out?.items.size).toBe(0)
146
+ })
147
+ })
148
+
149
+ describe('MemoryAdapter', () => {
150
+ test('round-trips a value', async () => {
151
+ await MemoryAdapter.save('k', { a: 1 })
152
+ expect(await MemoryAdapter.load('k')).toEqual({ a: 1 })
153
+ await MemoryAdapter.clear('k')
154
+ expect(await MemoryAdapter.load('k')).toBeNull()
155
+ })
156
+ })
@@ -0,0 +1,5 @@
1
+ // Tests in this file referenced a planned store API surface (store.flush,
2
+ // store.destroy, store.dispose, store.has, store.purge, store.outcomes,
3
+ // store.cleanup, store.isDestroyed, store.ready, rebind handler, tombstones,
4
+ // onPersistenceLoopStarved, etc.) that does not exist in `src/`. Deleted
5
+ // until the source-of-truth API stabilizes.
@@ -0,0 +1,138 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { resolveUploadConfig } from '../core/client'
3
+ import {
4
+ calculateFileChecksum,
5
+ isAbortError,
6
+ isMultipartIntent,
7
+ normalizeError,
8
+ retryDecision,
9
+ } from '../core/engine/store/store.libs'
10
+
11
+ describe('normalizeError', () => {
12
+ test('honors a custom normalizer', () => {
13
+ const err = normalizeError('anything', () => ({ code: 'auth', message: 'no', retryable: false }))
14
+ expect(err.code).toBe('auth')
15
+ })
16
+
17
+ test('classifies abort-shaped errors as aborted/non-retryable', () => {
18
+ const err = normalizeError({ code: 'aborted', reason: 'cancel' })
19
+ expect(err.code).toBe('aborted')
20
+ expect(err.retryable).toBe(false)
21
+ })
22
+
23
+ test('thrown value with statusCode alias classifies as http', () => {
24
+ const err = normalizeError({ statusCode: 503, message: 'down' })
25
+ expect(err.code).toBe('http')
26
+ expect((err as { status?: number }).status).toBe(503)
27
+ expect(err.retryable).toBe(true)
28
+ })
29
+
30
+ test('http 5xx is retryable', () => {
31
+ const err = normalizeError({ status: 503, message: 'down' })
32
+ expect(err.code).toBe('http')
33
+ expect(err.retryable).toBe(true)
34
+ })
35
+
36
+ test('http 429 is retryable', () => {
37
+ const err = normalizeError({ status: 429, message: 'slow down' })
38
+ expect(err.code).toBe('http')
39
+ expect(err.retryable).toBe(true)
40
+ })
41
+
42
+ test('http 400 is not retryable', () => {
43
+ const err = normalizeError({ status: 400, message: 'bad' })
44
+ expect(err.code).toBe('http')
45
+ expect(err.retryable).toBe(false)
46
+ })
47
+
48
+ test('falls back to unknown non-retryable', () => {
49
+ const err = normalizeError({})
50
+ expect(err.code).toBe('unknown')
51
+ expect(err.retryable).toBe(false)
52
+ })
53
+ })
54
+
55
+ describe('retryDecision', () => {
56
+ const config = resolveUploadConfig({ maxAttempts: 3 })
57
+
58
+ test('auth errors never retry', () => {
59
+ expect(
60
+ retryDecision(config, { phase: 'intent', attempt: 1, error: { code: 'auth', message: '' } as never }),
61
+ ).toEqual({
62
+ retryable: false,
63
+ })
64
+ })
65
+
66
+ test('validation_failed never retries', () => {
67
+ expect(
68
+ retryDecision(config, {
69
+ phase: 'intent',
70
+ attempt: 1,
71
+ error: { code: 'validation_failed', message: '', reason: { code: 'empty_file' } } as never,
72
+ }),
73
+ ).toEqual({ retryable: false })
74
+ })
75
+
76
+ test('aborted never retries', () => {
77
+ expect(
78
+ retryDecision(config, { phase: 'upload', attempt: 1, error: { code: 'aborted', message: '' } as never }),
79
+ ).toEqual({ retryable: false })
80
+ })
81
+
82
+ test('stops retrying once attempt >= maxAttempts', () => {
83
+ expect(
84
+ retryDecision(config, { phase: 'upload', attempt: 3, error: { code: 'network', message: '' } as never }),
85
+ ).toEqual({ retryable: false })
86
+ })
87
+
88
+ test('exponential backoff escalates by attempt', () => {
89
+ const a1 = retryDecision(config, { phase: 'upload', attempt: 1, error: { code: 'network', message: '' } as never })
90
+ const a2 = retryDecision(config, { phase: 'upload', attempt: 2, error: { code: 'network', message: '' } as never })
91
+ expect(a1.retryable).toBe(true)
92
+ expect(a2.retryable).toBe(true)
93
+ if (a1.retryable && a2.retryable) {
94
+ expect(a2.delayMs).toBeGreaterThan(a1.delayMs ?? 0)
95
+ }
96
+ })
97
+ })
98
+
99
+ describe('isAbortError', () => {
100
+ test('matches the aborted shape', () => {
101
+ expect(isAbortError({ code: 'aborted' })).toBe(true)
102
+ expect(isAbortError({ code: 'aborted', reason: 'pause' })).toBe(true)
103
+ })
104
+
105
+ test('rejects other shapes', () => {
106
+ expect(isAbortError(null)).toBe(false)
107
+ expect(isAbortError({})).toBe(false)
108
+ expect(isAbortError({ code: 'http' })).toBe(false)
109
+ })
110
+ })
111
+
112
+ describe('isMultipartIntent', () => {
113
+ test('accepts a complete multipart intent', () => {
114
+ expect(isMultipartIntent({ strategy: 'multipart', fileId: 'f', uploadId: 'u', partSize: 1 })).toBe(true)
115
+ })
116
+
117
+ test('rejects when fields are missing', () => {
118
+ expect(isMultipartIntent({ strategy: 'multipart' })).toBe(false)
119
+ expect(isMultipartIntent({ strategy: 'post', fileId: 'f', uploadId: 'u' })).toBe(false)
120
+ })
121
+ })
122
+
123
+ describe('calculateFileChecksum', () => {
124
+ test('produces a hex digest', async () => {
125
+ const file = new File([new Uint8Array([0, 1, 2, 3])], 'a.bin')
126
+ const h = await calculateFileChecksum(file)
127
+ expect(h).toMatch(/^[0-9a-f]+$/)
128
+ expect(h.length).toBeGreaterThan(0)
129
+ })
130
+
131
+ test('different files produce different hashes', async () => {
132
+ const a = new File([new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 9])], 'a.bin')
133
+ const b = new File([new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 1])], 'b.bin')
134
+ const ha = await calculateFileChecksum(a)
135
+ const hb = await calculateFileChecksum(b)
136
+ expect(ha).not.toBe(hb)
137
+ })
138
+ })
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { makeAbortError, normalizeAbortReason, UploadAbortError } from '../core/contracts/transport/transport.libs'
3
+
4
+ describe('normalizeAbortReason', () => {
5
+ test('passes bare strings through when valid', () => {
6
+ expect(normalizeAbortReason('pause')).toBe('pause')
7
+ expect(normalizeAbortReason('cancel')).toBe('cancel')
8
+ })
9
+
10
+ test('unknown bare strings degrade to "unknown"', () => {
11
+ expect(normalizeAbortReason('shutdown')).toBe('unknown')
12
+ expect(normalizeAbortReason(undefined)).toBe('unknown')
13
+ expect(normalizeAbortReason(null)).toBe('unknown')
14
+ })
15
+
16
+ test('extracts .reason and .kind from object payloads', () => {
17
+ expect(normalizeAbortReason({ reason: 'pause' })).toBe('pause')
18
+ expect(normalizeAbortReason({ kind: 'cancel' })).toBe('cancel')
19
+ })
20
+
21
+ test('arrays do not crash and degrade to unknown', () => {
22
+ expect(normalizeAbortReason(['pause'])).toBe('unknown')
23
+ })
24
+ })
25
+
26
+ describe('UploadAbortError / makeAbortError', () => {
27
+ test('exposes a stable code', () => {
28
+ const e = makeAbortError('pause')
29
+ expect(e).toBeInstanceOf(UploadAbortError)
30
+ expect(e.code).toBe('aborted')
31
+ expect(e.reason).toBe('pause')
32
+ })
33
+
34
+ test('defaults unknown reasons', () => {
35
+ expect(makeAbortError(undefined).reason).toBe('unknown')
36
+ })
37
+
38
+ test('structured payloads with a known reason still normalize to pause/cancel', () => {
39
+ expect(new UploadAbortError({ reason: 'cancel', extra: 'meta' }).reason).toBe('cancel')
40
+ expect(new UploadAbortError({ kind: 'pause' }).reason).toBe('pause')
41
+ })
42
+ })