Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions docs/packages/adapter-store.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ The store calls `subscribe` exactly once at construction and wires the handlers
By design. Exposing a raw mutation method would let any caller bypass HTTP, which is almost always a bug (you'd end up with stale server state). The `broadcast` contract forces the bridge to be declared explicitly at store construction, scoped to one event source per store.
:::

The handlers the store passes to your `subscribe` are **validating wrappers**, not the raw internal mutators. Because broadcast payloads come from an external channel and are applied without an HTTP round-trip, they are checked before they touch state: `onUpdate` requires an object with an integer `id`, `onDelete` requires an integer id (`NaN` / `Infinity` / non-integer floats are rejected — they pass a `typeof === 'number'` check but corrupt the keyspace). A payload that fails throws [`BroadcastPayloadError`](#error-handling) rather than corrupting the store. The raw mutators never leave the factory — and since this is a closed contract, don't re-export the handlers onto your own public surface, which would publish a non-HTTP write path for arbitrary callers.

### Lifecycle

The `subscribe` call happens once, when the store is created. The unsubscribe return is retained internally and never exposed. In practice stores live for the app's lifetime, so teardown isn't needed — but if your event source has its own lifecycle (e.g., a channel you join and leave), manage that _outside_ the store. The store only cares about incoming events, not which channel they came from.
Expand Down Expand Up @@ -254,14 +256,19 @@ const newUser = usersStore.generateNew();

## Error Handling

The package exports two error classes:
The package exports three error classes:

```typescript
import {EntryNotFoundError, MissingResponseDataError} from '@script-development/fs-adapter-store';
import {
BroadcastPayloadError,
EntryNotFoundError,
MissingResponseDataError,
} from '@script-development/fs-adapter-store';
```

- **`EntryNotFoundError`** — thrown by `getOrFailById` when the resource doesn't exist in the store
- **`MissingResponseDataError`** — thrown when a CRUD response doesn't contain a `data` field
- **`BroadcastPayloadError`** — thrown by a `broadcast` handler when the incoming payload is malformed (`onUpdate` not given an object with an integer `id`, or `onDelete` given a non-integer id)

## API Reference

Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/adapter-store/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@script-development/fs-adapter-store",
"version": "0.1.7",
"version": "0.2.0",
"description": "Reactive adapter-store pattern with domain state management and CRUD resource adapters",
"homepage": "https://packages.script.nl/packages/adapter-store",
"license": "MIT",
Expand Down
27 changes: 25 additions & 2 deletions packages/adapter-store/src/adapter-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {computed, ref} from 'vue';

import type {Adapted, AdapterStoreConfig, AdapterStoreModule, Item, NewAdapted, StoreModuleForAdapter} from './types';

import {EntryNotFoundError} from './errors';
import {BroadcastPayloadError, EntryNotFoundError} from './errors';

export const createAdapterStoreModule = <
T extends Item,
Expand Down Expand Up @@ -52,7 +52,30 @@ export const createAdapterStoreModule = <

const storeModule: AdapterStoreModule<T> = {setById, deleteById};

broadcast?.subscribe({onUpdate: setById, onDelete: deleteById});
// Broadcast payloads arrive from an external channel (e.g. a WebSocket) and are
// applied to the store without an HTTP round-trip — that non-HTTP path is the
// feature. The trade-off is that unvalidated data would land straight in frozen
// state, so a malformed payload would silently corrupt the store. The handlers
// passed to the consumer's `subscribe` are therefore validating wrappers, not the
// bare internal mutators: they reject a bad payload up front, and the raw
// `setById`/`deleteById` never leave the factory. The id must be an integer, not
// merely `typeof === 'number'` — `NaN` / `Infinity` / a non-integer float would
// pass a typeof check yet corrupt the keyspace (`state.value[NaN]` stringifies to
// `"NaN"`, and `deleteById` could never match it since `Number("NaN") !== NaN`).
broadcast?.subscribe({
onUpdate: (item) => {
if (typeof item !== 'object' || item === null || !Number.isInteger((item as {id?: unknown}).id)) {
throw new BroadcastPayloadError(domainName, 'onUpdate', item);
}
setById(item);
},
onDelete: (id) => {
if (!Number.isInteger(id)) {
throw new BroadcastPayloadError(domainName, 'onDelete', id);
}
deleteById(id);
},
});

const getById = (id: number): ComputedRef<E | undefined> => {
const cached = getByIdComputedCache.get(id);
Expand Down
10 changes: 10 additions & 0 deletions packages/adapter-store/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,13 @@ export class MissingResponseDataError extends Error {
this.name = 'MissingResponseDataError';
}
}

export class BroadcastPayloadError extends Error {
constructor(domainName: string, handler: 'onUpdate' | 'onDelete', received: unknown) {
const expected = handler === 'onUpdate' ? 'an object with an integer `id`' : 'an integer id';
super(
`${domainName} broadcast ${handler} received an invalid payload — expected ${expected}, got ${typeof received}. The store rejects it rather than corrupting state.`,
);
this.name = 'BroadcastPayloadError';
}
}
2 changes: 1 addition & 1 deletion packages/adapter-store/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export {createAdapterStoreModule} from './adapter-store';
export {resourceAdapter} from './resource-adapter';
export {EntryNotFoundError, MissingResponseDataError} from './errors';
export {BroadcastPayloadError, EntryNotFoundError, MissingResponseDataError} from './errors';
export type {
Item,
DefaultNew,
Expand Down
16 changes: 13 additions & 3 deletions packages/adapter-store/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,19 @@ export type Adapter<T extends Item, E extends Adapted<T, object>, N extends NewA
/**
* Contract for binding server-initiated events (e.g. WebSocket broadcasts)
* to an adapter-store. The store calls `subscribe` once at construction and
* routes incoming events straight into its internal mutation path. The
* handlers are never exposed on the public store API, so consumers cannot
* acquire them to bypass HTTP.
* routes incoming events straight into its internal mutation path.
*
* This is a **closed** contract: the handlers are consumed inside the consumer's
* `subscribe` body (wired to an event source) and are never returned, so they do
* not reach the public store surface. The handlers the store passes are validating
* wrappers, not the bare internal mutators — `onUpdate` rejects a payload that is
* not an object with an integer `id`, and `onDelete` rejects a non-integer id, each
* throwing `BroadcastPayloadError` so a malformed broadcast cannot corrupt store
* state (`NaN` / `Infinity` / a non-integer float pass a `typeof === 'number'` check
* yet break the keyspace, so the guard requires an integer). Because the channel
* applies events without an HTTP round-trip, do not
* re-export the handlers onto your own public surface — that would publish a
* non-HTTP write path for arbitrary callers.
*/
export type AdapterStoreBroadcast<T extends Item> = {
subscribe: (handlers: {onUpdate: (item: T) => void; onDelete: (id: number) => void}) => () => void;
Expand Down
117 changes: 116 additions & 1 deletion packages/adapter-store/tests/adapter-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type {
} from '../src/types';

import {createAdapterStoreModule} from '../src/adapter-store';
import {EntryNotFoundError} from '../src/errors';
import {BroadcastPayloadError, EntryNotFoundError} from '../src/errors';

type TestNew = Omit<TestItem, 'id'>;
type TestStorageService = Pick<StorageService, 'get' | 'put'>;
Expand Down Expand Up @@ -947,6 +947,121 @@ describe('createAdapterStoreModule', () => {
}),
).not.toThrow();
});

it.each([
['a non-object payload', 'not-an-object'],
['an undefined payload', undefined],
['a null payload', null],
['an object with a non-numeric id', {id: 'KD-7', name: 'Bad'}],
['an object with a NaN id', {id: NaN, name: 'Bad'}],
['an object with a non-integer id', {id: 1.5, name: 'Bad'}],
])('should reject onUpdate given %s without corrupting state', (_label, payload) => {
// Arrange
const httpService: Pick<HttpService, 'getRequest'> = {getRequest: vi.fn()};
const storageService: TestStorageService = {put: vi.fn(), get: vi.fn().mockReturnValue({})};
const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)};
const {broadcast, getHandlers} = captureBroadcast();
const store = createAdapterStoreModule<TestItem, TestAdapted, TestNewAdapted>({
domainName: 'test-items',
adapter: createTestAdapter,
httpService,
storageService,
loadingService,
broadcast,
});

// Act & Assert
expect(() => getHandlers().onUpdate(payload as unknown as TestItem)).toThrow(BroadcastPayloadError);
expect(storageService.put).not.toHaveBeenCalled();
expect(store.getAll.value).toEqual([]);
});

