@polycode-projects/the-mechanical-code-talker 6.0.21 → 6.0.22
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/README.md +5 -15
- package/bin/tmct.mjs +8 -3
- package/package.json +1 -5
- package/src/adapters/memory/core.mjs +108 -96
- package/src/domain/agent-traits.mjs +1 -1
- package/src/domain/answer-variants.json +1 -1
- package/src/domain/cli-verbs.mjs +1 -0
- package/src/domain/memory/causal-stability.mjs +22 -5
- package/src/domain/memory/fact-order.mjs +3 -3
- package/src/domain/memory/provenance-time.mjs +35 -0
- package/src/domain/memory/retraction.mjs +3 -5
- package/src/domain/memory/trust.mjs +11 -11
- package/src/domain/news-feed.mjs +1 -1
- package/src/domain/seeded-random.mjs +6 -6
- package/src/services/adventure.mjs +10 -41
- package/src/services/chat-page-viz.mjs +12 -1111
- package/src/services/chat-session.mjs +20 -6
- package/src/services/chat.mjs +269 -197
- package/src/services/extract-facts.mjs +1 -1
- package/src/services/import-file.mjs +1 -1
- package/src/services/mud-viz.mjs +21 -1082
- package/src/services/mudiii-viz.mjs +0 -4
- package/src/services/pill-complete.mjs +5 -8
- package/src/services/predator-prey.mjs +3 -5
- package/src/surfaces/web/memory-ask-browser.bundle.js +107 -107
- package/src/surfaces/web/mud-browser-entry.mjs +8 -48
- package/test-benchmarks/agentbench/README.md +7 -10
- package/src/adapters/p2p/webrtc-transport.mjs +0 -169
- package/src/domain/p2p/facts.mjs +0 -102
- package/src/domain/p2p/peer-id.mjs +0 -47
- package/src/domain/p2p/provenance-relabel.mjs +0 -37
- package/src/domain/p2p/sync-filter.mjs +0 -43
- package/src/domain/p2p/wire.mjs +0 -126
- package/src/services/p2p-room.mjs +0 -848
- package/src/services/share-overlay-viz.mjs +0 -623
- package/src/surfaces/web/p2p-browser-entry.mjs +0 -39
|
@@ -1,848 +0,0 @@
|
|
|
1
|
-
// services/p2p-room.mjs — room orchestration for the P2P layer: the two-paste
|
|
2
|
-
// signaling handoff, the mesh of direct connections it grows into, and the
|
|
3
|
-
// diff/broadcast/merge loop that keeps every peer's fact store converged.
|
|
4
|
-
// Everything WebRTC arrives through an injected `transportFactory`, so this
|
|
5
|
-
// module and every test of it run in plain Node with no browser and no network.
|
|
6
|
-
// src/adapters/p2p/webrtc-transport.mjs supplies the real factory; the pure
|
|
7
|
-
// message shapes live in src/domain/p2p/.
|
|
8
|
-
import {
|
|
9
|
-
encodeInviteBlob,
|
|
10
|
-
decodeInviteBlob,
|
|
11
|
-
helloMessage,
|
|
12
|
-
peerListMessage,
|
|
13
|
-
introOfferMessage,
|
|
14
|
-
introAnswerMessage,
|
|
15
|
-
syncRequestMessage,
|
|
16
|
-
syncResponseMessage,
|
|
17
|
-
opMessage,
|
|
18
|
-
isValidRoomMessage,
|
|
19
|
-
} from "../domain/p2p/wire.mjs";
|
|
20
|
-
import { relabelForBroadcast } from "../domain/p2p/provenance-relabel.mjs";
|
|
21
|
-
import {
|
|
22
|
-
worldNameFact,
|
|
23
|
-
nodeNameFact,
|
|
24
|
-
invitedByFact,
|
|
25
|
-
waveFact,
|
|
26
|
-
latestFact,
|
|
27
|
-
latestProvenanceTimestamp,
|
|
28
|
-
isRecentWave,
|
|
29
|
-
NODE_NAME_PREDICATE,
|
|
30
|
-
WAVED_PREDICATE,
|
|
31
|
-
} from "../domain/p2p/facts.mjs";
|
|
32
|
-
import { generateNodeId } from "../domain/p2p/peer-id.mjs";
|
|
33
|
-
import {
|
|
34
|
-
RETRACTION_PREDICATE, encodeRetractionValue, decodeRetractionValue,
|
|
35
|
-
} from "../domain/memory/retraction.mjs";
|
|
36
|
-
import {
|
|
37
|
-
appendFacts, appendRetractions, loadMemory, loadNodeId, saveNodeId,
|
|
38
|
-
readFactRows, readRetractions, normFactTerm, factGroupId, factRecordIdForTag, FACT_CLASS,
|
|
39
|
-
} from "../adapters/memory/core.mjs";
|
|
40
|
-
|
|
41
|
-
export const ROOM_IDLE = "idle";
|
|
42
|
-
export const ROOM_SHARING = "sharing";
|
|
43
|
-
export const ROOM_ANSWERING = "answering";
|
|
44
|
-
export const ROOM_CONNECTING = "connecting";
|
|
45
|
-
export const ROOM_CONNECTED = "connected";
|
|
46
|
-
export const ROOM_FAILED = "failed";
|
|
47
|
-
|
|
48
|
-
/** The object term a wave carries when it isn't scoped to a mud room —
|
|
49
|
-
* chat.html has presence but no geography, and a wave still needs a real
|
|
50
|
-
* object term because a triple with an empty slot is skipped on write. */
|
|
51
|
-
export const PRESENCE_SCOPE = "presence";
|
|
52
|
-
|
|
53
|
-
const INVITE_PROBLEM_MESSAGES = {
|
|
54
|
-
invite: {
|
|
55
|
-
empty: "paste the invite you were sent, then try again",
|
|
56
|
-
truncated: "this invite looks cut short — ask for it to be sent again",
|
|
57
|
-
malformed: "this invite looks cut short — ask for it to be sent again",
|
|
58
|
-
"unsupported-version": "this invite came from a newer version of the page than this one",
|
|
59
|
-
"wrong-kind": "that's a reply, not an invite — paste it into the box on the page that sent the invite",
|
|
60
|
-
"wrong-world": "that invite is for a different world than this page is sharing",
|
|
61
|
-
},
|
|
62
|
-
reply: {
|
|
63
|
-
empty: "paste their reply here, then try again",
|
|
64
|
-
truncated: "that doesn't look like a complete reply — check the whole thing was copied, then paste it again",
|
|
65
|
-
malformed: "that doesn't look like a complete reply — check the whole thing was copied, then paste it again",
|
|
66
|
-
"unsupported-version": "that reply came from a newer version of the page than this one",
|
|
67
|
-
"wrong-kind": "that's an invite, not a reply — send it to the person you're inviting, or open it in a new tab to join their world",
|
|
68
|
-
"no-pending-invite": "that reply matched an invite that's already been used — create a fresh link and send that instead",
|
|
69
|
-
"connect-failed": "your two machines couldn't find a path to each other — a public STUN server helps with most networks, but some firewalls or strict NATs still block it",
|
|
70
|
-
},
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const problem = (context, code) => ({
|
|
74
|
-
error: code,
|
|
75
|
-
message: INVITE_PROBLEM_MESSAGES[context]?.[code] || INVITE_PROBLEM_MESSAGES[context]?.malformed || code,
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
// A tag this shape has already been rewritten for the wire by whichever peer
|
|
79
|
-
// authored it. Relabeling it again would overwrite their node name with ours
|
|
80
|
-
// and lose the attribution the receiving page renders as "taught by X". The
|
|
81
|
-
// prefix is all this needs to match: everything a relabel adds after it — the
|
|
82
|
-
// display name, the `#node:<id>` origin segment, the timestamp — is that
|
|
83
|
-
// peer's, so a relayed tag stays byte-identical however much it carries.
|
|
84
|
-
const ALREADY_PEER_LABELED = /^teach:peer:/;
|
|
85
|
-
|
|
86
|
-
/** One outgoing tag per stored tag, each relabeled under the timestamp it
|
|
87
|
-
* already carries. Keeping a relabeled tag on the fact's own assertion time
|
|
88
|
-
* rather than the moment it went over the wire is what stops a months-old
|
|
89
|
-
* wave replayed inside a sync response reading as freshly waved, and it makes
|
|
90
|
-
* the rewrite idempotent: a peer that relays a tag it received produces the
|
|
91
|
-
* same tag again, so the union stops growing once every peer has seen it. */
|
|
92
|
-
/** `observedAtBySegment` maps a STORED (pre-relabel) tag to the observedAt its
|
|
93
|
-
* own record carries, when it has one — the dated-teach frame's own
|
|
94
|
-
* `mgx:observedAt`, threaded onto the wire fact each tag becomes.
|
|
95
|
-
* `extractionBySegment` does the same for that record's own extraction
|
|
96
|
-
* findings, so a peer replicating the tag reads back the same findings the
|
|
97
|
-
* original assertion recorded. Returns `{ tag, observedAt, extraction }`
|
|
98
|
-
* triples, deduped by the RELABELED tag (the first value seen for a given
|
|
99
|
-
* output tag wins, matching the prior de-dup by tag string alone). */
|
|
100
|
-
function wireProvenanceTags(provenance, identity, fallbackTimestamp, observedAtBySegment, extractionBySegment) {
|
|
101
|
-
const stored = String(provenance || "").split(" | ").filter(Boolean);
|
|
102
|
-
if (!stored.length) return [{ tag: "", observedAt: undefined, extraction: undefined }];
|
|
103
|
-
const byTag = new Map();
|
|
104
|
-
for (const segment of stored) {
|
|
105
|
-
const observedAt = observedAtBySegment?.get(segment);
|
|
106
|
-
const extraction = extractionBySegment?.get(segment);
|
|
107
|
-
const tag = ALREADY_PEER_LABELED.test(segment) ? segment : (() => {
|
|
108
|
-
const assertedAt = latestProvenanceTimestamp(segment);
|
|
109
|
-
const timestamp = assertedAt === null ? fallbackTimestamp : new Date(assertedAt).toISOString();
|
|
110
|
-
return relabelForBroadcast(segment, identity.displayName, timestamp, identity.nodeId);
|
|
111
|
-
})();
|
|
112
|
-
if (!byTag.has(tag)) byTag.set(tag, { observedAt, extraction });
|
|
113
|
-
}
|
|
114
|
-
return [...byTag].map(([tag, meta]) => ({ tag, ...meta }));
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** This store's own node id, minted on first use and never regenerated: it
|
|
118
|
-
* keys every assertion the node broadcasts, so re-minting one would split a
|
|
119
|
-
* single node's history into two apparent origins that then corroborate each
|
|
120
|
-
* other. An id already on the store always wins over `preferred` (what a
|
|
121
|
-
* browser page carries across reloads) for exactly that reason — `preferred`
|
|
122
|
-
* only seeds a store that has never joined a room. */
|
|
123
|
-
export async function resolveStoreNodeId(memoryDir, preferred = "") {
|
|
124
|
-
const stored = await loadNodeId(memoryDir);
|
|
125
|
-
if (stored) return stored;
|
|
126
|
-
const minted = preferred || generateNodeId();
|
|
127
|
-
await saveNodeId(memoryDir, minted);
|
|
128
|
-
return minted;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const isFactShaped = (f) => !!f
|
|
132
|
-
&& typeof f.subject === "string" && f.subject.length > 0
|
|
133
|
-
&& typeof f.predicate === "string" && f.predicate.length > 0
|
|
134
|
-
&& typeof f.object === "string" && f.object.length > 0;
|
|
135
|
-
|
|
136
|
-
/** One wire fact per provenance tag rather than one carrying the whole
|
|
137
|
-
* " | "-joined union. appendFacts dedupes an incoming tag against the tags it
|
|
138
|
-
* already stores, and a joined union arrives as one opaque string that matches
|
|
139
|
-
* none of them — so a fact whose provenance had grown would union again on
|
|
140
|
-
* every hop and never settle. Sending each tag on its own row keeps the merge
|
|
141
|
-
* idempotent, and a repeated triple inside one batch unions correctly.
|
|
142
|
-
*
|
|
143
|
-
* `mgx:observedAt` and `mgx:extractionFinding` are both record content the
|
|
144
|
-
* same way the tag is: each of row.assertions is one live head, and its own
|
|
145
|
-
* `provenance` (that head's own tags) is the join key back to which outgoing
|
|
146
|
-
* wire fact carries its observedAt and its extraction findings. A dated
|
|
147
|
-
* taught fact's broadcast carries the date, so a peer that receives it can
|
|
148
|
-
* resolve it against a fresher OR staler claim the same way the local store
|
|
149
|
-
* already does (memory/resolution.mjs's effectiveObservedAt chain), and a
|
|
150
|
-
* finding-bearing assertion's broadcast carries its findings, so a peer
|
|
151
|
-
* renders the same caveat the origin did rather than reading a clean
|
|
152
|
-
* reading that never happened. Sent per assertion, never unioned at the wire
|
|
153
|
-
* layer — the row's own union is a read-time fold over exactly these
|
|
154
|
-
* per-assertion values, so unioning again here would double up on merge. */
|
|
155
|
-
const toWireFacts = (row, identity, fallbackTimestamp) => {
|
|
156
|
-
const observedAtBySegment = new Map();
|
|
157
|
-
const extractionBySegment = new Map();
|
|
158
|
-
for (const a of row.assertions || []) {
|
|
159
|
-
const segments = String(a.provenance || "").split(" | ").filter(Boolean);
|
|
160
|
-
if (a.observedAt) for (const segment of segments) observedAtBySegment.set(segment, a.observedAt);
|
|
161
|
-
if (a.extraction?.length) for (const segment of segments) extractionBySegment.set(segment, a.extraction);
|
|
162
|
-
}
|
|
163
|
-
return wireProvenanceTags(row.provenance, identity, fallbackTimestamp, observedAtBySegment, extractionBySegment)
|
|
164
|
-
.map(({ tag, observedAt, extraction }) => ({
|
|
165
|
-
subject: row.subject,
|
|
166
|
-
predicate: row.predicate,
|
|
167
|
-
object: row.object,
|
|
168
|
-
provenance: tag,
|
|
169
|
-
...(observedAt ? { observedAt } : {}),
|
|
170
|
-
...(extraction?.length ? { extraction } : {}),
|
|
171
|
-
}));
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
/** The record ids one provenance string keys once its tags have been relabelled
|
|
175
|
-
* for the wire. Two stores file one assertion under two ids — here under the
|
|
176
|
-
* chat session that taught it, at the peer under the node that sent it — so a
|
|
177
|
-
* retraction that named only the ids it sees locally would bite on one store
|
|
178
|
-
* and miss the other. Naming both is what makes it land either way. */
|
|
179
|
-
function broadcastRecordIds(groupId, provenance, identity, fallbackTimestamp) {
|
|
180
|
-
const ids = [];
|
|
181
|
-
for (const { tag } of wireProvenanceTags(provenance, identity, fallbackTimestamp)) {
|
|
182
|
-
if (tag) ids.push(factRecordIdForTag(groupId, tag));
|
|
183
|
-
}
|
|
184
|
-
return ids;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** A retraction goes out as ONE fact carrying every tag, not one per tag. The
|
|
188
|
-
* per-tag split exists because appendFacts unions an incoming tag against the
|
|
189
|
-
* ones it stores; a retraction merges through its own union instead, and the
|
|
190
|
-
* tags it carries are already relabel fixpoints. */
|
|
191
|
-
const toWireRetraction = (record, identity, fallbackTimestamp) => {
|
|
192
|
-
const groupId = factGroupId(record.subject);
|
|
193
|
-
const { retractedAt, ids } = decodeRetractionValue(record.object);
|
|
194
|
-
const tags = wireProvenanceTags(record.provenance, identity, fallbackTimestamp)
|
|
195
|
-
.map(({ tag }) => tag)
|
|
196
|
-
.filter(Boolean);
|
|
197
|
-
return {
|
|
198
|
-
subject: record.subject,
|
|
199
|
-
predicate: RETRACTION_PREDICATE,
|
|
200
|
-
object: encodeRetractionValue(retractedAt, [
|
|
201
|
-
...ids,
|
|
202
|
-
...broadcastRecordIds(groupId, record.provenance, identity, fallbackTimestamp),
|
|
203
|
-
]),
|
|
204
|
-
provenance: tags.join(" | "),
|
|
205
|
-
};
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
/** A room: one shared world, one local fact store, and however many direct
|
|
209
|
-
* peer connections have been made into it.
|
|
210
|
-
*
|
|
211
|
-
* `syncableFacts(rows) -> rows` is injected because a chat room and a mud
|
|
212
|
-
* room disagree about which facts are worth replicating, and this module has
|
|
213
|
-
* no business knowing which one it is serving. It gates both directions: what
|
|
214
|
-
* a sync response carries out, and what an incoming batch is allowed to merge.
|
|
215
|
-
*/
|
|
216
|
-
export function createP2pRoom({
|
|
217
|
-
memoryDir,
|
|
218
|
-
myPeerId,
|
|
219
|
-
myDisplayName,
|
|
220
|
-
// Optional. A browser page carries its node id across reloads itself (the
|
|
221
|
-
// in-memory store it rebuilds each visit cannot), so it passes the id it
|
|
222
|
-
// kept; a store that already holds one keeps that instead. Omitted, the
|
|
223
|
-
// room mints one onto the store the first time it joins a room.
|
|
224
|
-
myNodeId = "",
|
|
225
|
-
worldId,
|
|
226
|
-
worldName,
|
|
227
|
-
transportFactory,
|
|
228
|
-
syncableFacts,
|
|
229
|
-
now: rawNow = () => new Date().toISOString(),
|
|
230
|
-
}) {
|
|
231
|
-
// Wall-clock resolution is 1ms; two facts minted in the same tick (a
|
|
232
|
-
// double-click wave, two rapid local writes) would otherwise share one
|
|
233
|
-
// timestamp — and since the diff key is (id, provenance), an identical
|
|
234
|
-
// provenance string for the same triple looks like no change at all, so
|
|
235
|
-
// the second write silently fails to broadcast. Monotonic nudging fixes
|
|
236
|
-
// that at the source rather than asking every caller to space calls out.
|
|
237
|
-
let lastTimestampMs = -Infinity;
|
|
238
|
-
const now = () => {
|
|
239
|
-
const raw = rawNow();
|
|
240
|
-
const ms = Date.parse(raw);
|
|
241
|
-
if (Number.isNaN(ms)) return raw; // an injected non-ISO now() is trusted as-is
|
|
242
|
-
const nudged = Math.max(ms, lastTimestampMs + 1);
|
|
243
|
-
lastTimestampMs = nudged;
|
|
244
|
-
return new Date(nudged).toISOString();
|
|
245
|
-
};
|
|
246
|
-
let displayName = myDisplayName;
|
|
247
|
-
let nodeId = myNodeId;
|
|
248
|
-
// What this node's own assertions go out under: the name people read, and
|
|
249
|
-
// the stable id that keys them. Read fresh per broadcast — a rename or a
|
|
250
|
-
// recast can move either between one turn and the next.
|
|
251
|
-
const wireIdentity = () => ({ displayName, nodeId });
|
|
252
|
-
let state = ROOM_IDLE;
|
|
253
|
-
let lastError = null;
|
|
254
|
-
let started = false;
|
|
255
|
-
let closed = false;
|
|
256
|
-
let droppedMessages = 0;
|
|
257
|
-
|
|
258
|
-
const peers = new Map(); // peerId -> { peerId, displayName, transport, connected }
|
|
259
|
-
const transportPeerId = new Map(); // transport -> peerId, for routing a reply back where it came from
|
|
260
|
-
const pendingIntros = new Map(); // peerId -> transport we minted an offer for and are awaiting an answer on
|
|
261
|
-
let pendingShare = null; // the transport behind the most recently minted invite link
|
|
262
|
-
|
|
263
|
-
// id -> the provenance string this room last saw on that fact. The diff key
|
|
264
|
-
// is (id, provenance) rather than id alone because a repeat wave re-asserts
|
|
265
|
-
// the SAME content-addressed triple and only unions a fresh tag onto it, so
|
|
266
|
-
// an id-only diff would never see the second wave. Recording a merged fact's
|
|
267
|
-
// post-merge provenance here immediately after appendFacts is what stops a
|
|
268
|
-
// fact that arrived from a peer being re-broadcast as if we had authored it.
|
|
269
|
-
const seenProvenanceById = new Map();
|
|
270
|
-
let cachedRows = [];
|
|
271
|
-
// A retraction is not a fact row — readFactRows can never return one, because
|
|
272
|
-
// the whole point of the record is that the rows it names are gone. So it
|
|
273
|
-
// gets its own cache and its own diff, keyed on what it actually carries: the
|
|
274
|
-
// ids it suppressed. Those grow by union as peers merge, and a grown record
|
|
275
|
-
// is a change worth broadcasting even though its provenance never moved.
|
|
276
|
-
let cachedRetractions = [];
|
|
277
|
-
const seenRetractionValueById = new Map();
|
|
278
|
-
const retractionDiffValue = (fact) => `${fact.provenance}\u0000${fact.object}`;
|
|
279
|
-
|
|
280
|
-
// Store-touching work runs one job at a time, in arrival order. Every path
|
|
281
|
-
// that reads or writes memoryDir/seenProvenanceById/cachedRows crosses at
|
|
282
|
-
// least one await, so without this a rebind could swap the store while a
|
|
283
|
-
// merge is half-written into the old one — the merged rows would then
|
|
284
|
-
// baseline against the wrong store, or vanish. Jobs queued behind a rebind
|
|
285
|
-
// land on the store the rebind installed.
|
|
286
|
-
let storeChain = Promise.resolve();
|
|
287
|
-
function withStore(job) {
|
|
288
|
-
const run = storeChain.then(job);
|
|
289
|
-
storeChain = run.then(() => {}, () => {});
|
|
290
|
-
return run;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const factsListeners = new Set();
|
|
294
|
-
const stateListeners = new Set();
|
|
295
|
-
const peersListeners = new Set();
|
|
296
|
-
|
|
297
|
-
const emit = (listeners, payload) => { for (const fn of listeners) fn(payload); };
|
|
298
|
-
|
|
299
|
-
function setState(next) {
|
|
300
|
-
if (state === next) return;
|
|
301
|
-
state = next;
|
|
302
|
-
emit(stateListeners, state);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// A channel that died mid-send is a disconnect, reported by its own onClose
|
|
306
|
-
// handler; it should never fail the turn that happened to be broadcasting.
|
|
307
|
-
function send(transport, message) {
|
|
308
|
-
try { transport.send(message); } catch { /* handled by onClose */ }
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const connectedPeers = () => [...peers.values()].filter((p) => p.connected);
|
|
312
|
-
|
|
313
|
-
function broadcast(message) {
|
|
314
|
-
for (const peer of connectedPeers()) send(peer.transport, message);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function relayTo(peerId, message) {
|
|
318
|
-
const peer = peers.get(peerId);
|
|
319
|
-
if (peer?.connected) send(peer.transport, message);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
async function refreshRows() {
|
|
323
|
-
const memory = await loadMemory(memoryDir);
|
|
324
|
-
cachedRows = readFactRows(memory);
|
|
325
|
-
cachedRetractions = readRetractions(memory);
|
|
326
|
-
return cachedRows;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
const toWire = (row, identity, timestamp) => (row.predicate === RETRACTION_PREDICATE
|
|
330
|
-
? [toWireRetraction(row, identity, timestamp)]
|
|
331
|
-
: toWireFacts(row, identity, timestamp));
|
|
332
|
-
|
|
333
|
-
/** Fold the broadcast form of this node's own retractions back into the store.
|
|
334
|
-
* A retraction made here names the ids this store files the assertion under;
|
|
335
|
-
* a peer relaying that same assertion back sends it under the id the RELABEL
|
|
336
|
-
* produces. Merging the broadcast form in is what makes the local refusal
|
|
337
|
-
* cover it, and it is a union, so it settles after one pass. */
|
|
338
|
-
async function alignOwnRetractions() {
|
|
339
|
-
if (!cachedRetractions.length) return;
|
|
340
|
-
const identity = wireIdentity();
|
|
341
|
-
const timestamp = now();
|
|
342
|
-
const expanded = cachedRetractions.map((record) => toWireRetraction(record, identity, timestamp));
|
|
343
|
-
if (expanded.every((wire, i) => wire.object === cachedRetractions[i].object)) return;
|
|
344
|
-
await appendRetractions(memoryDir, expanded);
|
|
345
|
-
await refreshRows();
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/** The other side of the same re-keying: a retraction that names this node's
|
|
349
|
-
* broadcast id for an assertion also names whatever id this store files that
|
|
350
|
-
* same assertion under. Without it a peer could retract a fact it learned
|
|
351
|
-
* from here and the origin would keep reading its own copy. */
|
|
352
|
-
function localiseRetraction(fact) {
|
|
353
|
-
const groupId = factGroupId(fact.subject);
|
|
354
|
-
const { retractedAt, ids } = decodeRetractionValue(fact.object);
|
|
355
|
-
const named = new Set(ids);
|
|
356
|
-
const row = cachedRows.find((r) => r.id === groupId);
|
|
357
|
-
if (!row) return fact;
|
|
358
|
-
const identity = wireIdentity();
|
|
359
|
-
const timestamp = now();
|
|
360
|
-
const alsoNamed = [];
|
|
361
|
-
for (const assertion of row.assertions || []) {
|
|
362
|
-
if (named.has(assertion.id)) continue;
|
|
363
|
-
const asBroadcast = broadcastRecordIds(groupId, assertion.provenance, identity, timestamp);
|
|
364
|
-
if (asBroadcast.some((id) => named.has(id))) alsoNamed.push(assertion.id);
|
|
365
|
-
}
|
|
366
|
-
if (!alsoNamed.length) return fact;
|
|
367
|
-
return { ...fact, object: encodeRetractionValue(retractedAt, [...ids, ...alsoNamed]) };
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/** readFactRows walks `individuals` in array order and the fold reads them in
|
|
371
|
-
* that order, so two peers holding an identical fact set can still fold it
|
|
372
|
-
* differently while their arrival orders differ. Sorting the Fact
|
|
373
|
-
* individuals by content-addressed id after every merge makes that order the
|
|
374
|
-
* same on every peer. The sort is in place on the payload loadMemory
|
|
375
|
-
* returned, which for the in-memory backend a browser bundle uses is the
|
|
376
|
-
* live store itself. */
|
|
377
|
-
async function sortFactIndividualsById() {
|
|
378
|
-
const individuals = (await loadMemory(memoryDir))?.individuals;
|
|
379
|
-
if (!Array.isArray(individuals)) return;
|
|
380
|
-
const slots = [];
|
|
381
|
-
const facts = [];
|
|
382
|
-
for (let i = 0; i < individuals.length; i += 1) {
|
|
383
|
-
if (individuals[i]?.class !== FACT_CLASS) continue;
|
|
384
|
-
slots.push(i);
|
|
385
|
-
facts.push(individuals[i]);
|
|
386
|
-
}
|
|
387
|
-
// Codepoint order, never localeCompare: the whole point is that two peers
|
|
388
|
-
// in different locales land on the same order.
|
|
389
|
-
facts.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
390
|
-
for (let i = 0; i < slots.length; i += 1) individuals[slots[i]] = facts[i];
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
async function baselineSeen() {
|
|
394
|
-
await refreshRows();
|
|
395
|
-
for (const row of cachedRows) seenProvenanceById.set(row.id, row.provenance);
|
|
396
|
-
for (const fact of cachedRetractions) seenRetractionValueById.set(fact.id, retractionDiffValue(fact));
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
async function ensureStarted() {
|
|
400
|
-
if (started) return;
|
|
401
|
-
started = true;
|
|
402
|
-
// Joining a room is the first moment this store needs an identity other
|
|
403
|
-
// peers can key on, so it is where the node id is minted.
|
|
404
|
-
nodeId = await resolveStoreNodeId(memoryDir, myNodeId);
|
|
405
|
-
// Every fact already on disk is history, not something this session
|
|
406
|
-
// authored, so it is baselined as seen. A joiner gets it through sync
|
|
407
|
-
// instead, which is what the sync filter exists to size down.
|
|
408
|
-
await baselineSeen();
|
|
409
|
-
const timestamp = now();
|
|
410
|
-
const identity = [];
|
|
411
|
-
if (worldId && worldName) identity.push(worldNameFact(worldId, worldName, timestamp));
|
|
412
|
-
if (myPeerId && displayName) identity.push(nodeNameFact(myPeerId, displayName, timestamp));
|
|
413
|
-
if (identity.length) {
|
|
414
|
-
await appendFacts(memoryDir, identity);
|
|
415
|
-
await sortFactIndividualsById();
|
|
416
|
-
await refreshRows();
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/** Diff the store against what this room last saw, relabel each changed
|
|
421
|
-
* fact's provenance for the wire, and broadcast the batch. The page calls
|
|
422
|
-
* this (as `afterLocalChange`) once after every local turn or action. */
|
|
423
|
-
async function flushLocalChange() {
|
|
424
|
-
await ensureStarted();
|
|
425
|
-
await refreshRows();
|
|
426
|
-
await alignOwnRetractions();
|
|
427
|
-
const changed = [];
|
|
428
|
-
for (const row of cachedRows) {
|
|
429
|
-
if (seenProvenanceById.get(row.id) === row.provenance) continue;
|
|
430
|
-
seenProvenanceById.set(row.id, row.provenance);
|
|
431
|
-
changed.push(row);
|
|
432
|
-
}
|
|
433
|
-
for (const fact of cachedRetractions) {
|
|
434
|
-
const value = retractionDiffValue(fact);
|
|
435
|
-
if (seenRetractionValueById.get(fact.id) === value) continue;
|
|
436
|
-
seenRetractionValueById.set(fact.id, value);
|
|
437
|
-
changed.push(fact);
|
|
438
|
-
}
|
|
439
|
-
if (!changed.length) return { broadcast: 0 };
|
|
440
|
-
const targets = connectedPeers();
|
|
441
|
-
if (!targets.length) return { broadcast: 0 };
|
|
442
|
-
const timestamp = now();
|
|
443
|
-
const facts = changed.flatMap((row) => toWire(row, wireIdentity(), timestamp));
|
|
444
|
-
broadcast(opMessage({ from: myPeerId, facts }));
|
|
445
|
-
return { broadcast: changed.length };
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
async function mergeIncomingFacts(incoming) {
|
|
449
|
-
const accepted = syncableFacts((incoming || []).filter(isFactShaped));
|
|
450
|
-
if (!accepted.length) return { merged: 0 };
|
|
451
|
-
// Flush first: a local fact still waiting to be diffed would otherwise be
|
|
452
|
-
// recorded as merged below and never leave this browser.
|
|
453
|
-
await flushLocalChange();
|
|
454
|
-
// Retractions land BEFORE the assertions in the same batch, so a peer
|
|
455
|
-
// sending both the fact and the record that suppresses it lands them in the
|
|
456
|
-
// order that leaves the fact out. Either order converges — the read fold
|
|
457
|
-
// strips a record a retraction covers however late the retraction arrives —
|
|
458
|
-
// but doing it this way keeps a suppressed record from being written at all.
|
|
459
|
-
const retractions = accepted.filter((f) => f.predicate === RETRACTION_PREDICATE);
|
|
460
|
-
if (retractions.length) await appendRetractions(memoryDir, retractions.map(localiseRetraction));
|
|
461
|
-
const assertions = retractions.length
|
|
462
|
-
? accepted.filter((f) => f.predicate !== RETRACTION_PREDICATE)
|
|
463
|
-
: accepted;
|
|
464
|
-
const { ids } = assertions.length ? await appendFacts(memoryDir, assertions.map((f) => ({
|
|
465
|
-
subject: f.subject,
|
|
466
|
-
predicate: f.predicate,
|
|
467
|
-
object: f.object,
|
|
468
|
-
provenance: typeof f.provenance === "string" ? f.provenance : "",
|
|
469
|
-
// Presence-wins on an id collision with equal assertion timestamps is
|
|
470
|
-
// already the join appendFacts' own supersedesPriorAssertion performs —
|
|
471
|
-
// this is the passthrough that lets a dated taught fact's wire fact
|
|
472
|
-
// reach it at all.
|
|
473
|
-
...(typeof f.observedAt === "string" && f.observedAt ? { observedAt: f.observedAt } : {}),
|
|
474
|
-
// Same passthrough for the extraction findings the origin recorded on
|
|
475
|
-
// this same assertion — appendFacts dedupes and sorts them the same way
|
|
476
|
-
// a local write does, so a merged record reads back exactly as its
|
|
477
|
-
// origin stored it.
|
|
478
|
-
...(Array.isArray(f.extraction) && f.extraction.length ? { extraction: f.extraction } : {}),
|
|
479
|
-
}))) : { ids: [] };
|
|
480
|
-
await sortFactIndividualsById();
|
|
481
|
-
await refreshRows();
|
|
482
|
-
const mergedIds = new Set(ids);
|
|
483
|
-
for (const row of cachedRows) {
|
|
484
|
-
if (mergedIds.has(row.id)) seenProvenanceById.set(row.id, row.provenance);
|
|
485
|
-
}
|
|
486
|
-
// Same reason the fact baseline above exists: a retraction that arrived
|
|
487
|
-
// from a peer must not go back out as though this node had authored it.
|
|
488
|
-
// Every peer broadcasts its OWN record when it makes one, so the union each
|
|
489
|
-
// peer ends up with is reached from the originals rather than from relays.
|
|
490
|
-
for (const fact of cachedRetractions) seenRetractionValueById.set(fact.id, retractionDiffValue(fact));
|
|
491
|
-
const merged = ids.length + retractions.length;
|
|
492
|
-
emit(factsListeners, { merged, rows: cachedRows });
|
|
493
|
-
return { merged };
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
function registerPeer(peerId, peerDisplayName, transport) {
|
|
497
|
-
const existing = peers.get(peerId);
|
|
498
|
-
if (existing?.connected && existing.transport !== transport) {
|
|
499
|
-
// One direct channel per peer. A second one means both sides opened an
|
|
500
|
-
// introduction at once; drop the newcomer and keep the live channel.
|
|
501
|
-
transport.close();
|
|
502
|
-
return { accepted: false, freshlyConnected: false };
|
|
503
|
-
}
|
|
504
|
-
const freshlyConnected = !existing?.connected;
|
|
505
|
-
peers.set(peerId, { peerId, displayName: peerDisplayName, transport, connected: true });
|
|
506
|
-
transportPeerId.set(transport, peerId);
|
|
507
|
-
pendingIntros.delete(peerId);
|
|
508
|
-
setState(ROOM_CONNECTED);
|
|
509
|
-
emit(peersListeners, peerList());
|
|
510
|
-
return { accepted: true, freshlyConnected };
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
async function requestIntroduction(targetPeerId, viaTransport) {
|
|
514
|
-
const transport = transportFactory();
|
|
515
|
-
pendingIntros.set(targetPeerId, transport);
|
|
516
|
-
attachTransport(transport);
|
|
517
|
-
const sdp = await transport.createOffer();
|
|
518
|
-
send(viaTransport, introOfferMessage({ from: myPeerId, to: targetPeerId, sdp }));
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
async function handleMessage(transport, message) {
|
|
522
|
-
if (closed) return;
|
|
523
|
-
if (!isValidRoomMessage(message)) { droppedMessages += 1; return; }
|
|
524
|
-
switch (message.type) {
|
|
525
|
-
case "hello": {
|
|
526
|
-
if (message.peerId === myPeerId) return;
|
|
527
|
-
const { accepted, freshlyConnected } = registerPeer(message.peerId, message.displayName, transport);
|
|
528
|
-
if (!accepted || !freshlyConnected) return;
|
|
529
|
-
const others = connectedPeers()
|
|
530
|
-
.filter((p) => p.peerId !== message.peerId)
|
|
531
|
-
.map((p) => ({ peerId: p.peerId, displayName: p.displayName }));
|
|
532
|
-
send(transport, peerListMessage({ peers: others }));
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
case "peer-list": {
|
|
536
|
-
for (const entry of message.peers) {
|
|
537
|
-
if (entry.peerId === myPeerId) continue;
|
|
538
|
-
if (peers.get(entry.peerId)?.connected) continue;
|
|
539
|
-
if (pendingIntros.has(entry.peerId)) continue;
|
|
540
|
-
await requestIntroduction(entry.peerId, transport);
|
|
541
|
-
}
|
|
542
|
-
return;
|
|
543
|
-
}
|
|
544
|
-
// intro-offer and intro-answer are the only messages this room ever
|
|
545
|
-
// forwards, and only while two peers who have never met are exchanging
|
|
546
|
-
// SDP through the one peer that already reaches both. Once their own
|
|
547
|
-
// channel opens they talk directly; no fact traffic is ever relayed.
|
|
548
|
-
case "intro-offer": {
|
|
549
|
-
if (message.to !== myPeerId) { relayTo(message.to, message); return; }
|
|
550
|
-
if (message.from === myPeerId) return;
|
|
551
|
-
if (peers.get(message.from)?.connected) return;
|
|
552
|
-
const answering = transportFactory();
|
|
553
|
-
attachTransport(answering);
|
|
554
|
-
const sdp = await answering.createAnswerFor(message.sdp);
|
|
555
|
-
send(transport, introAnswerMessage({ from: myPeerId, to: message.from, sdp }));
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
case "intro-answer": {
|
|
559
|
-
if (message.to !== myPeerId) { relayTo(message.to, message); return; }
|
|
560
|
-
const pending = pendingIntros.get(message.from);
|
|
561
|
-
if (!pending) return;
|
|
562
|
-
await pending.completeWithAnswer(message.sdp);
|
|
563
|
-
return;
|
|
564
|
-
}
|
|
565
|
-
case "sync-request": {
|
|
566
|
-
const facts = await withStore(async () => {
|
|
567
|
-
await refreshRows();
|
|
568
|
-
await alignOwnRetractions();
|
|
569
|
-
const timestamp = now();
|
|
570
|
-
return syncableFacts([...cachedRows, ...cachedRetractions])
|
|
571
|
-
.flatMap((row) => toWire(row, wireIdentity(), timestamp));
|
|
572
|
-
});
|
|
573
|
-
send(transport, syncResponseMessage({ facts }));
|
|
574
|
-
return;
|
|
575
|
-
}
|
|
576
|
-
// A historical sync response and a live op merge the same way, through
|
|
577
|
-
// the same filter, because merging is idempotent by id — the overlap
|
|
578
|
-
// between them needs no sequencing.
|
|
579
|
-
case "sync-response":
|
|
580
|
-
case "op":
|
|
581
|
-
await withStore(() => mergeIncomingFacts(message.facts));
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
function attachTransport(transport) {
|
|
586
|
-
// A peer can send anything, including an intro-offer carrying an SDP that
|
|
587
|
-
// makes the transport reject. That is a dropped message, not a crashed room.
|
|
588
|
-
transport.onMessage((data) => {
|
|
589
|
-
handleMessage(transport, data).catch(() => { droppedMessages += 1; });
|
|
590
|
-
});
|
|
591
|
-
transport.onOpen(() => {
|
|
592
|
-
if (closed) return;
|
|
593
|
-
setState(ROOM_CONNECTED);
|
|
594
|
-
send(transport, helloMessage({ peerId: myPeerId, displayName }));
|
|
595
|
-
// Sync starts the moment the channel opens, before any peer identity is
|
|
596
|
-
// known. Merging is idempotent by id, so overlap between the historical
|
|
597
|
-
// response and live ops needs no sequencing.
|
|
598
|
-
send(transport, syncRequestMessage());
|
|
599
|
-
});
|
|
600
|
-
transport.onClose(() => {
|
|
601
|
-
const peerId = transportPeerId.get(transport);
|
|
602
|
-
transportPeerId.delete(transport);
|
|
603
|
-
if (peerId) {
|
|
604
|
-
const peer = peers.get(peerId);
|
|
605
|
-
// A peer that has gone stays in the list marked away: its facts and its
|
|
606
|
-
// node name are still part of the graph, and rejoining is a fresh invite.
|
|
607
|
-
if (peer?.transport === transport) peers.set(peerId, { ...peer, connected: false });
|
|
608
|
-
emit(peersListeners, peerList());
|
|
609
|
-
}
|
|
610
|
-
if (pendingShare === transport) pendingShare = null;
|
|
611
|
-
for (const [target, pending] of pendingIntros) if (pending === transport) pendingIntros.delete(target);
|
|
612
|
-
if (!connectedPeers().length && state === ROOM_CONNECTING) setState(ROOM_FAILED);
|
|
613
|
-
});
|
|
614
|
-
return transport;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
const peerList = () => [...peers.values()].map((p) => ({
|
|
618
|
-
peerId: p.peerId,
|
|
619
|
-
displayName: p.displayName,
|
|
620
|
-
connected: p.connected,
|
|
621
|
-
}));
|
|
622
|
-
|
|
623
|
-
/** Mint a fresh invite blob. One blob completes exactly one connection, so
|
|
624
|
-
* each call replaces whatever earlier invite was still waiting for a reply. */
|
|
625
|
-
async function startSharing() {
|
|
626
|
-
await withStore(ensureStarted);
|
|
627
|
-
const transport = attachTransport(transportFactory());
|
|
628
|
-
pendingShare = transport;
|
|
629
|
-
lastError = null;
|
|
630
|
-
const sdp = await transport.createOffer();
|
|
631
|
-
setState(ROOM_SHARING);
|
|
632
|
-
return { blob: encodeInviteBlob({ kind: "offer", sdp, world: worldId, worldName, node: nodeId }) };
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
/** Decode someone's invite and answer it. Returns the reply blob to send
|
|
636
|
-
* back, or a named problem the page can show beside the box it came from. */
|
|
637
|
-
async function acceptInvite(blobString) {
|
|
638
|
-
await withStore(ensureStarted);
|
|
639
|
-
const decoded = decodeInviteBlob(blobString);
|
|
640
|
-
if (decoded.error) {
|
|
641
|
-
lastError = problem("invite", decoded.error);
|
|
642
|
-
return lastError;
|
|
643
|
-
}
|
|
644
|
-
if (decoded.value.kind !== "offer") {
|
|
645
|
-
lastError = problem("invite", "wrong-kind");
|
|
646
|
-
return lastError;
|
|
647
|
-
}
|
|
648
|
-
if (worldId && decoded.value.world !== worldId) {
|
|
649
|
-
lastError = problem("invite", "wrong-world");
|
|
650
|
-
return { ...lastError, world: decoded.value.world, worldName: decoded.value.worldName };
|
|
651
|
-
}
|
|
652
|
-
const transport = attachTransport(transportFactory());
|
|
653
|
-
lastError = null;
|
|
654
|
-
const sdp = await transport.createAnswerFor(decoded.value.sdp);
|
|
655
|
-
await recordInviteEdge(decoded.value.node);
|
|
656
|
-
setState(ROOM_ANSWERING);
|
|
657
|
-
return {
|
|
658
|
-
blob: encodeInviteBlob({ kind: "reply", sdp }),
|
|
659
|
-
world: decoded.value.world,
|
|
660
|
-
worldName: decoded.value.worldName,
|
|
661
|
-
};
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
/** Write the admission edge for the invite this node just answered. Only the
|
|
665
|
-
* joiner can: the inviter does not learn the joining node's id until the
|
|
666
|
-
* channel is already open, and by then the edge would be indistinguishable
|
|
667
|
-
* from any other fact that arrived over it. An invite from a build that
|
|
668
|
-
* predates the node field carries no inviter to record, and an invite a node
|
|
669
|
-
* answers with its own id is no admission at all. */
|
|
670
|
-
async function recordInviteEdge(inviterNodeId) {
|
|
671
|
-
if (typeof inviterNodeId !== "string" || !inviterNodeId) return;
|
|
672
|
-
if (!nodeId || inviterNodeId === nodeId) return;
|
|
673
|
-
await withStore(async () => {
|
|
674
|
-
await appendFacts(memoryDir, [invitedByFact(nodeId, inviterNodeId, now())]);
|
|
675
|
-
await sortFactIndividualsById();
|
|
676
|
-
await refreshRows();
|
|
677
|
-
});
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
/** Feed the joiner's reply back into the invite it answers, completing the
|
|
681
|
-
* connection. */
|
|
682
|
-
async function completeInvite(replyBlob) {
|
|
683
|
-
await withStore(ensureStarted);
|
|
684
|
-
if (!pendingShare) {
|
|
685
|
-
lastError = problem("reply", "no-pending-invite");
|
|
686
|
-
return lastError;
|
|
687
|
-
}
|
|
688
|
-
const decoded = decodeInviteBlob(replyBlob);
|
|
689
|
-
if (decoded.error) {
|
|
690
|
-
lastError = problem("reply", decoded.error);
|
|
691
|
-
return lastError;
|
|
692
|
-
}
|
|
693
|
-
if (decoded.value.kind !== "reply") {
|
|
694
|
-
lastError = problem("reply", "wrong-kind");
|
|
695
|
-
return lastError;
|
|
696
|
-
}
|
|
697
|
-
const transport = pendingShare;
|
|
698
|
-
pendingShare = null;
|
|
699
|
-
lastError = null;
|
|
700
|
-
setState(ROOM_CONNECTING);
|
|
701
|
-
try {
|
|
702
|
-
await transport.completeWithAnswer(decoded.value.sdp);
|
|
703
|
-
} catch {
|
|
704
|
-
setState(ROOM_FAILED);
|
|
705
|
-
lastError = problem("reply", "connect-failed");
|
|
706
|
-
return lastError;
|
|
707
|
-
}
|
|
708
|
-
return { ok: true };
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
async function setMyDisplayName(name) {
|
|
712
|
-
return withStore(async () => {
|
|
713
|
-
await ensureStarted();
|
|
714
|
-
displayName = name;
|
|
715
|
-
await appendFacts(memoryDir, [nodeNameFact(myPeerId, name, now())]);
|
|
716
|
-
await sortFactIndividualsById();
|
|
717
|
-
return flushLocalChange();
|
|
718
|
-
});
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
/** Wave as `subjectId`, in `roomId` when there is one. chat.html's presence
|
|
722
|
-
* wave passes null and lands on PRESENCE_SCOPE instead. */
|
|
723
|
-
async function wave(subjectId, roomId = null) {
|
|
724
|
-
return withStore(async () => {
|
|
725
|
-
await ensureStarted();
|
|
726
|
-
await appendFacts(memoryDir, [waveFact(subjectId, roomId || PRESENCE_SCOPE, now())]);
|
|
727
|
-
await sortFactIndividualsById();
|
|
728
|
-
return flushLocalChange();
|
|
729
|
-
});
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
/** Whether `subjectId` is waving right now, read from the cached rows so a
|
|
733
|
-
* renderer can call it every frame. A stored row holds the normalized term,
|
|
734
|
-
* so a caller's raw id is normalized the same way before it's compared. */
|
|
735
|
-
function isWaving(subjectId, nowMs = Date.now(), windowMs) {
|
|
736
|
-
const subject = normFactTerm(subjectId);
|
|
737
|
-
return cachedRows.some((row) => row.subject === subject
|
|
738
|
-
&& row.predicate === WAVED_PREDICATE
|
|
739
|
-
&& isRecentWave(row, nowMs, windowMs));
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
/** A peer's own chosen node name, from its latest nodeName fact, falling
|
|
743
|
-
* back to the name it introduced itself with and then to a short peer id.
|
|
744
|
-
* A label never waits for a name fact to arrive. */
|
|
745
|
-
function displayNameFor(peerId) {
|
|
746
|
-
const fact = latestFact(cachedRows, normFactTerm(`peer:${peerId}`), NODE_NAME_PREDICATE);
|
|
747
|
-
if (fact?.object) return fact.object;
|
|
748
|
-
return peers.get(peerId)?.displayName || String(peerId).slice(0, 8);
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
/** Re-bind this room to a fresh store — the recast path. The peer
|
|
752
|
-
* connections are the point of keeping the room alive, so nothing about
|
|
753
|
-
* the transports or the peer map is touched. In order: flush whatever the
|
|
754
|
-
* OLD store still had undiffed (a turn landed just before the recast must
|
|
755
|
-
* not vanish silently), swap the store reference, rebuild the diff
|
|
756
|
-
* baseline against the new store, write the identity facts into it, then
|
|
757
|
-
* push the new store's syncable facts to every open channel as an
|
|
758
|
-
* ordinary op and ask each peer for its own view with an ordinary
|
|
759
|
-
* sync-request — the exact machinery a freshly opened channel uses, no
|
|
760
|
-
* new message type. Runs on the store chain, so a merge in flight when
|
|
761
|
-
* the recast happens finishes against the store it started on, and
|
|
762
|
-
* everything behind it lands on the new one. */
|
|
763
|
-
async function rebind({ memoryDir: nextMemoryDir, worldName: nextWorldName, myDisplayName: nextDisplayName } = {}) {
|
|
764
|
-
if (!nextMemoryDir) throw new Error("rebind needs the store to bind to");
|
|
765
|
-
return withStore(async () => {
|
|
766
|
-
if (closed) throw new Error("this room is closed");
|
|
767
|
-
if (started) await flushLocalChange();
|
|
768
|
-
memoryDir = nextMemoryDir;
|
|
769
|
-
if (nextWorldName) worldName = nextWorldName;
|
|
770
|
-
if (nextDisplayName) displayName = nextDisplayName;
|
|
771
|
-
started = true;
|
|
772
|
-
// The store changed, so the node id is re-resolved against the new one.
|
|
773
|
-
// A recast store that has never joined a room adopts the id this node
|
|
774
|
-
// has been broadcasting under all along, which is what keeps a recast
|
|
775
|
-
// from reading to every peer as a brand-new node.
|
|
776
|
-
nodeId = await resolveStoreNodeId(memoryDir, nodeId || myNodeId);
|
|
777
|
-
seenProvenanceById.clear();
|
|
778
|
-
seenRetractionValueById.clear();
|
|
779
|
-
cachedRows = [];
|
|
780
|
-
cachedRetractions = [];
|
|
781
|
-
const timestamp = now();
|
|
782
|
-
const identity = [];
|
|
783
|
-
if (worldId && worldName) identity.push(worldNameFact(worldId, worldName, timestamp));
|
|
784
|
-
if (myPeerId && displayName) identity.push(nodeNameFact(myPeerId, displayName, timestamp));
|
|
785
|
-
if (identity.length) {
|
|
786
|
-
await appendFacts(memoryDir, identity);
|
|
787
|
-
await sortFactIndividualsById();
|
|
788
|
-
}
|
|
789
|
-
await refreshRows();
|
|
790
|
-
for (const row of cachedRows) seenProvenanceById.set(row.id, row.provenance);
|
|
791
|
-
for (const fact of cachedRetractions) seenRetractionValueById.set(fact.id, retractionDiffValue(fact));
|
|
792
|
-
const targets = connectedPeers();
|
|
793
|
-
let pushed = 0;
|
|
794
|
-
if (targets.length) {
|
|
795
|
-
const wireTimestamp = now();
|
|
796
|
-
const facts = syncableFacts([...cachedRows, ...cachedRetractions])
|
|
797
|
-
.flatMap((row) => toWire(row, wireIdentity(), wireTimestamp));
|
|
798
|
-
pushed = facts.length;
|
|
799
|
-
if (facts.length) broadcast(opMessage({ from: myPeerId, facts }));
|
|
800
|
-
broadcast(syncRequestMessage());
|
|
801
|
-
}
|
|
802
|
-
emit(factsListeners, { merged: 0, rows: cachedRows });
|
|
803
|
-
return { pushed, peers: targets.length };
|
|
804
|
-
});
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
function close() {
|
|
808
|
-
closed = true;
|
|
809
|
-
for (const peer of peers.values()) peer.transport.close();
|
|
810
|
-
if (pendingShare) pendingShare.close();
|
|
811
|
-
for (const pending of pendingIntros.values()) pending.close();
|
|
812
|
-
pendingIntros.clear();
|
|
813
|
-
pendingShare = null;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
const subscribe = (listeners) => (handler) => {
|
|
817
|
-
listeners.add(handler);
|
|
818
|
-
return () => listeners.delete(handler);
|
|
819
|
-
};
|
|
820
|
-
|
|
821
|
-
return {
|
|
822
|
-
peerId: myPeerId,
|
|
823
|
-
worldId,
|
|
824
|
-
get worldName() { return worldName; },
|
|
825
|
-
get displayName() { return displayName; },
|
|
826
|
-
get nodeId() { return nodeId; },
|
|
827
|
-
get state() { return state; },
|
|
828
|
-
get lastError() { return lastError; },
|
|
829
|
-
get droppedMessages() { return droppedMessages; },
|
|
830
|
-
peers: peerList,
|
|
831
|
-
displayNameFor,
|
|
832
|
-
start: () => withStore(ensureStarted),
|
|
833
|
-
startSharing,
|
|
834
|
-
acceptInvite,
|
|
835
|
-
completeInvite,
|
|
836
|
-
afterLocalChange: () => withStore(flushLocalChange),
|
|
837
|
-
setMyDisplayName,
|
|
838
|
-
wave,
|
|
839
|
-
isWaving,
|
|
840
|
-
rebind,
|
|
841
|
-
factRows: () => cachedRows,
|
|
842
|
-
refresh: () => withStore(refreshRows),
|
|
843
|
-
onFactsChanged: subscribe(factsListeners),
|
|
844
|
-
onStateChanged: subscribe(stateListeners),
|
|
845
|
-
onPeersChanged: subscribe(peersListeners),
|
|
846
|
-
close,
|
|
847
|
-
};
|
|
848
|
-
}
|