@forgeax/engine-pack 0.1.3 → 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.
Files changed (95) hide show
  1. package/README.md +51 -3
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/owner-chain.integration.test.d.ts +2 -0
  4. package/dist/__tests__/owner-chain.integration.test.d.ts.map +1 -0
  5. package/dist/__tests__/package-finalizer.contract.test.d.ts +2 -0
  6. package/dist/__tests__/package-finalizer.contract.test.d.ts.map +1 -0
  7. package/dist/__tests__/runtime.browser.test.d.ts +2 -0
  8. package/dist/__tests__/runtime.browser.test.d.ts.map +1 -0
  9. package/dist/__tests__/scanner-inventory.contract.test.d.ts +2 -0
  10. package/dist/__tests__/scanner-inventory.contract.test.d.ts.map +1 -0
  11. package/dist/artifact-path.mjs +53 -0
  12. package/dist/artifact-path.mjs.map +1 -0
  13. package/dist/atlas/shelf-pack.d.ts.map +1 -1
  14. package/dist/build.d.ts +17 -0
  15. package/dist/build.d.ts.map +1 -0
  16. package/dist/build.mjs +10168 -0
  17. package/dist/build.mjs.map +1 -0
  18. package/dist/builtin.d.ts +20 -0
  19. package/dist/builtin.d.ts.map +1 -1
  20. package/dist/builtin.mjs +141 -0
  21. package/dist/builtin.mjs.map +1 -0
  22. package/dist/catalog-builder.d.ts +43 -0
  23. package/dist/catalog-builder.d.ts.map +1 -0
  24. package/dist/catalog-delta.d.ts +11 -0
  25. package/dist/catalog-delta.d.ts.map +1 -0
  26. package/dist/catalog-projection.d.ts +91 -0
  27. package/dist/catalog-projection.d.ts.map +1 -0
  28. package/dist/cli-asset.mjs +231 -484
  29. package/dist/cli-asset.mjs.map +1 -1
  30. package/dist/index.mjs +83 -1
  31. package/dist/index.mjs.map +1 -1
  32. package/dist/material-cook.d.ts +2 -0
  33. package/dist/material-cook.d.ts.map +1 -0
  34. package/dist/material-cook.mjs +193 -0
  35. package/dist/material-cook.mjs.map +1 -0
  36. package/dist/mesh-bin-contract.mjs +96 -0
  37. package/dist/mesh-bin-contract.mjs.map +1 -0
  38. package/dist/native-cooker-registry.d.ts +19 -0
  39. package/dist/native-cooker-registry.d.ts.map +1 -1
  40. package/dist/native-cooker.mjs +37 -0
  41. package/dist/native-cooker.mjs.map +1 -1
  42. package/dist/package-finalizer.d.ts +121 -0
  43. package/dist/package-finalizer.d.ts.map +1 -0
  44. package/dist/resolve-asset-source.d.ts.map +1 -1
  45. package/dist/resolve-asset-source.mjs +16 -6
  46. package/dist/resolve-asset-source.mjs.map +1 -1
  47. package/dist/runtime-publication.d.ts +44 -0
  48. package/dist/runtime-publication.d.ts.map +1 -0
  49. package/dist/runtime.d.ts +8 -0
  50. package/dist/runtime.d.ts.map +1 -0
  51. package/dist/runtime.mjs +7261 -0
  52. package/dist/runtime.mjs.map +1 -0
  53. package/dist/scanner.d.ts +87 -54
  54. package/dist/scanner.d.ts.map +1 -1
  55. package/dist/scanner.mjs +258 -454
  56. package/dist/scanner.mjs.map +1 -1
  57. package/dist/scriptable-pack-node.d.ts +3 -8
  58. package/dist/scriptable-pack-node.d.ts.map +1 -1
  59. package/dist/scriptable-pack-node.mjs +93 -9
  60. package/dist/scriptable-pack-node.mjs.map +1 -1
  61. package/dist/scriptable-pack-worker.mjs +1 -0
  62. package/dist/scriptable-pack-worker.mjs.map +1 -1
  63. package/dist/scriptable-pack.d.ts +33 -0
  64. package/dist/scriptable-pack.d.ts.map +1 -1
  65. package/dist/scriptable-pack.mjs +91 -2
  66. package/dist/scriptable-pack.mjs.map +1 -1
  67. package/dist/topology.d.ts +1 -1
  68. package/dist/topology.d.ts.map +1 -1
  69. package/package.json +27 -2
  70. package/src/__tests__/mesh-bin-consumer-surface.unit.test.ts +2 -1
  71. package/src/__tests__/owner-chain.integration.test.ts +78 -0
  72. package/src/__tests__/package-finalizer.contract.test.ts +105 -0
  73. package/src/__tests__/runtime.browser.test.ts +15 -0
  74. package/src/__tests__/scanner-inventory.contract.test.ts +167 -0
  75. package/src/__tests__/scanner-inventory.test.ts +16 -12
  76. package/src/__tests__/scriptable-pack.unit.test.ts +42 -0
  77. package/src/atlas/shelf-pack.ts +20 -24
  78. package/src/build.ts +137 -0
  79. package/src/builtin.ts +38 -0
  80. package/src/catalog-builder.ts +313 -0
  81. package/src/catalog-delta.ts +189 -0
  82. package/src/catalog-projection.ts +344 -0
  83. package/src/cli-asset.ts +1 -5
  84. package/src/index.ts +2 -2
  85. package/src/material-cook.ts +13 -0
  86. package/src/native-cooker-registry.ts +72 -0
  87. package/src/package-finalizer.ts +394 -0
  88. package/src/resolve-asset-source.ts +2 -7
  89. package/src/runtime-publication.ts +190 -0
  90. package/src/runtime.ts +23 -0
  91. package/src/scanner.ts +289 -290
  92. package/src/scriptable-pack-node.ts +27 -23
  93. package/src/scriptable-pack-worker.ts +2 -0
  94. package/src/scriptable-pack.ts +123 -1
  95. package/src/topology.ts +2 -1
