@semiont/event-sourcing 0.2.28-build.40
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 +407 -0
- package/dist/index.d.ts +606 -0
- package/dist/index.js +1170 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
import { promises, createReadStream } from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as readline from 'readline';
|
|
4
|
+
import crypto, { createHash } from 'crypto';
|
|
5
|
+
import { resourceId, isSystemEvent, isResourceEvent, didToAgent, findBodyItem } from '@semiont/core';
|
|
6
|
+
import { resourceUri, annotationUri } from '@semiont/api-client';
|
|
7
|
+
|
|
8
|
+
// src/storage/event-storage.ts
|
|
9
|
+
var rnds8Pool = new Uint8Array(256);
|
|
10
|
+
var poolPtr = rnds8Pool.length;
|
|
11
|
+
function rng() {
|
|
12
|
+
if (poolPtr > rnds8Pool.length - 16) {
|
|
13
|
+
crypto.randomFillSync(rnds8Pool);
|
|
14
|
+
poolPtr = 0;
|
|
15
|
+
}
|
|
16
|
+
return rnds8Pool.slice(poolPtr, poolPtr += 16);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ../../node_modules/uuid/dist/esm-node/regex.js
|
|
20
|
+
var regex_default = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i;
|
|
21
|
+
|
|
22
|
+
// ../../node_modules/uuid/dist/esm-node/validate.js
|
|
23
|
+
function validate(uuid) {
|
|
24
|
+
return typeof uuid === "string" && regex_default.test(uuid);
|
|
25
|
+
}
|
|
26
|
+
var validate_default = validate;
|
|
27
|
+
|
|
28
|
+
// ../../node_modules/uuid/dist/esm-node/stringify.js
|
|
29
|
+
var byteToHex = [];
|
|
30
|
+
for (let i = 0; i < 256; ++i) {
|
|
31
|
+
byteToHex.push((i + 256).toString(16).substr(1));
|
|
32
|
+
}
|
|
33
|
+
function stringify(arr, offset = 0) {
|
|
34
|
+
const uuid = (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + "-" + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + "-" + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + "-" + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + "-" + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase();
|
|
35
|
+
if (!validate_default(uuid)) {
|
|
36
|
+
throw TypeError("Stringified UUID is invalid");
|
|
37
|
+
}
|
|
38
|
+
return uuid;
|
|
39
|
+
}
|
|
40
|
+
var stringify_default = stringify;
|
|
41
|
+
|
|
42
|
+
// ../../node_modules/uuid/dist/esm-node/v4.js
|
|
43
|
+
function v4(options, buf, offset) {
|
|
44
|
+
options = options || {};
|
|
45
|
+
const rnds = options.random || (options.rng || rng)();
|
|
46
|
+
rnds[6] = rnds[6] & 15 | 64;
|
|
47
|
+
rnds[8] = rnds[8] & 63 | 128;
|
|
48
|
+
if (buf) {
|
|
49
|
+
offset = offset || 0;
|
|
50
|
+
for (let i = 0; i < 16; ++i) {
|
|
51
|
+
buf[offset + i] = rnds[i];
|
|
52
|
+
}
|
|
53
|
+
return buf;
|
|
54
|
+
}
|
|
55
|
+
return stringify_default(rnds);
|
|
56
|
+
}
|
|
57
|
+
var v4_default = v4;
|
|
58
|
+
function jumpConsistentHash(key, numBuckets = 65536) {
|
|
59
|
+
const hash = hashToUint32(key);
|
|
60
|
+
return hash % numBuckets;
|
|
61
|
+
}
|
|
62
|
+
function hashToUint32(str) {
|
|
63
|
+
let hash = 0;
|
|
64
|
+
for (let i = 0; i < str.length; i++) {
|
|
65
|
+
hash = (hash << 5) - hash + str.charCodeAt(i);
|
|
66
|
+
hash = hash & 4294967295;
|
|
67
|
+
}
|
|
68
|
+
return Math.abs(hash);
|
|
69
|
+
}
|
|
70
|
+
function shardIdToPath(shardId) {
|
|
71
|
+
if (shardId < 0 || shardId >= 65536) {
|
|
72
|
+
throw new Error(`Invalid shard ID: ${shardId}. Must be 0-65535 for 4-hex sharding.`);
|
|
73
|
+
}
|
|
74
|
+
const shardHex = shardId.toString(16).padStart(4, "0");
|
|
75
|
+
const ab = shardHex.substring(0, 2);
|
|
76
|
+
const cd = shardHex.substring(2, 4);
|
|
77
|
+
return [ab, cd];
|
|
78
|
+
}
|
|
79
|
+
function getShardPath(key, numBuckets = 65536) {
|
|
80
|
+
const shardId = jumpConsistentHash(key, numBuckets);
|
|
81
|
+
return shardIdToPath(shardId);
|
|
82
|
+
}
|
|
83
|
+
function sha256(data) {
|
|
84
|
+
const content = typeof data === "string" ? data : JSON.stringify(data);
|
|
85
|
+
return createHash("sha256").update(content).digest("hex");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/storage/event-storage.ts
|
|
89
|
+
var EventStorage = class {
|
|
90
|
+
config;
|
|
91
|
+
// Per-resource sequence tracking: resourceId -> sequence number
|
|
92
|
+
resourceSequences = /* @__PURE__ */ new Map();
|
|
93
|
+
// Per-resource last event hash: resourceId -> hash
|
|
94
|
+
resourceLastHash = /* @__PURE__ */ new Map();
|
|
95
|
+
constructor(config) {
|
|
96
|
+
this.config = {
|
|
97
|
+
basePath: config.basePath,
|
|
98
|
+
dataDir: config.dataDir,
|
|
99
|
+
maxEventsPerFile: config.maxEventsPerFile || 1e4,
|
|
100
|
+
enableSharding: config.enableSharding ?? true,
|
|
101
|
+
numShards: config.numShards || 65536,
|
|
102
|
+
enableCompression: config.enableCompression ?? true
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Calculate shard path for a resource ID
|
|
107
|
+
* Uses jump consistent hash for uniform distribution
|
|
108
|
+
* Special case: __system__ events bypass sharding
|
|
109
|
+
*/
|
|
110
|
+
getShardPath(resourceId) {
|
|
111
|
+
if (resourceId === "__system__" || !this.config.enableSharding) {
|
|
112
|
+
return "";
|
|
113
|
+
}
|
|
114
|
+
const shardIndex = jumpConsistentHash(resourceId, this.config.numShards);
|
|
115
|
+
const hex = shardIndex.toString(16).padStart(4, "0");
|
|
116
|
+
const [ab, cd] = [hex.substring(0, 2), hex.substring(2, 4)];
|
|
117
|
+
return path.join(ab, cd);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Get full path to resource's event directory
|
|
121
|
+
*/
|
|
122
|
+
getResourcePath(resourceId) {
|
|
123
|
+
const shardPath = this.getShardPath(resourceId);
|
|
124
|
+
return path.join(this.config.dataDir, "events", shardPath, resourceId);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Initialize directory structure for a resource's event stream
|
|
128
|
+
* Also loads sequence number and last hash if stream exists
|
|
129
|
+
*/
|
|
130
|
+
async initializeResourceStream(resourceId) {
|
|
131
|
+
const docPath = this.getResourcePath(resourceId);
|
|
132
|
+
let exists = false;
|
|
133
|
+
try {
|
|
134
|
+
await promises.access(docPath);
|
|
135
|
+
exists = true;
|
|
136
|
+
} catch {
|
|
137
|
+
}
|
|
138
|
+
if (!exists) {
|
|
139
|
+
await promises.mkdir(docPath, { recursive: true });
|
|
140
|
+
const filename = this.createEventFilename(1);
|
|
141
|
+
const filePath = path.join(docPath, filename);
|
|
142
|
+
await promises.writeFile(filePath, "", "utf-8");
|
|
143
|
+
this.resourceSequences.set(resourceId, 0);
|
|
144
|
+
console.log(`[EventStorage] Initialized event stream for ${resourceId} at ${docPath}`);
|
|
145
|
+
} else {
|
|
146
|
+
const files = await this.getEventFiles(resourceId);
|
|
147
|
+
if (files.length > 0) {
|
|
148
|
+
const lastFile = files[files.length - 1];
|
|
149
|
+
if (lastFile) {
|
|
150
|
+
const lastEvent = await this.getLastEvent(resourceId, lastFile);
|
|
151
|
+
if (lastEvent) {
|
|
152
|
+
this.resourceSequences.set(resourceId, lastEvent.metadata.sequenceNumber);
|
|
153
|
+
if (lastEvent.metadata.checksum) {
|
|
154
|
+
this.resourceLastHash.set(resourceId, lastEvent.metadata.checksum);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
this.resourceSequences.set(resourceId, 0);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Append an event - handles EVERYTHING for event creation
|
|
165
|
+
* Creates ID, timestamp, metadata, checksum, sequence tracking, and writes to disk
|
|
166
|
+
*/
|
|
167
|
+
async appendEvent(event, resourceId) {
|
|
168
|
+
if (this.getSequenceNumber(resourceId) === 0) {
|
|
169
|
+
await this.initializeResourceStream(resourceId);
|
|
170
|
+
}
|
|
171
|
+
const completeEvent = {
|
|
172
|
+
...event,
|
|
173
|
+
id: v4_default(),
|
|
174
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
175
|
+
};
|
|
176
|
+
const sequenceNumber = this.getNextSequenceNumber(resourceId);
|
|
177
|
+
const prevEventHash = this.getLastEventHash(resourceId);
|
|
178
|
+
const metadata = {
|
|
179
|
+
sequenceNumber,
|
|
180
|
+
streamPosition: 0,
|
|
181
|
+
// Will be set during write
|
|
182
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
183
|
+
prevEventHash: prevEventHash || void 0,
|
|
184
|
+
checksum: sha256(completeEvent)
|
|
185
|
+
};
|
|
186
|
+
const storedEvent = {
|
|
187
|
+
event: completeEvent,
|
|
188
|
+
metadata
|
|
189
|
+
};
|
|
190
|
+
await this.writeEvent(storedEvent, resourceId);
|
|
191
|
+
this.setLastEventHash(resourceId, metadata.checksum);
|
|
192
|
+
return storedEvent;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Write an event to storage (append to JSONL)
|
|
196
|
+
* Internal method - use appendEvent() instead
|
|
197
|
+
*/
|
|
198
|
+
async writeEvent(event, resourceId) {
|
|
199
|
+
const docPath = this.getResourcePath(resourceId);
|
|
200
|
+
const files = await this.getEventFiles(resourceId);
|
|
201
|
+
let targetFile;
|
|
202
|
+
if (files.length === 0) {
|
|
203
|
+
targetFile = await this.createNewEventFile(resourceId);
|
|
204
|
+
} else {
|
|
205
|
+
const currentFile = files[files.length - 1];
|
|
206
|
+
if (!currentFile) {
|
|
207
|
+
targetFile = await this.createNewEventFile(resourceId);
|
|
208
|
+
} else {
|
|
209
|
+
const eventCount = await this.countEventsInFile(resourceId, currentFile);
|
|
210
|
+
if (eventCount >= this.config.maxEventsPerFile) {
|
|
211
|
+
targetFile = await this.createNewEventFile(resourceId);
|
|
212
|
+
} else {
|
|
213
|
+
targetFile = currentFile;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const targetPath = path.join(docPath, targetFile);
|
|
218
|
+
const eventLine = JSON.stringify(event) + "\n";
|
|
219
|
+
await promises.appendFile(targetPath, eventLine, "utf-8");
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Count events in a specific file
|
|
223
|
+
*/
|
|
224
|
+
async countEventsInFile(resourceId, filename) {
|
|
225
|
+
const docPath = this.getResourcePath(resourceId);
|
|
226
|
+
const filePath = path.join(docPath, filename);
|
|
227
|
+
try {
|
|
228
|
+
const content = await promises.readFile(filePath, "utf-8");
|
|
229
|
+
const lines = content.trim().split("\n").filter((line) => line.trim() !== "");
|
|
230
|
+
return lines.length;
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (error.code === "ENOENT") {
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Read all events from a specific file
|
|
240
|
+
*/
|
|
241
|
+
async readEventsFromFile(resourceId, filename) {
|
|
242
|
+
const docPath = this.getResourcePath(resourceId);
|
|
243
|
+
const filePath = path.join(docPath, filename);
|
|
244
|
+
const events = [];
|
|
245
|
+
try {
|
|
246
|
+
const fileStream = createReadStream(filePath, { encoding: "utf-8" });
|
|
247
|
+
const rl = readline.createInterface({
|
|
248
|
+
input: fileStream,
|
|
249
|
+
crlfDelay: Infinity
|
|
250
|
+
});
|
|
251
|
+
for await (const line of rl) {
|
|
252
|
+
const trimmed = line.trim();
|
|
253
|
+
if (trimmed === "") continue;
|
|
254
|
+
try {
|
|
255
|
+
const event = JSON.parse(trimmed);
|
|
256
|
+
events.push(event);
|
|
257
|
+
} catch (parseError) {
|
|
258
|
+
console.error(`[EventStorage] Failed to parse event in ${filePath}:`, parseError);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (error.code === "ENOENT") {
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
return events;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Get list of event files for a resource (sorted by sequence)
|
|
271
|
+
*/
|
|
272
|
+
async getEventFiles(resourceId$1) {
|
|
273
|
+
const docPath = this.getResourcePath(resourceId(resourceId$1));
|
|
274
|
+
try {
|
|
275
|
+
const files = await promises.readdir(docPath);
|
|
276
|
+
const eventFiles = files.filter((f) => f.startsWith("events-") && f.endsWith(".jsonl")).sort((a, b) => {
|
|
277
|
+
const seqA = parseInt(a.match(/events-(\d+)\.jsonl/)?.[1] || "0");
|
|
278
|
+
const seqB = parseInt(b.match(/events-(\d+)\.jsonl/)?.[1] || "0");
|
|
279
|
+
return seqA - seqB;
|
|
280
|
+
});
|
|
281
|
+
return eventFiles;
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (error.code === "ENOENT") {
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Create a new event file for rotation
|
|
291
|
+
*/
|
|
292
|
+
async createNewEventFile(resourceId) {
|
|
293
|
+
const files = await this.getEventFiles(resourceId);
|
|
294
|
+
const lastFile = files[files.length - 1];
|
|
295
|
+
const lastSeq = lastFile ? parseInt(lastFile.match(/events-(\d+)\.jsonl/)?.[1] || "1") : 1;
|
|
296
|
+
const newSeq = lastSeq + 1;
|
|
297
|
+
const filename = this.createEventFilename(newSeq);
|
|
298
|
+
const docPath = this.getResourcePath(resourceId);
|
|
299
|
+
const filePath = path.join(docPath, filename);
|
|
300
|
+
await promises.writeFile(filePath, "", "utf-8");
|
|
301
|
+
console.log(`[EventStorage] Created new event file: ${filename} for ${resourceId}`);
|
|
302
|
+
return filename;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Get the last event from a specific file
|
|
306
|
+
*/
|
|
307
|
+
async getLastEvent(resourceId, filename) {
|
|
308
|
+
const events = await this.readEventsFromFile(resourceId, filename);
|
|
309
|
+
const lastEvent = events.length > 0 ? events[events.length - 1] : void 0;
|
|
310
|
+
return lastEvent ?? null;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Get all events for a resource across all files
|
|
314
|
+
*/
|
|
315
|
+
async getAllEvents(resourceId) {
|
|
316
|
+
const files = await this.getEventFiles(resourceId);
|
|
317
|
+
const allEvents = [];
|
|
318
|
+
for (const file of files) {
|
|
319
|
+
const events = await this.readEventsFromFile(resourceId, file);
|
|
320
|
+
allEvents.push(...events);
|
|
321
|
+
}
|
|
322
|
+
return allEvents;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Get all resource IDs by scanning shard directories
|
|
326
|
+
*/
|
|
327
|
+
async getAllResourceIds() {
|
|
328
|
+
const eventsDir = path.join(this.config.dataDir, "events");
|
|
329
|
+
const resourceIds = [];
|
|
330
|
+
try {
|
|
331
|
+
await promises.access(eventsDir);
|
|
332
|
+
} catch {
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
const scanDir = async (dir) => {
|
|
336
|
+
const entries = await promises.readdir(dir, { withFileTypes: true });
|
|
337
|
+
for (const entry of entries) {
|
|
338
|
+
const fullPath = path.join(dir, entry.name);
|
|
339
|
+
if (entry.isDirectory()) {
|
|
340
|
+
if (entry.name.length > 2) {
|
|
341
|
+
resourceIds.push(resourceId(entry.name));
|
|
342
|
+
} else {
|
|
343
|
+
await scanDir(fullPath);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
await scanDir(eventsDir);
|
|
349
|
+
return resourceIds;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Create filename for event file
|
|
353
|
+
*/
|
|
354
|
+
createEventFilename(sequenceNumber) {
|
|
355
|
+
return `events-${sequenceNumber.toString().padStart(6, "0")}.jsonl`;
|
|
356
|
+
}
|
|
357
|
+
// ============================================================
|
|
358
|
+
// Sequence/Hash Tracking
|
|
359
|
+
// ============================================================
|
|
360
|
+
/**
|
|
361
|
+
* Get current sequence number for a resource
|
|
362
|
+
*/
|
|
363
|
+
getSequenceNumber(resourceId) {
|
|
364
|
+
return this.resourceSequences.get(resourceId) || 0;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Increment and return next sequence number for a resource
|
|
368
|
+
*/
|
|
369
|
+
getNextSequenceNumber(resourceId) {
|
|
370
|
+
const current = this.getSequenceNumber(resourceId);
|
|
371
|
+
const next = current + 1;
|
|
372
|
+
this.resourceSequences.set(resourceId, next);
|
|
373
|
+
return next;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Get last event hash for a resource
|
|
377
|
+
*/
|
|
378
|
+
getLastEventHash(resourceId) {
|
|
379
|
+
return this.resourceLastHash.get(resourceId) || null;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Set last event hash for a resource
|
|
383
|
+
*/
|
|
384
|
+
setLastEventHash(resourceId, hash) {
|
|
385
|
+
this.resourceLastHash.set(resourceId, hash);
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// src/event-log.ts
|
|
390
|
+
var EventLog = class {
|
|
391
|
+
// Expose storage for EventQuery (read operations)
|
|
392
|
+
storage;
|
|
393
|
+
constructor(config) {
|
|
394
|
+
this.storage = new EventStorage({
|
|
395
|
+
basePath: config.basePath,
|
|
396
|
+
dataDir: config.dataDir,
|
|
397
|
+
enableSharding: config.enableSharding ?? true,
|
|
398
|
+
maxEventsPerFile: config.maxEventsPerFile ?? 1e4
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Append event to log
|
|
403
|
+
* @param event - Resource event (from @semiont/core)
|
|
404
|
+
* @param resourceId - Branded ResourceId (from @semiont/core)
|
|
405
|
+
* @returns Stored event with metadata (sequence number, timestamp, checksum)
|
|
406
|
+
*/
|
|
407
|
+
async append(event, resourceId) {
|
|
408
|
+
return this.storage.appendEvent(event, resourceId);
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Get all events for a resource
|
|
412
|
+
* @param resourceId - Branded ResourceId (from @semiont/core)
|
|
413
|
+
*/
|
|
414
|
+
async getEvents(resourceId) {
|
|
415
|
+
return this.storage.getAllEvents(resourceId);
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Get all resource IDs
|
|
419
|
+
* @returns Array of branded ResourceId types
|
|
420
|
+
*/
|
|
421
|
+
async getAllResourceIds() {
|
|
422
|
+
return this.storage.getAllResourceIds();
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Query events with filter
|
|
426
|
+
* @param resourceId - Branded ResourceId (from @semiont/core)
|
|
427
|
+
* @param filter - Optional event filter
|
|
428
|
+
*/
|
|
429
|
+
async queryEvents(resourceId, filter) {
|
|
430
|
+
const events = await this.storage.getAllEvents(resourceId);
|
|
431
|
+
if (!filter) return events;
|
|
432
|
+
return events.filter((e) => {
|
|
433
|
+
if (filter.eventTypes && !filter.eventTypes.includes(e.event.type)) return false;
|
|
434
|
+
if (filter.fromSequence && e.metadata.sequenceNumber < filter.fromSequence) return false;
|
|
435
|
+
if (filter.fromTimestamp && e.event.timestamp < filter.fromTimestamp) return false;
|
|
436
|
+
if (filter.toTimestamp && e.event.timestamp > filter.toTimestamp) return false;
|
|
437
|
+
if (filter.userId && e.event.userId !== filter.userId) return false;
|
|
438
|
+
return true;
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
function toResourceUri(config, id) {
|
|
443
|
+
if (!config.baseUrl) {
|
|
444
|
+
throw new Error("baseUrl is required");
|
|
445
|
+
}
|
|
446
|
+
return resourceUri(`${config.baseUrl}/resources/${id}`);
|
|
447
|
+
}
|
|
448
|
+
function toAnnotationUri(config, id) {
|
|
449
|
+
if (!config.baseUrl) {
|
|
450
|
+
throw new Error("baseUrl is required");
|
|
451
|
+
}
|
|
452
|
+
return annotationUri(`${config.baseUrl}/annotations/${id}`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// src/subscriptions/event-subscriptions.ts
|
|
456
|
+
var EventSubscriptions = class {
|
|
457
|
+
// Per-resource subscriptions: ResourceUri -> Set of callbacks
|
|
458
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
459
|
+
// Global subscriptions for system-level events (no resourceId)
|
|
460
|
+
globalSubscriptions = /* @__PURE__ */ new Set();
|
|
461
|
+
/**
|
|
462
|
+
* Subscribe to events for a specific resource using full URI
|
|
463
|
+
* Returns an EventSubscription with unsubscribe function
|
|
464
|
+
*/
|
|
465
|
+
subscribe(resourceUri2, callback) {
|
|
466
|
+
if (!this.subscriptions.has(resourceUri2)) {
|
|
467
|
+
this.subscriptions.set(resourceUri2, /* @__PURE__ */ new Set());
|
|
468
|
+
}
|
|
469
|
+
const callbacks = this.subscriptions.get(resourceUri2);
|
|
470
|
+
callbacks.add(callback);
|
|
471
|
+
console.log(`[EventSubscriptions] Subscription added for resource ${resourceUri2} (total: ${callbacks.size} subscribers)`);
|
|
472
|
+
return {
|
|
473
|
+
resourceUri: resourceUri2,
|
|
474
|
+
callback,
|
|
475
|
+
unsubscribe: () => {
|
|
476
|
+
callbacks.delete(callback);
|
|
477
|
+
console.log(`[EventSubscriptions] Subscription removed for resource ${resourceUri2} (remaining: ${callbacks.size} subscribers)`);
|
|
478
|
+
if (callbacks.size === 0) {
|
|
479
|
+
this.subscriptions.delete(resourceUri2);
|
|
480
|
+
console.log(`[EventSubscriptions] No more subscribers for resource ${resourceUri2}, removed from subscriptions map`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Subscribe to all system-level events (no resourceId)
|
|
487
|
+
* Returns an EventSubscription with unsubscribe function
|
|
488
|
+
*
|
|
489
|
+
* Use this for consumers that need to react to global events like:
|
|
490
|
+
* - entitytype.added (global entity type collection changes)
|
|
491
|
+
* - Future system-level events (user.created, workspace.created, etc.)
|
|
492
|
+
*/
|
|
493
|
+
subscribeGlobal(callback) {
|
|
494
|
+
this.globalSubscriptions.add(callback);
|
|
495
|
+
console.log(`[EventSubscriptions] Global subscription added (total: ${this.globalSubscriptions.size} subscribers)`);
|
|
496
|
+
return {
|
|
497
|
+
resourceUri: "__global__",
|
|
498
|
+
// Special marker for global subscriptions
|
|
499
|
+
callback,
|
|
500
|
+
unsubscribe: () => {
|
|
501
|
+
this.globalSubscriptions.delete(callback);
|
|
502
|
+
console.log(`[EventSubscriptions] Global subscription removed (remaining: ${this.globalSubscriptions.size} subscribers)`);
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Notify all subscribers for a resource when a new event is appended
|
|
508
|
+
* @param resourceUri - Full resource URI (e.g., http://localhost:4000/resources/abc123)
|
|
509
|
+
*/
|
|
510
|
+
async notifySubscribers(resourceUri2, event) {
|
|
511
|
+
const callbacks = this.subscriptions.get(resourceUri2);
|
|
512
|
+
if (!callbacks || callbacks.size === 0) {
|
|
513
|
+
console.log(`[EventSubscriptions] Event ${event.event.type} for resource ${resourceUri2} - no subscribers to notify`);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
console.log(`[EventSubscriptions] Notifying ${callbacks.size} subscriber(s) of event ${event.event.type} for resource ${resourceUri2}`);
|
|
517
|
+
Array.from(callbacks).forEach((callback, index) => {
|
|
518
|
+
Promise.resolve(callback(event)).then(() => {
|
|
519
|
+
console.log(`[EventSubscriptions] Subscriber #${index + 1} successfully notified of ${event.event.type}`);
|
|
520
|
+
}).catch((error) => {
|
|
521
|
+
console.error(`[EventSubscriptions] Error in subscriber #${index + 1} for resource ${resourceUri2}, event ${event.event.type}:`, error);
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Notify all global subscribers when a system-level event is appended
|
|
527
|
+
*/
|
|
528
|
+
async notifyGlobalSubscribers(event) {
|
|
529
|
+
if (this.globalSubscriptions.size === 0) {
|
|
530
|
+
console.log(`[EventSubscriptions] System event ${event.event.type} - no global subscribers to notify`);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
console.log(`[EventSubscriptions] Notifying ${this.globalSubscriptions.size} global subscriber(s) of system event ${event.event.type}`);
|
|
534
|
+
Array.from(this.globalSubscriptions).forEach((callback, index) => {
|
|
535
|
+
Promise.resolve(callback(event)).then(() => {
|
|
536
|
+
console.log(`[EventSubscriptions] Global subscriber #${index + 1} successfully notified of ${event.event.type}`);
|
|
537
|
+
}).catch((error) => {
|
|
538
|
+
console.error(`[EventSubscriptions] Error in global subscriber #${index + 1} for system event ${event.event.type}:`, error);
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Get subscription count for a resource (useful for debugging)
|
|
544
|
+
*/
|
|
545
|
+
getSubscriptionCount(resourceUri2) {
|
|
546
|
+
return this.subscriptions.get(resourceUri2)?.size || 0;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Get total number of active subscriptions across all resources
|
|
550
|
+
*/
|
|
551
|
+
getTotalSubscriptions() {
|
|
552
|
+
let total = 0;
|
|
553
|
+
for (const callbacks of this.subscriptions.values()) {
|
|
554
|
+
total += callbacks.size;
|
|
555
|
+
}
|
|
556
|
+
return total;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Get total number of global subscriptions
|
|
560
|
+
*/
|
|
561
|
+
getGlobalSubscriptionCount() {
|
|
562
|
+
return this.globalSubscriptions.size;
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
var globalEventSubscriptions = null;
|
|
566
|
+
function getEventSubscriptions() {
|
|
567
|
+
if (!globalEventSubscriptions) {
|
|
568
|
+
globalEventSubscriptions = new EventSubscriptions();
|
|
569
|
+
console.log("[EventSubscriptions] Created global singleton instance");
|
|
570
|
+
}
|
|
571
|
+
return globalEventSubscriptions;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// src/event-bus.ts
|
|
575
|
+
var EventBus = class {
|
|
576
|
+
// Expose subscriptions for direct access (legacy compatibility)
|
|
577
|
+
subscriptions;
|
|
578
|
+
identifierConfig;
|
|
579
|
+
constructor(config) {
|
|
580
|
+
this.identifierConfig = config.identifierConfig;
|
|
581
|
+
this.subscriptions = getEventSubscriptions();
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Publish event to subscribers
|
|
585
|
+
* - Resource events: notifies resource-scoped subscribers
|
|
586
|
+
* - System events: notifies global subscribers
|
|
587
|
+
* @param event - Stored event (from @semiont/core)
|
|
588
|
+
*/
|
|
589
|
+
async publish(event) {
|
|
590
|
+
if (isSystemEvent(event.event)) {
|
|
591
|
+
await this.subscriptions.notifyGlobalSubscribers(event);
|
|
592
|
+
} else if (isResourceEvent(event.event)) {
|
|
593
|
+
const resourceId = event.event.resourceId;
|
|
594
|
+
const resourceUri2 = toResourceUri(this.identifierConfig, resourceId);
|
|
595
|
+
await this.subscriptions.notifySubscribers(resourceUri2, event);
|
|
596
|
+
} else {
|
|
597
|
+
console.warn("[EventBus] Event is neither resource nor system event:", event.event.type);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Subscribe to events for a specific resource
|
|
602
|
+
* @param resourceId - Branded ResourceId (from @semiont/core)
|
|
603
|
+
* @param callback - Event callback function
|
|
604
|
+
* @returns EventSubscription with unsubscribe function
|
|
605
|
+
*/
|
|
606
|
+
subscribe(resourceId, callback) {
|
|
607
|
+
const resourceUri2 = toResourceUri(this.identifierConfig, resourceId);
|
|
608
|
+
return this.subscriptions.subscribe(resourceUri2, callback);
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Subscribe to all system-level events
|
|
612
|
+
* @param callback - Event callback function
|
|
613
|
+
* @returns EventSubscription with unsubscribe function
|
|
614
|
+
*/
|
|
615
|
+
subscribeGlobal(callback) {
|
|
616
|
+
return this.subscriptions.subscribeGlobal(callback);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Unsubscribe from resource events
|
|
620
|
+
* @param resourceId - Branded ResourceId (from @semiont/core)
|
|
621
|
+
* @param callback - Event callback function to remove
|
|
622
|
+
*/
|
|
623
|
+
unsubscribe(resourceId, callback) {
|
|
624
|
+
const resourceUri2 = toResourceUri(this.identifierConfig, resourceId);
|
|
625
|
+
const callbacks = this.subscriptions.subscriptions.get(resourceUri2);
|
|
626
|
+
if (callbacks) {
|
|
627
|
+
callbacks.delete(callback);
|
|
628
|
+
if (callbacks.size === 0) {
|
|
629
|
+
this.subscriptions.subscriptions.delete(resourceUri2);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Unsubscribe from global events
|
|
635
|
+
* @param callback - Event callback function to remove
|
|
636
|
+
*/
|
|
637
|
+
unsubscribeGlobal(callback) {
|
|
638
|
+
this.subscriptions.globalSubscriptions.delete(callback);
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Get subscriber count for a resource
|
|
642
|
+
* @param resourceId - Branded ResourceId (from @semiont/core)
|
|
643
|
+
* @returns Number of active subscribers
|
|
644
|
+
*/
|
|
645
|
+
getSubscriberCount(resourceId) {
|
|
646
|
+
const resourceUri2 = toResourceUri(this.identifierConfig, resourceId);
|
|
647
|
+
return this.subscriptions.getSubscriptionCount(resourceUri2);
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Get total number of active subscriptions across all resources
|
|
651
|
+
*/
|
|
652
|
+
getTotalSubscriptions() {
|
|
653
|
+
return this.subscriptions.getTotalSubscriptions();
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Get total number of global subscriptions
|
|
657
|
+
*/
|
|
658
|
+
getGlobalSubscriptionCount() {
|
|
659
|
+
return this.subscriptions.getGlobalSubscriptionCount();
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
var ViewMaterializer = class {
|
|
663
|
+
constructor(viewStorage, config) {
|
|
664
|
+
this.viewStorage = viewStorage;
|
|
665
|
+
this.config = config;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Materialize resource view from events
|
|
669
|
+
* Loads existing view if cached, otherwise rebuilds from events
|
|
670
|
+
*/
|
|
671
|
+
async materialize(events, resourceId) {
|
|
672
|
+
const existing = await this.viewStorage.get(resourceId);
|
|
673
|
+
if (existing) {
|
|
674
|
+
return existing;
|
|
675
|
+
}
|
|
676
|
+
if (events.length === 0) return null;
|
|
677
|
+
const view = this.materializeFromEvents(events, resourceId);
|
|
678
|
+
await this.viewStorage.save(resourceId, view);
|
|
679
|
+
return view;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Materialize view incrementally with a single event
|
|
683
|
+
* Falls back to full rebuild if view doesn't exist
|
|
684
|
+
*/
|
|
685
|
+
async materializeIncremental(resourceId, event, getAllEvents) {
|
|
686
|
+
console.log(`[ViewMaterializer] Updating view for ${resourceId} with event ${event.type}`);
|
|
687
|
+
let view = await this.viewStorage.get(resourceId);
|
|
688
|
+
if (!view) {
|
|
689
|
+
console.log(`[ViewMaterializer] No view found, rebuilding from scratch`);
|
|
690
|
+
const events = await getAllEvents();
|
|
691
|
+
view = this.materializeFromEvents(events, resourceId);
|
|
692
|
+
} else {
|
|
693
|
+
console.log(`[ViewMaterializer] Applying event incrementally to existing view (version ${view.annotations.version})`);
|
|
694
|
+
this.applyEventToResource(view.resource, event);
|
|
695
|
+
this.applyEventToAnnotations(view.annotations, event);
|
|
696
|
+
view.annotations.version++;
|
|
697
|
+
view.annotations.updatedAt = event.timestamp;
|
|
698
|
+
}
|
|
699
|
+
await this.viewStorage.save(resourceId, view);
|
|
700
|
+
console.log(`[ViewMaterializer] View saved (version ${view.annotations.version}, ${view.annotations.annotations.length} annotations)`);
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Materialize view from event list (full rebuild)
|
|
704
|
+
*/
|
|
705
|
+
materializeFromEvents(events, resourceId) {
|
|
706
|
+
const backendUrl = this.config.backendUrl;
|
|
707
|
+
const normalizedBase = backendUrl.endsWith("/") ? backendUrl.slice(0, -1) : backendUrl;
|
|
708
|
+
const resource = {
|
|
709
|
+
"@context": "https://schema.org/",
|
|
710
|
+
"@id": `${normalizedBase}/resources/${resourceId}`,
|
|
711
|
+
name: "",
|
|
712
|
+
representations: [],
|
|
713
|
+
archived: false,
|
|
714
|
+
entityTypes: [],
|
|
715
|
+
creationMethod: "api"
|
|
716
|
+
};
|
|
717
|
+
const annotations = {
|
|
718
|
+
resourceId,
|
|
719
|
+
annotations: [],
|
|
720
|
+
version: 0,
|
|
721
|
+
updatedAt: ""
|
|
722
|
+
};
|
|
723
|
+
events.sort((a, b) => a.metadata.sequenceNumber - b.metadata.sequenceNumber);
|
|
724
|
+
for (const storedEvent of events) {
|
|
725
|
+
this.applyEventToResource(resource, storedEvent.event);
|
|
726
|
+
this.applyEventToAnnotations(annotations, storedEvent.event);
|
|
727
|
+
annotations.version++;
|
|
728
|
+
annotations.updatedAt = storedEvent.event.timestamp;
|
|
729
|
+
}
|
|
730
|
+
return { resource, annotations };
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Apply an event to ResourceDescriptor state (metadata only)
|
|
734
|
+
*/
|
|
735
|
+
applyEventToResource(resource, event) {
|
|
736
|
+
switch (event.type) {
|
|
737
|
+
case "resource.created":
|
|
738
|
+
resource.name = event.payload.name;
|
|
739
|
+
resource.entityTypes = event.payload.entityTypes || [];
|
|
740
|
+
resource.dateCreated = event.timestamp;
|
|
741
|
+
resource.creationMethod = event.payload.creationMethod || "api";
|
|
742
|
+
resource.wasAttributedTo = didToAgent(event.userId);
|
|
743
|
+
if (!resource.representations) resource.representations = [];
|
|
744
|
+
const reps = Array.isArray(resource.representations) ? resource.representations : [resource.representations];
|
|
745
|
+
reps.push({
|
|
746
|
+
mediaType: event.payload.format,
|
|
747
|
+
checksum: event.payload.contentChecksum,
|
|
748
|
+
byteSize: event.payload.contentByteSize,
|
|
749
|
+
rel: "original",
|
|
750
|
+
language: event.payload.language
|
|
751
|
+
});
|
|
752
|
+
resource.representations = reps;
|
|
753
|
+
resource.isDraft = event.payload.isDraft;
|
|
754
|
+
resource.wasDerivedFrom = event.payload.generatedFrom;
|
|
755
|
+
break;
|
|
756
|
+
case "resource.cloned":
|
|
757
|
+
resource.name = event.payload.name;
|
|
758
|
+
resource.entityTypes = event.payload.entityTypes || [];
|
|
759
|
+
resource.dateCreated = event.timestamp;
|
|
760
|
+
resource.creationMethod = "clone";
|
|
761
|
+
resource.sourceResourceId = event.payload.parentResourceId;
|
|
762
|
+
resource.wasAttributedTo = didToAgent(event.userId);
|
|
763
|
+
if (!resource.representations) resource.representations = [];
|
|
764
|
+
const reps2 = Array.isArray(resource.representations) ? resource.representations : [resource.representations];
|
|
765
|
+
reps2.push({
|
|
766
|
+
mediaType: event.payload.format,
|
|
767
|
+
checksum: event.payload.contentChecksum,
|
|
768
|
+
byteSize: event.payload.contentByteSize,
|
|
769
|
+
rel: "original",
|
|
770
|
+
language: event.payload.language
|
|
771
|
+
});
|
|
772
|
+
resource.representations = reps2;
|
|
773
|
+
break;
|
|
774
|
+
case "resource.archived":
|
|
775
|
+
resource.archived = true;
|
|
776
|
+
break;
|
|
777
|
+
case "resource.unarchived":
|
|
778
|
+
resource.archived = false;
|
|
779
|
+
break;
|
|
780
|
+
case "entitytag.added":
|
|
781
|
+
if (!resource.entityTypes) resource.entityTypes = [];
|
|
782
|
+
if (!resource.entityTypes.includes(event.payload.entityType)) {
|
|
783
|
+
resource.entityTypes.push(event.payload.entityType);
|
|
784
|
+
}
|
|
785
|
+
break;
|
|
786
|
+
case "entitytag.removed":
|
|
787
|
+
if (resource.entityTypes) {
|
|
788
|
+
resource.entityTypes = resource.entityTypes.filter(
|
|
789
|
+
(t) => t !== event.payload.entityType
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
break;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Apply an event to ResourceAnnotations (annotation collections only)
|
|
797
|
+
*/
|
|
798
|
+
applyEventToAnnotations(annotations, event) {
|
|
799
|
+
switch (event.type) {
|
|
800
|
+
case "annotation.added":
|
|
801
|
+
annotations.annotations.push({
|
|
802
|
+
...event.payload.annotation,
|
|
803
|
+
creator: didToAgent(event.userId),
|
|
804
|
+
created: new Date(event.timestamp).toISOString()
|
|
805
|
+
});
|
|
806
|
+
break;
|
|
807
|
+
case "annotation.removed":
|
|
808
|
+
annotations.annotations = annotations.annotations.filter(
|
|
809
|
+
(a) => a.id !== event.payload.annotationId && !a.id.endsWith(`/annotations/${event.payload.annotationId}`)
|
|
810
|
+
);
|
|
811
|
+
break;
|
|
812
|
+
case "annotation.body.updated":
|
|
813
|
+
const annotation = annotations.annotations.find(
|
|
814
|
+
(a) => a.id === event.payload.annotationId || a.id.endsWith(`/annotations/${event.payload.annotationId}`)
|
|
815
|
+
);
|
|
816
|
+
if (annotation) {
|
|
817
|
+
if (!Array.isArray(annotation.body)) {
|
|
818
|
+
annotation.body = annotation.body ? [annotation.body] : [];
|
|
819
|
+
}
|
|
820
|
+
for (const op of event.payload.operations) {
|
|
821
|
+
if (op.op === "add") {
|
|
822
|
+
const exists = findBodyItem(annotation.body, op.item) !== -1;
|
|
823
|
+
if (!exists) {
|
|
824
|
+
annotation.body.push(op.item);
|
|
825
|
+
}
|
|
826
|
+
} else if (op.op === "remove") {
|
|
827
|
+
const index = findBodyItem(annotation.body, op.item);
|
|
828
|
+
if (index !== -1) {
|
|
829
|
+
annotation.body.splice(index, 1);
|
|
830
|
+
}
|
|
831
|
+
} else if (op.op === "replace") {
|
|
832
|
+
const index = findBodyItem(annotation.body, op.oldItem);
|
|
833
|
+
if (index !== -1) {
|
|
834
|
+
annotation.body[index] = op.newItem;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
annotation.modified = new Date(event.timestamp).toISOString();
|
|
839
|
+
}
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Materialize entity types view - System-level view
|
|
845
|
+
*/
|
|
846
|
+
async materializeEntityTypes(entityType) {
|
|
847
|
+
const entityTypesPath = path.join(
|
|
848
|
+
this.config.basePath,
|
|
849
|
+
"projections",
|
|
850
|
+
"entity-types",
|
|
851
|
+
"entity-types.json"
|
|
852
|
+
);
|
|
853
|
+
let view = { entityTypes: [] };
|
|
854
|
+
try {
|
|
855
|
+
const content = await promises.readFile(entityTypesPath, "utf-8");
|
|
856
|
+
view = JSON.parse(content);
|
|
857
|
+
} catch (error) {
|
|
858
|
+
if (error.code !== "ENOENT") throw error;
|
|
859
|
+
}
|
|
860
|
+
const entityTypeSet = new Set(view.entityTypes);
|
|
861
|
+
entityTypeSet.add(entityType);
|
|
862
|
+
view.entityTypes = Array.from(entityTypeSet).sort();
|
|
863
|
+
await promises.mkdir(path.dirname(entityTypesPath), { recursive: true });
|
|
864
|
+
await promises.writeFile(entityTypesPath, JSON.stringify(view, null, 2));
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
// src/view-manager.ts
|
|
869
|
+
var ViewManager = class {
|
|
870
|
+
// Expose materializer for direct access to view methods
|
|
871
|
+
materializer;
|
|
872
|
+
constructor(viewStorage, config) {
|
|
873
|
+
const materializerConfig = {
|
|
874
|
+
basePath: config.basePath,
|
|
875
|
+
backendUrl: config.backendUrl
|
|
876
|
+
};
|
|
877
|
+
this.materializer = new ViewMaterializer(viewStorage, materializerConfig);
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Update resource view with a new event
|
|
881
|
+
* Falls back to full rebuild if view doesn't exist
|
|
882
|
+
* @param resourceId - Branded ResourceId (from @semiont/core)
|
|
883
|
+
* @param event - Resource event (from @semiont/core)
|
|
884
|
+
* @param getAllEvents - Function to retrieve all events for rebuild if needed
|
|
885
|
+
*/
|
|
886
|
+
async materializeResource(resourceId, event, getAllEvents) {
|
|
887
|
+
await this.materializer.materializeIncremental(resourceId, event, getAllEvents);
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Update system-level view (currently only entity types)
|
|
891
|
+
* @param eventType - Type of system event
|
|
892
|
+
* @param payload - Event payload
|
|
893
|
+
*/
|
|
894
|
+
async materializeSystem(eventType, payload) {
|
|
895
|
+
if (eventType === "entitytype.added") {
|
|
896
|
+
await this.materializer.materializeEntityTypes(payload.entityType);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Get resource view (builds from events if needed)
|
|
901
|
+
* @param resourceId - Branded ResourceId (from @semiont/core)
|
|
902
|
+
* @param events - Stored events for the resource (from @semiont/core)
|
|
903
|
+
* @returns Resource view or null if no events
|
|
904
|
+
*/
|
|
905
|
+
async getOrMaterialize(resourceId, events) {
|
|
906
|
+
return this.materializer.materialize(events, resourceId);
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
// src/event-store.ts
|
|
911
|
+
var EventStore = class {
|
|
912
|
+
// Focused components - each with single responsibility
|
|
913
|
+
log;
|
|
914
|
+
bus;
|
|
915
|
+
views;
|
|
916
|
+
constructor(config, viewStorage, identifierConfig) {
|
|
917
|
+
const logConfig = {
|
|
918
|
+
basePath: config.basePath,
|
|
919
|
+
dataDir: config.dataDir,
|
|
920
|
+
enableSharding: config.enableSharding,
|
|
921
|
+
maxEventsPerFile: config.maxEventsPerFile
|
|
922
|
+
};
|
|
923
|
+
this.log = new EventLog(logConfig);
|
|
924
|
+
const busConfig = {
|
|
925
|
+
identifierConfig
|
|
926
|
+
};
|
|
927
|
+
this.bus = new EventBus(busConfig);
|
|
928
|
+
const viewConfig = {
|
|
929
|
+
basePath: config.basePath,
|
|
930
|
+
backendUrl: identifierConfig.baseUrl
|
|
931
|
+
};
|
|
932
|
+
this.views = new ViewManager(viewStorage, viewConfig);
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Append an event to the store
|
|
936
|
+
* Coordinates: persistence → view → notification
|
|
937
|
+
*/
|
|
938
|
+
async appendEvent(event) {
|
|
939
|
+
const resourceId = event.resourceId || "__system__";
|
|
940
|
+
const storedEvent = await this.log.append(event, resourceId);
|
|
941
|
+
if (resourceId === "__system__") {
|
|
942
|
+
await this.views.materializeSystem(
|
|
943
|
+
storedEvent.event.type,
|
|
944
|
+
storedEvent.event.payload
|
|
945
|
+
);
|
|
946
|
+
} else {
|
|
947
|
+
await this.views.materializeResource(
|
|
948
|
+
resourceId,
|
|
949
|
+
storedEvent.event,
|
|
950
|
+
() => this.log.getEvents(resourceId)
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
await this.bus.publish(storedEvent);
|
|
954
|
+
return storedEvent;
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
var FilesystemViewStorage = class {
|
|
958
|
+
basePath;
|
|
959
|
+
constructor(basePath, projectRoot) {
|
|
960
|
+
if (path.isAbsolute(basePath)) {
|
|
961
|
+
this.basePath = basePath;
|
|
962
|
+
} else if (projectRoot) {
|
|
963
|
+
this.basePath = path.resolve(projectRoot, basePath);
|
|
964
|
+
} else {
|
|
965
|
+
this.basePath = path.resolve(basePath);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
getProjectionPath(resourceId) {
|
|
969
|
+
const [ab, cd] = getShardPath(resourceId);
|
|
970
|
+
return path.join(this.basePath, "projections", "resources", ab, cd, `${resourceId}.json`);
|
|
971
|
+
}
|
|
972
|
+
async save(resourceId, projection) {
|
|
973
|
+
const projPath = this.getProjectionPath(resourceId);
|
|
974
|
+
const projDir = path.dirname(projPath);
|
|
975
|
+
await promises.mkdir(projDir, { recursive: true });
|
|
976
|
+
await promises.writeFile(projPath, JSON.stringify(projection, null, 2), "utf-8");
|
|
977
|
+
}
|
|
978
|
+
async get(resourceId) {
|
|
979
|
+
const projPath = this.getProjectionPath(resourceId);
|
|
980
|
+
try {
|
|
981
|
+
const content = await promises.readFile(projPath, "utf-8");
|
|
982
|
+
return JSON.parse(content);
|
|
983
|
+
} catch (error) {
|
|
984
|
+
if (error.code === "ENOENT") {
|
|
985
|
+
return null;
|
|
986
|
+
}
|
|
987
|
+
throw error;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
async delete(resourceId) {
|
|
991
|
+
const projPath = this.getProjectionPath(resourceId);
|
|
992
|
+
try {
|
|
993
|
+
await promises.unlink(projPath);
|
|
994
|
+
} catch (error) {
|
|
995
|
+
if (error.code !== "ENOENT") {
|
|
996
|
+
throw error;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
async exists(resourceId) {
|
|
1001
|
+
const projPath = this.getProjectionPath(resourceId);
|
|
1002
|
+
try {
|
|
1003
|
+
await promises.access(projPath);
|
|
1004
|
+
return true;
|
|
1005
|
+
} catch {
|
|
1006
|
+
return false;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
async getAll() {
|
|
1010
|
+
const views = [];
|
|
1011
|
+
const annotationsPath = path.join(this.basePath, "projections", "resources");
|
|
1012
|
+
try {
|
|
1013
|
+
const walkDir = async (dir) => {
|
|
1014
|
+
const entries = await promises.readdir(dir, { withFileTypes: true });
|
|
1015
|
+
for (const entry of entries) {
|
|
1016
|
+
const fullPath = path.join(dir, entry.name);
|
|
1017
|
+
if (entry.isDirectory()) {
|
|
1018
|
+
await walkDir(fullPath);
|
|
1019
|
+
} else if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
1020
|
+
try {
|
|
1021
|
+
const content = await promises.readFile(fullPath, "utf-8");
|
|
1022
|
+
const view = JSON.parse(content);
|
|
1023
|
+
views.push(view);
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
console.error(`[ViewStorage] Failed to read view ${fullPath}:`, error);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
};
|
|
1030
|
+
await walkDir(annotationsPath);
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
if (error.code === "ENOENT") {
|
|
1033
|
+
return [];
|
|
1034
|
+
}
|
|
1035
|
+
throw error;
|
|
1036
|
+
}
|
|
1037
|
+
return views;
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
// src/query/event-query.ts
|
|
1042
|
+
var EventQuery = class {
|
|
1043
|
+
constructor(eventStorage) {
|
|
1044
|
+
this.eventStorage = eventStorage;
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Query events with filters
|
|
1048
|
+
* Supports filtering by: userId, eventTypes, timestamps, sequence number, limit
|
|
1049
|
+
*/
|
|
1050
|
+
async queryEvents(query) {
|
|
1051
|
+
if (!query.resourceId) {
|
|
1052
|
+
throw new Error("resourceId is required for event queries");
|
|
1053
|
+
}
|
|
1054
|
+
const allEvents = await this.eventStorage.getAllEvents(query.resourceId);
|
|
1055
|
+
let results = allEvents;
|
|
1056
|
+
if (query.userId) {
|
|
1057
|
+
results = results.filter((e) => e.event.userId === query.userId);
|
|
1058
|
+
}
|
|
1059
|
+
if (query.eventTypes && query.eventTypes.length > 0) {
|
|
1060
|
+
results = results.filter((e) => query.eventTypes.includes(e.event.type));
|
|
1061
|
+
}
|
|
1062
|
+
if (query.fromTimestamp) {
|
|
1063
|
+
results = results.filter((e) => e.event.timestamp >= query.fromTimestamp);
|
|
1064
|
+
}
|
|
1065
|
+
if (query.toTimestamp) {
|
|
1066
|
+
results = results.filter((e) => e.event.timestamp <= query.toTimestamp);
|
|
1067
|
+
}
|
|
1068
|
+
if (query.fromSequence) {
|
|
1069
|
+
results = results.filter((e) => e.metadata.sequenceNumber >= query.fromSequence);
|
|
1070
|
+
}
|
|
1071
|
+
if (query.limit && query.limit > 0) {
|
|
1072
|
+
results = results.slice(0, query.limit);
|
|
1073
|
+
}
|
|
1074
|
+
return results;
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Get all events for a specific resource (no filters)
|
|
1078
|
+
*/
|
|
1079
|
+
async getResourceEvents(resourceId) {
|
|
1080
|
+
return this.eventStorage.getAllEvents(resourceId);
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Get the last event from a specific file
|
|
1084
|
+
* Useful for initializing sequence numbers and last hashes
|
|
1085
|
+
*/
|
|
1086
|
+
async getLastEvent(resourceId, filename) {
|
|
1087
|
+
return this.eventStorage.getLastEvent(resourceId, filename);
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Get the latest event for a resource across all files
|
|
1091
|
+
*/
|
|
1092
|
+
async getLatestEvent(resourceId) {
|
|
1093
|
+
const files = await this.eventStorage.getEventFiles(resourceId);
|
|
1094
|
+
if (files.length === 0) return null;
|
|
1095
|
+
for (let i = files.length - 1; i >= 0; i--) {
|
|
1096
|
+
const file = files[i];
|
|
1097
|
+
if (!file) continue;
|
|
1098
|
+
const lastEvent = await this.eventStorage.getLastEvent(resourceId, file);
|
|
1099
|
+
if (lastEvent) return lastEvent;
|
|
1100
|
+
}
|
|
1101
|
+
return null;
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Get event count for a resource
|
|
1105
|
+
*/
|
|
1106
|
+
async getEventCount(resourceId) {
|
|
1107
|
+
const events = await this.getResourceEvents(resourceId);
|
|
1108
|
+
return events.length;
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Check if a resource has any events
|
|
1112
|
+
*/
|
|
1113
|
+
async hasEvents(resourceId) {
|
|
1114
|
+
const files = await this.eventStorage.getEventFiles(resourceId);
|
|
1115
|
+
return files.length > 0;
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
// src/validation/event-validator.ts
|
|
1120
|
+
var EventValidator = class {
|
|
1121
|
+
/**
|
|
1122
|
+
* Validate event chain integrity for a resource's events
|
|
1123
|
+
* Checks that each event properly links to the previous event
|
|
1124
|
+
*/
|
|
1125
|
+
validateEventChain(events) {
|
|
1126
|
+
const errors = [];
|
|
1127
|
+
for (let i = 1; i < events.length; i++) {
|
|
1128
|
+
const prev = events[i - 1];
|
|
1129
|
+
const curr = events[i];
|
|
1130
|
+
if (!prev || !curr) continue;
|
|
1131
|
+
if (curr.metadata.prevEventHash !== prev.metadata.checksum) {
|
|
1132
|
+
errors.push(
|
|
1133
|
+
`Event chain broken at sequence ${curr.metadata.sequenceNumber}: prevEventHash=${curr.metadata.prevEventHash} but previous checksum=${prev.metadata.checksum}`
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
const calculated = sha256(curr.event);
|
|
1137
|
+
if (calculated !== curr.metadata.checksum) {
|
|
1138
|
+
errors.push(
|
|
1139
|
+
`Checksum mismatch at sequence ${curr.metadata.sequenceNumber}: calculated=${calculated} but stored=${curr.metadata.checksum}`
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return {
|
|
1144
|
+
valid: errors.length === 0,
|
|
1145
|
+
errors
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Validate a single event's checksum
|
|
1150
|
+
* Useful for validating events before writing them
|
|
1151
|
+
*/
|
|
1152
|
+
validateEventChecksum(event) {
|
|
1153
|
+
const calculated = sha256(event.event);
|
|
1154
|
+
return calculated === event.metadata.checksum;
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Validate that an event properly links to a previous event
|
|
1158
|
+
* Returns true if the link is valid or if this is the first event
|
|
1159
|
+
*/
|
|
1160
|
+
validateEventLink(currentEvent, previousEvent) {
|
|
1161
|
+
if (!previousEvent) {
|
|
1162
|
+
return !currentEvent.metadata.prevEventHash;
|
|
1163
|
+
}
|
|
1164
|
+
return currentEvent.metadata.prevEventHash === previousEvent.metadata.checksum;
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
export { EventBus, EventLog, EventQuery, EventStorage, EventStore, EventSubscriptions, EventValidator, FilesystemViewStorage, ViewManager, ViewMaterializer, getEventSubscriptions, getShardPath, jumpConsistentHash, sha256, toAnnotationUri, toResourceUri };
|
|
1169
|
+
//# sourceMappingURL=index.js.map
|
|
1170
|
+
//# sourceMappingURL=index.js.map
|