@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/dist/index.mjs ADDED
@@ -0,0 +1,896 @@
1
+ import { createHash, randomUUID } from 'crypto';
2
+ import { mkdir, writeFile, stat, rm, rename, readdir, readFile } from 'fs/promises';
3
+ import { join, isAbsolute, normalize } from 'path';
4
+
5
+ // src/entry-store.ts
6
+
7
+ // src/errors.ts
8
+ var DDC_ERROR_CODES = [
9
+ "ddc-project-root-required",
10
+ "ddc-object-conflict",
11
+ "ddc-head-conflict",
12
+ "ddc-generation-conflict",
13
+ "ddc-scope-mismatch",
14
+ "ddc-lease-expired"
15
+ ];
16
+ var defaultHints = {
17
+ "ddc-project-root-required": "inject the canonical projectDdcRoot before serving or publishing",
18
+ "ddc-object-conflict": "keep the verified object and reject the competing publication",
19
+ "ddc-head-conflict": "inspect the current head and retry with a fresh revision",
20
+ "ddc-generation-conflict": "allocate a new persistent generation; do not edit the counter",
21
+ "ddc-scope-mismatch": "reinspect the canonical root and allocate a new scope",
22
+ "ddc-lease-expired": "discard the stale commit and acquire a new lease",
23
+ "ddc-entry-incomplete": "discard the partial entry and cold-cook from author authority",
24
+ "ddc-entry-invalid": "preserve last-known-good, inspect the entry, then cold-cook",
25
+ "ddc-entry-conflict": "keep the verified entry and reject the competing publication"
26
+ };
27
+ function defaultExpected(code) {
28
+ if (code === "ddc-project-root-required") return "projectDdcRoot";
29
+ if (code === "ddc-entry-incomplete" || code === "ddc-entry-invalid") {
30
+ return "a complete DDC entry whose receipt and integrity digests validate";
31
+ }
32
+ return "a valid DDC owner, scope, generation, revision, and lease";
33
+ }
34
+ function defaultActions(code) {
35
+ switch (code) {
36
+ case "ddc-project-root-required":
37
+ case "ddc-scope-mismatch":
38
+ return [
39
+ { kind: "inspect", executable: true },
40
+ { kind: "retry", executable: true }
41
+ ];
42
+ case "ddc-generation-conflict":
43
+ return [{ kind: "allocate-generation", executable: true }];
44
+ case "ddc-lease-expired":
45
+ return [
46
+ { kind: "release-lease", executable: true },
47
+ { kind: "retry", executable: true }
48
+ ];
49
+ case "ddc-object-conflict":
50
+ case "ddc-head-conflict":
51
+ return [
52
+ { kind: "inspect", executable: true },
53
+ { kind: "retry", executable: true }
54
+ ];
55
+ case "ddc-entry-incomplete":
56
+ case "ddc-entry-invalid":
57
+ return [{ kind: "cold-rebuild", executable: true }];
58
+ case "ddc-entry-conflict":
59
+ return [{ kind: "inspect", executable: true }];
60
+ }
61
+ }
62
+ var DdcStoreError = class extends Error {
63
+ code;
64
+ detail;
65
+ hint;
66
+ expected;
67
+ actual;
68
+ owner;
69
+ rootKind;
70
+ scope;
71
+ generation;
72
+ lease;
73
+ revision;
74
+ recoveryActions;
75
+ hostPath;
76
+ constructor(inputOrCode, legacyDetail) {
77
+ const input = typeof inputOrCode === "string" ? { code: inputOrCode, detail: legacyDetail ?? inputOrCode } : inputOrCode;
78
+ super(input.detail);
79
+ this.name = "DdcStoreError";
80
+ this.code = input.code;
81
+ this.detail = input.detail;
82
+ this.hint = input.hint ?? defaultHints[input.code];
83
+ this.expected = input.expected ?? defaultExpected(input.code);
84
+ this.actual = input.actual;
85
+ this.owner = input.owner;
86
+ this.rootKind = input.rootKind;
87
+ this.scope = input.scope;
88
+ this.generation = input.generation;
89
+ this.lease = input.lease;
90
+ this.revision = input.revision;
91
+ this.recoveryActions = input.recoveryActions ?? defaultActions(input.code);
92
+ this.hostPath = input.hostPath;
93
+ }
94
+ toBrowserProjection() {
95
+ return {
96
+ code: this.code,
97
+ hint: this.hint,
98
+ expected: this.expected,
99
+ ...this.actual === void 0 ? {} : { actual: this.actual },
100
+ ...this.owner === void 0 ? {} : { owner: this.owner },
101
+ ...this.rootKind === void 0 ? {} : { rootKind: this.rootKind },
102
+ ...this.scope === void 0 ? {} : { scope: this.scope },
103
+ ...this.generation === void 0 ? {} : { generation: this.generation },
104
+ ...this.lease === void 0 ? {} : { lease: this.lease },
105
+ ...this.revision === void 0 ? {} : { revision: this.revision },
106
+ recoveryActions: this.recoveryActions.map(
107
+ ({ exactTarget: _exactTarget, ...action }) => action
108
+ )
109
+ };
110
+ }
111
+ };
112
+ function canonicalDdcJson(value) {
113
+ const sorted = sortValue(value);
114
+ return JSON.stringify(sorted) ?? "null";
115
+ }
116
+ function sortValue(value) {
117
+ if (value instanceof Uint8Array) {
118
+ return { encoding: "base64", bytes: Buffer.from(value).toString("base64") };
119
+ }
120
+ if (Array.isArray(value)) return value.map(sortValue);
121
+ if (value !== null && typeof value === "object") {
122
+ const result = {};
123
+ for (const key of Object.keys(value).sort()) {
124
+ result[key] = sortValue(value[key]);
125
+ }
126
+ return result;
127
+ }
128
+ return value;
129
+ }
130
+ function semanticDdcKey(input) {
131
+ const semantic = {
132
+ schemaVersion: input.schemaVersion,
133
+ importer: input.importer,
134
+ codec: input.codec,
135
+ settings: input.settings,
136
+ sourceBytes: input.sourceBytes,
137
+ declaredGuids: [...input.declaredGuids].sort(),
138
+ targetProfile: input.targetProfile,
139
+ producer: input.producer
140
+ };
141
+ return createHash("sha256").update(canonicalDdcJson(semantic)).digest("hex");
142
+ }
143
+
144
+ // src/entry-store.ts
145
+ function digest(value) {
146
+ return createHash("sha256").update(value).digest("hex");
147
+ }
148
+ function artifactFile(key) {
149
+ if (/^[A-Za-z0-9._-]+$/.test(key)) return key;
150
+ return Buffer.from(key, "utf8").toString("base64url");
151
+ }
152
+ function validateKey(key) {
153
+ if (!/^[a-f0-9]{64}$/.test(key)) {
154
+ throw new DdcStoreError("ddc-entry-invalid", `invalid semantic key: ${key}`);
155
+ }
156
+ }
157
+ function errorCode(error) {
158
+ if (error !== null && typeof error === "object" && "code" in error) {
159
+ const code = error.code;
160
+ return typeof code === "string" ? code : void 0;
161
+ }
162
+ return void 0;
163
+ }
164
+ function sortedArtifacts(entry) {
165
+ return Object.entries(entry.artifacts).sort(([left], [right]) => left.localeCompare(right));
166
+ }
167
+ function ddcOutputDigest(entry) {
168
+ return `sha256:${digest(
169
+ canonicalDdcJson({
170
+ guid: entry.guid,
171
+ payload: entry.payload,
172
+ refs: entry.refs,
173
+ artifacts: Object.fromEntries(
174
+ sortedArtifacts(entry).map(([key, artifact]) => [
175
+ key,
176
+ {
177
+ mediaType: artifact.mediaType,
178
+ bytes: artifact.bytes
179
+ }
180
+ ])
181
+ )
182
+ })
183
+ )}`;
184
+ }
185
+ function existingResult(existing, candidate, key) {
186
+ return canonicalDdcJson(existing) === canonicalDdcJson(candidate) ? { result: "existing", key } : { result: "conflict", key };
187
+ }
188
+ function entryShape(entry) {
189
+ return {
190
+ key: entry.key,
191
+ guid: entry.guid,
192
+ payload: entry.payload,
193
+ refs: entry.refs,
194
+ receipt: entry.receipt,
195
+ artifacts: Object.fromEntries(
196
+ sortedArtifacts(entry).map(([key, artifact]) => [
197
+ key,
198
+ { mediaType: artifact.mediaType, byteLength: artifact.bytes.byteLength }
199
+ ])
200
+ )
201
+ };
202
+ }
203
+ function integrityFor(entry) {
204
+ const artifacts = Object.fromEntries(
205
+ sortedArtifacts(entry).map(([key, artifact]) => [
206
+ key,
207
+ {
208
+ mediaType: artifact.mediaType,
209
+ byteLength: artifact.bytes.byteLength,
210
+ digest: digest(artifact.bytes)
211
+ }
212
+ ])
213
+ );
214
+ return {
215
+ payload: digest(canonicalDdcJson(entry.payload)),
216
+ refs: digest(canonicalDdcJson(entry.refs)),
217
+ receipt: digest(canonicalDdcJson(entry.receipt)),
218
+ artifacts,
219
+ entry: digest(canonicalDdcJson(entryShape(entry)))
220
+ };
221
+ }
222
+ async function readJson(path) {
223
+ return JSON.parse(await readFile(path, "utf8"));
224
+ }
225
+ var DdcEntryStore = class {
226
+ entries;
227
+ staging;
228
+ constructor(root) {
229
+ this.entries = join(root, "entries");
230
+ this.staging = join(root, "staging");
231
+ }
232
+ async stage(entry) {
233
+ validateKey(entry.key);
234
+ if (entry.receipt.key !== entry.key || entry.receipt.guid !== entry.guid) {
235
+ throw new DdcStoreError("ddc-entry-invalid", "receipt identity does not match entry");
236
+ }
237
+ if (entry.receipt.outputDigest !== ddcOutputDigest(entry)) {
238
+ throw new DdcStoreError("ddc-entry-invalid", "receipt output digest does not match entry");
239
+ }
240
+ const path = join(this.staging, `${entry.key}-${randomUUID()}`);
241
+ await mkdir(join(path, "artifacts"), { recursive: true });
242
+ await writeFile(join(path, "payload.json"), canonicalDdcJson(entry.payload));
243
+ await writeFile(join(path, "refs.json"), canonicalDdcJson(entry.refs));
244
+ await writeFile(join(path, "receipt.json"), canonicalDdcJson(entry.receipt));
245
+ const artifacts = {};
246
+ for (const [key, artifact] of sortedArtifacts(entry)) {
247
+ const file = artifactFile(key);
248
+ artifacts[key] = { mediaType: artifact.mediaType, file };
249
+ await writeFile(join(path, "artifacts", `${file}.bin`), artifact.bytes);
250
+ }
251
+ await writeFile(join(path, "artifacts.json"), canonicalDdcJson(artifacts));
252
+ await writeFile(join(path, "integrity.json"), canonicalDdcJson(integrityFor(entry)));
253
+ return { key: entry.key, path };
254
+ }
255
+ async publish(staged) {
256
+ validateKey(staged.key);
257
+ const candidate = await this.readDirectory(staged.path, true);
258
+ const target = join(this.entries, staged.key);
259
+ await mkdir(this.entries, { recursive: true });
260
+ try {
261
+ await stat(target);
262
+ const existing = await this.readDirectory(target, false);
263
+ await rm(staged.path, { recursive: true, force: true });
264
+ return existingResult(existing, candidate, staged.key);
265
+ } catch (error) {
266
+ if (error instanceof DdcStoreError) throw error;
267
+ if (errorCode(error) !== "ENOENT") throw error;
268
+ }
269
+ try {
270
+ await rename(staged.path, target);
271
+ return { result: "published", key: staged.key };
272
+ } catch (error) {
273
+ if (!["EEXIST", "ENOTEMPTY", "EISDIR"].includes(errorCode(error) ?? "")) throw error;
274
+ const existing = await this.readDirectory(target, false);
275
+ await rm(staged.path, { recursive: true, force: true });
276
+ return existingResult(existing, candidate, staged.key);
277
+ }
278
+ }
279
+ async write(entry) {
280
+ const staged = await this.stage(entry);
281
+ return this.publish(staged);
282
+ }
283
+ async read(key) {
284
+ try {
285
+ validateKey(key);
286
+ return await this.readDirectory(join(this.entries, key), false);
287
+ } catch {
288
+ return null;
289
+ }
290
+ }
291
+ async listKeys() {
292
+ const entries = await readdir(this.entries, { withFileTypes: true }).catch(() => []);
293
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
294
+ }
295
+ async remove(key) {
296
+ validateKey(key);
297
+ await rm(join(this.entries, key), { recursive: true, force: false });
298
+ }
299
+ /** Read with a machine-readable corruption result for recovery tooling. */
300
+ async readChecked(key) {
301
+ try {
302
+ validateKey(key);
303
+ try {
304
+ await stat(join(this.entries, key));
305
+ } catch (error) {
306
+ if (errorCode(error) === "ENOENT") return { ok: true, value: null };
307
+ throw error;
308
+ }
309
+ try {
310
+ return { ok: true, value: await this.readDirectory(join(this.entries, key), false) };
311
+ } catch (error) {
312
+ if (error instanceof DdcStoreError && error.code === "ddc-entry-incomplete") {
313
+ return { ok: true, value: null };
314
+ }
315
+ throw error;
316
+ }
317
+ } catch (error) {
318
+ const normalized = error instanceof DdcStoreError ? error : new DdcStoreError("ddc-entry-invalid", "entry is unreadable");
319
+ return { ok: false, error: normalized };
320
+ }
321
+ }
322
+ async readDirectory(path, incompleteIsError) {
323
+ try {
324
+ const payload = await readJson(join(path, "payload.json"));
325
+ const refs = await readJson(join(path, "refs.json"));
326
+ const receipt = await readJson(join(path, "receipt.json"));
327
+ const manifest = await readJson(join(path, "artifacts.json"));
328
+ const integrity = await readJson(join(path, "integrity.json"));
329
+ const artifacts = {};
330
+ for (const [key, descriptor] of Object.entries(manifest).sort(
331
+ ([left], [right]) => left.localeCompare(right)
332
+ )) {
333
+ const bytes = new Uint8Array(
334
+ await readFile(join(path, "artifacts", `${descriptor.file}.bin`))
335
+ );
336
+ artifacts[key] = { mediaType: descriptor.mediaType, bytes };
337
+ }
338
+ const entry = { key: receipt.key, guid: receipt.guid, payload, refs, artifacts, receipt };
339
+ if (entry.receipt.outputDigest !== ddcOutputDigest(entry)) {
340
+ throw new DdcStoreError("ddc-entry-invalid", "receipt output digest does not match entry");
341
+ }
342
+ const actual = integrityFor(entry);
343
+ if (canonicalDdcJson(actual) !== canonicalDdcJson(integrity)) {
344
+ throw new DdcStoreError("ddc-entry-invalid", "entry integrity mismatch");
345
+ }
346
+ return entry;
347
+ } catch (error) {
348
+ if (incompleteIsError) {
349
+ if (error instanceof DdcStoreError) throw error;
350
+ throw new DdcStoreError("ddc-entry-incomplete", "staged entry is incomplete");
351
+ }
352
+ if (error instanceof DdcStoreError) throw error;
353
+ throw new DdcStoreError("ddc-entry-invalid", "entry is unreadable");
354
+ }
355
+ }
356
+ };
357
+ var LOCK_WAIT_MS = 10;
358
+ var LOCK_TIMEOUT_MS = 5e3;
359
+ var LEASE_TTL_MS = 3e4;
360
+ function headFile(heads, guid) {
361
+ return join(heads, `${encodeURIComponent(guid)}.json`);
362
+ }
363
+ function lockFile(root, name) {
364
+ return join(root, "locks", `${encodeURIComponent(name)}.lock`);
365
+ }
366
+ function withRevision(result, revision) {
367
+ const value = { ...result };
368
+ Object.defineProperty(value, "revision", { value: revision, enumerable: false });
369
+ return value;
370
+ }
371
+ async function delay(ms) {
372
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
373
+ }
374
+ function isRecord(value) {
375
+ return value !== null && typeof value === "object" && !Array.isArray(value);
376
+ }
377
+ function isString(value) {
378
+ return typeof value === "string";
379
+ }
380
+ function isNonNegativeInteger(value) {
381
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
382
+ }
383
+ function isLeaseRecord(value) {
384
+ if (!isRecord(value)) return false;
385
+ return isString(value.guid) && isString(value.desiredKey) && isString(value.attempt) && isNonNegativeInteger(value.generation) && isNonNegativeInteger(value.expectedRevision) && isString(value.instanceId) && typeof value.expiresAt === "number" && Number.isFinite(value.expiresAt);
386
+ }
387
+ function isHeadRecord(value) {
388
+ if (!isRecord(value)) return false;
389
+ if (!isString(value.guid) || !isString(value.desiredKey) || !isNonNegativeInteger(value.revision)) {
390
+ return false;
391
+ }
392
+ if (value.currentKey !== void 0 && !isString(value.currentKey)) return false;
393
+ if (value.lastKnownGoodKey !== void 0 && !isString(value.lastKnownGoodKey)) return false;
394
+ if (value.generation !== void 0 && (!isNonNegativeInteger(value.generation) || value.generation === 0)) {
395
+ return false;
396
+ }
397
+ if (value.active !== void 0 && !isLeaseRecord(value.active)) return false;
398
+ if (value.supersededAttempts !== void 0 && (!Array.isArray(value.supersededAttempts) || !value.supersededAttempts.every(isString))) {
399
+ return false;
400
+ }
401
+ if (value.stale !== void 0 && typeof value.stale !== "boolean") return false;
402
+ if (value.failure !== void 0) {
403
+ if (!isRecord(value.failure)) return false;
404
+ if (!isString(value.failure.desiredKey) || !isString(value.failure.code) || !isString(value.failure.detail)) {
405
+ return false;
406
+ }
407
+ }
408
+ return true;
409
+ }
410
+ function malformedHeadError(actual) {
411
+ const detail = actual === "syntax-invalid" ? "DDC head JSON is syntactically invalid" : actual === "schema-invalid" ? "DDC head record does not match the lifecycle schema" : "DDC head file is not readable";
412
+ return new DdcStoreError({
413
+ code: "ddc-head-conflict",
414
+ detail,
415
+ expected: "a valid DDC head record",
416
+ actual,
417
+ hint: "inspect the current head and retry with a fresh revision",
418
+ owner: "engine-ddc",
419
+ rootKind: "project-ddc",
420
+ recoveryActions: [
421
+ { kind: "inspect", executable: true },
422
+ { kind: "retry", executable: true }
423
+ ]
424
+ });
425
+ }
426
+ async function withDdcLock(root, name, operation) {
427
+ const path = lockFile(root, name);
428
+ await mkdir(join(root, "locks"), { recursive: true });
429
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
430
+ while (true) {
431
+ try {
432
+ await mkdir(path);
433
+ await writeFile(
434
+ join(path, "owner.json"),
435
+ JSON.stringify({ pid: process.pid, acquiredAt: Date.now() })
436
+ );
437
+ try {
438
+ return await operation();
439
+ } finally {
440
+ await rm(path, { recursive: true, force: true });
441
+ }
442
+ } catch (error) {
443
+ if (error.code !== "EEXIST") throw error;
444
+ let ownerPid;
445
+ try {
446
+ ownerPid = JSON.parse(await readFile(join(path, "owner.json"), "utf8")).pid;
447
+ } catch {
448
+ ownerPid = void 0;
449
+ }
450
+ if (ownerPid === void 0) {
451
+ if (Date.now() >= deadline) {
452
+ throw new DdcStoreError({
453
+ code: "ddc-lease-expired",
454
+ detail: `DDC lock ${name} did not publish an owner record`,
455
+ hint: "inspect the lock owner and retry after the incomplete instance exits",
456
+ expected: "a lock owner record",
457
+ actual: { path },
458
+ rootKind: "project-ddc"
459
+ });
460
+ }
461
+ await delay(LOCK_WAIT_MS);
462
+ continue;
463
+ }
464
+ let alive = false;
465
+ try {
466
+ process.kill(ownerPid, 0);
467
+ alive = true;
468
+ } catch {
469
+ alive = false;
470
+ }
471
+ if (!alive) {
472
+ await rm(path, { recursive: true, force: true });
473
+ continue;
474
+ }
475
+ if (Date.now() >= deadline) {
476
+ throw new DdcStoreError({
477
+ code: "ddc-lease-expired",
478
+ detail: `DDC lock ${name} remained owned by process ${ownerPid}`,
479
+ hint: "inspect the owner and retry after the stale instance exits",
480
+ expected: "an available project lock",
481
+ actual: { ownerPid },
482
+ rootKind: "project-ddc",
483
+ lease: String(ownerPid)
484
+ });
485
+ }
486
+ await delay(LOCK_WAIT_MS);
487
+ }
488
+ }
489
+ }
490
+ var DdcLifecycle = class {
491
+ heads;
492
+ entries;
493
+ root;
494
+ leaseTtlMs;
495
+ constructor(root, options) {
496
+ this.root = root;
497
+ this.heads = join(root, "heads");
498
+ this.entries = new DdcEntryStore(root);
499
+ this.leaseTtlMs = options?.leaseTtlMs ?? LEASE_TTL_MS;
500
+ }
501
+ async inspect(guid, desiredKey) {
502
+ const record = await this.read(guid);
503
+ if (record === null) {
504
+ return {
505
+ guid,
506
+ desiredKey,
507
+ state: "missing",
508
+ currentKey: void 0,
509
+ lastKnownGoodKey: void 0,
510
+ revision: 0
511
+ };
512
+ }
513
+ const recordedFailure = record.failure?.desiredKey === desiredKey ? { code: record.failure.code, detail: record.failure.detail } : void 0;
514
+ const currentEntry = record.currentKey === void 0 ? null : await this.entries.readChecked(record.currentKey);
515
+ const entryFailure = currentEntry !== null && !currentEntry.ok ? { code: currentEntry.error.code, detail: currentEntry.error.detail } : void 0;
516
+ const failure = recordedFailure ?? entryFailure;
517
+ const currentValue = currentEntry?.ok === true ? currentEntry.value : null;
518
+ const state = failure !== void 0 ? "failed" : record.currentKey === desiredKey && currentValue?.guid === guid ? "current" : record.stale === true ? "stale" : record.active?.desiredKey === desiredKey ? "cooking" : "stale";
519
+ return {
520
+ guid,
521
+ desiredKey,
522
+ state,
523
+ currentKey: record.currentKey,
524
+ lastKnownGoodKey: record.lastKnownGoodKey,
525
+ revision: record.revision,
526
+ ...record.generation === void 0 ? {} : { generation: record.generation },
527
+ ...record.active === void 0 ? {} : { activeLease: record.active },
528
+ ...failure === void 0 ? {} : { failure }
529
+ };
530
+ }
531
+ async begin(guid, desiredKey) {
532
+ return withDdcLock(this.root, `head-${guid}`, async () => {
533
+ const previous = await this.read(guid);
534
+ const generation = await this.allocateGeneration();
535
+ const lease = {
536
+ guid,
537
+ desiredKey,
538
+ attempt: randomUUID(),
539
+ generation,
540
+ expectedRevision: previous?.revision ?? 0,
541
+ instanceId: `${process.pid}:${randomUUID()}`,
542
+ expiresAt: Date.now() + this.leaseTtlMs
543
+ };
544
+ const lastKnownGoodKey = previous?.currentKey !== void 0 && previous.currentKey !== desiredKey ? previous.currentKey : previous?.lastKnownGoodKey;
545
+ const supersededAttempts = [
546
+ ...previous?.supersededAttempts ?? [],
547
+ ...previous?.active === void 0 ? [] : [previous.active.attempt]
548
+ ];
549
+ await this.write({
550
+ guid,
551
+ desiredKey,
552
+ revision: previous?.revision ?? 0,
553
+ generation,
554
+ active: lease,
555
+ ...previous?.currentKey === void 0 ? {} : { currentKey: previous.currentKey },
556
+ ...supersededAttempts.length === 0 ? {} : { supersededAttempts },
557
+ ...lastKnownGoodKey === void 0 ? {} : { lastKnownGoodKey }
558
+ });
559
+ return lease;
560
+ });
561
+ }
562
+ async commit(lease, validatedKey) {
563
+ return withDdcLock(this.root, `head-${lease.guid}`, async () => {
564
+ const current = await this.read(lease.guid);
565
+ if (current?.active?.attempt !== lease.attempt) {
566
+ if (current?.supersededAttempts?.includes(lease.attempt)) {
567
+ await this.write({ ...current, stale: true });
568
+ return withRevision({ result: "stale", key: validatedKey }, current.revision);
569
+ }
570
+ return withRevision({ result: "lease-lost", key: validatedKey }, current?.revision ?? 0);
571
+ }
572
+ if (Date.now() > lease.expiresAt) {
573
+ throw new DdcStoreError({
574
+ code: "ddc-lease-expired",
575
+ detail: "cook lease expired before commit fencing",
576
+ expected: { attempt: lease.attempt, revision: lease.expectedRevision },
577
+ actual: { revision: current.revision },
578
+ owner: "engine-ddc",
579
+ rootKind: "project-ddc",
580
+ generation: lease.generation,
581
+ lease: lease.attempt,
582
+ revision: current.revision
583
+ });
584
+ }
585
+ if (current.revision !== lease.expectedRevision || current.desiredKey !== lease.desiredKey || validatedKey !== lease.desiredKey) {
586
+ await this.write({ ...current, stale: true });
587
+ return withRevision({ result: "stale", key: validatedKey }, current.revision);
588
+ }
589
+ const entry = await this.entries.read(validatedKey);
590
+ if (entry === null || entry.guid !== lease.guid || entry.receipt.key !== validatedKey) {
591
+ await this.write({
592
+ guid: lease.guid,
593
+ desiredKey: lease.desiredKey,
594
+ revision: current.revision + 1,
595
+ generation: lease.generation,
596
+ ...current.lastKnownGoodKey === void 0 ? {} : { lastKnownGoodKey: current.lastKnownGoodKey },
597
+ failure: {
598
+ desiredKey: lease.desiredKey,
599
+ code: "entry-invalid",
600
+ detail: "validated DDC key has no readable entry for this asset"
601
+ }
602
+ });
603
+ return withRevision({ result: "invalid", key: validatedKey }, current.revision + 1);
604
+ }
605
+ const lastKnownGoodKey = current.currentKey !== void 0 && current.currentKey !== validatedKey ? current.currentKey : current.lastKnownGoodKey;
606
+ const nextRevision = current.revision + 1;
607
+ await this.write({
608
+ guid: lease.guid,
609
+ desiredKey: lease.desiredKey,
610
+ revision: nextRevision,
611
+ generation: lease.generation,
612
+ currentKey: validatedKey,
613
+ stale: false,
614
+ ...current.supersededAttempts === void 0 ? {} : { supersededAttempts: current.supersededAttempts },
615
+ ...lastKnownGoodKey === void 0 ? {} : { lastKnownGoodKey }
616
+ });
617
+ return withRevision({ result: "current", key: validatedKey }, nextRevision);
618
+ });
619
+ }
620
+ async fail(lease, failure) {
621
+ await withDdcLock(this.root, `head-${lease.guid}`, async () => {
622
+ const current = await this.read(lease.guid);
623
+ if (current?.active?.attempt !== lease.attempt) return;
624
+ await this.write({
625
+ guid: lease.guid,
626
+ desiredKey: lease.desiredKey,
627
+ revision: current.revision + 1,
628
+ generation: lease.generation,
629
+ ...current.currentKey === void 0 ? {} : { currentKey: current.currentKey },
630
+ ...current.lastKnownGoodKey === void 0 ? {} : { lastKnownGoodKey: current.lastKnownGoodKey },
631
+ failure: { desiredKey: lease.desiredKey, ...failure }
632
+ });
633
+ });
634
+ }
635
+ async heartbeat(lease) {
636
+ return withDdcLock(this.root, `head-${lease.guid}`, async () => {
637
+ const current = await this.read(lease.guid);
638
+ if (current?.active?.attempt !== lease.attempt) {
639
+ throw new DdcStoreError({
640
+ code: "ddc-lease-expired",
641
+ detail: "heartbeat belongs to a stale lease",
642
+ expected: lease.attempt,
643
+ actual: current?.active?.attempt,
644
+ lease: lease.attempt,
645
+ generation: lease.generation,
646
+ rootKind: "project-ddc"
647
+ });
648
+ }
649
+ const refreshed = { ...lease, expiresAt: Date.now() + this.leaseTtlMs };
650
+ await this.write({ ...current, active: refreshed });
651
+ return refreshed;
652
+ });
653
+ }
654
+ async close(lease) {
655
+ await withDdcLock(this.root, `head-${lease.guid}`, async () => {
656
+ const current = await this.read(lease.guid);
657
+ if (current?.active?.instanceId !== lease.instanceId) return;
658
+ const { active: _active, ...withoutActive } = current;
659
+ await this.write({
660
+ ...withoutActive,
661
+ revision: current.revision + 1,
662
+ stale: current.currentKey === void 0
663
+ });
664
+ });
665
+ }
666
+ async revoke(lease) {
667
+ await this.fail(lease, {
668
+ code: "lease-lost",
669
+ detail: "cook lease was revoked before validation"
670
+ });
671
+ }
672
+ async recover(guid, desiredKey) {
673
+ return withDdcLock(this.root, `head-${guid}`, async () => {
674
+ const current = await this.read(guid);
675
+ if (current?.active?.desiredKey === desiredKey) {
676
+ await this.write({
677
+ guid,
678
+ desiredKey,
679
+ revision: current.revision + 1,
680
+ ...current.lastKnownGoodKey === void 0 ? {} : { lastKnownGoodKey: current.lastKnownGoodKey },
681
+ failure: {
682
+ desiredKey,
683
+ code: "writer-crashed",
684
+ detail: "active cook attempt was recovered after process interruption"
685
+ }
686
+ });
687
+ }
688
+ return this.inspect(guid, desiredKey);
689
+ });
690
+ }
691
+ async allocateGeneration() {
692
+ const path = join(this.root, "generations", "counter.json");
693
+ await mkdir(join(this.root, "generations"), { recursive: true });
694
+ let next = 1;
695
+ try {
696
+ const record = JSON.parse(await readFile(path, "utf8"));
697
+ if (record.schemaVersion === "forgeax-ddc-generation/v2" && Number.isInteger(record.next))
698
+ next = Math.max(1, record.next);
699
+ } catch {
700
+ }
701
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
702
+ await writeFile(
703
+ temporary,
704
+ JSON.stringify({ schemaVersion: "forgeax-ddc-generation/v2", next: next + 1 })
705
+ );
706
+ await rename(temporary, path);
707
+ return next;
708
+ }
709
+ async read(guid) {
710
+ const path = headFile(this.heads, guid);
711
+ let source;
712
+ try {
713
+ source = await readFile(path, "utf8");
714
+ } catch (error) {
715
+ if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") {
716
+ return null;
717
+ }
718
+ throw malformedHeadError("unreadable");
719
+ }
720
+ let value;
721
+ try {
722
+ value = JSON.parse(source);
723
+ } catch {
724
+ throw malformedHeadError("syntax-invalid");
725
+ }
726
+ if (!isHeadRecord(value) || value.guid !== guid) throw malformedHeadError("schema-invalid");
727
+ return value;
728
+ }
729
+ async write(record) {
730
+ await mkdir(this.heads, { recursive: true });
731
+ const path = headFile(this.heads, record.guid);
732
+ const temporary = `${path}.${randomUUID()}.tmp`;
733
+ await writeFile(temporary, JSON.stringify(record));
734
+ await rename(temporary, path);
735
+ }
736
+ };
737
+
738
+ // src/gc.ts
739
+ async function collectDdcGarbage(root, input) {
740
+ return withDdcLock(root, "gc", async () => {
741
+ const protectedKeys = /* @__PURE__ */ new Set([
742
+ ...input.currentKeys,
743
+ ...input.lastKnownGoodKeys,
744
+ ...input.activeLeaseKeys
745
+ ]);
746
+ const entries = await readdir(join(root, "entries"), { withFileTypes: true }).catch(() => []);
747
+ const deleted = [];
748
+ for (const entry of entries) {
749
+ if (!entry.isDirectory() || protectedKeys.has(entry.name)) continue;
750
+ await rm(join(root, "entries", entry.name), { recursive: true, force: false });
751
+ deleted.push(entry.name);
752
+ }
753
+ return { deleted, protected: [...protectedKeys].sort() };
754
+ });
755
+ }
756
+ var DDC_LAYOUT_VERSION = "v2";
757
+ function rootOrError(root, name) {
758
+ if (!isAbsolute(root)) {
759
+ return {
760
+ code: "ddc-root-absolute-required",
761
+ detail: `${name} must be an absolute injected path`,
762
+ hint: `inject the canonical ${name} from the host root policy`,
763
+ expected: "an absolute filesystem path",
764
+ actual: root
765
+ };
766
+ }
767
+ const normalized = normalize(root);
768
+ return normalized.replace(/[\\/]+$/, "") || normalized;
769
+ }
770
+ function buildLayout(root) {
771
+ return { objects: `${root}/objects`, staging: `${root}/staging` };
772
+ }
773
+ function projectLayout(root) {
774
+ return {
775
+ root,
776
+ scope: `${root}/scope.json`,
777
+ heads: `${root}/heads`,
778
+ generations: `${root}/generations`,
779
+ leases: `${root}/leases`,
780
+ staging: `${root}/staging`
781
+ };
782
+ }
783
+ function resolveBuildDdcLayout(options) {
784
+ const root = rootOrError(options.buildCacheRoot, "buildCacheRoot");
785
+ return typeof root === "string" ? { ok: true, value: buildLayout(root) } : { ok: false, error: root };
786
+ }
787
+ function resolveDdcLayout(options) {
788
+ const buildRoot = rootOrError(options.buildCacheRoot, "buildCacheRoot");
789
+ if (typeof buildRoot !== "string") return { ok: false, error: buildRoot };
790
+ if (options.projectDdcRoot === void 0 || options.projectDdcRoot.trim().length === 0) {
791
+ return {
792
+ ok: false,
793
+ error: {
794
+ code: "ddc-project-root-required",
795
+ detail: "serve and publication require an explicitly injected projectDdcRoot",
796
+ hint: "canonicalize the game directory in the host and inject its .forgeax/ddc/v2 root",
797
+ expected: "projectDdcRoot"
798
+ }
799
+ };
800
+ }
801
+ const projectRoot = rootOrError(options.projectDdcRoot, "projectDdcRoot");
802
+ if (typeof projectRoot !== "string") return { ok: false, error: projectRoot };
803
+ return {
804
+ ok: true,
805
+ value: {
806
+ version: DDC_LAYOUT_VERSION,
807
+ buildCacheRoot: buildRoot,
808
+ projectDdcRoot: projectRoot,
809
+ build: buildLayout(buildRoot),
810
+ project: projectLayout(projectRoot)
811
+ }
812
+ };
813
+ }
814
+ function isDdcLayout(value) {
815
+ return value !== null && typeof value === "object" && value.version === DDC_LAYOUT_VERSION;
816
+ }
817
+ function scopeHash(scopeId) {
818
+ return createHash("sha256").update(scopeId, "utf8").digest("hex");
819
+ }
820
+ function runtimeScopeHash(scopeId) {
821
+ return scopeHash(scopeId);
822
+ }
823
+ async function createRuntimeScope(root, scopeId) {
824
+ if (scopeId.length === 0) {
825
+ throw new DdcStoreError({
826
+ code: "ddc-scope-mismatch",
827
+ detail: "runtime scope id must not be empty",
828
+ expected: "a non-empty original scope id",
829
+ actual: scopeId,
830
+ rootKind: "project-ddc"
831
+ });
832
+ }
833
+ const hash = scopeHash(scopeId);
834
+ const scopeRoot = join(root, "scopes", hash);
835
+ const metadataPath = join(scopeRoot, "scope.json");
836
+ await mkdir(scopeRoot, { recursive: true });
837
+ const expected = {
838
+ schemaVersion: "forgeax-ddc-scope/v2",
839
+ scopeId,
840
+ scopeHash: hash
841
+ };
842
+ try {
843
+ const actual = JSON.parse(await readFile(metadataPath, "utf8"));
844
+ if (actual.schemaVersion !== expected.schemaVersion || actual.scopeId !== expected.scopeId || actual.scopeHash !== expected.scopeHash) {
845
+ throw new DdcStoreError({
846
+ code: "ddc-scope-mismatch",
847
+ detail: "runtime scope metadata does not match the requested scope",
848
+ expected,
849
+ actual,
850
+ rootKind: "project-ddc",
851
+ scope: scopeId
852
+ });
853
+ }
854
+ } catch (error) {
855
+ if (error instanceof DdcStoreError) throw error;
856
+ const temporary = `${metadataPath}.${process.pid}.tmp`;
857
+ await writeFile(temporary, JSON.stringify(expected));
858
+ await rename(temporary, metadataPath);
859
+ }
860
+ return { scopeId, scopeHash: hash, root: scopeRoot, metadataPath };
861
+ }
862
+ async function assertRuntimeScope(root, scope) {
863
+ const expectedHash = scopeHash(scope.scopeId);
864
+ if (scope.scopeHash !== expectedHash) {
865
+ throw new DdcStoreError({
866
+ code: "ddc-scope-mismatch",
867
+ detail: "runtime scope hash does not match the original scope id",
868
+ expected: expectedHash,
869
+ actual: scope.scopeHash,
870
+ rootKind: "project-ddc",
871
+ scope: scope.scopeId
872
+ });
873
+ }
874
+ return createRuntimeScope(root, scope.scopeId);
875
+ }
876
+
877
+ // src/status.ts
878
+ var DDC_STATUS_SCHEMA = "forgeax-ddc-status/v2";
879
+ function serializeDdcStatus(status) {
880
+ return JSON.stringify(status);
881
+ }
882
+ function projectDdcStatusForBrowser(status) {
883
+ return {
884
+ schemaVersion: DDC_STATUS_SCHEMA,
885
+ health: status.health,
886
+ layers: status.layers.map((layer) => ({
887
+ ...layer,
888
+ actions: layer.actions.map(({ exactTarget: _exactTarget, ...action }) => action)
889
+ }))
890
+ };
891
+ }
892
+ var toBrowserDdcStatus = projectDdcStatusForBrowser;
893
+
894
+ export { DDC_ERROR_CODES, DDC_LAYOUT_VERSION, DDC_STATUS_SCHEMA, DdcEntryStore, DdcLifecycle, DdcStoreError, assertRuntimeScope, collectDdcGarbage, createRuntimeScope, ddcOutputDigest, isDdcLayout, projectDdcStatusForBrowser, resolveBuildDdcLayout, resolveDdcLayout, runtimeScopeHash, semanticDdcKey, serializeDdcStatus, toBrowserDdcStatus };
895
+ //# sourceMappingURL=index.mjs.map
896
+ //# sourceMappingURL=index.mjs.map