@@ -0,0 +1,344 @@
1
+ import type {
2
+ AssetAuthoringCapability,
3
+ AssetPublicationEnvelope,
4
+ AssetRelation,
5
+ CatalogDiagnostic,
6
+ CatalogEntryV2,
7
+ CatalogLifecycle,
8
+ CatalogSubject,
9
+ CookExecution,
10
+ PackIndexEntry,
11
+ ProducerContractDiagnostic,
12
+ ProducerContractResult,
13
+ ProviderProvenance,
14
+ ResourceRevision,
15
+ SourceOverrideDescriptor,
16
+ } from '@forgeax/engine-types';
17
+ import { authoringCapabilityForAssetKind, catalogOperationsFor } from '@forgeax/engine-types';
18
+ import { isScriptablePackAssetKind } from './scriptable-pack.js';
19
+
20
+ export function catalogProjectionFor(
21
+ subject: 'internal-asset' | 'imported-output',
22
+ execution: 'direct' | 'cooked',
23
+ lifecycle: 'missing' | 'cooking' | 'current' | 'stale' | 'failed',
24
+ ) {
25
+ return {
26
+ subject,
27
+ execution,
28
+ lifecycle,
29
+ operations: catalogOperationsFor({ subject, execution, lifecycle }),
30
+ } as const;
31
+ }
32
+
33
+ export function currentProjectionFor(
34
+ subject: 'internal-asset' | 'imported-output',
35
+ execution: 'direct' | 'cooked',
36
+ ) {
37
+ return catalogRowProjectionFor(subject, execution, 'current');
38
+ }
39
+
40
+ function catalogRowProjectionFor(
41
+ subject: 'internal-asset' | 'imported-output',
42
+ execution: 'direct' | 'cooked',
43
+ lifecycle: 'missing' | 'cooking' | 'current' | 'stale' | 'failed',
44
+ ) {
45
+ const projection = catalogProjectionFor(subject, execution, lifecycle);
46
+ return { ...projection, projection } as const;
47
+ }
48
+
49
+ export interface CatalogProducerMeta {
50
+ readonly schemaVersion: string | number;
51
+ readonly packageId?: string;
52
+ readonly provenance?: ProviderProvenance;
53
+ readonly revision?: ResourceRevision;
54
+ readonly diagnostics?: readonly CatalogDiagnostic[];
55
+ readonly provider?: string;
56
+ readonly importer?: string;
57
+ readonly sourceOverrides?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
58
+ readonly sourceOverrideDescriptors?: readonly SourceOverrideDescriptor[];
59
+ }
60
+
61
+ export interface CatalogOutputDeclaration {
62
+ readonly guid: string;
63
+ readonly kind: string;
64
+ readonly sourceKey?: string;
65
+ readonly sourceIndex?: number;
66
+ readonly relations?: PackIndexEntry['relations'];
67
+ readonly authoring?: AssetAuthoringCapability;
68
+ readonly refs?: readonly string[];
69
+ readonly name?: string;
70
+ }
71
+
72
+ function referenceRelations(
73
+ guid: string,
74
+ refs: readonly string[] | undefined,
75
+ provenance: ProviderProvenance,
76
+ ): readonly AssetRelation[] | undefined {
77
+ if (refs === undefined || refs.length === 0) return undefined;
78
+ return refs.map((ref) => ({
79
+ from: { type: 'asset', id: guid } as const,
80
+ to: { type: 'asset', id: ref } as const,
81
+ type: 'references' as const,
82
+ policy: { strength: 'required' as const },
83
+ provenance,
84
+ }));
85
+ }
86
+
87
+ function producerFields(
88
+ producer: CatalogProducerMeta,
89
+ output: CatalogOutputDeclaration,
90
+ ): Pick<
91
+ PackIndexEntry,
92
+ | 'packageId'
93
+ | 'provenance'
94
+ | 'revision'
95
+ | 'sourceKey'
96
+ | 'sourceIndex'
97
+ | 'sourceOverrides'
98
+ | 'sourceOverrideDescriptors'
99
+ | 'relations'
100
+ | 'diagnostics'
101
+ | 'subject'
102
+ | 'execution'
103
+ | 'lifecycle'
104
+ | 'projection'
105
+ | 'authoring'
106
+ > {
107
+ const provenance =
108
+ producer.provenance ??
109
+ ({
110
+ provider: producer.provider ?? producer.importer ?? 'engine',
111
+ version: String(producer.schemaVersion),
112
+ } satisfies ProviderProvenance);
113
+ const relations = output.relations ?? referenceRelations(output.guid, output.refs, provenance);
114
+ const inferred = authoringCapabilityForAssetKind(output.kind);
115
+ return {
116
+ provenance,
117
+ ...(producer.packageId === undefined ? {} : { packageId: producer.packageId }),
118
+ ...(producer.revision === undefined ? {} : { revision: producer.revision }),
119
+ ...(output.sourceKey === undefined ? {} : { sourceKey: output.sourceKey }),
120
+ ...(output.sourceIndex === undefined ? {} : { sourceIndex: output.sourceIndex }),
121
+ ...(producer.sourceOverrides === undefined
122
+ ? {}
123
+ : { sourceOverrides: producer.sourceOverrides }),
124
+ ...(producer.sourceOverrideDescriptors === undefined || output.sourceKey === undefined
125
+ ? {}
126
+ : {
127
+ sourceOverrideDescriptors: producer.sourceOverrideDescriptors.filter(
128
+ (descriptor) => descriptor.sourceKey === output.sourceKey,
129
+ ),
130
+ }),
131
+ ...(relations === undefined ? {} : { relations }),
132
+ ...(producer.diagnostics === undefined ? {} : { diagnostics: producer.diagnostics }),
133
+ ...(output.authoring === undefined && inferred.ui === undefined
134
+ ? {}
135
+ : { authoring: output.authoring ?? inferred }),
136
+ ...catalogRowProjectionFor('imported-output', 'cooked', 'missing'),
137
+ };
138
+ }
139
+
140
+ /** Project producer declarations without interpreting a provider's kind vocabulary. */
141
+ export function projectExternalCatalogEntries(
142
+ producer: CatalogProducerMeta,
143
+ sourcePath: string,
144
+ packageUrl: string,
145
+ outputs: readonly CatalogOutputDeclaration[],
146
+ nameFor?: (output: CatalogOutputDeclaration, index: number) => string | undefined,
147
+ ): PackIndexEntry[] {
148
+ return outputs.map((output, index) => ({
149
+ guid: output.guid,
150
+ packageUrl,
151
+ kind: output.kind,
152
+ sourcePath,
153
+ ...producerFields(producer, output),
154
+ ...((nameFor?.(output, index) ?? output.name) === undefined
155
+ ? {}
156
+ : { name: nameFor?.(output, index) ?? output.name }),
157
+ }));
158
+ }
159
+
160
+ /** Return the first declaration that would shadow an engine-owned Pack kind. */
161
+ export function findReservedAssetKindConflict(
162
+ outputs: readonly Pick<CatalogOutputDeclaration, 'kind'>[],
163
+ ): Pick<CatalogOutputDeclaration, 'kind'> | undefined {
164
+ return outputs.find((output) => isScriptablePackAssetKind(output.kind));
165
+ }
166
+
167
+ export interface PackageCatalogInput {
168
+ readonly guid: string;
169
+ readonly kind: string;
170
+ readonly sourcePath: string;
171
+ readonly name?: string;
172
+ readonly refs?: readonly string[];
173
+ readonly authoring?: AssetAuthoringCapability;
174
+ readonly execution?: 'direct' | 'cooked';
175
+ readonly packageId?: string;
176
+ readonly provenance?: ProviderProvenance;
177
+ readonly revision?: ResourceRevision;
178
+ readonly sourceKey?: string;
179
+ readonly sourceIndex?: number;
180
+ readonly sourceOverrides?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
181
+ readonly sourceOverrideDescriptors?: readonly SourceOverrideDescriptor[];
182
+ readonly relations?: PackIndexEntry['relations'];
183
+ readonly diagnostics?: readonly CatalogDiagnostic[];
184
+ readonly cookReceiptUrl?: string;
185
+ }
186
+
187
+ /** Project self-contained Pack rows into the same Catalog contract as producer output. */
188
+ export function projectPackageCatalog(
189
+ entries: readonly PackageCatalogInput[],
190
+ packageUrl: string,
191
+ ): PackIndexEntry[] {
192
+ return entries.map((entry) => {
193
+ const execution = entry.execution ?? 'direct';
194
+ const lifecycle = execution === 'cooked' ? 'missing' : 'current';
195
+ const inferred = authoringCapabilityForAssetKind(entry.kind);
196
+ const provenance =
197
+ entry.provenance ?? ({ provider: 'pack', version: 'unknown' } satisfies ProviderProvenance);
198
+ const relations = entry.relations ?? referenceRelations(entry.guid, entry.refs, provenance);
199
+ return {
200
+ guid: entry.guid,
201
+ kind: entry.kind,
202
+ sourcePath: entry.sourcePath,
203
+ packageUrl,
204
+ ...(entry.name === undefined ? {} : { name: entry.name }),
205
+ ...(entry.refs === undefined ? {} : { refs: entry.refs }),
206
+ ...(entry.packageId === undefined ? {} : { packageId: entry.packageId }),
207
+ ...(entry.provenance === undefined ? {} : { provenance: entry.provenance }),
208
+ ...(entry.revision === undefined ? {} : { revision: entry.revision }),
209
+ ...(entry.sourceKey === undefined ? {} : { sourceKey: entry.sourceKey }),
210
+ ...(entry.sourceIndex === undefined ? {} : { sourceIndex: entry.sourceIndex }),
211
+ ...(entry.sourceOverrides === undefined ? {} : { sourceOverrides: entry.sourceOverrides }),
212
+ ...(entry.sourceOverrideDescriptors === undefined
213
+ ? {}
214
+ : { sourceOverrideDescriptors: entry.sourceOverrideDescriptors }),
215
+ ...(relations === undefined ? {} : { relations }),
216
+ ...(entry.diagnostics === undefined ? {} : { diagnostics: entry.diagnostics }),
217
+ ...(entry.cookReceiptUrl === undefined ? {} : { cookReceiptUrl: entry.cookReceiptUrl }),
218
+ ...(entry.authoring === undefined && inferred.ui === undefined
219
+ ? {}
220
+ : { authoring: entry.authoring ?? inferred }),
221
+ ...catalogRowProjectionFor('internal-asset', execution, lifecycle),
222
+ };
223
+ });
224
+ }
225
+
226
+ export interface CookedPackageProjection {
227
+ readonly packageUrl: string;
228
+ readonly revision: ResourceRevision;
229
+ readonly refs: readonly string[];
230
+ readonly cookReceiptUrl?: string;
231
+ readonly publication?: AssetPublicationEnvelope;
232
+ }
233
+
234
+ /** Project one complete cooked package; navigation facts stay in the Pack owner. */
235
+ export function projectCookedPackageEntry(
236
+ entry: PackIndexEntry,
237
+ projection: CookedPackageProjection,
238
+ ): PackIndexEntry {
239
+ return {
240
+ ...entry,
241
+ ...(projection.publication === undefined
242
+ ? {}
243
+ : { sourcePath: projection.publication.sourcePath }),
244
+ packageUrl: projection.packageUrl,
245
+ ...(projection.cookReceiptUrl === undefined
246
+ ? {}
247
+ : { cookReceiptUrl: projection.cookReceiptUrl }),
248
+ revision: projection.revision,
249
+ ...(projection.publication === undefined ? {} : { publication: projection.publication }),
250
+ ...currentProjectionFor('imported-output', 'cooked'),
251
+ refs: projection.refs,
252
+ };
253
+ }
254
+
255
+ export interface RuntimeCatalogRowInput {
256
+ readonly scopeId: string;
257
+ readonly generation: number;
258
+ readonly digest: string;
259
+ readonly outputSetDigest: string;
260
+ readonly guid: string;
261
+ readonly kind: string;
262
+ readonly packageUrl: string;
263
+ readonly sourcePath?: string;
264
+ readonly subject?: CatalogSubject;
265
+ readonly execution?: CookExecution;
266
+ readonly lifecycle?: CatalogLifecycle;
267
+ readonly publication?: unknown;
268
+ }
269
+
270
+ function runtimeCatalogRowError(
271
+ subjectId: string,
272
+ expected: string,
273
+ ): ProducerContractResult<never> {
274
+ const diagnostic: ProducerContractDiagnostic = {
275
+ code: 'invalid-producer-fact',
276
+ subject: { type: 'asset', id: subjectId },
277
+ expected,
278
+ hint: 'recook the source and publish one current runtime Catalog row',
279
+ authority: 'producer',
280
+ };
281
+ return { ok: false, error: diagnostic };
282
+ }
283
+
284
+ function projectRuntimeCatalogRowValue(
285
+ input: RuntimeCatalogRowInput,
286
+ ): ProducerContractResult<CatalogEntryV2> {
287
+ if (
288
+ input.publication !== undefined ||
289
+ input.guid.length === 0 ||
290
+ input.kind.length === 0 ||
291
+ input.packageUrl.length === 0 ||
292
+ input.scopeId.length === 0 ||
293
+ !Number.isInteger(input.generation) ||
294
+ input.generation < 1 ||
295
+ input.digest.length === 0 ||
296
+ input.outputSetDigest.length === 0
297
+ ) {
298
+ return runtimeCatalogRowError(
299
+ input.guid,
300
+ 'one flat publication tuple and no legacy publication object',
301
+ );
302
+ }
303
+
304
+ const subject = input.subject ?? 'imported-output';
305
+ const execution = input.execution ?? 'cooked';
306
+ const lifecycle = input.lifecycle ?? 'current';
307
+ return {
308
+ ok: true,
309
+ value: {
310
+ scopeId: input.scopeId,
311
+ generation: input.generation,
312
+ digest: input.digest,
313
+ outputSetDigest: input.outputSetDigest,
314
+ guid: input.guid,
315
+ kind: input.kind,
316
+ packageUrl: input.packageUrl,
317
+ sourcePath: input.sourcePath ?? input.packageUrl,
318
+ subject,
319
+ execution,
320
+ lifecycle,
321
+ projection: {
322
+ subject,
323
+ execution,
324
+ lifecycle,
325
+ operations: catalogOperationsFor({ subject, execution, lifecycle }),
326
+ },
327
+ },
328
+ };
329
+ }
330
+
331
+ /** Project one row; an array is accepted only to fail closed on double rows. */
332
+ export function projectRuntimeCatalogRow(
333
+ input: RuntimeCatalogRowInput | readonly RuntimeCatalogRowInput[],
334
+ ): ProducerContractResult<CatalogEntryV2> {
335
+ if (Array.isArray(input)) {
336
+ if (input.length !== 1) {
337
+ return runtimeCatalogRowError(input[0]?.guid ?? 'unknown', 'exactly one runtime row');
338
+ }
339
+ const [row] = input;
340
+ if (row === undefined) return runtimeCatalogRowError('unknown', 'exactly one runtime row');
341
+ return projectRuntimeCatalogRowValue(row);
342
+ }
343
+ return projectRuntimeCatalogRowValue(input as RuntimeCatalogRowInput);
344
+ }
package/src/cli-asset.ts CHANGED
@@ -27,7 +27,7 @@ import { readSourceInventory } from './evidence/source-inventory.js';
27
27
  import { isValidAssetGuidString } from './guid.js';
