mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(store): add error codes (#284)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user