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 +23 -0
- package/package.json +23 -6
- package/src/backfill.js +451 -0
- package/src/checkpoint-file.js +163 -0
- package/src/checkpoint.js +167 -0
- package/src/cli-command.js +201 -0
- package/src/cli.js +165 -0
- package/src/consistency.js +220 -0
- package/src/decoder.js +29 -5
- package/src/engine.js +536 -0
- package/src/handoff.js +203 -0
- package/src/index.d.ts +548 -0
- package/src/index.js +88 -1
- package/src/lifecycle.js +80 -0
- package/src/live-backfill.js +331 -0
- package/src/metrics.js +386 -0
- package/src/pipeline.js +134 -0
- package/src/recovery.js +272 -0
- package/src/reorg-monitor.js +362 -0
- package/src/replay.js +262 -0
- package/src/store.js +402 -0
- package/src/streamer.js +516 -6
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": "
|
|
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
|
-
".":
|
|
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
|
}
|
package/src/backfill.js
ADDED
|
@@ -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
|
+
}
|