@mh-gg/relay-runtime 0.1.1-alpha.20260613T085325975Z
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/package.json +26 -0
- package/src/encryptedRoomRuntime.cjs +641 -0
- package/src/encryptedRoomRuntimeManager.cjs +131 -0
- package/src/index.cjs +152 -0
- package/src/pluginRuntimeHost.cjs +779 -0
- package/test/encryptedRoomRuntime.test.cjs +729 -0
- package/test/operation-role-keys.test.cjs +346 -0
- package/test/plugin-runtime-manager.test.cjs +651 -0
- package/test/relay-runtime.test.cjs +219 -0
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mh-gg/relay-runtime",
|
|
3
|
+
"version": "0.1.1-alpha.20260613T085325975Z",
|
|
4
|
+
"description": "Relay runtime helpers for matterhorn.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "src/index.cjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.cjs"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@mh-gg/relay-core": "^0.1.1-alpha.20260613T085325975Z",
|
|
12
|
+
"@mh-gg/host-runtime": "^0.1.1-alpha.20260613T085325975Z",
|
|
13
|
+
"@mh-gg/authority": "^0.1.1-alpha.20260603T124500153Z",
|
|
14
|
+
"@mh-gg/base": "^0.1.1-alpha.20260613T085325975Z",
|
|
15
|
+
"@mh-gg/protocol": "^0.1.1-alpha.20260613T085325975Z",
|
|
16
|
+
"@mh-gg/room-security": "^0.1.1-alpha.20260613T085325975Z",
|
|
17
|
+
"@mh-gg/room-state": "^0.1.1-alpha.20260613T085325975Z"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22.12"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test test/*.test.cjs",
|
|
24
|
+
"coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=80 --test-coverage-include=src/*.cjs test/*.test.cjs"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
const { parseHlc } = require("@mh-gg/protocol");
|
|
2
|
+
const { createStreamNegentropySession } = require("@mh-gg/relay-core");
|
|
3
|
+
|
|
4
|
+
const ROOM_INDEX_NGRAM_KIND = 9013;
|
|
5
|
+
const ROOM_INDEX_NGRAM_PAYLOAD_KIND = "matterhorn.room-index-ngram";
|
|
6
|
+
const ROOM_INDEX_NGRAM_SUITE = "matterhorn.ngram.hmac-sha256.v1";
|
|
7
|
+
|
|
8
|
+
function tagValue(tags, name) {
|
|
9
|
+
const tag = Array.isArray(tags) ? tags.find((item) => Array.isArray(item) && item[0] === name) : undefined;
|
|
10
|
+
return tag ? tag[1] : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function tagNumber(tags, name) {
|
|
14
|
+
const raw = tagValue(tags, name);
|
|
15
|
+
const value = Number(raw);
|
|
16
|
+
return Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseStreamHeader(nostrEvent) {
|
|
20
|
+
const tags = nostrEvent?.tags;
|
|
21
|
+
if (!Array.isArray(tags)) return undefined;
|
|
22
|
+
const stream = tagValue(tags, "stream");
|
|
23
|
+
const hlc = tagValue(tags, "hlc");
|
|
24
|
+
if (!stream || !hlc) return undefined;
|
|
25
|
+
const parsedHlc = parseHlc(hlc);
|
|
26
|
+
if (!parsedHlc) return undefined;
|
|
27
|
+
return {
|
|
28
|
+
stream,
|
|
29
|
+
hlc,
|
|
30
|
+
writer: tagValue(tags, "member") || nostrEvent?.pubkey || "",
|
|
31
|
+
device: tagValue(tags, "device") || "",
|
|
32
|
+
seq: tagNumber(tags, "seq") || 0,
|
|
33
|
+
opId: tagValue(tags, "op-id") || "",
|
|
34
|
+
plugin: tagValue(tags, "plugin") || "",
|
|
35
|
+
type: tagValue(tags, "type") || "",
|
|
36
|
+
action: tagValue(tags, "action") || "",
|
|
37
|
+
role: tagValue(tags, "role") || "",
|
|
38
|
+
epoch: tagValue(tags, "epoch") || ""
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function compareIndexEntries(left, right) {
|
|
43
|
+
const hlcOrder = String(left.hlc).localeCompare(String(right.hlc));
|
|
44
|
+
if (hlcOrder !== 0) return hlcOrder;
|
|
45
|
+
const writerOrder = String(left.writer).localeCompare(String(right.writer));
|
|
46
|
+
if (writerOrder !== 0) return writerOrder;
|
|
47
|
+
const seqDiff = (left.seq || 0) - (right.seq || 0);
|
|
48
|
+
if (seqDiff !== 0) return seqDiff;
|
|
49
|
+
return String(left.eventId).localeCompare(String(right.eventId));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function binarySearchInsert(array, entry, compare) {
|
|
53
|
+
let low = 0;
|
|
54
|
+
let high = array.length;
|
|
55
|
+
while (low < high) {
|
|
56
|
+
const mid = Math.floor((low + high) / 2);
|
|
57
|
+
const cmp = compare(entry, array[mid]);
|
|
58
|
+
if (cmp === 0) return mid;
|
|
59
|
+
if (cmp > 0) low = mid + 1;
|
|
60
|
+
else high = mid;
|
|
61
|
+
}
|
|
62
|
+
array.splice(low, 0, entry);
|
|
63
|
+
return low;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createEncryptedRoomRuntime(options = {}) {
|
|
67
|
+
const roomName = options.roomName;
|
|
68
|
+
const logger = options.logger || { debug() {}, info() {}, warn() {}, error() {} };
|
|
69
|
+
const maxEventsPerStream = Number.isInteger(options.maxEventsPerStream) && options.maxEventsPerStream > 0
|
|
70
|
+
? options.maxEventsPerStream
|
|
71
|
+
: 10000;
|
|
72
|
+
|
|
73
|
+
const events = new Map(); // eventId (hex) -> nostrEvent
|
|
74
|
+
const streams = new Map(); // stream -> sorted entries[]
|
|
75
|
+
const streamWriters = new Map(); // stream -> Set(writer)
|
|
76
|
+
const stats = { inserts: 0, duplicates: 0, evictions: 0, ngramIndexEvents: 0, ngramPostings: 0 };
|
|
77
|
+
|
|
78
|
+
// Control-plane key state (visible to relays in plaintext tags/payload).
|
|
79
|
+
const keyEpochs = new Map(); // epochId -> { id, index, createdAt, retiredAt, createdBy, historyVisibility }
|
|
80
|
+
let activeEpochId = null;
|
|
81
|
+
const epochGrants = new Map(); // epochId -> Map(recipientKeyId -> grantEventId)
|
|
82
|
+
const roomIndexKeyGrants = new Map(); // recipientKeyId -> grantEventId
|
|
83
|
+
let controlPlaneCounter = 0;
|
|
84
|
+
|
|
85
|
+
// Opaque, client-generated n-gram search indexes. Relays see deterministic
|
|
86
|
+
// HMAC tokens and posting-list shape, never plaintext terms or roomIndexKey.
|
|
87
|
+
const ngramIndex = new Map(); // `${suite}:${keyId}:${token}` -> Set(targetEventId)
|
|
88
|
+
const ngramTargets = new Map(); // targetEventId -> metadata + tokenKeys
|
|
89
|
+
const ngramIndexEvents = new Map(); // indexEventId -> { targetEventId, tokenKeys }
|
|
90
|
+
|
|
91
|
+
function entryForEvent(eventId, nostrEvent) {
|
|
92
|
+
const header = parseStreamHeader(nostrEvent);
|
|
93
|
+
if (!header) return undefined;
|
|
94
|
+
return { eventId, ...header };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseControlPlanePayload(nostrEvent) {
|
|
98
|
+
try {
|
|
99
|
+
const kind = nostrEvent?.kind;
|
|
100
|
+
if (kind !== 9010 && kind !== 9011 && kind !== 9012 && kind !== ROOM_INDEX_NGRAM_KIND) return undefined;
|
|
101
|
+
const content = JSON.parse(nostrEvent?.content || "{}");
|
|
102
|
+
if (!content || typeof content !== "object") return undefined;
|
|
103
|
+
return content;
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function foldKeyEpochEvent(nostrEvent) {
|
|
110
|
+
const payload = parseControlPlanePayload(nostrEvent);
|
|
111
|
+
if (!payload || payload.kind !== "room.key.epoch" || payload.version !== 1) return;
|
|
112
|
+
const previous = keyEpochs.get(payload.id);
|
|
113
|
+
const record = {
|
|
114
|
+
id: payload.id,
|
|
115
|
+
index: payload.index,
|
|
116
|
+
createdAt: payload.createdAt,
|
|
117
|
+
retiredAt: payload.retiredAt,
|
|
118
|
+
createdBy: payload.createdBy,
|
|
119
|
+
createdByDevice: payload.createdByDevice,
|
|
120
|
+
historyVisibility: payload.historyVisibility
|
|
121
|
+
};
|
|
122
|
+
keyEpochs.set(payload.id, record);
|
|
123
|
+
const active = activeEpochId ? keyEpochs.get(activeEpochId) : undefined;
|
|
124
|
+
if (!record.retiredAt && (!active || record.index > active.index)) {
|
|
125
|
+
activeEpochId = payload.id;
|
|
126
|
+
} else if (activeEpochId === payload.id && record.retiredAt) {
|
|
127
|
+
// Find next non-retired epoch by highest index
|
|
128
|
+
let next = null;
|
|
129
|
+
for (const epoch of keyEpochs.values()) {
|
|
130
|
+
if (!epoch.retiredAt && (!next || epoch.index > next.index)) next = epoch;
|
|
131
|
+
}
|
|
132
|
+
activeEpochId = next ? next.id : null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function foldKeyEpochGrantEvent(nostrEvent) {
|
|
137
|
+
const payload = parseControlPlanePayload(nostrEvent);
|
|
138
|
+
if (!payload || payload.kind !== "matterhorn.key-epoch-grant" || payload.version !== 1) return;
|
|
139
|
+
let grants = epochGrants.get(payload.epochId);
|
|
140
|
+
if (!grants) {
|
|
141
|
+
grants = new Map();
|
|
142
|
+
epochGrants.set(payload.epochId, grants);
|
|
143
|
+
}
|
|
144
|
+
grants.set(payload.recipientEncryptionKeyId, nostrEvent.id);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function foldRoomIndexKeyGrantEvent(nostrEvent) {
|
|
148
|
+
const payload = parseControlPlanePayload(nostrEvent);
|
|
149
|
+
if (!payload || payload.kind !== "matterhorn.room-index-key-grant" || payload.version !== 1) return;
|
|
150
|
+
roomIndexKeyGrants.set(payload.recipientEncryptionKeyId, nostrEvent.id);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
function validNgramToken(token) {
|
|
156
|
+
return typeof token === "string" && /^[A-Za-z0-9_-]{22,96}$/.test(token);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function ngramPostingKey(suite, keyId, token) {
|
|
160
|
+
return `${suite || ROOM_INDEX_NGRAM_SUITE}:${keyId || ""}:${token}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function eventStreamHeader(eventId) {
|
|
164
|
+
const event = events.get(eventId);
|
|
165
|
+
return event ? parseStreamHeader(event) : undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function targetMetadata(payload) {
|
|
169
|
+
const fromEvent = eventStreamHeader(payload.targetEventId);
|
|
170
|
+
return {
|
|
171
|
+
targetEventId: payload.targetEventId,
|
|
172
|
+
targetOperationId: payload.targetOperationId || fromEvent?.opId || "",
|
|
173
|
+
stream: payload.stream || fromEvent?.stream || "",
|
|
174
|
+
hlc: payload.targetHlc || fromEvent?.hlc || "",
|
|
175
|
+
suite: payload.suite || ROOM_INDEX_NGRAM_SUITE,
|
|
176
|
+
keyId: payload.keyId || "",
|
|
177
|
+
field: payload.field || "",
|
|
178
|
+
tokenCount: Number.isInteger(payload.tokenCount) ? payload.tokenCount : 0,
|
|
179
|
+
tokenDigest: payload.tokenDigest || "",
|
|
180
|
+
indexedAt: Number.isInteger(payload.createdAt) ? payload.createdAt : 0
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function ensureNgramTarget(targetEventId, payload) {
|
|
185
|
+
const next = targetMetadata(payload);
|
|
186
|
+
const current = ngramTargets.get(targetEventId);
|
|
187
|
+
if (!current) {
|
|
188
|
+
ngramTargets.set(targetEventId, { ...next, tokenKeys: new Set(), indexEventIds: new Set() });
|
|
189
|
+
return ngramTargets.get(targetEventId);
|
|
190
|
+
}
|
|
191
|
+
for (const [key, value] of Object.entries(next)) {
|
|
192
|
+
if (value !== "" && value !== 0) current[key] = value;
|
|
193
|
+
}
|
|
194
|
+
return current;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function foldRoomIndexNgramEvent(nostrEvent) {
|
|
198
|
+
const payload = parseControlPlanePayload(nostrEvent);
|
|
199
|
+
if (!payload || payload.kind !== ROOM_INDEX_NGRAM_PAYLOAD_KIND || payload.version !== 1) return;
|
|
200
|
+
if (payload.suite !== ROOM_INDEX_NGRAM_SUITE) return;
|
|
201
|
+
if (roomName && payload.roomName && payload.roomName !== roomName) return;
|
|
202
|
+
if (typeof payload.targetEventId !== "string" || !/^[0-9a-f]{64}$/i.test(payload.targetEventId)) return;
|
|
203
|
+
if (typeof payload.keyId !== "string" || !payload.keyId) return;
|
|
204
|
+
if (!Array.isArray(payload.tokens) || payload.tokens.length === 0) return;
|
|
205
|
+
if (ngramIndexEvents.has(nostrEvent.id)) return;
|
|
206
|
+
|
|
207
|
+
const target = ensureNgramTarget(payload.targetEventId, payload);
|
|
208
|
+
const tokenKeys = new Set();
|
|
209
|
+
for (const token of payload.tokens) {
|
|
210
|
+
if (!validNgramToken(token)) continue;
|
|
211
|
+
const key = ngramPostingKey(payload.suite, payload.keyId, token);
|
|
212
|
+
tokenKeys.add(key);
|
|
213
|
+
target.tokenKeys.add(key);
|
|
214
|
+
let postings = ngramIndex.get(key);
|
|
215
|
+
if (!postings) {
|
|
216
|
+
postings = new Set();
|
|
217
|
+
ngramIndex.set(key, postings);
|
|
218
|
+
}
|
|
219
|
+
const before = postings.size;
|
|
220
|
+
postings.add(payload.targetEventId);
|
|
221
|
+
if (postings.size !== before) stats.ngramPostings += 1;
|
|
222
|
+
}
|
|
223
|
+
if (tokenKeys.size === 0) {
|
|
224
|
+
if (target.tokenKeys.size === 0 && target.indexEventIds.size === 0) ngramTargets.delete(payload.targetEventId);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
target.indexEventIds.add(nostrEvent.id);
|
|
228
|
+
ngramIndexEvents.set(nostrEvent.id, { targetEventId: payload.targetEventId, tokenKeys });
|
|
229
|
+
stats.ngramIndexEvents += 1;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function currentNgramPostingCount() {
|
|
233
|
+
let total = 0;
|
|
234
|
+
for (const postings of ngramIndex.values()) total += postings.size;
|
|
235
|
+
return total;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function pruneNgramPostingKey(key) {
|
|
239
|
+
const postings = ngramIndex.get(key);
|
|
240
|
+
if (postings && postings.size === 0) ngramIndex.delete(key);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function removeNgramIndexEvent(indexEventId) {
|
|
244
|
+
const record = ngramIndexEvents.get(indexEventId);
|
|
245
|
+
if (!record) return;
|
|
246
|
+
const target = ngramTargets.get(record.targetEventId);
|
|
247
|
+
for (const key of record.tokenKeys) {
|
|
248
|
+
const postings = ngramIndex.get(key);
|
|
249
|
+
if (postings) {
|
|
250
|
+
postings.delete(record.targetEventId);
|
|
251
|
+
pruneNgramPostingKey(key);
|
|
252
|
+
}
|
|
253
|
+
target?.tokenKeys?.delete?.(key);
|
|
254
|
+
}
|
|
255
|
+
target?.indexEventIds?.delete?.(indexEventId);
|
|
256
|
+
ngramIndexEvents.delete(indexEventId);
|
|
257
|
+
if (target && target.indexEventIds.size === 0) ngramTargets.delete(record.targetEventId);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function removeNgramTarget(targetEventId) {
|
|
261
|
+
const target = ngramTargets.get(targetEventId);
|
|
262
|
+
if (!target) return;
|
|
263
|
+
for (const key of target.tokenKeys || []) {
|
|
264
|
+
const postings = ngramIndex.get(key);
|
|
265
|
+
if (postings) {
|
|
266
|
+
postings.delete(targetEventId);
|
|
267
|
+
pruneNgramPostingKey(key);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (const indexEventId of target.indexEventIds || []) ngramIndexEvents.delete(indexEventId);
|
|
271
|
+
ngramTargets.delete(targetEventId);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function removeSecondaryIndexesForEvent(eventId) {
|
|
275
|
+
if (ngramIndexEvents.has(eventId)) removeNgramIndexEvent(eventId);
|
|
276
|
+
if (ngramTargets.has(eventId)) removeNgramTarget(eventId);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function foldControlPlaneEvent(nostrEvent) {
|
|
280
|
+
const kind = nostrEvent?.kind;
|
|
281
|
+
if (kind === 9010) foldKeyEpochEvent(nostrEvent);
|
|
282
|
+
else if (kind === 9011) foldKeyEpochGrantEvent(nostrEvent);
|
|
283
|
+
else if (kind === 9012) foldRoomIndexKeyGrantEvent(nostrEvent);
|
|
284
|
+
else if (kind === ROOM_INDEX_NGRAM_KIND) foldRoomIndexNgramEvent(nostrEvent);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function ensureStream(name) {
|
|
288
|
+
let list = streams.get(name);
|
|
289
|
+
if (!list) {
|
|
290
|
+
list = [];
|
|
291
|
+
streams.set(name, list);
|
|
292
|
+
streamWriters.set(name, new Set());
|
|
293
|
+
}
|
|
294
|
+
return list;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function insert(nostrEvent) {
|
|
298
|
+
const eventId = nostrEvent?.id;
|
|
299
|
+
if (!eventId || typeof eventId !== "string") {
|
|
300
|
+
return { ok: false, reason: "missing-event-id" };
|
|
301
|
+
}
|
|
302
|
+
if (events.has(eventId)) {
|
|
303
|
+
stats.duplicates += 1;
|
|
304
|
+
return { ok: true, inserted: false, eventId };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const kind = nostrEvent?.kind;
|
|
308
|
+
const isControlPlane = kind === 9010 || kind === 9011 || kind === 9012 || kind === ROOM_INDEX_NGRAM_KIND;
|
|
309
|
+
|
|
310
|
+
function controlPlaneHlc() {
|
|
311
|
+
// Use a stable, monotonic HLC for control-plane deduplication.
|
|
312
|
+
// For key-epoch events, embed the index so retirement updates supersede.
|
|
313
|
+
controlPlaneCounter += 1;
|
|
314
|
+
const payload = parseControlPlanePayload(nostrEvent);
|
|
315
|
+
const index = Number.isInteger(payload?.index) ? payload.index : 0;
|
|
316
|
+
return `${String(index).padStart(8, "0")}:${String(nostrEvent.created_at || 0).padStart(16, "0")}:${String(controlPlaneCounter).padStart(16, "0")}`;
|
|
317
|
+
}
|
|
318
|
+
const entry = isControlPlane ? { eventId, stream: `_ctl:${kind}`, hlc: controlPlaneHlc(), writer: nostrEvent.pubkey || "", device: "", seq: 0, opId: "", plugin: "", type: "", action: "", role: "", epoch: "" } : entryForEvent(eventId, nostrEvent);
|
|
319
|
+
if (!entry) {
|
|
320
|
+
return { ok: false, reason: "missing-stream-header" };
|
|
321
|
+
}
|
|
322
|
+
const list = ensureStream(entry.stream);
|
|
323
|
+
const writers = streamWriters.get(entry.stream);
|
|
324
|
+
if (writers) writers.add(entry.writer);
|
|
325
|
+
|
|
326
|
+
// Deduplicate by (stream, hlc, writer, seq) — keep first-seen eventId.
|
|
327
|
+
const existingIndex = list.findIndex((e) =>
|
|
328
|
+
e.hlc === entry.hlc && e.writer === entry.writer && e.seq === entry.seq
|
|
329
|
+
);
|
|
330
|
+
if (existingIndex >= 0) {
|
|
331
|
+
stats.duplicates += 1;
|
|
332
|
+
return { ok: true, inserted: false, eventId: list[existingIndex].eventId };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
binarySearchInsert(list, entry, compareIndexEntries);
|
|
336
|
+
events.set(eventId, nostrEvent);
|
|
337
|
+
stats.inserts += 1;
|
|
338
|
+
|
|
339
|
+
foldControlPlaneEvent(nostrEvent);
|
|
340
|
+
|
|
341
|
+
if (list.length > maxEventsPerStream) {
|
|
342
|
+
const removed = list.shift();
|
|
343
|
+
if (removed) {
|
|
344
|
+
removeSecondaryIndexesForEvent(removed.eventId);
|
|
345
|
+
events.delete(removed.eventId);
|
|
346
|
+
stats.evictions += 1;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return { ok: true, inserted: true, eventId };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function has(eventId) {
|
|
354
|
+
return events.has(eventId);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function get(eventId) {
|
|
358
|
+
return events.get(eventId);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function listStreams() {
|
|
362
|
+
return Array.from(streams.keys()).sort();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function streamWritersFor(stream) {
|
|
366
|
+
return Array.from(streamWriters.get(stream) || new Set()).sort();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function queryOptions(options = {}) {
|
|
370
|
+
const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 1000;
|
|
371
|
+
const offset = Number.isInteger(options.offset) && options.offset >= 0 ? options.offset : 0;
|
|
372
|
+
return { limit, offset };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function rangeQuery(stream, options = {}) {
|
|
376
|
+
const list = streams.get(stream);
|
|
377
|
+
if (!list || list.length === 0) return { events: [] };
|
|
378
|
+
const { limit, offset } = queryOptions(options);
|
|
379
|
+
const sinceHlc = options.sinceHlc;
|
|
380
|
+
const untilHlc = options.untilHlc;
|
|
381
|
+
const beforeHlc = options.beforeHlc;
|
|
382
|
+
|
|
383
|
+
// beforeHlc is a convenience for "load more above" pagination: return the
|
|
384
|
+
// most recent `limit` events strictly before `beforeHlc`, ordered oldest-first.
|
|
385
|
+
if (typeof beforeHlc === "string" && beforeHlc.length > 0) {
|
|
386
|
+
const firstAtOrAfter = list.findIndex((e) => String(e.hlc).localeCompare(beforeHlc) >= 0);
|
|
387
|
+
const end = firstAtOrAfter < 0 ? list.length : firstAtOrAfter;
|
|
388
|
+
const start = Math.max(0, end - limit);
|
|
389
|
+
const slice = list.slice(start, end);
|
|
390
|
+
return { events: slice.map((e) => e.eventId), total: end };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
let start = 0;
|
|
394
|
+
if (typeof sinceHlc === "string" && sinceHlc.length > 0) {
|
|
395
|
+
start = list.findIndex((e) => String(e.hlc).localeCompare(sinceHlc) >= 0);
|
|
396
|
+
if (start < 0) start = list.length;
|
|
397
|
+
}
|
|
398
|
+
let end = list.length;
|
|
399
|
+
if (typeof untilHlc === "string" && untilHlc.length > 0) {
|
|
400
|
+
const firstAfter = list.findIndex((e) => String(e.hlc).localeCompare(untilHlc) > 0);
|
|
401
|
+
end = firstAfter < 0 ? list.length : firstAfter;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const slice = list.slice(start, end).slice(offset, offset + limit);
|
|
405
|
+
return { events: slice.map((e) => e.eventId), total: end - start };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function headQuery(stream, options = {}) {
|
|
409
|
+
const list = streams.get(stream);
|
|
410
|
+
if (!list || list.length === 0) return { events: [] };
|
|
411
|
+
const { limit } = queryOptions(options);
|
|
412
|
+
return { events: list.slice(-limit).map((e) => e.eventId) };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function latestHlc(stream) {
|
|
416
|
+
const list = streams.get(stream);
|
|
417
|
+
if (!list || list.length === 0) return undefined;
|
|
418
|
+
return list[list.length - 1].hlc;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function streamEvents(stream) {
|
|
422
|
+
const list = streams.get(stream);
|
|
423
|
+
if (!list) return [];
|
|
424
|
+
return list.map((e) => events.get(e.eventId)).filter(Boolean);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function createNegentropySession(stream) {
|
|
428
|
+
const list = streams.get(stream);
|
|
429
|
+
const sessionEvents = list ? list.map((e) => events.get(e.eventId)).filter(Boolean) : [];
|
|
430
|
+
return createStreamNegentropySession(stream, sessionEvents);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function snapshot() {
|
|
434
|
+
return {
|
|
435
|
+
roomName,
|
|
436
|
+
events: Array.from(events.values()),
|
|
437
|
+
keyEpochs: Array.from(keyEpochs.entries()),
|
|
438
|
+
activeEpochId,
|
|
439
|
+
epochGrants: Array.from(epochGrants.entries()).map(([epochId, map]) => [epochId, Array.from(map.entries())]),
|
|
440
|
+
roomIndexKeyGrants: Array.from(roomIndexKeyGrants.entries()),
|
|
441
|
+
stats: { ...stats }
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function restore(data) {
|
|
446
|
+
if (!data || !Array.isArray(data.events)) return { ok: false, reason: "invalid-snapshot" };
|
|
447
|
+
events.clear();
|
|
448
|
+
streams.clear();
|
|
449
|
+
streamWriters.clear();
|
|
450
|
+
keyEpochs.clear();
|
|
451
|
+
epochGrants.clear();
|
|
452
|
+
roomIndexKeyGrants.clear();
|
|
453
|
+
ngramIndex.clear();
|
|
454
|
+
ngramTargets.clear();
|
|
455
|
+
ngramIndexEvents.clear();
|
|
456
|
+
activeEpochId = null;
|
|
457
|
+
controlPlaneCounter = 0;
|
|
458
|
+
stats.inserts = 0;
|
|
459
|
+
stats.duplicates = 0;
|
|
460
|
+
stats.evictions = 0;
|
|
461
|
+
stats.ngramIndexEvents = 0;
|
|
462
|
+
stats.ngramPostings = 0;
|
|
463
|
+
let inserted = 0;
|
|
464
|
+
for (const event of data.events) {
|
|
465
|
+
const result = insert(event);
|
|
466
|
+
if (result.ok && result.inserted) inserted += 1;
|
|
467
|
+
}
|
|
468
|
+
if (Array.isArray(data.keyEpochs)) {
|
|
469
|
+
for (const [epochId, epoch] of data.keyEpochs) keyEpochs.set(epochId, epoch);
|
|
470
|
+
}
|
|
471
|
+
if (typeof data.activeEpochId === "string") activeEpochId = data.activeEpochId;
|
|
472
|
+
if (Array.isArray(data.epochGrants)) {
|
|
473
|
+
for (const [epochId, entries] of data.epochGrants) {
|
|
474
|
+
const map = new Map();
|
|
475
|
+
for (const [keyId, eventId] of entries) map.set(keyId, eventId);
|
|
476
|
+
epochGrants.set(epochId, map);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (Array.isArray(data.roomIndexKeyGrants)) {
|
|
480
|
+
for (const [keyId, eventId] of data.roomIndexKeyGrants) roomIndexKeyGrants.set(keyId, eventId);
|
|
481
|
+
}
|
|
482
|
+
return { ok: true, inserted };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
function targetForSearch(eventId) {
|
|
488
|
+
const target = ngramTargets.get(eventId);
|
|
489
|
+
if (target) {
|
|
490
|
+
const header = (!target.stream || !target.hlc) ? eventStreamHeader(eventId) : undefined;
|
|
491
|
+
return {
|
|
492
|
+
eventId,
|
|
493
|
+
stream: target.stream || header?.stream || "",
|
|
494
|
+
hlc: target.hlc || header?.hlc || "",
|
|
495
|
+
targetOperationId: target.targetOperationId || header?.opId || "",
|
|
496
|
+
field: target.field || "",
|
|
497
|
+
indexedAt: target.indexedAt || 0
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
const header = eventStreamHeader(eventId);
|
|
501
|
+
return { eventId, stream: header?.stream || "", hlc: header?.hlc || "", targetOperationId: header?.opId || "", field: "", indexedAt: 0 };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function compareSearchMatches(left, right, order) {
|
|
505
|
+
if (order === "score") {
|
|
506
|
+
const scoreDiff = (right.score || 0) - (left.score || 0);
|
|
507
|
+
if (scoreDiff !== 0) return scoreDiff;
|
|
508
|
+
const matchedDiff = (right.matchedTokens || 0) - (left.matchedTokens || 0);
|
|
509
|
+
if (matchedDiff !== 0) return matchedDiff;
|
|
510
|
+
}
|
|
511
|
+
const hlcOrder = String(right.hlc || "").localeCompare(String(left.hlc || ""));
|
|
512
|
+
if (hlcOrder !== 0) return hlcOrder;
|
|
513
|
+
const indexedDiff = (right.indexedAt || 0) - (left.indexedAt || 0);
|
|
514
|
+
if (indexedDiff !== 0) return indexedDiff;
|
|
515
|
+
return String(left.eventId).localeCompare(String(right.eventId));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function searchNgrams(options = {}) {
|
|
519
|
+
const rawTokens = Array.isArray(options.tokens) ? options.tokens.filter(validNgramToken) : [];
|
|
520
|
+
const tokens = Array.from(new Set(rawTokens));
|
|
521
|
+
if (tokens.length === 0) return { events: [], matches: [], total: 0, queryTokenCount: 0 };
|
|
522
|
+
const suite = options.suite || ROOM_INDEX_NGRAM_SUITE;
|
|
523
|
+
const keyId = typeof options.keyId === "string" ? options.keyId : "";
|
|
524
|
+
const stream = typeof options.stream === "string" && options.stream ? options.stream : undefined;
|
|
525
|
+
const mode = options.mode === "any" || options.mode === "threshold" ? options.mode : "all";
|
|
526
|
+
const order = options.order === "score" ? "score" : "newest";
|
|
527
|
+
const minScore = Number.isFinite(options.minScore) ? Math.max(0, Math.min(1, Number(options.minScore))) : (mode === "threshold" ? 0.5 : 0);
|
|
528
|
+
const limit = Number.isInteger(options.limit) && options.limit > 0 ? Math.min(options.limit, 1000) : 100;
|
|
529
|
+
const offset = Number.isInteger(options.offset) && options.offset >= 0 ? options.offset : 0;
|
|
530
|
+
const postingKeys = tokens.map((token) => ngramPostingKey(suite, keyId, token));
|
|
531
|
+
const matchesByEvent = new Map();
|
|
532
|
+
|
|
533
|
+
if (mode === "all") {
|
|
534
|
+
const postingSets = postingKeys
|
|
535
|
+
.map((key) => ngramIndex.get(key) || new Set())
|
|
536
|
+
.sort((left, right) => left.size - right.size);
|
|
537
|
+
if (postingSets.length === 0 || postingSets[0].size === 0) return { events: [], matches: [], total: 0, queryTokenCount: tokens.length };
|
|
538
|
+
const candidateIds = new Set(postingSets[0]);
|
|
539
|
+
for (const postings of postingSets.slice(1)) {
|
|
540
|
+
for (const eventId of Array.from(candidateIds)) {
|
|
541
|
+
if (!postings.has(eventId)) candidateIds.delete(eventId);
|
|
542
|
+
}
|
|
543
|
+
if (candidateIds.size === 0) break;
|
|
544
|
+
}
|
|
545
|
+
for (const eventId of candidateIds) matchesByEvent.set(eventId, tokens.length);
|
|
546
|
+
} else {
|
|
547
|
+
for (const key of postingKeys) {
|
|
548
|
+
const postings = ngramIndex.get(key);
|
|
549
|
+
if (!postings) continue;
|
|
550
|
+
for (const eventId of postings) matchesByEvent.set(eventId, (matchesByEvent.get(eventId) || 0) + 1);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const matches = [];
|
|
555
|
+
for (const [eventId, matchedTokens] of matchesByEvent) {
|
|
556
|
+
const target = targetForSearch(eventId);
|
|
557
|
+
if (stream && target.stream !== stream) continue;
|
|
558
|
+
const score = tokens.length === 0 ? 0 : matchedTokens / tokens.length;
|
|
559
|
+
if (mode !== "all" && score < minScore) continue;
|
|
560
|
+
matches.push({
|
|
561
|
+
eventId,
|
|
562
|
+
score,
|
|
563
|
+
matchedTokens,
|
|
564
|
+
queryTokenCount: tokens.length,
|
|
565
|
+
stream: target.stream,
|
|
566
|
+
hlc: target.hlc,
|
|
567
|
+
targetOperationId: target.targetOperationId,
|
|
568
|
+
field: target.field,
|
|
569
|
+
indexedAt: target.indexedAt
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
matches.sort((left, right) => compareSearchMatches(left, right, order));
|
|
574
|
+
const slice = matches.slice(offset, offset + limit);
|
|
575
|
+
return { events: slice.map((match) => match.eventId), matches: slice, total: matches.length, queryTokenCount: tokens.length };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function ngramIndexStats() {
|
|
579
|
+
return {
|
|
580
|
+
targets: ngramTargets.size,
|
|
581
|
+
indexEvents: ngramIndexEvents.size,
|
|
582
|
+
tokenPostingLists: ngramIndex.size,
|
|
583
|
+
postings: currentNgramPostingCount()
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function activeKeyEpoch() {
|
|
588
|
+
if (!activeEpochId) return undefined;
|
|
589
|
+
const epoch = keyEpochs.get(activeEpochId);
|
|
590
|
+
if (!epoch) return undefined;
|
|
591
|
+
return { ...epoch };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function listKeyEpochs() {
|
|
595
|
+
return Array.from(keyEpochs.values()).sort((a, b) => a.index - b.index).map((e) => ({ ...e }));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function listEpochGrantRecipients(epochId) {
|
|
599
|
+
const grants = epochGrants.get(epochId);
|
|
600
|
+
if (!grants) return [];
|
|
601
|
+
return Array.from(grants.keys());
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function listRoomIndexKeyGrantRecipients() {
|
|
605
|
+
return Array.from(roomIndexKeyGrants.keys());
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function keyEpochForIngress(epochId) {
|
|
609
|
+
if (!epochId) return activeKeyEpoch();
|
|
610
|
+
const epoch = keyEpochs.get(epochId);
|
|
611
|
+
return epoch ? { ...epoch } : undefined;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
insert,
|
|
616
|
+
has,
|
|
617
|
+
get,
|
|
618
|
+
listStreams,
|
|
619
|
+
streamWritersFor,
|
|
620
|
+
rangeQuery,
|
|
621
|
+
headQuery,
|
|
622
|
+
latestHlc,
|
|
623
|
+
streamEvents,
|
|
624
|
+
searchNgrams,
|
|
625
|
+
ngramIndexStats,
|
|
626
|
+
createNegentropySession,
|
|
627
|
+
snapshot,
|
|
628
|
+
restore,
|
|
629
|
+
activeKeyEpoch,
|
|
630
|
+
listKeyEpochs,
|
|
631
|
+
listEpochGrantRecipients,
|
|
632
|
+
listRoomIndexKeyGrantRecipients,
|
|
633
|
+
keyEpochForIngress,
|
|
634
|
+
get stats() { return { ...stats }; }
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
module.exports = {
|
|
639
|
+
createEncryptedRoomRuntime,
|
|
640
|
+
parseStreamHeader
|
|
641
|
+
};
|