feat(store): lit bindings (#289)

This commit is contained in:
rahim
2026-01-06 22:26:09 +11:00
committed by GitHub
parent 36ea73f424
commit 648aae7e31
50 changed files with 3561 additions and 412 deletions
+73 -45
View File
@@ -13,6 +13,13 @@ export type TaskKey<T = string | symbol> = T & (string | symbol);
export type EnsureTaskKey<T> = T extends string | symbol ? T : never;
/**
* Status for async operations (mutations, optimistic updates).
*
* Used by framework bindings (React, Lit) for tracking request lifecycle.
*/
export type AsyncStatus = 'idle' | 'pending' | 'success' | 'error';
/**
* A task scheduler controls when a task flushes.
*
@@ -189,28 +196,32 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
return this.#destroyed;
}
isPending(key: keyof Tasks): boolean {
return this.#tasks[key]?.status === 'pending';
isPending(name: keyof Tasks): boolean {
return this.#tasks[name]?.status === 'pending';
}
isQueued(key: keyof Tasks): boolean {
return key in this.#queued;
isQueued(name: keyof Tasks): boolean {
// Note: #queued is keyed by key, but we need to search by name
for (const task of Object.values(this.#queued)) {
if (task?.name === name) return true;
}
return false;
}
isSettled(key: keyof Tasks): boolean {
const task = this.#tasks[key];
isSettled(name: keyof Tasks): boolean {
const task = this.#tasks[name];
return task?.status === 'success' || task?.status === 'error';
}
/**
* Clear settled task(s). If key provided, clears that task. If no key, clears all settled.
* Clear settled task(s). If name provided, clears that task. If no name, clears all settled.
*/
reset(key?: keyof Tasks): void {
if (!isUndefined(key)) {
const task = this.#tasks[key];
reset(name?: keyof Tasks): void {
if (!isUndefined(name)) {
const task = this.#tasks[name];
if (!task || task.status === 'pending') return;
delete this.#tasks[key];
delete this.#tasks[name];
this.#notifySubscribers();
return;
@@ -259,18 +270,20 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
return Promise.reject(new StoreError('DESTROYED'));
}
// Supersede any queued task with the same key
const queued = this.#queued[key];
queued?.invalidate?.();
queued?.reject(new StoreError('SUPERSEDED'));
delete this.#queued[key];
const existing = this.#tasks[key];
if (existing?.status === 'pending') {
existing.abort.abort(new StoreError('SUPERSEDED'));
// Supersede any pending task with the same key (may have different name)
// Don't delete - let error handler update status to 'error' so controllers can see it
for (const task of Object.values(this.#tasks)) {
if (task?.key === key && task.status === 'pending') {
task.abort.abort(new StoreError('SUPERSEDED'));
}
}
delete this.#tasks[key];
return new Promise<Tasks[K]['output']>((resolve, reject) => {
const task: QueuedTask = {
id: Symbol('@videojs/task'),
@@ -313,18 +326,20 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
}
/**
* Cancel queued task(s). If key provided, cancels that task. If no key, cancels all.
* Cancel queued task(s). If name provided, cancels that task. If no name, cancels all.
*/
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];
return true;
cancel(name?: keyof Tasks): boolean {
if (!isUndefined(name)) {
// Find queued task by name (#queued is keyed by key)
for (const [key, queued] of Object.entries(this.#queued)) {
if (queued?.name === name) {
queued.invalidate?.();
queued.reject(new StoreError('REMOVED'));
delete this.#queued[key as keyof Tasks];
return true;
}
}
return false;
}
const hadQueued = Object.keys(this.#queued).length > 0;
@@ -338,9 +353,15 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
return hadQueued;
}
async flush(key?: keyof Tasks): Promise<void> {
if (!isUndefined(key)) {
await this.#flushKey(key);
async flush(name?: keyof Tasks): Promise<void> {
if (!isUndefined(name)) {
// Find queued task by name and flush by its key
for (const [key, queued] of Object.entries(this.#queued)) {
if (queued?.name === name) {
await this.#flushKey(key as keyof Tasks);
return;
}
}
return;
}
@@ -349,16 +370,22 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
}
/**
* Abort task(s). If key provided, aborts that task. If no key, aborts all.
* Abort task(s). If name provided, aborts that task. If no name, aborts all.
*/
abort(key?: keyof Tasks): void {
if (!isUndefined(key)) {
const queued = this.#queued[key];
queued?.invalidate?.();
queued?.reject(new StoreError('ABORTED'));
delete this.#queued[key];
abort(name?: keyof Tasks): void {
if (!isUndefined(name)) {
// Find and abort queued task by name
for (const [key, queued] of Object.entries(this.#queued)) {
if (queued?.name === name) {
queued.invalidate?.();
queued.reject(new StoreError('ABORTED'));
delete this.#queued[key as keyof Tasks];
break;
}
}
const task = this.#tasks[key];
// Abort pending task (stored by name)
const task = this.#tasks[name];
if (task?.status === 'pending') {
task.abort.abort(new StoreError('ABORTED'));
}
@@ -421,7 +448,8 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
meta,
};
this.#tasks[key as keyof Tasks] = pendingTask;
// Store tasks by name for controller access (different names can share same key)
this.#tasks[name as keyof Tasks] = pendingTask;
this.#notifySubscribers();
this.#onDispatch?.(pendingTask);
@@ -445,9 +473,9 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
output: result,
};
// Only update if we're still the current task for this key
if (this.#tasks[key] === pendingTask) {
this.#tasks[key as keyof Tasks] = successTask;
// Only update if we're still the current task for this name
if (this.#tasks[name as keyof Tasks] === pendingTask) {
this.#tasks[name as keyof Tasks] = successTask;
this.#notifySubscribers();
}
@@ -463,9 +491,9 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
cancelled: abort.signal.aborted,
};
// Only update if we're still the current task for this key
if (this.#tasks[key] === pendingTask) {
this.#tasks[key as keyof Tasks] = errorTask;
// Only update if we're still the current task for this name
if (this.#tasks[name as keyof Tasks] === pendingTask) {
this.#tasks[name as keyof Tasks] = errorTask;
this.#notifySubscribers();
}
+1 -1
View File
@@ -135,7 +135,7 @@ export function resolveRequestKey(keyConfig: RequestKey<any>, input: unknown): T
return isFunction(keyConfig) ? keyConfig(input) : keyConfig;
}
export function resolveRequestCancelKeys(cancel: RequestCancel<any> | undefined, input: unknown): TaskKey[] {
export function resolveRequestCancel(cancel: RequestCancel<any> | undefined, input: unknown): TaskKey[] {
if (!cancel) return [];
const result = isFunction(cancel) ? cancel(input) : cancel;
return Array.isArray(result) ? result : [result];
+12 -4
View File
@@ -15,7 +15,7 @@ import { isNull } from '@videojs/utils/predicate';
import { StoreError } from './errors';
import { Queue } from './queue';
import { createRequestMeta, resolveRequestCancelKeys, resolveRequestKey } from './request';
import { createRequestMeta, resolveRequestCancel, resolveRequestKey } from './request';
import { State } from './state';
export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[]> {
@@ -274,8 +274,8 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
): Promise<unknown> {
const key = resolveRequestKey(config.key, input);
for (const cancelKey of resolveRequestCancelKeys(config.cancel, input)) {
this.#queue.abort(cancelKey);
for (const requestName of resolveRequestCancel(config.cancel, input)) {
this.#queue.abort(requestName);
}
const handler = async ({ input, signal }: TaskContext) => {
@@ -311,7 +311,7 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
});
} catch (error) {
const tasks = this.#queue.tasks as Record<string | symbol, Task | undefined>;
const task = tasks[key];
const task = tasks[name];
this.#handleError({
request: task?.status === 'pending' ? task : undefined,
@@ -385,6 +385,14 @@ export interface StoreErrorContext<Target, Slices extends AnySlice<Target>[]> {
error: unknown;
}
export interface StoreProvider<Slices extends AnySlice[]> {
store: Store<UnionSliceTarget<Slices>, Slices>;
}
export interface StoreConsumer<Slices extends AnySlice[]> {
readonly store: Store<UnionSliceTarget<Slices>, Slices> | null;
}
// ----------------------------------------
// Type Inference
// ----------------------------------------
+63 -60
View File
@@ -164,7 +164,7 @@ describe('queue', () => {
});
describe('cancel', () => {
it('cancel(key) removes specific queued task', async () => {
it('cancel(name) removes specific queued task', async () => {
const queue = createQueue({
scheduler: delay(100),
});
@@ -172,8 +172,8 @@ describe('queue', () => {
const handler = vi.fn();
const promise = queue.enqueue({ name: 'test', key: 'k', handler });
expect(queue.cancel('k')).toBe(true);
expect(queue.cancel('k')).toBe(false);
expect(queue.cancel('test')).toBe(true);
expect(queue.cancel('test')).toBe(false);
vi.advanceTimersByTime(100);
await expect(promise).rejects.toMatchObject({ code: 'REMOVED' });
@@ -214,7 +214,7 @@ describe('queue', () => {
await expect(promise).resolves.toBe('ok');
});
it('flush(key) executes specific task', async () => {
it('flush(name) executes specific task', async () => {
vi.useRealTimers();
const queue = createQueue({ scheduler: delay(1000) });
@@ -233,7 +233,7 @@ describe('queue', () => {
});
describe('abort', () => {
it('abort(key) cancels queued and aborts pending', async () => {
it('abort(name) cancels queued and aborts pending', async () => {
vi.useRealTimers();
const queue = createQueue();
@@ -254,7 +254,7 @@ describe('queue', () => {
});
await new Promise(r => setTimeout(r, 10));
queue.abort('k');
queue.abort('test');
await expect(promise).rejects.toMatchObject({ code: 'ABORTED' });
expect(aborted).toBe(true);
@@ -392,7 +392,7 @@ describe('queue', () => {
handler: vi.fn().mockResolvedValue('result1'),
});
// Queue should have the first task
// Queue should have the first task (#queued is keyed by key 'shared')
expect(Reflect.ownKeys(queue.queued).length).toBe(1);
expect(queue.queued.shared?.name).toBe('task1');
@@ -406,7 +406,7 @@ describe('queue', () => {
// First should be superseded
await expect(promise1).rejects.toMatchObject({ code: 'SUPERSEDED' });
// Queue should only have the second task (first was explicitly deleted)
// Queue should only have the second task (#queued keyed by key 'shared')
expect(Reflect.ownKeys(queue.queued).length).toBe(1);
expect(queue.queued.shared?.name).toBe('task2');
@@ -438,7 +438,7 @@ describe('queue', () => {
// Wait for task to start
await new Promise(r => setTimeout(r, 10));
expect(queue.tasks.k?.status).toBe('pending');
expect(queue.tasks.task?.status).toBe('pending');
// Destroy queue
queue.destroy();
@@ -449,7 +449,7 @@ describe('queue', () => {
expect(cleanupSpy).toHaveBeenCalledWith('cleanup');
// After destroy, all tasks are cleared for memory cleanup
expect(queue.tasks.k).toBeUndefined();
expect(queue.tasks.task).toBeUndefined();
});
it('handles scheduler error without double-cleanup when already flushed', async () => {
@@ -520,12 +520,12 @@ describe('queue', () => {
await new Promise(r => setTimeout(r, 10));
expect(queue.isPending('test-key')).toBe(true);
expect(queue.isPending('other-key')).toBe(false);
expect(queue.isPending('test')).toBe(true);
expect(queue.isPending('other')).toBe(false);
await promise;
expect(queue.isPending('test-key')).toBe(false);
expect(queue.isPending('test')).toBe(false);
});
it('isQueued returns true when task is waiting to execute', async () => {
@@ -537,13 +537,13 @@ describe('queue', () => {
handler: vi.fn().mockResolvedValue('result'),
});
expect(queue.isQueued('test-key')).toBe(true);
expect(queue.isQueued('other-key')).toBe(false);
expect(queue.isQueued('test')).toBe(true);
expect(queue.isQueued('other')).toBe(false);
vi.advanceTimersByTime(100);
await vi.runAllTimersAsync();
expect(queue.isQueued('test-key')).toBe(false);
expect(queue.isQueued('test')).toBe(false);
});
});
@@ -602,11 +602,11 @@ describe('queue', () => {
// Wait for dispatch
await new Promise(r => setTimeout(r, 10));
// First call should have pending task
// First call should have pending task (keyed by name 'test')
expect(listener).toHaveBeenCalledTimes(1);
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');
expect(pendingSnapshot.test?.status).toBe('pending');
// Complete the handler
resolveHandler!();
@@ -616,7 +616,7 @@ describe('queue', () => {
expect(listener).toHaveBeenCalledTimes(2);
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');
expect(settledSnapshot.test?.status).toBe('success');
});
it('unsubscribe stops notifications', async () => {
@@ -724,9 +724,9 @@ describe('queue', () => {
},
});
// Task should be pending
// Task should be pending (keyed by name 'task')
await new Promise(r => setTimeout(r, 5));
const pendingTask = queue.tasks.k;
const pendingTask = queue.tasks.task;
expect(pendingTask?.status).toBe('pending');
expect(pendingTask?.name).toBe('task');
@@ -734,7 +734,7 @@ describe('queue', () => {
await promise;
// Task should be success
const successTask = queue.tasks.k;
const successTask = queue.tasks.task;
expect(successTask?.status).toBe('success');
if (successTask?.status === 'success') {
expect(successTask.output).toBe('result');
@@ -759,13 +759,13 @@ describe('queue', () => {
// Task should be pending
await new Promise(r => setTimeout(r, 5));
expect(queue.tasks.k?.status).toBe('pending');
expect(queue.tasks.task?.status).toBe('pending');
// Wait for failure
await expect(promise).rejects.toThrow('test error');
// Task should be error
const errorTask = queue.tasks.k;
const errorTask = queue.tasks.task;
expect(errorTask?.status).toBe('error');
if (errorTask?.status === 'error') {
expect(errorTask.error).toBe(error);
@@ -791,14 +791,14 @@ describe('queue', () => {
// Wait for task to start
await new Promise(r => setTimeout(r, 10));
expect(queue.tasks.k?.status).toBe('pending');
expect(queue.tasks.task?.status).toBe('pending');
// Abort the task
queue.abort('k');
// Abort the task (by name)
queue.abort('task');
await promise.catch(() => {});
// Task should be error with cancelled=true
const errorTask = queue.tasks.k;
const errorTask = queue.tasks.task;
expect(errorTask?.status).toBe('error');
if (errorTask?.status === 'error') {
expect(errorTask.cancelled).toBe(true);
@@ -817,22 +817,24 @@ describe('queue', () => {
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');
// Keyed by name 'first'
expect(queue.tasks.first?.status).toBe('success');
if (queue.tasks.first?.status === 'success') {
expect(queue.tasks.first.output).toBe('first-result');
}
// Second request replaces settled task
// Second request (different name, same key) - doesn't replace first since different name
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');
// Both tasks exist at different keys (names)
expect(queue.tasks.second?.status).toBe('success');
if (queue.tasks.second?.status === 'success') {
expect(queue.tasks.second.output).toBe('second-result');
expect(queue.tasks.second.name).toBe('second');
}
});
});
@@ -849,11 +851,11 @@ describe('queue', () => {
handler: async () => 'result',
});
expect(queue.tasks.k?.status).toBe('success');
expect(queue.tasks.task?.status).toBe('success');
queue.reset('k');
queue.reset('task');
expect(queue.tasks.k).toBeUndefined();
expect(queue.tasks.task).toBeUndefined();
});
it('is no-op when task is pending', async () => {
@@ -872,11 +874,11 @@ describe('queue', () => {
// Wait for task to start
await new Promise(r => setTimeout(r, 10));
expect(queue.tasks.k?.status).toBe('pending');
expect(queue.tasks.task?.status).toBe('pending');
// Reset should be no-op
queue.reset('k');
expect(queue.tasks.k?.status).toBe('pending');
queue.reset('task');
expect(queue.tasks.task?.status).toBe('pending');
await promise;
});
@@ -904,11 +906,11 @@ describe('queue', () => {
queue.subscribe(listener);
queue.reset('k');
queue.reset('task');
expect(listener).toHaveBeenCalledTimes(1);
const snapshot = listener.mock.calls[0]![0] as Record<string, unknown>;
expect(snapshot.k).toBeUndefined();
expect(snapshot.task).toBeUndefined();
});
it('does not notify subscribers when task does not exist', () => {
@@ -979,7 +981,7 @@ describe('queue', () => {
const queue = createQueue();
await queue.enqueue({ name: 'task', key: 'k', handler: async () => 'result' });
expect(queue.isSettled('k')).toBe(true);
expect(queue.isSettled('task')).toBe(true);
});
it('returns true for error task', async () => {
@@ -996,7 +998,7 @@ describe('queue', () => {
await promise.catch(() => {});
expect(queue.isSettled('k')).toBe(true);
expect(queue.isSettled('task')).toBe(true);
});
it('returns false for pending task', async () => {
@@ -1014,7 +1016,7 @@ describe('queue', () => {
await new Promise(r => setTimeout(r, 10));
expect(queue.isSettled('k')).toBe(false);
expect(queue.isSettled('task')).toBe(false);
await promise;
});
@@ -1033,7 +1035,7 @@ describe('queue', () => {
const queue = createQueue();
await queue.enqueue({ name: 'task', key: 'k', handler: async () => 'result' });
expect(queue.tasks.k?.status).toBe('success');
expect(queue.tasks.task?.status).toBe('success');
queue.destroy();
@@ -1065,31 +1067,32 @@ describe('queue', () => {
const snapshot2 = queue.tasks;
// Snapshots should be independent
// Tasks keyed by name - first and second are different entries
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');
if (snapshot1.first?.status === 'success' && snapshot2.second?.status === 'success') {
expect(snapshot1.first.output).toBe('first');
expect(snapshot2.second.output).toBe('second');
}
});
});
describe('symbol keys', () => {
it('supports symbol keys', async () => {
it('supports symbol names', async () => {
vi.useRealTimers();
const queue = createQueue();
const key = Symbol('task');
const name = Symbol('task');
await queue.enqueue({
name: 'task',
key,
name: name as unknown as string,
key: name,
handler: async () => 'result',
});
expect(queue.tasks[key]?.status).toBe('success');
if (queue.tasks[key]?.status === 'success') {
expect(queue.tasks[key].output).toBe('result');
const task = queue.tasks[name as unknown as string];
expect(task?.status).toBe('success');
if (task?.status === 'success') {
expect(task.output).toBe('result');
}
});
});
@@ -1106,7 +1109,7 @@ describe('queue', () => {
handler: async () => 'result',
});
expect(queue.tasks.k?.meta).toBeNull();
expect(queue.tasks.task?.meta).toBeNull();
});
});
});
@@ -0,0 +1,156 @@
import type { AsyncStatus, ErrorTask, PendingTask, SuccessTask, Task, TasksRecord } from '../queue';
import { describe, expectTypeOf, it } from 'vitest';
import { createQueue } from '../queue';
describe('queue types', () => {
describe('AsyncStatus', () => {
it('includes all status values', () => {
const idle: AsyncStatus = 'idle';
const pending: AsyncStatus = 'pending';
const success: AsyncStatus = 'success';
const error: AsyncStatus = 'error';
expectTypeOf(idle).toExtend<AsyncStatus>();
expectTypeOf(pending).toExtend<AsyncStatus>();
expectTypeOf(success).toExtend<AsyncStatus>();
expectTypeOf(error).toExtend<AsyncStatus>();
});
});
describe('Task', () => {
it('is discriminated union of task states', () => {
const task: Task = {} as Task;
if (task.status === 'pending') {
expectTypeOf(task).toExtend<PendingTask>();
expectTypeOf(task.abort).toExtend<AbortController>();
}
if (task.status === 'success') {
expectTypeOf(task).toExtend<SuccessTask>();
expectTypeOf(task.output).toBeUnknown();
expectTypeOf(task.settledAt).toBeNumber();
}
if (task.status === 'error') {
expectTypeOf(task).toExtend<ErrorTask>();
expectTypeOf(task.error).toBeUnknown();
expectTypeOf(task.cancelled).toBeBoolean();
expectTypeOf(task.settledAt).toBeNumber();
}
});
it('has common properties across all states', () => {
const task: Task = {} as Task;
expectTypeOf(task.id).toEqualTypeOf<symbol>();
expectTypeOf(task.name).toEqualTypeOf<string>();
expectTypeOf(task.key).toExtend<string | symbol>();
expectTypeOf(task.startedAt).toBeNumber();
});
});
describe('createQueue', () => {
it('returns Queue with default task record', () => {
const queue = createQueue();
expectTypeOf(queue.tasks).toExtend<TasksRecord<any>>();
expectTypeOf(queue.destroyed).toBeBoolean();
});
});
describe('Queue methods', () => {
it('isPending takes name parameter', () => {
const queue = createQueue();
expectTypeOf(queue.isPending).toBeFunction();
expectTypeOf(queue.isPending).returns.toBeBoolean();
});
it('isQueued takes name parameter', () => {
const queue = createQueue();
expectTypeOf(queue.isQueued).toBeFunction();
expectTypeOf(queue.isQueued).returns.toBeBoolean();
});
it('isSettled takes name parameter', () => {
const queue = createQueue();
expectTypeOf(queue.isSettled).toBeFunction();
expectTypeOf(queue.isSettled).returns.toBeBoolean();
});
it('reset takes optional name parameter', () => {
const queue = createQueue();
expectTypeOf(queue.reset).toBeFunction();
expectTypeOf(queue.reset).returns.toBeVoid();
});
it('cancel takes optional name parameter and returns boolean', () => {
const queue = createQueue();
expectTypeOf(queue.cancel).toBeFunction();
expectTypeOf(queue.cancel).returns.toBeBoolean();
});
it('abort takes optional name parameter', () => {
const queue = createQueue();
expectTypeOf(queue.abort).toBeFunction();
expectTypeOf(queue.abort).returns.toBeVoid();
});
it('flush takes optional name parameter and returns promise', () => {
const queue = createQueue();
expectTypeOf(queue.flush).toBeFunction();
expectTypeOf(queue.flush).returns.toExtend<Promise<void>>();
});
it('subscribe takes listener and returns unsubscribe', () => {
const queue = createQueue();
expectTypeOf(queue.subscribe).toBeFunction();
expectTypeOf(queue.subscribe).returns.toExtend<() => void>();
});
it('destroy returns void', () => {
const queue = createQueue();
expectTypeOf(queue.destroy).toBeFunction();
expectTypeOf(queue.destroy).returns.toBeVoid();
});
});
describe('enqueue', () => {
it('returns promise', async () => {
const queue = createQueue();
const result = queue.enqueue({
name: 'test',
key: 'test',
handler: async () => 42,
});
// Verify it's a promise by checking it has then
expectTypeOf(result.then).toBeFunction();
});
it('handler receives TaskContext with signal', async () => {
const queue = createQueue();
await queue.enqueue({
name: 'test',
key: 'test',
handler: async (ctx) => {
expectTypeOf(ctx.signal).toEqualTypeOf<AbortSignal>();
return 'done';
},
});
});
});
});
@@ -5,7 +5,7 @@ import {
createRequestMetaFromEvent,
isRequestMeta,
REQUEST_META,
resolveRequestCancelKeys,
resolveRequestCancel,
resolveRequestKey,
} from '../request';
@@ -128,22 +128,22 @@ describe('request', () => {
});
});
describe('resolveRequestCancelKeys', () => {
describe('resolveRequestCancelNames', () => {
it('returns empty array for undefined', () => {
expect(resolveRequestCancelKeys(undefined, null)).toEqual([]);
expect(resolveRequestCancel(undefined, null)).toEqual([]);
});
it('wraps single key in array', () => {
expect(resolveRequestCancelKeys('key', null)).toEqual(['key']);
it('wraps single name in array', () => {
expect(resolveRequestCancel('seek', null)).toEqual(['seek']);
});
it('returns array directly', () => {
expect(resolveRequestCancelKeys(['a', 'b'], null)).toEqual(['a', 'b']);
expect(resolveRequestCancel(['seek', 'load'], null)).toEqual(['seek', 'load']);
});
it('calls function with input', () => {
const cancelFn = (type: string) => [`${type}-loading`, `${type}-fetch`];
expect(resolveRequestCancelKeys(cancelFn, 'video')).toEqual(['video-loading', 'video-fetch']);
const cancelFn = (type: string) => [`${type}Loading`, `${type}Fetch`];
expect(resolveRequestCancel(cancelFn, 'video')).toEqual(['videoLoading', 'videoFetch']);
});
});
});
@@ -0,0 +1,147 @@
import type { InferSliceRequests, InferSliceState, Slice } from '../slice';
import { describe, expectTypeOf, it } from 'vitest';
import { createSlice } from '../slice';
interface MockTarget {
volume: number;
muted: boolean;
}
describe('slice types', () => {
describe('createSlice', () => {
it('returns Slice type with inferred state', () => {
const slice = createSlice<MockTarget>()({
initialState: { volume: 1, muted: false },
getSnapshot: ({ target }) => ({ volume: target.volume, muted: target.muted }),
subscribe: () => {},
request: {},
});
expectTypeOf(slice).toExtend<Slice<MockTarget, {
volume: number;
muted: boolean;
}, object>>();
});
it('infers request handler types from simple handlers', () => {
const _slice = createSlice<MockTarget>()({
initialState: { volume: 1 },
getSnapshot: ({ target }) => ({ volume: target.volume }),
subscribe: () => {},
request: {
setVolume: (volume: number, { target }) => {
target.volume = volume;
return volume;
},
},
});
type Requests = InferSliceRequests<typeof _slice>;
expectTypeOf<Requests>().toHaveProperty('setVolume');
expectTypeOf<Requests['setVolume']>().toExtend<{ input: number; output: number }>();
});
it('infers async request handler types', () => {
const _slice = createSlice<MockTarget>()({
initialState: { volume: 1 },
getSnapshot: ({ target }) => ({ volume: target.volume }),
subscribe: () => {},
request: {
asyncSetVolume: async (volume: number, { target }): Promise<number> => {
target.volume = volume;
return volume;
},
},
});
type Requests = InferSliceRequests<typeof _slice>;
expectTypeOf<Requests['asyncSetVolume']>().toExtend<{ input: number; output: number }>();
});
it('infers request config with custom key', () => {
const _slice = createSlice<MockTarget>()({
initialState: { volume: 1, muted: false },
getSnapshot: ({ target }) => ({ volume: target.volume, muted: target.muted }),
subscribe: () => {},
request: {
setVolume: {
key: 'audio',
handler: (volume: number, { target }) => {
target.volume = volume;
return volume;
},
},
setMuted: {
key: 'audio',
handler: (muted: boolean, { target }) => {
target.muted = muted;
return muted;
},
},
},
});
type Requests = InferSliceRequests<typeof _slice>;
expectTypeOf<Requests['setVolume']>().toExtend<{ input: number; output: number }>();
expectTypeOf<Requests['setMuted']>().toExtend<{ input: boolean; output: boolean }>();
});
it('infers void input when handler takes no arguments', () => {
const _slice = createSlice<MockTarget>()({
initialState: { muted: false },
getSnapshot: ({ target }) => ({ muted: target.muted }),
subscribe: () => {},
request: {
toggleMute: ({ target }) => {
target.muted = !target.muted;
return target.muted;
},
},
});
type Requests = InferSliceRequests<typeof _slice>;
expectTypeOf<Requests['toggleMute']>().toExtend<{ input: void; output: boolean }>();
});
});
describe('InferSliceState', () => {
it('extracts state type from slice', () => {
const _slice = createSlice<MockTarget>()({
initialState: { volume: 1, muted: false, label: 'test' },
getSnapshot: ({ initialState }) => initialState,
subscribe: () => {},
request: {},
});
type State = InferSliceState<typeof _slice>;
expectTypeOf<State>().toEqualTypeOf<{ volume: number; muted: boolean; label: string }>();
});
});
describe('InferSliceRequests', () => {
it('extracts request types from slice', () => {
const _slice = createSlice<MockTarget>()({
initialState: { volume: 1 },
getSnapshot: ({ target }) => ({ volume: target.volume }),
subscribe: () => {},
request: {
setVolume: (v: number) => v,
reset: () => 1,
},
});
type Requests = InferSliceRequests<typeof _slice>;
expectTypeOf<Requests>().toHaveProperty('setVolume');
expectTypeOf<Requests>().toHaveProperty('reset');
});
});
});
@@ -0,0 +1,253 @@
import type { Queue, TasksRecord } from '../queue';
import type { InferStoreRequests, InferStoreState, InferStoreTarget, InferStoreTasks } from '../store';
import { describe, expectTypeOf, it } from 'vitest';
import { createSlice } from '../slice';
import { createStore } from '../store';
interface MockTarget {
volume: number;
muted: boolean;
}
const audioSlice = createSlice<MockTarget>()({
initialState: { volume: 1, muted: false },
getSnapshot: ({ target }) => ({ volume: target.volume, muted: target.muted }),
subscribe: () => {},
request: {
setVolume: (volume: number, { target }) => {
target.volume = volume;
return volume;
},
setMuted: (muted: boolean, { target }) => {
target.muted = muted;
return muted;
},
},
});
const playbackSlice = createSlice<MockTarget>()({
initialState: { playing: false },
getSnapshot: () => ({ playing: false }),
subscribe: () => {},
request: {
play: () => true,
pause: () => false,
},
});
function createTestStore() {
return createStore({ slices: [audioSlice, playbackSlice] });
}
function createSingleSliceStore() {
return createStore({ slices: [audioSlice] });
}
describe('store types', () => {
describe('createStore', () => {
it('state has union of all slice states', () => {
const store = createTestStore();
expectTypeOf(store.state.volume).toEqualTypeOf<number>();
expectTypeOf(store.state.muted).toEqualTypeOf<boolean>();
expectTypeOf(store.state.playing).toEqualTypeOf<boolean>();
});
it('request has union of all slice requests', () => {
const store = createTestStore();
expectTypeOf(store.request).toHaveProperty('setVolume');
expectTypeOf(store.request).toHaveProperty('setMuted');
expectTypeOf(store.request).toHaveProperty('play');
expectTypeOf(store.request).toHaveProperty('pause');
});
it('request methods have correct signatures', () => {
const store = createSingleSliceStore();
expectTypeOf(store.request.setVolume).toBeFunction();
expectTypeOf(store.request.setVolume).parameter(0).toEqualTypeOf<number>();
expectTypeOf(store.request.setVolume).returns.toEqualTypeOf<Promise<number>>();
expectTypeOf(store.request.setMuted).toBeFunction();
expectTypeOf(store.request.setMuted).parameter(0).toEqualTypeOf<boolean>();
expectTypeOf(store.request.setMuted).returns.toEqualTypeOf<Promise<boolean>>();
});
it('queue has correctly typed tasks', () => {
const store = createSingleSliceStore();
expectTypeOf(store.queue).toExtend<Queue<any>>();
expectTypeOf(store.queue.tasks).toExtend<TasksRecord<any>>();
});
it('target is nullable before attach', () => {
const store = createSingleSliceStore();
expectTypeOf(store.target).toEqualTypeOf<MockTarget | null>();
});
});
describe('InferStoreTarget', () => {
it('extracts target type from store', () => {
const _store = createSingleSliceStore();
type Target = InferStoreTarget<typeof _store>;
const _target: Target = {} as Target;
expectTypeOf(_target).toEqualTypeOf<MockTarget>();
});
});
describe('InferStoreState', () => {
it('extracts state type from store', () => {
const _store = createTestStore();
type State = InferStoreState<typeof _store>;
const _state: State = {} as State;
expectTypeOf(_state.volume).toEqualTypeOf<number>();
expectTypeOf(_state.muted).toEqualTypeOf<boolean>();
expectTypeOf(_state.playing).toEqualTypeOf<boolean>();
});
});
describe('InferStoreRequests', () => {
it('extracts request types from store', () => {
const _store = createSingleSliceStore();
type Requests = InferStoreRequests<typeof _store>;
expectTypeOf<Requests>().toHaveProperty('setVolume');
expectTypeOf<Requests>().toHaveProperty('setMuted');
});
});
describe('InferStoreTasks', () => {
it('extracts task types from store', () => {
const _store = createSingleSliceStore();
type Tasks = InferStoreTasks<typeof _store>;
expectTypeOf<Tasks>().toHaveProperty('setVolume');
expectTypeOf<Tasks>().toHaveProperty('setMuted');
});
});
describe('subscribe', () => {
it('listener receives full state', () => {
const store = createSingleSliceStore();
store.subscribe((state) => {
expectTypeOf(state).toEqualTypeOf<{ volume: number; muted: boolean }>();
});
});
it('selector listener receives selected value', () => {
const store = createSingleSliceStore();
store.subscribe(
s => s.volume,
(volume) => {
expectTypeOf(volume).toEqualTypeOf<number>();
},
);
});
});
describe('store queue integration types', () => {
it('queue.tasks has keys matching request names', () => {
const store = createSingleSliceStore();
expectTypeOf(store.queue.tasks).toHaveProperty('setVolume');
expectTypeOf(store.queue.tasks).toHaveProperty('setMuted');
});
it('task input type matches request parameter', () => {
const store = createSingleSliceStore();
const task = store.queue.tasks.setVolume;
if (task) {
expectTypeOf(task.input).toEqualTypeOf<number>();
}
});
it('task output type matches request return on success', () => {
const store = createSingleSliceStore();
const task = store.queue.tasks.setVolume;
if (task?.status === 'success') {
expectTypeOf(task.output).toEqualTypeOf<number>();
}
});
it('multi-slice store has combined queue task types', () => {
const store = createTestStore();
expectTypeOf(store.queue.tasks).toHaveProperty('setVolume');
expectTypeOf(store.queue.tasks).toHaveProperty('setMuted');
expectTypeOf(store.queue.tasks).toHaveProperty('play');
expectTypeOf(store.queue.tasks).toHaveProperty('pause');
});
it('queue.isPending accepts request names', () => {
const store = createSingleSliceStore();
// Should compile - valid request names
store.queue.isPending('setVolume');
store.queue.isPending('setMuted');
});
it('queue.isQueued accepts request names', () => {
const store = createSingleSliceStore();
store.queue.isQueued('setVolume');
store.queue.isQueued('setMuted');
});
it('queue.isSettled accepts request names', () => {
const store = createSingleSliceStore();
store.queue.isSettled('setVolume');
store.queue.isSettled('setMuted');
});
it('queue.reset accepts request names', () => {
const store = createSingleSliceStore();
store.queue.reset('setVolume');
store.queue.reset('setMuted');
store.queue.reset(); // all
});
it('queue.cancel accepts request names', () => {
const store = createSingleSliceStore();
store.queue.cancel('setVolume');
store.queue.cancel('setMuted');
store.queue.cancel(); // all
});
it('queue.abort accepts request names', () => {
const store = createSingleSliceStore();
store.queue.abort('setVolume');
store.queue.abort('setMuted');
store.queue.abort(); // all
});
it('queue.flush accepts request names', () => {
const store = createSingleSliceStore();
store.queue.flush('setVolume');
store.queue.flush('setMuted');
store.queue.flush(); // all
});
it('InferStoreTasks matches queue task record keys', () => {
const _store = createSingleSliceStore();
type Tasks = InferStoreTasks<typeof _store>;
expectTypeOf<Tasks>().toHaveProperty('setVolume');
expectTypeOf<Tasks>().toHaveProperty('setMuted');
});
});
});