@kontsedal/olas-devtools 0.0.1-rc.0
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 +90 -0
- package/dist/index.cjs +1922 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +204 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +204 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1914 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +45 -0
- package/src/DevtoolsLauncher.tsx +258 -0
- package/src/DevtoolsPanel.tsx +749 -0
- package/src/JsonView.tsx +151 -0
- package/src/format.ts +37 -0
- package/src/index.ts +13 -0
- package/src/store.ts +352 -0
- package/src/styles.ts +594 -0
package/src/JsonView.tsx
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Compact JSON renderer for the devtools panel payload column.
|
|
2
|
+
//
|
|
3
|
+
// Renders values inline by default. Objects/arrays show their summary
|
|
4
|
+
// ("{4}", "[12]") inline; clicking the summary expands them. Each nested
|
|
5
|
+
// level inherits the same expand/collapse semantics. Scoped to the
|
|
6
|
+
// `olas-devtools-json-*` class prefix; styles are in `styles.ts`.
|
|
7
|
+
|
|
8
|
+
import { type ReactElement, useState } from 'react'
|
|
9
|
+
|
|
10
|
+
export function JsonView({ value, depth = 0 }: { value: unknown; depth?: number }): ReactElement {
|
|
11
|
+
return <Render value={value} depth={depth} initiallyOpen={depth === 0} />
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function Render({
|
|
15
|
+
value,
|
|
16
|
+
depth,
|
|
17
|
+
initiallyOpen,
|
|
18
|
+
}: {
|
|
19
|
+
value: unknown
|
|
20
|
+
depth: number
|
|
21
|
+
initiallyOpen: boolean
|
|
22
|
+
}): ReactElement {
|
|
23
|
+
if (value === null) return <span className="olas-devtools-json-null">null</span>
|
|
24
|
+
if (value === undefined) return <span className="olas-devtools-json-null">undefined</span>
|
|
25
|
+
|
|
26
|
+
const t = typeof value
|
|
27
|
+
if (t === 'string') return <span className="olas-devtools-json-string">"{value as string}"</span>
|
|
28
|
+
if (t === 'number') return <span className="olas-devtools-json-number">{String(value)}</span>
|
|
29
|
+
if (t === 'boolean') return <span className="olas-devtools-json-boolean">{String(value)}</span>
|
|
30
|
+
if (t === 'bigint') return <span className="olas-devtools-json-number">{String(value)}n</span>
|
|
31
|
+
|
|
32
|
+
// Errors render as `Error("message")` so they're distinguishable from
|
|
33
|
+
// plain string payloads.
|
|
34
|
+
if (value instanceof Error) {
|
|
35
|
+
return (
|
|
36
|
+
<span className="olas-devtools-json-error">
|
|
37
|
+
{value.name}({JSON.stringify(value.message)})
|
|
38
|
+
</span>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (Array.isArray(value)) {
|
|
43
|
+
return <CollapsibleArray value={value} depth={depth} initiallyOpen={initiallyOpen} />
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (t === 'object') {
|
|
47
|
+
return (
|
|
48
|
+
<CollapsibleObject
|
|
49
|
+
value={value as Record<string, unknown>}
|
|
50
|
+
depth={depth}
|
|
51
|
+
initiallyOpen={initiallyOpen}
|
|
52
|
+
/>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return <span>{String(value)}</span>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function CollapsibleArray({
|
|
60
|
+
value,
|
|
61
|
+
depth,
|
|
62
|
+
initiallyOpen,
|
|
63
|
+
}: {
|
|
64
|
+
value: unknown[]
|
|
65
|
+
depth: number
|
|
66
|
+
initiallyOpen: boolean
|
|
67
|
+
}): ReactElement {
|
|
68
|
+
const [open, setOpen] = useState(initiallyOpen && value.length <= 12)
|
|
69
|
+
if (value.length === 0) {
|
|
70
|
+
return <span className="olas-devtools-json-bracket">[]</span>
|
|
71
|
+
}
|
|
72
|
+
if (!open) {
|
|
73
|
+
return (
|
|
74
|
+
<button type="button" className="olas-devtools-json-toggle" onClick={() => setOpen(true)}>
|
|
75
|
+
<span className="olas-devtools-json-bracket">[</span>
|
|
76
|
+
<span className="olas-devtools-json-summary">
|
|
77
|
+
{value.length} item{value.length === 1 ? '' : 's'}
|
|
78
|
+
</span>
|
|
79
|
+
<span className="olas-devtools-json-bracket">]</span>
|
|
80
|
+
</button>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
return (
|
|
84
|
+
<span className="olas-devtools-json-block">
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
className="olas-devtools-json-toggle olas-devtools-json-toggle-open"
|
|
88
|
+
onClick={() => setOpen(false)}
|
|
89
|
+
>
|
|
90
|
+
<span className="olas-devtools-json-bracket">[</span>
|
|
91
|
+
</button>
|
|
92
|
+
<span className="olas-devtools-json-children">
|
|
93
|
+
{value.map((item, idx) => (
|
|
94
|
+
<span key={idx} className="olas-devtools-json-row">
|
|
95
|
+
<span className="olas-devtools-json-index">{idx}:</span>
|
|
96
|
+
<Render value={item} depth={depth + 1} initiallyOpen={false} />
|
|
97
|
+
</span>
|
|
98
|
+
))}
|
|
99
|
+
</span>
|
|
100
|
+
<span className="olas-devtools-json-bracket">]</span>
|
|
101
|
+
</span>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function CollapsibleObject({
|
|
106
|
+
value,
|
|
107
|
+
depth,
|
|
108
|
+
initiallyOpen,
|
|
109
|
+
}: {
|
|
110
|
+
value: Record<string, unknown>
|
|
111
|
+
depth: number
|
|
112
|
+
initiallyOpen: boolean
|
|
113
|
+
}): ReactElement {
|
|
114
|
+
const keys = Object.keys(value)
|
|
115
|
+
const [open, setOpen] = useState(initiallyOpen && keys.length <= 8)
|
|
116
|
+
if (keys.length === 0) {
|
|
117
|
+
return <span className="olas-devtools-json-bracket">{'{}'}</span>
|
|
118
|
+
}
|
|
119
|
+
if (!open) {
|
|
120
|
+
return (
|
|
121
|
+
<button type="button" className="olas-devtools-json-toggle" onClick={() => setOpen(true)}>
|
|
122
|
+
<span className="olas-devtools-json-bracket">{'{'}</span>
|
|
123
|
+
<span className="olas-devtools-json-summary">
|
|
124
|
+
{keys.slice(0, 3).join(', ')}
|
|
125
|
+
{keys.length > 3 ? ` +${keys.length - 3}` : ''}
|
|
126
|
+
</span>
|
|
127
|
+
<span className="olas-devtools-json-bracket">{'}'}</span>
|
|
128
|
+
</button>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
return (
|
|
132
|
+
<span className="olas-devtools-json-block">
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
className="olas-devtools-json-toggle olas-devtools-json-toggle-open"
|
|
136
|
+
onClick={() => setOpen(false)}
|
|
137
|
+
>
|
|
138
|
+
<span className="olas-devtools-json-bracket">{'{'}</span>
|
|
139
|
+
</button>
|
|
140
|
+
<span className="olas-devtools-json-children">
|
|
141
|
+
{keys.map((k) => (
|
|
142
|
+
<span key={k} className="olas-devtools-json-row">
|
|
143
|
+
<span className="olas-devtools-json-key">{k}:</span>
|
|
144
|
+
<Render value={value[k]} depth={depth + 1} initiallyOpen={false} />
|
|
145
|
+
</span>
|
|
146
|
+
))}
|
|
147
|
+
</span>
|
|
148
|
+
<span className="olas-devtools-json-bracket">{'}'}</span>
|
|
149
|
+
</span>
|
|
150
|
+
)
|
|
151
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a payload (props, vars, result, etc.) as a single-line string for
|
|
3
|
+
* the panel. Cuts at `maxLen` so a giant blob doesn't blow up the layout.
|
|
4
|
+
*/
|
|
5
|
+
export function formatPayload(value: unknown, maxLen = 200): string {
|
|
6
|
+
if (value === undefined) return 'undefined'
|
|
7
|
+
if (value === null) return 'null'
|
|
8
|
+
if (typeof value === 'function') return '[fn]'
|
|
9
|
+
let s: string
|
|
10
|
+
try {
|
|
11
|
+
s = JSON.stringify(value, replaceUnserializable)
|
|
12
|
+
} catch {
|
|
13
|
+
s = String(value)
|
|
14
|
+
}
|
|
15
|
+
if (s === undefined) s = String(value)
|
|
16
|
+
return s.length > maxLen ? `${s.slice(0, maxLen)}…` : s
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function replaceUnserializable(_key: string, value: unknown): unknown {
|
|
20
|
+
if (typeof value === 'function') return '[fn]'
|
|
21
|
+
if (typeof value === 'bigint') return value.toString()
|
|
22
|
+
if (value instanceof Error) return { name: value.name, message: value.message }
|
|
23
|
+
return value
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Render an HH:MM:SS.mmm timestamp from epoch ms. */
|
|
27
|
+
export function formatTime(t: number): string {
|
|
28
|
+
const d = new Date(t)
|
|
29
|
+
const pad = (n: number, w = 2) => n.toString().padStart(w, '0')
|
|
30
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Render a controller path / query key as a compact string. */
|
|
34
|
+
export function formatPath(path: readonly unknown[]): string {
|
|
35
|
+
if (path.length === 0) return '∅'
|
|
36
|
+
return path.map((p) => String(p)).join(' › ')
|
|
37
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { DevtoolsLauncher, type DevtoolsLauncherProps } from './DevtoolsLauncher'
|
|
2
|
+
export { DevtoolsPanel, type DevtoolsPanelProps, type DevtoolsTab } from './DevtoolsPanel'
|
|
3
|
+
export { formatPath, formatPayload, formatTime } from './format'
|
|
4
|
+
export {
|
|
5
|
+
type CacheEntry,
|
|
6
|
+
type ControllerNode,
|
|
7
|
+
DevtoolsStore,
|
|
8
|
+
type DevtoolsStoreOptions,
|
|
9
|
+
type FieldEntry,
|
|
10
|
+
insertNode,
|
|
11
|
+
type MutationEntry,
|
|
12
|
+
setNodeState,
|
|
13
|
+
} from './store'
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import type { DebugEvent, Root } from '@kontsedal/olas-core'
|
|
2
|
+
import { type Signal, signal } from '@kontsedal/olas-core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-path node in the live controller tree. `state` reflects the most
|
|
6
|
+
* recently observed lifecycle event; `path` is the array reported by the
|
|
7
|
+
* devtools bus.
|
|
8
|
+
*/
|
|
9
|
+
export type ControllerNode = {
|
|
10
|
+
readonly path: readonly string[]
|
|
11
|
+
state: 'active' | 'suspended' | 'disposed'
|
|
12
|
+
props: unknown
|
|
13
|
+
children: ControllerNode[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One entry in the cache timeline. */
|
|
17
|
+
export type CacheEntry =
|
|
18
|
+
| {
|
|
19
|
+
id: number
|
|
20
|
+
t: number
|
|
21
|
+
kind: 'subscribed'
|
|
22
|
+
queryKey: readonly unknown[]
|
|
23
|
+
subscriberPath: readonly string[]
|
|
24
|
+
}
|
|
25
|
+
| { id: number; t: number; kind: 'fetch-start'; queryKey: readonly unknown[] }
|
|
26
|
+
| {
|
|
27
|
+
id: number
|
|
28
|
+
t: number
|
|
29
|
+
kind: 'fetch-success'
|
|
30
|
+
queryKey: readonly unknown[]
|
|
31
|
+
durationMs: number
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
id: number
|
|
35
|
+
t: number
|
|
36
|
+
kind: 'fetch-error'
|
|
37
|
+
queryKey: readonly unknown[]
|
|
38
|
+
durationMs: number
|
|
39
|
+
error: unknown
|
|
40
|
+
}
|
|
41
|
+
| { id: number; t: number; kind: 'invalidated'; queryKey: readonly unknown[] }
|
|
42
|
+
| { id: number; t: number; kind: 'gc'; queryKey: readonly unknown[] }
|
|
43
|
+
|
|
44
|
+
/** One entry in the mutation log. `durationMs` is set on success/error when
|
|
45
|
+
* the entry can be paired with a preceding `run` for the same path+name. */
|
|
46
|
+
export type MutationEntry =
|
|
47
|
+
| { id: number; t: number; kind: 'run'; path: readonly string[]; name?: string; vars: unknown }
|
|
48
|
+
| {
|
|
49
|
+
id: number
|
|
50
|
+
t: number
|
|
51
|
+
kind: 'success'
|
|
52
|
+
path: readonly string[]
|
|
53
|
+
name?: string
|
|
54
|
+
result: unknown
|
|
55
|
+
durationMs?: number
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
id: number
|
|
59
|
+
t: number
|
|
60
|
+
kind: 'error'
|
|
61
|
+
path: readonly string[]
|
|
62
|
+
name?: string
|
|
63
|
+
error: unknown
|
|
64
|
+
durationMs?: number
|
|
65
|
+
}
|
|
66
|
+
| { id: number; t: number; kind: 'rollback'; path: readonly string[]; name?: string }
|
|
67
|
+
|
|
68
|
+
/** One entry in the field validation log. */
|
|
69
|
+
export type FieldEntry = {
|
|
70
|
+
id: number
|
|
71
|
+
t: number
|
|
72
|
+
path: readonly string[]
|
|
73
|
+
field: string
|
|
74
|
+
valid: boolean
|
|
75
|
+
errors: string[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Defaults — exported so callers can override via `new DevtoolsStore({ maxEntries: 500 })`. */
|
|
79
|
+
export const DEFAULT_MAX_ENTRIES = 100
|
|
80
|
+
|
|
81
|
+
export type DevtoolsStoreOptions = {
|
|
82
|
+
/** Cap on each event log (cache, mutation, field). Oldest entries drop first. */
|
|
83
|
+
maxEntries?: number
|
|
84
|
+
/** Optional clock — useful for tests. Default: `() => Date.now()`. */
|
|
85
|
+
now?: () => number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Subscribes to a root's `__debug` bus and maintains live state for the
|
|
90
|
+
* devtools panel. Exposes signals so the React layer can consume via
|
|
91
|
+
* `@kontsedal/olas-react`'s `use()`.
|
|
92
|
+
*
|
|
93
|
+
* Pure logic — no DOM, no React. Construct one per root.
|
|
94
|
+
*/
|
|
95
|
+
export class DevtoolsStore {
|
|
96
|
+
readonly tree$: Signal<ControllerNode> = signal(makeRoot())
|
|
97
|
+
readonly cache$: Signal<CacheEntry[]> = signal([])
|
|
98
|
+
readonly mutations$: Signal<MutationEntry[]> = signal([])
|
|
99
|
+
readonly fields$: Signal<FieldEntry[]> = signal([])
|
|
100
|
+
|
|
101
|
+
private readonly maxEntries: number
|
|
102
|
+
private readonly now: () => number
|
|
103
|
+
private nextId = 1
|
|
104
|
+
|
|
105
|
+
/** Keyed by `path|name` so a mutation:run can be paired with its
|
|
106
|
+
* success/error to compute duration. Cleared after pairing. */
|
|
107
|
+
private mutationStarts = new Map<string, number>()
|
|
108
|
+
|
|
109
|
+
constructor(options?: DevtoolsStoreOptions) {
|
|
110
|
+
this.maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES
|
|
111
|
+
this.now = options?.now ?? (() => Date.now())
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Subscribe to the given root's debug bus. Returns the unsubscribe. The
|
|
116
|
+
* caller (typically the React component) is responsible for invoking it
|
|
117
|
+
* on unmount.
|
|
118
|
+
*/
|
|
119
|
+
attach(root: Pick<Root<unknown>, '__debug'>): () => void {
|
|
120
|
+
return root.__debug.subscribe((event) => this.handle(event))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Apply one event. Exposed for tests. */
|
|
124
|
+
handle(event: DebugEvent): void {
|
|
125
|
+
switch (event.type) {
|
|
126
|
+
case 'controller:constructed':
|
|
127
|
+
this.tree$.set(insertNode(this.tree$.peek(), event.path, event.props))
|
|
128
|
+
return
|
|
129
|
+
case 'controller:suspended':
|
|
130
|
+
this.tree$.set(setNodeState(this.tree$.peek(), event.path, 'suspended'))
|
|
131
|
+
return
|
|
132
|
+
case 'controller:resumed':
|
|
133
|
+
this.tree$.set(setNodeState(this.tree$.peek(), event.path, 'active'))
|
|
134
|
+
return
|
|
135
|
+
case 'controller:disposed':
|
|
136
|
+
this.tree$.set(setNodeState(this.tree$.peek(), event.path, 'disposed'))
|
|
137
|
+
return
|
|
138
|
+
case 'cache:subscribed':
|
|
139
|
+
this.pushCache({
|
|
140
|
+
kind: 'subscribed',
|
|
141
|
+
queryKey: event.queryKey,
|
|
142
|
+
subscriberPath: event.subscriberPath,
|
|
143
|
+
})
|
|
144
|
+
return
|
|
145
|
+
case 'cache:fetch-start':
|
|
146
|
+
this.pushCache({ kind: 'fetch-start', queryKey: event.queryKey })
|
|
147
|
+
return
|
|
148
|
+
case 'cache:fetch-success':
|
|
149
|
+
this.pushCache({
|
|
150
|
+
kind: 'fetch-success',
|
|
151
|
+
queryKey: event.queryKey,
|
|
152
|
+
durationMs: event.durationMs,
|
|
153
|
+
})
|
|
154
|
+
return
|
|
155
|
+
case 'cache:fetch-error':
|
|
156
|
+
this.pushCache({
|
|
157
|
+
kind: 'fetch-error',
|
|
158
|
+
queryKey: event.queryKey,
|
|
159
|
+
durationMs: event.durationMs,
|
|
160
|
+
error: event.error,
|
|
161
|
+
})
|
|
162
|
+
return
|
|
163
|
+
case 'cache:invalidated':
|
|
164
|
+
this.pushCache({ kind: 'invalidated', queryKey: event.queryKey })
|
|
165
|
+
return
|
|
166
|
+
case 'cache:gc':
|
|
167
|
+
this.pushCache({ kind: 'gc', queryKey: event.queryKey })
|
|
168
|
+
return
|
|
169
|
+
case 'mutation:run': {
|
|
170
|
+
this.mutationStarts.set(mutationKey(event.path, event.name), this.now())
|
|
171
|
+
this.pushMutation({ kind: 'run', path: event.path, name: event.name, vars: event.vars })
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
case 'mutation:success': {
|
|
175
|
+
const durationMs = this.consumeStart(event.path, event.name)
|
|
176
|
+
this.pushMutation({
|
|
177
|
+
kind: 'success',
|
|
178
|
+
path: event.path,
|
|
179
|
+
name: event.name,
|
|
180
|
+
result: event.result,
|
|
181
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
182
|
+
})
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
case 'mutation:error': {
|
|
186
|
+
const durationMs = this.consumeStart(event.path, event.name)
|
|
187
|
+
this.pushMutation({
|
|
188
|
+
kind: 'error',
|
|
189
|
+
path: event.path,
|
|
190
|
+
name: event.name,
|
|
191
|
+
error: event.error,
|
|
192
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
193
|
+
})
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
case 'mutation:rollback':
|
|
197
|
+
this.pushMutation({ kind: 'rollback', path: event.path, name: event.name })
|
|
198
|
+
return
|
|
199
|
+
case 'field:validated':
|
|
200
|
+
this.pushField({
|
|
201
|
+
path: event.path,
|
|
202
|
+
field: event.field,
|
|
203
|
+
valid: event.valid,
|
|
204
|
+
errors: event.errors,
|
|
205
|
+
})
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Clear every log. Tree state is preserved — the live tree is not a log. */
|
|
211
|
+
clearLogs(): void {
|
|
212
|
+
this.cache$.set([])
|
|
213
|
+
this.mutations$.set([])
|
|
214
|
+
this.fields$.set([])
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// -----------------------------------------------------------------------
|
|
218
|
+
// Internals
|
|
219
|
+
// -----------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
private pushCache(entry: DistributiveOmit<CacheEntry, 'id' | 't'>): void {
|
|
222
|
+
const full = { id: this.nextId++, t: this.now(), ...entry } as CacheEntry
|
|
223
|
+
this.cache$.set(appendBounded(this.cache$.peek(), full, this.maxEntries))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private pushMutation(entry: DistributiveOmit<MutationEntry, 'id' | 't'>): void {
|
|
227
|
+
const full = { id: this.nextId++, t: this.now(), ...entry } as MutationEntry
|
|
228
|
+
this.mutations$.set(appendBounded(this.mutations$.peek(), full, this.maxEntries))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private pushField(entry: Omit<FieldEntry, 'id' | 't'>): void {
|
|
232
|
+
const full = { id: this.nextId++, t: this.now(), ...entry } as FieldEntry
|
|
233
|
+
this.fields$.set(appendBounded(this.fields$.peek(), full, this.maxEntries))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private consumeStart(path: readonly string[], name: string | undefined): number | undefined {
|
|
237
|
+
const key = mutationKey(path, name)
|
|
238
|
+
const startedAt = this.mutationStarts.get(key)
|
|
239
|
+
if (startedAt === undefined) return undefined
|
|
240
|
+
this.mutationStarts.delete(key)
|
|
241
|
+
return this.now() - startedAt
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function mutationKey(path: readonly string[], name: string | undefined): string {
|
|
246
|
+
return `${path.join('>')}#${name ?? ''}`
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Distributes `Omit` over a discriminated union so each variant keeps its own
|
|
251
|
+
* keys. The default `Omit<A | B, K>` collapses to the intersection of keys —
|
|
252
|
+
* not what we want when constructing one variant at a time.
|
|
253
|
+
*/
|
|
254
|
+
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Pure helpers — tested independently of the class.
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
function makeRoot(): ControllerNode {
|
|
261
|
+
return { path: [], state: 'active', props: undefined, children: [] }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function appendBounded<T>(arr: readonly T[], item: T, max: number): T[] {
|
|
265
|
+
const next = arr.length >= max ? arr.slice(arr.length - max + 1) : arr.slice()
|
|
266
|
+
next.push(item)
|
|
267
|
+
return next
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Insert (or update) a node at `path` inside the tree. Auto-creates any
|
|
272
|
+
* missing intermediate ancestors as 'active' placeholders — needed if the
|
|
273
|
+
* subscriber attached after the root was constructed.
|
|
274
|
+
*
|
|
275
|
+
* Returns a NEW tree object (immutable update).
|
|
276
|
+
*/
|
|
277
|
+
export function insertNode(
|
|
278
|
+
root: ControllerNode,
|
|
279
|
+
path: readonly string[],
|
|
280
|
+
props: unknown,
|
|
281
|
+
): ControllerNode {
|
|
282
|
+
if (path.length === 0) {
|
|
283
|
+
// The root controller's "constructed" event has path === ['root']
|
|
284
|
+
// (one segment), not []. We never receive empty paths in practice, but
|
|
285
|
+
// handle defensively.
|
|
286
|
+
return { ...root, state: 'active', props }
|
|
287
|
+
}
|
|
288
|
+
return cloneWithUpsert(root, path, 0, props)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function cloneWithUpsert(
|
|
292
|
+
node: ControllerNode,
|
|
293
|
+
path: readonly string[],
|
|
294
|
+
depth: number,
|
|
295
|
+
props: unknown,
|
|
296
|
+
): ControllerNode {
|
|
297
|
+
if (depth === path.length) {
|
|
298
|
+
return { ...node, state: 'active', props }
|
|
299
|
+
}
|
|
300
|
+
const segment = path[depth] as string
|
|
301
|
+
const idx = node.children.findIndex((c) => c.path[c.path.length - 1] === segment)
|
|
302
|
+
const childPath = path.slice(0, depth + 1)
|
|
303
|
+
if (idx === -1) {
|
|
304
|
+
const newChild = cloneWithUpsert(
|
|
305
|
+
{ path: childPath, state: 'active', props: undefined, children: [] },
|
|
306
|
+
path,
|
|
307
|
+
depth + 1,
|
|
308
|
+
props,
|
|
309
|
+
)
|
|
310
|
+
return { ...node, children: [...node.children, newChild] }
|
|
311
|
+
}
|
|
312
|
+
const existing = node.children[idx]!
|
|
313
|
+
const updatedChild = cloneWithUpsert(existing, path, depth + 1, props)
|
|
314
|
+
const nextChildren = node.children.slice()
|
|
315
|
+
nextChildren[idx] = updatedChild
|
|
316
|
+
return { ...node, children: nextChildren }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Set `state` on the node at `path`. If the node doesn't exist (out-of-order
|
|
321
|
+
* event delivery), the tree is returned unchanged.
|
|
322
|
+
*/
|
|
323
|
+
export function setNodeState(
|
|
324
|
+
root: ControllerNode,
|
|
325
|
+
path: readonly string[],
|
|
326
|
+
state: ControllerNode['state'],
|
|
327
|
+
): ControllerNode {
|
|
328
|
+
if (path.length === 0) {
|
|
329
|
+
return { ...root, state }
|
|
330
|
+
}
|
|
331
|
+
return setStateAt(root, path, 0, state) ?? root
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function setStateAt(
|
|
335
|
+
node: ControllerNode,
|
|
336
|
+
path: readonly string[],
|
|
337
|
+
depth: number,
|
|
338
|
+
state: ControllerNode['state'],
|
|
339
|
+
): ControllerNode | null {
|
|
340
|
+
if (depth === path.length) {
|
|
341
|
+
return { ...node, state }
|
|
342
|
+
}
|
|
343
|
+
const segment = path[depth] as string
|
|
344
|
+
const idx = node.children.findIndex((c) => c.path[c.path.length - 1] === segment)
|
|
345
|
+
if (idx === -1) return null
|
|
346
|
+
const existing = node.children[idx]!
|
|
347
|
+
const updatedChild = setStateAt(existing, path, depth + 1, state)
|
|
348
|
+
if (updatedChild === null) return null
|
|
349
|
+
const nextChildren = node.children.slice()
|
|
350
|
+
nextChildren[idx] = updatedChild
|
|
351
|
+
return { ...node, children: nextChildren }
|
|
352
|
+
}
|