@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.
- package/LICENSE +202 -0
- package/README.md +136 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/concurrency.integration.test.d.ts +2 -0
- package/dist/__tests__/concurrency.integration.test.d.ts.map +1 -0
- package/dist/__tests__/consumer-path.integration.test.d.ts +2 -0
- package/dist/__tests__/consumer-path.integration.test.d.ts.map +1 -0
- package/dist/__tests__/crash-recovery.integration.test.d.ts +2 -0
- package/dist/__tests__/crash-recovery.integration.test.d.ts.map +1 -0
- package/dist/__tests__/entry-store.integration.test.d.ts +2 -0
- package/dist/__tests__/entry-store.integration.test.d.ts.map +1 -0
- package/dist/__tests__/errors.unit.test.d.ts +2 -0
- package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/key.unit.test.d.ts +2 -0
- package/dist/__tests__/key.unit.test.d.ts.map +1 -0
- package/dist/__tests__/layout.unit.test.d.ts +2 -0
- package/dist/__tests__/layout.unit.test.d.ts.map +1 -0
- package/dist/__tests__/lifecycle.unit.test.d.ts +2 -0
- package/dist/__tests__/lifecycle.unit.test.d.ts.map +1 -0
- package/dist/__tests__/multiprocess-gc.integration.test.d.ts +2 -0
- package/dist/__tests__/multiprocess-gc.integration.test.d.ts.map +1 -0
- package/dist/__tests__/multiprocess-lifecycle.integration.test.d.ts +2 -0
- package/dist/__tests__/multiprocess-lifecycle.integration.test.d.ts.map +1 -0
- package/dist/__tests__/multiprocess-worker.d.ts +2 -0
- package/dist/__tests__/multiprocess-worker.d.ts.map +1 -0
- package/dist/__tests__/status-root-kind-owner.test-d.d.ts +2 -0
- package/dist/__tests__/status-root-kind-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/status.unit.test.d.ts +2 -0
- package/dist/__tests__/status.unit.test.d.ts.map +1 -0
- package/dist/entry-store.d.ts +52 -0
- package/dist/entry-store.d.ts.map +1 -0
- package/dist/entry-store.mjs +339 -0
- package/dist/entry-store.mjs.map +1 -0
- package/dist/errors.d.ts +58 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.mjs +109 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/gc.d.ts +13 -0
- package/dist/gc.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +896 -0
- package/dist/index.mjs.map +1 -0
- package/dist/key.d.ts +13 -0
- package/dist/key.d.ts.map +1 -0
- package/dist/key.mjs +38 -0
- package/dist/key.mjs.map +1 -0
- package/dist/layout.d.ts +51 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.mjs +68 -0
- package/dist/layout.mjs.map +1 -0
- package/dist/lifecycle.d.ts +55 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/runtime-scope.d.ts +10 -0
- package/dist/runtime-scope.d.ts.map +1 -0
- package/dist/status.d.ts +39 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.mjs +20 -0
- package/dist/status.mjs.map +1 -0
- package/package.json +98 -0
- package/src/__tests__/concurrency.integration.test.ts +53 -0
- package/src/__tests__/consumer-path.integration.test.ts +61 -0
- package/src/__tests__/crash-recovery.integration.test.ts +84 -0
- package/src/__tests__/entry-store.integration.test.ts +83 -0
- package/src/__tests__/errors.unit.test.ts +54 -0
- package/src/__tests__/key.unit.test.ts +49 -0
- package/src/__tests__/layout.unit.test.ts +50 -0
- package/src/__tests__/lifecycle.unit.test.ts +258 -0
- package/src/__tests__/multiprocess-gc.integration.test.ts +36 -0
- package/src/__tests__/multiprocess-lifecycle.integration.test.ts +109 -0
- package/src/__tests__/multiprocess-worker.ts +41 -0
- package/src/__tests__/status-root-kind-owner.test-d.ts +42 -0
- package/src/__tests__/status.unit.test.ts +56 -0
- package/src/entry-store.ts +307 -0
- package/src/errors.ts +164 -0
- package/src/gc.ts +34 -0
- package/src/index.ts +65 -0
- package/src/key.ts +46 -0
- package/src/layout.ts +132 -0
- package/src/lifecycle.ts +549 -0
- package/src/runtime-scope.ts +87 -0
- package/src/status.ts +58 -0
package/package.json
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeax/engine-ddc",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"description": "Node-only v2 immutable derived data cache with project-local lifecycle evidence.",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"node": {
|
|
13
|
+
"import": "./dist/index.mjs"
|
|
14
|
+
},
|
|
15
|
+
"default": null
|
|
16
|
+
},
|
|
17
|
+
"./key": {
|
|
18
|
+
"types": "./dist/key.d.ts",
|
|
19
|
+
"node": {
|
|
20
|
+
"import": "./dist/key.mjs"
|
|
21
|
+
},
|
|
22
|
+
"default": null
|
|
23
|
+
},
|
|
24
|
+
"./entry-store": {
|
|
25
|
+
"types": "./dist/entry-store.d.ts",
|
|
26
|
+
"node": {
|
|
27
|
+
"import": "./dist/entry-store.mjs"
|
|
28
|
+
},
|
|
29
|
+
"default": null
|
|
30
|
+
},
|
|
31
|
+
"./layout": {
|
|
32
|
+
"types": "./dist/layout.d.ts",
|
|
33
|
+
"node": {
|
|
34
|
+
"import": "./dist/layout.mjs"
|
|
35
|
+
},
|
|
36
|
+
"default": null
|
|
37
|
+
},
|
|
38
|
+
"./status": {
|
|
39
|
+
"types": "./dist/status.d.ts",
|
|
40
|
+
"node": {
|
|
41
|
+
"import": "./dist/status.mjs"
|
|
42
|
+
},
|
|
43
|
+
"default": null
|
|
44
|
+
},
|
|
45
|
+
"./errors": {
|
|
46
|
+
"types": "./dist/errors.d.ts",
|
|
47
|
+
"node": {
|
|
48
|
+
"import": "./dist/errors.mjs"
|
|
49
|
+
},
|
|
50
|
+
"default": null
|
|
51
|
+
},
|
|
52
|
+
"./package.json": "./package.json"
|
|
53
|
+
},
|
|
54
|
+
"main": "./dist/index.mjs",
|
|
55
|
+
"types": "./dist/index.d.ts",
|
|
56
|
+
"files": [
|
|
57
|
+
"dist",
|
|
58
|
+
"src",
|
|
59
|
+
"README.md",
|
|
60
|
+
"LICENSE"
|
|
61
|
+
],
|
|
62
|
+
"forgeax": {
|
|
63
|
+
"metrics": {
|
|
64
|
+
"bundle-size": {
|
|
65
|
+
"enabled": true,
|
|
66
|
+
"path": "dist/index.mjs",
|
|
67
|
+
"compression": "gzip"
|
|
68
|
+
},
|
|
69
|
+
"fps": {
|
|
70
|
+
"enabled": false,
|
|
71
|
+
"reason": "Node-only build-time cache package; no runtime frame loop"
|
|
72
|
+
},
|
|
73
|
+
"bench": {
|
|
74
|
+
"enabled": false,
|
|
75
|
+
"reason": "DDC transaction correctness is covered by integration tests; no benchmark contract is declared"
|
|
76
|
+
},
|
|
77
|
+
"gate": {
|
|
78
|
+
"enabled": false,
|
|
79
|
+
"reason": "No package-level binary gate; workspace CI owns cache audit and type gates"
|
|
80
|
+
},
|
|
81
|
+
"spike-report": {
|
|
82
|
+
"enabled": false,
|
|
83
|
+
"reason": "Not a spike package"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"devDependencies": {
|
|
88
|
+
"@types/node": "^20.14.0",
|
|
89
|
+
"tsup": "^8.3.5",
|
|
90
|
+
"typescript": "^5.8.3",
|
|
91
|
+
"vitest": "4.1.5"
|
|
92
|
+
},
|
|
93
|
+
"scripts": {
|
|
94
|
+
"build": "tsup",
|
|
95
|
+
"typecheck": "tsc -b",
|
|
96
|
+
"test": "vitest run"
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { type DdcEntry, DdcEntryStore, ddcOutputDigest } from '../entry-store.js';
|
|
6
|
+
|
|
7
|
+
const KEY = 'a'.repeat(64);
|
|
8
|
+
const entry = (payload: string): DdcEntry => {
|
|
9
|
+
const value = {
|
|
10
|
+
key: KEY,
|
|
11
|
+
guid: '019e3969-1d48-7c3b-ac24-6d68f457065f',
|
|
12
|
+
payload: { payload },
|
|
13
|
+
refs: [],
|
|
14
|
+
artifacts: {
|
|
15
|
+
data: { mediaType: 'application/octet-stream', bytes: new Uint8Array([1, 2, 3]) },
|
|
16
|
+
},
|
|
17
|
+
receipt: {
|
|
18
|
+
guid: '019e3969-1d48-7c3b-ac24-6d68f457065f',
|
|
19
|
+
key: KEY,
|
|
20
|
+
producer: 'test-producer',
|
|
21
|
+
inputFingerprint: 'input',
|
|
22
|
+
outputDigest: '',
|
|
23
|
+
},
|
|
24
|
+
} satisfies DdcEntry;
|
|
25
|
+
return { ...value, receipt: { ...value.receipt, outputDigest: ddcOutputDigest(value) } };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe('DDC same-key writer concurrency', () => {
|
|
29
|
+
const roots: string[] = [];
|
|
30
|
+
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('lets one writer publish and makes the loser observe an identical entry', async () => {
|
|
36
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-concurrency-'));
|
|
37
|
+
roots.push(root);
|
|
38
|
+
const store = new DdcEntryStore(root);
|
|
39
|
+
const results = await Promise.all([store.write(entry('same')), store.write(entry('same'))]);
|
|
40
|
+
|
|
41
|
+
expect(results.map((result) => result.result).sort()).toEqual(['existing', 'published']);
|
|
42
|
+
await expect(store.read(KEY)).resolves.toEqual(entry('same'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('reports a conflict when the same key maps to different bytes', async () => {
|
|
46
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-concurrency-'));
|
|
47
|
+
roots.push(root);
|
|
48
|
+
const store = new DdcEntryStore(root);
|
|
49
|
+
await store.write(entry('first'));
|
|
50
|
+
await expect(store.write(entry('second'))).resolves.toEqual({ result: 'conflict', key: KEY });
|
|
51
|
+
await expect(store.read(KEY)).resolves.toEqual(entry('first'));
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { type DdcEntry, DdcEntryStore, ddcOutputDigest } from '../entry-store.js';
|
|
6
|
+
|
|
7
|
+
const guid = '019e3969-1d48-7c3b-ac24-6d68f457065f';
|
|
8
|
+
const key = 'a'.repeat(64);
|
|
9
|
+
|
|
10
|
+
describe('DDC consumer path', () => {
|
|
11
|
+
const roots: string[] = [];
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('rebuilds the same output after deleting DDC storage', async () => {
|
|
18
|
+
const firstRoot = await mkdtemp(join(tmpdir(), 'forgeax-ddc-consumer-'));
|
|
19
|
+
const secondRoot = await mkdtemp(join(tmpdir(), 'forgeax-ddc-consumer-'));
|
|
20
|
+
roots.push(firstRoot, secondRoot);
|
|
21
|
+
const cookedBase = {
|
|
22
|
+
key,
|
|
23
|
+
guid,
|
|
24
|
+
payload: { kind: 'texture', width: 2, height: 1 },
|
|
25
|
+
refs: [],
|
|
26
|
+
artifacts: {
|
|
27
|
+
payload: { mediaType: 'application/octet-stream', bytes: new Uint8Array([1, 2]) },
|
|
28
|
+
},
|
|
29
|
+
receipt: {
|
|
30
|
+
guid,
|
|
31
|
+
key,
|
|
32
|
+
producer: 'image-importer@4',
|
|
33
|
+
inputFingerprint: 'source-bytes',
|
|
34
|
+
outputDigest: '',
|
|
35
|
+
},
|
|
36
|
+
} satisfies DdcEntry;
|
|
37
|
+
const cooked: DdcEntry = {
|
|
38
|
+
...cookedBase,
|
|
39
|
+
receipt: { ...cookedBase.receipt, outputDigest: ddcOutputDigest(cookedBase) },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const firstStore = new DdcEntryStore(firstRoot);
|
|
43
|
+
await firstStore.write(cooked);
|
|
44
|
+
const cold = await firstStore.read(key);
|
|
45
|
+
await rm(firstRoot, { recursive: true, force: true });
|
|
46
|
+
const secondStore = new DdcEntryStore(secondRoot);
|
|
47
|
+
await secondStore.write(cooked);
|
|
48
|
+
const rebuilt = await secondStore.read(key);
|
|
49
|
+
|
|
50
|
+
expect(rebuilt).toEqual(cold);
|
|
51
|
+
expect(rebuilt?.guid).toBe(guid);
|
|
52
|
+
expect(rebuilt?.receipt.outputDigest).toBe(ddcOutputDigest(cooked));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('keeps a direct product path independent from DDC presence', async () => {
|
|
56
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-direct-'));
|
|
57
|
+
roots.push(root);
|
|
58
|
+
const store = new DdcEntryStore(root);
|
|
59
|
+
await expect(store.read(key)).resolves.toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { DdcEntryStore, ddcOutputDigest } from '../entry-store.js';
|
|
6
|
+
import { DdcLifecycle } from '../lifecycle.js';
|
|
7
|
+
|
|
8
|
+
const GUID = '019e3969-1d48-7c3b-ac24-6d68f457065f';
|
|
9
|
+
const KEY_A = 'a'.repeat(64);
|
|
10
|
+
const KEY_B = 'b'.repeat(64);
|
|
11
|
+
|
|
12
|
+
async function writeEntry(root: string, key: string): Promise<void> {
|
|
13
|
+
const base = {
|
|
14
|
+
key,
|
|
15
|
+
guid: GUID,
|
|
16
|
+
payload: { key },
|
|
17
|
+
refs: [],
|
|
18
|
+
artifacts: {},
|
|
19
|
+
receipt: {
|
|
20
|
+
guid: GUID,
|
|
21
|
+
key,
|
|
22
|
+
producer: 'test',
|
|
23
|
+
inputFingerprint: key,
|
|
24
|
+
outputDigest: '',
|
|
25
|
+
},
|
|
26
|
+
} as const;
|
|
27
|
+
await new DdcEntryStore(root).write({
|
|
28
|
+
...base,
|
|
29
|
+
receipt: { ...base.receipt, outputDigest: ddcOutputDigest(base) },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('DDC lease and crash recovery', () => {
|
|
34
|
+
const roots: string[] = [];
|
|
35
|
+
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('keeps a crashed cooking attempt out of the readable head', async () => {
|
|
41
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-crash-'));
|
|
42
|
+
roots.push(root);
|
|
43
|
+
const lifecycle = new DdcLifecycle(root);
|
|
44
|
+
await lifecycle.begin(GUID, KEY_A);
|
|
45
|
+
|
|
46
|
+
await expect(lifecycle.inspect(GUID, KEY_A)).resolves.toMatchObject({ state: 'cooking' });
|
|
47
|
+
await expect(lifecycle.recover(GUID, KEY_A)).resolves.toMatchObject({ state: 'failed' });
|
|
48
|
+
await expect(lifecycle.inspect(GUID, KEY_A)).resolves.toMatchObject({
|
|
49
|
+
state: 'failed',
|
|
50
|
+
currentKey: undefined,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects a commit after the lease is lost', async () => {
|
|
55
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-crash-'));
|
|
56
|
+
roots.push(root);
|
|
57
|
+
const lifecycle = new DdcLifecycle(root);
|
|
58
|
+
const lease = await lifecycle.begin(GUID, KEY_A);
|
|
59
|
+
await lifecycle.revoke(lease);
|
|
60
|
+
|
|
61
|
+
await expect(lifecycle.commit(lease, KEY_A)).resolves.toEqual({
|
|
62
|
+
result: 'lease-lost',
|
|
63
|
+
key: KEY_A,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('keeps an old writer stale after a newer desired key starts cooking', async () => {
|
|
68
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-crash-'));
|
|
69
|
+
roots.push(root);
|
|
70
|
+
const lifecycle = new DdcLifecycle(root);
|
|
71
|
+
const oldLease = await lifecycle.begin(GUID, KEY_A);
|
|
72
|
+
const newLease = await lifecycle.begin(GUID, KEY_B);
|
|
73
|
+
await writeEntry(root, KEY_B);
|
|
74
|
+
|
|
75
|
+
await expect(lifecycle.commit(oldLease, KEY_A)).resolves.toEqual({
|
|
76
|
+
result: 'stale',
|
|
77
|
+
key: KEY_A,
|
|
78
|
+
});
|
|
79
|
+
await expect(lifecycle.commit(newLease, KEY_B)).resolves.toEqual({
|
|
80
|
+
result: 'current',
|
|
81
|
+
key: KEY_B,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { type DdcEntry, DdcEntryStore, ddcOutputDigest } from '../entry-store.js';
|
|
6
|
+
|
|
7
|
+
const GUID = '019e3969-1d48-7c3b-ac24-6d68f457065f';
|
|
8
|
+
const KEY = 'a'.repeat(64);
|
|
9
|
+
|
|
10
|
+
const entry = (): DdcEntry => {
|
|
11
|
+
const value = {
|
|
12
|
+
key: KEY,
|
|
13
|
+
guid: GUID,
|
|
14
|
+
payload: { kind: 'texture', width: 2, height: 1 },
|
|
15
|
+
refs: ['019e3969-1d48-7c3b-ac24-6d68f457065e'],
|
|
16
|
+
artifacts: {
|
|
17
|
+
payload: { mediaType: 'application/octet-stream', bytes: new Uint8Array([1, 2, 3]) },
|
|
18
|
+
},
|
|
19
|
+
receipt: {
|
|
20
|
+
guid: GUID,
|
|
21
|
+
key: KEY,
|
|
22
|
+
producer: 'image-importer@4',
|
|
23
|
+
inputFingerprint: 'input-a',
|
|
24
|
+
outputDigest: '',
|
|
25
|
+
},
|
|
26
|
+
} satisfies DdcEntry;
|
|
27
|
+
return { ...value, receipt: { ...value.receipt, outputDigest: ddcOutputDigest(value) } };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('immutable DDC entry store', () => {
|
|
31
|
+
const roots: string[] = [];
|
|
32
|
+
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('keeps staged data invisible until an atomic publish', async () => {
|
|
38
|
+
const root = join(tmpdir(), `forgeax-ddc-entry-${Date.now()}-${Math.random()}`);
|
|
39
|
+
roots.push(root);
|
|
40
|
+
const store = new DdcEntryStore(root);
|
|
41
|
+
const staged = await store.stage(entry());
|
|
42
|
+
|
|
43
|
+
expect(await store.read(KEY)).toBeNull();
|
|
44
|
+
expect(await readdir(join(staged.path, 'artifacts'))).toEqual(['payload.bin']);
|
|
45
|
+
expect(JSON.parse(await readFile(join(staged.path, 'refs.json'), 'utf8'))).toEqual([
|
|
46
|
+
'019e3969-1d48-7c3b-ac24-6d68f457065e',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const published = await store.publish(staged);
|
|
50
|
+
expect(published).toEqual({ result: 'published', key: KEY });
|
|
51
|
+
await expect(store.read(KEY)).resolves.toEqual(entry());
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does not expose a partial entry when a required artifact is missing', async () => {
|
|
55
|
+
const root = join(tmpdir(), `forgeax-ddc-entry-${Date.now()}-${Math.random()}`);
|
|
56
|
+
roots.push(root);
|
|
57
|
+
const store = new DdcEntryStore(root);
|
|
58
|
+
const staged = await store.stage(entry());
|
|
59
|
+
await rm(join(staged.path, 'artifacts', 'payload.bin'));
|
|
60
|
+
await expect(store.publish(staged)).rejects.toMatchObject({ code: 'ddc-entry-incomplete' });
|
|
61
|
+
await expect(store.read(KEY)).resolves.toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('rejects a receipt or integrity mismatch on reread', async () => {
|
|
65
|
+
const root = join(tmpdir(), `forgeax-ddc-entry-${Date.now()}-${Math.random()}`);
|
|
66
|
+
roots.push(root);
|
|
67
|
+
const store = new DdcEntryStore(root);
|
|
68
|
+
await store.write(entry());
|
|
69
|
+
await writeFile(
|
|
70
|
+
join(root, 'entries', KEY, 'receipt.json'),
|
|
71
|
+
JSON.stringify({ ...entry().receipt, key: 'b'.repeat(64) }),
|
|
72
|
+
);
|
|
73
|
+
await expect(store.read(KEY)).resolves.toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns an existing immutable entry instead of replacing it', async () => {
|
|
77
|
+
const root = join(tmpdir(), `forgeax-ddc-entry-${Date.now()}-${Math.random()}`);
|
|
78
|
+
roots.push(root);
|
|
79
|
+
const store = new DdcEntryStore(root);
|
|
80
|
+
await expect(store.write(entry())).resolves.toEqual({ result: 'published', key: KEY });
|
|
81
|
+
await expect(store.write(entry())).resolves.toEqual({ result: 'existing', key: KEY });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DDC_ERROR_CODES, DdcStoreError } from '../errors.js';
|
|
3
|
+
|
|
4
|
+
describe('DDC closed errors', () => {
|
|
5
|
+
it('exposes the complete closed conflict/configuration code union', () => {
|
|
6
|
+
expect(DDC_ERROR_CODES).toEqual([
|
|
7
|
+
'ddc-project-root-required',
|
|
8
|
+
'ddc-object-conflict',
|
|
9
|
+
'ddc-head-conflict',
|
|
10
|
+
'ddc-generation-conflict',
|
|
11
|
+
'ddc-scope-mismatch',
|
|
12
|
+
'ddc-lease-expired',
|
|
13
|
+
]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('keeps structured recovery fields and redacts host paths for browser consumers', () => {
|
|
17
|
+
const error = new DdcStoreError({
|
|
18
|
+
code: 'ddc-scope-mismatch',
|
|
19
|
+
detail: 'scope metadata does not match the requested scope',
|
|
20
|
+
hint: 'reinspect the canonical project root and allocate a new scope',
|
|
21
|
+
expected: { scopeId: 'editor/main' },
|
|
22
|
+
actual: { scopeId: 'editor_main' },
|
|
23
|
+
owner: 'engine-ddc',
|
|
24
|
+
rootKind: 'project-ddc',
|
|
25
|
+
scope: 'editor/main',
|
|
26
|
+
generation: 7,
|
|
27
|
+
lease: 'lease-7',
|
|
28
|
+
revision: 3,
|
|
29
|
+
recoveryActions: [
|
|
30
|
+
{ kind: 'inspect', executable: true },
|
|
31
|
+
{ kind: 'allocate-generation', executable: true },
|
|
32
|
+
],
|
|
33
|
+
hostPath: '/Users/test/game/.forgeax/ddc/v2/scope.json',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(error).toMatchObject({
|
|
37
|
+
code: 'ddc-scope-mismatch',
|
|
38
|
+
expected: { scopeId: 'editor/main' },
|
|
39
|
+
actual: { scopeId: 'editor_main' },
|
|
40
|
+
owner: 'engine-ddc',
|
|
41
|
+
rootKind: 'project-ddc',
|
|
42
|
+
scope: 'editor/main',
|
|
43
|
+
generation: 7,
|
|
44
|
+
lease: 'lease-7',
|
|
45
|
+
revision: 3,
|
|
46
|
+
recoveryActions: [
|
|
47
|
+
{ kind: 'inspect', executable: true },
|
|
48
|
+
{ kind: 'allocate-generation', executable: true },
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
expect(error.toBrowserProjection()).not.toHaveProperty('hostPath');
|
|
52
|
+
expect(JSON.stringify(error.toBrowserProjection())).not.toContain('/Users/test');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { type SemanticDdcInput, semanticDdcKey } from '../key.js';
|
|
3
|
+
|
|
4
|
+
const baseInput = (): SemanticDdcInput => ({
|
|
5
|
+
schemaVersion: 'asset-pack@2',
|
|
6
|
+
importer: 'image@4',
|
|
7
|
+
codec: 'basis@3',
|
|
8
|
+
settings: { colorSpace: 'srgb', mipmap: true },
|
|
9
|
+
sourceBytes: [new Uint8Array([1, 2, 3, 4])],
|
|
10
|
+
declaredGuids: ['019e3969-1d48-7c3b-ac24-6d68f457065f'],
|
|
11
|
+
targetProfile: 'webgpu-release',
|
|
12
|
+
producer: 'image-importer@4',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('semantic DDC key', () => {
|
|
16
|
+
it('is deterministic for equal semantic inputs', () => {
|
|
17
|
+
expect(semanticDdcKey(baseInput())).toBe(semanticDdcKey(baseInput()));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('does not include author path or publication environment', () => {
|
|
21
|
+
const input = baseInput();
|
|
22
|
+
const moved = { ...input, sourceBytes: [new Uint8Array([1, 2, 3, 4])] };
|
|
23
|
+
expect(semanticDdcKey(input)).toBe(semanticDdcKey(moved));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it.each([
|
|
27
|
+
['schemaVersion', { schemaVersion: 'asset-pack@3' }],
|
|
28
|
+
['importer', { importer: 'image@5' }],
|
|
29
|
+
['codec', { codec: 'basis@4' }],
|
|
30
|
+
['settings', { settings: { colorSpace: 'linear', mipmap: true } }],
|
|
31
|
+
['sourceBytes', { sourceBytes: [new Uint8Array([1, 2, 3, 5])] }],
|
|
32
|
+
['declaredGuids', { declaredGuids: ['019e3969-1d48-7c3b-ac24-6d68f457065e'] }],
|
|
33
|
+
['targetProfile', { targetProfile: 'webgpu-debug' }],
|
|
34
|
+
['producer', { producer: 'image-importer@5' }],
|
|
35
|
+
])('misses when %s changes', (_name, change) => {
|
|
36
|
+
expect(semanticDdcKey({ ...baseInput(), ...change })).not.toBe(semanticDdcKey(baseInput()));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('canonicalizes object and GUID ordering without changing the digest', () => {
|
|
40
|
+
const input = baseInput();
|
|
41
|
+
expect(
|
|
42
|
+
semanticDdcKey({
|
|
43
|
+
...input,
|
|
44
|
+
settings: { mipmap: true, colorSpace: 'srgb' },
|
|
45
|
+
declaredGuids: [...input.declaredGuids].reverse(),
|
|
46
|
+
}),
|
|
47
|
+
).toBe(semanticDdcKey(input));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DDC_LAYOUT_VERSION, type DdcServeOptions, resolveDdcLayout } from '../layout.js';
|
|
3
|
+
|
|
4
|
+
describe('DDC v2 layout contract', () => {
|
|
5
|
+
it('normalizes both explicitly injected roots without consulting cwd or roots[0]', () => {
|
|
6
|
+
const result = resolveDdcLayout({
|
|
7
|
+
buildCacheRoot: '/tmp/forgeax/cache/../cache',
|
|
8
|
+
projectDdcRoot: '/tmp/forgeax/game/.forgeax/ddc/v2/./',
|
|
9
|
+
} satisfies DdcServeOptions);
|
|
10
|
+
|
|
11
|
+
expect(result).toMatchObject({
|
|
12
|
+
ok: true,
|
|
13
|
+
value: {
|
|
14
|
+
version: DDC_LAYOUT_VERSION,
|
|
15
|
+
buildCacheRoot: '/tmp/forgeax/cache',
|
|
16
|
+
projectDdcRoot: '/tmp/forgeax/game/.forgeax/ddc/v2',
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('fails closed when serve/publication has no project root', () => {
|
|
22
|
+
const result = resolveDdcLayout({ buildCacheRoot: '/tmp/forgeax/cache' });
|
|
23
|
+
|
|
24
|
+
expect(result).toMatchObject({
|
|
25
|
+
ok: false,
|
|
26
|
+
error: { code: 'ddc-project-root-required' },
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('has one canonical v2 child layout and cannot derive a root from asset inputs', () => {
|
|
31
|
+
const result = resolveDdcLayout({
|
|
32
|
+
buildCacheRoot: '/tmp/cache',
|
|
33
|
+
projectDdcRoot: '/tmp/game/.forgeax/ddc/v2',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (!result.ok) throw result.error;
|
|
37
|
+
expect(result.value.build).toEqual({
|
|
38
|
+
objects: '/tmp/cache/objects',
|
|
39
|
+
staging: '/tmp/cache/staging',
|
|
40
|
+
});
|
|
41
|
+
expect(result.value.project).toEqual({
|
|
42
|
+
root: '/tmp/game/.forgeax/ddc/v2',
|
|
43
|
+
scope: '/tmp/game/.forgeax/ddc/v2/scope.json',
|
|
44
|
+
heads: '/tmp/game/.forgeax/ddc/v2/heads',
|
|
45
|
+
generations: '/tmp/game/.forgeax/ddc/v2/generations',
|
|
46
|
+
leases: '/tmp/game/.forgeax/ddc/v2/leases',
|
|
47
|
+
staging: '/tmp/game/.forgeax/ddc/v2/staging',
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
});
|