@forgeax/engine-ddc 0.0.0-dev.8d955ade1c79
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 +147 -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__/generation-session.integration.test.d.ts +2 -0
- package/dist/__tests__/generation-session.integration.test.d.ts.map +1 -0
- package/dist/__tests__/generation-session.unit.test.d.ts +2 -0
- package/dist/__tests__/generation-session.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__/owner-chain.integration.test.d.ts +2 -0
- package/dist/__tests__/owner-chain.integration.test.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/build-cache.d.ts +18 -0
- package/dist/build-cache.d.ts.map +1 -0
- package/dist/entry-store.d.ts +53 -0
- package/dist/entry-store.d.ts.map +1 -0
- package/dist/entry-store.mjs +349 -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 +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1574 -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 +93 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/publication.d.ts +50 -0
- package/dist/publication.d.ts.map +1 -0
- package/dist/runtime-scope.d.ts +10 -0
- package/dist/runtime-scope.d.ts.map +1 -0
- package/dist/session.d.ts +46 -0
- package/dist/session.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 +101 -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__/generation-session.integration.test.ts +202 -0
- package/src/__tests__/generation-session.unit.test.ts +35 -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 +129 -0
- package/src/__tests__/multiprocess-worker.ts +41 -0
- package/src/__tests__/owner-chain.integration.test.ts +45 -0
- package/src/__tests__/status-root-kind-owner.test-d.ts +42 -0
- package/src/__tests__/status.unit.test.ts +56 -0
- package/src/build-cache.ts +60 -0
- package/src/entry-store.ts +317 -0
- package/src/errors.ts +164 -0
- package/src/gc.ts +34 -0
- package/src/index.ts +92 -0
- package/src/key.ts +46 -0
- package/src/layout.ts +132 -0
- package/src/lifecycle.ts +792 -0
- package/src/publication.ts +446 -0
- package/src/runtime-scope.ts +87 -0
- package/src/session.ts +281 -0
- package/src/status.ts +58 -0
|
@@ -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,202 @@
|
|
|
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
|
+
import { DdcLifecycle } from '../lifecycle.js';
|
|
7
|
+
import { DdcGenerationSession } from '../session.js';
|
|
8
|
+
|
|
9
|
+
const GUID = '019e3969-1d48-7c3b-ac24-6d68f457065f';
|
|
10
|
+
const KEY = 'a'.repeat(64);
|
|
11
|
+
const KEY_B = 'b'.repeat(64);
|
|
12
|
+
const KEY_C = 'c'.repeat(64);
|
|
13
|
+
|
|
14
|
+
function entry(key = KEY): DdcEntry {
|
|
15
|
+
const value = {
|
|
16
|
+
key,
|
|
17
|
+
guid: GUID,
|
|
18
|
+
payload: { kind: 'fixture', key },
|
|
19
|
+
refs: [],
|
|
20
|
+
artifacts: {},
|
|
21
|
+
receipt: {
|
|
22
|
+
guid: GUID,
|
|
23
|
+
key,
|
|
24
|
+
producer: 'fixture',
|
|
25
|
+
inputFingerprint: 'sha256:source',
|
|
26
|
+
outputDigest: '',
|
|
27
|
+
},
|
|
28
|
+
} satisfies DdcEntry;
|
|
29
|
+
return { ...value, receipt: { ...value.receipt, outputDigest: ddcOutputDigest(value) } };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('DDC generation session integration', () => {
|
|
33
|
+
const roots: string[] = [];
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('accepts one validated candidate and keeps a later generation isolated', async () => {
|
|
40
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-session-'));
|
|
41
|
+
roots.push(root);
|
|
42
|
+
const store = new DdcEntryStore(root);
|
|
43
|
+
await store.write(entry());
|
|
44
|
+
|
|
45
|
+
const first = new DdcGenerationSession(root, { generation: 1 });
|
|
46
|
+
const candidate = await first.beginCandidate(GUID, KEY);
|
|
47
|
+
await expect(first.commitCandidate(candidate, KEY)).resolves.toMatchObject({
|
|
48
|
+
result: 'current',
|
|
49
|
+
});
|
|
50
|
+
await expect(first.inspect(GUID, KEY)).resolves.toMatchObject({
|
|
51
|
+
state: 'current',
|
|
52
|
+
currentKey: KEY,
|
|
53
|
+
});
|
|
54
|
+
await first.close();
|
|
55
|
+
|
|
56
|
+
const second = new DdcGenerationSession(root, { generation: 2 });
|
|
57
|
+
expect(second.metrics().hitCount).toBe(0);
|
|
58
|
+
await expect(second.inspect(GUID, KEY)).resolves.toMatchObject({ state: 'current' });
|
|
59
|
+
expect(second.metrics().hitCount).toBe(1);
|
|
60
|
+
await second.close();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('heartbeats staged candidates during a long generation before commit', async () => {
|
|
64
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-session-'));
|
|
65
|
+
roots.push(root);
|
|
66
|
+
const session = new DdcGenerationSession(root, { generation: 1, leaseTtlMs: 100 });
|
|
67
|
+
try {
|
|
68
|
+
const candidate = await session.stageEntry(entry());
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
70
|
+
await expect(session.commitEntry(candidate, KEY)).resolves.toMatchObject({
|
|
71
|
+
result: 'current',
|
|
72
|
+
});
|
|
73
|
+
} finally {
|
|
74
|
+
await session.close();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('does not let a stale rollback restore over a newer active lease', async () => {
|
|
79
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-session-'));
|
|
80
|
+
roots.push(root);
|
|
81
|
+
const store = new DdcEntryStore(root);
|
|
82
|
+
await store.write(entry());
|
|
83
|
+
|
|
84
|
+
const first = new DdcGenerationSession(root, { generation: 1 });
|
|
85
|
+
const firstCandidate = await first.stageEntry(entry());
|
|
86
|
+
const second = new DdcGenerationSession(root, { generation: 2 });
|
|
87
|
+
const secondCandidate = await second.stageEntry(entry());
|
|
88
|
+
|
|
89
|
+
await expect(first.commitEntry(firstCandidate, KEY)).resolves.toMatchObject({
|
|
90
|
+
result: 'stale',
|
|
91
|
+
});
|
|
92
|
+
await expect(first.restoreEntry(firstCandidate)).resolves.toMatchObject({
|
|
93
|
+
result: 'not-owner',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await expect(second.commitEntry(secondCandidate, KEY)).resolves.toMatchObject({
|
|
97
|
+
result: 'current',
|
|
98
|
+
});
|
|
99
|
+
await expect(first.restoreEntry(firstCandidate)).resolves.toMatchObject({
|
|
100
|
+
result: 'not-owner',
|
|
101
|
+
});
|
|
102
|
+
await expect(second.inspect(GUID, KEY)).resolves.toMatchObject({
|
|
103
|
+
state: 'current',
|
|
104
|
+
currentKey: KEY,
|
|
105
|
+
});
|
|
106
|
+
await first.close();
|
|
107
|
+
await second.close();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('restores an own active candidate without regressing revision or generation', async () => {
|
|
111
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-session-'));
|
|
112
|
+
roots.push(root);
|
|
113
|
+
const lifecycle = new DdcLifecycle(root);
|
|
114
|
+
const { lease, previousHead } = await lifecycle.beginWithSnapshot(GUID, KEY);
|
|
115
|
+
|
|
116
|
+
expect('activeLease' in previousHead).toBe(false);
|
|
117
|
+
await expect(lifecycle.restoreIfCurrent(previousHead, lease)).resolves.toMatchObject({
|
|
118
|
+
result: 'restored',
|
|
119
|
+
revision: 1,
|
|
120
|
+
generation: lease.generation,
|
|
121
|
+
});
|
|
122
|
+
await expect(lifecycle.inspect(GUID, KEY)).resolves.toMatchObject({
|
|
123
|
+
state: 'missing',
|
|
124
|
+
revision: 1,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const next = await lifecycle.begin(GUID, KEY);
|
|
128
|
+
expect(next.expectedRevision).toBe(1);
|
|
129
|
+
expect(next.generation).toBeGreaterThan(lease.generation);
|
|
130
|
+
await lifecycle.close(next);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('restores an own terminal mutation while preserving the accepted head', async () => {
|
|
134
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-session-'));
|
|
135
|
+
roots.push(root);
|
|
136
|
+
const store = new DdcEntryStore(root);
|
|
137
|
+
await store.write(entry(KEY));
|
|
138
|
+
const lifecycle = new DdcLifecycle(root);
|
|
139
|
+
const accepted = await lifecycle.begin(GUID, KEY);
|
|
140
|
+
await expect(lifecycle.commit(accepted, KEY)).resolves.toMatchObject({ result: 'current' });
|
|
141
|
+
|
|
142
|
+
const { lease, previousHead } = await lifecycle.beginWithSnapshot(GUID, KEY_B);
|
|
143
|
+
await store.write(entry(KEY_B));
|
|
144
|
+
const committed = await lifecycle.commit(lease, KEY_B);
|
|
145
|
+
expect(committed.restoreFence).toMatchObject({ outcome: 'current' });
|
|
146
|
+
await expect(
|
|
147
|
+
lifecycle.restoreIfCurrent(previousHead, lease, committed.restoreFence),
|
|
148
|
+
).resolves.toMatchObject({ result: 'restored' });
|
|
149
|
+
await expect(lifecycle.inspect(GUID, KEY_B)).resolves.toMatchObject({
|
|
150
|
+
state: 'stale',
|
|
151
|
+
currentKey: KEY,
|
|
152
|
+
revision: (committed.revision ?? 0) + 1,
|
|
153
|
+
generation: lease.generation,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('rolls back a newer cooking candidate without reviving its superseded writer', async () => {
|
|
158
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-session-'));
|
|
159
|
+
roots.push(root);
|
|
160
|
+
const store = new DdcEntryStore(root);
|
|
161
|
+
await store.write(entry(KEY));
|
|
162
|
+
const lifecycle = new DdcLifecycle(root);
|
|
163
|
+
const accepted = await lifecycle.begin(GUID, KEY);
|
|
164
|
+
await lifecycle.commit(accepted, KEY);
|
|
165
|
+
|
|
166
|
+
const first = new DdcGenerationSession(root, { generation: 1 });
|
|
167
|
+
const firstCandidate = await first.stageEntry(entry(KEY_B));
|
|
168
|
+
const second = new DdcGenerationSession(root, { generation: 2 });
|
|
169
|
+
const secondCandidate = await second.stageEntry(entry(KEY_C));
|
|
170
|
+
|
|
171
|
+
await expect(second.restoreEntry(secondCandidate)).resolves.toMatchObject({
|
|
172
|
+
result: 'restored',
|
|
173
|
+
});
|
|
174
|
+
await expect(first.restoreEntry(firstCandidate)).resolves.toMatchObject({
|
|
175
|
+
result: 'not-owner',
|
|
176
|
+
});
|
|
177
|
+
await expect(second.inspect(GUID, KEY_C)).resolves.toMatchObject({
|
|
178
|
+
state: 'stale',
|
|
179
|
+
currentKey: KEY,
|
|
180
|
+
});
|
|
181
|
+
await first.close();
|
|
182
|
+
await second.close();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('fences a failed terminal mutation before restoring accepted content', async () => {
|
|
186
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-session-'));
|
|
187
|
+
roots.push(root);
|
|
188
|
+
const lifecycle = new DdcLifecycle(root);
|
|
189
|
+
const { lease, previousHead } = await lifecycle.beginWithSnapshot(GUID, KEY);
|
|
190
|
+
const failed = await lifecycle.fail(lease, { code: 'producer-failed', detail: 'fixture' });
|
|
191
|
+
|
|
192
|
+
expect(failed).toMatchObject({ outcome: 'failed', revision: 1 });
|
|
193
|
+
await expect(lifecycle.restoreIfCurrent(previousHead, lease, failed)).resolves.toMatchObject({
|
|
194
|
+
result: 'restored',
|
|
195
|
+
revision: 2,
|
|
196
|
+
});
|
|
197
|
+
await expect(lifecycle.inspect(GUID, KEY)).resolves.toMatchObject({
|
|
198
|
+
state: 'missing',
|
|
199
|
+
revision: 2,
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
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 { DdcGenerationSession } from '../session.js';
|
|
6
|
+
|
|
7
|
+
describe('DDC generation session contract', () => {
|
|
8
|
+
const roots: string[] = [];
|
|
9
|
+
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('fences candidates to one generation and exposes bounded session metrics', async () => {
|
|
15
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-session-'));
|
|
16
|
+
roots.push(root);
|
|
17
|
+
const session = new DdcGenerationSession(root, { generation: 7 });
|
|
18
|
+
|
|
19
|
+
const candidate = await session.beginCandidate('asset-a', 'a'.repeat(64));
|
|
20
|
+
expect(candidate.generation).toBe(7);
|
|
21
|
+
expect(candidate.lease.guid).toBe('asset-a');
|
|
22
|
+
expect(session.metrics()).toEqual({
|
|
23
|
+
hitCount: 0,
|
|
24
|
+
missCount: 1,
|
|
25
|
+
corruptCount: 0,
|
|
26
|
+
writeFailureCount: 0,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
await session.discardCandidate(candidate);
|
|
30
|
+
await session.close();
|
|
31
|
+
await expect(session.beginCandidate('asset-b', 'b'.repeat(64))).rejects.toThrow(
|
|
32
|
+
'DDC generation session is closed',
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -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
|
+
});
|