soroban-events 1.0.0 → 2.0.0

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/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 poaspergillus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
20
+ BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
21
+ ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
22
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/package.json CHANGED
@@ -1,19 +1,28 @@
1
1
  {
2
2
  "name": "soroban-events",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "Resilient, windowed event streamer and XDR decoder for Soroban RPC",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "exports": {
8
- ".": "./src/index.js"
8
+ ".": {
9
+ "types": "./src/index.d.ts",
10
+ "import": "./src/index.js"
11
+ }
9
12
  },
10
13
  "files": [
11
- "src/"
14
+ "src/",
15
+ "README.md",
16
+ "LICENSE"
12
17
  ],
13
18
  "scripts": {
14
19
  "test": "node --test tests/*.test.js",
15
20
  "test:live": "node tests/test-engine.js",
16
- "pack:check": "npm pack --dry-run"
21
+ "pack:check": "npm pack --dry-run",
22
+ "cli": "node src/cli.js",
23
+ "test:unit": "node --test tests/*.test.js",
24
+ "audit": "node scripts/audit.mjs",
25
+ "pack:audit": "node scripts/pack-audit.mjs"
17
26
  },
18
27
  "keywords": [
19
28
  "soroban",
@@ -22,7 +31,11 @@
22
31
  "event-streaming",
23
32
  "event-indexer",
24
33
  "xdr",
25
- "rpc"
34
+ "rpc",
35
+ "soroban-rpc",
36
+ "blockchain-events",
37
+ "event-replay",
38
+ "event-backfill"
26
39
  ],
27
40
  "license": "MIT",
28
41
  "engines": {
@@ -30,5 +43,9 @@
30
43
  },
31
44
  "dependencies": {
32
45
  "@stellar/stellar-sdk": "^17.1.0"
33
- }
46
+ },
47
+ "bin": {
48
+ "soroban-events": "./src/cli.js"
49
+ },
50
+ "types": "./src/index.d.ts"
34
51
  }
