@forgeax/engine-ddc 0.1.6 → 0.1.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.
package/src/lifecycle.ts CHANGED
@@ -32,6 +32,33 @@ export interface DdcCommitResult {
32
32
  readonly result: 'current' | 'stale' | 'lease-lost' | 'invalid';
33
33
  readonly key: string;
34
34
  readonly revision?: number;
35
+ /** Internal CAS evidence retained for a later generation rollback. */
36
+ readonly restoreFence?: DdcRestoreFence;
37
+ }
38
+
39
+ export interface DdcRestoreFence {
40
+ readonly attempt: string;
41
+ readonly generation: number;
42
+ readonly revision: number;
43
+ readonly desiredKey: string;
44
+ readonly outcome: 'current' | 'invalid' | 'failed';
45
+ readonly key?: string;
46
+ readonly currentKey?: string;
47
+ readonly lastKnownGoodKey?: string;
48
+ readonly failure?: { readonly code: string; readonly detail: string };
49
+ }
50
+
51
+ export interface DdcRestoreResult {
52
+ readonly result: 'restored' | 'not-owner';
53
+ readonly revision: number;
54
+ readonly generation?: number;
55
+ }
56
+
57
+ export type DdcRollbackSnapshot = Omit<DdcHead, 'activeLease'>;
58
+
59
+ export interface DdcBeginResult {
60
+ readonly lease: DdcLease;
61
+ readonly previousHead: DdcRollbackSnapshot;
35
62
  }
36
63
 
