feat(store): add error codes (#284)

This commit is contained in:
rahim
2026-01-05 11:06:57 +11:00
committed by GitHub
parent 4c7ba6df23
commit 8a1c674dad
11 changed files with 990 additions and 52 deletions
+43 -3
View File
@@ -1,13 +1,53 @@
interface ErrorOptions {
/**
* Error codes for store operations.
*
* @example
* ```ts
* if (isStoreError(error)) {
* switch (error.code) {
* case 'SUPERSEDED':
* // Request was replaced by another - expected behavior
* break;
* case 'REJECTED':
* // Guard condition failed
* break;
* }
* }
* ```
*/
export type StoreErrorCode
/** Request was aborted via AbortSignal - user or system requested cancellation. */
= | 'ABORTED'
/** Request was cancelled by another request's `cancel` configuration. */
| 'CANCELLED'
/** Store or queue was destroyed - lifecycle ended. */
| 'DESTROYED'
/** Target was detached while request was in flight. */
| 'DETACHED'
/** No target is attached to the store. */
| 'NO_TARGET'
/** Guard condition returned falsy - request preconditions not met. */
| 'REJECTED'
/** Task was removed from queue via `dequeue()` or `clear()`. */
| 'REMOVED'
/** Request was replaced by a newer request with the same key. */
| 'SUPERSEDED'
/** Guard condition timed out waiting for a truthy result. */
| 'TIMEOUT';
export interface StoreErrorOptions {
cause?: unknown;
message?: string;
}
export class StoreError extends Error {
readonly code: StoreErrorCode;
cause?: unknown;
constructor(message: string, options?: ErrorOptions) {
super(message);
constructor(code: StoreErrorCode, options?: StoreErrorOptions) {
super(options?.message ?? code);
this.name = 'StoreError';
this.code = code;
this.cause = options?.cause;
}
}
+16 -3
View File
@@ -3,7 +3,7 @@ import { isBoolean } from '@videojs/utils/predicate';
import { StoreError } from './errors';
/**
* A guard gates request execution.
* Result of a guard check.
*
* - Truthy → proceed
* - Falsy → cancel
@@ -11,7 +11,20 @@ import { StoreError } from './errors';
* - Promise resolves falsy → cancel
* - Promise rejects → cancel
*/
export type Guard<Target> = (ctx: { target: Target; signal: AbortSignal }) => boolean | Promise<unknown>;
export type GuardResult = boolean | Promise<unknown>;
/**
* Context passed to guard functions.
*/
export interface GuardContext<Target> {
target: Target;
signal: AbortSignal;
}
/**
* A guard gates request execution.
*/
export type Guard<Target> = (ctx: GuardContext<Target>) => GuardResult;
/**
* Combine guards: All must pass (truthy).
@@ -69,7 +82,7 @@ export function timeout<Target>(guard: Guard<Target>, ms: number, name = 'guard'
return Promise.race([
result,
new Promise<never>((_, reject) => {
const timer = setTimeout(() => reject(new StoreError(`Timeout: ${name}`)), ms);
const timer = setTimeout(() => reject(new StoreError('TIMEOUT', { message: `Timeout: ${name}` })), ms);
ctx.signal.addEventListener('abort', () => clearTimeout(timer));
}),
]);
+15 -15
View File
@@ -252,17 +252,17 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
const { name, key, input, schedule, meta = null, handler } = task;
if (this.#destroyed) {
return Promise.reject(new StoreError('Queue destroyed'));
return Promise.reject(new StoreError('DESTROYED'));
}
// Cancel any queued task with same key
const queued = this.#queued[key];
queued?.invalidate?.();
queued?.reject(new StoreError('Superseded'));
queued?.reject(new StoreError('SUPERSEDED'));
delete this.#queued[key];
// Abort any pending task with same key
this.#pending[key]?.abort.abort(new StoreError('Superseded'));
this.#pending[key]?.abort.abort(new StoreError('SUPERSEDED'));
return new Promise<Tasks[K]['output']>((resolve, reject) => {
const task: QueuedTask = {
@@ -312,7 +312,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
if (!queued) return false;
queued.invalidate?.();
queued.reject(new StoreError('Dequeued'));
queued.reject(new StoreError('REMOVED'));
delete this.#queued[key];
return true;
@@ -321,7 +321,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
clear(): void {
for (const queued of Object.values(this.#queued)) {
queued.invalidate?.();
queued.reject(new StoreError('Cleared'));
queued.reject(new StoreError('REMOVED'));
}
this.#queued = {};
@@ -338,19 +338,19 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
await Promise.allSettled(keys.map(k => this.#flushKey(k)));
}
abort<K extends keyof Tasks>(key: K, reason = 'Aborted'): void {
abort<K extends keyof Tasks>(key: K): void {
// Reject queued
const queued = this.#queued[key];
queued?.invalidate?.();
queued?.reject(new StoreError(reason));
queued?.reject(new StoreError('ABORTED'));
delete this.#queued[key];
// Abort pending with reason
this.#pending[key]?.abort.abort(new StoreError(reason));
// Abort pending
this.#pending[key]?.abort.abort(new StoreError('ABORTED'));
}
abortAll(reason = 'All requests aborted'): void {
const error = new StoreError(reason);
abortAll(): void {
const error = new StoreError('ABORTED');
// Reject all queued
for (const queued of Object.values(this.#queued)) {
@@ -360,7 +360,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
this.#queued = {};
// Abort all pending with reason
// Abort all pending
for (const pending of Object.values(this.#pending)) {
pending.abort.abort(error);
}
@@ -370,7 +370,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
if (this.#destroyed) return;
this.#destroyed = true;
this.abortAll('Queue destroyed');
this.abortAll();
this.#subscribers.clear();
}
@@ -409,13 +409,13 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
try {
if (abort.signal.aborted) {
throw abort.signal.reason || new StoreError('Aborted');
throw abort.signal.reason || new StoreError('ABORTED');
}
const result = await handler({ input, signal: abort.signal });
if (abort.signal.aborted) {
throw abort.signal.reason || new StoreError('Aborted');
throw abort.signal.reason || new StoreError('ABORTED');
}
resolve(result);
+7 -7
View File
@@ -81,7 +81,7 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
attach(newTarget: Target): () => void {
if (this.#destroyed) {
throw new StoreError('Store destroyed');
throw new StoreError('DESTROYED');
}
this.#attachAbort?.abort();
@@ -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('Target detached');
this.#queue.abortAll();
this.#resetState();
}
@@ -264,7 +264,7 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
for (const [name, config] of this.#requestConfigs) {
proxy[name] = (input?: unknown, meta?: RequestMetaInit) => {
if (this.#destroyed) {
return Promise.reject(new StoreError('Store destroyed'));
return Promise.reject(new StoreError('DESTROYED'));
}
return this.#execute(name, config, input, meta ? createRequestMeta(meta) : null);
@@ -283,25 +283,25 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
const key = resolveRequestKey(config.key, input);
for (const cancelKey of resolveRequestCancelKeys(config.cancel, input)) {
this.#queue.abort(cancelKey, `Cancelled by ${name}`);
this.#queue.abort(cancelKey);
}
const handler = async ({ input, signal }: TaskContext) => {
const target = this.#target;
if (!target) {
throw new StoreError('No target attached');
throw new StoreError('NO_TARGET');
}
for (const guard of config.guard) {
if (signal.aborted) {
throw new StoreError('Aborted');
throw new StoreError('ABORTED');
}
const result = await guard({ target, signal });
if (!result) {
throw new StoreError('Rejected');
throw new StoreError('REJECTED');
}
}
+22 -6
View File
@@ -4,24 +4,40 @@ import { isStoreError, StoreError } from '../errors';
describe('errors', () => {
describe('storeError', () => {
it('creates error with message', () => {
const error = new StoreError('test message');
expect(error.message).toBe('test message');
it('creates error with code only', () => {
const error = new StoreError('ABORTED');
expect(error.code).toBe('ABORTED');
expect(error.message).toBe('ABORTED');
expect(error.name).toBe('StoreError');
expect(error).toBeInstanceOf(Error);
});
it('creates error with code and message', () => {
const error = new StoreError('TIMEOUT', { message: 'Timeout: canPlay' });
expect(error.code).toBe('TIMEOUT');
expect(error.message).toBe('Timeout: canPlay');
});
it('supports cause for error chaining', () => {
const cause = new Error('original error');
const error = new StoreError('wrapped error', { cause });
expect(error.message).toBe('wrapped error');
const error = new StoreError('ABORTED', { cause });
expect(error.code).toBe('ABORTED');
expect(error.cause).toBe(cause);
});
it('supports both message and cause', () => {
const cause = new Error('original');
const error = new StoreError('TIMEOUT', { message: 'Timeout: guard', cause });
expect(error.code).toBe('TIMEOUT');
expect(error.message).toBe('Timeout: guard');
expect(error.cause).toBe(cause);
});
});
describe('type guard', () => {
it('isStoreError identifies store errors', () => {
expect(isStoreError(new StoreError('test'))).toBe(true);
expect(isStoreError(new StoreError('ABORTED'))).toBe(true);
expect(isStoreError(new StoreError('REJECTED'))).toBe(true);
expect(isStoreError(new Error('regular'))).toBe(false);
expect(isStoreError(null)).toBe(false);
});
+2 -1
View File
@@ -111,7 +111,7 @@ describe('guard', () => {
expect(await guard(createContext())).toBe(true);
});
it('throws StoreError on timeout', async () => {
it('throws StoreError with TIMEOUT code', async () => {
vi.useFakeTimers();
const guard = timeout(
@@ -125,6 +125,7 @@ describe('guard', () => {
await expect(promise).rejects.toThrow(StoreError);
await expect(promise).rejects.toMatchObject({
code: 'TIMEOUT',
message: 'Timeout: waitForReady',
});
@@ -83,7 +83,7 @@ describe('store lifecycle integration', () => {
// Test 1: Guard rejects when not ready
const failPromise = store.request.delayedAction().catch(e => e);
await vi.runAllTimersAsync();
await expect(failPromise).resolves.toMatchObject({ message: 'Rejected' });
await expect(failPromise).resolves.toMatchObject({ code: 'REJECTED' });
// Test 2: Guard passes when ready
ready = true;
+12 -11
View File
@@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { StoreError } from '../errors';
import { createQueue, delay } from '../queue';
describe('queue', () => {
@@ -89,7 +88,7 @@ describe('queue', () => {
const promise1 = queue.enqueue({ name: 'a', key: 'same', handler: first });
const promise2 = queue.enqueue({ name: 'b', key: 'same', handler: second });
await expect(promise1).rejects.toThrow(StoreError);
await expect(promise1).rejects.toMatchObject({ code: 'SUPERSEDED' });
await expect(promise2).resolves.toBe('second');
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledOnce();
@@ -177,7 +176,7 @@ describe('queue', () => {
expect(queue.dequeue('k')).toBe(false);
vi.advanceTimersByTime(100);
await expect(promise).rejects.toThrow(StoreError);
await expect(promise).rejects.toMatchObject({ code: 'REMOVED' });
expect(handler).not.toHaveBeenCalled();
});
});
@@ -192,8 +191,8 @@ describe('queue', () => {
queue.clear();
vi.advanceTimersByTime(100);
await expect(p1).rejects.toThrow(StoreError);
await expect(p2).rejects.toThrow(StoreError);
await expect(p1).rejects.toMatchObject({ code: 'REMOVED' });
await expect(p2).rejects.toMatchObject({ code: 'REMOVED' });
expect(Reflect.ownKeys(queue.queued).length).toBe(0);
});
});
@@ -243,7 +242,7 @@ describe('queue', () => {
await new Promise((_, reject) => {
signal.addEventListener('abort', () => {
aborted = true;
reject(new Error('aborted'));
reject(signal.reason);
});
setTimeout(() => {}, 1000);
});
@@ -251,9 +250,9 @@ describe('queue', () => {
});
await new Promise(r => setTimeout(r, 10));
queue.abort('k', 'test abort');
queue.abort('k');
await expect(promise).rejects.toThrow();
await expect(promise).rejects.toMatchObject({ code: 'ABORTED' });
expect(aborted).toBe(true);
});
});
@@ -351,7 +350,9 @@ describe('queue', () => {
const queue = createQueue();
queue.destroy();
await expect(queue.enqueue({ name: 't', key: 'k', handler: vi.fn() })).rejects.toThrow('Queue destroyed');
await expect(queue.enqueue({ name: 't', key: 'k', handler: vi.fn() })).rejects.toMatchObject({
code: 'DESTROYED',
});
});
it('aborts all pending on destroy', async () => {
@@ -372,7 +373,7 @@ describe('queue', () => {
await new Promise(r => setTimeout(r, 10));
queue.destroy();
await expect(promise).rejects.toThrow();
await expect(promise).rejects.toMatchObject({ code: 'ABORTED' });
expect(aborted).toHaveBeenCalled();
expect(queue.destroyed).toBe(true);
});
@@ -401,7 +402,7 @@ describe('queue', () => {
});
// First should be superseded
await expect(promise1).rejects.toMatchObject({ message: 'Superseded' });
await expect(promise1).rejects.toMatchObject({ code: 'SUPERSEDED' });
// Queue should only have the second task (first was explicitly deleted)
expect(Reflect.ownKeys(queue.queued).length).toBe(1);
+2 -3
View File
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { StoreError } from '../errors';
import { createQueue } from '../queue';
import { createSlice } from '../slice';
import { createStore } from '../store';
@@ -210,7 +209,7 @@ describe('store', () => {
onError: () => {}, // silence errors
});
await expect(store.request.setVolume(0.5)).rejects.toThrow(StoreError);
await expect(store.request.setVolume(0.5)).rejects.toMatchObject({ code: 'NO_TARGET' });
});
it('coordinates requests with same key', async () => {
@@ -225,7 +224,7 @@ describe('store', () => {
const playPromise = store.request.play();
const pausePromise = store.request.pause();
await expect(playPromise).rejects.toThrow(StoreError);
await expect(playPromise).rejects.toMatchObject({ code: 'SUPERSEDED' });
await pausePromise;
expect(media.paused).toBe(true);