@@ -0,0 +1,451 @@
1
+ import { assertEventStore } from './store.js';
2
+ export function orderBackfillEvents(windowResults) {
3
+ if (!Array.isArray(windowResults)) {
4
+ throw new TypeError(
5
+ 'window results must be an array'
6
+ );
7
+ }
8
+
9
+ const events = [];
10
+
11
+ for (const result of windowResults) {
12
+ if (!Array.isArray(result)) {
13
+ throw new TypeError(
14
+ 'each window result must be an array'
15
+ );
16
+ }
17
+
18
+ events.push(...result);
19
+ }
20
+
21
+ events.sort(compareEvents);
22
+
23
+ const seen = new Set();
24
+ const output = [];
25
+
26
+ for (const event of events) {
27
+ const id = event?.id;
28
+
29
+ if (id != null) {
30
+ if (seen.has(id)) continue;
31
+ seen.add(id);
32
+ }
33
+
34
+ output.push(event);
35
+ }
36
+
37
+ return output;
38
+ }
39
+
40
+ export function compareEvents(a, b) {
41
+ const ledgerA = Number(a?.ledger ?? 0);
42
+ const ledgerB = Number(b?.ledger ?? 0);
43
+
44
+ if (ledgerA !== ledgerB) {
45
+ return ledgerA - ledgerB;
46
+ }
47
+
48
+ const txA = Number(
49
+ a?.transactionIndex ??
50
+ a?.txIndex ??
51
+ 0
52
+ );
53
+
54
+ const txB = Number(
55
+ b?.transactionIndex ??
56
+ b?.txIndex ??
57
+ 0
58
+ );
59
+
60
+ if (txA !== txB) {
61
+ return txA - txB;
62
+ }
63
+
64
+ const opA = Number(
65
+ a?.operationIndex ??
66
+ a?.opIndex ??
67
+ 0
68
+ );
69
+
70
+ const opB = Number(
71
+ b?.operationIndex ??
72
+ b?.opIndex ??
73
+ 0
74
+ );
75
+
76
+ if (opA !== opB) {
77
+ return opA - opB;
78
+ }
79
+
80
+ return String(a?.id ?? "").localeCompare(
81
+ String(b?.id ?? "")
82
+ );
83
+ }
84
+
85
+ export class BackfillError extends Error {
86
+ constructor(message, options = {}) {
87
+ super(message, options);
88
+ this.name = 'BackfillError';
89
+ }
90
+ }
91
+
92
+ export class BackfillEngine {
93
+ constructor(streamer, options = {}) {
94
+ if (
95
+ !streamer ||
96
+ typeof streamer.getEventsWindowed !== 'function'
97
+ ) {
98
+ throw new TypeError(
99
+ 'backfill streamer must implement getEventsWindowed()'
100
+ );
101
+ }
102
+
103
+ this.streamer = streamer;
104
+ this.concurrency = options.concurrency ?? 1;
105
+ this.windowSize =
106
+ options.windowSize ??
107
+ streamer.windowSize ??
108
+ 9000;
109
+
110
+ if (
111
+ !Number.isSafeInteger(this.concurrency) ||
112
+ this.concurrency < 1
113
+ ) {
114
+ throw new TypeError(
115
+ 'backfill concurrency must be a positive safe integer'
116
+ );
117
+ }
118
+
119
+ if (
120
+ !Number.isSafeInteger(this.windowSize) ||
121
+ this.windowSize < 1
122
+ ) {
123
+ throw new TypeError(
124
+ 'backfill windowSize must be a positive safe integer'
125
+ );
126
+ }
127
+ }
128
+
129
+ async run({
130
+ startLedger,
131
+ endLedger,
132
+ filters = [],
133
+ pipeline = null,
134
+ onEvent,
135
+ onProgress,
136
+ signal,
137
+ checkpoint = null,
138
+ checkpointKey = 'default',
139
+ dedupe = true,
140
+ store = null
141
+ } = {}) {
142
+ validateLedger(startLedger, 'startLedger');
143
+ validateLedger(endLedger, 'endLedger');
144
+
145
+ if (endLedger < startLedger) {
146
+ throw new TypeError(
147
+ 'endLedger must be greater than or equal to startLedger'
148
+ );
149
+ }
150
+
151
+ if (
152
+ pipeline != null &&
153
+ typeof pipeline.process !== 'function'
154
+ ) {
155
+ throw new TypeError(
156
+ 'pipeline must implement process()'
157
+ );
158
+ }
159
+
160
+ if (typeof onEvent !== 'function') {
161
+ throw new TypeError(
162
+ 'onEvent must be a function'
163
+ );
164
+ }
165
+
166
+ if (
167
+ onProgress != null &&
168
+ typeof onProgress !== 'function'
169
+ ) {
170
+ throw new TypeError(
171
+ 'onProgress must be a function'
172
+ );
173
+ }
174
+
175
+ if (checkpoint != null) {
176
+ if (
177
+ typeof checkpoint.resumeFrom !== 'function' ||
178
+ typeof checkpoint.save !== 'function'
179
+ ) {
180
+ throw new TypeError(
181
+ 'checkpoint must implement resumeFrom() and save()'
182
+ );
183
+ }
184
+ }
185
+
186
+ if (store != null) {
187
+ assertEventStore(store);
188
+ }
189
+
190
+ throwIfAborted(signal);
191
+
192
+ let effectiveStart = startLedger;
193
+
194
+ if (checkpoint) {
195
+ effectiveStart = await checkpoint.resumeFrom(
196
+ checkpointKey,
197
+ startLedger
198
+ );
199
+
200
+ if (
201
+ !Number.isSafeInteger(effectiveStart) ||
202
+ effectiveStart < 1
203
+ ) {
204
+ throw new BackfillError(
205
+ 'checkpoint returned an invalid ledger'
206
+ );
207
+ }
208
+
209
+ if (effectiveStart > endLedger) {
210
+ return {
211
+ startLedger,
212
+ endLedger,
213
+ effectiveStart,
214
+ processed: 0,
215
+ windows: 0,
216
+ windowsCompleted: 0,
217
+ skipped: true
218
+ };
219
+ }
220
+ }
221
+
222
+ const windows = buildWindows(
223
+ effectiveStart,
224
+ endLedger,
225
+ this.windowSize
226
+ );
227
+
228
+ let processed = 0;
229
+ let windowsCompleted = 0;
230
+
231
+ const seen = dedupe ? new Set() : null;
232
+ const queue = windows.map((window, index) => ({
233
+ ...window,
234
+ index
235
+ }));
236
+
237
+ /*
238
+ * A window may finish before an earlier window.
239
+ *
240
+ * We therefore track completed window indexes separately
241
+ * and only advance the durable checkpoint across the
242
+ * contiguous completed prefix.
243
+ *
244
+ * Example:
245
+ *
246
+ * window 0 = running
247
+ * window 1 = complete
248
+ *
249
+ * checkpoint stays at window 0.
250
+ *
251
+ * Once window 0 completes, both can be committed:
252
+ *
253
+ * checkpoint -> window 2 start
254
+ */
255
+
256
+ const completed = new Set();
257
+
258
+ let nextCheckpointIndex = 0;
259
+ let checkpointChain = Promise.resolve();
260
+
261
+ const advanceCheckpoint = () => {
262
+ if (!checkpoint) return checkpointChain;
263
+
264
+ checkpointChain = checkpointChain.then(async () => {
265
+ while (
266
+ completed.has(nextCheckpointIndex)
267
+ ) {
268
+ throwIfAborted(signal);
269
+
270
+ const window =
271
+ windows[nextCheckpointIndex];
272
+
273
+ await checkpoint.save(
274
+ window.endLedger + 1,
275
+ checkpointKey
276
+ );
277
+
278
+ completed.delete(nextCheckpointIndex);
279
+ nextCheckpointIndex++;
280
+ }
281
+ });
282
+
283
+ return checkpointChain;
284
+ };
285
+
286
+ const processWindow = async window => {
287
+ throwIfAborted(signal);
288
+
289
+ const events =
290
+ await this.streamer.getEventsWindowed({
291
+ startLedger: window.startLedger,
292
+ endLedger: window.endLedger,
293
+ filters,
294
+ limit: 10000,
295
+ signal
296
+ });
297
+
298
+ for (const event of events) {
299
+ throwIfAborted(signal);
300
+
301
+ if (
302
+ seen &&
303
+ event?.id != null
304
+ ) {
305
+ if (seen.has(event.id)) {
306
+ continue;
307
+ }
308
+
309
+ seen.add(event.id);
310
+ }
311
+
312
+ if (store != null) {
313
+ await store.put(event);
314
+ }
315
+
316
+ const processedEvent = pipeline
317
+ ? await pipeline.process(event, {
318
+ signal,
319
+ checkpointKey,
320
+ ledger: event.ledger,
321
+ window,
322
+ streamer: this.streamer
323
+ })
324
+ : event;
325
+
326
+ if (processedEvent === null) {
327
+ continue;
328
+ }
329
+
330
+ await onEvent(processedEvent);
331
+ processed++;
332
+ }
333
+
334
+ windowsCompleted++;
335
+
336
+ completed.add(window.index);
337
+
338
+ await advanceCheckpoint();
339
+
340
+ if (onProgress) {
341
+ await onProgress({
342
+ startLedger,
343
+ endLedger,
344
+ effectiveStart,
345
+ windowsTotal: windows.length,
346
+ windowsCompleted,
347
+ processed,
348
+ checkpointLedger:
349
+ checkpoint
350
+ ? nextCheckpointIndex < windows.length
351
+ ? windows[nextCheckpointIndex].startLedger
352
+ : endLedger + 1
353
+ : null,
354
+ currentWindow: {
355
+ startLedger: window.startLedger,
356
+ endLedger: window.endLedger
357
+ }
358
+ });
359
+ }
360
+ };
361
+
362
+ const worker = async () => {
363
+ while (true) {
364
+ throwIfAborted(signal);
365
+
366
+ const window = queue.shift();
367
+
368
+ if (!window) return;
369
+
370
+ await processWindow(window);
371
+ }
372
+ };
373
+
374
+ const workerCount = Math.min(
375
+ this.concurrency,
376
+ windows.length || 1
377
+ );
378
+
379
+ const workers = [];
380
+
381
+ for (let i = 0; i < workerCount; i++) {
382
+ workers.push(worker());
383
+ }
384
+
385
+ await Promise.all(workers);
386
+
387
+ /*
388
+ * All workers have finished. There may still be a queued
389
+ * checkpoint advancement waiting on the serialization chain.
390
+ */
391
+ await checkpointChain;
392
+
393
+ return {
394
+ startLedger,
395
+ endLedger,
396
+ effectiveStart,
397
+ processed,
398
+ windows: windows.length,
399
+ windowsCompleted,
400
+ skipped: false
401
+ };
402
+ }
403
+ }
404
+
405
+ function buildWindows(startLedger, endLedger, windowSize) {
406
+ const windows = [];
407
+
408
+ let cursor = startLedger;
409
+
410
+ while (cursor <= endLedger) {
411
+ const end = Math.min(
412
+ endLedger,
413
+ cursor + windowSize - 1
414
+ );
415
+
416
+ windows.push({
417
+ startLedger: cursor,
418
+ endLedger: end
419
+ });
420
+
421
+ cursor = end + 1;
422
+ }
423
+
424
+ return windows;
425
+ }
426
+
427
+ function validateLedger(value, name) {
428
+ if (
429
+ !Number.isSafeInteger(value) ||
430
+ value < 1
431
+ ) {
432
+ throw new TypeError(
433
+ `${name} must be a positive safe integer`
434
+ );
435
+ }
436
+ }
437
+
438
+ function throwIfAborted(signal) {
439
+ if (signal?.aborted) {
440
+ throw new BackfillError(
441
+ 'backfill aborted',
442
+ {
443
+ cause: signal.reason
444
+ }
445
+ );
446
+ }
447
+ }
448
+
449
+ export { buildWindows };
450
+
451
+
@@ -0,0 +1,163 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { CheckpointError, assertCheckpointStore } from './checkpoint.js';
5
+
6
+ export class FileCheckpointStore {
7
+ constructor(directory) {
8
+ if (typeof directory !== 'string' || directory.length === 0) {
9
+ throw new TypeError(
10
+ 'checkpoint directory must be a non-empty string'
11
+ );
12
+ }
13
+
14
+ this.directory = path.resolve(directory);
15
+
16
+ assertCheckpointStore(this);
17
+ }
18
+
19
+ async load(key) {
20
+ validateKey(key);
21
+
22
+ const file = this.#fileFor(key);
23
+
24
+ try {
25
+ const text = await fs.readFile(file, 'utf8');
26
+ const data = JSON.parse(text);
27
+
28
+ validateStoredCheckpoint(data);
29
+
30
+ return data.ledger;
31
+ } catch (error) {
32
+ if (error?.code === 'ENOENT') {
33
+ return null;
34
+ }
35
+
36
+ if (error instanceof CheckpointError) {
37
+ throw error;
38
+ }
39
+
40
+ if (error instanceof SyntaxError) {
41
+ throw new CheckpointError(
42
+ `invalid checkpoint file for key: ${key}`
43
+ );
44
+ }
45
+
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ async save(key, ledger) {
51
+ validateKey(key);
52
+ validateLedger(ledger);
53
+
54
+ await fs.mkdir(this.directory, {
55
+ recursive: true
56
+ });
57
+
58
+ const file = this.#fileFor(key);
59
+ const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
60
+
61
+ const previous = await this.load(key);
62
+
63
+ if (previous != null && ledger < previous) {
64
+ throw new CheckpointError(
65
+ `checkpoint cannot move backwards: ${ledger} < ${previous}`
66
+ );
67
+ }
68
+
69
+ const data = {
70
+ version: 1,
71
+ key,
72
+ ledger
73
+ };
74
+
75
+ await fs.writeFile(
76
+ temporary,
77
+ `${JSON.stringify(data)}\n`,
78
+ 'utf8'
79
+ );
80
+
81
+ await fs.rename(temporary, file);
82
+ }
83
+
84
+ async rewind(key, ledger) {
85
+ validateKey(key);
86
+ validateLedger(ledger);
87
+
88
+ await fs.mkdir(this.directory, {
89
+ recursive: true
90
+ });
91
+
92
+ const file = this.#fileFor(key);
93
+ const temporary =
94
+ `${file}.${process.pid}.${Date.now()}.tmp`;
95
+
96
+ const data = {
97
+ version: 1,
98
+ key,
99
+ ledger
100
+ };
101
+
102
+ await fs.writeFile(
103
+ temporary,
104
+ `${JSON.stringify(data)}\n`,
105
+ 'utf8'
106
+ );
107
+
108
+ await fs.rename(
109
+ temporary,
110
+ file
111
+ );
112
+ }
113
+
114
+ async clear(key) {
115
+ validateKey(key);
116
+
117
+ try {
118
+ await fs.unlink(this.#fileFor(key));
119
+ } catch (error) {
120
+ if (error?.code !== 'ENOENT') {
121
+ throw error;
122
+ }
123
+ }
124
+ }
125
+
126
+ #fileFor(key) {
127
+ return path.join(
128
+ this.directory,
129
+ `${encodeURIComponent(key)}.json`
130
+ );
131
+ }
132
+ }
133
+
134
+ function validateKey(key) {
135
+ if (typeof key !== 'string' || key.length === 0) {
136
+ throw new TypeError(
137
+ 'checkpoint key must be a non-empty string'
138
+ );
139
+ }
140
+ }
141
+
142
+ function validateLedger(ledger) {
143
+ if (!Number.isSafeInteger(ledger) || ledger < 1) {
144
+ throw new TypeError(
145
+ 'checkpoint ledger must be a positive safe integer'
146
+ );
147
+ }
148
+ }
149
+
150
+ function validateStoredCheckpoint(data) {
151
+ if (
152
+ !data ||
153
+ data.version !== 1 ||
154
+ typeof data.key !== 'string'
155
+ ) {
156
+ throw new CheckpointError(
157
+ 'invalid checkpoint file format'
158
+ );
159
+ }
160
+
161
+ validateKey(data.key);
162
+ validateLedger(data.ledger);
163
+ }