@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,317 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { DdcStoreError } from './errors.js';
|
|
5
|
+
import { canonicalDdcJson } from './key.js';
|
|
6
|
+
|
|
7
|
+
export { DdcStoreError } from './errors.js';
|
|
8
|
+
|
|
9
|
+
export interface DdcArtifact {
|
|
10
|
+
readonly mediaType: string;
|
|
11
|
+
readonly bytes: Uint8Array;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DdcReceipt {
|
|
15
|
+
readonly guid: string;
|
|
16
|
+
readonly key: string;
|
|
17
|
+
readonly producer: string;
|
|
18
|
+
readonly inputFingerprint: string;
|
|
19
|
+
readonly outputDigest: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DdcEntry {
|
|
23
|
+
readonly key: string;
|
|
24
|
+
readonly guid: string;
|
|
25
|
+
readonly payload: unknown;
|
|
26
|
+
readonly refs: readonly string[];
|
|
27
|
+
readonly artifacts: Readonly<Record<string, DdcArtifact>>;
|
|
28
|
+
readonly receipt: DdcReceipt;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StagedDdcEntry {
|
|
32
|
+
readonly key: string;
|
|
33
|
+
readonly path: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PublishDdcEntryResult {
|
|
37
|
+
readonly result: 'published' | 'existing' | 'conflict';
|
|
38
|
+
readonly key: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ArtifactIntegrity {
|
|
42
|
+
readonly mediaType: string;
|
|
43
|
+
readonly byteLength: number;
|
|
44
|
+
readonly digest: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface EntryIntegrity {
|
|
48
|
+
readonly payload: string;
|
|
49
|
+
readonly refs: string;
|
|
50
|
+
readonly receipt: string;
|
|
51
|
+
readonly artifacts: Readonly<Record<string, ArtifactIntegrity>>;
|
|
52
|
+
readonly entry: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function digest(value: string | Uint8Array): string {
|
|
56
|
+
return createHash('sha256').update(value).digest('hex');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function artifactFile(key: string): string {
|
|
60
|
+
if (/^[A-Za-z0-9._-]+$/.test(key)) return key;
|
|
61
|
+
return Buffer.from(key, 'utf8').toString('base64url');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validateKey(key: string): void {
|
|
65
|
+
if (!/^[a-f0-9]{64}$/.test(key)) {
|
|
66
|
+
throw new DdcStoreError('ddc-entry-invalid', `invalid semantic key: ${key}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function errorCode(error: unknown): string | undefined {
|
|
71
|
+
if (error !== null && typeof error === 'object' && 'code' in error) {
|
|
72
|
+
const code = (error as { readonly code?: unknown }).code;
|
|
73
|
+
return typeof code === 'string' ? code : undefined;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sortedArtifacts(entry: DdcEntry): readonly [string, DdcArtifact][] {
|
|
79
|
+
return Object.entries(entry.artifacts).sort(([left], [right]) => left.localeCompare(right));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The one output identity shared by receipt validation and DDC publication. */
|
|
83
|
+
export function ddcOutputDigest(
|
|
84
|
+
entry: Pick<DdcEntry, 'guid' | 'payload' | 'refs' | 'artifacts'>,
|
|
85
|
+
): string {
|
|
86
|
+
return `sha256:${digest(
|
|
87
|
+
canonicalDdcJson({
|
|
88
|
+
guid: entry.guid,
|
|
89
|
+
payload: entry.payload,
|
|
90
|
+
refs: entry.refs,
|
|
91
|
+
artifacts: Object.fromEntries(
|
|
92
|
+
sortedArtifacts(entry as DdcEntry).map(([key, artifact]) => [
|
|
93
|
+
key,
|
|
94
|
+
{
|
|
95
|
+
mediaType: artifact.mediaType,
|
|
96
|
+
bytes: artifact.bytes,
|
|
97
|
+
},
|
|
98
|
+
]),
|
|
99
|
+
),
|
|
100
|
+
}),
|
|
101
|
+
)}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function existingResult(
|
|
105
|
+
existing: DdcEntry,
|
|
106
|
+
candidate: DdcEntry,
|
|
107
|
+
key: string,
|
|
108
|
+
): PublishDdcEntryResult {
|
|
109
|
+
return canonicalDdcJson(existing) === canonicalDdcJson(candidate)
|
|
110
|
+
? { result: 'existing', key }
|
|
111
|
+
: { result: 'conflict', key };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function entryShape(entry: DdcEntry): Record<string, unknown> {
|
|
115
|
+
return {
|
|
116
|
+
key: entry.key,
|
|
117
|
+
guid: entry.guid,
|
|
118
|
+
payload: entry.payload,
|
|
119
|
+
refs: entry.refs,
|
|
120
|
+
receipt: entry.receipt,
|
|
121
|
+
artifacts: Object.fromEntries(
|
|
122
|
+
sortedArtifacts(entry).map(([key, artifact]) => [
|
|
123
|
+
key,
|
|
124
|
+
{ mediaType: artifact.mediaType, byteLength: artifact.bytes.byteLength },
|
|
125
|
+
]),
|
|
126
|
+
),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function integrityFor(entry: DdcEntry): EntryIntegrity {
|
|
131
|
+
const artifacts = Object.fromEntries(
|
|
132
|
+
sortedArtifacts(entry).map(([key, artifact]) => [
|
|
133
|
+
key,
|
|
134
|
+
{
|
|
135
|
+
mediaType: artifact.mediaType,
|
|
136
|
+
byteLength: artifact.bytes.byteLength,
|
|
137
|
+
digest: digest(artifact.bytes),
|
|
138
|
+
},
|
|
139
|
+
]),
|
|
140
|
+
);
|
|
141
|
+
return {
|
|
142
|
+
payload: digest(canonicalDdcJson(entry.payload)),
|
|
143
|
+
refs: digest(canonicalDdcJson(entry.refs)),
|
|
144
|
+
receipt: digest(canonicalDdcJson(entry.receipt)),
|
|
145
|
+
artifacts,
|
|
146
|
+
entry: digest(canonicalDdcJson(entryShape(entry))),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function readJson<T>(path: string): Promise<T> {
|
|
151
|
+
return JSON.parse(await readFile(path, 'utf8')) as T;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export class DdcEntryStore {
|
|
155
|
+
private readonly entries: string;
|
|
156
|
+
private readonly staging: string;
|
|
157
|
+
|
|
158
|
+
public constructor(root: string) {
|
|
159
|
+
this.entries = join(root, 'entries');
|
|
160
|
+
this.staging = join(root, 'staging');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
public async stage(entry: DdcEntry): Promise<StagedDdcEntry> {
|
|
164
|
+
validateKey(entry.key);
|
|
165
|
+
if (entry.receipt.key !== entry.key || entry.receipt.guid !== entry.guid) {
|
|
166
|
+
throw new DdcStoreError('ddc-entry-invalid', 'receipt identity does not match entry');
|
|
167
|
+
}
|
|
168
|
+
if (entry.receipt.outputDigest !== ddcOutputDigest(entry)) {
|
|
169
|
+
throw new DdcStoreError('ddc-entry-invalid', 'receipt output digest does not match entry');
|
|
170
|
+
}
|
|
171
|
+
const path = join(this.staging, `${entry.key}-${randomUUID()}`);
|
|
172
|
+
try {
|
|
173
|
+
await mkdir(join(path, 'artifacts'), { recursive: true });
|
|
174
|
+
await writeFile(join(path, 'payload.json'), canonicalDdcJson(entry.payload));
|
|
175
|
+
await writeFile(join(path, 'refs.json'), canonicalDdcJson(entry.refs));
|
|
176
|
+
await writeFile(join(path, 'receipt.json'), canonicalDdcJson(entry.receipt));
|
|
177
|
+
const artifacts: Record<string, { mediaType: string; file: string }> = {};
|
|
178
|
+
for (const [key, artifact] of sortedArtifacts(entry)) {
|
|
179
|
+
const file = artifactFile(key);
|
|
180
|
+
artifacts[key] = { mediaType: artifact.mediaType, file };
|
|
181
|
+
await writeFile(join(path, 'artifacts', `${file}.bin`), artifact.bytes);
|
|
182
|
+
}
|
|
183
|
+
await writeFile(join(path, 'artifacts.json'), canonicalDdcJson(artifacts));
|
|
184
|
+
await writeFile(join(path, 'integrity.json'), canonicalDdcJson(integrityFor(entry)));
|
|
185
|
+
return { key: entry.key, path };
|
|
186
|
+
} catch (error) {
|
|
187
|
+
await rm(path, { recursive: true, force: true }).catch(() => {});
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
public async publish(staged: StagedDdcEntry): Promise<PublishDdcEntryResult> {
|
|
193
|
+
validateKey(staged.key);
|
|
194
|
+
const candidate = await this.readDirectory(staged.path, true);
|
|
195
|
+
const target = join(this.entries, staged.key);
|
|
196
|
+
await mkdir(this.entries, { recursive: true });
|
|
197
|
+
const useExisting = async (): Promise<PublishDdcEntryResult> => {
|
|
198
|
+
const existing = await this.readDirectory(target, false);
|
|
199
|
+
await rm(staged.path, { recursive: true, force: true });
|
|
200
|
+
return existingResult(existing, candidate, staged.key);
|
|
201
|
+
};
|
|
202
|
+
try {
|
|
203
|
+
await stat(target);
|
|
204
|
+
return useExisting();
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (error instanceof DdcStoreError) throw error;
|
|
207
|
+
if (errorCode(error) !== 'ENOENT') throw error;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
await rename(staged.path, target);
|
|
211
|
+
return { result: 'published', key: staged.key };
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (!['EEXIST', 'ENOTEMPTY', 'EISDIR'].includes(errorCode(error) ?? '')) throw error;
|
|
214
|
+
return useExisting();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
public async discard(staged: StagedDdcEntry): Promise<void> {
|
|
219
|
+
await rm(staged.path, { recursive: true, force: true });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
public async write(entry: DdcEntry): Promise<PublishDdcEntryResult> {
|
|
223
|
+
const staged = await this.stage(entry);
|
|
224
|
+
return this.publish(staged);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
public async read(key: string): Promise<DdcEntry | null> {
|
|
228
|
+
try {
|
|
229
|
+
validateKey(key);
|
|
230
|
+
return await this.readDirectory(join(this.entries, key), false);
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
public async listKeys(): Promise<readonly string[]> {
|
|
237
|
+
const entries = await readdir(this.entries, { withFileTypes: true }).catch(() => []);
|
|
238
|
+
return entries
|
|
239
|
+
.filter((entry) => entry.isDirectory())
|
|
240
|
+
.map((entry) => entry.name)
|
|
241
|
+
.sort();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
public async remove(key: string): Promise<void> {
|
|
245
|
+
validateKey(key);
|
|
246
|
+
await rm(join(this.entries, key), { recursive: true, force: false });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Read with a machine-readable corruption result for recovery tooling. */
|
|
250
|
+
public async readChecked(
|
|
251
|
+
key: string,
|
|
252
|
+
): Promise<
|
|
253
|
+
| { readonly ok: true; readonly value: DdcEntry | null }
|
|
254
|
+
| { readonly ok: false; readonly error: DdcStoreError }
|
|
255
|
+
> {
|
|
256
|
+
try {
|
|
257
|
+
validateKey(key);
|
|
258
|
+
try {
|
|
259
|
+
await stat(join(this.entries, key));
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (errorCode(error) === 'ENOENT') return { ok: true, value: null };
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
return { ok: true, value: await this.readDirectory(join(this.entries, key), false) };
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (error instanceof DdcStoreError && error.code === 'ddc-entry-incomplete') {
|
|
268
|
+
return { ok: true, value: null };
|
|
269
|
+
}
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
} catch (error) {
|
|
273
|
+
const normalized =
|
|
274
|
+
error instanceof DdcStoreError
|
|
275
|
+
? error
|
|
276
|
+
: new DdcStoreError('ddc-entry-invalid', 'entry is unreadable');
|
|
277
|
+
return { ok: false, error: normalized };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private async readDirectory(path: string, incompleteIsError: boolean): Promise<DdcEntry> {
|
|
282
|
+
try {
|
|
283
|
+
const payload = await readJson<unknown>(join(path, 'payload.json'));
|
|
284
|
+
const refs = await readJson<readonly string[]>(join(path, 'refs.json'));
|
|
285
|
+
const receipt = await readJson<DdcReceipt>(join(path, 'receipt.json'));
|
|
286
|
+
const manifest = await readJson<
|
|
287
|
+
Readonly<Record<string, { mediaType: string; file: string }>>
|
|
288
|
+
>(join(path, 'artifacts.json'));
|
|
289
|
+
const integrity = await readJson<EntryIntegrity>(join(path, 'integrity.json'));
|
|
290
|
+
const artifacts: Record<string, DdcArtifact> = {};
|
|
291
|
+
for (const [key, descriptor] of Object.entries(manifest).sort(([left], [right]) =>
|
|
292
|
+
left.localeCompare(right),
|
|
293
|
+
)) {
|
|
294
|
+
const bytes = new Uint8Array(
|
|
295
|
+
await readFile(join(path, 'artifacts', `${descriptor.file}.bin`)),
|
|
296
|
+
);
|
|
297
|
+
artifacts[key] = { mediaType: descriptor.mediaType, bytes };
|
|
298
|
+
}
|
|
299
|
+
const entry = { key: receipt.key, guid: receipt.guid, payload, refs, artifacts, receipt };
|
|
300
|
+
if (entry.receipt.outputDigest !== ddcOutputDigest(entry)) {
|
|
301
|
+
throw new DdcStoreError('ddc-entry-invalid', 'receipt output digest does not match entry');
|
|
302
|
+
}
|
|
303
|
+
const actual = integrityFor(entry);
|
|
304
|
+
if (canonicalDdcJson(actual) !== canonicalDdcJson(integrity)) {
|
|
305
|
+
throw new DdcStoreError('ddc-entry-invalid', 'entry integrity mismatch');
|
|
306
|
+
}
|
|
307
|
+
return entry;
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (incompleteIsError) {
|
|
310
|
+
if (error instanceof DdcStoreError) throw error;
|
|
311
|
+
throw new DdcStoreError('ddc-entry-incomplete', 'staged entry is incomplete');
|
|
312
|
+
}
|
|
313
|
+
if (error instanceof DdcStoreError) throw error;
|
|
314
|
+
throw new DdcStoreError('ddc-entry-invalid', 'entry is unreadable');
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
export const DDC_ERROR_CODES = [
|
|
2
|
+
'ddc-project-root-required',
|
|
3
|
+
'ddc-object-conflict',
|
|
4
|
+
'ddc-head-conflict',
|
|
5
|
+
'ddc-generation-conflict',
|
|
6
|
+
'ddc-scope-mismatch',
|
|
7
|
+
'ddc-lease-expired',
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
export type DdcContractErrorCode = (typeof DDC_ERROR_CODES)[number];
|
|
11
|
+
export type DdcEntryErrorCode = 'ddc-entry-incomplete' | 'ddc-entry-invalid' | 'ddc-entry-conflict';
|
|
12
|
+
export type DdcErrorCode = DdcContractErrorCode | DdcEntryErrorCode;
|
|
13
|
+
|
|
14
|
+
export type DdcErrorRootKind = 'build-cache' | 'project-ddc' | 'runtime';
|
|
15
|
+
|
|
16
|
+
export interface DdcRecoveryAction {
|
|
17
|
+
readonly kind:
|
|
18
|
+
| 'inspect'
|
|
19
|
+
| 'allocate-generation'
|
|
20
|
+
| 'retry'
|
|
21
|
+
| 'cold-rebuild'
|
|
22
|
+
| 'prune'
|
|
23
|
+
| 'release-lease';
|
|
24
|
+
readonly executable: boolean;
|
|
25
|
+
readonly exactTarget?: string;
|
|
26
|
+
readonly reason?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DdcStoreErrorInit {
|
|
30
|
+
readonly code: DdcErrorCode;
|
|
31
|
+
readonly detail: string;
|
|
32
|
+
readonly hint?: string;
|
|
33
|
+
readonly expected?: unknown;
|
|
34
|
+
readonly actual?: unknown;
|
|
35
|
+
readonly owner?: string;
|
|
36
|
+
readonly rootKind?: DdcErrorRootKind;
|
|
37
|
+
readonly scope?: string;
|
|
38
|
+
readonly generation?: number;
|
|
39
|
+
readonly lease?: string;
|
|
40
|
+
readonly revision?: number;
|
|
41
|
+
readonly recoveryActions?: readonly DdcRecoveryAction[];
|
|
42
|
+
readonly hostPath?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DdcBrowserError {
|
|
46
|
+
readonly code: DdcErrorCode;
|
|
47
|
+
readonly hint: string;
|
|
48
|
+
readonly expected: unknown;
|
|
49
|
+
readonly actual?: unknown;
|
|
50
|
+
readonly owner?: string;
|
|
51
|
+
readonly rootKind?: DdcErrorRootKind;
|
|
52
|
+
readonly scope?: string;
|
|
53
|
+
readonly generation?: number;
|
|
54
|
+
readonly lease?: string;
|
|
55
|
+
readonly revision?: number;
|
|
56
|
+
readonly recoveryActions: readonly Omit<DdcRecoveryAction, 'exactTarget'>[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const defaultHints: Record<DdcErrorCode, string> = {
|
|
60
|
+
'ddc-project-root-required': 'inject the canonical projectDdcRoot before serving or publishing',
|
|
61
|
+
'ddc-object-conflict': 'keep the verified object and reject the competing publication',
|
|
62
|
+
'ddc-head-conflict': 'inspect the current head and retry with a fresh revision',
|
|
63
|
+
'ddc-generation-conflict': 'allocate a new persistent generation; do not edit the counter',
|
|
64
|
+
'ddc-scope-mismatch': 'reinspect the canonical root and allocate a new scope',
|
|
65
|
+
'ddc-lease-expired': 'discard the stale commit and acquire a new lease',
|
|
66
|
+
'ddc-entry-incomplete': 'discard the partial entry and cold-cook from author authority',
|
|
67
|
+
'ddc-entry-invalid': 'preserve last-known-good, inspect the entry, then cold-cook',
|
|
68
|
+
'ddc-entry-conflict': 'keep the verified entry and reject the competing publication',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function defaultExpected(code: DdcErrorCode): unknown {
|
|
72
|
+
if (code === 'ddc-project-root-required') return 'projectDdcRoot';
|
|
73
|
+
if (code === 'ddc-entry-incomplete' || code === 'ddc-entry-invalid') {
|
|
74
|
+
return 'a complete DDC entry whose receipt and integrity digests validate';
|
|
75
|
+
}
|
|
76
|
+
return 'a valid DDC owner, scope, generation, revision, and lease';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function defaultActions(code: DdcErrorCode): readonly DdcRecoveryAction[] {
|
|
80
|
+
switch (code) {
|
|
81
|
+
case 'ddc-project-root-required':
|
|
82
|
+
case 'ddc-scope-mismatch':
|
|
83
|
+
return [
|
|
84
|
+
{ kind: 'inspect', executable: true },
|
|
85
|
+
{ kind: 'retry', executable: true },
|
|
86
|
+
];
|
|
87
|
+
case 'ddc-generation-conflict':
|
|
88
|
+
return [{ kind: 'allocate-generation', executable: true }];
|
|
89
|
+
case 'ddc-lease-expired':
|
|
90
|
+
return [
|
|
91
|
+
{ kind: 'release-lease', executable: true },
|
|
92
|
+
{ kind: 'retry', executable: true },
|
|
93
|
+
];
|
|
94
|
+
case 'ddc-object-conflict':
|
|
95
|
+
case 'ddc-head-conflict':
|
|
96
|
+
return [
|
|
97
|
+
{ kind: 'inspect', executable: true },
|
|
98
|
+
{ kind: 'retry', executable: true },
|
|
99
|
+
];
|
|
100
|
+
case 'ddc-entry-incomplete':
|
|
101
|
+
case 'ddc-entry-invalid':
|
|
102
|
+
return [{ kind: 'cold-rebuild', executable: true }];
|
|
103
|
+
case 'ddc-entry-conflict':
|
|
104
|
+
return [{ kind: 'inspect', executable: true }];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class DdcStoreError extends Error {
|
|
109
|
+
public readonly code: DdcErrorCode;
|
|
110
|
+
public readonly detail: string;
|
|
111
|
+
public readonly hint: string;
|
|
112
|
+
public readonly expected: unknown;
|
|
113
|
+
public readonly actual: unknown;
|
|
114
|
+
public readonly owner: string | undefined;
|
|
115
|
+
public readonly rootKind: DdcErrorRootKind | undefined;
|
|
116
|
+
public readonly scope: string | undefined;
|
|
117
|
+
public readonly generation: number | undefined;
|
|
118
|
+
public readonly lease: string | undefined;
|
|
119
|
+
public readonly revision: number | undefined;
|
|
120
|
+
public readonly recoveryActions: readonly DdcRecoveryAction[];
|
|
121
|
+
public readonly hostPath: string | undefined;
|
|
122
|
+
|
|
123
|
+
public constructor(code: DdcErrorCode, detail: string);
|
|
124
|
+
public constructor(input: DdcStoreErrorInit);
|
|
125
|
+
public constructor(inputOrCode: DdcStoreErrorInit | DdcErrorCode, legacyDetail?: string) {
|
|
126
|
+
const input: DdcStoreErrorInit =
|
|
127
|
+
typeof inputOrCode === 'string'
|
|
128
|
+
? { code: inputOrCode, detail: legacyDetail ?? inputOrCode }
|
|
129
|
+
: inputOrCode;
|
|
130
|
+
super(input.detail);
|
|
131
|
+
this.name = 'DdcStoreError';
|
|
132
|
+
this.code = input.code;
|
|
133
|
+
this.detail = input.detail;
|
|
134
|
+
this.hint = input.hint ?? defaultHints[input.code];
|
|
135
|
+
this.expected = input.expected ?? defaultExpected(input.code);
|
|
136
|
+
this.actual = input.actual;
|
|
137
|
+
this.owner = input.owner;
|
|
138
|
+
this.rootKind = input.rootKind;
|
|
139
|
+
this.scope = input.scope;
|
|
140
|
+
this.generation = input.generation;
|
|
141
|
+
this.lease = input.lease;
|
|
142
|
+
this.revision = input.revision;
|
|
143
|
+
this.recoveryActions = input.recoveryActions ?? defaultActions(input.code);
|
|
144
|
+
this.hostPath = input.hostPath;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
public toBrowserProjection(): DdcBrowserError {
|
|
148
|
+
return {
|
|
149
|
+
code: this.code,
|
|
150
|
+
hint: this.hint,
|
|
151
|
+
expected: this.expected,
|
|
152
|
+
...(this.actual === undefined ? {} : { actual: this.actual }),
|
|
153
|
+
...(this.owner === undefined ? {} : { owner: this.owner }),
|
|
154
|
+
...(this.rootKind === undefined ? {} : { rootKind: this.rootKind }),
|
|
155
|
+
...(this.scope === undefined ? {} : { scope: this.scope }),
|
|
156
|
+
...(this.generation === undefined ? {} : { generation: this.generation }),
|
|
157
|
+
...(this.lease === undefined ? {} : { lease: this.lease }),
|
|
158
|
+
...(this.revision === undefined ? {} : { revision: this.revision }),
|
|
159
|
+
recoveryActions: this.recoveryActions.map(
|
|
160
|
+
({ exactTarget: _exactTarget, ...action }) => action,
|
|
161
|
+
),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
package/src/gc.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readdir, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { withDdcLock } from './lifecycle.js';
|
|
4
|
+
|
|
5
|
+
export interface DdcGcInput {
|
|
6
|
+
readonly currentKeys: readonly string[];
|
|
7
|
+
readonly lastKnownGoodKeys: readonly string[];
|
|
8
|
+
readonly activeLeaseKeys: readonly string[];
|
|
9
|
+
readonly scopeIds?: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DdcGcResult {
|
|
13
|
+
readonly deleted: readonly string[];
|
|
14
|
+
readonly protected: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Mark current/LKG/lease roots, then sweep only the same project root. */
|
|
18
|
+
export async function collectDdcGarbage(root: string, input: DdcGcInput): Promise<DdcGcResult> {
|
|
19
|
+
return withDdcLock(root, 'gc', async () => {
|
|
20
|
+
const protectedKeys = new Set([
|
|
21
|
+
...input.currentKeys,
|
|
22
|
+
...input.lastKnownGoodKeys,
|
|
23
|
+
...input.activeLeaseKeys,
|
|
24
|
+
]);
|
|
25
|
+
const entries = await readdir(join(root, 'entries'), { withFileTypes: true }).catch(() => []);
|
|
26
|
+
const deleted: string[] = [];
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (!entry.isDirectory() || protectedKeys.has(entry.name)) continue;
|
|
29
|
+
await rm(join(root, 'entries', entry.name), { recursive: true, force: false });
|
|
30
|
+
deleted.push(entry.name);
|
|
31
|
+
}
|
|
32
|
+
return { deleted, protected: [...protectedKeys].sort() };
|
|
33
|
+
});
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type BuildDdcInput,
|
|
3
|
+
resolveDdcRoot,
|
|
4
|
+
semanticBuildKey,
|
|
5
|
+
} from './build-cache.js';
|
|
6
|
+
export {
|
|
7
|
+
type DdcArtifact,
|
|
8
|
+
type DdcEntry,
|
|
9
|
+
DdcEntryStore,
|
|
10
|
+
type DdcReceipt,
|
|
11
|
+
DdcStoreError,
|
|
12
|
+
ddcOutputDigest,
|
|
13
|
+
type PublishDdcEntryResult,
|
|
14
|
+
type StagedDdcEntry,
|
|
15
|
+
} from './entry-store.js';
|
|
16
|
+
export {
|
|
17
|
+
DDC_ERROR_CODES,
|
|
18
|
+
type DdcBrowserError,
|
|
19
|
+
type DdcContractErrorCode,
|
|
20
|
+
type DdcEntryErrorCode,
|
|
21
|
+
type DdcErrorCode,
|
|
22
|
+
type DdcErrorRootKind,
|
|
23
|
+
type DdcRecoveryAction as DdcErrorRecoveryAction,
|
|
24
|
+
type DdcStoreErrorInit,
|
|
25
|
+
} from './errors.js';
|
|
26
|
+
export { collectDdcGarbage, type DdcGcInput, type DdcGcResult } from './gc.js';
|
|
27
|
+
export { semanticDdcKey } from './key.js';
|
|
28
|
+
export {
|
|
29
|
+
DDC_LAYOUT_VERSION,
|
|
30
|
+
type DdcBuildLayout,
|
|
31
|
+
type DdcBuildLayoutResult,
|
|
32
|
+
type DdcBuildOptions,
|
|
33
|
+
type DdcLayout,
|
|
34
|
+
type DdcLayoutError,
|
|
35
|
+
type DdcLayoutFailure,
|
|
36
|
+
type DdcLayoutResult,
|
|
37
|
+
type DdcProjectLayout,
|
|
38
|
+
type DdcServeOptions,
|
|
39
|
+
isDdcLayout,
|
|
40
|
+
resolveBuildDdcLayout,
|
|
41
|
+
resolveDdcLayout,
|
|
42
|
+
} from './layout.js';
|
|
43
|
+
export {
|
|
44
|
+
type DdcBeginResult,
|
|
45
|
+
type DdcCommitResult,
|
|
46
|
+
type DdcHead,
|
|
47
|
+
type DdcLease,
|
|
48
|
+
DdcLifecycle,
|
|
49
|
+
type DdcLifecycleState,
|
|
50
|
+
type DdcRestoreFence,
|
|
51
|
+
type DdcRestoreResult,
|
|
52
|
+
type DdcRollbackSnapshot,
|
|
53
|
+
} from './lifecycle.js';
|
|
54
|
+
export {
|
|
55
|
+
type AcceptedPublicationCandidate,
|
|
56
|
+
type AcceptedPublicationStore,
|
|
57
|
+
createAcceptedPublication,
|
|
58
|
+
createAcceptedPublicationStore,
|
|
59
|
+
type ScriptablePackPublicationBuildInput,
|
|
60
|
+
type ScriptablePackPublicationError,
|
|
61
|
+
type ScriptablePackPublicationSnapshot,
|
|
62
|
+
scriptablePackOutputSetDigest,
|
|
63
|
+
scriptablePackPublicationGeneration,
|
|
64
|
+
} from './publication.js';
|
|
65
|
+
export {
|
|
66
|
+
assertRuntimeScope,
|
|
67
|
+
createRuntimeScope,
|
|
68
|
+
type DdcRuntimeScope,
|
|
69
|
+
runtimeScopeHash,
|
|
70
|
+
} from './runtime-scope.js';
|
|
71
|
+
export {
|
|
72
|
+
type DdcGenerationCandidate,
|
|
73
|
+
type DdcGenerationEntryCandidate,
|
|
74
|
+
DdcGenerationSession,
|
|
75
|
+
type DdcSessionMetrics,
|
|
76
|
+
type DdcSessionOptions,
|
|
77
|
+
} from './session.js';
|
|
78
|
+
export {
|
|
79
|
+
type BrowserDdcRecoveryAction,
|
|
80
|
+
type BrowserDdcStatus,
|
|
81
|
+
type BrowserDdcStatusLayer,
|
|
82
|
+
DDC_STATUS_SCHEMA,
|
|
83
|
+
type DdcRecoveryAction,
|
|
84
|
+
type DdcStatus,
|
|
85
|
+
type DdcStatusHealth,
|
|
86
|
+
type DdcStatusLayer,
|
|
87
|
+
type DdcStatusLayerKind,
|
|
88
|
+
type DdcStatusRootKind,
|
|
89
|
+
projectDdcStatusForBrowser,
|
|
90
|
+
serializeDdcStatus,
|
|
91
|
+
toBrowserDdcStatus,
|
|
92
|
+
} from './status.js';
|
package/src/key.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export interface SemanticDdcInput {
|
|
4
|
+
readonly schemaVersion: string;
|
|
5
|
+
readonly importer: string;
|
|
6
|
+
readonly codec: string;
|
|
7
|
+
readonly settings: unknown;
|
|
8
|
+
readonly sourceBytes: readonly Uint8Array[];
|
|
9
|
+
readonly declaredGuids: readonly string[];
|
|
10
|
+
readonly targetProfile: string;
|
|
11
|
+
readonly producer: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function canonicalDdcJson(value: unknown): string {
|
|
15
|
+
const sorted = sortValue(value);
|
|
16
|
+
return JSON.stringify(sorted) ?? 'null';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sortValue(value: unknown): unknown {
|
|
20
|
+
if (value instanceof Uint8Array) {
|
|
21
|
+
return { encoding: 'base64', bytes: Buffer.from(value).toString('base64') };
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(value)) return value.map(sortValue);
|
|
24
|
+
if (value !== null && typeof value === 'object') {
|
|
25
|
+
const result: Record<string, unknown> = {};
|
|
26
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
27
|
+
result[key] = sortValue((value as Record<string, unknown>)[key]);
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function semanticDdcKey(input: SemanticDdcInput): string {
|
|
35
|
+
const semantic = {
|
|
36
|
+
schemaVersion: input.schemaVersion,
|
|
37
|
+
importer: input.importer,
|
|
38
|
+
codec: input.codec,
|
|
39
|
+
settings: input.settings,
|
|
40
|
+
sourceBytes: input.sourceBytes,
|
|
41
|
+
declaredGuids: [...input.declaredGuids].sort(),
|
|
42
|
+
targetProfile: input.targetProfile,
|
|
43
|
+
producer: input.producer,
|
|
44
|
+
};
|
|
45
|
+
return createHash('sha256').update(canonicalDdcJson(semantic)).digest('hex');
|
|
46
|
+
}
|