feat(store): queue task refactor (#287)

This commit is contained in:
rahim
2026-01-05 16:24:18 +11:00
committed by GitHub
parent 44cd824f3b
commit 6a7acdabfe
9 changed files with 941 additions and 296 deletions
+201 -79
View File
@@ -1,5 +1,6 @@
import type { Request, RequestMeta } from './request';
import { tryCatch } from '@videojs/utils/function';
import { isFunction, isUndefined } from '@videojs/utils/predicate';
import { StoreError } from './errors';
@@ -37,18 +38,62 @@ export type DefaultTaskRecord = Record<TaskKey, Request<unknown, unknown>>;
export type EnsureTaskRecord<T> = T extends TaskRecord ? T : never;
/**
* Pending task info.
* Base fields shared by all task states.
*/
export interface PendingTask<Key extends TaskKey = TaskKey, Input = unknown> {
export interface TaskBase<Key extends TaskKey = TaskKey, Input = unknown> {
id: symbol;
name: string;
key: Key;
input: Input;
startedAt: number;
abort: AbortController;
meta: RequestMeta | null;
}
/**
* Pending task - request in flight.
*/
export interface PendingTask<Key extends TaskKey = TaskKey, Input = unknown> extends TaskBase<Key, Input> {
status: 'pending';
abort: AbortController;
}
/**
* Success task - completed successfully.
*/
export interface SuccessTask<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown> extends TaskBase<
Key,
Input
> {
status: 'success';
settledAt: number;
output: Output;
}
/**
* Error task - failed or cancelled.
*/
export interface ErrorTask<Key extends TaskKey = TaskKey, Input = unknown> extends TaskBase<Key, Input> {
status: 'error';
settledAt: number;
error: unknown;
cancelled: boolean;
}
/**
* Task with status discriminator.
*/
export type Task<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown>
= | PendingTask<Key, Input>
| SuccessTask<Key, Input, Output>
| ErrorTask<Key, Input>;
/**
* Settled task (success or error).
*/
export type SettledTask<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown>
= | SuccessTask<Key, Input, Output>
| ErrorTask<Key, Input>;
/**
* Context passed to task handler.
*/
@@ -90,13 +135,7 @@ export interface QueueConfig<Tasks extends TaskRecord = DefaultTaskRecord> {
/** Default scheduler when task has no schedule */
scheduler?: TaskScheduler;
onDispatch?: <K extends keyof Tasks>(task: PendingTask<TaskKey<K>, Tasks[K]['input']>) => void;
onSettled?: <K extends keyof Tasks>(
task: PendingTask<TaskKey<K>, Tasks[K]['input']>,
result:
| { status: 'success'; duration: number; output: Tasks[K]['output'] }
| { status: 'cancelled'; error: unknown; duration: number }
| { status: 'error'; error: unknown; duration: number },
) => void;
onSettled?: <K extends keyof Tasks>(task: SettledTask<TaskKey<K>, Tasks[K]['input'], Tasks[K]['output']>) => void;
}
export interface QueuedTaskId<Key extends TaskKey = TaskKey> {
@@ -112,16 +151,19 @@ export type QueuedRecord<Tasks extends TaskRecord> = {
[K in keyof Tasks]?: QueuedTask<TaskKey<K>>;
};
export type PendingRecord<Tasks extends TaskRecord> = {
[K in keyof Tasks]?: PendingTask<TaskKey<K>, Tasks[K]['input']>;
/**
* Map of task key -> task (pending, success, or error).
*/
export type TasksRecord<Tasks extends TaskRecord> = {
[K in keyof Tasks]?: Task<TaskKey<K>, Tasks[K]['input'], Tasks[K]['output']>;
};
/**
* Listener callback for pending state changes.
* Listener callback for task state changes.
*
* Called when tasks are dispatched or settled.
* Called when tasks are dispatched, settled, or reset.
*/
export type QueueListener<Tasks extends TaskRecord> = (pending: PendingRecord<Tasks>) => void;
export type QueueListener<Tasks extends TaskRecord> = (tasks: TasksRecord<Tasks>) => void;
// ----------------------------------------
// Schedulers
@@ -168,36 +210,27 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
readonly #subscribers = new Set<QueueListener<Tasks>>();
#queued: QueuedRecord<Tasks> = {};
#pending: PendingRecord<Tasks> = {};
#tasks: TasksRecord<Tasks> = {};
#destroyed = false;
constructor(config: QueueConfig<Tasks> = {}) {
this.#scheduler = config.scheduler ?? microtask;
// Wrap callbacks to catch errors and prevent breaking queue/scheduler
const safeCallback = <Args extends [PendingTask, ...unknown[]]>(
callback: ((...args: Args) => unknown) | undefined,
) => {
if (!callback) return undefined;
return (...args: Args) => {
try {
callback(...args);
} catch (e) {
console.error('[vjs-queue]', e);
}
};
};
this.#onDispatch = safeCallback(config.onDispatch);
this.#onSettled = safeCallback(config.onSettled);
const logError = (e: unknown) => console.error('[vjs-queue]', e);
this.#onDispatch = tryCatch(config.onDispatch, logError);
this.#onSettled = tryCatch(config.onSettled, logError);
}
get queued(): Readonly<PublicQueuedRecord<Tasks>> {
return Object.freeze({ ...this.#queued });
}
get pending(): Readonly<PendingRecord<Tasks>> {
return Object.freeze({ ...this.#pending });
/**
* Map of task key -> task (pending, success, or error).
*/
get tasks(): Readonly<TasksRecord<Tasks>> {
return Object.freeze({ ...this.#tasks });
}
get destroyed(): boolean {
@@ -208,7 +241,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
* Check if a task with the given key is currently pending (executing).
*/
isPending(key: keyof Tasks): boolean {
return key in this.#pending;
return this.#tasks[key]?.status === 'pending';
}
/**
@@ -219,11 +252,53 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
}
/**
* Subscribe to pending state changes.
* Check if a task with the given key is settled (success or error).
*/
isSettled(key: keyof Tasks): boolean {
const task = this.#tasks[key];
return task?.status === 'success' || task?.status === 'error';
}
/**
* Clear settled task(s).
*
* Fires when tasks are dispatched or settled.
* - If key provided: clears that specific settled task (no-op if pending or doesn't exist)
* - If no key: clears all settled tasks (pending tasks are preserved)
*
* @param listener - Callback receiving the current pending map
* @param key - Optional task key to reset. If omitted, resets all settled tasks.
*/
reset(key?: keyof Tasks): void {
if (!isUndefined(key)) {
const task = this.#tasks[key];
if (!task || task.status === 'pending') return;
delete this.#tasks[key];
this.#notifySubscribers();
return;
}
// Reset all settled tasks
let cleared = false;
for (const key of Reflect.ownKeys(this.#tasks)) {
const task = this.#tasks[key];
if (task && task.status !== 'pending') {
delete this.#tasks[key];
cleared = true;
}
}
if (cleared) {
this.#notifySubscribers();
}
}
/**
* Subscribe to task state changes.
*
* Fires when tasks are dispatched, settled, or reset.
*
* @param listener - Callback receiving the current tasks map
* @returns Unsubscribe function
*/
subscribe(listener: QueueListener<Tasks>): () => void {
@@ -236,7 +311,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
#notifySubscribers(): void {
if (this.#subscribers.size === 0) return;
const snapshot = this.pending;
const snapshot = this.tasks;
for (const listener of this.#subscribers) {
try {
listener(snapshot);
@@ -262,7 +337,13 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
delete this.#queued[key];
// Abort any pending task with same key
this.#pending[key]?.abort.abort(new StoreError('SUPERSEDED'));
const existing = this.#tasks[key];
if (existing?.status === 'pending') {
existing.abort.abort(new StoreError('SUPERSEDED'));
}
// Clear any settled task for this key (new request replaces it)
delete this.#tasks[key];
return new Promise<Tasks[K]['output']>((resolve, reject) => {
const task: QueuedTask = {
@@ -307,24 +388,37 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
});
}
dequeue<K extends keyof Tasks>(key: K): boolean {
const queued = this.#queued[key];
if (!queued) return false;
/**
* Cancel queued task(s) waiting to execute.
*
* - If key provided: cancels that specific queued task
* - If no key: cancels all queued tasks
*
* @param key - Optional task key to cancel
* @returns true if any task was cancelled
*/
cancel(key?: keyof Tasks): boolean {
if (!isUndefined(key)) {
const queued = this.#queued[key];
if (!queued) return false;
queued.invalidate?.();
queued.reject(new StoreError('REMOVED'));
delete this.#queued[key];
queued.invalidate?.();
queued.reject(new StoreError('REMOVED'));
delete this.#queued[key];
return true;
}
return true;
}
clear(): void {
// Cancel all queued
const hadQueued = Object.keys(this.#queued).length > 0;
for (const queued of Object.values(this.#queued)) {
queued.invalidate?.();
queued.reject(new StoreError('REMOVED'));
}
this.#queued = {};
return hadQueued;
}
async flush(key?: keyof Tasks): Promise<void> {
@@ -338,18 +432,32 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
await Promise.allSettled(keys.map(k => this.#flushKey(k)));
}
abort<K extends keyof Tasks>(key: K): void {
// Reject queued
const queued = this.#queued[key];
queued?.invalidate?.();
queued?.reject(new StoreError('ABORTED'));
delete this.#queued[key];
/**
* Abort task(s) - both queued (waiting) and pending (executing).
*
* - If key provided: aborts that specific task
* - If no key: aborts all tasks
*
* @param key - Optional task key to abort
*/
abort(key?: keyof Tasks): void {
if (!isUndefined(key)) {
// Reject queued
const queued = this.#queued[key];
queued?.invalidate?.();
queued?.reject(new StoreError('ABORTED'));
delete this.#queued[key];
// Abort pending
this.#pending[key]?.abort.abort(new StoreError('ABORTED'));
}
// Abort pending task
const task = this.#tasks[key];
if (task?.status === 'pending') {
task.abort.abort(new StoreError('ABORTED'));
}
abortAll(): void {
return;
}
// Abort all
const error = new StoreError('ABORTED');
// Reject all queued
@@ -360,9 +468,11 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
this.#queued = {};
// Abort all pending
for (const pending of Object.values(this.#pending)) {
pending.abort.abort(error);
// Abort all pending tasks
for (const task of Object.values(this.#tasks)) {
if (task?.status === 'pending') {
task.abort.abort(error);
}
}
}
@@ -370,8 +480,9 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
if (this.#destroyed) return;
this.#destroyed = true;
this.abortAll();
this.abort();
this.#subscribers.clear();
this.#tasks = {};
}
async #flushKey(key: keyof Tasks): Promise<void> {
@@ -393,7 +504,8 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
const abort = new AbortController();
const startedAt = Date.now();
const pending: PendingTask = {
const pendingTask: PendingTask = {
status: 'pending',
id,
name,
key,
@@ -403,9 +515,9 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
meta,
};
this.#pending[key as keyof Tasks] = pending;
this.#tasks[key as keyof Tasks] = pendingTask;
this.#notifySubscribers();
this.#onDispatch?.(pending);
this.#onDispatch?.(pendingTask);
try {
if (abort.signal.aborted) {
@@ -420,28 +532,38 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
resolve(result);
this.#onSettled?.(pending, {
const successTask: SuccessTask = {
...pendingTask,
status: 'success',
duration: Date.now() - startedAt,
settledAt: Date.now(),
output: result,
});
};
// Only update if we're still the current task for this key
if (this.#tasks[key as keyof Tasks] === pendingTask) {
this.#tasks[key as keyof Tasks] = successTask;
this.#notifySubscribers();
}
this.#onSettled?.(successTask);
} catch (error) {
reject(error);
const cancelled = abort.signal.aborted;
this.#onSettled?.(pending, {
status: cancelled ? 'cancelled' : 'error',
duration: Date.now() - startedAt,
const errorTask: ErrorTask = {
...pendingTask,
status: 'error',
settledAt: Date.now(),
error,
});
} finally {
const currentPending = this.#pending[key as keyof Tasks];
// Only remove if we're still the pending task for this key
if (currentPending === pending) {
delete this.#pending[key];
cancelled: abort.signal.aborted,
};
// Only update if we're still the current task for this key
if (this.#tasks[key as keyof Tasks] === pendingTask) {
this.#tasks[key as keyof Tasks] = errorTask;
this.#notifySubscribers();
}
this.#onSettled?.(errorTask);
}
}
}
+5 -4
View File
@@ -1,4 +1,4 @@
import type { PendingTask, TaskContext } from './queue';
import type { PendingTask, Task, TaskContext } from './queue';
import type { RequestMeta, RequestMetaInit, ResolvedRequestConfig } from './request';
import type { AnySlice, InferSliceTarget, Slice, UnionSliceRequests, UnionSliceState, UnionSliceTasks } from './slice';
import type { StateFactory } from './state';
@@ -138,7 +138,7 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
this.#attachAbort?.abort();
this.#attachAbort = null;
this.#target = null;
this.#queue.abortAll();
this.#queue.abort();
this.#resetState();
}
@@ -318,10 +318,11 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
handler,
});
} catch (error) {
const pending = this.#queue.pending as Record<string | symbol, PendingTask | undefined>;
const tasks = this.#queue.tasks as Record<string | symbol, Task | undefined>;
const task = tasks[key];
this.#handleError({
request: pending[key],
request: task?.status === 'pending' ? task : undefined,
error,
});
+434 -30
View File
@@ -163,8 +163,8 @@ describe('queue', () => {
});
});
describe('dequeue', () => {
it('removes queued task', async () => {
describe('cancel', () => {
it('cancel(key) removes specific queued task', async () => {
const queue = createQueue({
scheduler: delay(100),
});
@@ -172,29 +172,33 @@ describe('queue', () => {
const handler = vi.fn();
const promise = queue.enqueue({ name: 'test', key: 'k', handler });
expect(queue.dequeue('k')).toBe(true);
expect(queue.dequeue('k')).toBe(false);
expect(queue.cancel('k')).toBe(true);
expect(queue.cancel('k')).toBe(false);
vi.advanceTimersByTime(100);
await expect(promise).rejects.toMatchObject({ code: 'REMOVED' });
expect(handler).not.toHaveBeenCalled();
});
});
describe('clear', () => {
it('clears all queued tasks', async () => {
it('cancel() clears all queued tasks', async () => {
const queue = createQueue({ scheduler: delay(100) });
const p1 = queue.enqueue({ name: 'a', key: 'a', handler: vi.fn() });
const p2 = queue.enqueue({ name: 'b', key: 'b', handler: vi.fn() });
queue.clear();
expect(queue.cancel()).toBe(true);
vi.advanceTimersByTime(100);
await expect(p1).rejects.toMatchObject({ code: 'REMOVED' });
await expect(p2).rejects.toMatchObject({ code: 'REMOVED' });
expect(Reflect.ownKeys(queue.queued).length).toBe(0);
});
it('cancel() returns false when no queued tasks', () => {
const queue = createQueue();
expect(queue.cancel()).toBe(false);
});
});
describe('flush', () => {
@@ -280,7 +284,7 @@ describe('queue', () => {
);
});
it('onSettled called with success', async () => {
it('onSettled called with success task', async () => {
vi.useRealTimers();
const onSettled = vi.fn();
@@ -293,12 +297,11 @@ describe('queue', () => {
});
expect(onSettled).toHaveBeenCalledWith(
expect.objectContaining({ name: 'task' }),
expect.objectContaining({ status: 'success' }),
expect.objectContaining({ name: 'task', status: 'success', output: 'done' }),
);
});
it('onSettled called with error status', async () => {
it('onSettled called with error task', async () => {
vi.useRealTimers();
const onSettled = vi.fn();
@@ -313,10 +316,10 @@ describe('queue', () => {
});
await expect(promise).rejects.toThrow('oops');
expect(onSettled).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ status: 'error' }));
expect(onSettled).toHaveBeenCalledWith(expect.objectContaining({ status: 'error', cancelled: false }));
});
it('onSettled called with cancelled status', async () => {
it('onSettled called with cancelled task', async () => {
vi.useRealTimers();
const onSettled = vi.fn();
@@ -339,8 +342,7 @@ describe('queue', () => {
await promise.catch(() => {});
expect(onSettled).toHaveBeenCalledWith(
expect.objectContaining({ name: 'first' }),
expect.objectContaining({ status: 'cancelled' }),
expect.objectContaining({ name: 'first', status: 'error', cancelled: true }),
);
});
});
@@ -436,7 +438,7 @@ describe('queue', () => {
// Wait for task to start
await new Promise(r => setTimeout(r, 10));
expect(Reflect.ownKeys(queue.pending).length).toBe(1);
expect(queue.tasks.k?.status).toBe('pending');
// Destroy queue
queue.destroy();
@@ -446,8 +448,8 @@ describe('queue', () => {
expect(cleanupSpy).toHaveBeenCalledWith('aborted');
expect(cleanupSpy).toHaveBeenCalledWith('cleanup');
// Pending object should be empty (self-cleaned)
expect(Reflect.ownKeys(queue.pending).length).toBe(0);
// After destroy, all tasks are cleared for memory cleanup
expect(queue.tasks.k).toBeUndefined();
});
it('handles scheduler error without double-cleanup when already flushed', async () => {
@@ -575,7 +577,7 @@ describe('queue', () => {
expect(listener).toHaveBeenCalledTimes(2);
});
it('notifies with pending map when task dispatches', async () => {
it('notifies with tasks map on dispatch and settlement', async () => {
vi.useRealTimers();
const queue = createQueue();
@@ -602,18 +604,19 @@ describe('queue', () => {
// First call should have pending task
expect(listener).toHaveBeenCalledTimes(1);
const pendingObj = listener.mock.calls[0]![0] as Record<string, unknown>;
expect(Reflect.ownKeys(pendingObj).length).toBe(1);
expect('test-key' in pendingObj).toBe(true);
const pendingSnapshot = listener.mock.calls[0]![0] as Record<string, { status: string }>;
expect(Reflect.ownKeys(pendingSnapshot).length).toBe(1);
expect(pendingSnapshot['test-key']?.status).toBe('pending');
// Complete the handler
resolveHandler!();
await promise;
// Second call should have empty pending
// Second call should have settled task (success)
expect(listener).toHaveBeenCalledTimes(2);
const settledObj = listener.mock.calls[1]![0] as Record<string, unknown>;
expect(Reflect.ownKeys(settledObj).length).toBe(0);
const settledSnapshot = listener.mock.calls[1]![0] as Record<string, { status: string }>;
expect(Reflect.ownKeys(settledSnapshot).length).toBe(1);
expect(settledSnapshot['test-key']?.status).toBe('success');
});
it('unsubscribe stops notifications', async () => {
@@ -683,15 +686,15 @@ describe('queue', () => {
consoleSpy.mockRestore();
});
it('provides strongly typed pending object', async () => {
it('provides strongly typed tasks object', async () => {
vi.useRealTimers();
// Use default queue - type safety is validated at compile time
const queue = createQueue();
queue.subscribe((pending) => {
// Pending is a frozen object
const task = pending.playback;
queue.subscribe((tasks) => {
// Tasks is a frozen object
const task = tasks.playback;
if (task) {
expect(task.key).toBe('playback');
expect(task.name).toBeDefined();
@@ -705,5 +708,406 @@ describe('queue', () => {
});
});
});
describe('task lifecycle', () => {
it('task starts as pending and transitions to success', async () => {
vi.useRealTimers();
const queue = createQueue();
const promise = queue.enqueue({
name: 'task',
key: 'k',
handler: async () => {
await new Promise(r => setTimeout(r, 10));
return 'result';
},
});
// Task should be pending
await new Promise(r => setTimeout(r, 5));
const pendingTask = queue.tasks.k;
expect(pendingTask?.status).toBe('pending');
expect(pendingTask?.name).toBe('task');
// Wait for completion
await promise;
// Task should be success
const successTask = queue.tasks.k;
expect(successTask?.status).toBe('success');
if (successTask?.status === 'success') {
expect(successTask.output).toBe('result');
expect(successTask.settledAt).toBeGreaterThan(successTask.startedAt);
}
});
it('task starts as pending and transitions to error', async () => {
vi.useRealTimers();
const queue = createQueue();
const error = new Error('test error');
const promise = queue.enqueue({
name: 'task',
key: 'k',
handler: async () => {
await new Promise(r => setTimeout(r, 10));
throw error;
},
});
// Task should be pending
await new Promise(r => setTimeout(r, 5));
expect(queue.tasks.k?.status).toBe('pending');
// Wait for failure
await expect(promise).rejects.toThrow('test error');
// Task should be error
const errorTask = queue.tasks.k;
expect(errorTask?.status).toBe('error');
if (errorTask?.status === 'error') {
expect(errorTask.error).toBe(error);
expect(errorTask.cancelled).toBe(false);
}
});
it('aborted task has cancelled flag set to true', async () => {
vi.useRealTimers();
const queue = createQueue();
const promise = queue.enqueue({
name: 'task',
key: 'k',
handler: async ({ signal }) => {
await new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason));
setTimeout(() => {}, 1000);
});
},
});
// Wait for task to start
await new Promise(r => setTimeout(r, 10));
expect(queue.tasks.k?.status).toBe('pending');
// Abort the task
queue.abort('k');
await promise.catch(() => {});
// Task should be error with cancelled=true
const errorTask = queue.tasks.k;
expect(errorTask?.status).toBe('error');
if (errorTask?.status === 'error') {
expect(errorTask.cancelled).toBe(true);
}
});
it('new request replaces settled task', async () => {
vi.useRealTimers();
const queue = createQueue();
// First request
await queue.enqueue({
name: 'first',
key: 'k',
handler: async () => 'first-result',
});
expect(queue.tasks.k?.status).toBe('success');
if (queue.tasks.k?.status === 'success') {
expect(queue.tasks.k.output).toBe('first-result');
}
// Second request replaces settled task
await queue.enqueue({
name: 'second',
key: 'k',
handler: async () => 'second-result',
});
expect(queue.tasks.k?.status).toBe('success');
if (queue.tasks.k?.status === 'success') {
expect(queue.tasks.k.output).toBe('second-result');
expect(queue.tasks.k.name).toBe('second');
}
});
});
describe('reset', () => {
it('clears settled task', async () => {
vi.useRealTimers();
const queue = createQueue();
await queue.enqueue({
name: 'task',
key: 'k',
handler: async () => 'result',
});
expect(queue.tasks.k?.status).toBe('success');
queue.reset('k');
expect(queue.tasks.k).toBeUndefined();
});
it('is no-op when task is pending', async () => {
vi.useRealTimers();
const queue = createQueue();
const promise = queue.enqueue({
name: 'task',
key: 'k',
handler: async () => {
await new Promise(r => setTimeout(r, 50));
return 'result';
},
});
// Wait for task to start
await new Promise(r => setTimeout(r, 10));
expect(queue.tasks.k?.status).toBe('pending');
// Reset should be no-op
queue.reset('k');
expect(queue.tasks.k?.status).toBe('pending');
await promise;
});
it('is no-op when task does not exist', () => {
const queue = createQueue();
// Should not throw
queue.reset('nonexistent');
expect(queue.tasks.nonexistent).toBeUndefined();
});
it('notifies subscribers when reset clears a task', async () => {
vi.useRealTimers();
const queue = createQueue();
const listener = vi.fn();
await queue.enqueue({
name: 'task',
key: 'k',
handler: async () => 'result',
});
queue.subscribe(listener);
queue.reset('k');
expect(listener).toHaveBeenCalledTimes(1);
const snapshot = listener.mock.calls[0]![0] as Record<string, unknown>;
expect(snapshot.k).toBeUndefined();
});
it('does not notify subscribers when task does not exist', () => {
const queue = createQueue();
const listener = vi.fn();
queue.subscribe(listener);
queue.reset('nonexistent');
expect(listener).not.toHaveBeenCalled();
});
it('resets all settled tasks when no key provided', async () => {
vi.useRealTimers();
const queue = createQueue();
// Create multiple settled tasks
await queue.enqueue({ name: 'a', key: 'a', handler: async () => 'a-result' });
await queue.enqueue({ name: 'b', key: 'b', handler: async () => 'b-result' });
expect(queue.tasks.a?.status).toBe('success');
expect(queue.tasks.b?.status).toBe('success');
// Reset all
queue.reset();
expect(queue.tasks.a).toBeUndefined();
expect(queue.tasks.b).toBeUndefined();
});
it('preserves pending tasks when resetting all', async () => {
vi.useRealTimers();
const queue = createQueue();
// Create a settled task
await queue.enqueue({ name: 'settled', key: 'settled', handler: async () => 'done' });
// Create a pending task
const pendingPromise = queue.enqueue({
name: 'pending',
key: 'pending',
handler: async () => {
await new Promise(r => setTimeout(r, 100));
return 'pending-done';
},
});
await new Promise(r => setTimeout(r, 10));
expect(queue.tasks.settled?.status).toBe('success');
expect(queue.tasks.pending?.status).toBe('pending');
// Reset all - should only clear settled
queue.reset();
expect(queue.tasks.settled).toBeUndefined();
expect(queue.tasks.pending?.status).toBe('pending');
await pendingPromise;
});
});
describe('isSettled', () => {
it('returns true for success task', async () => {
vi.useRealTimers();
const queue = createQueue();
await queue.enqueue({ name: 'task', key: 'k', handler: async () => 'result' });
expect(queue.isSettled('k')).toBe(true);
});
it('returns true for error task', async () => {
vi.useRealTimers();
const queue = createQueue();
const promise = queue.enqueue({
name: 'task',
key: 'k',
handler: async () => {
throw new Error('fail');
},
});
await promise.catch(() => {});
expect(queue.isSettled('k')).toBe(true);
});
it('returns false for pending task', async () => {
vi.useRealTimers();
const queue = createQueue();
const promise = queue.enqueue({
name: 'task',
key: 'k',
handler: async () => {
await new Promise(r => setTimeout(r, 50));
return 'result';
},
});
await new Promise(r => setTimeout(r, 10));
expect(queue.isSettled('k')).toBe(false);
await promise;
});
it('returns false for non-existent task', () => {
const queue = createQueue();
expect(queue.isSettled('nonexistent')).toBe(false);
});
});
describe('destroy cleanup', () => {
it('clears all task references on destroy', async () => {
vi.useRealTimers();
const queue = createQueue();
await queue.enqueue({ name: 'task', key: 'k', handler: async () => 'result' });
expect(queue.tasks.k?.status).toBe('success');
queue.destroy();
expect(Reflect.ownKeys(queue.tasks).length).toBe(0);
});
});
describe('tasks getter', () => {
it('returns frozen snapshot', async () => {
vi.useRealTimers();
const queue = createQueue();
await queue.enqueue({ name: 'task', key: 'k', handler: async () => 'result' });
const tasks = queue.tasks;
expect(Object.isFrozen(tasks)).toBe(true);
});
it('returns independent snapshots', async () => {
vi.useRealTimers();
const queue = createQueue();
await queue.enqueue({ name: 'first', key: 'k', handler: async () => 'first' });
const snapshot1 = queue.tasks;
await queue.enqueue({ name: 'second', key: 'k', handler: async () => 'second' });
const snapshot2 = queue.tasks;
// Snapshots should be independent
expect(snapshot1).not.toBe(snapshot2);
if (snapshot1.k?.status === 'success' && snapshot2.k?.status === 'success') {
expect(snapshot1.k.output).toBe('first');
expect(snapshot2.k.output).toBe('second');
}
});
});
describe('symbol keys', () => {
it('supports symbol keys', async () => {
vi.useRealTimers();
const queue = createQueue();
const key = Symbol('task');
await queue.enqueue({
name: 'task',
key,
handler: async () => 'result',
});
expect(queue.tasks[key]?.status).toBe('success');
if (queue.tasks[key]?.status === 'success') {
expect(queue.tasks[key].output).toBe('result');
}
});
});
describe('meta propagation', () => {
it('meta defaults to null when not provided', async () => {
vi.useRealTimers();
const queue = createQueue();
await queue.enqueue({
name: 'task',
key: 'k',
handler: async () => 'result',
});
expect(queue.tasks.k?.meta).toBeNull();
});
});
});
});