37
64
  interface HeadRecord {
@@ -44,6 +71,7 @@ interface HeadRecord {
44
71
  readonly active?: DdcLease;
45
72
  readonly supersededAttempts?: readonly string[];
46
73
  readonly stale?: boolean;
74
+ readonly empty?: boolean;
47
75
  readonly failure?: {
48
76
  readonly desiredKey: string;
49
77
  readonly code: string;
@@ -71,9 +99,13 @@ function lockFile(root: string, name: string): string {
71
99
  function withRevision(
72
100
  result: Omit<DdcCommitResult, 'revision'>,
73
101
  revision: number,
102
+ restoreFence?: DdcRestoreFence,
74
103
  ): DdcCommitResult {
75
104
  const value = { ...result } as DdcCommitResult;
76
105
  Object.defineProperty(value, 'revision', { value: revision, enumerable: false });
106
+ if (restoreFence !== undefined) {
107
+ Object.defineProperty(value, 'restoreFence', { value: restoreFence, enumerable: false });
108
+ }
77
109
  return value;
78
110
  }
79
111
 
@@ -132,6 +164,7 @@ function isHeadRecord(value: unknown): value is HeadRecord {
132
164
  return false;
133
165
  }
134
166
  if (value.stale !== undefined && typeof value.stale !== 'boolean') return false;
167
+ if (value.empty !== undefined && typeof value.empty !== 'boolean') return false;
135
168
  if (value.failure !== undefined) {
136
169
  if (!isRecord(value.failure)) return false;
137
170
  if (
@@ -260,55 +293,15 @@ export class DdcLifecycle {
260
293
 
261
294
  public async inspect(guid: string, desiredKey: string): Promise<DdcHead> {
262
295
  const record = await this.read(guid);
263
- if (record === null) {
264
- return {
265
- guid,
266
- desiredKey,
267
- state: 'missing',
268
- currentKey: undefined,
269
- lastKnownGoodKey: undefined,
270
- revision: 0,
271
- };
272
- }
273
- const recordedFailure =
274
- record.failure?.desiredKey === desiredKey
275
- ? { code: record.failure.code, detail: record.failure.detail }
276
- : undefined;
277
- const currentEntry =
278
- record.currentKey === undefined ? null : await this.entries.readChecked(record.currentKey);
279
- const entryFailure =
280
- currentEntry !== null && !currentEntry.ok
281
- ? { code: currentEntry.error.code, detail: currentEntry.error.detail }
282
- : undefined;
283
- const failure = recordedFailure ?? entryFailure;
284
- const currentValue = currentEntry?.ok === true ? currentEntry.value : null;
285
- const state: DdcLifecycleState =
286
- failure !== undefined
287
- ? 'failed'
288
- : record.currentKey === desiredKey && currentValue?.guid === guid
289
- ? 'current'
290
- : record.stale === true
291
- ? 'stale'
292
- : record.active?.desiredKey === desiredKey
293
- ? 'cooking'
294
- : 'stale';
295
- return {
296
- guid,
297
- desiredKey,
298
- state,
299
- currentKey: record.currentKey,
300
- lastKnownGoodKey: record.lastKnownGoodKey,
301
- revision: record.revision,
302
- ...(record.generation === undefined ? {} : { generation: record.generation }),
303
- ...(record.active === undefined ? {} : { activeLease: record.active }),
304
- ...(failure === undefined ? {} : { failure }),
305
- };
296
+ return this.projectHead(guid, desiredKey, record);
306
297
  }
307
298
 
308
- public async begin(guid: string, desiredKey: string): Promise<DdcLease> {
299
+ /** Begin a lease and capture the accepted rollback snapshot under one head lock. */
300
+ public async beginWithSnapshot(guid: string, desiredKey: string): Promise<DdcBeginResult> {
309
301
  return withDdcLock(this.root, `head-${guid}`, async () => {
310
302
  const previous = await this.read(guid);
311
- const generation = await this.allocateGeneration();
303
+ const previousHead = await this.projectHead(guid, desiredKey, previous, false);
304
+ const generation = await this.allocateGeneration(previous?.generation ?? 0);
312
305
  const lease: DdcLease = {
313
306
  guid,
314
307
  desiredKey,
@@ -336,10 +329,14 @@ export class DdcLifecycle {
336
329
  ...(supersededAttempts.length === 0 ? {} : { supersededAttempts }),
337
330
  ...(lastKnownGoodKey === undefined ? {} : { lastKnownGoodKey }),
338
331
  });
339
- return lease;
332
+ return { lease, previousHead };
340
333
  });
341
334
  }
342
335
 
336
+ public async begin(guid: string, desiredKey: string): Promise<DdcLease> {
337
+ return (await this.beginWithSnapshot(guid, desiredKey)).lease;
338
+ }
339
+
343
340
  public async commit(lease: DdcLease, validatedKey: string): Promise<DdcCommitResult> {
344
341
  return withDdcLock(this.root, `head-${lease.guid}`, async () => {
345
342
  const current = await this.read(lease.guid);
@@ -373,21 +370,36 @@ export class DdcLifecycle {
373
370
  }
374
371
  const entry = await this.entries.read(validatedKey);
375
372
  if (entry === null || entry.guid !== lease.guid || entry.receipt.key !== validatedKey) {
373
+ const nextRevision = current.revision + 1;
374
+ const failure = {
375
+ desiredKey: lease.desiredKey,
376
+ code: 'entry-invalid',
377
+ detail: 'validated DDC key has no readable entry for this asset',
378
+ };
376
379
  await this.write({
377
380
  guid: lease.guid,
378
381
  desiredKey: lease.desiredKey,
379
- revision: current.revision + 1,
382
+ revision: nextRevision,
380
383
  generation: lease.generation,
381
384
  ...(current.lastKnownGoodKey === undefined
382
385
  ? {}
383
386
  : { lastKnownGoodKey: current.lastKnownGoodKey }),
384
- failure: {
385
- desiredKey: lease.desiredKey,
386
- code: 'entry-invalid',
387
- detail: 'validated DDC key has no readable entry for this asset',
388
- },
387
+ ...(current.supersededAttempts === undefined
388
+ ? {}
389
+ : { supersededAttempts: current.supersededAttempts }),
390
+ failure,
391
+ });
392
+ return withRevision({ result: 'invalid', key: validatedKey }, nextRevision, {
393
+ attempt: lease.attempt,
394
+ generation: lease.generation,
395
+ revision: nextRevision,
396
+ desiredKey: lease.desiredKey,
397
+ outcome: 'invalid',
398
+ ...(current.lastKnownGoodKey === undefined
399
+ ? {}
400
+ : { lastKnownGoodKey: current.lastKnownGoodKey }),
401
+ failure: { code: failure.code, detail: failure.detail },
389
402
  });
390
- return withRevision({ result: 'invalid', key: validatedKey }, current.revision + 1);
391
403
  }
392
404
  const lastKnownGoodKey =
393
405
  current.currentKey !== undefined && current.currentKey !== validatedKey
@@ -406,28 +418,53 @@ export class DdcLifecycle {
406
418
  : { supersededAttempts: current.supersededAttempts }),
407
419
  ...(lastKnownGoodKey === undefined ? {} : { lastKnownGoodKey }),
408
420
  });
409
- return withRevision({ result: 'current', key: validatedKey }, nextRevision);
421
+ return withRevision({ result: 'current', key: validatedKey }, nextRevision, {
422
+ attempt: lease.attempt,
423
+ generation: lease.generation,
424
+ revision: nextRevision,
425
+ desiredKey: lease.desiredKey,
426
+ outcome: 'current',
427
+ key: validatedKey,
428
+ currentKey: validatedKey,
429
+ ...(lastKnownGoodKey === undefined ? {} : { lastKnownGoodKey }),
430
+ });
410
431
  });
411
432
  }
412
433
 
413
434
  public async fail(
414
435
  lease: DdcLease,
415
436
  failure: { readonly code: string; readonly detail: string },
416
- ): Promise<void> {
417
- await withDdcLock(this.root, `head-${lease.guid}`, async () => {
437
+ ): Promise<DdcRestoreFence | undefined> {
438
+ return withDdcLock(this.root, `head-${lease.guid}`, async () => {
418
439
  const current = await this.read(lease.guid);
419
440
  if (current?.active?.attempt !== lease.attempt) return;
441
+ const nextRevision = current.revision + 1;
420
442
  await this.write({
421
443
  guid: lease.guid,
422
444
  desiredKey: lease.desiredKey,
423
- revision: current.revision + 1,
445
+ revision: nextRevision,
424
446
  generation: lease.generation,
425
447
  ...(current.currentKey === undefined ? {} : { currentKey: current.currentKey }),
426
448
  ...(current.lastKnownGoodKey === undefined
427
449
  ? {}
428
450
  : { lastKnownGoodKey: current.lastKnownGoodKey }),
451
+ ...(current.supersededAttempts === undefined
452
+ ? {}
453
+ : { supersededAttempts: current.supersededAttempts }),
429
454
  failure: { desiredKey: lease.desiredKey, ...failure },
430
455
  });
456
+ return {
457
+ attempt: lease.attempt,
458
+ generation: lease.generation,
459
+ revision: nextRevision,
460
+ desiredKey: lease.desiredKey,
461
+ outcome: 'failed' as const,
462
+ ...(current.currentKey === undefined ? {} : { currentKey: current.currentKey }),
463
+ ...(current.lastKnownGoodKey === undefined
464
+ ? {}
465
+ : { lastKnownGoodKey: current.lastKnownGoodKey }),
466
+ failure,
467
+ };
431
468
  });
432
469
  }
433
470
 
@@ -478,27 +515,81 @@ export class DdcLifecycle {
478
515
  });
479
516
  }
480
517
 
481
- /** Restore the last accepted head after a failed multi-owner generation commit. */
482
- public async restore(head: DdcHead): Promise<void> {
483
- await withDdcLock(this.root, `head-${head.guid}`, async () => {
484
- const path = headFile(this.heads, head.guid);
485
- if (head.state === 'missing') {
486
- await rm(path, { force: true });
487
- return;
488
- }
489
- await this.write({
490
- guid: head.guid,
491
- desiredKey: head.desiredKey,
518
+ /**
519
+ * Restore accepted content only when this lease still owns the mutable head.
520
+ * A newer active or terminal mutation makes rollback a deliberate no-op.
521
+ */
522
+ public async restore(
523
+ head: DdcHead | DdcRollbackSnapshot,
524
+ lease?: DdcLease,
525
+ fence?: DdcRestoreFence,
526
+ ): Promise<DdcRestoreResult> {
527
+ const inferredLease =
528
+ lease ??
529
+ ('activeLease' in head && head.activeLease !== undefined ? head.activeLease : undefined);
530
+ if (inferredLease === undefined) {
531
+ return {
532
+ result: 'not-owner',
492
533
  revision: head.revision ?? 0,
493
- ...(head.currentKey === undefined ? {} : { currentKey: head.currentKey }),
494
- ...(head.lastKnownGoodKey === undefined ? {} : { lastKnownGoodKey: head.lastKnownGoodKey }),
495
534
  ...(head.generation === undefined ? {} : { generation: head.generation }),
496
- ...(head.activeLease === undefined ? {} : { active: head.activeLease }),
497
- ...(head.state === 'stale' ? { stale: true } : {}),
498
- ...(head.failure === undefined
535
+ };
536
+ }
537
+ const snapshot = this.rollbackSnapshot(head);
538
+ return this.restoreIfCurrent(snapshot, inferredLease, fence);
539
+ }
540
+
541
+ public async restoreIfCurrent(
542
+ snapshot: DdcRollbackSnapshot,
543
+ lease: DdcLease,
544
+ fence?: DdcRestoreFence,
545
+ ): Promise<DdcRestoreResult> {
546
+ return withDdcLock(this.root, `head-${lease.guid}`, async () => {
547
+ const current = await this.read(lease.guid);
548
+ if (current === null || current.guid !== snapshot.guid) {
549
+ return {
550
+ result: 'not-owner',
551
+ revision: current?.revision ?? 0,
552
+ ...(current?.generation === undefined ? {} : { generation: current.generation }),
553
+ };
554
+ }
555
+ const ownsActive =
556
+ current.active?.attempt === lease.attempt &&
557
+ current.active.generation === lease.generation &&
558
+ current.active.desiredKey === lease.desiredKey;
559
+ const ownsTerminal = fence !== undefined && this.matchesRestoreFence(current, lease, fence);
560
+ if (!ownsActive && !ownsTerminal) {
561
+ return {
562
+ result: 'not-owner',
563
+ revision: current.revision,
564
+ ...(current.generation === undefined ? {} : { generation: current.generation }),
565
+ };
566
+ }
567
+
568
+ const nextRevision = current.revision + 1;
569
+ const generation = Math.max(
570
+ current.generation ?? 0,
571
+ lease.generation,
572
+ snapshot.generation ?? 0,
573
+ );
574
+ const supersededAttempts = current.supersededAttempts;
575
+ const restored: HeadRecord = {
576
+ guid: snapshot.guid,
577
+ desiredKey: snapshot.desiredKey,
578
+ revision: nextRevision,
579
+ generation,
580
+ ...(snapshot.currentKey === undefined ? {} : { currentKey: snapshot.currentKey }),
581
+ ...(snapshot.lastKnownGoodKey === undefined
499
582
  ? {}
500
- : { failure: { desiredKey: head.desiredKey, ...head.failure } }),
501
- });
583
+ : { lastKnownGoodKey: snapshot.lastKnownGoodKey }),
584
+ ...(supersededAttempts === undefined ? {} : { supersededAttempts }),
585
+ ...(snapshot.state === 'missing' ? { empty: true } : {}),
586
+ ...(snapshot.state === 'stale' ? { stale: true } : {}),
587
+ ...(snapshot.failure === undefined
588
+ ? {}
589
+ : { failure: { desiredKey: snapshot.desiredKey, ...snapshot.failure } }),
590
+ };
591
+ await this.write(restored);
592
+ return { result: 'restored', revision: nextRevision, generation };
502
593
  });
503
594
  }
504
595
 
@@ -517,6 +608,10 @@ export class DdcLifecycle {
517
608
  guid,
518
609
  desiredKey,
519
610
  revision: current.revision + 1,
611
+ ...(current.generation === undefined ? {} : { generation: current.generation }),
612
+ ...(current.supersededAttempts === undefined
613
+ ? {}
614
+ : { supersededAttempts: current.supersededAttempts }),
520
615
  ...(current.lastKnownGoodKey === undefined
521
616
  ? {}
522
617
  : { lastKnownGoodKey: current.lastKnownGoodKey }),
@@ -531,24 +626,134 @@ export class DdcLifecycle {
531
626
  });
532
627
  }
533
628
 
534
- private async allocateGeneration(): Promise<number> {
535
- const path = join(this.root, 'generations', 'counter.json');
536
- await mkdir(join(this.root, 'generations'), { recursive: true });
537
- let next = 1;
538
- try {
539
- const record = JSON.parse(await readFile(path, 'utf8')) as GenerationRecord;
540
- if (record.schemaVersion === 'forgeax-ddc-generation/v2' && Number.isInteger(record.next))
541
- next = Math.max(1, record.next);
542
- } catch {
543
- // A missing counter is the first allocation, not an implicit legacy fallback.
629
+ private rollbackSnapshot(head: DdcHead | DdcRollbackSnapshot): DdcRollbackSnapshot {
630
+ const { activeLease: _activeLease, ...snapshot } = head as DdcHead;
631
+ if (snapshot.state !== 'cooking') return snapshot;
632
+ return {
633
+ ...snapshot,
634
+ state: snapshot.currentKey === undefined ? 'missing' : 'stale',
635
+ };
636
+ }
637
+
638
+ private matchesRestoreFence(
639
+ current: HeadRecord,
640
+ lease: DdcLease,
641
+ fence: DdcRestoreFence,
642
+ ): boolean {
643
+ if (
644
+ fence.attempt !== lease.attempt ||
645
+ fence.generation !== lease.generation ||
646
+ current.active !== undefined ||
647
+ current.revision !== fence.revision ||
648
+ current.generation !== fence.generation ||
649
+ current.desiredKey !== fence.desiredKey ||
650
+ current.currentKey !== fence.currentKey ||
651
+ current.lastKnownGoodKey !== fence.lastKnownGoodKey
652
+ ) {
653
+ return false;
544
654
  }
545
- const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
546
- await writeFile(
547
- temporary,
548
- JSON.stringify({ schemaVersion: 'forgeax-ddc-generation/v2', next: next + 1 }),
549
- );
550
- await rename(temporary, path);
551
- return next;
655
+ if (fence.outcome === 'current') {
656
+ return (
657
+ fence.key !== undefined &&
658
+ current.currentKey === fence.key &&
659
+ current.failure === undefined &&
660
+ current.stale !== true
661
+ );
662
+ }
663
+ if (
664
+ fence.failure === undefined ||
665
+ current.failure === undefined ||
666
+ current.failure.desiredKey !== fence.desiredKey ||
667
+ current.failure.code !== fence.failure.code ||
668
+ current.failure.detail !== fence.failure.detail
669
+ ) {
670
+ return false;
671
+ }
672
+ return fence.outcome === 'invalid' ? fence.currentKey === undefined : true;
673
+ }
674
+
675
+ private async projectHead(
676
+ guid: string,
677
+ desiredKey: string,
678
+ record: HeadRecord | null,
679
+ includeActive = true,
680
+ ): Promise<DdcHead> {
681
+ if (record === null) {
682
+ return {
683
+ guid,
684
+ desiredKey,
685
+ state: 'missing',
686
+ currentKey: undefined,
687
+ lastKnownGoodKey: undefined,
688
+ revision: 0,
689
+ };
690
+ }
691
+ const recordedFailure =
692
+ record.failure?.desiredKey === desiredKey
693
+ ? { code: record.failure.code, detail: record.failure.detail }
694
+ : undefined;
695
+ const currentEntry =
696
+ record.currentKey === undefined ? null : await this.entries.readChecked(record.currentKey);
697
+ const entryFailure =
698
+ currentEntry !== null && !currentEntry.ok
699
+ ? { code: currentEntry.error.code, detail: currentEntry.error.detail }
700
+ : undefined;
701
+ const failure = recordedFailure ?? entryFailure;
702
+ const currentValue = currentEntry?.ok === true ? currentEntry.value : null;
703
+ const state: DdcLifecycleState =
704
+ failure !== undefined
705
+ ? 'failed'
706
+ : record.currentKey === desiredKey && currentValue?.guid === guid
707
+ ? 'current'
708
+ : record.stale === true
709
+ ? 'stale'
710
+ : includeActive && record.active?.desiredKey === desiredKey
711
+ ? 'cooking'
712
+ : record.empty === true
713
+ ? 'missing'
714
+ : 'stale';
715
+ return {
716
+ guid,
717
+ desiredKey,
718
+ state,
719
+ currentKey: record.currentKey,
720
+ lastKnownGoodKey: record.lastKnownGoodKey,
721
+ revision: record.revision,
722
+ ...(record.generation === undefined ? {} : { generation: record.generation }),
723
+ ...(includeActive && record.active === undefined
724
+ ? {}
725
+ : includeActive && record.active !== undefined
726
+ ? { activeLease: record.active }
727
+ : {}),
728
+ ...(failure === undefined ? {} : { failure }),
729
+ };
730
+ }
731
+
732
+ private async allocateGeneration(minimumExclusive = 0): Promise<number> {
733
+ return withDdcLock(this.root, 'generation-counter', async () => {
734
+ const path = join(this.root, 'generations', 'counter.json');
735
+ await mkdir(join(this.root, 'generations'), { recursive: true });
736
+ let next = 1;
737
+ try {
738
+ const record = JSON.parse(await readFile(path, 'utf8')) as GenerationRecord;
739
+ if (
740
+ record.schemaVersion === 'forgeax-ddc-generation/v2' &&
741
+ Number.isSafeInteger(record.next) &&
742
+ record.next >= 1
743
+ )
744
+ next = Math.max(1, record.next);
745
+ } catch {
746
+ // A missing counter is the first allocation, not an implicit legacy fallback.
747
+ }
748
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
749
+ next = Math.max(next, minimumExclusive + 1);
750
+ await writeFile(
751
+ temporary,
752
+ JSON.stringify({ schemaVersion: 'forgeax-ddc-generation/v2', next: next + 1 }),
753
+ );
754
+ await rename(temporary, path);
755
+ return next;
756
+ });
552
757
  }
553
758
 
554
759
  private async read(guid: string): Promise<HeadRecord | null> {
@@ -315,7 +315,13 @@ function validateEnvelope(
315
315
 
316
316
  interface PublicationState {
317
317
  snapshot: ScriptablePackPublicationSnapshot;
318
- readonly staged: Map<string, AssetPublicationEnvelope>;
318
+ /**
319
+ * Staged candidates are owned by their envelope object, not by the
320
+ * publication tuple. Two overlapping generations can legitimately produce
321
+ * the same deterministic tuple; a stale owner's discard must not remove the
322
+ * current owner's candidate before commit.
323
+ */
324
+ readonly staged: Set<AssetPublicationEnvelope>;
319
325
  }
320
326
 
321
327
  function validateCandidate(
@@ -389,13 +395,10 @@ export function createAcceptedPublicationStore(): AcceptedPublicationStore {
389
395
  function stateFor(sourcePath: string): PublicationState {
390
396
  const existing = states.get(sourcePath);
391
397
  if (existing !== undefined) return existing;
392
- const created: PublicationState = { snapshot: {}, staged: new Map() };
398
+ const created: PublicationState = { snapshot: {}, staged: new Set() };
393
399
  states.set(sourcePath, created);
394
400
  return created;
395
401
  }
396
- function candidateKey(candidate: AssetPublicationEnvelope): string {
397
- return `${candidate.generation}\0${candidate.digest}\0${candidate.outputSetDigest}`;
398
- }
399
402
  return {
400
403
  observe(sourcePath) {
401
404
  return stateFor(sourcePath).snapshot;
@@ -407,13 +410,12 @@ export function createAcceptedPublicationStore(): AcceptedPublicationStore {
407
410
  'publication request was cancelled before DDC commit',
408
411
  );
409
412
  if (invalid !== undefined) return err(invalid);
410
- stateFor(sourcePath).staged.set(candidateKey(candidate.envelope), candidate.envelope);
413
+ stateFor(sourcePath).staged.add(candidate.envelope);
411
414
  return ok(undefined);
412
415
  },
413
416
  async commit(sourcePath, candidate, commitRoute) {
414
- const key = candidateKey(candidate.envelope);
415
417
  const state = stateFor(sourcePath);
416
- if (!state.staged.has(key)) {
418
+ if (!state.staged.has(candidate.envelope)) {
417
419
  const current = state.snapshot.current;
418
420
  const lastKnownGood = state.snapshot.lastKnownGood ?? current;
419
421
  return err(
@@ -429,11 +431,11 @@ export function createAcceptedPublicationStore(): AcceptedPublicationStore {
429
431
  );
430
432
  }
431
433
  const result = await publishCandidate(state, candidate, commitRoute);
432
- state.staged.delete(key);
434
+ state.staged.delete(candidate.envelope);
433
435
  return result;
434
436
  },
435
437
  discard(sourcePath, candidate) {
436
- stateFor(sourcePath).staged.delete(candidateKey(candidate));
438
+ stateFor(sourcePath).staged.delete(candidate);
437
439
  },
438
440
  restore(sourcePath, snapshot) {
439
441
  const state = stateFor(sourcePath);