@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.
Files changed (100) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +147 -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__/generation-session.integration.test.d.ts +2 -0
  15. package/dist/__tests__/generation-session.integration.test.d.ts.map +1 -0
  16. package/dist/__tests__/generation-session.unit.test.d.ts +2 -0
  17. package/dist/__tests__/generation-session.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/key.unit.test.d.ts +2 -0
  19. package/dist/__tests__/key.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/layout.unit.test.d.ts +2 -0
  21. package/dist/__tests__/layout.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/lifecycle.unit.test.d.ts +2 -0
  23. package/dist/__tests__/lifecycle.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/multiprocess-gc.integration.test.d.ts +2 -0
  25. package/dist/__tests__/multiprocess-gc.integration.test.d.ts.map +1 -0
  26. package/dist/__tests__/multiprocess-lifecycle.integration.test.d.ts +2 -0
  27. package/dist/__tests__/multiprocess-lifecycle.integration.test.d.ts.map +1 -0
  28. package/dist/__tests__/multiprocess-worker.d.ts +2 -0
  29. package/dist/__tests__/multiprocess-worker.d.ts.map +1 -0
  30. package/dist/__tests__/owner-chain.integration.test.d.ts +2 -0
  31. package/dist/__tests__/owner-chain.integration.test.d.ts.map +1 -0
  32. package/dist/__tests__/status-root-kind-owner.test-d.d.ts +2 -0
  33. package/dist/__tests__/status-root-kind-owner.test-d.d.ts.map +1 -0
  34. package/dist/__tests__/status.unit.test.d.ts +2 -0
  35. package/dist/__tests__/status.unit.test.d.ts.map +1 -0
  36. package/dist/build-cache.d.ts +18 -0
  37. package/dist/build-cache.d.ts.map +1 -0
  38. package/dist/entry-store.d.ts +53 -0
  39. package/dist/entry-store.d.ts.map +1 -0
  40. package/dist/entry-store.mjs +349 -0
  41. package/dist/entry-store.mjs.map +1 -0
  42. package/dist/errors.d.ts +58 -0
  43. package/dist/errors.d.ts.map +1 -0
  44. package/dist/errors.mjs +109 -0
  45. package/dist/errors.mjs.map +1 -0
  46. package/dist/gc.d.ts +13 -0
  47. package/dist/gc.d.ts.map +1 -0
  48. package/dist/index.d.ts +12 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.mjs +1574 -0
  51. package/dist/index.mjs.map +1 -0
  52. package/dist/key.d.ts +13 -0
  53. package/dist/key.d.ts.map +1 -0
  54. package/dist/key.mjs +38 -0
  55. package/dist/key.mjs.map +1 -0
  56. package/dist/layout.d.ts +51 -0
  57. package/dist/layout.d.ts.map +1 -0
  58. package/dist/layout.mjs +68 -0
  59. package/dist/layout.mjs.map +1 -0
  60. package/dist/lifecycle.d.ts +93 -0
  61. package/dist/lifecycle.d.ts.map +1 -0
  62. package/dist/publication.d.ts +50 -0
  63. package/dist/publication.d.ts.map +1 -0
  64. package/dist/runtime-scope.d.ts +10 -0
  65. package/dist/runtime-scope.d.ts.map +1 -0
  66. package/dist/session.d.ts +46 -0
  67. package/dist/session.d.ts.map +1 -0
  68. package/dist/status.d.ts +39 -0
  69. package/dist/status.d.ts.map +1 -0
  70. package/dist/status.mjs +20 -0
  71. package/dist/status.mjs.map +1 -0
  72. package/package.json +101 -0
  73. package/src/__tests__/concurrency.integration.test.ts +53 -0
  74. package/src/__tests__/consumer-path.integration.test.ts +61 -0
  75. package/src/__tests__/crash-recovery.integration.test.ts +84 -0
  76. package/src/__tests__/entry-store.integration.test.ts +83 -0
  77. package/src/__tests__/errors.unit.test.ts +54 -0
  78. package/src/__tests__/generation-session.integration.test.ts +202 -0
  79. package/src/__tests__/generation-session.unit.test.ts +35 -0
  80. package/src/__tests__/key.unit.test.ts +49 -0
  81. package/src/__tests__/layout.unit.test.ts +50 -0
  82. package/src/__tests__/lifecycle.unit.test.ts +258 -0
  83. package/src/__tests__/multiprocess-gc.integration.test.ts +36 -0
  84. package/src/__tests__/multiprocess-lifecycle.integration.test.ts +129 -0
  85. package/src/__tests__/multiprocess-worker.ts +41 -0
  86. package/src/__tests__/owner-chain.integration.test.ts +45 -0
  87. package/src/__tests__/status-root-kind-owner.test-d.ts +42 -0
  88. package/src/__tests__/status.unit.test.ts +56 -0
  89. package/src/build-cache.ts +60 -0
  90. package/src/entry-store.ts +317 -0
  91. package/src/errors.ts +164 -0
  92. package/src/gc.ts +34 -0
  93. package/src/index.ts +92 -0
  94. package/src/key.ts +46 -0
  95. package/src/layout.ts +132 -0
  96. package/src/lifecycle.ts +792 -0
  97. package/src/publication.ts +446 -0
  98. package/src/runtime-scope.ts +87 -0
  99. package/src/session.ts +281 -0
  100. package/src/status.ts +58 -0
