@pyreon/state-tree 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,87 @@
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(
66
+ name: string,
67
+ ): Record<string, unknown> | undefined {
68
+ const instance = getModelInstance(name)
69
+ if (!instance) return undefined
70
+ return getSnapshot(instance)
71
+ }
72
+
73
+ /**
74
+ * Subscribe to model registry changes. Returns unsubscribe function.
75
+ */
76
+ export function onModelChange(listener: () => void): () => void {
77
+ _listeners.add(listener)
78
+ return () => {
79
+ _listeners.delete(listener)
80
+ }
81
+ }
82
+
83
+ /** @internal — reset devtools registry (for tests). */
84
+ export function _resetDevtools(): void {
85
+ _activeModels.clear()
86
+ _listeners.clear()
87
+ }
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ // ─── Core ─────────────────────────────────────────────────────────────────────
2
+
3
+ export type { ModelDefinition } from './model'
4
+ export { model, resetHook, resetAllHooks } from './model'
5
+
6
+ // ─── Snapshot ─────────────────────────────────────────────────────────────────
7
+
8
+ export { applySnapshot, getSnapshot } from './snapshot'
9
+
10
+ // ─── Patches ─────────────────────────────────────────────────────────────────
11
+
12
+ export { onPatch, applyPatch } 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'
@@ -0,0 +1,128 @@
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
+ for (const [key, defaultValue] of Object.entries(config.state)) {
73
+ meta.stateKeys.push(key)
74
+ const path = `/${key}`
75
+ const initValue: unknown =
76
+ key in initial ? (initial as Record<string, unknown>)[key] : undefined
77
+
78
+ let rawSig: Signal<unknown>
79
+
80
+ if (isModelDef(defaultValue)) {
81
+ // Nested model — create its instance from the supplied snapshot (or defaults).
82
+ const nestedInstance = createInstance(
83
+ defaultValue._config,
84
+ (initValue as Record<string, unknown>) ?? {},
85
+ )
86
+ rawSig = signal(nestedInstance)
87
+
88
+ // Propagate nested patches upward with the key as path prefix.
89
+ onPatch(nestedInstance, (patch) => {
90
+ meta.emitPatch({ ...patch, path: path + patch.path })
91
+ })
92
+ } else {
93
+ rawSig = signal(initValue !== undefined ? initValue : defaultValue)
94
+ }
95
+
96
+ const tracked = trackedSignal(
97
+ rawSig,
98
+ path,
99
+ (p) => meta.emitPatch(p),
100
+ () => meta.patchListeners.size > 0,
101
+ )
102
+ instance[key] = tracked
103
+ }
104
+
105
+ // ── 2. Views ──────────────────────────────────────────────────────────────
106
+ if (config.views) {
107
+ const views = config.views(self)
108
+ for (const [key, view] of Object.entries(
109
+ views as Record<string, unknown>,
110
+ )) {
111
+ instance[key] = view
112
+ }
113
+ }
114
+
115
+ // ── 3. Actions (wrapped with middleware runner) ───────────────────────────
116
+ if (config.actions) {
117
+ const rawActions = config.actions(self) as Record<
118
+ string,
119
+ (...args: unknown[]) => unknown
120
+ >
121
+ for (const [key, actionFn] of Object.entries(rawActions)) {
122
+ instance[key] = (...args: unknown[]) =>
123
+ runAction(meta, key, actionFn, args)
124
+ }
125
+ }
126
+
127
+ return instance as ModelInstance<TState, TActions, TViews>
128
+ }
@@ -0,0 +1,57 @@
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(
46
+ instance: object,
47
+ middleware: MiddlewareFn,
48
+ ): () => void {
49
+ const meta = instanceMeta.get(instance)
50
+ if (!meta)
51
+ throw new Error('[@pyreon/state-tree] addMiddleware: not a model instance')
52
+ meta.middlewares.push(middleware)
53
+ return () => {
54
+ const idx = meta.middlewares.indexOf(middleware)
55
+ if (idx !== -1) meta.middlewares.splice(idx, 1)
56
+ }
57
+ }
package/src/model.ts ADDED
@@ -0,0 +1,117 @@
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(
51
+ initial?: Partial<Snapshot<TState>>,
52
+ ): ModelInstance<TState, TActions, TViews> {
53
+ return createInstance(this._config, initial ?? {})
54
+ }
55
+
56
+ /**
57
+ * Returns a hook function that always returns the same singleton instance
58
+ * for the given `id` — Zustand / Pinia style.
59
+ *
60
+ * @example
61
+ * const useCounter = Counter.asHook("app-counter")
62
+ * // Any call to useCounter() returns the same instance.
63
+ * const store = useCounter()
64
+ */
65
+ asHook(id: string): () => ModelInstance<TState, TActions, TViews> {
66
+ return () => {
67
+ if (!_hookRegistry.has(id)) {
68
+ _hookRegistry.set(id, this.create())
69
+ }
70
+ return _hookRegistry.get(id) as ModelInstance<TState, TActions, TViews>
71
+ }
72
+ }
73
+ }
74
+
75
+ // ─── model() factory ──────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Define a reactive model with state, views, and actions.
79
+ *
80
+ * - **state** — plain JS object; each key becomes a `Signal<T>` on the instance.
81
+ * - **views** — factory receiving `self`; return computed signals for derived state.
82
+ * - **actions** — factory receiving `self`; return functions that mutate state.
83
+ *
84
+ * Use nested `ModelDefinition` values in `state` to compose models.
85
+ *
86
+ * @example
87
+ * const Counter = model({
88
+ * state: { count: 0 },
89
+ * views: (self) => ({
90
+ * doubled: computed(() => self.count() * 2),
91
+ * }),
92
+ * actions: (self) => ({
93
+ * inc: () => self.count.update(c => c + 1),
94
+ * reset: () => self.count.set(0),
95
+ * }),
96
+ * })
97
+ *
98
+ * const c = Counter.create({ count: 5 })
99
+ * c.count() // 5
100
+ * c.inc()
101
+ * c.doubled() // 12
102
+ */
103
+ export function model<
104
+ TState extends StateShape,
105
+ TActions extends Record<string, (...args: any[]) => any> = Record<
106
+ never,
107
+ never
108
+ >,
109
+ TViews extends Record<string, Signal<any> | Computed<any>> = Record<
110
+ never,
111
+ never
112
+ >,
113
+ >(
114
+ config: ModelConfig<TState, TActions, TViews>,
115
+ ): ModelDefinition<TState, TActions, TViews> {
116
+ return new ModelDefinition(config)
117
+ }
package/src/patch.ts ADDED
@@ -0,0 +1,173 @@
1
+ import type { Signal } from '@pyreon/reactivity'
2
+ import { batch } from '@pyreon/reactivity'
3
+ import { instanceMeta, isModelInstance } from './registry'
4
+ import type { Patch, PatchListener } from './types'
5
+
6
+ /** Property names that must never be used as patch path segments. */
7
+ const RESERVED_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
8
+
9
+ // ─── Tracked signal ───────────────────────────────────────────────────────────
10
+
11
+ /**
12
+ * Wraps a signal so that every write emits a JSON patch via `emitPatch`.
13
+ * Reads are pass-through — no overhead on hot reactive paths.
14
+ *
15
+ * @param hasListeners Optional predicate — when provided, patch object allocation
16
+ * and snapshotting are skipped entirely when no listeners are registered.
17
+ */
18
+ export function trackedSignal<T>(
19
+ inner: Signal<T>,
20
+ path: string,
21
+ emitPatch: (patch: Patch) => void,
22
+ hasListeners?: () => boolean,
23
+ ): Signal<T> {
24
+ const read = (): T => inner()
25
+
26
+ read.peek = (): T => inner.peek()
27
+
28
+ read.subscribe = (listener: () => void): (() => void) =>
29
+ inner.subscribe(listener)
30
+
31
+ read.set = (newValue: T): void => {
32
+ const prev = inner.peek()
33
+ inner.set(newValue)
34
+ // Skip patch emission entirely when no one is listening — avoids object
35
+ // allocation and (for nested instances) a full recursive snapshot.
36
+ if (!Object.is(prev, newValue) && (!hasListeners || hasListeners())) {
37
+ // For model instances, emit the snapshot rather than the live object
38
+ // so patches are always plain JSON-serializable values.
39
+ const patchValue = isModelInstance(newValue)
40
+ ? snapshotValue(newValue as object)
41
+ : newValue
42
+ emitPatch({ op: 'replace', path, value: patchValue })
43
+ }
44
+ }
45
+
46
+ read.update = (fn: (current: T) => T): void => {
47
+ read.set(fn(inner.peek()))
48
+ }
49
+
50
+ return read as Signal<T>
51
+ }
52
+
53
+ /** Shallow snapshot helper (avoids importing snapshot.ts to prevent circular deps). */
54
+ function snapshotValue(instance: object): Record<string, unknown> {
55
+ const meta = instanceMeta.get(instance)
56
+ if (!meta) return instance as Record<string, unknown>
57
+ const out: Record<string, unknown> = {}
58
+ for (const key of meta.stateKeys) {
59
+ const sig = (instance as Record<string, Signal<unknown>>)[key]
60
+ if (!sig) continue
61
+ const val = sig.peek()
62
+ out[key] = isModelInstance(val) ? snapshotValue(val as object) : val
63
+ }
64
+ return out
65
+ }
66
+
67
+ // ─── onPatch ──────────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Subscribe to every state mutation in `instance` as a JSON patch.
71
+ * Also captures mutations in nested model instances (path is prefixed).
72
+ *
73
+ * Returns an unsubscribe function.
74
+ *
75
+ * @example
76
+ * const unsub = onPatch(counter, patch => {
77
+ * // { op: "replace", path: "/count", value: 6 }
78
+ * })
79
+ */
80
+ export function onPatch(instance: object, listener: PatchListener): () => void {
81
+ const meta = instanceMeta.get(instance)
82
+ if (!meta)
83
+ throw new Error('[@pyreon/state-tree] onPatch: not a model instance')
84
+ meta.patchListeners.add(listener)
85
+ return () => meta.patchListeners.delete(listener)
86
+ }
87
+
88
+ // ─── applyPatch ─────────────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Apply a JSON patch (or array of patches) to a model instance.
92
+ * Only "replace" operations are supported (matching the patches emitted by `onPatch`).
93
+ *
94
+ * Paths use JSON pointer format: `"/count"` for top-level, `"/profile/name"` for nested.
95
+ * Nested model instances are resolved automatically.
96
+ *
97
+ * @example
98
+ * applyPatch(counter, { op: "replace", path: "/count", value: 10 })
99
+ *
100
+ * @example
101
+ * // Replay patches recorded from onPatch (undo/redo, time-travel)
102
+ * applyPatch(counter, [
103
+ * { op: "replace", path: "/count", value: 1 },
104
+ * { op: "replace", path: "/count", value: 2 },
105
+ * ])
106
+ */
107
+ export function applyPatch(instance: object, patch: Patch | Patch[]): void {
108
+ const patches = Array.isArray(patch) ? patch : [patch]
109
+
110
+ batch(() => {
111
+ for (const p of patches) {
112
+ if (p.op !== 'replace') {
113
+ throw new Error(
114
+ `[@pyreon/state-tree] applyPatch: unsupported op "${p.op}"`,
115
+ )
116
+ }
117
+
118
+ const segments = p.path.split('/').filter(Boolean)
119
+ if (segments.length === 0) {
120
+ throw new Error('[@pyreon/state-tree] applyPatch: empty path')
121
+ }
122
+
123
+ // Walk to the target instance for nested paths
124
+ let target: object = instance
125
+ for (let i = 0; i < segments.length - 1; i++) {
126
+ const segment = segments[i]!
127
+ if (RESERVED_KEYS.has(segment)) {
128
+ throw new Error(
129
+ `[@pyreon/state-tree] applyPatch: reserved property name "${segment}"`,
130
+ )
131
+ }
132
+ const meta = instanceMeta.get(target)
133
+ if (!meta)
134
+ throw new Error(
135
+ `[@pyreon/state-tree] applyPatch: not a model instance at "${segment}"`,
136
+ )
137
+ const sig = (target as Record<string, Signal<unknown>>)[segment]
138
+ if (!sig || typeof sig.peek !== 'function') {
139
+ throw new Error(
140
+ `[@pyreon/state-tree] applyPatch: unknown state key "${segment}"`,
141
+ )
142
+ }
143
+ const nested = sig.peek()
144
+ if (!nested || typeof nested !== 'object' || !isModelInstance(nested)) {
145
+ throw new Error(
146
+ `[@pyreon/state-tree] applyPatch: "${segment}" is not a nested model instance`,
147
+ )
148
+ }
149
+ target = nested as object
150
+ }
151
+
152
+ const lastKey = segments[segments.length - 1]!
153
+ if (RESERVED_KEYS.has(lastKey)) {
154
+ throw new Error(
155
+ `[@pyreon/state-tree] applyPatch: reserved property name "${lastKey}"`,
156
+ )
157
+ }
158
+ const meta = instanceMeta.get(target)
159
+ if (!meta)
160
+ throw new Error('[@pyreon/state-tree] applyPatch: not a model instance')
161
+ if (!meta.stateKeys.includes(lastKey)) {
162
+ throw new Error(
163
+ `[@pyreon/state-tree] applyPatch: unknown state key "${lastKey}"`,
164
+ )
165
+ }
166
+
167
+ const sig = (target as Record<string, Signal<unknown>>)[lastKey]
168
+ if (sig && typeof sig.set === 'function') {
169
+ sig.set(p.value)
170
+ }
171
+ }
172
+ })
173
+ }
@@ -0,0 +1,16 @@
1
+ import type { InstanceMeta } from './types'
2
+
3
+ /**
4
+ * WeakMap from every model instance object → its internal metadata.
5
+ * Shared across patch, middleware, and snapshot modules.
6
+ */
7
+ export const instanceMeta = new WeakMap<object, InstanceMeta>()
8
+
9
+ /** Returns true when a value is a model instance (has metadata registered). */
10
+ export function isModelInstance(value: unknown): boolean {
11
+ return (
12
+ value != null &&
13
+ typeof value === 'object' &&
14
+ instanceMeta.has(value as object)
15
+ )
16
+ }
@@ -0,0 +1,66 @@
1
+ import type { Signal } from '@pyreon/reactivity'
2
+ import { batch } from '@pyreon/reactivity'
3
+ import { instanceMeta, isModelInstance } from './registry'
4
+ import type { Snapshot, StateShape } from './types'
5
+
6
+ // ─── getSnapshot ──────────────────────────────────────────────────────────────
7
+
8
+ /**
9
+ * Serialize a model instance to a plain JS object (no signals, no functions).
10
+ * Nested model instances are recursively serialized.
11
+ *
12
+ * @example
13
+ * getSnapshot(counter) // { count: 6 }
14
+ * getSnapshot(app) // { profile: { name: "Alice" }, title: "My App" }
15
+ */
16
+ export function getSnapshot<TState extends StateShape>(
17
+ instance: object,
18
+ ): Snapshot<TState> {
19
+ const meta = instanceMeta.get(instance)
20
+ if (!meta)
21
+ throw new Error('[@pyreon/state-tree] getSnapshot: not a model instance')
22
+
23
+ const out: Record<string, unknown> = {}
24
+ for (const key of meta.stateKeys) {
25
+ const sig = (instance as Record<string, Signal<unknown>>)[key]
26
+ if (!sig) continue
27
+ const val = sig.peek()
28
+ out[key] = isModelInstance(val) ? getSnapshot(val as object) : val
29
+ }
30
+ return out as Snapshot<TState>
31
+ }
32
+
33
+ // ─── applySnapshot ────────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Restore a model instance from a plain-object snapshot.
37
+ * All signal writes are coalesced via `batch()` for a single reactive flush.
38
+ * Keys absent from the snapshot are left unchanged.
39
+ *
40
+ * @example
41
+ * applySnapshot(counter, { count: 0 })
42
+ */
43
+ export function applySnapshot<TState extends StateShape>(
44
+ instance: object,
45
+ snapshot: Partial<Snapshot<TState>>,
46
+ ): void {
47
+ const meta = instanceMeta.get(instance)
48
+ if (!meta)
49
+ throw new Error('[@pyreon/state-tree] applySnapshot: not a model instance')
50
+
51
+ batch(() => {
52
+ for (const key of meta.stateKeys) {
53
+ if (!(key in snapshot)) continue
54
+ const sig = (instance as Record<string, Signal<unknown>>)[key]
55
+ if (!sig) continue
56
+ const val = (snapshot as Record<string, unknown>)[key]
57
+ const current = sig.peek()
58
+ if (isModelInstance(current)) {
59
+ // Recurse into nested model instance
60
+ applySnapshot(current as object, val as Record<string, unknown>)
61
+ } else {
62
+ sig.set(val)
63
+ }
64
+ }
65
+ })
66
+ }