@forgeax/engine-ddc 0.1.2

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 (82) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +136 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/concurrency.integration.test.d.ts +2 -0
  5. package/dist/__tests__/concurrency.integration.test.d.ts.map +1 -0
  6. package/dist/__tests__/consumer-path.integration.test.d.ts +2 -0
  7. package/dist/__tests__/consumer-path.integration.test.d.ts.map +1 -0
  8. package/dist/__tests__/crash-recovery.integration.test.d.ts +2 -0
  9. package/dist/__tests__/crash-recovery.integration.test.d.ts.map +1 -0
  10. package/dist/__tests__/entry-store.integration.test.d.ts +2 -0
  11. package/dist/__tests__/entry-store.integration.test.d.ts.map +1 -0
  12. package/dist/__tests__/errors.unit.test.d.ts +2 -0
  13. package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/key.unit.test.d.ts +2 -0
  15. package/dist/__tests__/key.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/layout.unit.test.d.ts +2 -0
  17. package/dist/__tests__/layout.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/lifecycle.unit.test.d.ts +2 -0
  19. package/dist/__tests__/lifecycle.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/multiprocess-gc.integration.test.d.ts +2 -0
  21. package/dist/__tests__/multiprocess-gc.integration.test.d.ts.map +1 -0
  22. package/dist/__tests__/multiprocess-lifecycle.integration.test.d.ts +2 -0
  23. package/dist/__tests__/multiprocess-lifecycle.integration.test.d.ts.map +1 -0
  24. package/dist/__tests__/multiprocess-worker.d.ts +2 -0
  25. package/dist/__tests__/multiprocess-worker.d.ts.map +1 -0
  26. package/dist/__tests__/status-root-kind-owner.test-d.d.ts +2 -0
  27. package/dist/__tests__/status-root-kind-owner.test-d.d.ts.map +1 -0
  28. package/dist/__tests__/status.unit.test.d.ts +2 -0
  29. package/dist/__tests__/status.unit.test.d.ts.map +1 -0
  30. package/dist/entry-store.d.ts +52 -0
  31. package/dist/entry-store.d.ts.map +1 -0
  32. package/dist/entry-store.mjs +339 -0
  33. package/dist/entry-store.mjs.map +1 -0
  34. package/dist/errors.d.ts +58 -0
  35. package/dist/errors.d.ts.map +1 -0
  36. package/dist/errors.mjs +109 -0
  37. package/dist/errors.mjs.map +1 -0
  38. package/dist/gc.d.ts +13 -0
  39. package/dist/gc.d.ts.map +1 -0
  40. package/dist/index.d.ts +9 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.mjs +896 -0
  43. package/dist/index.mjs.map +1 -0
  44. package/dist/key.d.ts +13 -0
  45. package/dist/key.d.ts.map +1 -0
  46. package/dist/key.mjs +38 -0
  47. package/dist/key.mjs.map +1 -0
  48. package/dist/layout.d.ts +51 -0
  49. package/dist/layout.d.ts.map +1 -0
  50. package/dist/layout.mjs +68 -0
  51. package/dist/layout.mjs.map +1 -0
  52. package/dist/lifecycle.d.ts +55 -0
  53. package/dist/lifecycle.d.ts.map +1 -0
  54. package/dist/runtime-scope.d.ts +10 -0
  55. package/dist/runtime-scope.d.ts.map +1 -0
  56. package/dist/status.d.ts +39 -0
  57. package/dist/status.d.ts.map +1 -0
  58. package/dist/status.mjs +20 -0
  59. package/dist/status.mjs.map +1 -0
  60. package/package.json +98 -0
  61. package/src/__tests__/concurrency.integration.test.ts +53 -0
  62. package/src/__tests__/consumer-path.integration.test.ts +61 -0
  63. package/src/__tests__/crash-recovery.integration.test.ts +84 -0
  64. package/src/__tests__/entry-store.integration.test.ts +83 -0
  65. package/src/__tests__/errors.unit.test.ts +54 -0
  66. package/src/__tests__/key.unit.test.ts +49 -0
  67. package/src/__tests__/layout.unit.test.ts +50 -0
  68. package/src/__tests__/lifecycle.unit.test.ts +258 -0
  69. package/src/__tests__/multiprocess-gc.integration.test.ts +36 -0
  70. package/src/__tests__/multiprocess-lifecycle.integration.test.ts +109 -0
  71. package/src/__tests__/multiprocess-worker.ts +41 -0
  72. package/src/__tests__/status-root-kind-owner.test-d.ts +42 -0
  73. package/src/__tests__/status.unit.test.ts +56 -0
  74. package/src/entry-store.ts +307 -0
  75. package/src/errors.ts +164 -0
  76. package/src/gc.ts +34 -0
  77. package/src/index.ts +65 -0
  78. package/src/key.ts +46 -0
  79. package/src/layout.ts +132 -0
  80. package/src/lifecycle.ts +549 -0
  81. package/src/runtime-scope.ts +87 -0
  82. package/src/status.ts +58 -0
