@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/README.md +9 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +213 -93
- package/dist/index.mjs.map +1 -1
- package/dist/lifecycle.d.ts +38 -3
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/publication.d.ts.map +1 -1
- package/dist/session.d.ts +4 -3
- package/dist/session.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/generation-session.integration.test.ts +132 -4
- package/src/__tests__/multiprocess-lifecycle.integration.test.ts +21 -1
- package/src/index.ts +4 -0
- package/src/lifecycle.ts +297 -92
- package/src/publication.ts +12 -10
- package/src/session.ts +40 -13
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
382
|
+
revision: nextRevision,
|
|
380
383
|
generation: lease.generation,
|
|
381
384
|
...(current.lastKnownGoodKey === undefined
|
|
382
385
|
? {}
|
|
383
386
|
: { lastKnownGoodKey: current.lastKnownGoodKey }),
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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<
|
|
417
|
-
|
|
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:
|
|
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
|
-
/**
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
: {
|
|
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
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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> {
|
package/src/publication.ts
CHANGED
|
@@ -315,7 +315,13 @@ function validateEnvelope(
|
|
|
315
315
|
|
|
316
316
|
interface PublicationState {
|
|
317
317
|
snapshot: ScriptablePackPublicationSnapshot;
|
|
318
|
-
|
|
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
|
|
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.
|
|
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(
|
|
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(
|
|
434
|
+
state.staged.delete(candidate.envelope);
|
|
433
435
|
return result;
|
|
434
436
|
},
|
|
435
437
|
discard(sourcePath, candidate) {
|
|
436
|
-
stateFor(sourcePath).staged.delete(
|
|
438
|
+
stateFor(sourcePath).staged.delete(candidate);
|
|
437
439
|
},
|
|
438
440
|
restore(sourcePath, snapshot) {
|
|
439
441
|
const state = stateFor(sourcePath);
|