@pyreon/state-tree 0.12.15 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/state-tree",
3
- "version": "0.12.15",
3
+ "version": "0.13.1",
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": {
@@ -47,9 +47,11 @@
47
47
  },
48
48
  "devDependencies": {
49
49
  "@happy-dom/global-registrator": "^20.8.9",
50
- "@pyreon/reactivity": "^0.12.15"
50
+ "@pyreon/manifest": "0.13.1",
51
+ "@pyreon/reactivity": "^0.13.1",
52
+ "bun-types": "^1.3.12"
51
53
  },
52
54
  "peerDependencies": {
53
- "@pyreon/reactivity": "^0.12.15"
55
+ "@pyreon/reactivity": "^0.13.1"
54
56
  }
55
57
  }
@@ -0,0 +1,161 @@
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
+ })
@@ -0,0 +1,85 @@
1
+ import {
2
+ renderApiReferenceEntries,
3
+ renderLlmsFullSection,
4
+ renderLlmsTxtLine,
5
+ } from '@pyreon/manifest'
6
+ import manifest from '../manifest'
7
+
8
+ describe('gen-docs — state-tree snapshot', () => {
9
+ it('renders to llms.txt bullet', () => {
10
+ expect(renderLlmsTxtLine(manifest)).toMatchInlineSnapshot(`"- @pyreon/state-tree — Structured reactive state tree — composable models with snapshots, patches, and middleware. State mutations must go through actions — direct \`.set()\` calls on state signals bypass middleware and patch recording. The model enforces this in dev mode."`)
11
+ })
12
+
13
+ it('renders to llms-full.txt section', () => {
14
+ expect(renderLlmsFullSection(manifest)).toMatchInlineSnapshot(`
15
+ "## @pyreon/state-tree — State Tree
16
+
17
+ 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.
18
+
19
+ \`\`\`typescript
20
+ import { model, getSnapshot, applySnapshot, onPatch, applyPatch, addMiddleware } from '@pyreon/state-tree'
21
+
22
+ // Define a model — state (signals), views (derived), actions (mutations):
23
+ const Todo = model({
24
+ state: { title: '', done: false },
25
+ views: (self) => ({
26
+ summary: () => \`\${self.title()} [\${self.done() ? 'x' : ' '}]\`,
27
+ }),
28
+ actions: (self) => ({
29
+ toggle: () => self.done.set(!self.done()),
30
+ rename: (title: string) => self.title.set(title),
31
+ }),
32
+ })
33
+
34
+ const TodoList = model({
35
+ state: { todos: [] as ReturnType<typeof Todo.create>[] },
36
+ actions: (self) => ({
37
+ add: (title: string) => {
38
+ const todo = Todo.create({ title, done: false })
39
+ self.todos.update(list => [...list, todo])
40
+ },
41
+ }),
42
+ })
43
+
44
+ // Create instances:
45
+ const list = TodoList.create({ todos: [] })
46
+ list.add('Write tests')
47
+ list.todos()[0].toggle()
48
+
49
+ // Snapshots — typed recursive serialization:
50
+ const snap = getSnapshot(list)
51
+ applySnapshot(list, { todos: [{ title: 'Restored', done: true }] })
52
+
53
+ // JSON patches — record/replay for undo, sync, debugging:
54
+ const patches: Patch[] = []
55
+ const dispose = onPatch(list, (patch) => patches.push(patch))
56
+ list.add('New item')
57
+ // Later: applyPatch(list, patches[0]) to replay
58
+
59
+ // Middleware — intercept any action in the tree:
60
+ addMiddleware(list, (call, next) => {
61
+ console.log(\`Action: \${call.name}\`, call.args)
62
+ return next(call)
63
+ })
64
+
65
+ // Singleton hook for app-wide state:
66
+ const useTodoList = TodoList.asHook('todo-list')
67
+ const { store } = useTodoList() // same instance on every call
68
+ \`\`\`
69
+
70
+ > **Actions only**: State mutations must go through actions — direct \`.set()\` calls on state signals bypass middleware and patch recording. The model enforces this in dev mode.
71
+ >
72
+ > **Snapshot serialization**: \`getSnapshot\` reads via \`.peek()\` so it does not subscribe to signals. The snapshot is a one-time read, not a reactive computed.
73
+ >
74
+ > **Devtools**: Import \`@pyreon/state-tree/devtools\` for a WeakRef-based registry of live model instances. Tree-shakeable — zero cost unless imported.
75
+ "
76
+ `)
77
+ })
78
+
79
+ it('renders to MCP api-reference entries', () => {
80
+ const record = renderApiReferenceEntries(manifest)
81
+ expect(Object.keys(record).length).toBe(6)
82
+ expect(record['state-tree/model']!.notes).toContain('ModelDefinition')
83
+ expect(record['state-tree/model']!.mistakes?.split('\n').length).toBe(3)
84
+ })
85
+ })