package/src/layout.ts ADDED
@@ -0,0 +1,132 @@
1
+ import { isAbsolute, normalize } from 'node:path';
2
+
3
+ export const DDC_LAYOUT_VERSION = 'v2' as const;
4
+
5
+ export interface DdcBuildOptions {
6
+ readonly buildCacheRoot: string;
7
+ }
8
+
9
+ export interface DdcServeOptions extends DdcBuildOptions {
10
+ readonly projectDdcRoot?: string;
11
+ }
12
+
13
+ export interface DdcBuildLayout {
14
+ readonly objects: string;
15
+ readonly staging: string;
16
+ }
17
+
18
+ export interface DdcProjectLayout {
19
+ readonly root: string;
20
+ readonly scope: string;
21
+ readonly heads: string;
22
+ readonly generations: string;
23
+ readonly leases: string;
24
+ readonly staging: string;
25
+ }
26
+
27
+ export interface DdcLayout {
28
+ readonly version: typeof DDC_LAYOUT_VERSION;
29
+ readonly buildCacheRoot: string;
30
+ readonly projectDdcRoot: string;
31
+ readonly build: DdcBuildLayout;
32
+ readonly project: DdcProjectLayout;
33
+ }
34
+
35
+ export interface DdcBuildLayoutResult {
36
+ readonly ok: true;
37
+ readonly value: DdcBuildLayout;
38
+ }
39
+
40
+ export interface DdcLayoutError {
41
+ readonly code: 'ddc-project-root-required' | 'ddc-root-absolute-required';
42
+ readonly detail: string;
43
+ readonly hint: string;
44
+ readonly expected: string;
45
+ readonly actual?: string;
46
+ }
47
+
48
+ export interface DdcLayoutResult {
49
+ readonly ok: true;
50
+ readonly value: DdcLayout;
51
+ }
52
+
53
+ export interface DdcLayoutFailure {
54
+ readonly ok: false;
55
+ readonly error: DdcLayoutError;
56
+ }
57
+
58
+ type ClosedLayoutResult = DdcLayoutResult | DdcLayoutFailure;
59
+
60
+ function rootOrError(root: string, name: string): string | DdcLayoutError {
61
+ if (!isAbsolute(root)) {
62
+ return {
63
+ code: 'ddc-root-absolute-required',
64
+ detail: `${name} must be an absolute injected path`,
65
+ hint: `inject the canonical ${name} from the host root policy`,
66
+ expected: 'an absolute filesystem path',
67
+ actual: root,
68
+ };
69
+ }
70
+ const normalized = normalize(root);
71
+ return normalized.replace(/[\\/]+$/, '') || normalized;
72
+ }
73
+
74
+ function buildLayout(root: string): DdcBuildLayout {
75
+ return { objects: `${root}/objects`, staging: `${root}/staging` };
76
+ }
77
+
78
+ function projectLayout(root: string): DdcProjectLayout {
79
+ return {
80
+ root,
81
+ scope: `${root}/scope.json`,
82
+ heads: `${root}/heads`,
83
+ generations: `${root}/generations`,
84
+ leases: `${root}/leases`,
85
+ staging: `${root}/staging`,
86
+ };
87
+ }
88
+
89
+ export function resolveBuildDdcLayout(
90
+ options: DdcBuildOptions,
91
+ ): DdcBuildLayoutResult | DdcLayoutFailure {
92
+ const root = rootOrError(options.buildCacheRoot, 'buildCacheRoot');
93
+ return typeof root === 'string'
94
+ ? { ok: true, value: buildLayout(root) }
95
+ : { ok: false, error: root };
96
+ }
97
+
98
+ export function resolveDdcLayout(options: DdcServeOptions): ClosedLayoutResult {
99
+ const buildRoot = rootOrError(options.buildCacheRoot, 'buildCacheRoot');
100
+ if (typeof buildRoot !== 'string') return { ok: false, error: buildRoot };
101
+ if (options.projectDdcRoot === undefined || options.projectDdcRoot.trim().length === 0) {
102
+ return {
103
+ ok: false,
104
+ error: {
105
+ code: 'ddc-project-root-required',
106
+ detail: 'serve and publication require an explicitly injected projectDdcRoot',
107
+ hint: 'canonicalize the game directory in the host and inject its .forgeax/ddc/v2 root',
108
+ expected: 'projectDdcRoot',
109
+ },
110
+ };
111
+ }
112
+ const projectRoot = rootOrError(options.projectDdcRoot, 'projectDdcRoot');
113
+ if (typeof projectRoot !== 'string') return { ok: false, error: projectRoot };
114
+ return {
115
+ ok: true,
116
+ value: {
117
+ version: DDC_LAYOUT_VERSION,
118
+ buildCacheRoot: buildRoot,
119
+ projectDdcRoot: projectRoot,
120
+ build: buildLayout(buildRoot),
121
+ project: projectLayout(projectRoot),
122
+ },
123
+ };
124
+ }
125
+
126
+ export function isDdcLayout(value: unknown): value is DdcLayout {
127
+ return (
128
+ value !== null &&
129
+ typeof value === 'object' &&
130
+ (value as { readonly version?: unknown }).version === DDC_LAYOUT_VERSION
131
+ );
132
+ }
@@ -0,0 +1,549 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { DdcEntryStore } from './entry-store.js';
5
+ import { DdcStoreError } from './errors.js';
6
+
7
+ export type DdcLifecycleState = 'missing' | 'cooking' | 'current' | 'stale' | 'failed';
8
+
9
+ export interface DdcLease {
10
+ readonly guid: string;
11
+ readonly desiredKey: string;
12
+ readonly attempt: string;
13
+ readonly generation: number;
14
+ readonly expectedRevision: number;
15
+ readonly instanceId: string;
16
+ readonly expiresAt: number;
17
+ }
18
+
19
+ export interface DdcHead {
20
+ readonly guid: string;
21
+ readonly desiredKey: string;
22
+ readonly state: DdcLifecycleState;
23
+ readonly currentKey: string | undefined;
24
+ readonly lastKnownGoodKey: string | undefined;
25
+ readonly revision?: number;
26
+ readonly generation?: number;
27
+ readonly activeLease?: DdcLease;
28
+ readonly failure?: { readonly code: string; readonly detail: string };
29
+ }
30
+
31
+ export interface DdcCommitResult {
32
+ readonly result: 'current' | 'stale' | 'lease-lost' | 'invalid';
33
+ readonly key: string;
34
+ readonly revision?: number;
35
+ }
36
+
37
+ interface HeadRecord {
38
+ readonly guid: string;
39
+ readonly desiredKey: string;
40
+ readonly revision: number;
41
+ readonly currentKey?: string;
42
+ readonly lastKnownGoodKey?: string;
43
+ readonly generation?: number;
44
+ readonly active?: DdcLease;
45
+ readonly supersededAttempts?: readonly string[];
46
+ readonly stale?: boolean;
47
+ readonly failure?: {
48
+ readonly desiredKey: string;
49
+ readonly code: string;
50
+ readonly detail: string;
51
+ };
52
+ }
53
+
54
+ interface GenerationRecord {
55
+ readonly schemaVersion: 'forgeax-ddc-generation/v2';
56
+ readonly next: number;
57
+ }
58
+
59
+ const LOCK_WAIT_MS = 10;
60
+ const LOCK_TIMEOUT_MS = 5000;
61
+ const LEASE_TTL_MS = 30_000;
62
+
63
+ function headFile(heads: string, guid: string): string {
64
+ return join(heads, `${encodeURIComponent(guid)}.json`);
65
+ }
66
+
67
+ function lockFile(root: string, name: string): string {
68
+ return join(root, 'locks', `${encodeURIComponent(name)}.lock`);
69
+ }
70
+
71
+ function withRevision(
72
+ result: Omit<DdcCommitResult, 'revision'>,
73
+ revision: number,
74
+ ): DdcCommitResult {
75
+ const value = { ...result } as DdcCommitResult;
76
+ Object.defineProperty(value, 'revision', { value: revision, enumerable: false });
77
+ return value;
78
+ }
79
+
80
+ async function delay(ms: number): Promise<void> {
81
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
82
+ }
83
+
84
+ function isRecord(value: unknown): value is Record<string, unknown> {
85
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
86
+ }
87
+
88
+ function isString(value: unknown): value is string {
89
+ return typeof value === 'string';
90
+ }
91
+
92
+ function isNonNegativeInteger(value: unknown): value is number {
93
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0;
94
+ }
95
+
96
+ function isLeaseRecord(value: unknown): value is DdcLease {
97
+ if (!isRecord(value)) return false;
98
+ return (
99
+ isString(value.guid) &&
100
+ isString(value.desiredKey) &&
101
+ isString(value.attempt) &&
102
+ isNonNegativeInteger(value.generation) &&
103
+ isNonNegativeInteger(value.expectedRevision) &&
104
+ isString(value.instanceId) &&
105
+ typeof value.expiresAt === 'number' &&
106
+ Number.isFinite(value.expiresAt)
107
+ );
108
+ }
109
+
110
+ function isHeadRecord(value: unknown): value is HeadRecord {
111
+ if (!isRecord(value)) return false;
112
+ if (
113
+ !isString(value.guid) ||
114
+ !isString(value.desiredKey) ||
115
+ !isNonNegativeInteger(value.revision)
116
+ ) {
117
+ return false;
118
+ }
119
+ if (value.currentKey !== undefined && !isString(value.currentKey)) return false;
120
+ if (value.lastKnownGoodKey !== undefined && !isString(value.lastKnownGoodKey)) return false;
121
+ if (
122
+ value.generation !== undefined &&
123
+ (!isNonNegativeInteger(value.generation) || value.generation === 0)
124
+ ) {
125
+ return false;
126
+ }
127
+ if (value.active !== undefined && !isLeaseRecord(value.active)) return false;
128
+ if (
129
+ value.supersededAttempts !== undefined &&
130
+ (!Array.isArray(value.supersededAttempts) || !value.supersededAttempts.every(isString))
131
+ ) {
132
+ return false;
133
+ }
134
+ if (value.stale !== undefined && typeof value.stale !== 'boolean') return false;
135
+ if (value.failure !== undefined) {
136
+ if (!isRecord(value.failure)) return false;
137
+ if (
138
+ !isString(value.failure.desiredKey) ||
139
+ !isString(value.failure.code) ||
140
+ !isString(value.failure.detail)
141
+ ) {
142
+ return false;
143
+ }
144
+ }
145
+ return true;
146
+ }
147
+
148
+ function malformedHeadError(
149
+ actual: 'syntax-invalid' | 'schema-invalid' | 'unreadable',
150
+ ): DdcStoreError {
151
+ const detail =
152
+ actual === 'syntax-invalid'
153
+ ? 'DDC head JSON is syntactically invalid'
154
+ : actual === 'schema-invalid'
155
+ ? 'DDC head record does not match the lifecycle schema'
156
+ : 'DDC head file is not readable';
157
+ return new DdcStoreError({
158
+ code: 'ddc-head-conflict',
159
+ detail,
160
+ expected: 'a valid DDC head record',
161
+ actual,
162
+ hint: 'inspect the current head and retry with a fresh revision',
163
+ owner: 'engine-ddc',
164
+ rootKind: 'project-ddc',
165
+ recoveryActions: [
166
+ { kind: 'inspect', executable: true },
167
+ { kind: 'retry', executable: true },
168
+ ],
169
+ });
170
+ }
171
+
172
+ /** Cross-process mkdir lock; a lock owned by a dead PID is reclaimable. */
173
+ export async function withDdcLock<T>(
174
+ root: string,
175
+ name: string,
176
+ operation: () => Promise<T>,
177
+ ): Promise<T> {
178
+ const path = lockFile(root, name);
179
+ await mkdir(join(root, 'locks'), { recursive: true });
180
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
181
+ while (true) {
182
+ try {
183
+ await mkdir(path);
184
+ await writeFile(
185
+ join(path, 'owner.json'),
186
+ JSON.stringify({ pid: process.pid, acquiredAt: Date.now() }),
187
+ );
188
+ try {
189
+ return await operation();
190
+ } finally {
191
+ await rm(path, { recursive: true, force: true });
192
+ }
193
+ } catch (error) {
194
+ if ((error as { code?: string }).code !== 'EEXIST') throw error;
195
+ let ownerPid: number | undefined;
196
+ try {
197
+ ownerPid = (
198
+ JSON.parse(await readFile(join(path, 'owner.json'), 'utf8')) as { pid?: number }
199
+ ).pid;
200
+ } catch {
201
+ ownerPid = undefined;
202
+ }
203
+ // mkdir() publishes the lock directory before the owner record can be
204
+ // written. Treat that short publication window as an in-flight lock,
205
+ // not as a dead owner: reclaiming it here lets two processes enter the
206
+ // same critical section and loses the superseded-attempt fence.
207
+ if (ownerPid === undefined) {
208
+ if (Date.now() >= deadline) {
209
+ throw new DdcStoreError({
210
+ code: 'ddc-lease-expired',
211
+ detail: `DDC lock ${name} did not publish an owner record`,
212
+ hint: 'inspect the lock owner and retry after the incomplete instance exits',
213
+ expected: 'a lock owner record',
214
+ actual: { path },
215
+ rootKind: 'project-ddc',
216
+ });
217
+ }
218
+ await delay(LOCK_WAIT_MS);
219
+ continue;
220
+ }
221
+ let alive = false;
222
+ try {
223
+ process.kill(ownerPid, 0);
224
+ alive = true;
225
+ } catch {
226
+ alive = false;
227
+ }
228
+ if (!alive) {
229
+ await rm(path, { recursive: true, force: true });
230
+ continue;
231
+ }
232
+ if (Date.now() >= deadline) {
233
+ throw new DdcStoreError({
234
+ code: 'ddc-lease-expired',
235
+ detail: `DDC lock ${name} remained owned by process ${ownerPid}`,
236
+ hint: 'inspect the owner and retry after the stale instance exits',
237
+ expected: 'an available project lock',
238
+ actual: { ownerPid },
239
+ rootKind: 'project-ddc',
240
+ lease: String(ownerPid),
241
+ });
242
+ }
243
+ await delay(LOCK_WAIT_MS);
244
+ }
245
+ }
246
+ }
247
+
248
+ export class DdcLifecycle {
249
+ private readonly heads: string;
250
+ private readonly entries: DdcEntryStore;
251
+ private readonly root: string;
252
+ private readonly leaseTtlMs: number;
253
+
254
+ public constructor(root: string, options?: { readonly leaseTtlMs?: number }) {
255
+ this.root = root;
256
+ this.heads = join(root, 'heads');
257
+ this.entries = new DdcEntryStore(root);
258
+ this.leaseTtlMs = options?.leaseTtlMs ?? LEASE_TTL_MS;
259
+ }
260
+
261
+ public async inspect(guid: string, desiredKey: string): Promise<DdcHead> {
262
+ 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
+ };
306
+ }
307
+
308
+ public async begin(guid: string, desiredKey: string): Promise<DdcLease> {
309
+ return withDdcLock(this.root, `head-${guid}`, async () => {
310
+ const previous = await this.read(guid);
311
+ const generation = await this.allocateGeneration();
312
+ const lease: DdcLease = {
313
+ guid,
314
+ desiredKey,
315
+ attempt: randomUUID(),
316
+ generation,
317
+ expectedRevision: previous?.revision ?? 0,
318
+ instanceId: `${process.pid}:${randomUUID()}`,
319
+ expiresAt: Date.now() + this.leaseTtlMs,
320
+ };
321
+ const lastKnownGoodKey =
322
+ previous?.currentKey !== undefined && previous.currentKey !== desiredKey
323
+ ? previous.currentKey
324
+ : previous?.lastKnownGoodKey;
325
+ const supersededAttempts = [
326
+ ...(previous?.supersededAttempts ?? []),
327
+ ...(previous?.active === undefined ? [] : [previous.active.attempt]),
328
+ ];
329
+ await this.write({
330
+ guid,
331
+ desiredKey,
332
+ revision: previous?.revision ?? 0,
333
+ generation,
334
+ active: lease,
335
+ ...(previous?.currentKey === undefined ? {} : { currentKey: previous.currentKey }),
336
+ ...(supersededAttempts.length === 0 ? {} : { supersededAttempts }),
337
+ ...(lastKnownGoodKey === undefined ? {} : { lastKnownGoodKey }),
338
+ });
339
+ return lease;
340
+ });
341
+ }
342
+
343
+ public async commit(lease: DdcLease, validatedKey: string): Promise<DdcCommitResult> {
344
+ return withDdcLock(this.root, `head-${lease.guid}`, async () => {
345
+ const current = await this.read(lease.guid);
346
+ if (current?.active?.attempt !== lease.attempt) {
347
+ if (current?.supersededAttempts?.includes(lease.attempt)) {
348
+ await this.write({ ...current, stale: true });
349
+ return withRevision({ result: 'stale', key: validatedKey }, current.revision);
350
+ }
351
+ return withRevision({ result: 'lease-lost', key: validatedKey }, current?.revision ?? 0);
352
+ }
353
+ if (Date.now() > lease.expiresAt) {
354
+ throw new DdcStoreError({
355
+ code: 'ddc-lease-expired',
356
+ detail: 'cook lease expired before commit fencing',
357
+ expected: { attempt: lease.attempt, revision: lease.expectedRevision },
358
+ actual: { revision: current.revision },
359
+ owner: 'engine-ddc',
360
+ rootKind: 'project-ddc',
361
+ generation: lease.generation,
362
+ lease: lease.attempt,
363
+ revision: current.revision,
364
+ });
365
+ }
366
+ if (
367
+ current.revision !== lease.expectedRevision ||
368
+ current.desiredKey !== lease.desiredKey ||
369
+ validatedKey !== lease.desiredKey
370
+ ) {
371
+ await this.write({ ...current, stale: true });
372
+ return withRevision({ result: 'stale', key: validatedKey }, current.revision);
373
+ }
374
+ const entry = await this.entries.read(validatedKey);
375
+ if (entry === null || entry.guid !== lease.guid || entry.receipt.key !== validatedKey) {
376
+ await this.write({
377
+ guid: lease.guid,
378
+ desiredKey: lease.desiredKey,
379
+ revision: current.revision + 1,
380
+ generation: lease.generation,
381
+ ...(current.lastKnownGoodKey === undefined
382
+ ? {}
383
+ : { 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
+ },
389
+ });
390
+ return withRevision({ result: 'invalid', key: validatedKey }, current.revision + 1);
391
+ }
392
+ const lastKnownGoodKey =
393
+ current.currentKey !== undefined && current.currentKey !== validatedKey
394
+ ? current.currentKey
395
+ : current.lastKnownGoodKey;
396
+ const nextRevision = current.revision + 1;
397
+ await this.write({
398
+ guid: lease.guid,
399
+ desiredKey: lease.desiredKey,
400
+ revision: nextRevision,
401
+ generation: lease.generation,
402
+ currentKey: validatedKey,
403
+ stale: false,
404
+ ...(current.supersededAttempts === undefined
405
+ ? {}
406
+ : { supersededAttempts: current.supersededAttempts }),
407
+ ...(lastKnownGoodKey === undefined ? {} : { lastKnownGoodKey }),
408
+ });
409
+ return withRevision({ result: 'current', key: validatedKey }, nextRevision);
410
+ });
411
+ }
412
+
413
+ public async fail(
414
+ lease: DdcLease,
415
+ failure: { readonly code: string; readonly detail: string },
416
+ ): Promise<void> {
417
+ await withDdcLock(this.root, `head-${lease.guid}`, async () => {
418
+ const current = await this.read(lease.guid);
419
+ if (current?.active?.attempt !== lease.attempt) return;
420
+ await this.write({
421
+ guid: lease.guid,
422
+ desiredKey: lease.desiredKey,
423
+ revision: current.revision + 1,
424
+ generation: lease.generation,
425
+ ...(current.currentKey === undefined ? {} : { currentKey: current.currentKey }),
426
+ ...(current.lastKnownGoodKey === undefined
427
+ ? {}
428
+ : { lastKnownGoodKey: current.lastKnownGoodKey }),
429
+ failure: { desiredKey: lease.desiredKey, ...failure },
430
+ });
431
+ });
432
+ }
433
+
434
+ public async heartbeat(lease: DdcLease): Promise<DdcLease> {
435
+ return withDdcLock(this.root, `head-${lease.guid}`, async () => {
436
+ const current = await this.read(lease.guid);
437
+ if (current?.active?.attempt !== lease.attempt) {
438
+ throw new DdcStoreError({
439
+ code: 'ddc-lease-expired',
440
+ detail: 'heartbeat belongs to a stale lease',
441
+ expected: lease.attempt,
442
+ actual: current?.active?.attempt,
443
+ lease: lease.attempt,
444
+ generation: lease.generation,
445
+ rootKind: 'project-ddc',
446
+ });
447
+ }
448
+ const refreshed = { ...lease, expiresAt: Date.now() + this.leaseTtlMs };
449
+ await this.write({ ...current, active: refreshed });
450
+ return refreshed;
451
+ });
452
+ }
453
+
454
+ public async close(lease: DdcLease): Promise<void> {
455
+ await withDdcLock(this.root, `head-${lease.guid}`, async () => {
456
+ const current = await this.read(lease.guid);
457
+ if (current?.active?.instanceId !== lease.instanceId) return;
458
+ const { active: _active, ...withoutActive } = current;
459
+ await this.write({
460
+ ...withoutActive,
461
+ revision: current.revision + 1,
462
+ stale: current.currentKey === undefined,
463
+ });
464
+ });
465
+ }
466
+
467
+ public async revoke(lease: DdcLease): Promise<void> {
468
+ await this.fail(lease, {
469
+ code: 'lease-lost',
470
+ detail: 'cook lease was revoked before validation',
471
+ });
472
+ }
473
+
474
+ public async recover(guid: string, desiredKey: string): Promise<DdcHead> {
475
+ return withDdcLock(this.root, `head-${guid}`, async () => {
476
+ const current = await this.read(guid);
477
+ if (current?.active?.desiredKey === desiredKey) {
478
+ await this.write({
479
+ guid,
480
+ desiredKey,
481
+ revision: current.revision + 1,
482
+ ...(current.lastKnownGoodKey === undefined
483
+ ? {}
484
+ : { lastKnownGoodKey: current.lastKnownGoodKey }),
485
+ failure: {
486
+ desiredKey,
487
+ code: 'writer-crashed',
488
+ detail: 'active cook attempt was recovered after process interruption',
489
+ },
490
+ });
491
+ }
492
+ return this.inspect(guid, desiredKey);
493
+ });
494
+ }
495
+
496
+ private async allocateGeneration(): Promise<number> {
497
+ const path = join(this.root, 'generations', 'counter.json');
498
+ await mkdir(join(this.root, 'generations'), { recursive: true });
499
+ let next = 1;
500
+ try {
501
+ const record = JSON.parse(await readFile(path, 'utf8')) as GenerationRecord;
502
+ if (record.schemaVersion === 'forgeax-ddc-generation/v2' && Number.isInteger(record.next))
503
+ next = Math.max(1, record.next);
504
+ } catch {
505
+ // A missing counter is the first allocation, not an implicit legacy fallback.
506
+ }
507
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
508
+ await writeFile(
509
+ temporary,
510
+ JSON.stringify({ schemaVersion: 'forgeax-ddc-generation/v2', next: next + 1 }),
511
+ );
512
+ await rename(temporary, path);
513
+ return next;
514
+ }
515
+
516
+ private async read(guid: string): Promise<HeadRecord | null> {
517
+ const path = headFile(this.heads, guid);
518
+ let source: string;
519
+ try {
520
+ source = await readFile(path, 'utf8');
521
+ } catch (error) {
522
+ if (
523
+ error !== null &&
524
+ typeof error === 'object' &&
525
+ 'code' in error &&
526
+ (error as { readonly code?: unknown }).code === 'ENOENT'
527
+ ) {
528
+ return null;
529
+ }
530
+ throw malformedHeadError('unreadable');
531
+ }
532
+ let value: unknown;
533
+ try {
534
+ value = JSON.parse(source);
535
+ } catch {
536
+ throw malformedHeadError('syntax-invalid');
537
+ }
538
+ if (!isHeadRecord(value) || value.guid !== guid) throw malformedHeadError('schema-invalid');
539
+ return value;
540
+ }
541
+
542
+ private async write(record: HeadRecord): Promise<void> {
543
+ await mkdir(this.heads, { recursive: true });
544
+ const path = headFile(this.heads, record.guid);
545
+ const temporary = `${path}.${randomUUID()}.tmp`;
546
+ await writeFile(temporary, JSON.stringify(record));
547
+ await rename(temporary, path);
548
+ }
549
+ }