it('should accept a well-formed onUpdate payload through the validating wrapper', () => {
// Arrange
const httpService: Pick<HttpService, 'getRequest'> = {getRequest: vi.fn()};
const storageService: TestStorageService = {put: vi.fn(), get: vi.fn().mockReturnValue({})};
const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)};
const {broadcast, getHandlers} = captureBroadcast();
const store = createAdapterStoreModule<TestItem, TestAdapted, TestNewAdapted>({
domainName: 'test-items',
adapter: createTestAdapter,
httpService,
storageService,
loadingService,
broadcast,
});

// Act
getHandlers().onUpdate({
id: 9,
name: 'Valid',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
});

// Assert
expect(store.getById(9).value).toBeDefined();
expect(storageService.put).toHaveBeenCalled();
});

it.each([
['a non-numeric id', 'KD-7'],
['a NaN id', NaN],
['a non-integer id', 1.5],
])('should reject onDelete given %s without corrupting state', (_label, id) => {
// Arrange
const httpService: Pick<HttpService, 'getRequest'> = {getRequest: vi.fn()};
const storageService: TestStorageService = {put: vi.fn(), get: vi.fn().mockReturnValue({})};
const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)};
const {broadcast, getHandlers} = captureBroadcast();
createAdapterStoreModule<TestItem, TestAdapted, TestNewAdapted>({
domainName: 'test-items',
adapter: createTestAdapter,
httpService,
storageService,
loadingService,
broadcast,
});

