@adhdev/mesh-shared 1.0.60-rc.69 → 1.0.60-rc.70
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/dist/index.d.ts +2 -0
- package/dist/index.js +405 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +398 -3
- package/dist/index.mjs.map +1 -1
- package/dist/keyed-doc-delta.d.ts +83 -0
- package/dist/keyed-status-delta.d.ts +76 -0
- package/dist/protocol/daemon-server.d.ts +3 -10
- package/dist/protocol/dashboard-daemon-p2p.d.ts +14 -10
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/keyed-doc-delta.ts +348 -0
- package/src/keyed-status-delta.ts +239 -0
- package/src/protocol/daemon-server.ts +3 -10
- package/src/protocol/dashboard-daemon-p2p.ts +15 -7
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed status delta — the diff/fold pair behind the `daemon.metadata` lane
|
|
3
|
+
* (the ONE daemon→dashboard state lane; data-path audit 2026-09-29 P0-3).
|
|
4
|
+
*
|
|
5
|
+
* The daemon digests every body it would have sent, diffs it against the
|
|
6
|
+
* digest of the last body DELIVERED to a subscription, and sends only the
|
|
7
|
+
* difference: changed top-level fields, changed daemon-level `status` fields,
|
|
8
|
+
* and per-session changed fields keyed by session id, with removals explicit.
|
|
9
|
+
* An unchanged state diffs to `null` — zero bytes on the wire. The dashboard
|
|
10
|
+
* folds each delta into its held snapshot; the fold of every delta since a
|
|
11
|
+
* snapshot equals the latest snapshot (pinned by keyed-status-delta.test.ts).
|
|
12
|
+
*
|
|
13
|
+
* Pure leaf: plain objects in, plain objects out; no transport, no clock.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A session row: identified by `id`, everything else is a field. */
|
|
17
|
+
export interface KeyedStatusSession {
|
|
18
|
+
id: string
|
|
19
|
+
[field: string]: unknown
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The snapshot body being diffed: top-level fields plus a `status` with a keyed session list. */
|
|
23
|
+
export interface KeyedStatusBody {
|
|
24
|
+
status: { sessions: KeyedStatusSession[]; [field: string]: unknown }
|
|
25
|
+
[field: string]: unknown
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface KeyedStatusDigestOptions {
|
|
29
|
+
/** Top-level keys that are never diffed (envelope identity: daemonId, …). */
|
|
30
|
+
ignoreTopLevel?: readonly string[]
|
|
31
|
+
/** `status` keys that are never diffed (in addition to `sessions`). */
|
|
32
|
+
ignoreStatus?: readonly string[]
|
|
33
|
+
/**
|
|
34
|
+
* Session fields that change on every build without meaning anything
|
|
35
|
+
* (a build-time `lastUpdated` stamp). A change to ONLY these fields is not
|
|
36
|
+
* a change; when something else changed they ride along.
|
|
37
|
+
*/
|
|
38
|
+
volatileSessionFields?: readonly string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface SessionDigest {
|
|
42
|
+
readonly value: KeyedStatusSession
|
|
43
|
+
/** Signature of every non-volatile field (fast unchanged check). */
|
|
44
|
+
readonly sig: string
|
|
45
|
+
/** Per-field signatures, computed lazily — only for sessions that changed. */
|
|
46
|
+
fieldSigs?: Map<string, string>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface KeyedStatusDigest {
|
|
50
|
+
readonly top: ReadonlyMap<string, string>
|
|
51
|
+
readonly topValues: Readonly<Record<string, unknown>>
|
|
52
|
+
readonly status: ReadonlyMap<string, string>
|
|
53
|
+
readonly statusValues: Readonly<Record<string, unknown>>
|
|
54
|
+
readonly sessions: ReadonlyMap<string, SessionDigest>
|
|
55
|
+
readonly order: readonly string[]
|
|
56
|
+
readonly volatile: ReadonlySet<string>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface KeyedStatusDelta {
|
|
60
|
+
set?: Record<string, unknown>
|
|
61
|
+
unset?: string[]
|
|
62
|
+
statusSet?: Record<string, unknown>
|
|
63
|
+
statusUnset?: string[]
|
|
64
|
+
sessions?: KeyedStatusSession[]
|
|
65
|
+
sessionUnset?: Record<string, string[]>
|
|
66
|
+
removedSessionIds?: string[]
|
|
67
|
+
sessionOrder?: string[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sig(value: unknown): string | undefined {
|
|
71
|
+
if (value === undefined) return undefined
|
|
72
|
+
try {
|
|
73
|
+
return JSON.stringify(value)
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function digestFields(
|
|
80
|
+
source: Record<string, unknown>,
|
|
81
|
+
ignore: ReadonlySet<string>,
|
|
82
|
+
): { sigs: Map<string, string>; values: Record<string, unknown> } {
|
|
83
|
+
const sigs = new Map<string, string>()
|
|
84
|
+
const values: Record<string, unknown> = {}
|
|
85
|
+
for (const [key, value] of Object.entries(source)) {
|
|
86
|
+
if (ignore.has(key)) continue
|
|
87
|
+
const s = sig(value)
|
|
88
|
+
if (s === undefined) continue
|
|
89
|
+
sigs.set(key, s)
|
|
90
|
+
values[key] = value
|
|
91
|
+
}
|
|
92
|
+
return { sigs, values }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sessionSignature(session: KeyedStatusSession, volatile: ReadonlySet<string>): string {
|
|
96
|
+
if (volatile.size === 0) return sig(session) ?? ''
|
|
97
|
+
const stable: Record<string, unknown> = {}
|
|
98
|
+
for (const [key, value] of Object.entries(session)) {
|
|
99
|
+
if (!volatile.has(key)) stable[key] = value
|
|
100
|
+
}
|
|
101
|
+
return sig(stable) ?? ''
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fieldSigsOf(digest: SessionDigest): Map<string, string> {
|
|
105
|
+
if (!digest.fieldSigs) {
|
|
106
|
+
const sigs = new Map<string, string>()
|
|
107
|
+
for (const [key, value] of Object.entries(digest.value)) {
|
|
108
|
+
const s = sig(value)
|
|
109
|
+
if (s !== undefined) sigs.set(key, s)
|
|
110
|
+
}
|
|
111
|
+
digest.fieldSigs = sigs
|
|
112
|
+
}
|
|
113
|
+
return digest.fieldSigs
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Digest one body. Cheap to hold: values are referenced, not copied. */
|
|
117
|
+
export function digestKeyedStatus(body: KeyedStatusBody, options: KeyedStatusDigestOptions = {}): KeyedStatusDigest {
|
|
118
|
+
const volatile = new Set(options.volatileSessionFields ?? [])
|
|
119
|
+
const top = digestFields(body as Record<string, unknown>, new Set(['status', ...(options.ignoreTopLevel ?? [])]))
|
|
120
|
+
const status = digestFields(body.status, new Set(['sessions', ...(options.ignoreStatus ?? [])]))
|
|
121
|
+
const sessions = new Map<string, SessionDigest>()
|
|
122
|
+
const order: string[] = []
|
|
123
|
+
for (const session of Array.isArray(body.status?.sessions) ? body.status.sessions : []) {
|
|
124
|
+
if (!session || typeof session.id !== 'string' || !session.id) continue
|
|
125
|
+
if (sessions.has(session.id)) continue
|
|
126
|
+
sessions.set(session.id, { value: session, sig: sessionSignature(session, volatile) })
|
|
127
|
+
order.push(session.id)
|
|
128
|
+
}
|
|
129
|
+
return { top: top.sigs, topValues: top.values, status: status.sigs, statusValues: status.values, sessions, order, volatile }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function diffFieldMaps(
|
|
133
|
+
prev: ReadonlyMap<string, string>,
|
|
134
|
+
next: ReadonlyMap<string, string>,
|
|
135
|
+
nextValues: Readonly<Record<string, unknown>>,
|
|
136
|
+
): { set?: Record<string, unknown>; unset?: string[] } {
|
|
137
|
+
let set: Record<string, unknown> | undefined
|
|
138
|
+
let unset: string[] | undefined
|
|
139
|
+
for (const [key, s] of next) {
|
|
140
|
+
if (prev.get(key) === s) continue
|
|
141
|
+
;(set ??= {})[key] = nextValues[key]
|
|
142
|
+
}
|
|
143
|
+
for (const key of prev.keys()) {
|
|
144
|
+
if (!next.has(key)) (unset ??= []).push(key)
|
|
145
|
+
}
|
|
146
|
+
return { ...(set ? { set } : {}), ...(unset ? { unset } : {}) }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function sameOrder(a: readonly string[], b: readonly string[]): boolean {
|
|
150
|
+
if (a.length !== b.length) return false
|
|
151
|
+
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false
|
|
152
|
+
return true
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The difference `prev → next`, or `null` when nothing a consumer can observe
|
|
157
|
+
* changed. `prev === null` is not accepted here — a subscription with no
|
|
158
|
+
* delivered baseline gets a snapshot, not a delta.
|
|
159
|
+
*/
|
|
160
|
+
export function diffKeyedStatus(prev: KeyedStatusDigest, next: KeyedStatusDigest): KeyedStatusDelta | null {
|
|
161
|
+
const delta: KeyedStatusDelta = {}
|
|
162
|
+
const top = diffFieldMaps(prev.top, next.top, next.topValues)
|
|
163
|
+
if (top.set) delta.set = top.set
|
|
164
|
+
if (top.unset) delta.unset = top.unset
|
|
165
|
+
const status = diffFieldMaps(prev.status, next.status, next.statusValues)
|
|
166
|
+
if (status.set) delta.statusSet = status.set
|
|
167
|
+
if (status.unset) delta.statusUnset = status.unset
|
|
168
|
+
|
|
169
|
+
for (const [id, nextSession] of next.sessions) {
|
|
170
|
+
const prevSession = prev.sessions.get(id)
|
|
171
|
+
if (!prevSession) {
|
|
172
|
+
;(delta.sessions ??= []).push(nextSession.value)
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
if (prevSession.sig === nextSession.sig) continue
|
|
176
|
+
const prevSigs = fieldSigsOf(prevSession)
|
|
177
|
+
const nextSigs = fieldSigsOf(nextSession)
|
|
178
|
+
const change: KeyedStatusSession = { id }
|
|
179
|
+
let changed = false
|
|
180
|
+
for (const [key, s] of nextSigs) {
|
|
181
|
+
if (key === 'id' || prevSigs.get(key) === s) continue
|
|
182
|
+
change[key] = nextSession.value[key]
|
|
183
|
+
changed = true
|
|
184
|
+
}
|
|
185
|
+
const gone: string[] = []
|
|
186
|
+
for (const key of prevSigs.keys()) {
|
|
187
|
+
if (!nextSigs.has(key)) gone.push(key)
|
|
188
|
+
}
|
|
189
|
+
if (changed) (delta.sessions ??= []).push(change)
|
|
190
|
+
if (gone.length > 0) (delta.sessionUnset ??= {})[id] = gone
|
|
191
|
+
}
|
|
192
|
+
for (const id of prev.sessions.keys()) {
|
|
193
|
+
if (!next.sessions.has(id)) (delta.removedSessionIds ??= []).push(id)
|
|
194
|
+
}
|
|
195
|
+
if (!sameOrder(prev.order, next.order)) delta.sessionOrder = [...next.order]
|
|
196
|
+
|
|
197
|
+
return Object.keys(delta).length > 0 ? delta : null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Rebuild the full body from a held one plus a delta. Never mutates `held`. */
|
|
201
|
+
export function foldKeyedStatus<T extends KeyedStatusBody>(held: T, delta: KeyedStatusDelta): T {
|
|
202
|
+
const next: Record<string, unknown> = { ...held }
|
|
203
|
+
for (const key of delta.unset ?? []) delete next[key]
|
|
204
|
+
Object.assign(next, delta.set ?? {})
|
|
205
|
+
|
|
206
|
+
const status: Record<string, unknown> = { ...held.status }
|
|
207
|
+
for (const key of delta.statusUnset ?? []) delete status[key]
|
|
208
|
+
Object.assign(status, delta.statusSet ?? {})
|
|
209
|
+
|
|
210
|
+
const byId = new Map<string, KeyedStatusSession>()
|
|
211
|
+
for (const session of held.status.sessions ?? []) byId.set(session.id, session)
|
|
212
|
+
for (const id of delta.removedSessionIds ?? []) byId.delete(id)
|
|
213
|
+
for (const change of delta.sessions ?? []) {
|
|
214
|
+
const base = byId.get(change.id)
|
|
215
|
+
byId.set(change.id, base ? { ...base, ...change } : { ...change })
|
|
216
|
+
}
|
|
217
|
+
for (const [id, keys] of Object.entries(delta.sessionUnset ?? {})) {
|
|
218
|
+
const base = byId.get(id)
|
|
219
|
+
if (!base) continue
|
|
220
|
+
const trimmed = { ...base }
|
|
221
|
+
for (const key of keys) delete trimmed[key]
|
|
222
|
+
byId.set(id, trimmed)
|
|
223
|
+
}
|
|
224
|
+
const order = delta.sessionOrder ?? (held.status.sessions ?? []).map((session) => session.id)
|
|
225
|
+
const sessions: KeyedStatusSession[] = []
|
|
226
|
+
const placed = new Set<string>()
|
|
227
|
+
for (const id of order) {
|
|
228
|
+
const session = byId.get(id)
|
|
229
|
+
if (!session || placed.has(id)) continue
|
|
230
|
+
sessions.push(session)
|
|
231
|
+
placed.add(id)
|
|
232
|
+
}
|
|
233
|
+
for (const [id, session] of byId) {
|
|
234
|
+
if (!placed.has(id)) sessions.push(session)
|
|
235
|
+
}
|
|
236
|
+
status.sessions = sessions
|
|
237
|
+
next.status = status
|
|
238
|
+
return next as T
|
|
239
|
+
}
|
|
@@ -124,15 +124,6 @@ export type SeqscribeStatusWireSummary = {
|
|
|
124
124
|
fgenAgeBucket: number
|
|
125
125
|
quarantined: boolean
|
|
126
126
|
authority: boolean
|
|
127
|
-
dualWrite?: boolean
|
|
128
|
-
dualWriteFailedBucket?: number
|
|
129
|
-
dualWriteDroppedBucket?: number
|
|
130
|
-
dualWriteBackfilledBucket?: number
|
|
131
|
-
parityMismatchBucket?: number
|
|
132
|
-
parityRan?: boolean
|
|
133
|
-
parityMissingInShadowBucket?: number
|
|
134
|
-
parityExtraInShadowBucket?: number
|
|
135
|
-
parityFieldMismatchBucket?: number
|
|
136
127
|
transcriptPublish?: boolean
|
|
137
128
|
transcriptPublishedBucket?: number
|
|
138
129
|
transcriptPublishFailedBucket?: number
|
|
@@ -286,7 +277,8 @@ export type P2PIceWirePayload = { candidate: string; mid: string; peerId: string
|
|
|
286
277
|
|
|
287
278
|
/**
|
|
288
279
|
* A dashboard-originated signal as relayed to the daemon. `peerId` is stamped
|
|
289
|
-
* by UserSession/SharedSession, `sharePermission`
|
|
280
|
+
* by UserSession/SharedSession, `sharePermission` and `shareTargetSessionId`
|
|
281
|
+
* (the one session a share link may see) only by SharedSession (server
|
|
290
282
|
* authority, never the client). Field names follow the browser/node-datachannel
|
|
291
283
|
* spellings both ends already read.
|
|
292
284
|
*/
|
|
@@ -294,6 +286,7 @@ export type RelayedP2PSignalWirePayload = {
|
|
|
294
286
|
peerId?: string
|
|
295
287
|
daemonId?: string
|
|
296
288
|
sharePermission?: string
|
|
289
|
+
shareTargetSessionId?: string
|
|
297
290
|
sdp?: string
|
|
298
291
|
type?: string
|
|
299
292
|
candidate?: string
|
|
@@ -154,7 +154,6 @@ export const DAEMON_TO_DASHBOARD_P2P_TYPES = [
|
|
|
154
154
|
'ping',
|
|
155
155
|
'pong',
|
|
156
156
|
'p2p_evicted',
|
|
157
|
-
'status_report',
|
|
158
157
|
'status_event',
|
|
159
158
|
'topic_update',
|
|
160
159
|
'topic_update_chunk',
|
|
@@ -164,13 +163,11 @@ export const DAEMON_TO_DASHBOARD_P2P_TYPES = [
|
|
|
164
163
|
'command_result',
|
|
165
164
|
'command_result_chunk',
|
|
166
165
|
'response',
|
|
166
|
+
'transcript_topics_available',
|
|
167
167
|
] as const
|
|
168
168
|
export type DaemonToDashboardP2PType = typeof DAEMON_TO_DASHBOARD_P2P_TYPES[number]
|
|
169
169
|
export const isDaemonToDashboardP2PType = makeTypeGuard(DAEMON_TO_DASHBOARD_P2P_TYPES)
|
|
170
170
|
|
|
171
|
-
/** Rich P2P status snapshot (daemon-core `StatusReportPayload`, or a `_delta` partial of it). Dynamic by design on this plane. */
|
|
172
|
-
export type P2PStatusReportWirePayload = Record<string, unknown>
|
|
173
|
-
|
|
174
171
|
/** The server-bound event plus the P2P-only structured prompt (daemon-core `P2PStatusEventPayload`). */
|
|
175
172
|
export type P2PStatusEventWirePayload = DaemonStatusEventWirePayload & {
|
|
176
173
|
interactivePrompt?: unknown
|
|
@@ -184,7 +181,6 @@ export type TopicUpdateWireEnvelope = { topic: string; key: string }
|
|
|
184
181
|
export type P2PFileEntry = { name: string; type: string; size?: number }
|
|
185
182
|
|
|
186
183
|
export type P2PEvictedMsg = { type: 'p2p_evicted'; reason: string; maxconnections: number }
|
|
187
|
-
export type P2PStatusReportMsg = { type: 'status_report'; payload: P2PStatusReportWirePayload; timestamp: number }
|
|
188
184
|
export type P2PStatusEventMsg = { type: 'status_event'; payload: P2PStatusEventWirePayload; timestamp: number }
|
|
189
185
|
export type P2PTopicUpdateMsg = { type: 'topic_update'; update: TopicUpdateWireEnvelope }
|
|
190
186
|
export type P2PTopicUpdateChunkMsg = ChunkFrame<'topic_update_chunk'>
|
|
@@ -205,11 +201,19 @@ export type P2PResponseMsg = {
|
|
|
205
201
|
entries?: P2PFileEntry[]
|
|
206
202
|
}
|
|
207
203
|
|
|
204
|
+
/**
|
|
205
|
+
* `session.<id>.chat` topics that just became SUB-able for THIS peer (the
|
|
206
|
+
* topic was defined, or the peer's declared session interest widened to it).
|
|
207
|
+
* Sent only after the grant is in place, so a seqscribe SUB issued on receipt
|
|
208
|
+
* is accepted; without it the browser can only guess when to re-SUB a refused
|
|
209
|
+
* subscription. Topic names only — the P2P plane already carries the ids.
|
|
210
|
+
*/
|
|
211
|
+
export type P2PTranscriptTopicsAvailableMsg = { type: 'transcript_topics_available'; topics: string[] }
|
|
212
|
+
|
|
208
213
|
export type DaemonToDashboardP2PMsg =
|
|
209
214
|
| P2PPingMsg
|
|
210
215
|
| P2PPongMsg
|
|
211
216
|
| P2PEvictedMsg
|
|
212
|
-
| P2PStatusReportMsg
|
|
213
217
|
| P2PStatusEventMsg
|
|
214
218
|
| P2PTopicUpdateMsg
|
|
215
219
|
| P2PTopicUpdateChunkMsg
|
|
@@ -219,6 +223,7 @@ export type DaemonToDashboardP2PMsg =
|
|
|
219
223
|
| P2PCommandResultMsg
|
|
220
224
|
| P2PCommandResultChunkMsg
|
|
221
225
|
| P2PResponseMsg
|
|
226
|
+
| P2PTranscriptTopicsAvailableMsg
|
|
222
227
|
|
|
223
228
|
const _daemonToDashboardNamesCoverUnion: AssertSameMembers<typeof DAEMON_TO_DASHBOARD_P2P_TYPES, DaemonToDashboardP2PMsg['type']> = true
|
|
224
229
|
void _daemonToDashboardNamesCoverUnion
|
|
@@ -241,7 +246,6 @@ export function decodeDaemonToDashboardP2P(raw: unknown): DaemonToDashboardP2PMs
|
|
|
241
246
|
case 'pong':
|
|
242
247
|
case 'p2p_evicted':
|
|
243
248
|
return raw as DaemonToDashboardP2PMsg
|
|
244
|
-
case 'status_report':
|
|
245
249
|
case 'status_event':
|
|
246
250
|
return isRecord(raw.payload) ? (raw as DaemonToDashboardP2PMsg) : null
|
|
247
251
|
case 'topic_update':
|
|
@@ -263,6 +267,10 @@ export function decodeDaemonToDashboardP2P(raw: unknown): DaemonToDashboardP2PMs
|
|
|
263
267
|
case 'command_result':
|
|
264
268
|
case 'response':
|
|
265
269
|
return isNonEmptyString(raw.id) ? (raw as DaemonToDashboardP2PMsg) : null
|
|
270
|
+
case 'transcript_topics_available':
|
|
271
|
+
return Array.isArray(raw.topics) && raw.topics.every((topic) => typeof topic === 'string')
|
|
272
|
+
? { type: raw.type, topics: raw.topics as string[] }
|
|
273
|
+
: null
|
|
266
274
|
}
|
|
267
275
|
}
|
|
268
276
|
|