@claudexor/daemon 3.3.15 → 3.4.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.
Files changed (91) hide show
  1. package/dist/command-store.d.ts +3 -0
  2. package/dist/command-store.d.ts.map +1 -1
  3. package/dist/command-store.js +4 -0
  4. package/dist/command-store.js.map +1 -1
  5. package/dist/daemon-listen.d.ts +10 -0
  6. package/dist/daemon-listen.d.ts.map +1 -0
  7. package/dist/daemon-listen.js +36 -0
  8. package/dist/daemon-listen.js.map +1 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/interactions.d.ts +3 -0
  14. package/dist/interactions.d.ts.map +1 -1
  15. package/dist/interactions.js +4 -0
  16. package/dist/interactions.js.map +1 -1
  17. package/dist/journal-manager-lifecycle.d.ts +8 -0
  18. package/dist/journal-manager-lifecycle.d.ts.map +1 -0
  19. package/dist/journal-manager-lifecycle.js +2 -0
  20. package/dist/journal-manager-lifecycle.js.map +1 -0
  21. package/dist/journal-manager.d.ts +21 -11
  22. package/dist/journal-manager.d.ts.map +1 -1
  23. package/dist/journal-manager.js +234 -198
  24. package/dist/journal-manager.js.map +1 -1
  25. package/dist/journal-prepared-operation.d.ts +53 -0
  26. package/dist/journal-prepared-operation.d.ts.map +1 -0
  27. package/dist/journal-prepared-operation.js +158 -0
  28. package/dist/journal-prepared-operation.js.map +1 -0
  29. package/dist/journal-projection-slot.d.ts +28 -0
  30. package/dist/journal-projection-slot.d.ts.map +1 -0
  31. package/dist/journal-projection-slot.js +71 -0
  32. package/dist/journal-projection-slot.js.map +1 -0
  33. package/dist/journal-projection.d.ts +2 -0
  34. package/dist/journal-projection.d.ts.map +1 -1
  35. package/dist/journal-recovery-files.d.ts +19 -1
  36. package/dist/journal-recovery-files.d.ts.map +1 -1
  37. package/dist/journal-recovery-files.js +271 -26
  38. package/dist/journal-recovery-files.js.map +1 -1
  39. package/dist/journal-recovery-operation.d.ts +27 -0
  40. package/dist/journal-recovery-operation.d.ts.map +1 -0
  41. package/dist/journal-recovery-operation.js +68 -0
  42. package/dist/journal-recovery-operation.js.map +1 -0
  43. package/dist/project-partition-preparation.d.ts +75 -0
  44. package/dist/project-partition-preparation.d.ts.map +1 -0
  45. package/dist/project-partition-preparation.js +233 -0
  46. package/dist/project-partition-preparation.js.map +1 -0
  47. package/dist/project-partitions.d.ts +18 -16
  48. package/dist/project-partitions.d.ts.map +1 -1
  49. package/dist/project-partitions.js +98 -99
  50. package/dist/project-partitions.js.map +1 -1
  51. package/dist/project-thread-listing.d.ts +14 -0
  52. package/dist/project-thread-listing.d.ts.map +1 -0
  53. package/dist/project-thread-listing.js +37 -0
  54. package/dist/project-thread-listing.js.map +1 -0
  55. package/dist/quota-projection.d.ts +9 -0
  56. package/dist/quota-projection.d.ts.map +1 -0
  57. package/dist/quota-projection.js +10 -0
  58. package/dist/quota-projection.js.map +1 -0
  59. package/dist/quota-registry.d.ts +3 -5
  60. package/dist/quota-registry.d.ts.map +1 -1
  61. package/dist/quota-registry.js +9 -10
  62. package/dist/quota-registry.js.map +1 -1
  63. package/dist/root-authority.d.ts +68 -0
  64. package/dist/root-authority.d.ts.map +1 -0
  65. package/dist/root-authority.js +380 -0
  66. package/dist/root-authority.js.map +1 -0
  67. package/dist/rpc-followers.d.ts +17 -0
  68. package/dist/rpc-followers.d.ts.map +1 -0
  69. package/dist/rpc-followers.js +46 -0
  70. package/dist/rpc-followers.js.map +1 -0
  71. package/dist/server.d.ts +4 -1
  72. package/dist/server.d.ts.map +1 -1
  73. package/dist/server.js +42 -62
  74. package/dist/server.js.map +1 -1
  75. package/dist/serving-admission.d.ts +21 -0
  76. package/dist/serving-admission.d.ts.map +1 -0
  77. package/dist/serving-admission.js +17 -0
  78. package/dist/serving-admission.js.map +1 -0
  79. package/dist/terminate.d.ts +18 -7
  80. package/dist/terminate.d.ts.map +1 -1
  81. package/dist/terminate.js +213 -69
  82. package/dist/terminate.js.map +1 -1
  83. package/dist/token.d.ts +6 -0
  84. package/dist/token.d.ts.map +1 -1
  85. package/dist/token.js +8 -0
  86. package/dist/token.js.map +1 -1
  87. package/dist/writer-lease.d.ts +76 -4
  88. package/dist/writer-lease.d.ts.map +1 -1
  89. package/dist/writer-lease.js +346 -56
  90. package/dist/writer-lease.js.map +1 -1
  91. package/package.json +5 -5