28
28
  import { parsePackV2 } from './index.js';
29
29
  import { scan } from './scanner.js';
30
- import { projectScriptablePackMeta } from './scriptable-pack.js';
30
+ import { isRecord, projectScriptablePackMeta } from './scriptable-pack.js';
31
31
  import { loadScriptablePack } from './scriptable-pack-node.js';
32
32
 
33
33
  export interface PackEntry {
@@ -453,10 +453,6 @@ function catalogRows(value: unknown): readonly Record<string, unknown>[] | undef
453
453
  return value.entries.filter(isRecord);
454
454
  }
455
455
 
456
- function isRecord(value: unknown): value is Record<string, unknown> {
457
- return value !== null && typeof value === 'object' && !Array.isArray(value);
458
- }
459
-
460
456
  function packagePath(projectRoot: string, packageUrl: string): string {
461
457
  const relative = packageUrl.replace(/^\/+/, '');
462
458
  return resolve(projectRoot, relative);
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // @forgeax/engine-pack
2
- // Disk schema, GUID tools, scanner, fail-fast error chain, and CLI bin.
3
- // Entry subpaths: . / schema / guid / errors / bridge / scanner
2
+ // Disk schema, GUID tools, and browser-safe asset contracts.
3
+ // Node-only catalog/build APIs live under @forgeax/engine-pack/build.
4
4
 
5
5
  export {
6
6
  type CookedMaterialRecord,
@@ -0,0 +1,13 @@
1
+ export {
2
+ type CookedMaterialRecord,
3
+ collectMaterialCookRefs,
4
+ createMaterialArtifactDigest,
5
+ type MaterialCookArtifact,
6
+ type MaterialCookReceipt,
7
+ type MaterialCookRecordError,
8
+ type MaterialCookRefs,
9
+ projectCookedMaterialRecord,
10
+ serializeCookedMaterialRecord,
11
+ serializeMaterialCookReceipt,
12
+ validateCookedMaterialRecord,
13
+ } from './evidence/material-cook.js';
@@ -28,6 +28,27 @@ export interface NativeCooker<P = unknown, I = unknown> {
28
28
  cook(input: I): NativeCookDraft<P> | Promise<NativeCookDraft<P>>;
29
29
  }
30
30
 
31
+ export interface NativeCookTransactionSnapshot<P = unknown> {
32
+ readonly draft: NativeCookDraft<P>;
33
+ readonly generation: number;
34
+ }
35
+
36
+ export interface NativeCookTransactionOptions<P = unknown, I = unknown> {
37
+ readonly key: string;
38
+ readonly input: I;
39
+ readonly previous?: NativeCookTransactionSnapshot<P>;
40
+ readonly validate?: (draft: NativeCookDraft<P>) => string | undefined;
41
+ readonly publish?: (draft: NativeCookDraft<P>, generation: number) => void | Promise<void>;
42
+ }
43
+
44
+ export interface NativeCookTransactionResult<P = unknown> extends NativeCookTransactionSnapshot<P> {
45
+ readonly status: 'committed' | 'recovered';
46
+ readonly lastKnownGood: NativeCookDraft<P>;
47
+ readonly candidateGeneration: number;
48
+ readonly lastKnownGoodGeneration: number;
49
+ readonly recoveryHint?: string;
50
+ }
51
+
31
52
  function canonicalize(value: unknown): unknown {
32
53
  if (value instanceof Uint8Array) return Buffer.from(value).toString('base64');
33
54
  if (Array.isArray(value)) return value.map(canonicalize);
@@ -67,6 +88,24 @@ function failure(key: string, detail: NativeCookErrorDetail): Result<never, Asse
67
88
  });
68
89
  }
