@forgeax/engine-ddc 0.1.2 → 0.1.4
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/README.md +2 -0
- package/dist/.tsbuildinfo +1 -1
- 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__/owner-chain.integration.test.d.ts +2 -0
- package/dist/__tests__/owner-chain.integration.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 +1 -0
- package/dist/entry-store.d.ts.map +1 -1
- package/dist/entry-store.mjs +28 -18
- package/dist/entry-store.mjs.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +615 -57
- package/dist/index.mjs.map +1 -1
- package/dist/lifecycle.d.ts +3 -0
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/publication.d.ts +50 -0
- package/dist/publication.d.ts.map +1 -0
- package/dist/session.d.ts +45 -0
- package/dist/session.d.ts.map +1 -0
- package/package.json +4 -1
- package/src/__tests__/generation-session.integration.test.ts +74 -0
- package/src/__tests__/generation-session.unit.test.ts +35 -0
- package/src/__tests__/owner-chain.integration.test.ts +45 -0
- package/src/build-cache.ts +60 -0
- package/src/entry-store.ts +27 -17
- package/src/index.ts +24 -1
- package/src/lifecycle.ts +38 -0
- package/src/publication.ts +444 -0
- package/src/session.ts +254 -0
|
@@ -0,0 +1,444 @@
|
|
|
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
|
+
readonly staged: Map<string, AssetPublicationEnvelope>;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function validateCandidate(
|
|
322
|
+
state: PublicationState,
|
|
323
|
+
candidate: AcceptedPublicationCandidate,
|
|
324
|
+
cancelledReason: string,
|
|
325
|
+
): ScriptablePackPublicationError | undefined {
|
|
326
|
+
const current = state.snapshot.current;
|
|
327
|
+
const lastKnownGood = state.snapshot.lastKnownGood ?? current;
|
|
328
|
+
if (candidate.cancelled === true) {
|
|
329
|
+
return failure(
|
|
330
|
+
candidate.envelope,
|
|
331
|
+
'asset-publication-cancelled',
|
|
332
|
+
'cancelled',
|
|
333
|
+
cancelledReason,
|
|
334
|
+
true,
|
|
335
|
+
current,
|
|
336
|
+
lastKnownGood,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
return validateEnvelope(candidate.envelope, current, lastKnownGood);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function publishCandidate(
|
|
343
|
+
state: PublicationState,
|
|
344
|
+
candidate: AcceptedPublicationCandidate,
|
|
345
|
+
commitRoute: () => Promise<void> | void,
|
|
346
|
+
): Promise<Result<ScriptablePackPublicationSnapshot, ScriptablePackPublicationError>> {
|
|
347
|
+
const current = state.snapshot.current;
|
|
348
|
+
const lastKnownGood = state.snapshot.lastKnownGood ?? current;
|
|
349
|
+
const invalid = validateCandidate(
|
|
350
|
+
state,
|
|
351
|
+
candidate,
|
|
352
|
+
'publication request was cancelled before route commit',
|
|
353
|
+
);
|
|
354
|
+
if (invalid !== undefined) {
|
|
355
|
+
state.snapshot = { ...state.snapshot, failure: invalid };
|
|
356
|
+
return err(invalid);
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
await commitRoute();
|
|
360
|
+
} catch (error) {
|
|
361
|
+
const failed = failure(
|
|
362
|
+
candidate.envelope,
|
|
363
|
+
'asset-publication-route-failed',
|
|
364
|
+
'route',
|
|
365
|
+
error instanceof Error ? error.message : String(error),
|
|
366
|
+
true,
|
|
367
|
+
current,
|
|
368
|
+
lastKnownGood,
|
|
369
|
+
);
|
|
370
|
+
state.snapshot = { ...state.snapshot, failure: failed };
|
|
371
|
+
return err(failed);
|
|
372
|
+
}
|
|
373
|
+
const published = {
|
|
374
|
+
...candidate.envelope,
|
|
375
|
+
current: locatorFor(candidate.envelope),
|
|
376
|
+
...(current === undefined ? {} : { lastKnownGood: locatorFor(current) }),
|
|
377
|
+
recovery: recoveryFor(false, current !== undefined),
|
|
378
|
+
};
|
|
379
|
+
state.snapshot = {
|
|
380
|
+
current: published,
|
|
381
|
+
...(current === undefined ? {} : { lastKnownGood: current }),
|
|
382
|
+
};
|
|
383
|
+
return ok(state.snapshot);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Create the one DDC publication authority shared by dev attempts. */
|
|
387
|
+
export function createAcceptedPublicationStore(): AcceptedPublicationStore {
|
|
388
|
+
const states = new Map<string, PublicationState>();
|
|
389
|
+
function stateFor(sourcePath: string): PublicationState {
|
|
390
|
+
const existing = states.get(sourcePath);
|
|
391
|
+
if (existing !== undefined) return existing;
|
|
392
|
+
const created: PublicationState = { snapshot: {}, staged: new Map() };
|
|
393
|
+
states.set(sourcePath, created);
|
|
394
|
+
return created;
|
|
395
|
+
}
|
|
396
|
+
function candidateKey(candidate: AssetPublicationEnvelope): string {
|
|
397
|
+
return `${candidate.generation}\0${candidate.digest}\0${candidate.outputSetDigest}`;
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
observe(sourcePath) {
|
|
401
|
+
return stateFor(sourcePath).snapshot;
|
|
402
|
+
},
|
|
403
|
+
stage(sourcePath, candidate) {
|
|
404
|
+
const invalid = validateCandidate(
|
|
405
|
+
stateFor(sourcePath),
|
|
406
|
+
candidate,
|
|
407
|
+
'publication request was cancelled before DDC commit',
|
|
408
|
+
);
|
|
409
|
+
if (invalid !== undefined) return err(invalid);
|
|
410
|
+
stateFor(sourcePath).staged.set(candidateKey(candidate.envelope), candidate.envelope);
|
|
411
|
+
return ok(undefined);
|
|
412
|
+
},
|
|
413
|
+
async commit(sourcePath, candidate, commitRoute) {
|
|
414
|
+
const key = candidateKey(candidate.envelope);
|
|
415
|
+
const state = stateFor(sourcePath);
|
|
416
|
+
if (!state.staged.has(key)) {
|
|
417
|
+
const current = state.snapshot.current;
|
|
418
|
+
const lastKnownGood = state.snapshot.lastKnownGood ?? current;
|
|
419
|
+
return err(
|
|
420
|
+
failure(
|
|
421
|
+
candidate.envelope,
|
|
422
|
+
'asset-publication-cancelled',
|
|
423
|
+
'cancelled',
|
|
424
|
+
'publication candidate was discarded before DDC commit',
|
|
425
|
+
true,
|
|
426
|
+
current,
|
|
427
|
+
lastKnownGood,
|
|
428
|
+
),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
const result = await publishCandidate(state, candidate, commitRoute);
|
|
432
|
+
state.staged.delete(key);
|
|
433
|
+
return result;
|
|
434
|
+
},
|
|
435
|
+
discard(sourcePath, candidate) {
|
|
436
|
+
stateFor(sourcePath).staged.delete(candidateKey(candidate));
|
|
437
|
+
},
|
|
438
|
+
restore(sourcePath, snapshot) {
|
|
439
|
+
const state = stateFor(sourcePath);
|
|
440
|
+
state.staged.clear();
|
|
441
|
+
state.snapshot = snapshot;
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { DdcEntry, StagedDdcEntry } from './entry-store.js';
|
|
2
|
+
import { DdcEntryStore } from './entry-store.js';
|
|
3
|
+
import type { DdcCommitResult, DdcHead, DdcLease } from './lifecycle.js';
|
|
4
|
+
import { DdcLifecycle } from './lifecycle.js';
|
|
5
|
+
|
|
6
|
+
export interface DdcSessionOptions {
|
|
7
|
+
readonly generation: number;
|
|
8
|
+
readonly leaseTtlMs?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DdcGenerationCandidate {
|
|
12
|
+
readonly generation: number;
|
|
13
|
+
readonly lease: DdcLease;
|
|
14
|
+
readonly previousHead: DdcHead;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DdcGenerationEntryCandidate extends DdcGenerationCandidate {
|
|
18
|
+
readonly staged: StagedDdcEntry;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DdcSessionMetrics {
|
|
22
|
+
readonly hitCount: number;
|
|
23
|
+
readonly missCount: number;
|
|
24
|
+
readonly corruptCount: number;
|
|
25
|
+
readonly writeFailureCount: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type MutableDdcGenerationCandidate = Omit<DdcGenerationCandidate, 'lease'> & {
|
|
29
|
+
lease: DdcLease;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type MutableDdcGenerationEntryCandidate = Omit<DdcGenerationEntryCandidate, 'lease'> & {
|
|
33
|
+
lease: DdcLease;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type HeartbeatTimer = ReturnType<typeof setTimeout>;
|
|
37
|
+
|
|
38
|
+
interface MutableMetrics {
|
|
39
|
+
hitCount: number;
|
|
40
|
+
missCount: number;
|
|
41
|
+
corruptCount: number;
|
|
42
|
+
writeFailureCount: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class DdcGenerationSession {
|
|
46
|
+
public readonly generation: number;
|
|
47
|
+
private readonly lifecycle: DdcLifecycle;
|
|
48
|
+
private readonly entries: DdcEntryStore;
|
|
49
|
+
private readonly candidates = new Map<string, MutableDdcGenerationCandidate>();
|
|
50
|
+
private readonly heartbeatTimers = new Map<string, HeartbeatTimer>();
|
|
51
|
+
private readonly counters: MutableMetrics = {
|
|
52
|
+
hitCount: 0,
|
|
53
|
+
missCount: 0,
|
|
54
|
+
corruptCount: 0,
|
|
55
|
+
writeFailureCount: 0,
|
|
56
|
+
};
|
|
57
|
+
private accepting = true;
|
|
58
|
+
|
|
59
|
+
public constructor(root: string, options: DdcSessionOptions) {
|
|
60
|
+
if (!Number.isSafeInteger(options.generation) || options.generation < 1) {
|
|
61
|
+
throw new TypeError('DDC generation must be a positive safe integer');
|
|
62
|
+
}
|
|
63
|
+
this.generation = options.generation;
|
|
64
|
+
this.lifecycle = new DdcLifecycle(
|
|
65
|
+
root,
|
|
66
|
+
options.leaseTtlMs === undefined ? undefined : { leaseTtlMs: options.leaseTtlMs },
|
|
67
|
+
);
|
|
68
|
+
this.entries = new DdcEntryStore(root);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public async beginCandidate(guid: string, desiredKey: string): Promise<DdcGenerationCandidate> {
|
|
72
|
+
const head = await this.inspect(guid, desiredKey);
|
|
73
|
+
const lease = await this.lifecycle.begin(guid, desiredKey);
|
|
74
|
+
const candidate: MutableDdcGenerationCandidate = {
|
|
75
|
+
generation: this.generation,
|
|
76
|
+
lease,
|
|
77
|
+
previousHead: head,
|
|
78
|
+
};
|
|
79
|
+
this.candidates.set(lease.attempt, candidate);
|
|
80
|
+
this.scheduleHeartbeat(candidate);
|
|
81
|
+
return candidate;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public async stageEntry(entry: DdcEntry): Promise<DdcGenerationEntryCandidate> {
|
|
85
|
+
this.assertOpen();
|
|
86
|
+
const candidate = (await this.beginCandidate(
|
|
87
|
+
entry.guid,
|
|
88
|
+
entry.key,
|
|
89
|
+
)) as MutableDdcGenerationCandidate;
|
|
90
|
+
try {
|
|
91
|
+
const staged = await this.entries.stage(entry);
|
|
92
|
+
const entryCandidate: MutableDdcGenerationEntryCandidate = Object.assign(candidate, {
|
|
93
|
+
staged,
|
|
94
|
+
});
|
|
95
|
+
this.candidates.set(candidate.lease.attempt, entryCandidate);
|
|
96
|
+
return entryCandidate;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
await this.lifecycle.fail(candidate.lease, {
|
|
99
|
+
code: 'ddc-entry-stage-failed',
|
|
100
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
101
|
+
});
|
|
102
|
+
this.stopHeartbeat(candidate);
|
|
103
|
+
this.candidates.delete(candidate.lease.attempt);
|
|
104
|
+
this.counters.writeFailureCount += 1;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public async inspect(guid: string, desiredKey: string): Promise<DdcHead> {
|
|
110
|
+
this.assertOpen();
|
|
111
|
+
const head = await this.lifecycle.inspect(guid, desiredKey);
|
|
112
|
+
if (head.state === 'current') this.counters.hitCount += 1;
|
|
113
|
+
else this.counters.missCount += 1;
|
|
114
|
+
return head;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
public async commitCandidate(
|
|
118
|
+
candidate: DdcGenerationCandidate,
|
|
119
|
+
validatedKey: string,
|
|
120
|
+
): Promise<DdcCommitResult> {
|
|
121
|
+
const registered = this.assertCandidate(candidate);
|
|
122
|
+
try {
|
|
123
|
+
const result = await this.lifecycle.commit(registered.lease, validatedKey);
|
|
124
|
+
if (result.result === 'invalid') this.counters.corruptCount += 1;
|
|
125
|
+
this.stopHeartbeat(registered);
|
|
126
|
+
this.candidates.delete(registered.lease.attempt);
|
|
127
|
+
return result;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
this.stopHeartbeat(registered);
|
|
130
|
+
this.counters.writeFailureCount += 1;
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
public async commitEntry(
|
|
136
|
+
candidate: DdcGenerationEntryCandidate,
|
|
137
|
+
validatedKey: string,
|
|
138
|
+
): Promise<DdcCommitResult> {
|
|
139
|
+
const registered = this.assertCandidate(candidate);
|
|
140
|
+
try {
|
|
141
|
+
await this.entries.publish(candidate.staged);
|
|
142
|
+
const result = await this.lifecycle.commit(registered.lease, validatedKey);
|
|
143
|
+
this.stopHeartbeat(registered);
|
|
144
|
+
this.candidates.delete(registered.lease.attempt);
|
|
145
|
+
return result;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
this.stopHeartbeat(registered);
|
|
148
|
+
await this.lifecycle.fail(registered.lease, {
|
|
149
|
+
code: 'ddc-entry-publish-failed',
|
|
150
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
151
|
+
});
|
|
152
|
+
await this.entries.discard(candidate.staged).catch(() => {});
|
|
153
|
+
this.candidates.delete(registered.lease.attempt);
|
|
154
|
+
this.counters.writeFailureCount += 1;
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
public async discardCandidate(candidate: DdcGenerationCandidate): Promise<void> {
|
|
160
|
+
const registered = this.candidates.get(candidate.lease.attempt);
|
|
161
|
+
if (registered === undefined) return;
|
|
162
|
+
try {
|
|
163
|
+
this.stopHeartbeat(registered);
|
|
164
|
+
await this.lifecycle.discard(registered.lease);
|
|
165
|
+
this.candidates.delete(registered.lease.attempt);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
this.counters.writeFailureCount += 1;
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
public async discardEntry(candidate: DdcGenerationEntryCandidate): Promise<void> {
|
|
173
|
+
await Promise.all([this.discardCandidate(candidate), this.entries.discard(candidate.staged)]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
public async restoreEntry(candidate: DdcGenerationEntryCandidate): Promise<void> {
|
|
177
|
+
const registered = this.candidates.get(candidate.lease.attempt);
|
|
178
|
+
let restoreError: unknown;
|
|
179
|
+
try {
|
|
180
|
+
await this.lifecycle.restore(candidate.previousHead);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
restoreError = error;
|
|
183
|
+
} finally {
|
|
184
|
+
await this.entries.discard(candidate.staged).catch(() => {});
|
|
185
|
+
if (registered !== undefined) {
|
|
186
|
+
this.stopHeartbeat(registered);
|
|
187
|
+
this.candidates.delete(registered.lease.attempt);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (restoreError !== undefined) throw restoreError;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
public metrics(): DdcSessionMetrics {
|
|
194
|
+
return { ...this.counters };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
public async close(): Promise<void> {
|
|
198
|
+
if (!this.accepting) return;
|
|
199
|
+
this.accepting = false;
|
|
200
|
+
for (const timer of this.heartbeatTimers.values()) clearTimeout(timer);
|
|
201
|
+
this.heartbeatTimers.clear();
|
|
202
|
+
const pending = [...this.candidates.values()];
|
|
203
|
+
for (const candidate of pending) {
|
|
204
|
+
try {
|
|
205
|
+
await this.lifecycle.discard(candidate.lease);
|
|
206
|
+
} catch {
|
|
207
|
+
this.counters.writeFailureCount += 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
this.candidates.clear();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private assertOpen(): void {
|
|
214
|
+
if (!this.accepting) throw new Error('DDC generation session is closed');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private assertCandidate(candidate: DdcGenerationCandidate): MutableDdcGenerationCandidate {
|
|
218
|
+
this.assertOpen();
|
|
219
|
+
const registered = this.candidates.get(candidate.lease.attempt);
|
|
220
|
+
if (candidate.generation !== this.generation || registered === undefined) {
|
|
221
|
+
throw new Error('DDC candidate belongs to another generation session');
|
|
222
|
+
}
|
|
223
|
+
return registered;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private scheduleHeartbeat(candidate: MutableDdcGenerationCandidate): void {
|
|
227
|
+
const attempt = candidate.lease.attempt;
|
|
228
|
+
this.stopHeartbeat(candidate);
|
|
229
|
+
const delay = Math.max(1, Math.floor((candidate.lease.expiresAt - Date.now()) / 2));
|
|
230
|
+
const timer = setTimeout(() => {
|
|
231
|
+
this.heartbeatTimers.delete(attempt);
|
|
232
|
+
if (!this.accepting || this.candidates.get(attempt) !== candidate) return;
|
|
233
|
+
void this.lifecycle
|
|
234
|
+
.heartbeat(candidate.lease)
|
|
235
|
+
.then((lease) => {
|
|
236
|
+
if (!this.accepting || this.candidates.get(attempt) !== candidate) return;
|
|
237
|
+
candidate.lease = lease;
|
|
238
|
+
this.scheduleHeartbeat(candidate);
|
|
239
|
+
})
|
|
240
|
+
.catch(() => {
|
|
241
|
+
this.stopHeartbeat(candidate);
|
|
242
|
+
});
|
|
243
|
+
}, delay);
|
|
244
|
+
timer.unref?.();
|
|
245
|
+
this.heartbeatTimers.set(attempt, timer);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private stopHeartbeat(candidate: DdcGenerationCandidate): void {
|
|
249
|
+
const timer = this.heartbeatTimers.get(candidate.lease.attempt);
|
|
250
|
+
if (timer === undefined) return;
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
this.heartbeatTimers.delete(candidate.lease.attempt);
|
|
253
|
+
}
|
|
254
|
+
}
|