@pyreon/state-tree 0.24.4 → 0.24.6
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/package.json +3 -6
- package/src/devtools.ts +0 -85
- package/src/index.ts +0 -29
- package/src/instance.ts +0 -128
- package/src/manifest.ts +0 -161
- package/src/middleware.ts +0 -53
- package/src/model.ts +0 -107
- package/src/patch.ts +0 -156
- package/src/registry.ts +0 -12
- package/src/snapshot.ts +0 -62
- package/src/tests/comprehensive.test.ts +0 -485
- package/src/tests/devtools.test.ts +0 -163
- package/src/tests/edge-cases.test.ts +0 -715
- package/src/tests/manifest-snapshot.test.ts +0 -85
- package/src/tests/model.test.ts +0 -712
- package/src/types.ts +0 -94
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/state-tree",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.6",
|
|
4
4
|
"description": "Structured reactive state tree — composable models with snapshots, patches, and middleware",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/state-tree#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"lib",
|
|
17
17
|
"!lib/**/*.map",
|
|
18
|
-
"src",
|
|
19
18
|
"README.md",
|
|
20
19
|
"LICENSE"
|
|
21
20
|
],
|
|
@@ -26,12 +25,10 @@
|
|
|
26
25
|
"types": "./lib/types/index.d.ts",
|
|
27
26
|
"exports": {
|
|
28
27
|
".": {
|
|
29
|
-
"bun": "./src/index.ts",
|
|
30
28
|
"import": "./lib/index.js",
|
|
31
29
|
"types": "./lib/types/index.d.ts"
|
|
32
30
|
},
|
|
33
31
|
"./devtools": {
|
|
34
|
-
"bun": "./src/devtools.ts",
|
|
35
32
|
"import": "./lib/devtools.js",
|
|
36
33
|
"types": "./lib/types/devtools.d.ts"
|
|
37
34
|
}
|
|
@@ -49,10 +46,10 @@
|
|
|
49
46
|
"devDependencies": {
|
|
50
47
|
"@happy-dom/global-registrator": "^20.8.9",
|
|
51
48
|
"@pyreon/manifest": "0.13.1",
|
|
52
|
-
"@pyreon/reactivity": "^0.24.
|
|
49
|
+
"@pyreon/reactivity": "^0.24.6",
|
|
53
50
|
"bun-types": "^1.3.12"
|
|
54
51
|
},
|
|
55
52
|
"dependencies": {
|
|
56
|
-
"@pyreon/reactivity": "^0.24.
|
|
53
|
+
"@pyreon/reactivity": "^0.24.6"
|
|
57
54
|
}
|
|
58
55
|
}
|
package/src/devtools.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @pyreon/state-tree devtools introspection API.
|
|
3
|
-
* Import: `import { ... } from "@pyreon/state-tree/devtools"`
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { getSnapshot } from './snapshot'
|
|
7
|
-
|
|
8
|
-
// Track active model instances (devtools-only, opt-in)
|
|
9
|
-
const _activeModels = new Map<string, WeakRef<object>>()
|
|
10
|
-
const _listeners = new Set<() => void>()
|
|
11
|
-
|
|
12
|
-
function _notify(): void {
|
|
13
|
-
for (const listener of _listeners) listener()
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Register a model instance for devtools inspection.
|
|
18
|
-
* Call this when creating instances you want visible in devtools.
|
|
19
|
-
*
|
|
20
|
-
* @example
|
|
21
|
-
* const counter = Counter.create()
|
|
22
|
-
* registerInstance("app-counter", counter)
|
|
23
|
-
*/
|
|
24
|
-
export function registerInstance(name: string, instance: object): void {
|
|
25
|
-
_activeModels.set(name, new WeakRef(instance))
|
|
26
|
-
_notify()
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Unregister a model instance.
|
|
31
|
-
*/
|
|
32
|
-
export function unregisterInstance(name: string): void {
|
|
33
|
-
_activeModels.delete(name)
|
|
34
|
-
_notify()
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Get all registered model instance names.
|
|
39
|
-
* Automatically cleans up garbage-collected instances.
|
|
40
|
-
*/
|
|
41
|
-
export function getActiveModels(): string[] {
|
|
42
|
-
for (const [name, ref] of _activeModels) {
|
|
43
|
-
if (ref.deref() === undefined) _activeModels.delete(name)
|
|
44
|
-
}
|
|
45
|
-
return [..._activeModels.keys()]
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Get a model instance by name (or undefined if GC'd or not registered).
|
|
50
|
-
*/
|
|
51
|
-
export function getModelInstance(name: string): object | undefined {
|
|
52
|
-
const ref = _activeModels.get(name)
|
|
53
|
-
if (!ref) return undefined
|
|
54
|
-
const instance = ref.deref()
|
|
55
|
-
if (!instance) {
|
|
56
|
-
_activeModels.delete(name)
|
|
57
|
-
return undefined
|
|
58
|
-
}
|
|
59
|
-
return instance
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Get a snapshot of a registered model instance.
|
|
64
|
-
*/
|
|
65
|
-
export function getModelSnapshot(name: string): Record<string, unknown> | undefined {
|
|
66
|
-
const instance = getModelInstance(name)
|
|
67
|
-
if (!instance) return undefined
|
|
68
|
-
return getSnapshot(instance)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Subscribe to model registry changes. Returns unsubscribe function.
|
|
73
|
-
*/
|
|
74
|
-
export function onModelChange(listener: () => void): () => void {
|
|
75
|
-
_listeners.add(listener)
|
|
76
|
-
return () => {
|
|
77
|
-
_listeners.delete(listener)
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** @internal — reset devtools registry (for tests). */
|
|
82
|
-
export function _resetDevtools(): void {
|
|
83
|
-
_activeModels.clear()
|
|
84
|
-
_listeners.clear()
|
|
85
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
// ─── Core ─────────────────────────────────────────────────────────────────────
|
|
2
|
-
|
|
3
|
-
export type { ModelDefinition } from './model'
|
|
4
|
-
export { model, resetAllHooks, resetHook } from './model'
|
|
5
|
-
|
|
6
|
-
// ─── Snapshot ─────────────────────────────────────────────────────────────────
|
|
7
|
-
|
|
8
|
-
export { applySnapshot, getSnapshot } from './snapshot'
|
|
9
|
-
|
|
10
|
-
// ─── Patches ─────────────────────────────────────────────────────────────────
|
|
11
|
-
|
|
12
|
-
export { applyPatch, onPatch } from './patch'
|
|
13
|
-
|
|
14
|
-
// ─── Middleware ───────────────────────────────────────────────────────────────
|
|
15
|
-
|
|
16
|
-
export { addMiddleware } from './middleware'
|
|
17
|
-
|
|
18
|
-
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
19
|
-
|
|
20
|
-
export type {
|
|
21
|
-
ActionCall,
|
|
22
|
-
MiddlewareFn,
|
|
23
|
-
ModelInstance,
|
|
24
|
-
ModelSelf,
|
|
25
|
-
Patch,
|
|
26
|
-
PatchListener,
|
|
27
|
-
Snapshot,
|
|
28
|
-
StateShape,
|
|
29
|
-
} from './types'
|
package/src/instance.ts
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import type { Computed, Signal } from '@pyreon/reactivity'
|
|
2
|
-
import { signal } from '@pyreon/reactivity'
|
|
3
|
-
import { runAction } from './middleware'
|
|
4
|
-
import { onPatch, trackedSignal } from './patch'
|
|
5
|
-
import { instanceMeta } from './registry'
|
|
6
|
-
import type { InstanceMeta, ModelInstance, Snapshot, StateShape } from './types'
|
|
7
|
-
import { MODEL_BRAND } from './types'
|
|
8
|
-
|
|
9
|
-
// ─── Model definition detection ───────────────────────────────────────────────
|
|
10
|
-
|
|
11
|
-
interface AnyModelDef {
|
|
12
|
-
readonly [MODEL_BRAND]: true
|
|
13
|
-
readonly _config: ModelConfig<
|
|
14
|
-
StateShape,
|
|
15
|
-
Record<string, (...args: unknown[]) => unknown>,
|
|
16
|
-
Record<string, Signal<unknown>>
|
|
17
|
-
>
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function isModelDef(v: unknown): v is AnyModelDef {
|
|
21
|
-
if (v == null || typeof v !== 'object') return false
|
|
22
|
-
return (v as Record<string, unknown>)[MODEL_BRAND] === true
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// ─── Config shape ─────────────────────────────────────────────────────────────
|
|
26
|
-
|
|
27
|
-
export interface ModelConfig<TState extends StateShape, TActions, TViews> {
|
|
28
|
-
state: TState
|
|
29
|
-
views?: (self: any) => TViews
|
|
30
|
-
actions?: (self: any) => TActions
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// ─── createInstance ───────────────────────────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Create a live model instance from a config + optional initial snapshot.
|
|
37
|
-
* Called by `ModelDefinition.create()`.
|
|
38
|
-
*/
|
|
39
|
-
export function createInstance<
|
|
40
|
-
TState extends StateShape,
|
|
41
|
-
TActions extends Record<string, (...args: any[]) => any>,
|
|
42
|
-
TViews extends Record<string, Signal<any> | Computed<any>>,
|
|
43
|
-
>(
|
|
44
|
-
config: ModelConfig<TState, TActions, TViews>,
|
|
45
|
-
initial: Partial<Snapshot<TState>>,
|
|
46
|
-
): ModelInstance<TState, TActions, TViews> {
|
|
47
|
-
// Raw object that will become the instance.
|
|
48
|
-
const instance: Record<string, unknown> = {}
|
|
49
|
-
|
|
50
|
-
// Metadata for this instance.
|
|
51
|
-
const meta: InstanceMeta = {
|
|
52
|
-
stateKeys: [],
|
|
53
|
-
patchListeners: new Set(),
|
|
54
|
-
middlewares: [],
|
|
55
|
-
emitPatch(patch) {
|
|
56
|
-
// Guard avoids iterating an empty Set on the hot signal-write path.
|
|
57
|
-
if (this.patchListeners.size === 0) return
|
|
58
|
-
for (const listener of this.patchListeners) listener(patch)
|
|
59
|
-
},
|
|
60
|
-
}
|
|
61
|
-
instanceMeta.set(instance, meta)
|
|
62
|
-
|
|
63
|
-
// `self` is a live proxy so that actions/views always see the final
|
|
64
|
-
// (fully-populated) instance — including wrapped actions added later.
|
|
65
|
-
const self = new Proxy(instance, {
|
|
66
|
-
get(_, k) {
|
|
67
|
-
return instance[k as string]
|
|
68
|
-
},
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
// ── 1. State signals ──────────────────────────────────────────────────────
|
|
72
|
-
// Per-state-key signal allocation at instance CREATION (createInstance
|
|
73
|
-
// runs once per model instance), not per-render — this is the model's
|
|
74
|
-
// fine-grained reactive architecture, not the signal-in-render-loop
|
|
75
|
-
// anti-pattern the rule targets. Disabled per-site below with rationale.
|
|
76
|
-
for (const [key, defaultValue] of Object.entries(config.state)) {
|
|
77
|
-
meta.stateKeys.push(key)
|
|
78
|
-
const path = `/${key}`
|
|
79
|
-
const initValue: unknown =
|
|
80
|
-
key in initial ? (initial as Record<string, unknown>)[key] : undefined
|
|
81
|
-
|
|
82
|
-
let rawSig: Signal<unknown>
|
|
83
|
-
|
|
84
|
-
if (isModelDef(defaultValue)) {
|
|
85
|
-
// Nested model — create its instance from the supplied snapshot (or defaults).
|
|
86
|
-
const nestedInstance = createInstance(
|
|
87
|
-
defaultValue._config,
|
|
88
|
-
(initValue as Record<string, unknown>) ?? {},
|
|
89
|
-
)
|
|
90
|
-
// pyreon-lint-disable-next-line pyreon/no-signal-in-loop
|
|
91
|
-
rawSig = signal(nestedInstance)
|
|
92
|
-
|
|
93
|
-
// Propagate nested patches upward with the key as path prefix.
|
|
94
|
-
onPatch(nestedInstance, (patch) => {
|
|
95
|
-
meta.emitPatch({ ...patch, path: path + patch.path })
|
|
96
|
-
})
|
|
97
|
-
} else {
|
|
98
|
-
// pyreon-lint-disable-next-line pyreon/no-signal-in-loop
|
|
99
|
-
rawSig = signal(initValue !== undefined ? initValue : defaultValue)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const tracked = trackedSignal(
|
|
103
|
-
rawSig,
|
|
104
|
-
path,
|
|
105
|
-
(p) => meta.emitPatch(p),
|
|
106
|
-
() => meta.patchListeners.size > 0,
|
|
107
|
-
)
|
|
108
|
-
instance[key] = tracked
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// ── 2. Views ──────────────────────────────────────────────────────────────
|
|
112
|
-
if (config.views) {
|
|
113
|
-
const views = config.views(self)
|
|
114
|
-
for (const [key, view] of Object.entries(views as Record<string, unknown>)) {
|
|
115
|
-
instance[key] = view
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// ── 3. Actions (wrapped with middleware runner) ───────────────────────────
|
|
120
|
-
if (config.actions) {
|
|
121
|
-
const rawActions = config.actions(self) as Record<string, (...args: unknown[]) => unknown>
|
|
122
|
-
for (const [key, actionFn] of Object.entries(rawActions)) {
|
|
123
|
-
instance[key] = (...args: unknown[]) => runAction(meta, key, actionFn, args)
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return instance as ModelInstance<TState, TActions, TViews>
|
|
128
|
-
}
|
package/src/manifest.ts
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import { defineManifest } from '@pyreon/manifest'
|
|
2
|
-
|
|
3
|
-
export default defineManifest({
|
|
4
|
-
name: '@pyreon/state-tree',
|
|
5
|
-
title: 'State Tree',
|
|
6
|
-
tagline:
|
|
7
|
-
'Structured reactive state tree — composable models with snapshots, patches, and middleware',
|
|
8
|
-
description:
|
|
9
|
-
'MobX-State-Tree-inspired structured state management built on Pyreon signals. Models compose state (signals), views (computeds), and actions into self-contained units that support typed snapshots, JSON-patch record/replay, and action interception middleware. Models can nest other models for tree-shaped state, and `.asHook(id)` provides singleton instances scoped to a store-like registry.',
|
|
10
|
-
category: 'universal',
|
|
11
|
-
longExample: `import { model, getSnapshot, applySnapshot, onPatch, applyPatch, addMiddleware } from '@pyreon/state-tree'
|
|
12
|
-
|
|
13
|
-
// Define a model — state (signals), views (derived), actions (mutations):
|
|
14
|
-
const Todo = model({
|
|
15
|
-
state: { title: '', done: false },
|
|
16
|
-
views: (self) => ({
|
|
17
|
-
summary: () => \`\${self.title()} [\${self.done() ? 'x' : ' '}]\`,
|
|
18
|
-
}),
|
|
19
|
-
actions: (self) => ({
|
|
20
|
-
toggle: () => self.done.set(!self.done()),
|
|
21
|
-
rename: (title: string) => self.title.set(title),
|
|
22
|
-
}),
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
const TodoList = model({
|
|
26
|
-
state: { todos: [] as ReturnType<typeof Todo.create>[] },
|
|
27
|
-
actions: (self) => ({
|
|
28
|
-
add: (title: string) => {
|
|
29
|
-
const todo = Todo.create({ title, done: false })
|
|
30
|
-
self.todos.update(list => [...list, todo])
|
|
31
|
-
},
|
|
32
|
-
}),
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
// Create instances:
|
|
36
|
-
const list = TodoList.create({ todos: [] })
|
|
37
|
-
list.add('Write tests')
|
|
38
|
-
list.todos()[0].toggle()
|
|
39
|
-
|
|
40
|
-
// Snapshots — typed recursive serialization:
|
|
41
|
-
const snap = getSnapshot(list)
|
|
42
|
-
applySnapshot(list, { todos: [{ title: 'Restored', done: true }] })
|
|
43
|
-
|
|
44
|
-
// JSON patches — record/replay for undo, sync, debugging:
|
|
45
|
-
const patches: Patch[] = []
|
|
46
|
-
const dispose = onPatch(list, (patch) => patches.push(patch))
|
|
47
|
-
list.add('New item')
|
|
48
|
-
// Later: applyPatch(list, patches[0]) to replay
|
|
49
|
-
|
|
50
|
-
// Middleware — intercept any action in the tree:
|
|
51
|
-
addMiddleware(list, (call, next) => {
|
|
52
|
-
console.log(\`Action: \${call.name}\`, call.args)
|
|
53
|
-
return next(call)
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
// Singleton hook for app-wide state:
|
|
57
|
-
const useTodoList = TodoList.asHook('todo-list')
|
|
58
|
-
const { store } = useTodoList() // same instance on every call`,
|
|
59
|
-
features: [
|
|
60
|
-
'model({ state, views, actions }) — structured reactive models',
|
|
61
|
-
'Nested model composition for tree-shaped state',
|
|
62
|
-
'getSnapshot / applySnapshot — typed recursive serialization',
|
|
63
|
-
'onPatch / applyPatch — JSON patch record and replay',
|
|
64
|
-
'addMiddleware — action interception chain',
|
|
65
|
-
'.create(initial) for instances, .asHook(id) for singleton hooks',
|
|
66
|
-
'Devtools subpath export with WeakRef-based registry',
|
|
67
|
-
],
|
|
68
|
-
api: [
|
|
69
|
-
{
|
|
70
|
-
name: 'model',
|
|
71
|
-
kind: 'function',
|
|
72
|
-
signature: '(definition: { state: StateShape, views?: (self: ModelSelf) => Record<string, () => any>, actions?: (self: ModelSelf) => Record<string, (...args: any[]) => any> }) => ModelDefinition',
|
|
73
|
-
summary:
|
|
74
|
-
'Define a structured reactive model. `state` declares signal-backed fields with their initial values. `views` are computed derivations. `actions` are the only way to mutate state — enabling middleware interception and patch recording. Returns a `ModelDefinition` with `.create(initial?)` for instances and `.asHook(id)` for singleton access.',
|
|
75
|
-
example: `const Counter = model({
|
|
76
|
-
state: { count: 0 },
|
|
77
|
-
views: (self) => ({
|
|
78
|
-
doubled: () => self.count() * 2,
|
|
79
|
-
}),
|
|
80
|
-
actions: (self) => ({
|
|
81
|
-
increment: () => self.count.update(n => n + 1),
|
|
82
|
-
}),
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
const counter = Counter.create({ count: 10 })
|
|
86
|
-
counter.count() // 10
|
|
87
|
-
counter.increment()
|
|
88
|
-
counter.doubled() // 22`,
|
|
89
|
-
mistakes: [
|
|
90
|
-
'Mutating state outside of actions — bypasses middleware and patch recording, breaks the structured contract',
|
|
91
|
-
'Forgetting that `self.count` is a signal — read with `self.count()`, write with `self.count.set(v)` or `.update(fn)` inside actions',
|
|
92
|
-
'Nesting plain objects in state instead of child models — plain objects are not signal-backed, changes to their properties are not reactive',
|
|
93
|
-
],
|
|
94
|
-
seeAlso: ['getSnapshot', 'applySnapshot', 'onPatch', 'addMiddleware'],
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
name: 'getSnapshot',
|
|
98
|
-
kind: 'function',
|
|
99
|
-
signature: '(instance: ModelInstance) => Snapshot',
|
|
100
|
-
summary:
|
|
101
|
-
'Recursively serialize a model instance into a plain JSON-safe snapshot. Reads all signal values via `.peek()` to avoid tracking subscriptions. Nested models are recursively serialized.',
|
|
102
|
-
example: `const snap = getSnapshot(counter) // { count: 10 }`,
|
|
103
|
-
seeAlso: ['applySnapshot', 'model'],
|
|
104
|
-
},
|
|
105
|
-
{
|
|
106
|
-
name: 'applySnapshot',
|
|
107
|
-
kind: 'function',
|
|
108
|
-
signature: '(instance: ModelInstance, snapshot: Snapshot) => void',
|
|
109
|
-
summary:
|
|
110
|
-
'Replace a model instance\'s state wholesale from a snapshot. Recursively applies to nested models. Triggers patch listeners with replace operations.',
|
|
111
|
-
example: `applySnapshot(counter, { count: 0 }) // reset to zero`,
|
|
112
|
-
seeAlso: ['getSnapshot', 'model'],
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
name: 'onPatch',
|
|
116
|
-
kind: 'function',
|
|
117
|
-
signature: '(instance: ModelInstance, listener: PatchListener) => () => void',
|
|
118
|
-
summary:
|
|
119
|
-
'Subscribe to JSON patches emitted by actions on a model instance. Each patch records the path, operation (add/replace/remove), and value. Returns an unsubscribe function. Pairs with `applyPatch` for undo/redo and state synchronization.',
|
|
120
|
-
example: `const dispose = onPatch(counter, (patch) => {
|
|
121
|
-
console.log(patch) // { op: 'replace', path: '/count', value: 11 }
|
|
122
|
-
})`,
|
|
123
|
-
seeAlso: ['applyPatch', 'model'],
|
|
124
|
-
},
|
|
125
|
-
{
|
|
126
|
-
name: 'applyPatch',
|
|
127
|
-
kind: 'function',
|
|
128
|
-
signature: '(instance: ModelInstance, patch: Patch | Patch[]) => void',
|
|
129
|
-
summary:
|
|
130
|
-
'Apply one or more JSON patches to a model instance. Accepts a single patch or an array for batch replay. Used with `onPatch` for undo/redo and state synchronization.',
|
|
131
|
-
example: `applyPatch(counter, { op: 'replace', path: '/count', value: 0 })`,
|
|
132
|
-
seeAlso: ['onPatch', 'model'],
|
|
133
|
-
},
|
|
134
|
-
{
|
|
135
|
-
name: 'addMiddleware',
|
|
136
|
-
kind: 'function',
|
|
137
|
-
signature: '(instance: ModelInstance, middleware: MiddlewareFn) => () => void',
|
|
138
|
-
summary:
|
|
139
|
-
'Add an action interception middleware to a model instance. The middleware receives the action call context and a `next` function — call `next(call)` to proceed or return early to block the action. Returns an unsubscribe function.',
|
|
140
|
-
example: `addMiddleware(counter, (call, next) => {
|
|
141
|
-
console.log(\`\${call.name}(\${call.args.join(', ')})\`)
|
|
142
|
-
return next(call)
|
|
143
|
-
})`,
|
|
144
|
-
seeAlso: ['model'],
|
|
145
|
-
},
|
|
146
|
-
],
|
|
147
|
-
gotchas: [
|
|
148
|
-
{
|
|
149
|
-
label: 'Actions only',
|
|
150
|
-
note: 'State mutations must go through actions — direct `.set()` calls on state signals bypass middleware and patch recording. The model enforces this in dev mode.',
|
|
151
|
-
},
|
|
152
|
-
{
|
|
153
|
-
label: 'Snapshot serialization',
|
|
154
|
-
note: '`getSnapshot` reads via `.peek()` so it does not subscribe to signals. The snapshot is a one-time read, not a reactive computed.',
|
|
155
|
-
},
|
|
156
|
-
{
|
|
157
|
-
label: 'Devtools',
|
|
158
|
-
note: 'Import `@pyreon/state-tree/devtools` for a WeakRef-based registry of live model instances. Tree-shakeable — zero cost unless imported.',
|
|
159
|
-
},
|
|
160
|
-
],
|
|
161
|
-
})
|
package/src/middleware.ts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import { instanceMeta } from './registry'
|
|
2
|
-
import type { ActionCall, InstanceMeta, MiddlewareFn } from './types'
|
|
3
|
-
|
|
4
|
-
// ─── Action runner ────────────────────────────────────────────────────────────
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Run an action through the middleware chain registered on `meta`.
|
|
8
|
-
* Each middleware receives the call descriptor and a `next` function.
|
|
9
|
-
* If no middlewares, the action runs directly.
|
|
10
|
-
*/
|
|
11
|
-
export function runAction(
|
|
12
|
-
meta: InstanceMeta,
|
|
13
|
-
name: string,
|
|
14
|
-
fn: (...fnArgs: unknown[]) => unknown,
|
|
15
|
-
args: unknown[],
|
|
16
|
-
): unknown {
|
|
17
|
-
const call: ActionCall = { name, args, path: `/${name}` }
|
|
18
|
-
|
|
19
|
-
const dispatch = (idx: number, c: ActionCall): unknown => {
|
|
20
|
-
if (idx >= meta.middlewares.length) return fn(...c.args)
|
|
21
|
-
const mw = meta.middlewares[idx]
|
|
22
|
-
if (!mw) return fn(...c.args)
|
|
23
|
-
return mw(c, (nextCall) => dispatch(idx + 1, nextCall))
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return dispatch(0, call)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// ─── addMiddleware ────────────────────────────────────────────────────────────
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Intercept every action call on `instance`.
|
|
33
|
-
* Middlewares run in registration order — call `next(call)` to continue.
|
|
34
|
-
*
|
|
35
|
-
* Returns an unsubscribe function.
|
|
36
|
-
*
|
|
37
|
-
* @example
|
|
38
|
-
* const unsub = addMiddleware(counter, (call, next) => {
|
|
39
|
-
* console.log(`> ${call.name}(${call.args})`)
|
|
40
|
-
* const result = next(call)
|
|
41
|
-
* console.log(`< ${call.name}`)
|
|
42
|
-
* return result
|
|
43
|
-
* })
|
|
44
|
-
*/
|
|
45
|
-
export function addMiddleware(instance: object, middleware: MiddlewareFn): () => void {
|
|
46
|
-
const meta = instanceMeta.get(instance)
|
|
47
|
-
if (!meta) throw new Error('[@pyreon/state-tree] addMiddleware: not a model instance')
|
|
48
|
-
meta.middlewares.push(middleware)
|
|
49
|
-
return () => {
|
|
50
|
-
const idx = meta.middlewares.indexOf(middleware)
|
|
51
|
-
if (idx !== -1) meta.middlewares.splice(idx, 1)
|
|
52
|
-
}
|
|
53
|
-
}
|
package/src/model.ts
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import type { Computed, Signal } from '@pyreon/reactivity'
|
|
2
|
-
import { createInstance, type ModelConfig } from './instance'
|
|
3
|
-
import type { ModelInstance, Snapshot, StateShape } from './types'
|
|
4
|
-
import { MODEL_BRAND } from './types'
|
|
5
|
-
|
|
6
|
-
// ─── Hook registry ────────────────────────────────────────────────────────────
|
|
7
|
-
|
|
8
|
-
// Module-level singleton registry for `asHook()` — isolated per package import.
|
|
9
|
-
// Use `resetHook(id)` or `resetAllHooks()` to clear entries (useful for tests / HMR).
|
|
10
|
-
const _hookRegistry = new Map<string, unknown>()
|
|
11
|
-
|
|
12
|
-
/** Destroy a hook singleton by id so next call re-creates the instance. */
|
|
13
|
-
export function resetHook(id: string): void {
|
|
14
|
-
_hookRegistry.delete(id)
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Destroy all hook singletons. */
|
|
18
|
-
export function resetAllHooks(): void {
|
|
19
|
-
_hookRegistry.clear()
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// ─── ModelDefinition ──────────────────────────────────────────────────────────
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Returned by `model()`. Call `.create()` for instances or `.asHook(id)` for
|
|
26
|
-
* a Zustand-style singleton hook.
|
|
27
|
-
*/
|
|
28
|
-
export class ModelDefinition<
|
|
29
|
-
TState extends StateShape,
|
|
30
|
-
TActions extends Record<string, (...args: any[]) => any>,
|
|
31
|
-
TViews extends Record<string, Signal<any> | Computed<any>>,
|
|
32
|
-
> {
|
|
33
|
-
/** Brand used to identify ModelDefinition objects at runtime (without instanceof). */
|
|
34
|
-
readonly [MODEL_BRAND] = true as const
|
|
35
|
-
|
|
36
|
-
/** @internal — exposed so nested instance creation can read it. */
|
|
37
|
-
readonly _config: ModelConfig<TState, TActions, TViews>
|
|
38
|
-
|
|
39
|
-
constructor(config: ModelConfig<TState, TActions, TViews>) {
|
|
40
|
-
this._config = config
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Create a new independent model instance.
|
|
45
|
-
* Pass a partial snapshot to override defaults.
|
|
46
|
-
*
|
|
47
|
-
* @example
|
|
48
|
-
* const counter = Counter.create({ count: 5 })
|
|
49
|
-
*/
|
|
50
|
-
create(initial?: Partial<Snapshot<TState>>): ModelInstance<TState, TActions, TViews> {
|
|
51
|
-
return createInstance(this._config, initial ?? {})
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Returns a hook function that always returns the same singleton instance
|
|
56
|
-
* for the given `id` — Zustand / Pinia style.
|
|
57
|
-
*
|
|
58
|
-
* @example
|
|
59
|
-
* const useCounter = Counter.asHook("app-counter")
|
|
60
|
-
* // Any call to useCounter() returns the same instance.
|
|
61
|
-
* const store = useCounter()
|
|
62
|
-
*/
|
|
63
|
-
asHook(id: string): () => ModelInstance<TState, TActions, TViews> {
|
|
64
|
-
return () => {
|
|
65
|
-
if (!_hookRegistry.has(id)) {
|
|
66
|
-
_hookRegistry.set(id, this.create())
|
|
67
|
-
}
|
|
68
|
-
return _hookRegistry.get(id) as ModelInstance<TState, TActions, TViews>
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// ─── model() factory ──────────────────────────────────────────────────────────
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Define a reactive model with state, views, and actions.
|
|
77
|
-
*
|
|
78
|
-
* - **state** — plain JS object; each key becomes a `Signal<T>` on the instance.
|
|
79
|
-
* - **views** — factory receiving `self`; return computed signals for derived state.
|
|
80
|
-
* - **actions** — factory receiving `self`; return functions that mutate state.
|
|
81
|
-
*
|
|
82
|
-
* Use nested `ModelDefinition` values in `state` to compose models.
|
|
83
|
-
*
|
|
84
|
-
* @example
|
|
85
|
-
* const Counter = model({
|
|
86
|
-
* state: { count: 0 },
|
|
87
|
-
* views: (self) => ({
|
|
88
|
-
* doubled: computed(() => self.count() * 2),
|
|
89
|
-
* }),
|
|
90
|
-
* actions: (self) => ({
|
|
91
|
-
* inc: () => self.count.update(c => c + 1),
|
|
92
|
-
* reset: () => self.count.set(0),
|
|
93
|
-
* }),
|
|
94
|
-
* })
|
|
95
|
-
*
|
|
96
|
-
* const c = Counter.create({ count: 5 })
|
|
97
|
-
* c.count() // 5
|
|
98
|
-
* c.inc()
|
|
99
|
-
* c.doubled() // 12
|
|
100
|
-
*/
|
|
101
|
-
export function model<
|
|
102
|
-
TState extends StateShape,
|
|
103
|
-
TActions extends Record<string, (...args: any[]) => any> = Record<never, never>,
|
|
104
|
-
TViews extends Record<string, Signal<any> | Computed<any>> = Record<never, never>,
|
|
105
|
-
>(config: ModelConfig<TState, TActions, TViews>): ModelDefinition<TState, TActions, TViews> {
|
|
106
|
-
return new ModelDefinition(config)
|
|
107
|
-
}
|