@pyreon/store 0.0.1

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.
@@ -0,0 +1,73 @@
1
+ import { Signal, batch, computed, effect, signal } from "@pyreon/reactivity";
2
+
3
+ //#region src/registry.d.ts
4
+ /**
5
+ * Override the store registry provider.
6
+ * Called by @pyreon/runtime-server to inject a per-request isolated registry,
7
+ * preventing store state from leaking between concurrent SSR requests.
8
+ *
9
+ * @example
10
+ * import { AsyncLocalStorage } from "node:async_hooks"
11
+ * const als = new AsyncLocalStorage<Map<string, unknown>>()
12
+ * setRegistryProvider(() => als.getStore() ?? new Map())
13
+ * // Then wrap each request: als.run(new Map(), () => renderToString(app))
14
+ */
15
+ declare function setRegistryProvider(fn: () => Map<string, unknown>): void;
16
+ //#endregion
17
+ //#region src/index.d.ts
18
+ interface MutationInfo {
19
+ storeId: string;
20
+ type: 'direct' | 'patch';
21
+ events: {
22
+ key: string;
23
+ newValue: unknown;
24
+ oldValue: unknown;
25
+ }[];
26
+ }
27
+ type SubscribeCallback = (mutation: MutationInfo, state: Record<string, unknown>) => void;
28
+ interface ActionContext {
29
+ name: string;
30
+ storeId: string;
31
+ args: unknown[];
32
+ after: (cb: (result: unknown) => void) => void;
33
+ onError: (cb: (error: unknown) => void) => void;
34
+ }
35
+ type OnActionCallback = (context: ActionContext) => void;
36
+ type StorePlugin = (api: StoreApi<Record<string, unknown>>) => void;
37
+ /** The structured result returned by every store hook. */
38
+ interface StoreApi<T> {
39
+ /** The user-defined store state, computeds, and actions. */
40
+ store: T;
41
+ /** Store identifier. */
42
+ id: string;
43
+ /** Read-only snapshot of all signal values. */
44
+ readonly state: Record<string, unknown>;
45
+ /** Batch-update multiple signals (object form) or direct access (function form). */
46
+ patch(partialState: Record<string, unknown>): void;
47
+ patch(fn: (state: Record<string, any>) => void): void;
48
+ /** Subscribe to state mutations. Returns an unsubscribe function. */
49
+ subscribe(callback: SubscribeCallback, options?: {
50
+ immediate?: boolean;
51
+ }): () => void;
52
+ /** Intercept action calls. Returns an unsubscribe function. */
53
+ onAction(callback: OnActionCallback): () => void;
54
+ /** Reset all signals to their initial values. */
55
+ reset(): void;
56
+ /** Teardown: unsubscribe all listeners and remove from registry. */
57
+ dispose(): void;
58
+ }
59
+ /** Register a global store plugin. Plugins run when a store is first created. */
60
+ declare function addStorePlugin(plugin: StorePlugin): void;
61
+ /**
62
+ * Define a store with a unique id and a setup function.
63
+ * Returns a hook that returns a `StoreApi<T>` with the user's state under `.store`
64
+ * and framework methods (`patch`, `subscribe`, `onAction`, `reset`, `dispose`) at the top level.
65
+ */
66
+ declare function defineStore<T extends Record<string, unknown>>(id: string, setup: () => T): () => StoreApi<T>;
67
+ /** Destroy a store by id so next call to useStore() re-runs setup. */
68
+ declare function resetStore(id: string): void;
69
+ /** Destroy all stores — useful for SSR isolation and tests. */
70
+ declare function resetAllStores(): void;
71
+ //#endregion
72
+ export { ActionContext, MutationInfo, OnActionCallback, type Signal, StoreApi, StorePlugin, SubscribeCallback, addStorePlugin, batch, computed, defineStore, effect, resetAllStores, resetStore, setRegistryProvider as setStoreRegistryProvider, signal };
73
+ //# sourceMappingURL=index2.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index2.d.ts","names":[],"sources":["../../src/registry.ts","../../src/index.ts"],"mappings":";;;;;;AAmBA;;;;;;;;iBAAgB,mBAAA,CAAoB,EAAA,QAAU,GAAA;;;UCiB7B,YAAA;EACf,OAAA;EACA,IAAA;EACA,MAAA;IAAU,GAAA;IAAa,QAAA;IAAmB,QAAA;EAAA;AAAA;AAAA,KAGhC,iBAAA,IACV,QAAA,EAAU,YAAA,EACV,KAAA,EAAO,MAAA;AAAA,UAGQ,aAAA;EACf,IAAA;EACA,OAAA;EACA,IAAA;EACA,KAAA,GAAQ,EAAA,GAAK,MAAA;EACb,OAAA,GAAU,EAAA,GAAK,KAAA;AAAA;AAAA,KAGL,gBAAA,IAAoB,OAAA,EAAS,aAAA;AAAA,KAE7B,WAAA,IAAe,GAAA,EAAK,QAAA,CAAS,MAAA;;UAGxB,QAAA;EALW;EAO1B,KAAA,EAAO,CAAA;EAPgC;EASvC,EAAA;EAPU;EAAA,SASD,KAAA,EAAO,MAAA;;EAEhB,KAAA,CAAM,YAAA,EAAc,MAAA;EACpB,KAAA,CAAM,EAAA,GAAK,KAAA,EAAO,MAAA;EAZqB;EAcvC,SAAA,CACE,QAAA,EAAU,iBAAA,EACV,OAAA;IAAY,SAAA;EAAA;EAbC;EAgBf,QAAA,CAAS,QAAA,EAAU,gBAAA;EAhBI;EAkBvB,KAAA;EAZgB;EAchB,OAAA;AAAA;;iBA8Bc,cAAA,CAAe,MAAA,EAAQ,WAAA;;;;;;iBAWvB,WAAA,WAAsB,MAAA,kBAAA,CACpC,EAAA,UACA,KAAA,QAAa,CAAA,SACN,QAAA,CAAS,CAAA;;iBAqPF,UAAA,CAAW,EAAA;;iBAMX,cAAA,CAAA"}
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@pyreon/store",
3
+ "version": "0.0.1",
4
+ "description": "Global state management for Pyreon — Pinia-inspired composition stores",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/pyreon/fundamentals.git",
9
+ "directory": "packages/store"
10
+ },
11
+ "homepage": "https://github.com/pyreon/fundamentals/tree/main/packages/store#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/pyreon/fundamentals/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "files": [
19
+ "lib",
20
+ "src",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "type": "module",
25
+ "main": "./lib/index.js",
26
+ "module": "./lib/index.js",
27
+ "types": "./lib/types/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "bun": "./src/index.ts",
31
+ "import": "./lib/index.js",
32
+ "types": "./lib/types/index.d.ts"
33
+ },
34
+ "./devtools": {
35
+ "bun": "./src/devtools.ts",
36
+ "import": "./lib/devtools.js",
37
+ "types": "./lib/types/devtools.d.ts"
38
+ }
39
+ },
40
+ "sideEffects": false,
41
+ "scripts": {
42
+ "build": "vl_rolldown_build",
43
+ "dev": "vl_rolldown_build-watch",
44
+ "test": "vitest run",
45
+ "typecheck": "tsc --noEmit"
46
+ },
47
+ "peerDependencies": {
48
+ "@pyreon/reactivity": "^0.2.1"
49
+ },
50
+ "devDependencies": {
51
+ "@happy-dom/global-registrator": "^20.8.3",
52
+ "@pyreon/reactivity": "^0.2.1",
53
+ "@vitus-labs/tools-lint": "^1.11.0"
54
+ }
55
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @pyreon/store devtools introspection API.
3
+ * Import: `import { ... } from "@pyreon/store/devtools"`
4
+ */
5
+
6
+ import { getRegistry } from './registry'
7
+
8
+ const _listeners = new Set<() => void>()
9
+
10
+ /** @internal — called by defineStore/resetStore to notify devtools. */
11
+ export function _notifyChange(): void {
12
+ for (const listener of _listeners) listener()
13
+ }
14
+
15
+ /** Get all registered store IDs. */
16
+ export function getRegisteredStores(): string[] {
17
+ return [...getRegistry().keys()]
18
+ }
19
+
20
+ /** Get a store API by ID (or undefined if not registered). */
21
+ export function getStoreById(
22
+ id: string,
23
+ ): import('./index').StoreApi<Record<string, unknown>> | undefined {
24
+ return getRegistry().get(id) as
25
+ | import('./index').StoreApi<Record<string, unknown>>
26
+ | undefined
27
+ }
28
+
29
+ /** Subscribe to store registry changes (store added/removed). Returns unsubscribe. */
30
+ export function onStoreChange(listener: () => void): () => void {
31
+ _listeners.add(listener)
32
+ return () => {
33
+ _listeners.delete(listener)
34
+ }
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,379 @@
1
+ /**
2
+ * @pyreon/store — global state management built on @pyreon/reactivity signals.
3
+ *
4
+ * API (composition style):
5
+ *
6
+ * const useCounter = defineStore("counter", () => {
7
+ * const count = signal(0)
8
+ * const double = computed(() => count() * 2)
9
+ * const increment = () => count.update(n => n + 1)
10
+ * return { count, double, increment }
11
+ * })
12
+ *
13
+ * // Inside a component (or anywhere):
14
+ * const { store, patch, subscribe } = useCounter()
15
+ * store.count() // read state
16
+ * store.increment() // call action
17
+ * patch({ count: 5 }) // batch-update
18
+ *
19
+ * Stores are singletons — the setup function runs once per store id.
20
+ * Call `resetStore(id)` or `resetAllStores()` to clear the registry
21
+ * (useful for testing or HMR).
22
+ *
23
+ * For concurrent SSR, call setStoreRegistryProvider() with an
24
+ * AsyncLocalStorage-backed provider so each request gets isolated store state.
25
+ */
26
+
27
+ export type { Signal } from '@pyreon/reactivity'
28
+ export { batch, computed, effect, signal } from '@pyreon/reactivity'
29
+ import { batch } from '@pyreon/reactivity'
30
+
31
+ export { setRegistryProvider as setStoreRegistryProvider } from './registry'
32
+ import { getRegistry } from './registry'
33
+ import { _notifyChange } from './devtools'
34
+
35
+ // ─── Types ───────────────────────────────────────────────────────────────────
36
+
37
+ export interface MutationInfo {
38
+ storeId: string
39
+ type: 'direct' | 'patch'
40
+ events: { key: string; newValue: unknown; oldValue: unknown }[]
41
+ }
42
+
43
+ export type SubscribeCallback = (
44
+ mutation: MutationInfo,
45
+ state: Record<string, unknown>,
46
+ ) => void
47
+
48
+ export interface ActionContext {
49
+ name: string
50
+ storeId: string
51
+ args: unknown[]
52
+ after: (cb: (result: unknown) => void) => void
53
+ onError: (cb: (error: unknown) => void) => void
54
+ }
55
+
56
+ export type OnActionCallback = (context: ActionContext) => void
57
+
58
+ export type StorePlugin = (api: StoreApi<Record<string, unknown>>) => void
59
+
60
+ /** The structured result returned by every store hook. */
61
+ export interface StoreApi<T> {
62
+ /** The user-defined store state, computeds, and actions. */
63
+ store: T
64
+ /** Store identifier. */
65
+ id: string
66
+ /** Read-only snapshot of all signal values. */
67
+ readonly state: Record<string, unknown>
68
+ /** Batch-update multiple signals (object form) or direct access (function form). */
69
+ patch(partialState: Record<string, unknown>): void
70
+ patch(fn: (state: Record<string, any>) => void): void
71
+ /** Subscribe to state mutations. Returns an unsubscribe function. */
72
+ subscribe(
73
+ callback: SubscribeCallback,
74
+ options?: { immediate?: boolean },
75
+ ): () => void
76
+ /** Intercept action calls. Returns an unsubscribe function. */
77
+ onAction(callback: OnActionCallback): () => void
78
+ /** Reset all signals to their initial values. */
79
+ reset(): void
80
+ /** Teardown: unsubscribe all listeners and remove from registry. */
81
+ dispose(): void
82
+ }
83
+
84
+ // ─── Detection helpers ───────────────────────────────────────────────────────
85
+
86
+ /** Duck-typed signal interface for detection without importing concrete types. */
87
+ interface SignalLike {
88
+ (): unknown
89
+ set(v: unknown): void
90
+ peek(): unknown
91
+ subscribe(l: () => void): () => void
92
+ }
93
+
94
+ function isSignalLike(v: unknown): v is SignalLike {
95
+ if (typeof v !== 'function') return false
96
+ const fn = v as unknown as Record<string, unknown>
97
+ return typeof fn.set === 'function' && typeof fn.peek === 'function'
98
+ }
99
+
100
+ function isComputedLike(v: unknown): boolean {
101
+ if (typeof v !== 'function') return false
102
+ const fn = v as unknown as Record<string, unknown>
103
+ return typeof fn.dispose === 'function' && !isSignalLike(v)
104
+ }
105
+
106
+ // ─── Plugin system ───────────────────────────────────────────────────────────
107
+
108
+ const _plugins: StorePlugin[] = []
109
+
110
+ /** Register a global store plugin. Plugins run when a store is first created. */
111
+ export function addStorePlugin(plugin: StorePlugin): void {
112
+ _plugins.push(plugin)
113
+ }
114
+
115
+ // ─── defineStore ─────────────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Define a store with a unique id and a setup function.
119
+ * Returns a hook that returns a `StoreApi<T>` with the user's state under `.store`
120
+ * and framework methods (`patch`, `subscribe`, `onAction`, `reset`, `dispose`) at the top level.
121
+ */
122
+ export function defineStore<T extends Record<string, unknown>>(
123
+ id: string,
124
+ setup: () => T,
125
+ ): () => StoreApi<T> {
126
+ return function useStore(): StoreApi<T> {
127
+ const registry = getRegistry()
128
+ if (registry.has(id)) return registry.get(id) as StoreApi<T>
129
+
130
+ const raw = setup()
131
+
132
+ // Classify properties
133
+ const signalKeys: string[] = []
134
+ const actionKeys: string[] = []
135
+ const initialValues = new Map<string, unknown>()
136
+
137
+ for (const key of Object.keys(raw)) {
138
+ const val = raw[key]
139
+ if (isSignalLike(val)) {
140
+ signalKeys.push(key)
141
+ initialValues.set(key, val.peek())
142
+ } else if (isComputedLike(val)) {
143
+ // computed — skip, just pass through
144
+ } else if (typeof val === 'function') {
145
+ actionKeys.push(key)
146
+ }
147
+ }
148
+
149
+ // ─── subscribe infrastructure ───────────────────────────────────────
150
+ const subscribers = new Set<SubscribeCallback>()
151
+ let patchInProgress = false
152
+ let patchEvents: MutationInfo['events'] = []
153
+
154
+ function getState(): Record<string, unknown> {
155
+ const state: Record<string, unknown> = {}
156
+ for (const key of signalKeys) {
157
+ state[key] = (raw[key] as any).peek()
158
+ }
159
+ return state
160
+ }
161
+
162
+ function notifyDirect(key: string, oldValue: unknown, newValue: unknown) {
163
+ if (patchInProgress) {
164
+ patchEvents.push({ key, newValue, oldValue })
165
+ return
166
+ }
167
+ if (subscribers.size === 0) return
168
+ const mutation: MutationInfo = {
169
+ storeId: id,
170
+ type: 'direct',
171
+ events: [{ key, newValue, oldValue }],
172
+ }
173
+ const state = getState()
174
+ for (const cb of subscribers) cb(mutation, state)
175
+ }
176
+
177
+ // Subscribe to each signal for change detection
178
+ const signalUnsubs: (() => void)[] = []
179
+ for (const key of signalKeys) {
180
+ const sig = raw[key] as any
181
+ let prev = sig.peek()
182
+ const unsub = sig.subscribe(() => {
183
+ const next = sig.peek()
184
+ const old = prev
185
+ prev = next
186
+ notifyDirect(key, old, next)
187
+ })
188
+ signalUnsubs.push(unsub)
189
+ }
190
+
191
+ // ─── onAction infrastructure ────────────────────────────────────────
192
+ const actionListeners = new Set<OnActionCallback>()
193
+
194
+ // Wrap actions
195
+ function wrapAction(key: string, original: (...args: any[]) => unknown) {
196
+ return (...args: unknown[]) => {
197
+ const afterCbs: ((result: unknown) => void)[] = []
198
+ const errorCbs: ((error: unknown) => void)[] = []
199
+
200
+ const context: ActionContext = {
201
+ name: key,
202
+ storeId: id,
203
+ args,
204
+ after: (cb) => afterCbs.push(cb),
205
+ onError: (cb) => errorCbs.push(cb),
206
+ }
207
+
208
+ for (const listener of actionListeners) {
209
+ listener(context)
210
+ }
211
+
212
+ try {
213
+ const result = original(...args)
214
+
215
+ // Handle async actions: if the result is a thenable, wait for
216
+ // resolution before calling after/onError callbacks.
217
+ if (result != null && typeof (result as any).then === 'function') {
218
+ return (result as Promise<unknown>).then(
219
+ (resolved) => {
220
+ for (const cb of afterCbs) cb(resolved)
221
+ return resolved
222
+ },
223
+ (err) => {
224
+ for (const cb of errorCbs) cb(err)
225
+ throw err
226
+ },
227
+ )
228
+ }
229
+
230
+ for (const cb of afterCbs) cb(result)
231
+ return result
232
+ } catch (err) {
233
+ for (const cb of errorCbs) cb(err)
234
+ throw err
235
+ }
236
+ }
237
+ }
238
+
239
+ // ─── Build user store object ────────────────────────────────────────
240
+ const userStore: Record<string, unknown> = {}
241
+
242
+ for (const key of Object.keys(raw)) {
243
+ if (actionKeys.includes(key)) {
244
+ userStore[key] = wrapAction(
245
+ key,
246
+ raw[key] as (...args: any[]) => unknown,
247
+ )
248
+ } else {
249
+ userStore[key] = raw[key]
250
+ }
251
+ }
252
+
253
+ // ─── Build StoreApi ─────────────────────────────────────────────────
254
+ const api: StoreApi<T> = {
255
+ store: userStore as T,
256
+
257
+ id,
258
+
259
+ get state() {
260
+ return getState()
261
+ },
262
+
263
+ patch(
264
+ partialOrFn:
265
+ | Record<string, unknown>
266
+ | ((state: Record<string, any>) => void),
267
+ ) {
268
+ patchInProgress = true
269
+ patchEvents = []
270
+
271
+ batch(() => {
272
+ if (typeof partialOrFn === 'function') {
273
+ // Functional form: pass an object with the actual signals so user calls .set()
274
+ const signalMap: Record<string, any> = {}
275
+ for (const key of signalKeys) {
276
+ signalMap[key] = raw[key]
277
+ }
278
+ partialOrFn(signalMap)
279
+ } else {
280
+ // Object form: set values directly (skip reserved proto keys)
281
+ for (const [key, value] of Object.entries(partialOrFn)) {
282
+ if (
283
+ key === '__proto__' ||
284
+ key === 'constructor' ||
285
+ key === 'prototype'
286
+ )
287
+ continue
288
+ if (signalKeys.includes(key)) {
289
+ ;(raw[key] as SignalLike).set(value)
290
+ }
291
+ }
292
+ }
293
+ })
294
+
295
+ patchInProgress = false
296
+
297
+ // Emit a single notification for the patch
298
+ if (subscribers.size > 0 && patchEvents.length > 0) {
299
+ const mutation: MutationInfo = {
300
+ storeId: id,
301
+ type: 'patch',
302
+ events: patchEvents,
303
+ }
304
+ const state = getState()
305
+ for (const cb of subscribers) cb(mutation, state)
306
+ }
307
+ patchEvents = []
308
+ },
309
+
310
+ subscribe(
311
+ callback: SubscribeCallback,
312
+ options?: { immediate?: boolean },
313
+ ): () => void {
314
+ subscribers.add(callback)
315
+ if (options?.immediate) {
316
+ const mutation: MutationInfo = {
317
+ storeId: id,
318
+ type: 'direct',
319
+ events: [],
320
+ }
321
+ callback(mutation, getState())
322
+ }
323
+ return () => {
324
+ subscribers.delete(callback)
325
+ }
326
+ },
327
+
328
+ onAction(callback: OnActionCallback): () => void {
329
+ actionListeners.add(callback)
330
+ return () => {
331
+ actionListeners.delete(callback)
332
+ }
333
+ },
334
+
335
+ reset() {
336
+ batch(() => {
337
+ for (const [key, initial] of initialValues) {
338
+ ;(raw[key] as any).set(initial)
339
+ }
340
+ })
341
+ },
342
+
343
+ dispose() {
344
+ for (const unsub of signalUnsubs) unsub()
345
+ signalUnsubs.length = 0
346
+ subscribers.clear()
347
+ actionListeners.clear()
348
+ getRegistry().delete(id)
349
+ },
350
+ }
351
+
352
+ // Run plugins — errors in one plugin should not break store creation
353
+ for (const plugin of _plugins) {
354
+ try {
355
+ plugin(api as StoreApi<Record<string, unknown>>)
356
+ } catch (_err) {
357
+ // Plugin errors should not break store creation
358
+ }
359
+ }
360
+
361
+ registry.set(id, api)
362
+ _notifyChange()
363
+ return api
364
+ }
365
+ }
366
+
367
+ // ─── Utilities ───────────────────────────────────────────────────────────────
368
+
369
+ /** Destroy a store by id so next call to useStore() re-runs setup. */
370
+ export function resetStore(id: string): void {
371
+ getRegistry().delete(id)
372
+ _notifyChange()
373
+ }
374
+
375
+ /** Destroy all stores — useful for SSR isolation and tests. */
376
+ export function resetAllStores(): void {
377
+ getRegistry().clear()
378
+ _notifyChange()
379
+ }
@@ -0,0 +1,26 @@
1
+ // ─── Registry ─────────────────────────────────────────────────────────────────
2
+
3
+ // Default: module-level singleton (CSR and single-threaded SSR).
4
+ // For concurrent SSR, @pyreon/runtime-server replaces this with an
5
+ // AsyncLocalStorage-backed provider via setRegistryProvider().
6
+ const _defaultRegistry = new Map<string, unknown>()
7
+ let _registryProvider: () => Map<string, unknown> = () => _defaultRegistry
8
+
9
+ /**
10
+ * Override the store registry provider.
11
+ * Called by @pyreon/runtime-server to inject a per-request isolated registry,
12
+ * preventing store state from leaking between concurrent SSR requests.
13
+ *
14
+ * @example
15
+ * import { AsyncLocalStorage } from "node:async_hooks"
16
+ * const als = new AsyncLocalStorage<Map<string, unknown>>()
17
+ * setRegistryProvider(() => als.getStore() ?? new Map())
18
+ * // Then wrap each request: als.run(new Map(), () => renderToString(app))
19
+ */
20
+ export function setRegistryProvider(fn: () => Map<string, unknown>): void {
21
+ _registryProvider = fn
22
+ }
23
+
24
+ export function getRegistry(): Map<string, unknown> {
25
+ return _registryProvider()
26
+ }
@@ -0,0 +1,70 @@
1
+ import { signal } from '@pyreon/reactivity'
2
+ import { getRegisteredStores, getStoreById, onStoreChange } from '../devtools'
3
+ import { defineStore, resetAllStores } from '../index'
4
+
5
+ afterEach(() => resetAllStores())
6
+
7
+ describe('store devtools', () => {
8
+ test('getRegisteredStores returns empty initially', () => {
9
+ expect(getRegisteredStores()).toEqual([])
10
+ })
11
+
12
+ test('getRegisteredStores returns store IDs after creation', () => {
13
+ const useCounter = defineStore('counter', () => ({ count: signal(0) }))
14
+ useCounter()
15
+ expect(getRegisteredStores()).toContain('counter')
16
+ })
17
+
18
+ test('getStoreById returns the store instance', () => {
19
+ const useCounter = defineStore('counter', () => ({ count: signal(0) }))
20
+ const store = useCounter()
21
+ const retrieved = getStoreById('counter')
22
+ expect(retrieved).toBe(store)
23
+ })
24
+
25
+ test('getStoreById returns undefined for non-existent store', () => {
26
+ expect(getStoreById('nope')).toBeUndefined()
27
+ })
28
+
29
+ test('onStoreChange fires when a store is created', () => {
30
+ const calls: number[] = []
31
+ const unsub = onStoreChange(() => calls.push(1))
32
+
33
+ const useCounter = defineStore('counter', () => ({ count: signal(0) }))
34
+ useCounter()
35
+ expect(calls.length).toBe(1)
36
+
37
+ unsub()
38
+ })
39
+
40
+ test('onStoreChange fires when a store is reset', () => {
41
+ const useCounter = defineStore('counter', () => ({ count: signal(0) }))
42
+ useCounter()
43
+
44
+ const calls: number[] = []
45
+ const unsub = onStoreChange(() => calls.push(1))
46
+
47
+ resetAllStores()
48
+ expect(calls.length).toBe(1)
49
+
50
+ unsub()
51
+ })
52
+
53
+ test('onStoreChange unsubscribe stops notifications', () => {
54
+ const calls: number[] = []
55
+ const unsub = onStoreChange(() => calls.push(1))
56
+ unsub()
57
+
58
+ const useCounter = defineStore('counter', () => ({ count: signal(0) }))
59
+ useCounter()
60
+ expect(calls.length).toBe(0)
61
+ })
62
+
63
+ test('multiple stores are tracked', () => {
64
+ const useA = defineStore('a', () => ({ val: signal(1) }))
65
+ const useB = defineStore('b', () => ({ val: signal(2) }))
66
+ useA()
67
+ useB()
68
+ expect(getRegisteredStores().sort()).toEqual(['a', 'b'])
69
+ })
70
+ })