@adhdev/mesh-shared 1.0.60-rc.70 → 1.0.60-rc.72
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 +0 -1
- package/dist/index.js +96 -217
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +91 -205
- package/dist/index.mjs.map +1 -1
- package/dist/interpolation.d.ts +0 -9
- package/dist/keyed-doc-delta.d.ts +39 -17
- package/dist/mesh-vocabulary.d.ts +0 -3
- package/dist/protocol/daemon-server.d.ts +2 -4
- package/dist/protocol/dashboard-daemon-p2p.d.ts +7 -10
- package/dist/protocol/dashboard-server.d.ts +2 -2
- package/dist/protocol/dashboard-wire-version.d.ts +21 -0
- package/dist/protocol/index.d.ts +1 -0
- package/dist/session-status.d.ts +14 -0
- package/dist/turn-evidence.d.ts +0 -4
- package/dist/ws-protocol.d.ts +0 -2
- package/package.json +1 -1
- package/src/index.ts +0 -1
- package/src/interpolation.ts +0 -18
- package/src/keyed-doc-delta.ts +94 -29
- package/src/mesh-vocabulary.ts +0 -3
- package/src/protocol/daemon-server.ts +0 -4
- package/src/protocol/dashboard-daemon-p2p.ts +7 -14
- package/src/protocol/dashboard-wire-version.ts +27 -0
- package/src/protocol/index.ts +1 -0
- package/src/session-status.ts +24 -0
- package/src/turn-evidence.ts +0 -6
- package/src/turn-ipc.ts +1 -2
- package/src/ws-protocol.ts +0 -4
- package/dist/keyed-status-delta.d.ts +0 -76
- package/src/keyed-status-delta.ts +0 -239
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* protocol/dashboard-wire-version — the dashboard ↔ daemon wire version.
|
|
3
|
+
*
|
|
4
|
+
* Both dashboards (cloud over P2P, standalone over WS) state the version they
|
|
5
|
+
* speak on every topic subscribe; the daemon states its own on the standalone
|
|
6
|
+
* hello, on every keyed snapshot frame, and in the `protocol_mismatch` reply it
|
|
7
|
+
* sends INSTEAD of state when the two differ. There is no dual-format serving:
|
|
8
|
+
* a mismatched pair renders no state at all —
|
|
9
|
+
* - the page is older than the daemon → the page must reload (its bundle
|
|
10
|
+
* cannot read the daemon's frames);
|
|
11
|
+
* - the daemon is older than the page → the daemon must be updated.
|
|
12
|
+
*
|
|
13
|
+
* Bump this whenever a frame shape changes incompatibly; one release carries
|
|
14
|
+
* the same value on both ends.
|
|
15
|
+
* 1 — implicit (frames before versioning).
|
|
16
|
+
* 2 — daemon.metadata deltas on the keyed-doc engine (2026-09-29).
|
|
17
|
+
*/
|
|
18
|
+
export const DASHBOARD_WIRE_VERSION = 2
|
|
19
|
+
|
|
20
|
+
export type DashboardWireCompat = 'ok' | 'reload_required' | 'daemon_update_required'
|
|
21
|
+
|
|
22
|
+
/** How a page speaking `pageVersion` relates to a daemon speaking `daemonVersion` (absent = 1). */
|
|
23
|
+
export function compareDashboardWireVersion(pageVersion: number, daemonVersion: number | null | undefined): DashboardWireCompat {
|
|
24
|
+
const daemon = typeof daemonVersion === 'number' && Number.isFinite(daemonVersion) ? daemonVersion : 1
|
|
25
|
+
if (daemon === pageVersion) return 'ok'
|
|
26
|
+
return daemon > pageVersion ? 'reload_required' : 'daemon_update_required'
|
|
27
|
+
}
|
package/src/protocol/index.ts
CHANGED
package/src/session-status.ts
CHANGED
|
@@ -138,3 +138,27 @@ export function isBusyStatus(raw: unknown): boolean {
|
|
|
138
138
|
export function statusesOfClass(cls: Exclude<SessionStatusClass, 'unknown'>): SessionStatus[] {
|
|
139
139
|
return SESSION_STATUSES.filter((status) => SESSION_STATUS_CLASS[status] === cls)
|
|
140
140
|
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Mesh-record terminal spellings: a session RECORD in one of these is gone
|
|
144
|
+
* (the vocabulary's `dead` class covers the canonical members; records also
|
|
145
|
+
* carry `failed` / `terminated` / `exited` / `closed`).
|
|
146
|
+
*/
|
|
147
|
+
export const TERMINAL_SESSION_RECORD_STATUSES = ['stopped', 'failed', 'terminated', 'exited', 'closed'] as const
|
|
148
|
+
|
|
149
|
+
export function isTerminalSessionRecordStatus(raw: unknown): boolean {
|
|
150
|
+
return typeof raw === 'string' && (TERMINAL_SESSION_RECORD_STATUSES as readonly string[]).includes(raw.trim().toLowerCase())
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* THE "idle — can take a task now" predicate over a session state/record (its
|
|
155
|
+
* top-level `status` and chat lane). Every idle-candidate check (queue drain,
|
|
156
|
+
* auto-launch orphan sweep, dispatch pick) uses this one, so they cannot
|
|
157
|
+
* disagree about which sessions are candidates.
|
|
158
|
+
*/
|
|
159
|
+
export function isIdleSessionState(state: unknown): boolean {
|
|
160
|
+
const record = state && typeof state === 'object' ? state as { status?: unknown; activeChat?: { status?: unknown } } : {}
|
|
161
|
+
const status = typeof record.status === 'string' ? record.status.trim().toLowerCase() : ''
|
|
162
|
+
if (isTerminalSessionRecordStatus(status) || isDeadStatus(status)) return false
|
|
163
|
+
return isReadyStatus(status) || record.activeChat?.status === 'waiting_input'
|
|
164
|
+
}
|
package/src/turn-evidence.ts
CHANGED
|
@@ -388,11 +388,9 @@ function makeGuard<T extends readonly string[]>(values: T): (value: unknown) =>
|
|
|
388
388
|
return (value: unknown): value is T[number] => typeof value === 'string' && set.has(value)
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
-
export const isEvidenceSourceId = makeGuard(EVIDENCE_SOURCE_IDS)
|
|
392
391
|
export const isTurnEvidenceKind = makeGuard(TURN_EVIDENCE_KINDS as readonly TurnEvidenceKind[])
|
|
393
392
|
export const isTurnReason = makeGuard(TURN_REASONS)
|
|
394
393
|
export const isNotifyKind = makeGuard(NOTIFY_KINDS)
|
|
395
|
-
export const isHoldReason = makeGuard(HOLD_REASONS)
|
|
396
394
|
export const isTurnOutcome = makeGuard(TURN_OUTCOMES)
|
|
397
395
|
export const isCommitStrength = makeGuard(COMMIT_STRENGTHS)
|
|
398
396
|
|
|
@@ -491,9 +489,6 @@ export function isTurnEvidence(value: unknown): value is TurnEvidence {
|
|
|
491
489
|
return true
|
|
492
490
|
}
|
|
493
491
|
|
|
494
|
-
export function isTerminalClassEvidenceKind(kind: TurnEvidenceKind): kind is typeof TERMINAL_CLASS_EVIDENCE_KINDS[number] {
|
|
495
|
-
return (TERMINAL_CLASS_EVIDENCE_KINDS as readonly string[]).includes(kind)
|
|
496
|
-
}
|
|
497
492
|
|
|
498
493
|
// ─── mesh.<id>.events topic entries (C2) ─────────────────────────────────
|
|
499
494
|
|
|
@@ -516,7 +511,6 @@ export type MeshTopicEntry = { v: typeof MESH_TOPIC_PROTOCOL_VERSION; eventId: s
|
|
|
516
511
|
| { k: 'mesh.record'; ledgerKind: string; nodeId?: string; sessionId?: string; taskId?: string; payload: ProjectedScalars }
|
|
517
512
|
)
|
|
518
513
|
|
|
519
|
-
export const isMeshTopicEntryKind = makeGuard(MESH_TOPIC_ENTRY_KINDS)
|
|
520
514
|
|
|
521
515
|
function isOptionalId(value: unknown): boolean {
|
|
522
516
|
return value === undefined || isEvidenceIdentifier(value)
|
package/src/turn-ipc.ts
CHANGED
|
@@ -55,13 +55,12 @@ import {
|
|
|
55
55
|
isTurnEvidence,
|
|
56
56
|
isTurnReason,
|
|
57
57
|
type ProjectedScalars,
|
|
58
|
-
type SummaryRef,
|
|
59
58
|
type TurnAttemptRef,
|
|
60
59
|
type TurnEvidence,
|
|
61
60
|
type TurnEvidenceKind,
|
|
62
61
|
type TurnOutcome,
|
|
63
62
|
type TurnReason,
|
|
64
|
-
} from './turn-evidence'
|
|
63
|
+
} from './turn-evidence';
|
|
65
64
|
import { isRecord } from './protocol/envelope'
|
|
66
65
|
|
|
67
66
|
// ─── shared primitives ─────────────────────────────────────────────────────
|
package/src/ws-protocol.ts
CHANGED
|
@@ -18,8 +18,6 @@
|
|
|
18
18
|
import {
|
|
19
19
|
DAEMON_TO_SERVER_TYPES,
|
|
20
20
|
SERVER_TO_DAEMON_CONTROL_TYPES,
|
|
21
|
-
isDaemonToServerType,
|
|
22
|
-
isServerToDaemonControlType,
|
|
23
21
|
type DaemonToServerType,
|
|
24
22
|
type ServerToDaemonControlType,
|
|
25
23
|
type P2PSignalType,
|
|
@@ -48,9 +46,7 @@ export const DAEMON_TO_SERVER_WS_MSGS: readonly DaemonToServerWsMsg[] = DAEMON_T
|
|
|
48
46
|
|
|
49
47
|
export const SERVER_TO_DAEMON_WS_MSGS: readonly ServerToDaemonWsMsg[] = SERVER_TO_DAEMON_CONTROL_TYPES;
|
|
50
48
|
|
|
51
|
-
export const isDaemonToServerWsMsg: (value: unknown) => value is DaemonToServerWsMsg = isDaemonToServerType;
|
|
52
49
|
|
|
53
|
-
export const isServerToDaemonWsMsg: (value: unknown) => value is ServerToDaemonWsMsg = isServerToDaemonControlType;
|
|
54
50
|
|
|
55
51
|
/**
|
|
56
52
|
* `auth_ok.payload.limits` — the plan-limit contract the server sends DOWN to
|
|
@@ -1,76 +0,0 @@
|
|
|
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
|
-
/** A session row: identified by `id`, everything else is a field. */
|
|
16
|
-
export interface KeyedStatusSession {
|
|
17
|
-
id: string;
|
|
18
|
-
[field: string]: unknown;
|
|
19
|
-
}
|
|
20
|
-
/** The snapshot body being diffed: top-level fields plus a `status` with a keyed session list. */
|
|
21
|
-
export interface KeyedStatusBody {
|
|
22
|
-
status: {
|
|
23
|
-
sessions: KeyedStatusSession[];
|
|
24
|
-
[field: string]: unknown;
|
|
25
|
-
};
|
|
26
|
-
[field: string]: unknown;
|
|
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
|
-
interface SessionDigest {
|
|
41
|
-
readonly value: KeyedStatusSession;
|
|
42
|
-
/** Signature of every non-volatile field (fast unchanged check). */
|
|
43
|
-
readonly sig: string;
|
|
44
|
-
/** Per-field signatures, computed lazily — only for sessions that changed. */
|
|
45
|
-
fieldSigs?: Map<string, string>;
|
|
46
|
-
}
|
|
47
|
-
export interface KeyedStatusDigest {
|
|
48
|
-
readonly top: ReadonlyMap<string, string>;
|
|
49
|
-
readonly topValues: Readonly<Record<string, unknown>>;
|
|
50
|
-
readonly status: ReadonlyMap<string, string>;
|
|
51
|
-
readonly statusValues: Readonly<Record<string, unknown>>;
|
|
52
|
-
readonly sessions: ReadonlyMap<string, SessionDigest>;
|
|
53
|
-
readonly order: readonly string[];
|
|
54
|
-
readonly volatile: ReadonlySet<string>;
|
|
55
|
-
}
|
|
56
|
-
export interface KeyedStatusDelta {
|
|
57
|
-
set?: Record<string, unknown>;
|
|
58
|
-
unset?: string[];
|
|
59
|
-
statusSet?: Record<string, unknown>;
|
|
60
|
-
statusUnset?: string[];
|
|
61
|
-
sessions?: KeyedStatusSession[];
|
|
62
|
-
sessionUnset?: Record<string, string[]>;
|
|
63
|
-
removedSessionIds?: string[];
|
|
64
|
-
sessionOrder?: string[];
|
|
65
|
-
}
|
|
66
|
-
/** Digest one body. Cheap to hold: values are referenced, not copied. */
|
|
67
|
-
export declare function digestKeyedStatus(body: KeyedStatusBody, options?: KeyedStatusDigestOptions): KeyedStatusDigest;
|
|
68
|
-
/**
|
|
69
|
-
* The difference `prev → next`, or `null` when nothing a consumer can observe
|
|
70
|
-
* changed. `prev === null` is not accepted here — a subscription with no
|
|
71
|
-
* delivered baseline gets a snapshot, not a delta.
|
|
72
|
-
*/
|
|
73
|
-
export declare function diffKeyedStatus(prev: KeyedStatusDigest, next: KeyedStatusDigest): KeyedStatusDelta | null;
|
|
74
|
-
/** Rebuild the full body from a held one plus a delta. Never mutates `held`. */
|
|
75
|
-
export declare function foldKeyedStatus<T extends KeyedStatusBody>(held: T, delta: KeyedStatusDelta): T;
|
|
76
|
-
export {};
|
|
@@ -1,239 +0,0 @@
|
|
|
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
|
-
}
|