@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/src/patch.ts
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
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) => inner.subscribe(listener)
|
|
29
|
-
|
|
30
|
-
read.set = (newValue: T): void => {
|
|
31
|
-
const prev = inner.peek()
|
|
32
|
-
inner.set(newValue)
|
|
33
|
-
// Skip patch emission entirely when no one is listening — avoids object
|
|
34
|
-
// allocation and (for nested instances) a full recursive snapshot.
|
|
35
|
-
if (!Object.is(prev, newValue) && (!hasListeners || hasListeners())) {
|
|
36
|
-
// For model instances, emit the snapshot rather than the live object
|
|
37
|
-
// so patches are always plain JSON-serializable values.
|
|
38
|
-
const patchValue = isModelInstance(newValue) ? snapshotValue(newValue as object) : newValue
|
|
39
|
-
emitPatch({ op: 'replace', path, value: patchValue })
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
read.update = (fn: (current: T) => T): void => {
|
|
44
|
-
read.set(fn(inner.peek()))
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return read as Signal<T>
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Shallow snapshot helper (avoids importing snapshot.ts to prevent circular deps). */
|
|
51
|
-
function snapshotValue(instance: object): Record<string, unknown> {
|
|
52
|
-
const meta = instanceMeta.get(instance)
|
|
53
|
-
if (!meta) return instance as Record<string, unknown>
|
|
54
|
-
const out: Record<string, unknown> = {}
|
|
55
|
-
for (const key of meta.stateKeys) {
|
|
56
|
-
const sig = (instance as Record<string, Signal<unknown>>)[key]
|
|
57
|
-
if (!sig) continue
|
|
58
|
-
const val = sig.peek()
|
|
59
|
-
out[key] = isModelInstance(val) ? snapshotValue(val as object) : val
|
|
60
|
-
}
|
|
61
|
-
return out
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// ─── onPatch ──────────────────────────────────────────────────────────────────
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Subscribe to every state mutation in `instance` as a JSON patch.
|
|
68
|
-
* Also captures mutations in nested model instances (path is prefixed).
|
|
69
|
-
*
|
|
70
|
-
* Returns an unsubscribe function.
|
|
71
|
-
*
|
|
72
|
-
* @example
|
|
73
|
-
* const unsub = onPatch(counter, patch => {
|
|
74
|
-
* // { op: "replace", path: "/count", value: 6 }
|
|
75
|
-
* })
|
|
76
|
-
*/
|
|
77
|
-
export function onPatch(instance: object, listener: PatchListener): () => void {
|
|
78
|
-
const meta = instanceMeta.get(instance)
|
|
79
|
-
if (!meta) throw new Error('[@pyreon/state-tree] onPatch: not a model instance')
|
|
80
|
-
meta.patchListeners.add(listener)
|
|
81
|
-
return () => meta.patchListeners.delete(listener)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ─── applyPatch ─────────────────────────────────────────────────────────────
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Apply a JSON patch (or array of patches) to a model instance.
|
|
88
|
-
* Only "replace" operations are supported (matching the patches emitted by `onPatch`).
|
|
89
|
-
*
|
|
90
|
-
* Paths use JSON pointer format: `"/count"` for top-level, `"/profile/name"` for nested.
|
|
91
|
-
* Nested model instances are resolved automatically.
|
|
92
|
-
*
|
|
93
|
-
* @example
|
|
94
|
-
* applyPatch(counter, { op: "replace", path: "/count", value: 10 })
|
|
95
|
-
*
|
|
96
|
-
* @example
|
|
97
|
-
* // Replay patches recorded from onPatch (undo/redo, time-travel)
|
|
98
|
-
* applyPatch(counter, [
|
|
99
|
-
* { op: "replace", path: "/count", value: 1 },
|
|
100
|
-
* { op: "replace", path: "/count", value: 2 },
|
|
101
|
-
* ])
|
|
102
|
-
*/
|
|
103
|
-
export function applyPatch(instance: object, patch: Patch | Patch[]): void {
|
|
104
|
-
const patches = Array.isArray(patch) ? patch : [patch]
|
|
105
|
-
|
|
106
|
-
batch(() => {
|
|
107
|
-
for (const p of patches) {
|
|
108
|
-
if (p.op !== 'replace') {
|
|
109
|
-
throw new Error(`[@pyreon/state-tree] applyPatch: unsupported op "${p.op}"`)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const segments = p.path.split('/').filter(Boolean)
|
|
113
|
-
if (segments.length === 0) {
|
|
114
|
-
throw new Error('[@pyreon/state-tree] applyPatch: empty path')
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Walk to the target instance for nested paths
|
|
118
|
-
let target: object = instance
|
|
119
|
-
for (let i = 0; i < segments.length - 1; i++) {
|
|
120
|
-
const segment = segments[i]!
|
|
121
|
-
if (RESERVED_KEYS.has(segment)) {
|
|
122
|
-
throw new Error(`[@pyreon/state-tree] applyPatch: reserved property name "${segment}"`)
|
|
123
|
-
}
|
|
124
|
-
const meta = instanceMeta.get(target)
|
|
125
|
-
if (!meta)
|
|
126
|
-
throw new Error(`[@pyreon/state-tree] applyPatch: not a model instance at "${segment}"`)
|
|
127
|
-
const sig = (target as Record<string, Signal<unknown>>)[segment]
|
|
128
|
-
if (!sig || typeof sig.peek !== 'function') {
|
|
129
|
-
throw new Error(`[@pyreon/state-tree] applyPatch: unknown state key "${segment}"`)
|
|
130
|
-
}
|
|
131
|
-
const nested = sig.peek()
|
|
132
|
-
if (!nested || typeof nested !== 'object' || !isModelInstance(nested)) {
|
|
133
|
-
throw new Error(
|
|
134
|
-
`[@pyreon/state-tree] applyPatch: "${segment}" is not a nested model instance`,
|
|
135
|
-
)
|
|
136
|
-
}
|
|
137
|
-
target = nested as object
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const lastKey = segments[segments.length - 1]!
|
|
141
|
-
if (RESERVED_KEYS.has(lastKey)) {
|
|
142
|
-
throw new Error(`[@pyreon/state-tree] applyPatch: reserved property name "${lastKey}"`)
|
|
143
|
-
}
|
|
144
|
-
const meta = instanceMeta.get(target)
|
|
145
|
-
if (!meta) throw new Error('[@pyreon/state-tree] applyPatch: not a model instance')
|
|
146
|
-
if (!meta.stateKeys.includes(lastKey)) {
|
|
147
|
-
throw new Error(`[@pyreon/state-tree] applyPatch: unknown state key "${lastKey}"`)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const sig = (target as Record<string, Signal<unknown>>)[lastKey]
|
|
151
|
-
if (sig && typeof sig.set === 'function') {
|
|
152
|
-
sig.set(p.value)
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
})
|
|
156
|
-
}
|
package/src/registry.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
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 value != null && typeof value === 'object' && instanceMeta.has(value as object)
|
|
12
|
-
}
|
package/src/snapshot.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
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>(instance: object): Snapshot<TState> {
|
|
17
|
-
const meta = instanceMeta.get(instance)
|
|
18
|
-
if (!meta) throw new Error('[@pyreon/state-tree] getSnapshot: not a model instance')
|
|
19
|
-
|
|
20
|
-
const out: Record<string, unknown> = {}
|
|
21
|
-
for (const key of meta.stateKeys) {
|
|
22
|
-
const sig = (instance as Record<string, Signal<unknown>>)[key]
|
|
23
|
-
if (!sig) continue
|
|
24
|
-
const val = sig.peek()
|
|
25
|
-
out[key] = isModelInstance(val) ? getSnapshot(val as object) : val
|
|
26
|
-
}
|
|
27
|
-
return out as Snapshot<TState>
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// ─── applySnapshot ────────────────────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Restore a model instance from a plain-object snapshot.
|
|
34
|
-
* All signal writes are coalesced via `batch()` for a single reactive flush.
|
|
35
|
-
* Keys absent from the snapshot are left unchanged.
|
|
36
|
-
*
|
|
37
|
-
* @example
|
|
38
|
-
* applySnapshot(counter, { count: 0 })
|
|
39
|
-
*/
|
|
40
|
-
export function applySnapshot<TState extends StateShape>(
|
|
41
|
-
instance: object,
|
|
42
|
-
snapshot: Partial<Snapshot<TState>>,
|
|
43
|
-
): void {
|
|
44
|
-
const meta = instanceMeta.get(instance)
|
|
45
|
-
if (!meta) throw new Error('[@pyreon/state-tree] applySnapshot: not a model instance')
|
|
46
|
-
|
|
47
|
-
batch(() => {
|
|
48
|
-
for (const key of meta.stateKeys) {
|
|
49
|
-
if (!(key in snapshot)) continue
|
|
50
|
-
const sig = (instance as Record<string, Signal<unknown>>)[key]
|
|
51
|
-
if (!sig) continue
|
|
52
|
-
const val = (snapshot as Record<string, unknown>)[key]
|
|
53
|
-
const current = sig.peek()
|
|
54
|
-
if (isModelInstance(current)) {
|
|
55
|
-
// Recurse into nested model instance
|
|
56
|
-
applySnapshot(current as object, val as Record<string, unknown>)
|
|
57
|
-
} else {
|
|
58
|
-
sig.set(val)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
})
|
|
62
|
-
}
|