Skip to content

Commit 220a6fa

Browse files
authored
Merge pull request #35 from script-development/feat/adapter-store-apply-external-updates
2 parents 3efcf1f + 1148707 commit 220a6fa

5 files changed

Lines changed: 356 additions & 17 deletions

File tree

docs/packages/adapter-store.md

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,59 @@ try {
181181

182182
The store automatically persists state to the provided storage service. When the page reloads, stored data is available immediately while `retrieveAll()` fetches fresh data from the API. This provides a fast initial render without loading spinners.
183183

184+
## Syncing External Updates
185+
186+
Some resources are updated outside of the store's own CRUD calls — by another user over a WebSocket, by a background job, by an in-process event emitter. The `broadcast` config slot is the single, narrow bridge for feeding those updates into the store without going through HTTP.
187+
188+
```typescript
189+
import type { AdapterStoreBroadcast } from "@script-development/fs-adapter-store";
190+
191+
const broadcast: AdapterStoreBroadcast<User> = {
192+
subscribe: ({ onUpdate, onDelete }) => {
193+
eventSource.on("user.updated", onUpdate);
194+
eventSource.on("user.deleted", onDelete);
195+
return () => {
196+
eventSource.off("user.updated", onUpdate);
197+
eventSource.off("user.deleted", onDelete);
198+
};
199+
},
200+
};
201+
202+
const usersStore = createAdapterStoreModule<User>({
203+
domainName: "users",
204+
adapter: resourceAdapter,
205+
httpService: http,
206+
storageService: storage,
207+
loadingService: loading,
208+
broadcast,
209+
});
210+
```
211+
212+
The store calls `subscribe` exactly once at construction and wires the handlers straight into its internal mutation path. `onUpdate(item)` replaces or inserts; `onDelete(id)` removes. Both update reactive state, refresh adapted views, and persist to storage — identical to what `update()` / `delete()` do after a successful HTTP call.
213+
214+
::: tip Why isn't there a public `setById` / `applyUpdate` method?
215+
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.
216+
:::
217+
218+
### Lifecycle
219+
220+
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.
221+
222+
A common pattern is a small in-process emitter as a middleman: your transport layer (WebSocket, SSE, channel service, whatever) joins and leaves connections as views mount/unmount, and forwards incoming payloads onto an emitter that the store subscribes to. The store stays agnostic of transport and lifecycle.
223+
224+
### The Contract
225+
226+
```typescript
227+
type AdapterStoreBroadcast<T> = {
228+
subscribe: (handlers: {
229+
onUpdate: (item: T) => void;
230+
onDelete: (id: number) => void;
231+
}) => () => void; // unsubscribe
232+
};
233+
```
234+
235+
That's it. Any event source that can emit "updated" and "deleted" events for your resource type can implement this.
236+
184237
## Custom New Types
185238

186239
By default, `generateNew()` creates an object with all fields except `id`. You can customize this with a third type parameter:
@@ -219,23 +272,25 @@ import { EntryNotFoundError, MissingResponseDataError } from "@script-developmen
219272

220273
### `createAdapterStoreModule(config)`
221274

222-
| Parameter | Type | Description |
223-
| ----------------------- | ----------------------------------------------- | -------------------------------------------- |
224-
| `config.domainName` | `string` | Resource endpoint name (e.g., `"users"`) |
225-
| `config.adapter` | `Adapter` | CRUD adapter factory (use `resourceAdapter`) |
226-
| `config.httpService` | `Pick<HttpService, "getRequest">` | HTTP service for fetching |
227-
| `config.storageService` | `Pick<StorageService, "get" \| "put">` | Storage for persistence |
228-
| `config.loadingService` | `Pick<LoadingService, "ensureLoadingFinished">` | Loading service for sync |
275+
| Parameter | Type | Description |
276+
| ----------------------- | ----------------------------------------------- | ----------------------------------------------------------- |
277+
| `config.domainName` | `string` | Resource endpoint name (e.g., `"users"`) |
278+
| `config.adapter` | `Adapter` | CRUD adapter factory (use `resourceAdapter`) |
279+
| `config.httpService` | `Pick<HttpService, "getRequest">` | HTTP service for fetching |
280+
| `config.storageService` | `Pick<StorageService, "get" \| "put">` | Storage for persistence |
281+
| `config.loadingService` | `Pick<LoadingService, "ensureLoadingFinished">` | Loading service for sync |
282+
| `config.broadcast?` | `AdapterStoreBroadcast<T>` | Optional external-event bridge for server-initiated updates |
229283

230284
### Store Module Methods
231285

232-
| Method | Returns | Description |
233-
| ------------------- | ----------------------------------- | -------------------------------------- |
234-
| `getAll` | `ComputedRef<Adapted[]>` | Reactive list of all adapted resources |
235-
| `getById(id)` | `ComputedRef<Adapted \| undefined>` | Reactive lookup by ID |
236-
| `getOrFailById(id)` | `Promise<Adapted>` | Wait for loading, throw if not found |
237-
| `generateNew()` | `NewAdapted` | Create a new unsaved resource |
238-
| `retrieveAll()` | `Promise<void>` | Fetch all from API and update state |
286+
| Method | Returns | Description |
287+
| ------------------- | ----------------------------------- | ------------------------------------------ |
288+
| `getAll` | `ComputedRef<Adapted[]>` | Reactive list of all adapted resources |
289+
| `getById(id)` | `ComputedRef<Adapted \| undefined>` | Reactive lookup by ID |
290+
| `getOrFailById(id)` | `Promise<Adapted>` | Wait for loading, throw if not found |
291+
| `generateNew()` | `NewAdapted` | Create a new unsaved resource |
292+
| `retrieveById(id)` | `Promise<void>` | Fetch a single resource from the API by id |
293+
| `retrieveAll()` | `Promise<void>` | Fetch all from API and update state |
239294

240295
### Adapted Properties
241296

packages/adapter-store/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@script-development/fs-adapter-store",
3-
"version": "0.1.2",
3+
"version": "0.1.3",
44
"description": "Reactive adapter-store pattern with domain state management and CRUD resource adapters",
55
"homepage": "https://packages.script.nl/packages/adapter-store",
66
"license": "MIT",

packages/adapter-store/src/adapter-store.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const createAdapterStoreModule = <
1919
>(
2020
config: AdapterStoreConfig<T, E, N>,
2121
): StoreModuleForAdapter<T, E, N> => {
22-
const { domainName, adapter, httpService, storageService, loadingService } = config;
22+
const { domainName, adapter, httpService, storageService, loadingService, broadcast } = config;
2323

2424
const storedItems = storageService.get<{ [id: number]: T }>(domainName, {});
2525
const frozenStoredItems = Object.fromEntries(
@@ -50,14 +50,18 @@ export const createAdapterStoreModule = <
5050
const deleteById = (id: number): void => {
5151
state.value = Object.fromEntries(
5252
Object.entries(state.value).filter(([key]) => Number(key) !== id),
53-
) as { [id: number]: Readonly<T> };
53+
) as {
54+
[id: number]: Readonly<T>;
55+
};
5456
storageService.put(domainName, state.value);
5557
adaptedCache.delete(id);
5658
getByIdComputedCache.delete(id);
5759
};
5860

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

63+
broadcast?.subscribe({ onUpdate: setById, onDelete: deleteById });
64+
6165
const getById = (id: number): ComputedRef<E | undefined> => {
6266
const cached = getByIdComputedCache.get(id);
6367
if (cached) {

packages/adapter-store/src/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,20 @@ export type Adapter<
5555
(storeModule: AdapterStoreModule<T>, resourceGetter: () => T): E;
5656
};
5757

58+
/**
59+
* Contract for binding server-initiated events (e.g. WebSocket broadcasts)
60+
* to an adapter-store. The store calls `subscribe` once at construction and
61+
* routes incoming events straight into its internal mutation path. The
62+
* handlers are never exposed on the public store API, so consumers cannot
63+
* acquire them to bypass HTTP.
64+
*/
65+
export type AdapterStoreBroadcast<T extends Item> = {
66+
subscribe: (handlers: {
67+
onUpdate: (item: T) => void;
68+
onDelete: (id: number) => void;
69+
}) => () => void;
70+
};
71+
5872
/** Configuration for createAdapterStoreModule. */
5973
export type AdapterStoreConfig<
6074
T extends Item,
@@ -66,6 +80,7 @@ export type AdapterStoreConfig<
6680
httpService: Pick<HttpService, "getRequest">;
6781
storageService: Pick<StorageService, "get" | "put">;
6882
loadingService: Pick<LoadingService, "ensureLoadingFinished">;
83+
broadcast?: AdapterStoreBroadcast<T>;
6984
};
7085

7186
/** Public API of a store module. */

0 commit comments

Comments
 (0)