@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.
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/lib/analysis/devtools.js.html +5406 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/devtools.js +37 -0
- package/lib/devtools.js.map +1 -0
- package/lib/index.js +225 -0
- package/lib/index.js.map +1 -0
- package/lib/types/devtools.d.ts +34 -0
- package/lib/types/devtools.d.ts.map +1 -0
- package/lib/types/devtools2.d.ts +58 -0
- package/lib/types/devtools2.d.ts.map +1 -0
- package/lib/types/index.d.ts +222 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +73 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +55 -0
- package/src/devtools.ts +35 -0
- package/src/index.ts +379 -0
- package/src/registry.ts +26 -0
- package/src/tests/devtools.test.ts +70 -0
- package/src/tests/store.test.ts +592 -0
|
@@ -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
|
+
}
|
package/src/devtools.ts
ADDED
|
@@ -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
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -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
|
+
})
|