@@ -0,0 +1,446 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type {
3
+ AssetPublicationEnvelope,
4
+ AssetPublicationExternalEvidence,
5
+ AssetPublicationFailure,
6
+ AssetPublicationLocator,
7
+ AssetPublicationOutput,
8
+ AssetPublicationRecovery,
9
+ Result,
10
+ } from '@forgeax/engine-types';
11
+ import { err, ok } from '@forgeax/engine-types';
12
+
13
+ export interface ScriptablePackPublicationSnapshot {
14
+ readonly current?: AssetPublicationEnvelope;
15
+ readonly lastKnownGood?: AssetPublicationEnvelope;
16
+ readonly failure?: AssetPublicationFailure;
17
+ }
18
+
19
+ export interface ScriptablePackPublicationError extends AssetPublicationFailure {
20
+ readonly recovery: AssetPublicationRecovery;
21
+ readonly current?: AssetPublicationLocator;
22
+ readonly lastKnownGood?: AssetPublicationLocator;
23
+ }
24
+
25
+ export interface AcceptedPublicationCandidate {
26
+ readonly envelope: AssetPublicationEnvelope;
27
+ readonly cancelled?: boolean;
28
+ }
29
+
30
+ export interface ScriptablePackPublicationBuildInput {
31
+ readonly sourcePath: string;
32
+ readonly sourceRevision: string;
33
+ /** Optional caller generation; the DDC owner derives one when omitted. */
34
+ readonly generation?: number;
35
+ readonly digest: string;
36
+ readonly packageUrl: string;
37
+ readonly inputFingerprint: string;
38
+ readonly outputs: readonly AssetPublicationOutput[];
39
+ readonly externalEvidence: readonly AssetPublicationExternalEvidence[];
40
+ }
41
+
42
+ /**
43
+ * DDC-owned publication authority for source keyed producers. Transport
44
+ * adapters may stage bytes, but they do not retain current/LKG or generation
45
+ * state themselves.
46
+ */
47
+ export interface AcceptedPublicationStore {
48
+ observe(sourcePath: string): ScriptablePackPublicationSnapshot;
49
+ stage(
50
+ sourcePath: string,
51
+ candidate: AcceptedPublicationCandidate,
52
+ ): Result<void, ScriptablePackPublicationError>;
53
+ commit(
54
+ sourcePath: string,
55
+ candidate: AcceptedPublicationCandidate,
56
+ commitRoute: () => Promise<void> | void,
57
+ ): Promise<Result<ScriptablePackPublicationSnapshot, ScriptablePackPublicationError>>;
58
+ discard(sourcePath: string, candidate: AssetPublicationEnvelope): void;
59
+ restore(sourcePath: string, snapshot: ScriptablePackPublicationSnapshot): void;
60
+ }
61
+
62
+ function stable(value: unknown): string {
63
+ if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
64
+ if (value !== null && typeof value === 'object') {
65
+ const record = value as Record<string, unknown>;
66
+ return `{${Object.keys(record)
67
+ .sort()
68
+ .map((key) => `${JSON.stringify(key)}:${stable(record[key])}`)
69
+ .join(',')}}`;
70
+ }
71
+ return JSON.stringify(value) ?? 'null';
72
+ }
73
+
74
+ function digest(value: unknown): string {
75
+ return `sha256:${createHash('sha256').update(stable(value)).digest('hex')}`;
76
+ }
77
+
78
+ export function scriptablePackOutputSetDigest(outputs: readonly AssetPublicationOutput[]): string {
79
+ return digest(
80
+ outputs.map((output) => ({
81
+ guid: output.guid.toLowerCase(),
82
+ sourceKey: output.sourceKey,
83
+ kind: output.kind,
84
+ digest: output.digest,
85
+ refs: [...output.refs].map((guid) => guid.toLowerCase()),
86
+ })),
87
+ );
88
+ }
89
+
90
+ /** Derive a stable positive generation from the accepted publication tuple. */
91
+ export function scriptablePackPublicationGeneration(input: {
92
+ readonly sourceRevision: string;
93
+ readonly digest: string;
94
+ readonly outputSetDigest: string;
95
+ }): number {
96
+ const hash = createHash('sha256')
97
+ .update(input.sourceRevision)
98
+ .update('\n')
99
+ .update(input.digest)
100
+ .update('\n')
101
+ .update(input.outputSetDigest)
102
+ .digest('hex');
103
+ const generation = Number.parseInt(hash.slice(0, 8), 16);
104
+ return generation > 0 ? generation : 1;
105
+ }
106
+
107
+ /** Build the complete producer-owned tuple after the package route is finalized. */
108
+ export function createAcceptedPublication(
109
+ input: ScriptablePackPublicationBuildInput,
110
+ ): AssetPublicationEnvelope {
111
+ const outputSetDigest = scriptablePackOutputSetDigest(input.outputs);
112
+ const publicationGeneration =
113
+ input.generation ??
114
+ scriptablePackPublicationGeneration({
115
+ sourceRevision: input.sourceRevision,
116
+ digest: input.digest,
117
+ outputSetDigest,
118
+ });
119
+ return {
120
+ schemaVersion: 'asset-publication/1',
121
+ sourcePath: input.sourcePath,
122
+ sourceRevision: input.sourceRevision,
123
+ generation: publicationGeneration,
124
+ digest: input.digest,
125
+ outputSetDigest,
126
+ outputs: input.outputs,
127
+ receipt: {
128
+ schemaVersion: 'asset-publication-receipt/1',
129
+ sourcePath: input.sourcePath,
130
+ sourceRevision: input.sourceRevision,
131
+ inputFingerprint: input.inputFingerprint,
132
+ outputDigest: input.digest,
133
+ outputSetDigest,
134
+ externalEvidence: input.externalEvidence,
135
+ },
136
+ externalEvidence: input.externalEvidence,
137
+ current: {
138
+ generation: publicationGeneration,
139
+ digest: input.digest,
140
+ outputSetDigest,
141
+ packageUrl: input.packageUrl,
142
+ receiptKey: input.inputFingerprint,
143
+ },
144
+ };
145
+ }
146
+
147
+ function locatorFor(envelope: AssetPublicationEnvelope): AssetPublicationLocator {
148
+ return {
149
+ generation: envelope.generation,
150
+ digest: envelope.digest,
151
+ outputSetDigest: envelope.outputSetDigest,
152
+ packageUrl: envelope.current?.packageUrl ?? '',
153
+ receiptKey: envelope.receipt.inputFingerprint,
154
+ };
155
+ }
156
+
157
+ function recoveryFor(retryable: boolean, useLastKnownGood: boolean): AssetPublicationRecovery {
158
+ return {
159
+ retryable,
160
+ preserveCurrent: true,
161
+ useLastKnownGood,
162
+ actions: retryable
163
+ ? ['inspect-publication-failure', 'retry-rebuild', 'continue-last-known-good']
164
+ : ['inspect-publication-failure', 'edit-source', 'continue-last-known-good'],
165
+ };
166
+ }
167
+
168
+ function failure(
169
+ envelope: AssetPublicationEnvelope,
170
+ code: string,
171
+ stage: AssetPublicationFailure['stage'],
172
+ reason: string,
173
+ retryable: boolean,
174
+ current: AssetPublicationEnvelope | undefined,
175
+ lastKnownGood: AssetPublicationEnvelope | undefined,
176
+ ): ScriptablePackPublicationError {
177
+ return {
178
+ code,
179
+ stage,
180
+ sourcePath: envelope.sourcePath,
181
+ sourceRevision: envelope.sourceRevision,
182
+ generation: envelope.generation,
183
+ reason,
184
+ recovery: recoveryFor(retryable, lastKnownGood !== undefined),
185
+ ...(current === undefined ? {} : { current: locatorFor(current) }),
186
+ ...(lastKnownGood === undefined ? {} : { lastKnownGood: locatorFor(lastKnownGood) }),
187
+ };
188
+ }
189
+
190
+ function validateOutputs(
191
+ envelope: AssetPublicationEnvelope,
192
+ current: AssetPublicationEnvelope | undefined,
193
+ lastKnownGood: AssetPublicationEnvelope | undefined,
194
+ ): ScriptablePackPublicationError | undefined {
195
+ const guids = new Set<string>();
196
+ const sourceKeys = new Set<string>();
197
+ for (const output of envelope.outputs) {
198
+ const guid = output.guid.toLowerCase();
199
+ if (guids.has(guid) || sourceKeys.has(output.sourceKey)) {
200
+ return failure(
201
+ envelope,
202
+ 'asset-publication-output-duplicate',
203
+ 'output',
204
+ 'publication output GUIDs and sourceKeys must be unique',
205
+ false,
206
+ current,
207
+ lastKnownGood,
208
+ );
209
+ }
210
+ guids.add(guid);
211
+ sourceKeys.add(output.sourceKey);
212
+ }
213
+ if (
214
+ envelope.outputs.length === 0 ||
215
+ scriptablePackOutputSetDigest(envelope.outputs) !== envelope.outputSetDigest
216
+ ) {
217
+ return failure(
218
+ envelope,
219
+ 'asset-publication-output-set-mismatch',
220
+ 'receipt',
221
+ 'receipt outputSetDigest does not match the complete output tuple',
222
+ false,
223
+ current,
224
+ lastKnownGood,
225
+ );
226
+ }
227
+ return undefined;
228
+ }
229
+
230
+ function validateReceipt(
231
+ envelope: AssetPublicationEnvelope,
232
+ current: AssetPublicationEnvelope | undefined,
233
+ lastKnownGood: AssetPublicationEnvelope | undefined,
234
+ ): ScriptablePackPublicationError | undefined {
235
+ const receipt = envelope.receipt;
236
+ if (
237
+ receipt.schemaVersion !== 'asset-publication-receipt/1' ||
238
+ receipt.sourcePath !== envelope.sourcePath ||
239
+ receipt.sourceRevision !== envelope.sourceRevision ||
240
+ receipt.outputSetDigest !== envelope.outputSetDigest ||
241
+ receipt.outputDigest !== envelope.digest ||
242
+ stable(receipt.externalEvidence) !== stable(envelope.externalEvidence)
243
+ ) {
244
+ return failure(
245
+ envelope,
246
+ 'asset-publication-receipt-mismatch',
247
+ 'receipt',
248
+ 'publication receipt does not match the candidate output tuple',
249
+ false,
250
+ current,
251
+ lastKnownGood,
252
+ );
253
+ }
254
+ return undefined;
255
+ }
256
+
257
+ function validateEnvelope(
258
+ envelope: AssetPublicationEnvelope,
259
+ current: AssetPublicationEnvelope | undefined,
260
+ lastKnownGood: AssetPublicationEnvelope | undefined,
261
+ ): ScriptablePackPublicationError | undefined {
262
+ if (envelope.schemaVersion !== 'asset-publication/1') {
263
+ return failure(
264
+ envelope,
265
+ 'asset-publication-schema-invalid',
266
+ 'receipt',
267
+ 'publication envelope schemaVersion is not supported',
268
+ false,
269
+ current,
270
+ lastKnownGood,
271
+ );
272
+ }
273
+ if (!Number.isSafeInteger(envelope.generation) || envelope.generation < 1) {
274
+ return failure(
275
+ envelope,
276
+ 'asset-publication-generation-invalid',
277
+ 'receipt',
278
+ 'publication generation must be a positive integer',
279
+ false,
280
+ current,
281
+ lastKnownGood,
282
+ );
283
+ }
284
+ const outputError = validateOutputs(envelope, current, lastKnownGood);
285
+ if (outputError !== undefined) return outputError;
286
+ const receiptError = validateReceipt(envelope, current, lastKnownGood);
287
+ if (receiptError !== undefined) return receiptError;
288
+ // Re-running a deterministic producer (for example a cold cook after a
289
+ // rebuild) can yield the exact same publication tuple. That is an
290
+ // idempotent request, not a late result: rejecting it here makes the
291
+ // editor's retry/cold-cook operation fail even though the published asset is
292
+ // already the requested content. A same-generation tuple with any changed
293
+ // source/digest/output-set fact remains stale and is rejected below.
294
+ const sameTuple =
295
+ current !== undefined &&
296
+ envelope.generation === current.generation &&
297
+ envelope.sourcePath === current.sourcePath &&
298
+ envelope.sourceRevision === current.sourceRevision &&
299
+ envelope.digest === current.digest &&
300
+ envelope.outputSetDigest === current.outputSetDigest;
301
+ if (sameTuple) return undefined;
302
+ if (current !== undefined && envelope.generation <= current.generation) {
303
+ return failure(
304
+ envelope,
305
+ 'asset-publication-stale',
306
+ 'cancelled',
307
+ `candidate generation ${envelope.generation} is not newer than current ${current.generation}`,
308
+ true,
309
+ current,
310
+ lastKnownGood,
311
+ );
312
+ }
313
+ return undefined;
314
+ }
315
+
316
+ interface PublicationState {
317
+ snapshot: ScriptablePackPublicationSnapshot;
318
+ /**
319
+ * Staged candidates are owned by their envelope object, not by the
320
+ * publication tuple. Two overlapping generations can legitimately produce
321
+ * the same deterministic tuple; a stale owner's discard must not remove the
322
+ * current owner's candidate before commit.
323
+ */
324
+ readonly staged: Set<AssetPublicationEnvelope>;
325
+ }
326
+
327
+ function validateCandidate(
328
+ state: PublicationState,
329
+ candidate: AcceptedPublicationCandidate,
330
+ cancelledReason: string,
331
+ ): ScriptablePackPublicationError | undefined {
332
+ const current = state.snapshot.current;
333
+ const lastKnownGood = state.snapshot.lastKnownGood ?? current;
334
+ if (candidate.cancelled === true) {
335
+ return failure(
336
+ candidate.envelope,
337
+ 'asset-publication-cancelled',
338
+ 'cancelled',
339
+ cancelledReason,
340
+ true,
341
+ current,
342
+ lastKnownGood,
343
+ );
344
+ }
345
+ return validateEnvelope(candidate.envelope, current, lastKnownGood);
346
+ }
347
+
348
+ async function publishCandidate(
349
+ state: PublicationState,
350
+ candidate: AcceptedPublicationCandidate,
351
+ commitRoute: () => Promise<void> | void,
352
+ ): Promise<Result<ScriptablePackPublicationSnapshot, ScriptablePackPublicationError>> {
353
+ const current = state.snapshot.current;
354
+ const lastKnownGood = state.snapshot.lastKnownGood ?? current;
355
+ const invalid = validateCandidate(
356
+ state,
357
+ candidate,
358
+ 'publication request was cancelled before route commit',
359
+ );
360
+ if (invalid !== undefined) {
361
+ state.snapshot = { ...state.snapshot, failure: invalid };
362
+ return err(invalid);
363
+ }
364
+ try {
365
+ await commitRoute();
366
+ } catch (error) {
367
+ const failed = failure(
368
+ candidate.envelope,
369
+ 'asset-publication-route-failed',
370
+ 'route',
371
+ error instanceof Error ? error.message : String(error),
372
+ true,
373
+ current,
374
+ lastKnownGood,
375
+ );
376
+ state.snapshot = { ...state.snapshot, failure: failed };
377
+ return err(failed);
378
+ }
379
+ const published = {
380
+ ...candidate.envelope,
381
+ current: locatorFor(candidate.envelope),
382
+ ...(current === undefined ? {} : { lastKnownGood: locatorFor(current) }),
383
+ recovery: recoveryFor(false, current !== undefined),
384
+ };
385
+ state.snapshot = {
386
+ current: published,
387
+ ...(current === undefined ? {} : { lastKnownGood: current }),
388
+ };
389
+ return ok(state.snapshot);
390
+ }
391
+
392
+ /** Create the one DDC publication authority shared by dev attempts. */
393
+ export function createAcceptedPublicationStore(): AcceptedPublicationStore {
394
+ const states = new Map<string, PublicationState>();
395
+ function stateFor(sourcePath: string): PublicationState {
396
+ const existing = states.get(sourcePath);
397
+ if (existing !== undefined) return existing;
398
+ const created: PublicationState = { snapshot: {}, staged: new Set() };
399
+ states.set(sourcePath, created);
400
+ return created;
401
+ }
402
+ return {
403
+ observe(sourcePath) {
404
+ return stateFor(sourcePath).snapshot;
405
+ },
406
+ stage(sourcePath, candidate) {
407
+ const invalid = validateCandidate(
408
+ stateFor(sourcePath),
409
+ candidate,
410
+ 'publication request was cancelled before DDC commit',
411
+ );
412
+ if (invalid !== undefined) return err(invalid);
413
+ stateFor(sourcePath).staged.add(candidate.envelope);
414
+ return ok(undefined);
415
+ },
416
+ async commit(sourcePath, candidate, commitRoute) {
417
+ const state = stateFor(sourcePath);
418
+ if (!state.staged.has(candidate.envelope)) {
419
+ const current = state.snapshot.current;
420
+ const lastKnownGood = state.snapshot.lastKnownGood ?? current;
421
+ return err(
422
+ failure(
423
+ candidate.envelope,
424
+ 'asset-publication-cancelled',
425
+ 'cancelled',
426
+ 'publication candidate was discarded before DDC commit',
427
+ true,
428
+ current,
429
+ lastKnownGood,
430
+ ),
431
+ );
432
+ }
433
+ const result = await publishCandidate(state, candidate, commitRoute);
434
+ state.staged.delete(candidate.envelope);
435
+ return result;
436
+ },
437
+ discard(sourcePath, candidate) {
438
+ stateFor(sourcePath).staged.delete(candidate);
439
+ },
440
+ restore(sourcePath, snapshot) {
441
+ const state = stateFor(sourcePath);
442
+ state.staged.clear();
443
+ state.snapshot = snapshot;
444
+ },
445
+ };
446
+ }
@@ -0,0 +1,87 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { DdcStoreError } from './errors.js';
5
+
6
+ export interface DdcRuntimeScope {
7
+ readonly scopeId: string;
8
+ readonly scopeHash: string;
9
+ readonly root: string;
10
+ readonly metadataPath: string;
11
+ }
12
+
13
+ interface ScopeMetadata {
14
+ readonly schemaVersion: 'forgeax-ddc-scope/v2';
15
+ readonly scopeId: string;
16
+ readonly scopeHash: string;
17
+ }
18
+
19
+ function scopeHash(scopeId: string): string {
20
+ return createHash('sha256').update(scopeId, 'utf8').digest('hex');
21
+ }
22
+
23
+ export function runtimeScopeHash(scopeId: string): string {
24
+ return scopeHash(scopeId);
25
+ }
26
+
27
+ export async function createRuntimeScope(root: string, scopeId: string): Promise<DdcRuntimeScope> {
28
+ if (scopeId.length === 0) {
29
+ throw new DdcStoreError({
30
+ code: 'ddc-scope-mismatch',
31
+ detail: 'runtime scope id must not be empty',
32
+ expected: 'a non-empty original scope id',
33
+ actual: scopeId,
34
+ rootKind: 'project-ddc',
35
+ });
36
+ }
37
+ const hash = scopeHash(scopeId);
38
+ const scopeRoot = join(root, 'scopes', hash);
39
+ const metadataPath = join(scopeRoot, 'scope.json');
40
+ await mkdir(scopeRoot, { recursive: true });
41
+ const expected: ScopeMetadata = {
42
+ schemaVersion: 'forgeax-ddc-scope/v2',
43
+ scopeId,
44
+ scopeHash: hash,
45
+ };
46
+ try {
47
+ const actual = JSON.parse(await readFile(metadataPath, 'utf8')) as Partial<ScopeMetadata>;
48
+ if (
49
+ actual.schemaVersion !== expected.schemaVersion ||
50
+ actual.scopeId !== expected.scopeId ||
51
+ actual.scopeHash !== expected.scopeHash
52
+ ) {
53
+ throw new DdcStoreError({
54
+ code: 'ddc-scope-mismatch',
55
+ detail: 'runtime scope metadata does not match the requested scope',
56
+ expected,
57
+ actual,
58
+ rootKind: 'project-ddc',
59
+ scope: scopeId,
60
+ });
61
+ }
62
+ } catch (error) {
63
+ if (error instanceof DdcStoreError) throw error;
64
+ const temporary = `${metadataPath}.${process.pid}.tmp`;
65
+ await writeFile(temporary, JSON.stringify(expected));
66
+ await rename(temporary, metadataPath);
67
+ }
68
+ return { scopeId, scopeHash: hash, root: scopeRoot, metadataPath };
69
+ }
70
+
71
+ export async function assertRuntimeScope(
72
+ root: string,
73
+ scope: Pick<DdcRuntimeScope, 'scopeId' | 'scopeHash'>,
74
+ ): Promise<DdcRuntimeScope> {
75
+ const expectedHash = scopeHash(scope.scopeId);
76
+ if (scope.scopeHash !== expectedHash) {
77
+ throw new DdcStoreError({
78
+ code: 'ddc-scope-mismatch',
79
+ detail: 'runtime scope hash does not match the original scope id',
80
+ expected: expectedHash,
81
+ actual: scope.scopeHash,
82
+ rootKind: 'project-ddc',
83
+ scope: scope.scopeId,
84
+ });
85
+ }
86
+ return createRuntimeScope(root, scope.scopeId);
87
+ }