@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,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed document delta — the diff/fold pair behind the `mesh.status` lane
|
|
3
|
+
* (the dashboard's mesh view; data-path audit 2026-09-29 P1-5).
|
|
4
|
+
*
|
|
5
|
+
* A document is plain top-level fields plus named keyed COLLECTIONS (arrays of
|
|
6
|
+
* rows identified by an id field, possibly nested one object deep, e.g.
|
|
7
|
+
* `queue.tasks`). The daemon digests every body it would have sent, diffs it
|
|
8
|
+
* against the digest of the last body DELIVERED to a subscription, and sends
|
|
9
|
+
* only the difference: changed top-level fields, and per collection the rows
|
|
10
|
+
* whose content changed (changed fields only), the fields a row lost, and the
|
|
11
|
+
* rows that disappeared. An unchanged document diffs to `null` — zero bytes on
|
|
12
|
+
* the wire. The dashboard folds each delta into its held snapshot; the fold of
|
|
13
|
+
* every delta since a snapshot equals the latest snapshot (pinned by
|
|
14
|
+
* keyed-doc-delta.test.ts).
|
|
15
|
+
*
|
|
16
|
+
* `volatileTopLevel` fields (a build stamp, live process counters) are not a
|
|
17
|
+
* change by themselves; when something else changed they ride along.
|
|
18
|
+
*
|
|
19
|
+
* Pure leaf: plain objects in, plain objects out; no transport, no clock.
|
|
20
|
+
*/
|
|
21
|
+
export interface KeyedDocSpec {
|
|
22
|
+
/** Collection path (`nodes`, `queue.tasks`) → the id field of its rows. */
|
|
23
|
+
collections: Readonly<Record<string, string>>;
|
|
24
|
+
/** Top-level fields that change on every build without meaning anything. */
|
|
25
|
+
volatileTopLevel?: readonly string[];
|
|
26
|
+
/**
|
|
27
|
+
* The part of a collection row that decides whether it CHANGED (drop the
|
|
28
|
+
* per-build stamps inside it). A row whose stable view is unchanged sends
|
|
29
|
+
* nothing; a changed field is sent with its full current value, stamps
|
|
30
|
+
* included. Must not mutate `row`.
|
|
31
|
+
*/
|
|
32
|
+
stableRow?: (collection: string, row: Record<string, unknown>) => Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
type Row = Record<string, unknown>;
|
|
35
|
+
interface RowDigest {
|
|
36
|
+
readonly value: Row;
|
|
37
|
+
/** The row as compared (stableRow view). */
|
|
38
|
+
readonly stable: Row;
|
|
39
|
+
readonly sig: string;
|
|
40
|
+
fieldSigs?: Map<string, string>;
|
|
41
|
+
}
|
|
42
|
+
interface CollectionDigest {
|
|
43
|
+
readonly rows: ReadonlyMap<string, RowDigest>;
|
|
44
|
+
readonly order: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
export interface KeyedDocDigest {
|
|
47
|
+
readonly top: ReadonlyMap<string, string>;
|
|
48
|
+
readonly topValues: Readonly<Record<string, unknown>>;
|
|
49
|
+
readonly collections: ReadonlyMap<string, CollectionDigest>;
|
|
50
|
+
readonly volatile: ReadonlySet<string>;
|
|
51
|
+
}
|
|
52
|
+
export interface KeyedCollectionDelta {
|
|
53
|
+
/** New rows (whole) and changed rows (id + changed fields). */
|
|
54
|
+
upsert?: Row[];
|
|
55
|
+
/** Fields a row lost, by row id. */
|
|
56
|
+
unsetFields?: Record<string, string[]>;
|
|
57
|
+
removed?: string[];
|
|
58
|
+
order?: string[];
|
|
59
|
+
/** The collection is no longer present in the document at all. */
|
|
60
|
+
absent?: true;
|
|
61
|
+
}
|
|
62
|
+
export interface KeyedDocDelta {
|
|
63
|
+
set?: Record<string, unknown>;
|
|
64
|
+
unset?: string[];
|
|
65
|
+
collections?: Record<string, KeyedCollectionDelta>;
|
|
66
|
+
}
|
|
67
|
+
/** Digest one document. Cheap to hold: values are referenced, not copied. */
|
|
68
|
+
export declare function digestKeyedDoc(doc: Record<string, unknown>, spec: KeyedDocSpec): KeyedDocDigest;
|
|
69
|
+
/**
|
|
70
|
+
* The difference `prev → next`, or `null` when nothing a consumer can observe
|
|
71
|
+
* changed (volatile top-level fields alone are not a change).
|
|
72
|
+
*/
|
|
73
|
+
export declare function diffKeyedDoc(prev: KeyedDocDigest, next: KeyedDocDigest, spec: KeyedDocSpec): KeyedDocDelta | null;
|
|
74
|
+
/** Rebuild the full document from a held one plus a delta. Never mutates `held`. */
|
|
75
|
+
export declare function foldKeyedDoc<T extends Record<string, unknown>>(held: T, delta: KeyedDocDelta, spec: KeyedDocSpec): T;
|
|
76
|
+
/**
|
|
77
|
+
* The `mesh.status` document: nodes keyed by `nodeId`, queue tasks and missions
|
|
78
|
+
* by `id`. `refreshedAt` and `sourceOfTruth` (its aggregate-cache age /
|
|
79
|
+
* returnedAt) are per-call stamps and the counters are live process tallies —
|
|
80
|
+
* none of them is a change by itself.
|
|
81
|
+
*/
|
|
82
|
+
export declare const MESH_STATUS_DOC_SPEC: KeyedDocSpec;
|
|
83
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
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 {};
|
|
@@ -96,15 +96,6 @@ export type SeqscribeStatusWireSummary = {
|
|
|
96
96
|
fgenAgeBucket: number;
|
|
97
97
|
quarantined: boolean;
|
|
98
98
|
authority: boolean;
|
|
99
|
-
dualWrite?: boolean;
|
|
100
|
-
dualWriteFailedBucket?: number;
|
|
101
|
-
dualWriteDroppedBucket?: number;
|
|
102
|
-
dualWriteBackfilledBucket?: number;
|
|
103
|
-
parityMismatchBucket?: number;
|
|
104
|
-
parityRan?: boolean;
|
|
105
|
-
parityMissingInShadowBucket?: number;
|
|
106
|
-
parityExtraInShadowBucket?: number;
|
|
107
|
-
parityFieldMismatchBucket?: number;
|
|
108
99
|
transcriptPublish?: boolean;
|
|
109
100
|
transcriptPublishedBucket?: number;
|
|
110
101
|
transcriptPublishFailedBucket?: number;
|
|
@@ -245,7 +236,8 @@ export type P2PIceWirePayload = {
|
|
|
245
236
|
};
|
|
246
237
|
/**
|
|
247
238
|
* A dashboard-originated signal as relayed to the daemon. `peerId` is stamped
|
|
248
|
-
* by UserSession/SharedSession, `sharePermission`
|
|
239
|
+
* by UserSession/SharedSession, `sharePermission` and `shareTargetSessionId`
|
|
240
|
+
* (the one session a share link may see) only by SharedSession (server
|
|
249
241
|
* authority, never the client). Field names follow the browser/node-datachannel
|
|
250
242
|
* spellings both ends already read.
|
|
251
243
|
*/
|
|
@@ -253,6 +245,7 @@ export type RelayedP2PSignalWirePayload = {
|
|
|
253
245
|
peerId?: string;
|
|
254
246
|
daemonId?: string;
|
|
255
247
|
sharePermission?: string;
|
|
248
|
+
shareTargetSessionId?: string;
|
|
256
249
|
sdp?: string;
|
|
257
250
|
type?: string;
|
|
258
251
|
candidate?: string;
|
|
@@ -125,11 +125,9 @@ export declare function decodeDashboardToDaemonP2PChunk(raw: unknown): Dashboard
|
|
|
125
125
|
* `decodeDashboardToDaemonP2PChunk`.
|
|
126
126
|
*/
|
|
127
127
|
export declare function decodeDashboardToDaemonP2P(raw: unknown): DashboardToDaemonP2PMsg | null;
|
|
128
|
-
export declare const DAEMON_TO_DASHBOARD_P2P_TYPES: readonly ["ping", "pong", "p2p_evicted", "
|
|
128
|
+
export declare const DAEMON_TO_DASHBOARD_P2P_TYPES: readonly ["ping", "pong", "p2p_evicted", "status_event", "topic_update", "topic_update_chunk", "session_output", "session_output_chunk", "session_io_error", "command_result", "command_result_chunk", "response", "transcript_topics_available"];
|
|
129
129
|
export type DaemonToDashboardP2PType = typeof DAEMON_TO_DASHBOARD_P2P_TYPES[number];
|
|
130
|
-
export declare const isDaemonToDashboardP2PType: (value: unknown) => value is "
|
|
131
|
-
/** Rich P2P status snapshot (daemon-core `StatusReportPayload`, or a `_delta` partial of it). Dynamic by design on this plane. */
|
|
132
|
-
export type P2PStatusReportWirePayload = Record<string, unknown>;
|
|
130
|
+
export declare const isDaemonToDashboardP2PType: (value: unknown) => value is "status_event" | "command_result" | "ping" | "pong" | "p2p_evicted" | "topic_update" | "topic_update_chunk" | "session_output" | "session_output_chunk" | "session_io_error" | "command_result_chunk" | "response" | "transcript_topics_available";
|
|
133
131
|
/** The server-bound event plus the P2P-only structured prompt (daemon-core `P2PStatusEventPayload`). */
|
|
134
132
|
export type P2PStatusEventWirePayload = DaemonStatusEventWirePayload & {
|
|
135
133
|
interactivePrompt?: unknown;
|
|
@@ -151,11 +149,6 @@ export type P2PEvictedMsg = {
|
|
|
151
149
|
reason: string;
|
|
152
150
|
maxconnections: number;
|
|
153
151
|
};
|
|
154
|
-
export type P2PStatusReportMsg = {
|
|
155
|
-
type: 'status_report';
|
|
156
|
-
payload: P2PStatusReportWirePayload;
|
|
157
|
-
timestamp: number;
|
|
158
|
-
};
|
|
159
152
|
export type P2PStatusEventMsg = {
|
|
160
153
|
type: 'status_event';
|
|
161
154
|
payload: P2PStatusEventWirePayload;
|
|
@@ -199,7 +192,18 @@ export type P2PResponseMsg = {
|
|
|
199
192
|
content?: string;
|
|
200
193
|
entries?: P2PFileEntry[];
|
|
201
194
|
};
|
|
202
|
-
|
|
195
|
+
/**
|
|
196
|
+
* `session.<id>.chat` topics that just became SUB-able for THIS peer (the
|
|
197
|
+
* topic was defined, or the peer's declared session interest widened to it).
|
|
198
|
+
* Sent only after the grant is in place, so a seqscribe SUB issued on receipt
|
|
199
|
+
* is accepted; without it the browser can only guess when to re-SUB a refused
|
|
200
|
+
* subscription. Topic names only — the P2P plane already carries the ids.
|
|
201
|
+
*/
|
|
202
|
+
export type P2PTranscriptTopicsAvailableMsg = {
|
|
203
|
+
type: 'transcript_topics_available';
|
|
204
|
+
topics: string[];
|
|
205
|
+
};
|
|
206
|
+
export type DaemonToDashboardP2PMsg = P2PPingMsg | P2PPongMsg | P2PEvictedMsg | P2PStatusEventMsg | P2PTopicUpdateMsg | P2PTopicUpdateChunkMsg | P2PSessionOutputMsg | P2PSessionOutputChunkMsg | P2PSessionIoErrorMsg | P2PCommandResultMsg | P2PCommandResultChunkMsg | P2PResponseMsg | P2PTranscriptTopicsAvailableMsg;
|
|
203
207
|
/**
|
|
204
208
|
* Parse boundary for the dashboard (after chunk reassembly).
|
|
205
209
|
*
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed document delta — the diff/fold pair behind the `mesh.status` lane
|
|
3
|
+
* (the dashboard's mesh view; data-path audit 2026-09-29 P1-5).
|
|
4
|
+
*
|
|
5
|
+
* A document is plain top-level fields plus named keyed COLLECTIONS (arrays of
|
|
6
|
+
* rows identified by an id field, possibly nested one object deep, e.g.
|
|
7
|
+
* `queue.tasks`). The daemon digests every body it would have sent, diffs it
|
|
8
|
+
* against the digest of the last body DELIVERED to a subscription, and sends
|
|
9
|
+
* only the difference: changed top-level fields, and per collection the rows
|
|
10
|
+
* whose content changed (changed fields only), the fields a row lost, and the
|
|
11
|
+
* rows that disappeared. An unchanged document diffs to `null` — zero bytes on
|
|
12
|
+
* the wire. The dashboard folds each delta into its held snapshot; the fold of
|
|
13
|
+
* every delta since a snapshot equals the latest snapshot (pinned by
|
|
14
|
+
* keyed-doc-delta.test.ts).
|
|
15
|
+
*
|
|
16
|
+
* `volatileTopLevel` fields (a build stamp, live process counters) are not a
|
|
17
|
+
* change by themselves; when something else changed they ride along.
|
|
18
|
+
*
|
|
19
|
+
* Pure leaf: plain objects in, plain objects out; no transport, no clock.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface KeyedDocSpec {
|
|
23
|
+
/** Collection path (`nodes`, `queue.tasks`) → the id field of its rows. */
|
|
24
|
+
collections: Readonly<Record<string, string>>
|
|
25
|
+
/** Top-level fields that change on every build without meaning anything. */
|
|
26
|
+
volatileTopLevel?: readonly string[]
|
|
27
|
+
/**
|
|
28
|
+
* The part of a collection row that decides whether it CHANGED (drop the
|
|
29
|
+
* per-build stamps inside it). A row whose stable view is unchanged sends
|
|
30
|
+
* nothing; a changed field is sent with its full current value, stamps
|
|
31
|
+
* included. Must not mutate `row`.
|
|
32
|
+
*/
|
|
33
|
+
stableRow?: (collection: string, row: Record<string, unknown>) => Record<string, unknown>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Row = Record<string, unknown>
|
|
37
|
+
|
|
38
|
+
interface RowDigest {
|
|
39
|
+
readonly value: Row
|
|
40
|
+
/** The row as compared (stableRow view). */
|
|
41
|
+
readonly stable: Row
|
|
42
|
+
readonly sig: string
|
|
43
|
+
fieldSigs?: Map<string, string>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface CollectionDigest {
|
|
47
|
+
readonly rows: ReadonlyMap<string, RowDigest>
|
|
48
|
+
readonly order: readonly string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface KeyedDocDigest {
|
|
52
|
+
readonly top: ReadonlyMap<string, string>
|
|
53
|
+
readonly topValues: Readonly<Record<string, unknown>>
|
|
54
|
+
readonly collections: ReadonlyMap<string, CollectionDigest>
|
|
55
|
+
readonly volatile: ReadonlySet<string>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface KeyedCollectionDelta {
|
|
59
|
+
/** New rows (whole) and changed rows (id + changed fields). */
|
|
60
|
+
upsert?: Row[]
|
|
61
|
+
/** Fields a row lost, by row id. */
|
|
62
|
+
unsetFields?: Record<string, string[]>
|
|
63
|
+
removed?: string[]
|
|
64
|
+
order?: string[]
|
|
65
|
+
/** The collection is no longer present in the document at all. */
|
|
66
|
+
absent?: true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface KeyedDocDelta {
|
|
70
|
+
set?: Record<string, unknown>
|
|
71
|
+
unset?: string[]
|
|
72
|
+
collections?: Record<string, KeyedCollectionDelta>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sig(value: unknown): string | undefined {
|
|
76
|
+
if (value === undefined) return undefined
|
|
77
|
+
try {
|
|
78
|
+
return JSON.stringify(value)
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
85
|
+
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Read the array at `path` (`a` or `a.b`) — undefined when absent. */
|
|
89
|
+
function readPath(doc: Record<string, unknown>, path: string): unknown {
|
|
90
|
+
const [head, tail] = splitPath(path)
|
|
91
|
+
if (!tail) return doc[head]
|
|
92
|
+
const parent = doc[head]
|
|
93
|
+
return isRecord(parent) ? parent[tail] : undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function splitPath(path: string): [string, string | undefined] {
|
|
97
|
+
const dot = path.indexOf('.')
|
|
98
|
+
return dot < 0 ? [path, undefined] : [path.slice(0, dot), path.slice(dot + 1)]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The document minus its collections (a nested collection is removed from a
|
|
103
|
+
* shallow copy of its parent, so the parent's other fields stay top-level data).
|
|
104
|
+
*/
|
|
105
|
+
function stripCollections(doc: Record<string, unknown>, spec: KeyedDocSpec): Record<string, unknown> {
|
|
106
|
+
const out: Record<string, unknown> = { ...doc }
|
|
107
|
+
for (const path of Object.keys(spec.collections)) {
|
|
108
|
+
const [head, tail] = splitPath(path)
|
|
109
|
+
if (!tail) {
|
|
110
|
+
delete out[head]
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
const parent = out[head]
|
|
114
|
+
if (!isRecord(parent) || !(tail in parent)) continue
|
|
115
|
+
const copy = { ...parent }
|
|
116
|
+
delete copy[tail]
|
|
117
|
+
out[head] = copy
|
|
118
|
+
}
|
|
119
|
+
return out
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function fieldSigsOf(digest: RowDigest): Map<string, string> {
|
|
123
|
+
if (!digest.fieldSigs) {
|
|
124
|
+
const sigs = new Map<string, string>()
|
|
125
|
+
for (const [key, value] of Object.entries(digest.stable)) {
|
|
126
|
+
const s = sig(value)
|
|
127
|
+
if (s !== undefined) sigs.set(key, s)
|
|
128
|
+
}
|
|
129
|
+
digest.fieldSigs = sigs
|
|
130
|
+
}
|
|
131
|
+
return digest.fieldSigs
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Digest one document. Cheap to hold: values are referenced, not copied. */
|
|
135
|
+
export function digestKeyedDoc(doc: Record<string, unknown>, spec: KeyedDocSpec): KeyedDocDigest {
|
|
136
|
+
const top = new Map<string, string>()
|
|
137
|
+
const topValues: Record<string, unknown> = {}
|
|
138
|
+
for (const [key, value] of Object.entries(stripCollections(doc, spec))) {
|
|
139
|
+
const s = sig(value)
|
|
140
|
+
if (s === undefined) continue
|
|
141
|
+
top.set(key, s)
|
|
142
|
+
topValues[key] = value
|
|
143
|
+
}
|
|
144
|
+
const collections = new Map<string, CollectionDigest>()
|
|
145
|
+
for (const [path, idField] of Object.entries(spec.collections)) {
|
|
146
|
+
const list = readPath(doc, path)
|
|
147
|
+
if (!Array.isArray(list)) continue
|
|
148
|
+
const rows = new Map<string, RowDigest>()
|
|
149
|
+
const order: string[] = []
|
|
150
|
+
for (const row of list) {
|
|
151
|
+
if (!isRecord(row)) continue
|
|
152
|
+
const id = row[idField]
|
|
153
|
+
if (typeof id !== 'string' || !id || rows.has(id)) continue
|
|
154
|
+
const stable = spec.stableRow ? spec.stableRow(path, row) : row
|
|
155
|
+
rows.set(id, { value: row, stable, sig: sig(stable) ?? '' })
|
|
156
|
+
order.push(id)
|
|
157
|
+
}
|
|
158
|
+
collections.set(path, { rows, order })
|
|
159
|
+
}
|
|
160
|
+
return { top, topValues, collections, volatile: new Set(spec.volatileTopLevel ?? []) }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function sameOrder(a: readonly string[], b: readonly string[]): boolean {
|
|
164
|
+
if (a.length !== b.length) return false
|
|
165
|
+
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false
|
|
166
|
+
return true
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function diffCollection(prev: CollectionDigest | undefined, next: CollectionDigest | undefined, idField: string): KeyedCollectionDelta | null {
|
|
170
|
+
if (!next) return prev ? { absent: true } : null
|
|
171
|
+
const delta: KeyedCollectionDelta = {}
|
|
172
|
+
const prevRows = prev?.rows ?? new Map<string, RowDigest>()
|
|
173
|
+
const nextRows = next?.rows ?? new Map<string, RowDigest>()
|
|
174
|
+
for (const [id, nextRow] of nextRows) {
|
|
175
|
+
const prevRow = prevRows.get(id)
|
|
176
|
+
if (!prevRow) {
|
|
177
|
+
;(delta.upsert ??= []).push(nextRow.value)
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
if (prevRow.sig === nextRow.sig) continue
|
|
181
|
+
const prevSigs = fieldSigsOf(prevRow)
|
|
182
|
+
const nextSigs = fieldSigsOf(nextRow)
|
|
183
|
+
const change: Row = { [idField]: id }
|
|
184
|
+
let changed = false
|
|
185
|
+
for (const [key, s] of nextSigs) {
|
|
186
|
+
if (key === idField || prevSigs.get(key) === s) continue
|
|
187
|
+
change[key] = nextRow.value[key]
|
|
188
|
+
changed = true
|
|
189
|
+
}
|
|
190
|
+
const gone: string[] = []
|
|
191
|
+
for (const key of prevSigs.keys()) if (!nextSigs.has(key) && !(key in nextRow.value)) gone.push(key)
|
|
192
|
+
if (changed) (delta.upsert ??= []).push(change)
|
|
193
|
+
if (gone.length > 0) (delta.unsetFields ??= {})[id] = gone
|
|
194
|
+
}
|
|
195
|
+
for (const id of prevRows.keys()) {
|
|
196
|
+
if (!nextRows.has(id)) (delta.removed ??= []).push(id)
|
|
197
|
+
}
|
|
198
|
+
if (!sameOrder(prev?.order ?? [], next?.order ?? [])) delta.order = [...(next?.order ?? [])]
|
|
199
|
+
return Object.keys(delta).length > 0 ? delta : null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The difference `prev → next`, or `null` when nothing a consumer can observe
|
|
204
|
+
* changed (volatile top-level fields alone are not a change).
|
|
205
|
+
*/
|
|
206
|
+
export function diffKeyedDoc(prev: KeyedDocDigest, next: KeyedDocDigest, spec: KeyedDocSpec): KeyedDocDelta | null {
|
|
207
|
+
const delta: KeyedDocDelta = {}
|
|
208
|
+
let meaningful = false
|
|
209
|
+
let set: Record<string, unknown> | undefined
|
|
210
|
+
let unset: string[] | undefined
|
|
211
|
+
for (const [key, s] of next.top) {
|
|
212
|
+
if (prev.top.get(key) === s) continue
|
|
213
|
+
;(set ??= {})[key] = next.topValues[key]
|
|
214
|
+
if (!next.volatile.has(key)) meaningful = true
|
|
215
|
+
}
|
|
216
|
+
for (const key of prev.top.keys()) {
|
|
217
|
+
if (next.top.has(key)) continue
|
|
218
|
+
;(unset ??= []).push(key)
|
|
219
|
+
if (!next.volatile.has(key)) meaningful = true
|
|
220
|
+
}
|
|
221
|
+
for (const [path, idField] of Object.entries(spec.collections)) {
|
|
222
|
+
const change = diffCollection(prev.collections.get(path), next.collections.get(path), idField)
|
|
223
|
+
if (!change) continue
|
|
224
|
+
;(delta.collections ??= {})[path] = change
|
|
225
|
+
meaningful = true
|
|
226
|
+
}
|
|
227
|
+
if (!meaningful) return null
|
|
228
|
+
if (set) delta.set = set
|
|
229
|
+
if (unset) delta.unset = unset
|
|
230
|
+
return delta
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function foldCollection(held: unknown, change: KeyedCollectionDelta, idField: string): Row[] {
|
|
234
|
+
const byId = new Map<string, Row>()
|
|
235
|
+
const heldOrder: string[] = []
|
|
236
|
+
for (const row of Array.isArray(held) ? held : []) {
|
|
237
|
+
if (!isRecord(row) || typeof row[idField] !== 'string') continue
|
|
238
|
+
byId.set(row[idField] as string, row)
|
|
239
|
+
heldOrder.push(row[idField] as string)
|
|
240
|
+
}
|
|
241
|
+
for (const id of change.removed ?? []) byId.delete(id)
|
|
242
|
+
for (const row of change.upsert ?? []) {
|
|
243
|
+
const id = row[idField]
|
|
244
|
+
if (typeof id !== 'string') continue
|
|
245
|
+
const base = byId.get(id)
|
|
246
|
+
byId.set(id, base ? { ...base, ...row } : { ...row })
|
|
247
|
+
}
|
|
248
|
+
for (const [id, keys] of Object.entries(change.unsetFields ?? {})) {
|
|
249
|
+
const base = byId.get(id)
|
|
250
|
+
if (!base) continue
|
|
251
|
+
const trimmed = { ...base }
|
|
252
|
+
for (const key of keys) delete trimmed[key]
|
|
253
|
+
byId.set(id, trimmed)
|
|
254
|
+
}
|
|
255
|
+
const order = change.order ?? heldOrder
|
|
256
|
+
const out: Row[] = []
|
|
257
|
+
const placed = new Set<string>()
|
|
258
|
+
for (const id of order) {
|
|
259
|
+
const row = byId.get(id)
|
|
260
|
+
if (!row || placed.has(id)) continue
|
|
261
|
+
out.push(row)
|
|
262
|
+
placed.add(id)
|
|
263
|
+
}
|
|
264
|
+
for (const [id, row] of byId) if (!placed.has(id)) out.push(row)
|
|
265
|
+
return out
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Rebuild the full document from a held one plus a delta. Never mutates `held`. */
|
|
269
|
+
export function foldKeyedDoc<T extends Record<string, unknown>>(held: T, delta: KeyedDocDelta, spec: KeyedDocSpec): T {
|
|
270
|
+
// Collections are re-inserted below from the held ones, so the plain fields
|
|
271
|
+
// are folded on the stripped view (a nested parent keeps its other fields).
|
|
272
|
+
const heldCollections = new Map<string, unknown>()
|
|
273
|
+
for (const path of Object.keys(spec.collections)) heldCollections.set(path, readPath(held, path))
|
|
274
|
+
const next: Record<string, unknown> = stripCollections(held, spec)
|
|
275
|
+
for (const key of delta.unset ?? []) delete next[key]
|
|
276
|
+
Object.assign(next, delta.set ?? {})
|
|
277
|
+
for (const [path, idField] of Object.entries(spec.collections)) {
|
|
278
|
+
const change = delta.collections?.[path]
|
|
279
|
+
const heldList = heldCollections.get(path)
|
|
280
|
+
if (change?.absent) {
|
|
281
|
+
const [head, tail] = splitPath(path)
|
|
282
|
+
if (!tail) delete next[head]
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
if (!change && heldList === undefined) continue
|
|
286
|
+
const list = change ? foldCollection(heldList, change, idField) : heldList
|
|
287
|
+
const [head, tail] = splitPath(path)
|
|
288
|
+
if (!tail) {
|
|
289
|
+
next[head] = list
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
const parent = isRecord(next[head]) ? { ...(next[head] as Record<string, unknown>) } : {}
|
|
293
|
+
parent[tail] = list
|
|
294
|
+
next[head] = parent
|
|
295
|
+
}
|
|
296
|
+
return next as T
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The `mesh.status` document: nodes keyed by `nodeId`, queue tasks and missions
|
|
301
|
+
* by `id`. `refreshedAt` and `sourceOfTruth` (its aggregate-cache age /
|
|
302
|
+
* returnedAt) are per-call stamps and the counters are live process tallies —
|
|
303
|
+
* none of them is a change by itself.
|
|
304
|
+
*/
|
|
305
|
+
export const MESH_STATUS_DOC_SPEC: KeyedDocSpec = {
|
|
306
|
+
collections: { nodes: 'nodeId', 'queue.tasks': 'id', missions: 'id' },
|
|
307
|
+
volatileTopLevel: [
|
|
308
|
+
'refreshedAt', 'sourceOfTruth',
|
|
309
|
+
'turnPresentationCounters', 'meshProtocolV2Counters', 'pendingRetentionCounters', 'meshProtocolMetrics',
|
|
310
|
+
],
|
|
311
|
+
stableRow: (collection, row) => (collection === 'nodes' ? stableMeshNodeRow(row) : row),
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function without(value: unknown, keys: readonly string[]): unknown {
|
|
315
|
+
if (!isRecord(value)) return value
|
|
316
|
+
const out = { ...value }
|
|
317
|
+
for (const key of keys) delete out[key]
|
|
318
|
+
return out
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A mesh node minus the stamps an aggregate rebuild re-derives from the clock
|
|
323
|
+
* (last-seen / updated / freshness age, the local read's check time, a local
|
|
324
|
+
* facts bundle's reportedAt). A REMOTE node's `gitObservation.observedAt` is
|
|
325
|
+
* real news (the member's own check time — the dashboard's "aged" hint) and
|
|
326
|
+
* is compared; the coordinator's own / local checkout's is its read time.
|
|
327
|
+
*/
|
|
328
|
+
function stableMeshNodeRow(row: Record<string, unknown>): Record<string, unknown> {
|
|
329
|
+
const out: Record<string, unknown> = { ...row }
|
|
330
|
+
delete out.lastSeenAt
|
|
331
|
+
delete out.updatedAt
|
|
332
|
+
delete out.last_seen_at
|
|
333
|
+
delete out.updated_at
|
|
334
|
+
if ('dataFreshness' in out) out.dataFreshness = without(out.dataFreshness, ['ageMs', 'lastProbeAt', 'staleness'])
|
|
335
|
+
if ('connection' in out) out.connection = without(out.connection, ['lastStateChangeAt'])
|
|
336
|
+
if ('git' in out) {
|
|
337
|
+
const git = without(out.git, ['lastCheckedAt'])
|
|
338
|
+
// "No submodules" is the same fact whether a read listed none or skipped the scan.
|
|
339
|
+
if (isRecord(git) && Array.isArray(git.submodules) && git.submodules.length === 0) delete git.submodules
|
|
340
|
+
out.git = git
|
|
341
|
+
}
|
|
342
|
+
if ('nodeFacts' in out) out.nodeFacts = without(out.nodeFacts, ['reportedAt'])
|
|
343
|
+
const observation = out.gitObservation
|
|
344
|
+
if (isRecord(observation) && (observation.source === 'self' || observation.source === 'local')) {
|
|
345
|
+
out.gitObservation = without(observation, ['observedAt'])
|
|
346
|
+
}
|
|
347
|
+
return out
|
|
348
|
+
}
|