@@ -1,11 +1,13 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, readdirSync, realpathSync, renameSync, rmSync } from "node:fs";
2
+ import { existsSync, realpathSync, renameSync } from "node:fs";
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { DurableJournal, JournalRecoveryRequiredError, journalPartitionDirectory, } from "@claudexor/journal";
5
- import { ControlJournalQuarantineReceipt as QuarantineReceiptSchema, } from "@claudexor/schema";
6
5
  import { ensureCanonicalPrivateDirectory, fsyncDirectory } from "@claudexor/util";
7
- import { exportPartitionEntries, fingerprintPartition, cloneRecovery, readOwnedFile, recoveryAt, recoveryFrom, safeMessage, sha256, sha256File, writeAtomicPrivateJson, writeExclusiveFile, } from "./journal-recovery-files.js";
6
+ import { exportJournalRecovery, fingerprintPartition, cloneRecovery, recoveryAt, recoveryFrom, safeMessage, sha256, writeAtomicPrivateJson, } from "./journal-recovery-files.js";
7
+ import { conflict, matchingReceipt, quarantineRequestDigest, readOperation, typedError, validateRequest, } from "./journal-recovery-operation.js";
8
8
  import { journalEvents } from "./journal-events.js";
9
+ import { applyPreparedOperation, completePreparedReceipt, inspectPreparedOperation, preparationFingerprint, reconcilePreparedOperationOnStart, revalidatePreparedState, } from "./journal-prepared-operation.js";
10
+ import { prepareProjectionSlots, ProjectionSlot, } from "./journal-projection-slot.js";
9
11
  export class JournalManager {
10
12
  rootDir;
11
13
  partition;
@@ -20,16 +22,15 @@ export class JournalManager {
20
22
  journal = null;
21
23
  recovery = { status: "ready", discardedTailBytes: 0 };
22
24
  generationValue = 0;
23
- started = false;
24
- closed = false;
25
+ preparedOperation = null;
26
+ preparationResult = null;
27
+ lifecycle = "idle";
25
28
  constructor(rootDir, options = {}) {
26
- this.rootDir = rootDir;
27
29
  this.partition = options.partition?.trim() || "global";
28
30
  this.now = options.now ?? (() => new Date());
29
31
  this.faults = options.faults ?? {};
30
- ensureCanonicalPrivateDirectory(rootDir);
31
- this.journalRoot = join(realpathSync(rootDir), "journal");
32
- ensureCanonicalPrivateDirectory(this.journalRoot);
32
+ this.rootDir = realpathSync(rootDir);
33
+ this.journalRoot = join(this.rootDir, "journal");
33
34
  this.partitionDir = journalPartitionDirectory(this.journalRoot, this.partition);
34
35
  this.artifactPrefix = basename(this.partitionDir);
35
36
  this.operationsDir = join(this.rootDir, "recovery-operations", basename(this.partitionDir));
@@ -37,7 +38,7 @@ export class JournalManager {
37
38
  }
38
39
  registerProjection(descriptor) {
39
40
  this.assertOpen();
40
- if (this.started)
41
+ if (this.lifecycle !== "idle")
41
42
  throw new Error("journal projection registration is closed");
42
43
  if (!/^[A-Za-z0-9._-]+$/.test(descriptor.name) || this.registrations.has(descriptor.name)) {
43
44
  throw new Error(`invalid or duplicate journal projection '${descriptor.name}'`);
@@ -48,15 +49,158 @@ export class JournalManager {
48
49
  }
49
50
  start() {
50
51
  this.assertOpen();
51
- if (this.started)
52
+ if (this.lifecycle !== "idle")
52
53
  return this.inspect();
53
54
  if (this.registrations.size === 0)
54
55
  throw new Error("journal partition requires a projection");
55
- this.started = true;
56
+ ensureCanonicalPrivateDirectory(this.rootDir);
57
+ ensureCanonicalPrivateDirectory(this.journalRoot);
56
58
  if (!this.reconcilePrepared())
57
59
  this.openGeneration();
58
60
  return this.inspect();
59
61
  }
62
+ prepare() {
63
+ this.assertOpen();
64
+ if (this.preparationResult &&
65
+ (this.lifecycle === "prepared" || this.lifecycle === "recovery_required")) {
66
+ return structuredClone(this.preparationResult);
67
+ }
68
+ if (this.lifecycle !== "idle")
69
+ throw new Error("journal manager is already running");
70
+ if (this.registrations.size === 0)
71
+ throw new Error("journal partition requires a projection");
72
+ this.generationValue += 1;
73
+ const projectionStatus = [];
74
+ try {
75
+ this.journal = DurableJournal.prepare({
76
+ rootDir: this.journalRoot,
77
+ partition: this.partition,
78
+ now: this.now,
79
+ });
80
+ this.recovery = this.journal.state();
81
+ this.preparedOperation = inspectPreparedOperation({
82
+ rootDir: this.rootDir,
83
+ operationsDir: this.operationsDir,
84
+ quarantineDir: this.quarantineDir,
85
+ partitionDir: this.partitionDir,
86
+ partition: this.partition,
87
+ artifactPrefix: this.artifactPrefix,
88
+ journal: this.journal,
89
+ });
90
+ if (this.recovery.status === "ready") {
91
+ const prepared = prepareProjectionSlots(this.registrations.values(), this.journal);
92
+ projectionStatus.push(...prepared.status);
93
+ if (prepared.recovery)
94
+ this.enterRecovery(prepared.recovery);
95
+ }
96
+ }
97
+ catch (error) {
98
+ this.enterRecovery(recoveryFrom(error, `${this.partition} journal could not be prepared`));
99
+ }
100
+ const partitionFingerprint = this.observeFingerprint(this.partitionDir, "journal partition");
101
+ const operationsFingerprint = this.observeFingerprint(this.operationsDir, "journal recovery operations");
102
+ const preparedFingerprint = preparationFingerprint(this.journal?.preparation().preparationIdentity ?? partitionFingerprint.preparationIdentity, this.preparedOperation?.fingerprint ?? operationsFingerprint.preparationIdentity);
103
+ const inspection = this.inspection(partitionFingerprint.fingerprint);
104
+ this.preparationResult = {
105
+ partition: this.partition,
106
+ coverage: "complete",
107
+ inspection,
108
+ validation: { ...inspection, projectionStatus },
109
+ preparationFingerprint: preparedFingerprint,
110
+ virtual: this.journal?.preparation().virtual ?? !partitionFingerprint.exists,
111
+ };
112
+ if (this.recovery.status === "ready")
113
+ this.lifecycle = "prepared";
114
+ else
115
+ this.enterRecovery(this.recovery);
116
+ return structuredClone(this.preparationResult);
117
+ }
118
+ revalidatePreparation() {
119
+ this.assertStarted();
120
+ if (this.lifecycle === "recovery_required") {
121
+ throw new JournalRecoveryRequiredError(this.recovery);
122
+ }
123
+ if (this.lifecycle !== "prepared")
124
+ throw new Error("journal manager is not prepared");
125
+ if (!this.preparationResult || !this.journal)
126
+ throw new Error("journal is not prepared");
127
+ try {
128
+ this.preparedOperation = revalidatePreparedState({
129
+ rootDir: this.rootDir,
130
+ operationsDir: this.operationsDir,
131
+ quarantineDir: this.quarantineDir,
132
+ partitionDir: this.partitionDir,
133
+ partition: this.partition,
134
+ artifactPrefix: this.artifactPrefix,
135
+ journal: this.journal,
136
+ expectedFingerprint: this.preparationResult.preparationFingerprint,
137
+ });
138
+ }
139
+ catch (error) {
140
+ const recovery = recoveryFrom(error, `${this.partition} preparation revalidation failed`);
141
+ this.enterRecovery(recovery);
142
+ throw new JournalRecoveryRequiredError(recovery);
143
+ }
144
+ }
145
+ activatePrepared() {
146
+ this.assertStarted();
147
+ if (this.lifecycle === "active")
148
+ return;
149
+ if (this.lifecycle === "recovery_required") {
150
+ throw new JournalRecoveryRequiredError(this.recovery);
151
+ }
152
+ if (this.lifecycle !== "prepared")
153
+ throw new Error("journal manager is not prepared");
154
+ if (!this.preparationResult || !this.journal)
155
+ throw new Error("journal is not prepared");
156
+ try {
157
+ this.revalidatePreparation();
158
+ this.journal.activatePrepared();
159
+ if (!this.preparedOperation)
160
+ throw new Error("journal recovery operation is not prepared");
161
+ applyPreparedOperation({
162
+ rootDir: this.rootDir,
163
+ plan: this.preparedOperation,
164
+ journal: this.journal,
165
+ partition: this.partition,
166
+ artifactPrefix: this.artifactPrefix,
167
+ now: this.now,
168
+ afterReceipt: this.faults.afterQuarantineReceipt,
169
+ });
170
+ const activatedRecovery = this.journal.state();
171
+ if (activatedRecovery.status === "recovery_required") {
172
+ throw new JournalRecoveryRequiredError(activatedRecovery);
173
+ }
174
+ for (const registration of this.registrations.values()) {
175
+ this.faults.beforeProjectionActivation?.();
176
+ registration.slot.activate(this.generationValue);
177
+ }
178
+ this.recovery = activatedRecovery;
179
+ this.lifecycle = "active";
180
+ }
181
+ catch (error) {
182
+ const recovery = recoveryFrom(error, `${this.partition} prepared activation failed`);
183
+ this.journal?.close();
184
+ this.journal = null;
185
+ this.enterRecovery(recovery);
186
+ throw new JournalRecoveryRequiredError(recovery);
187
+ }
188
+ }
189
+ recoverAfterStartup() {
190
+ this.assertStarted();
191
+ if (this.lifecycle !== "active")
192
+ throw new Error("journal manager is not active");
193
+ try {
194
+ for (const registration of this.registrations.values()) {
195
+ registration.descriptor.recover?.(registration.slot.current());
196
+ }
197
+ }
198
+ catch (error) {
199
+ const recovery = recoveryFrom(error, `${this.partition} projection recovery failed`);
200
+ this.enterRecovery(recovery);
201
+ throw new JournalRecoveryRequiredError(recovery);
202
+ }
203
+ }
60
204
  inspect() {
61
205
  this.assertStarted();
62
206
  if (this.journal && this.recovery.status === "ready") {
@@ -64,7 +208,7 @@ export class JournalManager {
64
208
  if (state.status === "recovery_required")
65
209
  this.enterRecovery(state);
66
210
  }
67
- return this.inspection(fingerprintPartition(this.partitionDir));
211
+ return this.inspection(this.observeFingerprint(this.partitionDir, "journal partition").fingerprint);
68
212
  }
69
213
  ready() {
70
214
  this.assertStarted();
@@ -73,15 +217,21 @@ export class JournalManager {
73
217
  if (state.status === "recovery_required")
74
218
  this.enterRecovery(state);
75
219
  }
76
- return this.recovery.status === "ready";
220
+ return this.lifecycle === "active" && this.recovery.status === "ready";
77
221
  }
78
222
  validate() {
79
223
  this.assertStarted();
80
- const before = fingerprintPartition(this.partitionDir);
224
+ if (this.lifecycle === "prepared") {
225
+ try {
226
+ this.revalidatePreparation();
227
+ }
228
+ catch { }
229
+ }
230
+ const before = this.observeFingerprint(this.partitionDir, "journal validation input");
81
231
  const projectionStatus = [];
82
232
  for (const registration of this.registrations.values()) {
83
233
  try {
84
- const projection = registration.slot.current();
234
+ const projection = registration.slot.prepared();
85
235
  registration.descriptor.validate(projection);
86
236
  projectionStatus.push({
87
237
  name: registration.descriptor.name,
@@ -98,10 +248,11 @@ export class JournalManager {
98
248
  });
99
249
  }
100
250
  }
101
- const after = fingerprintPartition(this.partitionDir);
102
- if (before !== after)
251
+ const after = this.observeFingerprint(this.partitionDir, "journal validation input");
252
+ if (before.fingerprint !== after.fingerprint) {
103
253
  this.enterRecovery(recoveryAt(0, "journal changed during validation"));
104
- return { ...this.inspection(after), projectionStatus };
254
+ }
255
+ return { ...this.inspection(after.fingerprint), projectionStatus };
105
256
  }
106
257
  events(afterCursor) {
107
258
  this.assertStarted();
@@ -109,44 +260,13 @@ export class JournalManager {
109
260
  }
110
261
  exportRecovery() {
111
262
  this.assertStarted();
112
- const fingerprint = fingerprintPartition(this.partitionDir);
113
- const exportId = `journal-export-${this.now().getTime().toString(36)}-${randomUUID()}`;
114
- const exportsRoot = join(this.rootDir, "recovery-exports");
115
- ensureCanonicalPrivateDirectory(exportsRoot);
116
- const bundlePath = join(exportsRoot, exportId);
117
- ensureCanonicalPrivateDirectory(bundlePath);
118
- try {
119
- const entries = exportPartitionEntries(this.partitionDir, bundlePath);
120
- const createdAt = this.now().toISOString();
121
- const manifestPath = join(bundlePath, "manifest.json");
122
- writeExclusiveFile(manifestPath, Buffer.from(`${JSON.stringify({
123
- schemaVersion: 1,
124
- exportId,
125
- partition: this.partition,
126
- fingerprint,
127
- recovery: this.recovery,
128
- createdAt,
129
- entries,
130
- }, null, 2)}\n`), 0o400);
131
- fsyncDirectory(bundlePath);
132
- if (fingerprintPartition(this.partitionDir) !== fingerprint) {
133
- throw new Error("journal changed during recovery export");
134
- }
135
- return {
136
- schemaVersion: 1,
137
- exportId,
138
- partition: this.partition,
139
- fingerprint,
140
- bundlePath,
141
- manifestSha256: sha256File(manifestPath),
142
- createdAt,
143
- };
144
- }
145
- catch (error) {
146
- rmSync(bundlePath, { recursive: true, force: true });
147
- fsyncDirectory(exportsRoot);
148
- throw error;
149
- }
263
+ return exportJournalRecovery({
264
+ rootDir: this.rootDir,
265
+ partitionDir: this.partitionDir,
266
+ partition: this.partition,
267
+ recovery: this.recovery,
268
+ now: this.now,
269
+ });
150
270
  }
151
271
  preflightQuarantine(input) {
152
272
  this.assertStarted();
@@ -169,7 +289,8 @@ export class JournalManager {
169
289
  if (this.recovery.status !== "recovery_required") {
170
290
  throw typedError("journal_partition_ready", 409, "only a corrupt partition can be quarantined");
171
291
  }
172
- if (fingerprintPartition(this.partitionDir) !== input.expectedFingerprint) {
292
+ if (this.stableFingerprint(this.partitionDir, "journal recovery source").fingerprint !==
293
+ input.expectedFingerprint) {
173
294
  throw conflict("recovery_fingerprint_mismatch");
174
295
  }
175
296
  return { disposition: "new", receipt: null };
@@ -201,9 +322,9 @@ export class JournalManager {
201
322
  return this.resume(operation, operationPath);
202
323
  }
203
324
  close() {
204
- if (this.closed)
325
+ if (this.lifecycle === "closed")
205
326
  return;
206
- this.closed = true;
327
+ this.lifecycle = "closed";
207
328
  this.clearSlots();
208
329
  this.journal?.close();
209
330
  this.journal = null;
@@ -247,7 +368,7 @@ export class JournalManager {
247
368
  this.openGeneration();
248
369
  throw error;
249
370
  }
250
- this.closed = true;
371
+ this.lifecycle = "closed";
251
372
  fsyncDirectory(this.journalRoot);
252
373
  fsyncDirectory(archiveDir);
253
374
  return dest;
@@ -269,7 +390,7 @@ export class JournalManager {
269
390
  }
270
391
  renameSync(archivedPath, this.partitionDir);
271
392
  fsyncDirectory(this.journalRoot);
272
- this.closed = false;
393
+ this.lifecycle = "idle";
273
394
  this.openGeneration();
274
395
  }
275
396
  openGeneration() {
@@ -285,13 +406,17 @@ export class JournalManager {
285
406
  now: this.now,
286
407
  });
287
408
  this.recovery = this.journal.state();
288
- if (this.recovery.status === "recovery_required")
409
+ if (this.recovery.status === "recovery_required") {
410
+ this.enterRecovery(this.recovery);
289
411
  return;
412
+ }
290
413
  for (const registration of this.registrations.values()) {
291
414
  const projection = registration.descriptor.create(this.journal);
292
415
  registration.descriptor.validate(projection);
293
416
  registration.slot.bind(projection, this.generationValue);
294
417
  }
418
+ this.lifecycle = "active";
419
+ this.recoverAfterStartup();
295
420
  }
296
421
  catch (error) {
297
422
  const failed = this.journal;
@@ -308,12 +433,14 @@ export class JournalManager {
308
433
  this.clearSlots();
309
434
  }
310
435
  resume(operation, operationPath) {
311
- const sourceExists = existsSync(this.partitionDir);
312
- const targetExists = existsSync(operation.quarantinePath);
436
+ const source = this.stableFingerprint(this.partitionDir, "journal recovery source");
437
+ const target = this.stableFingerprint(operation.quarantinePath, "journal quarantine target");
438
+ const sourceExists = source.exists;
439
+ const targetExists = target.exists;
313
440
  if (sourceExists && targetExists)
314
441
  return this.completeFromReceipt(operation, operationPath);
315
442
  if (sourceExists) {
316
- if (fingerprintPartition(this.partitionDir) !== operation.expectedFingerprint) {
443
+ if (source.fingerprint !== operation.expectedFingerprint) {
317
444
  throw conflict("recovery_fingerprint_mismatch");
318
445
  }
319
446
  this.clearSlots();
@@ -322,7 +449,8 @@ export class JournalManager {
322
449
  renameSync(this.partitionDir, operation.quarantinePath);
323
450
  fsyncDirectory(this.journalRoot);
324
451
  fsyncDirectory(dirname(operation.quarantinePath));
325
- if (fingerprintPartition(operation.quarantinePath) !== operation.expectedFingerprint) {
452
+ if (this.stableFingerprint(operation.quarantinePath, "journal quarantine target")
453
+ .fingerprint !== operation.expectedFingerprint) {
326
454
  throw typedError("recovery_quarantine_mismatch", 503, "quarantined bytes changed");
327
455
  }
328
456
  this.faults.afterQuarantineRename?.();
@@ -330,7 +458,7 @@ export class JournalManager {
330
458
  else if (!targetExists) {
331
459
  throw typedError("recovery_operation_missing", 503, "recovery source and target are missing");
332
460
  }
333
- else if (fingerprintPartition(operation.quarantinePath) !== operation.expectedFingerprint) {
461
+ else if (target.fingerprint !== operation.expectedFingerprint) {
334
462
  throw typedError("recovery_quarantine_mismatch", 503, "quarantined bytes changed");
335
463
  }
336
464
  this.openGeneration();
@@ -358,52 +486,31 @@ export class JournalManager {
358
486
  return receipt;
359
487
  }
360
488
  completeFromReceipt(operation, operationPath) {
361
- if (fingerprintPartition(operation.quarantinePath) !== operation.expectedFingerprint) {
362
- throw typedError("recovery_quarantine_mismatch", 503, "quarantined bytes changed");
363
- }
364
489
  if (!this.journal)
365
490
  this.openGeneration();
366
491
  if (!this.journal || this.recovery.status === "recovery_required") {
367
492
  throw typedError("recovery_operation_ambiguous", 503, "fresh journal is unreadable");
368
493
  }
369
- const records = this.journal.records();
370
- if (records.length !== 1 || records[0]?.type !== "journal.partition_quarantined") {
371
- throw typedError("recovery_operation_ambiguous", 503, "fresh receipt is missing");
372
- }
373
- const receipt = matchingReceipt(operation, records[0].payload, this.partition, this.artifactPrefix);
374
- writeAtomicPrivateJson(operationPath, { ...operation, status: "completed", receipt }, false);
375
- return receipt;
494
+ return completePreparedReceipt({
495
+ rootDir: this.rootDir,
496
+ operation,
497
+ operationPath,
498
+ journal: this.journal,
499
+ partition: this.partition,
500
+ artifactPrefix: this.artifactPrefix,
501
+ });
376
502
  }
377
503
  reconcilePrepared() {
378
- if (!existsSync(this.operationsDir))
379
- return false;
380
- try {
381
- const prepared = readdirSync(this.operationsDir)
382
- .filter((name) => /^[a-f0-9]{64}\.json$/.test(name))
383
- .map((name) => {
384
- const path = join(this.operationsDir, name);
385
- return {
386
- path,
387
- operation: readOperation(path, this.quarantineDir, this.partition, this.artifactPrefix),
388
- };
389
- })
390
- .filter((entry) => entry.operation?.status === "prepared");
391
- if (prepared.length === 0)
392
- return false;
393
- if (prepared.length !== 1)
394
- throw new Error("multiple prepared recovery operations");
395
- const pending = prepared[0];
396
- if (existsSync(this.partitionDir) && !existsSync(pending.operation.quarantinePath)) {
397
- this.openGeneration();
398
- }
399
- else {
400
- this.resume(pending.operation, pending.path);
401
- }
402
- }
403
- catch (error) {
404
- this.enterRecovery(recoveryFrom(error, "prepared quarantine reconciliation failed"));
405
- }
406
- return true;
504
+ return reconcilePreparedOperationOnStart({
505
+ operationsDir: this.operationsDir,
506
+ quarantineDir: this.quarantineDir,
507
+ partitionDir: this.partitionDir,
508
+ partition: this.partition,
509
+ artifactPrefix: this.artifactPrefix,
510
+ openGeneration: () => this.openGeneration(),
511
+ resume: (operation, path) => void this.resume(operation, path),
512
+ failed: (error) => this.enterRecovery(recoveryFrom(error, "prepared quarantine reconciliation failed")),
513
+ });
407
514
  }
408
515
  inspection(fingerprint) {
409
516
  return {
@@ -418,8 +525,26 @@ export class JournalManager {
418
525
  };
419
526
  }
420
527
  enterRecovery(state) {
528
+ const closeWriter = this.lifecycle === "active";
421
529
  this.recovery = cloneRecovery(state);
530
+ this.lifecycle = "recovery_required";
422
531
  this.clearSlots();
532
+ if (closeWriter) {
533
+ this.journal?.close();
534
+ this.journal = null;
535
+ }
536
+ }
537
+ observeFingerprint(path, context) {
538
+ const observed = fingerprintPartition(path, this.rootDir);
539
+ if (observed.problem)
540
+ this.enterRecovery(recoveryAt(0, `${context}: ${observed.problem}`));
541
+ return observed;
542
+ }
543
+ stableFingerprint(path, context) {
544
+ const observed = this.observeFingerprint(path, context);
545
+ if (observed.problem)
546
+ throw new JournalRecoveryRequiredError(this.recovery);
547
+ return observed;
423
548
  }
424
549
  clearSlots() {
425
550
  for (const registration of this.registrations.values())
@@ -427,101 +552,12 @@ export class JournalManager {
427
552
  }
428
553
  assertStarted() {
429
554
  this.assertOpen();
430
- if (!this.started)
555
+ if (this.lifecycle === "idle")
431
556
  throw new Error("journal manager is not running");
432
557
  }
433
558
  assertOpen() {
434
- if (this.closed)
559
+ if (this.lifecycle === "closed")
435
560
  throw new Error("journal manager is closed");
436
561
  }
437
562
  }
438
- class ProjectionSlot {
439
- recovery;
440
- value = null;
441
- generationValue = 0;
442
- constructor(recovery) {
443
- this.recovery = recovery;
444
- }
445
- current() {
446
- if (this.value !== null)
447
- return this.value;
448
- const state = this.recovery();
449
- throw new JournalRecoveryRequiredError(state.status === "recovery_required"
450
- ? state
451
- : recoveryAt(0, "journal projection is unavailable"));
452
- }
453
- generation() {
454
- return this.generationValue;
455
- }
456
- bind(value, generation) {
457
- this.value = value;
458
- this.generationValue = generation;
459
- }
460
- clear() {
461
- this.value = null;
462
- }
463
- }
464
- function readOperation(path, quarantineDir, partition, artifactPrefix) {
465
- if (!existsSync(path))
466
- return null;
467
- const value = JSON.parse(readOwnedFile(path).toString("utf8"));
468
- if (!isRecord(value) ||
469
- value.schemaVersion !== 1 ||
470
- typeof value.operationId !== "string" ||
471
- typeof value.keyDigest !== "string" ||
472
- basename(path) !== `${value.keyDigest}.json` ||
473
- typeof value.requestDigest !== "string" ||
474
- typeof value.expectedFingerprint !== "string" ||
475
- typeof value.quarantinePath !== "string" ||
476
- value.quarantinePath !== join(quarantineDir, `${artifactPrefix}-${value.operationId}`) ||
477
- (value.status !== "prepared" && value.status !== "completed")) {
478
- throw new Error("recovery operation is malformed");
479
- }
480
- const operation = value;
481
- if (operation.status === "prepared" && operation.receipt !== null) {
482
- throw new Error("prepared recovery operation contains a receipt");
483
- }
484
- if (operation.status === "completed") {
485
- matchingReceipt(operation, undefined, partition, artifactPrefix);
486
- }
487
- return operation;
488
- }
489
- function matchingReceipt(operation, value, partition, artifactPrefix) {
490
- const receipt = QuarantineReceiptSchema.parse(value ?? operation.receipt);
491
- if (receipt.operationId !== operation.operationId ||
492
- receipt.partition !== partition ||
493
- receipt.previousFingerprint !== operation.expectedFingerprint ||
494
- receipt.quarantinePath !== operation.quarantinePath ||
495
- receipt.quarantineArtifactId !== `${artifactPrefix}-${operation.operationId}`) {
496
- throw typedError("recovery_receipt_mismatch", 503, "quarantine receipt does not match intent");
497
- }
498
- return receipt;
499
- }
500
- function validateRequest(input) {
501
- if (!input.idempotencyKey || input.idempotencyKey.length > 256) {
502
- throw typedError("invalid_idempotency_key", 400, "Idempotency-Key must contain 1-256 characters");
503
- }
504
- if (input.confirmation !== "quarantine_and_start_fresh") {
505
- throw typedError("quarantine_confirmation_required", 400, "explicit confirmation is required");
506
- }
507
- if (!/^[a-f0-9]{64}$/.test(input.expectedFingerprint)) {
508
- throw typedError("invalid_recovery_fingerprint", 400, "expectedFingerprint must be SHA-256");
509
- }
510
- }
511
- function quarantineRequestDigest(partition, input) {
512
- return sha256(Buffer.from(JSON.stringify({
513
- partition,
514
- expectedFingerprint: input.expectedFingerprint,
515
- confirmation: input.confirmation,
516
- })));
517
- }
518
- function conflict(code) {
519
- return typedError(code, 409, code.replaceAll("_", " "));
520
- }
521
- function typedError(code, status, message) {
522
- return Object.assign(new Error(message), { code, status });
523
- }
524
- function isRecord(value) {
525
- return typeof value === "object" && value !== null && !Array.isArray(value);
526
- }
527
563
  //# sourceMappingURL=journal-manager.js.map