// Act & Assert
expect(() => getHandlers().onDelete(id as unknown as number)).toThrow(BroadcastPayloadError);
expect(storageService.put).not.toHaveBeenCalled();
});

it('should accept a numeric id through the onDelete validating wrapper', () => {
// Arrange
const httpService: Pick<HttpService, 'getRequest'> = {getRequest: vi.fn()};
const storageService: TestStorageService = {
put: vi.fn(),
get: vi
.fn()
.mockReturnValue({
5: {
id: 5,
name: 'Existing',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
}),
};
const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)};
const {broadcast, getHandlers} = captureBroadcast();
const store = createAdapterStoreModule<TestItem, TestAdapted, TestNewAdapted>({
domainName: 'test-items',
adapter: createTestAdapter,
httpService,
storageService,
loadingService,
broadcast,
});

// Act
getHandlers().onDelete(5);

// Assert
expect(store.getById(5).value).toBeUndefined();
expect(storageService.put).toHaveBeenCalled();
});
});

describe('localStorage persistence', () => {
Expand Down
35 changes: 34 additions & 1 deletion packages/adapter-store/tests/errors.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {describe, expect, it} from 'vitest';

// @vitest-environment happy-dom
import {EntryNotFoundError, MissingResponseDataError} from '../src/errors';
import {BroadcastPayloadError, EntryNotFoundError, MissingResponseDataError} from '../src/errors';

describe('EntryNotFoundError', () => {
it('should create error with correct message', () => {
Expand Down Expand Up @@ -42,3 +42,36 @@ describe('MissingResponseDataError', () => {
expect(error).toBeInstanceOf(MissingResponseDataError);
});
});

describe('BroadcastPayloadError', () => {
it('should describe an invalid onUpdate payload, naming the expected shape and received type', () => {
// Act
const error = new BroadcastPayloadError('users', 'onUpdate', null);

// Assert
expect(error.message).toBe(
'users broadcast onUpdate received an invalid payload — expected an object with an integer `id`, got object. The store rejects it rather than corrupting state.',
);
expect(error.name).toBe('BroadcastPayloadError');
});

it('should describe an invalid onDelete payload, naming the expected shape and received type', () => {
// Act
const error = new BroadcastPayloadError('users', 'onDelete', 'KD-7');

// Assert
expect(error.message).toBe(
'users broadcast onDelete received an invalid payload — expected an integer id, got string. The store rejects it rather than corrupting state.',
);
expect(error.name).toBe('BroadcastPayloadError');
});

it('should be an instance of Error', () => {
// Act
const error = new BroadcastPayloadError('items', 'onUpdate', undefined);

// Assert
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(BroadcastPayloadError);
});
});
6 changes: 3 additions & 3 deletions packages/cached-adapter-store/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@script-development/fs-cached-adapter-store",
"version": "0.2.0",
"version": "0.2.1",
"description": "Higher-order factory wrapping @script-development/fs-adapter-store with hash-bumping cache-check that suppresses redundant retrieveAll GETs at source",
"homepage": "https://packages.script.nl/packages/cached-adapter-store",
"license": "MIT",
Expand Down Expand Up @@ -42,15 +42,15 @@
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@script-development/fs-adapter-store": "^0.1.0",
"@script-development/fs-adapter-store": "^0.1.0 || ^0.2.0",
"@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0",
"@script-development/fs-storage": "^0.1.0",
"axios": "^1.18.0",
"happy-dom": "^20.10.2",
"vue": "^3.5.33"
},
"peerDependencies": {
"@script-development/fs-adapter-store": "^0.1.0",
"@script-development/fs-adapter-store": "^0.1.0 || ^0.2.0",
"@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0",
"@script-development/fs-storage": "^0.1.0",
"vue": "^3.5.33"
Expand Down