69
90
 
91
+ function transactionFailure(key: string, reason: string): Result<never, AssetStageError> {
92
+ return failure(key, { guid: 'unknown', producer: reason });
93
+ }
94
+
95
+ function recovered<P>(
96
+ key: string,
97
+ previous: NativeCookTransactionSnapshot<P>,
98
+ ): NativeCookTransactionResult<P> {
99
+ return {
100
+ ...previous,
101
+ status: 'recovered',
102
+ lastKnownGood: previous.draft,
103
+ candidateGeneration: previous.generation + 1,
104
+ lastKnownGoodGeneration: previous.generation,
105
+ recoveryHint: `repair ${key} and submit a new candidate generation`,
106
+ };
107
+ }
108
+
70
109
  /** Injectable build-time table for native authored Pack producers. */
71
110
  export class NativeCookerRegistry {
72
111
  private readonly cookers = new Map<string, NativeCooker>();
@@ -160,4 +199,37 @@ export class NativeCookerRegistry {
160
199
  };
161
200
  return ok(product);
162
201
  }
202
+
203
+ async runTransaction<P = unknown, I = unknown>(
204
+ options: NativeCookTransactionOptions<P, I>,
205
+ ): Promise<Result<NativeCookTransactionResult<P>, AssetStageError>> {
206
+ const candidate = await this.runDraft<P, I>(options.key, options.input);
207
+ if (!candidate.ok) {
208
+ return options.previous === undefined
209
+ ? candidate
210
+ : ok(recovered(options.key, options.previous));
211
+ }
212
+ const invalidReason = options.validate?.(candidate.value);
213
+ if (invalidReason !== undefined) {
214
+ return options.previous === undefined
215
+ ? transactionFailure(options.key, invalidReason)
216
+ : ok(recovered(options.key, options.previous));
217
+ }
218
+ const generation = (options.previous?.generation ?? 0) + 1;
219
+ try {
220
+ await options.publish?.(candidate.value, generation);
221
+ } catch {
222
+ return options.previous === undefined
223
+ ? transactionFailure(options.key, 'atomic producer publication failed')
224
+ : ok(recovered(options.key, options.previous));
225
+ }
226
+ return ok({
227
+ draft: candidate.value,
228
+ generation,
229
+ status: 'committed',
230
+ lastKnownGood: candidate.value,
231
+ candidateGeneration: generation,
232
+ lastKnownGoodGeneration: generation,
233
+ });
234
+ }
163
235
  }