@stone-js/store 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright © 2026 Stone Foundation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # Stone.js · Store
2
+
3
+ > Application state written once, read by any view engine. Vanilla core, SSR hydration built in, and
4
+ > no knowledge of React, Vue, Svelte or the DOM.
5
+
6
+ Part of **[Stone.js](https://stonejs.dev)**, the reference implementation of the
7
+ [Continuum Architecture](https://evens-stone.github.io/continuum-manifesto/manifesto): write your
8
+ domain once, and the context (runtime, protocol, caller) applies to it at run time.
9
+
10
+ - **Agnostic**: runs during server rendering, in the browser and in React Native, unchanged.
11
+ - **Hydration is not glue**: the state the server rendered is adopted *before* the first render.
12
+ - **Request-isolated by default**: two visitors rendering at once never see each other's state.
13
+ - **A container citizen**: reached with `useContainer()` in a component, injected into a service.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm i @stone-js/store
19
+ ```
20
+
21
+ ## Enabling it
22
+
23
+ Like every Stone.js module, one of two ways.
24
+
25
+ ```ts
26
+ import { Store, defineStore } from '@stone-js/store'
27
+ import { StoneApp } from '@stone-js/core'
28
+
29
+ @Store({ stores: [defineStore({ name: 'tasks', state: { items: [], filter: 'all' } })] })
30
+ @StoneApp({ name: 'my-app' })
31
+ export class Application {}
32
+ ```
33
+
34
+ ```ts
35
+ import { defineStoneApp, defineConfig } from '@stone-js/core'
36
+ import { defineStore, storeBlueprint } from '@stone-js/store'
37
+
38
+ export const Application = defineStoneApp({ name: 'my-app' }, [storeBlueprint])
39
+
40
+ export const AppConfig = defineConfig((blueprint) => blueprint.set('stone.store.stores', [
41
+ defineStore({ name: 'tasks', state: { items: [], filter: 'all' } })
42
+ ]))
43
+ ```
44
+
45
+ ## Using it
46
+
47
+ Every store is registered as `store.<name>`, so anything with the container reaches it.
48
+
49
+ ```ts
50
+ // in a component, on any view engine
51
+ const tasks = useContainer().make<IStore<Tasks>>('store.tasks')
52
+
53
+ // in a service, through the constructor
54
+ constructor ({ 'store.tasks': tasks }: { 'store.tasks': IStore<Tasks> }) { this.tasks = tasks }
55
+ ```
56
+
57
+ ```ts
58
+ tasks.getState()
59
+ tasks.setState({ filter: 'done' }) // merges, like a component's setState
60
+ tasks.setState((s) => ({ items: [...s.items, task] })) // or from the current state
61
+ tasks.select((s) => s.items.length) // read a derived value now
62
+ tasks.watch((s) => s.items.length, (n) => render(n)) // watch it, told only when it changes
63
+ tasks.reset()
64
+ ```
65
+
66
+ `watch` compares before notifying, which is the difference that matters: a selector building a fresh
67
+ object is never equal to itself, so a naive subscription re-renders forever. Select values, or pass
68
+ your own comparison as the third argument.
69
+
70
+ ## SSR hydration
71
+
72
+ Nothing to wire. Stone.js already ships a keyed, XSS-safe snapshot channel, and a store reads its
73
+ state out of it when it is registered, which is **before** the first render. Hydrating in an effect
74
+ afterwards is what produces the flash of empty state that hand-rolled integrations suffer from.
75
+
76
+ The state is merged over the initial state, so a snapshot written before a key existed still hydrates
77
+ into a usable state instead of an incomplete one.
78
+
79
+ This module never imports a view layer: it reads the snapshot through the container, duck-typed. A Vue
80
+ or Svelte layer registering the same binding gets hydration with nothing to add here.
81
+
82
+ ## Request isolation
83
+
84
+ `perRequest` defaults to `true`, and the default is the point. A store held as a process-wide
85
+ singleton leaks one visitor's state into the next visitor's page during server rendering, and nothing
86
+ in development reveals it because there is only ever one request at a time. Stone.js gives an
87
+ ephemeral container per event, so honouring it costs nothing.
88
+
89
+ ```ts
90
+ defineStore({ name: 'flags', state: { beta: false }, perRequest: false }) // genuinely process-wide
91
+ ```
92
+
93
+ ## Documentation
94
+
95
+ Full documentation: **[stonejs.dev/docs/extensions/store](https://stonejs.dev/docs/extensions/store)**.
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,106 @@
1
+ import { IStore, StoreEquality, StoreListener, StoreSelector, StoreUpdate } from './declarations.js';
2
+ /**
3
+ * Reference equality, the default for a derived value.
4
+ *
5
+ * The failure it exists to make visible: a selector that builds a fresh object every call is never
6
+ * equal to itself, so a component subscribed to it re-renders forever. That is the most common way a
7
+ * store is misused, in every library on the market, so the rule is documented rather than folklore:
8
+ * select values, or memoise what you build.
9
+ */
10
+ export declare function sameValue<Value>(a: Value, b: Value): boolean;
11
+ /**
12
+ * A store: application state written once, read by any view engine.
13
+ *
14
+ * Named `StateStore` so the bare `Store` name belongs to the activation decorator, the same rule that
15
+ * gives `CacheManager`, `RealtimeManager` and `I18nManager` their suffixes. Nobody writes this name
16
+ * often: stores are declared with `defineStore` or `@Store()`.
17
+ *
18
+ * It knows nothing of React, Vue, Svelte, the DOM or Node. That is what lets the same state layer run
19
+ * during server rendering, in the browser and in React Native, which is the continuum applied to
20
+ * state rather than to requests.
21
+ *
22
+ * Created through {@link defineStore} or the `@Store()` decorator; resolved from the container, so a
23
+ * component reaches it with `useContainer()` and a service takes it through its constructor.
24
+ */
25
+ export declare class StateStore<State extends Record<string, any> = Record<string, any>> implements IStore<State> {
26
+ private state;
27
+ private readonly initial;
28
+ private readonly listeners;
29
+ /**
30
+ * Create a store.
31
+ *
32
+ * @param initialState - The state to start from, and to return to on `reset`.
33
+ * @returns A new store.
34
+ */
35
+ static create<State extends Record<string, any>>(initialState: State): StateStore<State>;
36
+ /**
37
+ * @param initialState - The state to start from.
38
+ */
39
+ protected constructor(initialState: State);
40
+ /**
41
+ * The current state.
42
+ *
43
+ * @returns The state.
44
+ */
45
+ getState(): State;
46
+ /**
47
+ * Commit a change, merging a partial state.
48
+ *
49
+ * @param update - The next partial state, or a function of the current one.
50
+ */
51
+ setState(update: StoreUpdate<State>): void;
52
+ /**
53
+ * Replace the state wholesale.
54
+ *
55
+ * @param state - The next state.
56
+ */
57
+ replaceState(state: State): void;
58
+ /**
59
+ * Watch every change.
60
+ *
61
+ * @param listener - Called after each committed change.
62
+ * @returns The function that stops watching.
63
+ */
64
+ subscribe(listener: StoreListener<State>): () => void;
65
+ /**
66
+ * Read a derived value now.
67
+ *
68
+ * @param selector - Reads the value out of the state.
69
+ * @returns The value.
70
+ */
71
+ select<Value>(selector: StoreSelector<State, Value>): Value;
72
+ /**
73
+ * Watch a derived value, notified only when it actually changes.
74
+ *
75
+ * @param selector - Reads the value out of the state.
76
+ * @param listener - Called with the new and previous value.
77
+ * @param equals - How to compare, reference equality by default.
78
+ * @returns The function that stops watching.
79
+ */
80
+ watch<Value>(selector: StoreSelector<State, Value>, listener: (value: Value, previous: Value) => void, equals?: StoreEquality<Value>): () => void;
81
+ /**
82
+ * Put the state back to what it was created with.
83
+ */
84
+ reset(): void;
85
+ /**
86
+ * The state as it will be handed to the client.
87
+ *
88
+ * @returns The state to serialise.
89
+ */
90
+ dehydrate(): State;
91
+ /**
92
+ * Adopt a state produced on the server.
93
+ *
94
+ * Merged over the initial state rather than replacing it, so a snapshot written by an older release
95
+ * that lacks a newly added key hydrates into a usable state instead of an incomplete one.
96
+ *
97
+ * @param state - The state read from the snapshot.
98
+ */
99
+ hydrate(state: State): void;
100
+ /**
101
+ * Commit a state and tell every listener, once.
102
+ *
103
+ * @param next - The next state.
104
+ */
105
+ private commit;
106
+ }
@@ -0,0 +1,46 @@
1
+ import { IContainer, IServiceProvider, Promiseable } from '@stone-js/core';
2
+ /**
3
+ * The container alias a store is resolved under.
4
+ *
5
+ * `store.<name>`, so a component can ask for exactly the one it needs: `useContainer().make('store.tasks')`.
6
+ *
7
+ * @param name - The store name.
8
+ * @returns The alias.
9
+ */
10
+ export declare function storeAlias(name: string): string;
11
+ /**
12
+ * Registers every declared store in the container, and hydrates it when the client is picking up
13
+ * server-rendered markup.
14
+ *
15
+ * Two things make this worth being first-party rather than a third-party store plus glue:
16
+ *
17
+ * - **Hydration is not glue.** The framework already ships a keyed, XSS-safe snapshot channel; a store
18
+ * registered here reads its state out of it at registration time, which is *before* the first render.
19
+ * Hydrating in an effect afterwards is what produces the flash of empty state every hand-rolled
20
+ * integration suffers from.
21
+ * - **Request isolation is the default.** A store declared `perRequest` (the default) is bound as a
22
+ * plain binding on the per-event container, so two visitors rendering at once cannot see each
23
+ * other's state. A module-level singleton silently shares it, and nothing in development reveals it.
24
+ */
25
+ export declare class StoreServiceProvider implements IServiceProvider {
26
+ private readonly container;
27
+ /**
28
+ * @param container - The service container.
29
+ */
30
+ constructor(container: IContainer);
31
+ /**
32
+ * Register and hydrate every declared store.
33
+ */
34
+ register(): Promiseable<void>;
35
+ /**
36
+ * The states the server put in the snapshot, if any.
37
+ *
38
+ * Duck-typed: `@stone-js/use-view` owns the snapshot transport, and this module never imports it, so
39
+ * the store stays free of any view engine. A Vue or Svelte layer registering the same `snapshot`
40
+ * binding gets hydration with nothing to add here.
41
+ *
42
+ * @param key - The snapshot key the states live under.
43
+ * @returns The states by store name.
44
+ */
45
+ private hydratedStates;
46
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * A listener notified after every committed change.
3
+ */
4
+ export type StoreListener<State> = (state: State, previous: State) => void;
5
+ /**
6
+ * Reads a value out of the state. Kept pure: it is called on every change.
7
+ */
8
+ export type StoreSelector<State, Value> = (state: State) => Value;
9
+ /**
10
+ * How the next state is produced: a value, or a function of the current one.
11
+ */
12
+ export type StoreUpdate<State> = Partial<State> | ((state: State) => Partial<State>);
13
+ /**
14
+ * Compares two selected values to decide whether subscribers must be told.
15
+ */
16
+ export type StoreEquality<Value> = (a: Value, b: Value) => boolean;
17
+ /**
18
+ * The store contract, deliberately small and free of any view engine.
19
+ *
20
+ * Four operations answer every need a view has: read, write, watch, and derive. Anything larger
21
+ * belongs to the application, not to a store.
22
+ */
23
+ export interface IStore<State> {
24
+ /** The current state. */
25
+ getState: () => State;
26
+ /** Commit a change. Merges a partial state, like a component's setState. */
27
+ setState: (update: StoreUpdate<State>) => void;
28
+ /** Replace the state wholesale, bypassing the merge. */
29
+ replaceState: (state: State) => void;
30
+ /** Watch every change. Returns the function that stops watching. */
31
+ subscribe: (listener: StoreListener<State>) => () => void;
32
+ /** Read a derived value now. */
33
+ select: <Value>(selector: StoreSelector<State, Value>) => Value;
34
+ /** Watch a derived value, notified only when it actually changes. */
35
+ watch: <Value>(selector: StoreSelector<State, Value>, listener: (value: Value, previous: Value) => void, equals?: StoreEquality<Value>) => () => void;
36
+ /** Put the state back to what it was created with. */
37
+ reset: () => void;
38
+ /** The state as it will be handed to the client, for SSR. */
39
+ dehydrate: () => State;
40
+ /** Adopt a state produced on the server. */
41
+ hydrate: (state: State) => void;
42
+ }
43
+ /**
44
+ * A duck-typed snapshot: whatever the view layer registered in the container under `snapshot`.
45
+ *
46
+ * Duck-typed on purpose. `@stone-js/use-view` owns the snapshot transport and its XSS-safe
47
+ * serializer; this module never imports it, so the store stays free of any view engine while still
48
+ * hydrating from the very channel the framework already uses. A Vue or Svelte layer that registers
49
+ * the same binding gets hydration with no change here.
50
+ */
51
+ export interface SnapshotLike {
52
+ get: <T = unknown>(key: string, fallback?: T) => T | undefined;
53
+ set?: (key: string, value: unknown) => unknown;
54
+ add?: (key: string, value: unknown) => unknown;
55
+ }
@@ -0,0 +1,31 @@
1
+ import { StoreDefinition } from '../defineStore.js';
2
+ import { ClassType } from '@stone-js/core';
3
+ /**
4
+ * Options for the `@Store` decorator: the stores to declare, and the `stone.store` bucket.
5
+ */
6
+ export interface StoreDecoratorOptions {
7
+ /** The stores this application declares. */
8
+ stores?: StoreDefinition[];
9
+ /** The snapshot key the hydrated states live under. */
10
+ snapshotKey?: string;
11
+ }
12
+ /**
13
+ * Class decorator: give the application a store, declaratively.
14
+ *
15
+ * `@Store()` registers the provider that puts every declared store in the container. A component then
16
+ * reaches one through `useContainer()`, and a service takes it through its constructor, so the same
17
+ * state layer serves SSR, a SPA and React Native without any of them being named here.
18
+ *
19
+ * @param options - The stores to declare. Everything is optional.
20
+ * @returns A class decorator.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * import { Store, defineStore } from '@stone-js/store'
25
+ *
26
+ * @Store({ stores: [defineStore({ name: 'tasks', state: { items: [] } })] })
27
+ * @StoneApp({ name: 'my-app' })
28
+ * export class Application {}
29
+ * ```
30
+ */
31
+ export declare const Store: <T extends ClassType = ClassType>(options?: StoreDecoratorOptions) => ClassDecorator;
@@ -0,0 +1,42 @@
1
+ import { IStore } from './declarations.js';
2
+ /**
3
+ * What a store declares about itself.
4
+ */
5
+ export interface StoreDefinition<State extends Record<string, any> = Record<string, any>> {
6
+ /** The name it is resolved under, in the container and in the snapshot. */
7
+ name: string;
8
+ /** The state it starts from. */
9
+ state: State;
10
+ /**
11
+ * Whether the server keeps one instance per request. Default `true`, and the default matters.
12
+ *
13
+ * A store held as a module-level singleton leaks one visitor's state into the next visitor's page
14
+ * during server rendering. It is the most common SSR state bug there is, and it is invisible in
15
+ * development because there is only ever one request at a time. The kernel already gives an
16
+ * ephemeral container per event, so honouring it costs nothing and closes the hole by default.
17
+ * Set `false` only for state that is genuinely process-wide, like a feature-flag cache.
18
+ */
19
+ perRequest?: boolean;
20
+ }
21
+ /**
22
+ * Declare a store, imperatively.
23
+ *
24
+ * The counterpart of the `@Store()` decorator: same declaration, same registration, no decorators
25
+ * required. Both end up in `stone.store.stores`, which is what the provider registers.
26
+ *
27
+ * @param definition - What the store declares.
28
+ * @returns The definition, ready to be handed to the blueprint.
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * export const tasksStore = defineStore({ name: 'tasks', state: { items: [], filter: 'all' } })
33
+ * ```
34
+ */
35
+ export declare function defineStore<State extends Record<string, any>>(definition: StoreDefinition<State>): StoreDefinition<State>;
36
+ /**
37
+ * Build a store from its definition.
38
+ *
39
+ * @param definition - What the store declares.
40
+ * @returns A new store.
41
+ */
42
+ export declare function makeStore<State extends Record<string, any>>(definition: StoreDefinition<State>): IStore<State>;
@@ -0,0 +1,6 @@
1
+ export * from './StateStore.js';
2
+ export * from './StoreServiceProvider.js';
3
+ export * from './declarations.js';
4
+ export * from './decorators/Store.js';
5
+ export * from './defineStore.js';
6
+ export * from './options/StoreBlueprint.js';
package/dist/index.js ADDED
@@ -0,0 +1,327 @@
1
+ import { cloneValue } from '@stone-js/config';
2
+ import { classDecoratorLegacyWrapper, addBlueprint } from '@stone-js/core';
3
+
4
+ /**
5
+ * Reference equality, the default for a derived value.
6
+ *
7
+ * The failure it exists to make visible: a selector that builds a fresh object every call is never
8
+ * equal to itself, so a component subscribed to it re-renders forever. That is the most common way a
9
+ * store is misused, in every library on the market, so the rule is documented rather than folklore:
10
+ * select values, or memoise what you build.
11
+ */
12
+ function sameValue(a, b) {
13
+ return a === b;
14
+ }
15
+ /**
16
+ * A store: application state written once, read by any view engine.
17
+ *
18
+ * Named `StateStore` so the bare `Store` name belongs to the activation decorator, the same rule that
19
+ * gives `CacheManager`, `RealtimeManager` and `I18nManager` their suffixes. Nobody writes this name
20
+ * often: stores are declared with `defineStore` or `@Store()`.
21
+ *
22
+ * It knows nothing of React, Vue, Svelte, the DOM or Node. That is what lets the same state layer run
23
+ * during server rendering, in the browser and in React Native, which is the continuum applied to
24
+ * state rather than to requests.
25
+ *
26
+ * Created through {@link defineStore} or the `@Store()` decorator; resolved from the container, so a
27
+ * component reaches it with `useContainer()` and a service takes it through its constructor.
28
+ */
29
+ class StateStore {
30
+ state;
31
+ initial;
32
+ listeners = new Set();
33
+ /**
34
+ * Create a store.
35
+ *
36
+ * @param initialState - The state to start from, and to return to on `reset`.
37
+ * @returns A new store.
38
+ */
39
+ static create(initialState) {
40
+ return new this(initialState);
41
+ }
42
+ /**
43
+ * @param initialState - The state to start from.
44
+ */
45
+ constructor(initialState) {
46
+ // Cloned both ways: a caller keeping a reference to the object it passed in must not be able to
47
+ // mutate the store behind its back, and `reset` must return to the original, not to whatever the
48
+ // state has become.
49
+ this.initial = cloneValue(initialState);
50
+ this.state = cloneValue(initialState);
51
+ }
52
+ /**
53
+ * The current state.
54
+ *
55
+ * @returns The state.
56
+ */
57
+ getState() {
58
+ return this.state;
59
+ }
60
+ /**
61
+ * Commit a change, merging a partial state.
62
+ *
63
+ * @param update - The next partial state, or a function of the current one.
64
+ */
65
+ setState(update) {
66
+ const patch = typeof update === 'function' ? update(this.state) : update;
67
+ this.commit({ ...this.state, ...patch });
68
+ }
69
+ /**
70
+ * Replace the state wholesale.
71
+ *
72
+ * @param state - The next state.
73
+ */
74
+ replaceState(state) {
75
+ this.commit(state);
76
+ }
77
+ /**
78
+ * Watch every change.
79
+ *
80
+ * @param listener - Called after each committed change.
81
+ * @returns The function that stops watching.
82
+ */
83
+ subscribe(listener) {
84
+ this.listeners.add(listener);
85
+ return () => { this.listeners.delete(listener); };
86
+ }
87
+ /**
88
+ * Read a derived value now.
89
+ *
90
+ * @param selector - Reads the value out of the state.
91
+ * @returns The value.
92
+ */
93
+ select(selector) {
94
+ return selector(this.state);
95
+ }
96
+ /**
97
+ * Watch a derived value, notified only when it actually changes.
98
+ *
99
+ * @param selector - Reads the value out of the state.
100
+ * @param listener - Called with the new and previous value.
101
+ * @param equals - How to compare, reference equality by default.
102
+ * @returns The function that stops watching.
103
+ */
104
+ watch(selector, listener, equals = sameValue) {
105
+ let current = selector(this.state);
106
+ return this.subscribe((state) => {
107
+ const next = selector(state);
108
+ if (equals(next, current)) {
109
+ return;
110
+ }
111
+ const previous = current;
112
+ current = next;
113
+ listener(next, previous);
114
+ });
115
+ }
116
+ /**
117
+ * Put the state back to what it was created with.
118
+ */
119
+ reset() {
120
+ this.commit(cloneValue(this.initial));
121
+ }
122
+ /**
123
+ * The state as it will be handed to the client.
124
+ *
125
+ * @returns The state to serialise.
126
+ */
127
+ dehydrate() {
128
+ return this.state;
129
+ }
130
+ /**
131
+ * Adopt a state produced on the server.
132
+ *
133
+ * Merged over the initial state rather than replacing it, so a snapshot written by an older release
134
+ * that lacks a newly added key hydrates into a usable state instead of an incomplete one.
135
+ *
136
+ * @param state - The state read from the snapshot.
137
+ */
138
+ hydrate(state) {
139
+ this.commit({ ...this.initial, ...state });
140
+ }
141
+ /**
142
+ * Commit a state and tell every listener, once.
143
+ *
144
+ * @param next - The next state.
145
+ */
146
+ commit(next) {
147
+ const previous = this.state;
148
+ if (next === previous) {
149
+ return;
150
+ }
151
+ this.state = next;
152
+ // Iterated over a copy: a listener that unsubscribes (or subscribes) while being notified must not
153
+ // change what this round notifies, which is how a set-mutation-during-iteration bug hides.
154
+ const listeners = [...this.listeners];
155
+ for (const listener of listeners) {
156
+ listener(next, previous);
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Declare a store, imperatively.
163
+ *
164
+ * The counterpart of the `@Store()` decorator: same declaration, same registration, no decorators
165
+ * required. Both end up in `stone.store.stores`, which is what the provider registers.
166
+ *
167
+ * @param definition - What the store declares.
168
+ * @returns The definition, ready to be handed to the blueprint.
169
+ *
170
+ * @example
171
+ * ```typescript
172
+ * export const tasksStore = defineStore({ name: 'tasks', state: { items: [], filter: 'all' } })
173
+ * ```
174
+ */
175
+ function defineStore(definition) {
176
+ return definition;
177
+ }
178
+ /**
179
+ * Build a store from its definition.
180
+ *
181
+ * @param definition - What the store declares.
182
+ * @returns A new store.
183
+ */
184
+ function makeStore(definition) {
185
+ return StateStore.create(definition.state);
186
+ }
187
+
188
+ /**
189
+ * The container alias a store is resolved under.
190
+ *
191
+ * `store.<name>`, so a component can ask for exactly the one it needs: `useContainer().make('store.tasks')`.
192
+ *
193
+ * @param name - The store name.
194
+ * @returns The alias.
195
+ */
196
+ function storeAlias(name) {
197
+ return `store.${name}`;
198
+ }
199
+ /**
200
+ * Registers every declared store in the container, and hydrates it when the client is picking up
201
+ * server-rendered markup.
202
+ *
203
+ * Two things make this worth being first-party rather than a third-party store plus glue:
204
+ *
205
+ * - **Hydration is not glue.** The framework already ships a keyed, XSS-safe snapshot channel; a store
206
+ * registered here reads its state out of it at registration time, which is *before* the first render.
207
+ * Hydrating in an effect afterwards is what produces the flash of empty state every hand-rolled
208
+ * integration suffers from.
209
+ * - **Request isolation is the default.** A store declared `perRequest` (the default) is bound as a
210
+ * plain binding on the per-event container, so two visitors rendering at once cannot see each
211
+ * other's state. A module-level singleton silently shares it, and nothing in development reveals it.
212
+ */
213
+ class StoreServiceProvider {
214
+ container;
215
+ /**
216
+ * @param container - The service container.
217
+ */
218
+ constructor(container) {
219
+ this.container = container;
220
+ }
221
+ /**
222
+ * Register and hydrate every declared store.
223
+ */
224
+ register() {
225
+ const options = this.container
226
+ .make('blueprint')
227
+ .get('stone.store', {});
228
+ const hydrated = this.hydratedStates(options.snapshotKey ?? 'stores');
229
+ for (const definition of options.stores ?? []) {
230
+ const build = () => {
231
+ const store = makeStore(definition);
232
+ const state = hydrated[definition.name];
233
+ // Adopted before anything can read the store, so the first render already has real state.
234
+ if (state !== undefined) {
235
+ store.hydrate(state);
236
+ }
237
+ return store;
238
+ };
239
+ // A per-request store is rebuilt for each resolution on the ephemeral container; a shared one is
240
+ // a singleton. Both are declared the same way, which is the point.
241
+ if (definition.perRequest === false) {
242
+ this.container.singletonIf(storeAlias(definition.name), build);
243
+ }
244
+ else {
245
+ this.container.bindingIf?.(storeAlias(definition.name), build) ?? this.container.singletonIf(storeAlias(definition.name), build);
246
+ }
247
+ }
248
+ }
249
+ /**
250
+ * The states the server put in the snapshot, if any.
251
+ *
252
+ * Duck-typed: `@stone-js/use-view` owns the snapshot transport, and this module never imports it, so
253
+ * the store stays free of any view engine. A Vue or Svelte layer registering the same `snapshot`
254
+ * binding gets hydration with nothing to add here.
255
+ *
256
+ * @param key - The snapshot key the states live under.
257
+ * @returns The states by store name.
258
+ */
259
+ hydratedStates(key) {
260
+ if (this.container.has?.('snapshot') === undefined || !this.container.has('snapshot')) {
261
+ return {};
262
+ }
263
+ const snapshot = this.container.make('snapshot');
264
+ return snapshot?.get?.(key, {}) ?? {};
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Opt-in blueprint: register it to give the application a store.
270
+ *
271
+ * The imperative half of the pair; `@Store()` is the declarative one. It contributes the service
272
+ * provider that registers every declared store in the container, so a component reaches one with
273
+ * `useContainer()` and a service takes it through its constructor. Nothing here knows about a view
274
+ * engine.
275
+ *
276
+ * @example
277
+ * ```typescript
278
+ * import { defineStore, storeBlueprint } from '@stone-js/store'
279
+ *
280
+ * export const Application = defineStoneApp({ name: 'my-app' }, [storeBlueprint])
281
+ *
282
+ * export const AppConfig = defineConfig((blueprint) => blueprint.set('stone.store.stores', [
283
+ * defineStore({ name: 'tasks', state: { items: [] } })
284
+ * ]))
285
+ * ```
286
+ */
287
+ const storeBlueprint = {
288
+ stone: {
289
+ store: {
290
+ stores: []
291
+ },
292
+ providers: [
293
+ StoreServiceProvider
294
+ ]
295
+ }
296
+ };
297
+
298
+ /**
299
+ * Class decorator: give the application a store, declaratively.
300
+ *
301
+ * `@Store()` registers the provider that puts every declared store in the container. A component then
302
+ * reaches one through `useContainer()`, and a service takes it through its constructor, so the same
303
+ * state layer serves SSR, a SPA and React Native without any of them being named here.
304
+ *
305
+ * @param options - The stores to declare. Everything is optional.
306
+ * @returns A class decorator.
307
+ *
308
+ * @example
309
+ * ```typescript
310
+ * import { Store, defineStore } from '@stone-js/store'
311
+ *
312
+ * @Store({ stores: [defineStore({ name: 'tasks', state: { items: [] } })] })
313
+ * @StoneApp({ name: 'my-app' })
314
+ * export class Application {}
315
+ * ```
316
+ */
317
+ const Store = (options = {}) => {
318
+ return classDecoratorLegacyWrapper((target, context) => {
319
+ // The blueprint is the single source of truth for what the module declares; the decorator only
320
+ // overrides what it can, its options bucket.
321
+ const blueprint = cloneValue(storeBlueprint);
322
+ blueprint.stone.store = { ...blueprint.stone.store, ...options };
323
+ addBlueprint(target, context, blueprint);
324
+ });
325
+ };
326
+
327
+ export { StateStore, Store, StoreServiceProvider, defineStore, makeStore, sameValue, storeAlias, storeBlueprint };
@@ -0,0 +1,49 @@
1
+ import { StoreDefinition } from '../defineStore.js';
2
+ import { AppConfig, StoneBlueprint } from '@stone-js/core';
3
+ /**
4
+ * Store configuration bucket (`stone.store`).
5
+ */
6
+ export interface StoreConfig {
7
+ /**
8
+ * The stores an application declares, by name.
9
+ *
10
+ * Filled by `@Store()` and by handing `defineStore(...)` definitions here.
11
+ */
12
+ stores?: StoreDefinition[];
13
+ /**
14
+ * The snapshot key the hydrated states live under. Default `'stores'`.
15
+ */
16
+ snapshotKey?: string;
17
+ }
18
+ /**
19
+ * Application config augmented with the store bucket.
20
+ */
21
+ export interface StoreAppConfig extends Partial<AppConfig> {
22
+ store: StoreConfig;
23
+ }
24
+ /**
25
+ * Blueprint for the store module.
26
+ */
27
+ export interface StoreBlueprint extends StoneBlueprint {
28
+ stone: StoreAppConfig;
29
+ }
30
+ /**
31
+ * Opt-in blueprint: register it to give the application a store.
32
+ *
33
+ * The imperative half of the pair; `@Store()` is the declarative one. It contributes the service
34
+ * provider that registers every declared store in the container, so a component reaches one with
35
+ * `useContainer()` and a service takes it through its constructor. Nothing here knows about a view
36
+ * engine.
37
+ *
38
+ * @example
39
+ * ```typescript
40
+ * import { defineStore, storeBlueprint } from '@stone-js/store'
41
+ *
42
+ * export const Application = defineStoneApp({ name: 'my-app' }, [storeBlueprint])
43
+ *
44
+ * export const AppConfig = defineConfig((blueprint) => blueprint.set('stone.store.stores', [
45
+ * defineStore({ name: 'tasks', state: { items: [] } })
46
+ * ]))
47
+ * ```
48
+ */
49
+ export declare const storeBlueprint: StoreBlueprint;
package/package.json ADDED
@@ -0,0 +1,88 @@
1
+ {
2
+ "name": "@stone-js/store",
3
+ "version": "0.8.9",
4
+ "description": "View-engine-agnostic universal store for Stone.js, with SSR hydration built in.",
5
+ "author": "Mr. Stone <evensstone@gmail.com>",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/stone-foundation/stone-js-framework.git",
10
+ "directory": "stone-js-resources"
11
+ },
12
+ "homepage": "https://stonejs.dev",
13
+ "bugs": {
14
+ "url": "https://github.com/stone-foundation/stone-js-framework/issues"
15
+ },
16
+ "keywords": [
17
+ "Stone.js",
18
+ "store",
19
+ "state",
20
+ "ssr",
21
+ "hydration",
22
+ "agnostic"
23
+ ],
24
+ "files": [
25
+ "/dist"
26
+ ],
27
+ "type": "module",
28
+ "sideEffects": false,
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ }
35
+ },
36
+ "engines": {
37
+ "node": ">=18.17.0"
38
+ },
39
+ "devDependencies": {
40
+ "@commitlint/cli": "^19.8.1",
41
+ "@commitlint/config-conventional": "^19.8.1",
42
+ "@rollup/plugin-commonjs": "^28.0.6",
43
+ "@rollup/plugin-multi-entry": "^6.0.1",
44
+ "@rollup/plugin-node-resolve": "^16.0.1",
45
+ "@rollup/plugin-typescript": "^12.1.4",
46
+ "@types/node": "^24.0.7",
47
+ "@vitest/coverage-v8": "^3.2.4",
48
+ "husky": "^9.1.7",
49
+ "rimraf": "^6.0.1",
50
+ "rollup": "^4.44.1",
51
+ "rollup-plugin-node-externals": "^8.0.1",
52
+ "ts-standard": "^12.0.2",
53
+ "tslib": "^2.8.1",
54
+ "typedoc": "^0.28.6",
55
+ "typedoc-plugin-markdown": "^4.7.0",
56
+ "typescript": "^5.6.3",
57
+ "vitest": "^3.2.4"
58
+ },
59
+ "ts-standard": {
60
+ "globals": [
61
+ "it",
62
+ "test",
63
+ "vi",
64
+ "expect",
65
+ "describe",
66
+ "beforeEach"
67
+ ]
68
+ },
69
+ "dependencies": {
70
+ "@stone-js/config": "0.8.9"
71
+ },
72
+ "peerDependencies": {
73
+ "@stone-js/core": "0.8.9"
74
+ },
75
+ "scripts": {
76
+ "lint": "ts-standard src",
77
+ "lint:fix": "ts-standard --fix src tests",
78
+ "predoc": "rimraf docs",
79
+ "doc": "typedoc",
80
+ "clean": "rimraf dist",
81
+ "build": "rollup -c",
82
+ "test": "vitest run",
83
+ "test:cvg": "npm run test -- --coverage",
84
+ "test:text": "npm run test:cvg -- --coverage.reporter=text",
85
+ "test:html": "npm run test:cvg -- --coverage.reporter=html",
86
+ "test:clover": "npm run test:cvg -- --coverage.reporter=clover"
87
+ }
88
+ }