@indigoai-us/hq-cloud 6.15.6 → 6.15.7

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.
@@ -16,6 +16,7 @@ const WAL_HEADER_BYTES = 8 + 8 + 32 + 32;
16
16
  const FRAME_PREFIX_BYTES = 4 + 4;
17
17
  const FRAME_FIXED_BODY_BYTES = 8 + 2 + 2 + 4 + 4 + 32;
18
18
  const MAX_FRAME_BYTES = 64 * 1024 * 1024;
19
+ const DEFAULT_MAX_WAL_BYTES_BEFORE_COMPACTION = 16 * 1024 * 1024;
19
20
  const LOCK_RETRY_MS = 10;
20
21
  const LOCK_TIMEOUT_MS = 5_000;
21
22
  export class StateStoreCorruptionError extends Error {
@@ -30,6 +31,12 @@ class StateStoreLockError extends Error {
30
31
  this.name = "StateStoreLockError";
31
32
  }
32
33
  }
34
+ class StateStoreReducerError extends Error {
35
+ constructor(message, options) {
36
+ super(message, options);
37
+ this.name = "StateStoreReducerError";
38
+ }
39
+ }
33
40
  /** A recovered v3 store. `append` is the ordinary hot path. */
34
41
  export class StateStore {
35
42
  options;
@@ -40,6 +47,8 @@ export class StateStore {
40
47
  state;
41
48
  lastRecordSequence;
42
49
  recordCount;
50
+ walBytes;
51
+ maxRecoveryReadBytes;
43
52
  constructor(options, recovered, wasCreated) {
44
53
  this.options = options;
45
54
  this.scopeDigest = digestScope(options.scopeId);
@@ -48,6 +57,8 @@ export class StateStore {
48
57
  this.state = recovered.state;
49
58
  this.lastRecordSequence = recovered.lastRecordSequence;
50
59
  this.recordCount = recovered.recordCount;
60
+ this.walBytes = recovered.walBytes;
61
+ this.maxRecoveryReadBytes = recovered.maxReadBytes;
51
62
  this.wasCreated = wasCreated;
52
63
  }
53
64
  static exists(rootDir, scopeId) {
@@ -62,32 +73,86 @@ export class StateStore {
62
73
  }
63
74
  }
64
75
  static open(options) {
65
- if (!options.scopeId)
66
- throw new Error("state-store: scopeId is required");
67
- const normalized = {
68
- ...options,
69
- maxRecordsBeforeCompaction: options.maxRecordsBeforeCompaction ?? 256,
70
- };
71
- if (normalized.maxRecordsBeforeCompaction < 1) {
72
- throw new Error("state-store: maxRecordsBeforeCompaction must be positive");
73
- }
76
+ const normalized = normalizeOptions(options);
74
77
  const scopeDigest = digestScope(normalized.scopeId);
75
78
  const dir = stateDirectory(normalized.rootDir, scopeDigest);
76
79
  fs.mkdirSync(dir, { recursive: true });
77
- cleanupUnpublishedInitialGeneration(dir);
78
- const generations = readGenerations(dir, scopeDigest, normalized.reduce);
79
- if (generations.length > 0) {
80
- return new StateStore(normalized, generations[0], false);
81
- }
82
- const initial = {
83
- generation: 1,
84
- state: normalized.initialState,
85
- lastRecordSequence: 0,
86
- recordCount: 0,
87
- };
88
- writeGeneration(dir, scopeDigest, initial);
89
- writeCurrentPointer(dir, initial.generation);
90
- return new StateStore(normalized, initial, true);
80
+ return withScopeLock(dir, () => {
81
+ cleanupUnpublishedInitialGeneration(dir);
82
+ const recovered = readNewestGeneration(dir, scopeDigest, normalized.reduce);
83
+ if (recovered) {
84
+ return new StateStore(normalized, recovered, false);
85
+ }
86
+ const initial = {
87
+ generation: 1,
88
+ state: normalized.initialState,
89
+ lastRecordSequence: 0,
90
+ recordCount: 0,
91
+ walBytes: WAL_HEADER_BYTES,
92
+ maxReadBytes: 0,
93
+ };
94
+ writeGeneration(dir, scopeDigest, initial);
95
+ writeCurrentPointer(dir, initial.generation);
96
+ return new StateStore(normalized, initial, true);
97
+ });
98
+ }
99
+ /**
100
+ * Repair an over-budget generation without opening it once and refreshing it
101
+ * a second time. The entire recovery and two-copy publication happens under
102
+ * the scope lock, so an appender cannot race the migration.
103
+ */
104
+ static repairIfNeeded(options, maintenance = {}) {
105
+ const normalized = normalizeOptions(options);
106
+ const scopeDigest = digestScope(normalized.scopeId);
107
+ const dir = stateDirectory(normalized.rootDir, scopeDigest);
108
+ if (!hasSnapshotGeneration(dir))
109
+ return { status: "missing" };
110
+ return withScopeLock(dir, () => {
111
+ cleanupUnpublishedInitialGeneration(dir);
112
+ const newestGeneration = highestSnapshotGenerationOnDisk(dir);
113
+ if (newestGeneration === undefined)
114
+ return { status: "missing" };
115
+ if (!maintenance.force) {
116
+ let walBytes;
117
+ let snapshotBytes;
118
+ const currentGeneration = currentGenerationOnDisk(dir);
119
+ try {
120
+ walBytes = fs.statSync(walPath(dir, newestGeneration)).size;
121
+ snapshotBytes = fs.statSync(snapshotPath(dir, newestGeneration)).size;
122
+ }
123
+ catch {
124
+ // Let the structural recovery path below classify/fall back rather
125
+ // than turning an incomplete newest generation into a false no-op.
126
+ }
127
+ const overBudget = walBytes === undefined ||
128
+ snapshotBytes === undefined ||
129
+ retainedGenerationHistoryNeedsRepair(dir, newestGeneration, currentGeneration) ||
130
+ walBytes > normalized.maxWalBytesBeforeCompaction ||
131
+ (walBytes > WAL_HEADER_BYTES &&
132
+ snapshotBytes > normalized.maxWalBytesBeforeCompaction);
133
+ if (!overBudget)
134
+ return { status: "not-needed" };
135
+ }
136
+ const recovered = readNewestGeneration(dir, scopeDigest, normalized.reduce, maintenance.onProgress);
137
+ if (!recovered)
138
+ return { status: "missing" };
139
+ const store = new StateStore(normalized, recovered, false);
140
+ const recoveredGeneration = recovered.generation;
141
+ const recoveredWalBytes = recovered.walBytes;
142
+ const firstCompactGeneration = highestGenerationOnDisk(dir) + 1;
143
+ const first = store.compactGeneration(firstCompactGeneration);
144
+ const second = store.compactGeneration(firstCompactGeneration + 1);
145
+ writeCurrentPointer(dir, second.generation);
146
+ pruneGenerations(dir, [first.generation, second.generation]);
147
+ store.installGeneration(second);
148
+ return {
149
+ status: "repaired",
150
+ recoveredGeneration,
151
+ recoveredWalBytes,
152
+ firstCompactGeneration: first.generation,
153
+ currentGeneration: second.generation,
154
+ };
155
+ });
91
156
  }
92
157
  getState() {
93
158
  return this.state;
@@ -98,6 +163,9 @@ export class StateStore {
98
163
  getLastRecordSequence() {
99
164
  return this.lastRecordSequence;
100
165
  }
166
+ getRecoveryStats() {
167
+ return { walBytes: this.walBytes, maxReadBytes: this.maxRecoveryReadBytes };
168
+ }
101
169
  append(type, payload, flags = 0) {
102
170
  assertUint16(type, "record type");
103
171
  assertUint16(flags, "record flags");
@@ -114,15 +182,44 @@ export class StateStore {
114
182
  }
115
183
  // Validate reducer acceptance before making this frame durable.
116
184
  const nextState = this.options.reduce(this.state, record);
117
- appendDurably(walPath(this.stateDir, this.generation), encodeFrame(record));
185
+ const frame = encodeFrame(record);
186
+ appendDurably(walPath(this.stateDir, this.generation), frame);
118
187
  this.state = nextState;
119
188
  this.lastRecordSequence = record.sequence;
120
189
  this.recordCount++;
121
- if (this.recordCount >= this.options.maxRecordsBeforeCompaction)
190
+ this.walBytes += frame.length;
191
+ if (this.recordCount >= this.options.maxRecordsBeforeCompaction ||
192
+ this.walBytes >= this.options.maxWalBytesBeforeCompaction)
122
193
  this.compactUnlocked();
123
194
  return record;
124
195
  });
125
196
  }
197
+ /**
198
+ * Atomically replace the current state with a compact snapshot generation.
199
+ *
200
+ * This is the rollback-compatible write path for domains whose previous
201
+ * reducer cannot understand a new WAL record type. If the recovered
202
+ * generation contains records, publish two compact copies so every retained
203
+ * WAL is header-only before pruning it. Later replacements need one new copy
204
+ * and retain the prior compact copy.
205
+ */
206
+ replaceState(nextState) {
207
+ withScopeLock(this.stateDir, () => {
208
+ this.refreshFromDisk();
209
+ const previousGeneration = this.generation;
210
+ const previousWalHasRecords = this.walBytes > WAL_HEADER_BYTES;
211
+ const first = this.compactGeneration(highestGenerationOnDisk(this.stateDir) + 1, nextState);
212
+ let retainedGeneration = previousGeneration;
213
+ let current = first;
214
+ if (previousWalHasRecords) {
215
+ current = this.compactGeneration(first.generation + 1, nextState);
216
+ retainedGeneration = first.generation;
217
+ }
218
+ writeCurrentPointer(this.stateDir, current.generation);
219
+ pruneGenerations(this.stateDir, [retainedGeneration, current.generation]);
220
+ this.installGeneration(current);
221
+ });
222
+ }
126
223
  /** Create G+1 without ever merging two generations during recovery. */
127
224
  compact() {
128
225
  withScopeLock(this.stateDir, () => {
@@ -131,31 +228,41 @@ export class StateStore {
131
228
  });
132
229
  }
133
230
  refreshFromDisk() {
134
- const recovered = readGenerations(this.stateDir, this.scopeDigest, this.options.reduce);
135
- if (recovered.length === 0) {
231
+ const recovered = readNewestGeneration(this.stateDir, this.scopeDigest, this.options.reduce);
232
+ if (!recovered) {
136
233
  throw new StateStoreCorruptionError("no valid generation remains while appending");
137
234
  }
138
- const current = recovered[0];
139
- this.generation = current.generation;
140
- this.state = current.state;
141
- this.lastRecordSequence = current.lastRecordSequence;
142
- this.recordCount = current.recordCount;
235
+ this.installGeneration(recovered);
143
236
  }
144
237
  compactUnlocked() {
238
+ const previousGeneration = this.generation;
239
+ const next = this.compactGeneration(highestGenerationOnDisk(this.stateDir) + 1);
240
+ writeCurrentPointer(this.stateDir, next.generation);
241
+ pruneGenerations(this.stateDir, [previousGeneration, next.generation]);
242
+ this.installGeneration(next);
243
+ }
244
+ compactGeneration(generation, state = this.state) {
145
245
  const next = {
146
- generation: highestGenerationOnDisk(this.stateDir) + 1,
147
- state: this.state,
246
+ generation,
247
+ state,
148
248
  lastRecordSequence: this.lastRecordSequence,
149
249
  recordCount: 0,
250
+ walBytes: WAL_HEADER_BYTES,
251
+ maxReadBytes: 0,
150
252
  };
151
253
  writeGeneration(this.stateDir, this.scopeDigest, next);
152
- writeCurrentPointer(this.stateDir, next.generation);
153
- pruneGenerations(this.stateDir, [this.generation, next.generation]);
154
- this.generation = next.generation;
155
- this.recordCount = 0;
254
+ return next;
255
+ }
256
+ installGeneration(generation) {
257
+ this.generation = generation.generation;
258
+ this.state = generation.state;
259
+ this.lastRecordSequence = generation.lastRecordSequence;
260
+ this.recordCount = generation.recordCount;
261
+ this.walBytes = generation.walBytes;
262
+ this.maxRecoveryReadBytes = generation.maxReadBytes;
156
263
  }
157
264
  }
158
- function readGenerations(dir, scopeDigest, reduce) {
265
+ function readNewestGeneration(dir, scopeDigest, reduce, onProgress) {
159
266
  const names = fs.readdirSync(dir);
160
267
  const generations = names
161
268
  .map((name) => /^snapshot-(\d+)\.bin$/.exec(name)?.[1])
@@ -163,37 +270,40 @@ function readGenerations(dir, scopeDigest, reduce) {
163
270
  .map(Number)
164
271
  .filter(Number.isSafeInteger)
165
272
  .sort((a, b) => b - a);
166
- const recovered = [];
167
273
  const errors = [];
168
274
  for (const generation of generations) {
169
275
  try {
170
- recovered.push(readGeneration(dir, scopeDigest, generation, reduce));
276
+ return readGeneration(dir, scopeDigest, generation, reduce, onProgress);
171
277
  }
172
278
  catch (error) {
173
- if (!(error instanceof StateStoreCorruptionError)) {
279
+ if (error instanceof StateStoreReducerError) {
174
280
  throw new Error(`state-store: reducer rejected generation ${generation}`, { cause: error });
175
281
  }
282
+ if (!(error instanceof StateStoreCorruptionError))
283
+ throw error;
176
284
  errors.push(error);
177
285
  }
178
286
  }
179
- if (recovered.length === 0 && generations.length > 0) {
287
+ if (generations.length > 0) {
180
288
  throw new StateStoreCorruptionError("no valid snapshot/WAL generation remains", {
181
289
  cause: errors[0],
182
290
  });
183
291
  }
184
- return recovered;
292
+ return undefined;
185
293
  }
186
- function readGeneration(dir, scopeDigest, generation, reduce) {
294
+ function readGeneration(dir, scopeDigest, generation, reduce, onProgress) {
295
+ const snapshotBytes = generationFileSize(snapshotPath(dir, generation), "snapshot", generation);
296
+ const totalBytes = snapshotBytes + generationFileSize(walPath(dir, generation), "WAL", generation);
187
297
  const snapshot = readSnapshot(snapshotPath(dir, generation), scopeDigest, generation);
188
- const parsed = readWal(walPath(dir, generation), scopeDigest, generation, snapshot.lastRecordSequence);
189
- let state = snapshot.state;
190
- for (const record of parsed.records)
191
- state = reduce(state, record);
298
+ onProgress?.({ bytesRead: snapshotBytes, totalBytes });
299
+ const parsed = readWal(walPath(dir, generation), scopeDigest, generation, snapshot.payload.lastRecordSequence, snapshot.payload.state, reduce, snapshotBytes, totalBytes, onProgress);
192
300
  return {
193
301
  generation,
194
- state,
302
+ state: parsed.state,
195
303
  lastRecordSequence: parsed.lastRecordSequence,
196
- recordCount: parsed.records.length,
304
+ recordCount: parsed.recordCount,
305
+ walBytes: parsed.walBytes,
306
+ maxReadBytes: Math.max(snapshot.maxReadBytes, parsed.maxReadBytes),
197
307
  };
198
308
  }
199
309
  function writeGeneration(dir, scopeDigest, generation) {
@@ -207,71 +317,172 @@ function writeGeneration(dir, scopeDigest, generation) {
207
317
  writeNewDurably(walPath(dir, generation.generation), header);
208
318
  }
209
319
  function readSnapshot(filePath, scopeDigest, generation) {
210
- let bytes;
320
+ let fd;
211
321
  try {
212
- bytes = fs.readFileSync(filePath);
322
+ fd = fs.openSync(filePath, "r");
213
323
  }
214
324
  catch (error) {
215
- throw new StateStoreCorruptionError(`cannot read snapshot generation ${generation}`, { cause: error });
216
- }
217
- if (bytes.length < SNAPSHOT_HEADER_BYTES) {
218
- throw new StateStoreCorruptionError(`snapshot generation ${generation} has a torn header`);
219
- }
220
- if (!bytes.subarray(0, 8).equals(SNAPSHOT_MAGIC)) {
221
- throw new StateStoreCorruptionError(`snapshot generation ${generation} has an invalid magic`);
222
- }
223
- if (readU64(bytes, 8) !== generation) {
224
- throw new StateStoreCorruptionError(`snapshot generation ${generation} has a generation mismatch`);
225
- }
226
- const payloadLength = bytes.readUInt32BE(16);
227
- if (bytes.length !== SNAPSHOT_HEADER_BYTES + payloadLength) {
228
- throw new StateStoreCorruptionError(`snapshot generation ${generation} has an invalid payload length`);
229
- }
230
- if (!bytes.subarray(20, 52).equals(scopeDigest)) {
231
- throw new StateStoreCorruptionError(`snapshot generation ${generation} has a scope digest mismatch`);
232
- }
233
- const expected = sha256(bytes.subarray(0, 52 + payloadLength));
234
- if (!expected.equals(bytes.subarray(52 + payloadLength))) {
235
- throw new StateStoreCorruptionError(`snapshot generation ${generation} has a bad checksum`);
325
+ if (isMissing(error)) {
326
+ throw new StateStoreCorruptionError(`snapshot generation ${generation} is missing`, { cause: error });
327
+ }
328
+ throw new Error(`state-store: cannot read snapshot generation ${generation}`, { cause: error });
236
329
  }
237
- let decoded;
238
330
  try {
239
- decoded = JSON.parse(bytes.subarray(52, 52 + payloadLength).toString("utf8"));
331
+ const stat = fs.fstatSync(fd);
332
+ const header = Buffer.alloc(52);
333
+ if (readIntoSync(fd, header, 0) !== header.length) {
334
+ throw new StateStoreCorruptionError(`snapshot generation ${generation} has a torn header`);
335
+ }
336
+ if (!header.subarray(0, 8).equals(SNAPSHOT_MAGIC)) {
337
+ throw new StateStoreCorruptionError(`snapshot generation ${generation} has an invalid magic`);
338
+ }
339
+ if (readU64(header, 8) !== generation) {
340
+ throw new StateStoreCorruptionError(`snapshot generation ${generation} has a generation mismatch`);
341
+ }
342
+ const payloadLength = header.readUInt32BE(16);
343
+ if (stat.size !== SNAPSHOT_HEADER_BYTES + payloadLength) {
344
+ throw new StateStoreCorruptionError(`snapshot generation ${generation} has an invalid payload length`);
345
+ }
346
+ if (!header.subarray(20, 52).equals(scopeDigest)) {
347
+ throw new StateStoreCorruptionError(`snapshot generation ${generation} has a scope digest mismatch`);
348
+ }
349
+ const data = Buffer.allocUnsafe(payloadLength);
350
+ if (readIntoSync(fd, data, 52) !== data.length) {
351
+ throw new StateStoreCorruptionError(`snapshot generation ${generation} has a torn payload`);
352
+ }
353
+ const checksum = Buffer.alloc(32);
354
+ if (readIntoSync(fd, checksum, 52 + payloadLength) !== checksum.length) {
355
+ throw new StateStoreCorruptionError(`snapshot generation ${generation} has a torn checksum`);
356
+ }
357
+ const expected = crypto.createHash("sha256").update(header).update(data).digest();
358
+ if (!expected.equals(checksum)) {
359
+ throw new StateStoreCorruptionError(`snapshot generation ${generation} has a bad checksum`);
360
+ }
361
+ let decoded;
362
+ try {
363
+ decoded = JSON.parse(data.toString("utf8"));
364
+ }
365
+ catch (error) {
366
+ throw new StateStoreCorruptionError(`snapshot generation ${generation} has invalid JSON`, { cause: error });
367
+ }
368
+ if (!isSnapshotPayload(decoded)) {
369
+ throw new StateStoreCorruptionError(`snapshot generation ${generation} has an invalid state payload`);
370
+ }
371
+ return { payload: decoded, maxReadBytes: data.length };
240
372
  }
241
373
  catch (error) {
242
- throw new StateStoreCorruptionError(`snapshot generation ${generation} has invalid JSON`, { cause: error });
374
+ if (error instanceof StateStoreCorruptionError)
375
+ throw error;
376
+ throw new Error(`state-store: cannot read snapshot generation ${generation}`, { cause: error });
243
377
  }
244
- if (!isSnapshotPayload(decoded)) {
245
- throw new StateStoreCorruptionError(`snapshot generation ${generation} has an invalid state payload`);
378
+ finally {
379
+ fs.closeSync(fd);
246
380
  }
247
- return decoded;
248
381
  }
249
- function readWal(filePath, scopeDigest, generation, snapshotSequence) {
250
- let bytes;
382
+ function readWal(filePath, scopeDigest, generation, snapshotSequence, initialState, reduce, recoveryBytesBeforeWal = 0, recoveryTotalBytes, onProgress) {
383
+ let fd;
251
384
  try {
252
- bytes = fs.readFileSync(filePath);
385
+ fd = fs.openSync(filePath, "r");
253
386
  }
254
387
  catch (error) {
255
- throw new StateStoreCorruptionError(`cannot read WAL generation ${generation}`, { cause: error });
388
+ if (isMissing(error)) {
389
+ throw new StateStoreCorruptionError(`WAL generation ${generation} is missing`, { cause: error });
390
+ }
391
+ throw new Error(`state-store: cannot read WAL generation ${generation}`, { cause: error });
256
392
  }
257
- verifyWalHeader(bytes, scopeDigest, generation);
258
- const records = [];
259
- let offset = WAL_HEADER_BYTES;
260
- let sequence = snapshotSequence;
261
- while (offset < bytes.length) {
262
- const parsed = decodeFrame(bytes, offset);
263
- if (parsed.kind === "torn") {
264
- truncateWal(filePath, offset);
265
- break;
393
+ let truncateBoundary;
394
+ try {
395
+ const walBytes = fs.fstatSync(fd).size;
396
+ const header = Buffer.alloc(WAL_HEADER_BYTES);
397
+ const headerBytes = readIntoSync(fd, header, 0);
398
+ if (headerBytes < WAL_HEADER_BYTES) {
399
+ throw new StateStoreCorruptionError(`WAL generation ${generation} has a torn header`);
266
400
  }
267
- if (parsed.record.sequence !== sequence + 1) {
268
- throw new StateStoreCorruptionError(`WAL generation ${generation} has non-contiguous record sequence`);
401
+ verifyWalHeader(header, scopeDigest, generation);
402
+ let state = initialState;
403
+ let offset = WAL_HEADER_BYTES;
404
+ let sequence = snapshotSequence;
405
+ let recordCount = 0;
406
+ let maxReadBytes = header.length;
407
+ while (offset < walBytes) {
408
+ const prefix = Buffer.alloc(FRAME_PREFIX_BYTES);
409
+ if (readIntoSync(fd, prefix, offset) < FRAME_PREFIX_BYTES) {
410
+ truncateBoundary = offset;
411
+ break;
412
+ }
413
+ if (prefix.readUInt32BE(0) !== FRAME_MAGIC) {
414
+ throw new StateStoreCorruptionError("WAL has an invalid frame magic");
415
+ }
416
+ const frameLength = prefix.readUInt32BE(4);
417
+ if (frameLength < FRAME_FIXED_BODY_BYTES || frameLength > MAX_FRAME_BYTES) {
418
+ throw new StateStoreCorruptionError("WAL has an invalid frame length");
419
+ }
420
+ const available = walBytes - offset - FRAME_PREFIX_BYTES;
421
+ if (available < frameLength) {
422
+ if (available >= 16) {
423
+ const fixed = Buffer.alloc(16);
424
+ readIntoSync(fd, fixed, offset + FRAME_PREFIX_BYTES);
425
+ const declaredPayloadLength = fixed.readUInt32BE(12);
426
+ const declaredFrameLength = FRAME_FIXED_BODY_BYTES + declaredPayloadLength;
427
+ if (declaredFrameLength <= available && declaredFrameLength !== frameLength) {
428
+ throw new StateStoreCorruptionError("WAL frame length prefix is corrupt");
429
+ }
430
+ }
431
+ truncateBoundary = offset;
432
+ break;
433
+ }
434
+ const frame = Buffer.allocUnsafe(FRAME_PREFIX_BYTES + frameLength);
435
+ prefix.copy(frame);
436
+ if (readIntoSync(fd, frame.subarray(FRAME_PREFIX_BYTES), offset + FRAME_PREFIX_BYTES) !== frameLength) {
437
+ truncateBoundary = offset;
438
+ break;
439
+ }
440
+ maxReadBytes = Math.max(maxReadBytes, frame.length);
441
+ const parsed = decodeFrame(frame, 0);
442
+ if (parsed.kind === "torn") {
443
+ truncateBoundary = offset;
444
+ break;
445
+ }
446
+ if (parsed.record.sequence !== sequence + 1) {
447
+ throw new StateStoreCorruptionError(`WAL generation ${generation} has non-contiguous record sequence`);
448
+ }
449
+ try {
450
+ state = reduce(state, parsed.record);
451
+ }
452
+ catch (error) {
453
+ throw new StateStoreReducerError(`record ${parsed.record.sequence} was rejected`, { cause: error });
454
+ }
455
+ sequence = parsed.record.sequence;
456
+ recordCount++;
457
+ offset += frame.length;
458
+ onProgress?.({
459
+ bytesRead: recoveryBytesBeforeWal + offset,
460
+ totalBytes: recoveryTotalBytes ?? recoveryBytesBeforeWal + walBytes,
461
+ });
269
462
  }
270
- records.push(parsed.record);
271
- sequence = parsed.record.sequence;
272
- offset = parsed.nextOffset;
463
+ onProgress?.({
464
+ bytesRead: recoveryBytesBeforeWal + (truncateBoundary ?? walBytes),
465
+ totalBytes: recoveryTotalBytes ?? recoveryBytesBeforeWal + walBytes,
466
+ });
467
+ return {
468
+ state,
469
+ lastRecordSequence: sequence,
470
+ recordCount,
471
+ walBytes: truncateBoundary ?? walBytes,
472
+ maxReadBytes,
473
+ };
474
+ }
475
+ catch (error) {
476
+ if (error instanceof StateStoreCorruptionError ||
477
+ error instanceof StateStoreReducerError)
478
+ throw error;
479
+ throw new Error(`state-store: cannot read WAL generation ${generation}`, { cause: error });
480
+ }
481
+ finally {
482
+ fs.closeSync(fd);
483
+ if (truncateBoundary !== undefined)
484
+ truncateWal(filePath, truncateBoundary);
273
485
  }
274
- return { records, lastRecordSequence: sequence };
275
486
  }
276
487
  function verifyWalHeader(bytes, scopeDigest, generation) {
277
488
  if (bytes.length < WAL_HEADER_BYTES) {
@@ -442,6 +653,16 @@ function writeAllSync(fd, bytes) {
442
653
  offset += written;
443
654
  }
444
655
  }
656
+ function readIntoSync(fd, target, position) {
657
+ let offset = 0;
658
+ while (offset < target.length) {
659
+ const read = fs.readSync(fd, target, offset, target.length - offset, position + offset);
660
+ if (read === 0)
661
+ break;
662
+ offset += read;
663
+ }
664
+ return offset;
665
+ }
445
666
  function withScopeLock(dir, action) {
446
667
  const lockPath = path.join(dir, "append.lock");
447
668
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
@@ -545,6 +766,47 @@ function fsyncDirectory(dir) {
545
766
  function stateDirectory(rootDir, scopeDigest) {
546
767
  return path.join(rootDir, "sync-state-v3", scopeDigest.toString("hex"));
547
768
  }
769
+ function normalizeOptions(options) {
770
+ if (!options.scopeId)
771
+ throw new Error("state-store: scopeId is required");
772
+ const normalized = {
773
+ ...options,
774
+ maxRecordsBeforeCompaction: options.maxRecordsBeforeCompaction ?? 256,
775
+ maxWalBytesBeforeCompaction: options.maxWalBytesBeforeCompaction ?? DEFAULT_MAX_WAL_BYTES_BEFORE_COMPACTION,
776
+ };
777
+ if (normalized.maxRecordsBeforeCompaction < 1) {
778
+ throw new Error("state-store: maxRecordsBeforeCompaction must be positive");
779
+ }
780
+ if (normalized.maxWalBytesBeforeCompaction < WAL_HEADER_BYTES + 1) {
781
+ throw new Error("state-store: maxWalBytesBeforeCompaction must exceed the WAL header size");
782
+ }
783
+ return normalized;
784
+ }
785
+ function generationFileSize(filePath, kind, generation) {
786
+ try {
787
+ return fs.statSync(filePath).size;
788
+ }
789
+ catch (error) {
790
+ if (isMissing(error)) {
791
+ throw new StateStoreCorruptionError(`${kind} generation ${generation} is missing`, {
792
+ cause: error,
793
+ });
794
+ }
795
+ throw new Error(`state-store: cannot inspect ${kind} generation ${generation}`, {
796
+ cause: error,
797
+ });
798
+ }
799
+ }
800
+ function hasSnapshotGeneration(dir) {
801
+ try {
802
+ return fs.readdirSync(dir).some((name) => /^snapshot-\d+\.bin$/.test(name));
803
+ }
804
+ catch (error) {
805
+ if (isMissing(error))
806
+ return false;
807
+ throw new Error(`state-store: cannot inspect ${dir}`, { cause: error });
808
+ }
809
+ }
548
810
  function snapshotPath(dir, generation) {
549
811
  return path.join(dir, `snapshot-${generation}.bin`);
550
812
  }
@@ -559,6 +821,56 @@ function highestGenerationOnDisk(dir) {
559
821
  .filter(Number.isSafeInteger)
560
822
  .reduce((highest, generation) => Math.max(highest, generation), 0);
561
823
  }
824
+ function highestSnapshotGenerationOnDisk(dir) {
825
+ const generations = fs.readdirSync(dir)
826
+ .map((name) => /^snapshot-(\d+)\.bin$/.exec(name)?.[1])
827
+ .filter((value) => value !== undefined)
828
+ .map(Number)
829
+ .filter(Number.isSafeInteger);
830
+ return generations.length === 0 ? undefined : Math.max(...generations);
831
+ }
832
+ function currentGenerationOnDisk(dir) {
833
+ try {
834
+ const generation = Number(fs.readFileSync(path.join(dir, "current"), "ascii").trim());
835
+ return Number.isSafeInteger(generation) && generation > 0 ? generation : undefined;
836
+ }
837
+ catch (error) {
838
+ if (isMissing(error))
839
+ return undefined;
840
+ throw new Error("state-store: cannot read current generation pointer", { cause: error });
841
+ }
842
+ }
843
+ /**
844
+ * Detect a crash after pointer publication but before obsolete-history prune.
845
+ * Journal maintenance promises rollback readers that every retained WAL is a
846
+ * compact header, so an old record-bearing generation is migration work even
847
+ * when the newest generation and pointer already look healthy.
848
+ */
849
+ function retainedGenerationHistoryNeedsRepair(dir, newestGeneration, currentGeneration) {
850
+ if (currentGeneration !== newestGeneration)
851
+ return true;
852
+ const snapshots = new Set();
853
+ const wals = new Set();
854
+ for (const name of fs.readdirSync(dir)) {
855
+ const snapshot = /^snapshot-(\d+)\.bin$/.exec(name);
856
+ if (snapshot)
857
+ snapshots.add(Number(snapshot[1]));
858
+ const wal = /^wal-(\d+)\.bin$/.exec(name);
859
+ if (wal)
860
+ wals.add(Number(wal[1]));
861
+ }
862
+ if (snapshots.size === 0 || snapshots.size > 2 || snapshots.size !== wals.size) {
863
+ return true;
864
+ }
865
+ for (const generation of snapshots) {
866
+ if (!wals.has(generation))
867
+ return true;
868
+ if (generationFileSize(walPath(dir, generation), "WAL", generation) !== WAL_HEADER_BYTES) {
869
+ return true;
870
+ }
871
+ }
872
+ return false;
873
+ }
562
874
  function pruneGenerations(dir, retain) {
563
875
  const retained = new Set(retain);
564
876
  for (const name of fs.readdirSync(dir)) {