feat(web): transferStore skeleton with versioned persistence
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { useTransferStore } from './transferStore';
|
||||
|
||||
describe('transferStore basics', () => {
|
||||
beforeEach(() => {
|
||||
useTransferStore.setState({ transfers: new Map() });
|
||||
if (typeof localStorage !== 'undefined') localStorage.clear();
|
||||
});
|
||||
|
||||
it('creates a transfer with queued state and unique id', () => {
|
||||
const id = useTransferStore.getState().createTransfer({
|
||||
type: 'upload',
|
||||
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
|
||||
tray: true,
|
||||
});
|
||||
const t = useTransferStore.getState().get(id);
|
||||
expect(t).toBeDefined();
|
||||
expect(t!.state).toBe('queued');
|
||||
expect(t!.progress).toEqual({ loaded: 0, total: 100 });
|
||||
});
|
||||
|
||||
it('createTransfer assigns unique ids across repeated calls', () => {
|
||||
const a = useTransferStore.getState().createTransfer({
|
||||
type: 'upload',
|
||||
file: { name: 'a', size: 1, mimetype: 'image/png' },
|
||||
tray: true,
|
||||
});
|
||||
const b = useTransferStore.getState().createTransfer({
|
||||
type: 'upload',
|
||||
file: { name: 'b', size: 1, mimetype: 'image/png' },
|
||||
tray: true,
|
||||
});
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('updateProgress mutates only the loaded count', () => {
|
||||
const id = useTransferStore.getState().createTransfer({
|
||||
type: 'upload',
|
||||
file: { name: 'a.png', size: 1000, mimetype: 'image/png' },
|
||||
tray: true,
|
||||
});
|
||||
useTransferStore.getState().updateProgress(id, 250);
|
||||
const t = useTransferStore.getState().get(id)!;
|
||||
expect(t.progress.loaded).toBe(250);
|
||||
expect(t.progress.total).toBe(1000);
|
||||
});
|
||||
|
||||
it('updateProgress on a missing id is a no-op', () => {
|
||||
useTransferStore.getState().updateProgress('not-real', 100);
|
||||
expect(useTransferStore.getState().get('not-real')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('setState_ moves through the state machine', () => {
|
||||
const id = useTransferStore.getState().createTransfer({
|
||||
type: 'upload',
|
||||
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
|
||||
tray: true,
|
||||
});
|
||||
const s = useTransferStore.getState();
|
||||
s.setState_(id, 'active');
|
||||
s.setState_(id, 'paused');
|
||||
s.setState_(id, 'completed');
|
||||
expect(useTransferStore.getState().get(id)!.state).toBe('completed');
|
||||
});
|
||||
|
||||
it('setError marks the transfer failed and stores the error payload', () => {
|
||||
const id = useTransferStore.getState().createTransfer({
|
||||
type: 'upload',
|
||||
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
|
||||
tray: true,
|
||||
});
|
||||
useTransferStore.getState().setError(id, { message: 'boom', permanent: true });
|
||||
const t = useTransferStore.getState().get(id)!;
|
||||
expect(t.state).toBe('failed');
|
||||
expect(t.error).toEqual({ message: 'boom', permanent: true });
|
||||
});
|
||||
|
||||
it('setTusUrl + setAttachmentId stores the metadata', () => {
|
||||
const id = useTransferStore.getState().createTransfer({
|
||||
type: 'upload',
|
||||
file: { name: 'a', size: 1, mimetype: 'image/png' },
|
||||
tray: true,
|
||||
});
|
||||
useTransferStore.getState().setTusUrl(id, '/api/files/abc', 5_000);
|
||||
useTransferStore.getState().setAttachmentId(id, 'att-9');
|
||||
const t = useTransferStore.getState().get(id)!;
|
||||
expect(t.tusUploadUrl).toBe('/api/files/abc');
|
||||
expect(t.tusExpiresAt).toBe(5_000);
|
||||
expect(t.attachmentId).toBe('att-9');
|
||||
});
|
||||
|
||||
it('remove drops a transfer; idempotent on missing id', () => {
|
||||
const id = useTransferStore.getState().createTransfer({
|
||||
type: 'upload',
|
||||
file: { name: 'a', size: 1, mimetype: 'image/png' },
|
||||
tray: true,
|
||||
});
|
||||
useTransferStore.getState().remove(id);
|
||||
expect(useTransferStore.getState().get(id)).toBeUndefined();
|
||||
// Idempotent
|
||||
expect(() => useTransferStore.getState().remove(id)).not.toThrow();
|
||||
expect(() => useTransferStore.getState().remove('not-real')).not.toThrow();
|
||||
});
|
||||
|
||||
it('listVisible omits tray:false transfers', () => {
|
||||
useTransferStore.getState().createTransfer({
|
||||
type: 'upload',
|
||||
file: { name: 'visible.png', size: 100, mimetype: 'image/png' },
|
||||
tray: true,
|
||||
});
|
||||
useTransferStore.getState().createTransfer({
|
||||
type: 'upload',
|
||||
file: { name: 'hidden.png', size: 100, mimetype: 'image/png' },
|
||||
tray: false,
|
||||
});
|
||||
expect(useTransferStore.getState().listVisible().length).toBe(1);
|
||||
expect(useTransferStore.getState().listVisible()[0]!.file.name).toBe('visible.png');
|
||||
});
|
||||
|
||||
it('listForChannel filters by channelId', () => {
|
||||
useTransferStore.getState().createTransfer({
|
||||
type: 'upload',
|
||||
file: { name: 'a', size: 1, mimetype: 'image/png' },
|
||||
tray: true,
|
||||
channelId: 'ch-1',
|
||||
});
|
||||
useTransferStore.getState().createTransfer({
|
||||
type: 'upload',
|
||||
file: { name: 'b', size: 1, mimetype: 'image/png' },
|
||||
tray: true,
|
||||
channelId: 'ch-2',
|
||||
});
|
||||
expect(useTransferStore.getState().listForChannel('ch-1').length).toBe(1);
|
||||
expect(useTransferStore.getState().listForChannel('ch-2').length).toBe(1);
|
||||
expect(useTransferStore.getState().listForChannel('ch-missing').length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transferStore persistence (localStorage)', () => {
|
||||
beforeEach(() => {
|
||||
useTransferStore.setState({ transfers: new Map() });
|
||||
if (typeof localStorage !== 'undefined') localStorage.clear();
|
||||
});
|
||||
|
||||
it('persists a transfer that has a tusUploadUrl across explicit rehydrate', async () => {
|
||||
const id = useTransferStore.getState().createTransfer({
|
||||
type: 'upload',
|
||||
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
|
||||
tray: true,
|
||||
});
|
||||
useTransferStore.getState().setTusUrl(id, '/api/files/abc', Date.now() + 60_000);
|
||||
|
||||
// Force flush — zustand persist writes synchronously to localStorage on update.
|
||||
// Read back from localStorage and confirm the transfer is in the serialized payload.
|
||||
const raw = localStorage.getItem('transferStore@v1');
|
||||
expect(raw).not.toBeNull();
|
||||
const parsed = JSON.parse(raw!);
|
||||
expect(parsed.state.transfers.length).toBe(1);
|
||||
expect(parsed.state.transfers[0][1].tusUploadUrl).toBe('/api/files/abc');
|
||||
});
|
||||
|
||||
it('does NOT persist queued transfers without a tusUploadUrl or attachmentId', () => {
|
||||
useTransferStore.getState().createTransfer({
|
||||
type: 'upload',
|
||||
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
|
||||
tray: true,
|
||||
});
|
||||
const raw = localStorage.getItem('transferStore@v1');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
expect(parsed.state.transfers.length).toBe(0);
|
||||
}
|
||||
// If raw is null, that also means "did not persist" — pass.
|
||||
});
|
||||
|
||||
it('does persist failed transfers (user can retry)', () => {
|
||||
const id = useTransferStore.getState().createTransfer({
|
||||
type: 'upload',
|
||||
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
|
||||
tray: true,
|
||||
});
|
||||
useTransferStore.getState().setError(id, { message: 'oops', permanent: false });
|
||||
const raw = localStorage.getItem('transferStore@v1');
|
||||
expect(raw).not.toBeNull();
|
||||
const parsed = JSON.parse(raw!);
|
||||
expect(parsed.state.transfers.length).toBe(1);
|
||||
expect(parsed.state.transfers[0][1].state).toBe('failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, type PersistStorage, type StorageValue } from 'zustand/middleware';
|
||||
|
||||
export type TransferType = 'upload' | 'download';
|
||||
export type TransferState =
|
||||
| 'queued' | 'active' | 'paused' | 'completed' | 'failed' | 'aborted';
|
||||
|
||||
export interface TransferError {
|
||||
message: string;
|
||||
permanent: boolean;
|
||||
}
|
||||
|
||||
export interface Transfer {
|
||||
id: string;
|
||||
type: TransferType;
|
||||
state: TransferState;
|
||||
file: { name: string; size: number; mimetype: string };
|
||||
progress: { loaded: number; total: number };
|
||||
error?: TransferError;
|
||||
channelId?: string;
|
||||
tray: boolean;
|
||||
origin?: string;
|
||||
|
||||
// Upload-specific
|
||||
tusUploadUrl?: string;
|
||||
tusExpiresAt?: number;
|
||||
fileHandleId?: string;
|
||||
attachmentId?: string;
|
||||
uploaderUserId?: string;
|
||||
|
||||
// Download-specific
|
||||
sourceUrl?: string;
|
||||
destFileHandleId?: string;
|
||||
bytesWrittenToDisk?: number;
|
||||
}
|
||||
|
||||
export interface CreateTransferInput {
|
||||
type: TransferType;
|
||||
file: { name: string; size: number; mimetype: string };
|
||||
tray: boolean;
|
||||
channelId?: string;
|
||||
origin?: string;
|
||||
fileHandleId?: string;
|
||||
destFileHandleId?: string;
|
||||
sourceUrl?: string;
|
||||
uploaderUserId?: string;
|
||||
}
|
||||
|
||||
interface TransferStoreState {
|
||||
transfers: Map<string, Transfer>;
|
||||
}
|
||||
|
||||
interface TransferStoreActions {
|
||||
createTransfer: (input: CreateTransferInput) => string;
|
||||
setState_: (id: string, state: TransferState) => void;
|
||||
updateProgress: (id: string, loaded: number) => void;
|
||||
setError: (id: string, error: TransferError) => void;
|
||||
setTusUrl: (id: string, url: string, expiresAt: number) => void;
|
||||
setAttachmentId: (id: string, attachmentId: string) => void;
|
||||
remove: (id: string) => void;
|
||||
|
||||
get: (id: string) => Transfer | undefined;
|
||||
listVisible: () => Transfer[];
|
||||
listForChannel: (channelId: string) => Transfer[];
|
||||
}
|
||||
|
||||
type TransferStore = TransferStoreState & TransferStoreActions;
|
||||
|
||||
function uuid(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Custom storage that serializes Map<string, Transfer> as an array of entries.
|
||||
// Only the `transfers` slice is persisted; partialize controls which entries.
|
||||
const mapAwareStorage: PersistStorage<Pick<TransferStoreState, 'transfers'>> = {
|
||||
getItem: (name) => {
|
||||
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(name) : null;
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { state: { transfers: [string, Transfer][] }; version?: number };
|
||||
const stateOut: Pick<TransferStoreState, 'transfers'> = {
|
||||
transfers: new Map<string, Transfer>(parsed.state.transfers ?? []),
|
||||
};
|
||||
return { state: stateOut, version: parsed.version } as StorageValue<Pick<TransferStoreState, 'transfers'>>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem: (name, value) => {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
const entries = Array.from(value.state.transfers.entries());
|
||||
const payload = JSON.stringify({ state: { transfers: entries }, version: value.version });
|
||||
localStorage.setItem(name, payload);
|
||||
},
|
||||
removeItem: (name) => {
|
||||
if (typeof localStorage !== 'undefined') localStorage.removeItem(name);
|
||||
},
|
||||
};
|
||||
|
||||
export const useTransferStore = create<TransferStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
transfers: new Map<string, Transfer>(),
|
||||
|
||||
createTransfer: (input) => {
|
||||
const id = uuid();
|
||||
const t: Transfer = {
|
||||
id,
|
||||
type: input.type,
|
||||
state: 'queued',
|
||||
file: input.file,
|
||||
progress: { loaded: 0, total: input.file.size },
|
||||
tray: input.tray,
|
||||
channelId: input.channelId,
|
||||
origin: input.origin,
|
||||
fileHandleId: input.fileHandleId,
|
||||
destFileHandleId: input.destFileHandleId,
|
||||
sourceUrl: input.sourceUrl,
|
||||
uploaderUserId: input.uploaderUserId,
|
||||
};
|
||||
set((s) => {
|
||||
const next = new Map(s.transfers);
|
||||
next.set(id, t);
|
||||
return { transfers: next };
|
||||
});
|
||||
return id;
|
||||
},
|
||||
|
||||
setState_: (id, state) => set((s) => {
|
||||
const t = s.transfers.get(id);
|
||||
if (!t) return s;
|
||||
const next = new Map(s.transfers);
|
||||
next.set(id, { ...t, state });
|
||||
return { transfers: next };
|
||||
}),
|
||||
|
||||
updateProgress: (id, loaded) => set((s) => {
|
||||
const t = s.transfers.get(id);
|
||||
if (!t) return s;
|
||||
const next = new Map(s.transfers);
|
||||
next.set(id, { ...t, progress: { ...t.progress, loaded } });
|
||||
return { transfers: next };
|
||||
}),
|
||||
|
||||
setError: (id, error) => set((s) => {
|
||||
const t = s.transfers.get(id);
|
||||
if (!t) return s;
|
||||
const next = new Map(s.transfers);
|
||||
next.set(id, { ...t, error, state: 'failed' });
|
||||
return { transfers: next };
|
||||
}),
|
||||
|
||||
setTusUrl: (id, tusUploadUrl, tusExpiresAt) => set((s) => {
|
||||
const t = s.transfers.get(id);
|
||||
if (!t) return s;
|
||||
const next = new Map(s.transfers);
|
||||
next.set(id, { ...t, tusUploadUrl, tusExpiresAt });
|
||||
return { transfers: next };
|
||||
}),
|
||||
|
||||
setAttachmentId: (id, attachmentId) => set((s) => {
|
||||
const t = s.transfers.get(id);
|
||||
if (!t) return s;
|
||||
const next = new Map(s.transfers);
|
||||
next.set(id, { ...t, attachmentId });
|
||||
return { transfers: next };
|
||||
}),
|
||||
|
||||
remove: (id) => set((s) => {
|
||||
if (!s.transfers.has(id)) return s;
|
||||
const next = new Map(s.transfers);
|
||||
next.delete(id);
|
||||
return { transfers: next };
|
||||
}),
|
||||
|
||||
get: (id) => get().transfers.get(id),
|
||||
listVisible: () => Array.from(get().transfers.values()).filter((t) => t.tray),
|
||||
listForChannel: (channelId) =>
|
||||
Array.from(get().transfers.values()).filter((t) => t.channelId === channelId),
|
||||
}),
|
||||
{
|
||||
name: 'transferStore@v1',
|
||||
storage: mapAwareStorage,
|
||||
version: 1,
|
||||
// Only persist transfers that are meaningful to rehydrate:
|
||||
// - completed uploads waiting for the deferred POST (have attachmentId)
|
||||
// - in-flight uploads with a tusUploadUrl (resumable on reload)
|
||||
// - failed transfers (user may want to retry / discard)
|
||||
// Drop active in-flight without a tusUrl (their state is lost), drop aborted (user discarded).
|
||||
partialize: (s) => ({
|
||||
transfers: new Map(
|
||||
Array.from(s.transfers.entries()).filter(([, t]) =>
|
||||
t.attachmentId !== undefined ||
|
||||
t.tusUploadUrl !== undefined ||
|
||||
t.state === 'failed' ||
|
||||
t.state === 'paused'
|
||||
)
|
||||
),
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user