@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,394 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { AssetCodec, AssetRelation, CookReceipt } from '@forgeax/engine-types';
3
+
4
+ export interface PackageArtifactBody {
5
+ readonly mediaType: string;
6
+ readonly assetCodec?: AssetCodec;
7
+ readonly bytes: Uint8Array;
8
+ }
9
+
10
+ export interface PackageProductAsset {
11
+ readonly guid: string;
12
+ readonly kind: string;
13
+ readonly name?: string;
14
+ readonly payload: Readonly<Record<string, unknown>>;
15
+ readonly refs: readonly string[];
16
+ readonly artifacts: Readonly<Record<string, PackageArtifactBody>>;
17
+ }
18
+
19
+ export interface PackageProduct {
20
+ readonly assets: readonly PackageProductAsset[];
21
+ readonly receipts: readonly CookReceipt[];
22
+ readonly diagnostics: readonly unknown[];
23
+ readonly sourceRevision: string;
24
+ readonly sourceKey?: string;
25
+ }
26
+
27
+ /** Minimal authored Pack v1/v2 shape consumed by the file transport seam. */
28
+ export interface AuthoredPackAssetInput {
29
+ readonly guid: string;
30
+ readonly kind: string;
31
+ readonly name?: string;
32
+ readonly execution?: 'direct' | 'cooked';
33
+ readonly sourceKey?: string;
34
+ readonly sourceIndex?: number;
35
+ readonly relations?: readonly AssetRelation[];
36
+ readonly payload: Record<string, unknown>;
37
+ readonly refs?: readonly string[];
38
+ readonly artifacts?: Readonly<Record<string, unknown>>;
39
+ }
40
+
41
+ export interface AuthoredPackInput {
42
+ readonly schemaVersion?: string;
43
+ readonly assets?: readonly AuthoredPackAssetInput[];
44
+ }
45
+
46
+ export interface PackageDocumentArtifact {
47
+ readonly path: string;
48
+ readonly mediaType: string;
49
+ readonly assetCodec?: AssetCodec;
50
+ readonly byteLength: number;
51
+ readonly integrity: { readonly algorithm: 'sha256'; readonly digest: string };
52
+ }
53
+
54
+ export interface PackageDocumentAsset {
55
+ readonly guid: string;
56
+ readonly kind: string;
57
+ readonly name?: string;
58
+ readonly payload: Record<string, unknown>;
59
+ readonly refs: readonly string[];
60
+ readonly artifacts: Readonly<Record<string, PackageDocumentArtifact>>;
61
+ }
62
+
63
+ export interface PackageDocument {
64
+ readonly schemaVersion: '2.0.0';
65
+ readonly kind: 'internal-text-package';
66
+ readonly assets: readonly PackageDocumentAsset[];
67
+ }
68
+
69
+ /** Upgrade the legacy authored Pack envelope at the Pack schema boundary. */
70
+ export function upgradeLegacyAuthoredPack(pack: AuthoredPackInput): AuthoredPackInput {
71
+ if (pack.schemaVersion !== '1.0.0' || pack.assets === undefined) return pack;
72
+ return {
73
+ ...pack,
74
+ schemaVersion: '2.0.0',
75
+ assets: pack.assets.map((asset) => {
76
+ const fontRefs =
77
+ asset.kind === 'font'
78
+ ? [asset.payload.atlasGuid, asset.payload.samplerGuid].filter(
79
+ (guid): guid is string => typeof guid === 'string',
80
+ )
81
+ : [];
82
+ return {
83
+ ...asset,
84
+ refs: asset.refs ?? fontRefs,
85
+ artifacts: asset.artifacts ?? {},
86
+ };
87
+ }),
88
+ };
89
+ }
90
+
91
+ export interface PackageFinalizePolicy {
92
+ readonly base: string;
93
+ readonly packagePath: string;
94
+ readonly artifactPath: (guid: string, localKey: string) => string;
95
+ readonly sink?: (path: string, bytes: Uint8Array) => void | Promise<void>;
96
+ }
97
+
98
+ export interface FinalizedPackageProduct {
99
+ readonly pack: PackageDocument;
100
+ readonly packageUrl: string;
101
+ readonly digest: string;
102
+ readonly receipts: readonly CookReceipt[];
103
+ readonly diagnostics: readonly unknown[];
104
+ readonly sourceRevision: string;
105
+ readonly sourceKey?: string;
106
+ }
107
+
108
+ interface PackageTransportSource {
109
+ readonly assets: readonly PackageProduct['assets'][number][];
110
+ readonly receipts?: readonly CookReceipt[];
111
+ readonly diagnostics?: readonly unknown[];
112
+ readonly sourceKey?: string;
113
+ }
114
+
115
+ export interface PackageFinalizerError {
116
+ readonly code:
117
+ | 'duplicate-guid'
118
+ | 'missing-source-revision'
119
+ | 'missing-receipt'
120
+ | 'invalid-artifact';
121
+ readonly expected: string;
122
+ readonly hint: string;
123
+ readonly detail: { readonly guid?: string; readonly key?: string };
124
+ }
125
+
126
+ export type PackageFinalizeResult =
127
+ | { readonly ok: true; readonly value: FinalizedPackageProduct }
128
+ | { readonly ok: false; readonly error: PackageFinalizerError };
129
+
130
+ function failure(
131
+ code: PackageFinalizerError['code'],
132
+ expected: string,
133
+ hint: string,
134
+ detail: PackageFinalizerError['detail'],
135
+ ): PackageFinalizeResult {
136
+ return { ok: false, error: { code, expected, hint, detail } };
137
+ }
138
+
139
+ function sorted(value: unknown): unknown {
140
+ if (value instanceof Uint8Array) return Buffer.from(value).toString('base64');
141
+ if (Array.isArray(value)) return value.map(sorted);
142
+ if (value !== null && typeof value === 'object') {
143
+ const output: Record<string, unknown> = {};
144
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
145
+ output[key] = sorted((value as Record<string, unknown>)[key]);
146
+ }
147
+ return output;
148
+ }
149
+ return value;
150
+ }
151
+
152
+ function packageUrl(base: string, packagePath: string): string {
153
+ const prefix = base === '/' ? '' : `/${base.replace(/^\/+|\/+$/g, '')}`;
154
+ return `${prefix}/${packagePath.replace(/^\/+/, '')}`;
155
+ }
156
+
157
+ function safePath(path: string): boolean {
158
+ const normalized = path.replaceAll('\\', '/');
159
+ return (
160
+ normalized.length > 0 &&
161
+ !normalized.startsWith('/') &&
162
+ !/^[A-Za-z]:\//.test(normalized) &&
163
+ !normalized.split('/').includes('..')
164
+ );
165
+ }
166
+
167
+ function digest(assets: readonly PackageProductAsset[]): string {
168
+ const pack = { schemaVersion: '2.0.0', kind: 'internal-text-package', assets } as const;
169
+ const hash = createHash('sha256').update(
170
+ JSON.stringify(
171
+ sorted({
172
+ ...pack,
173
+ assets: pack.assets.map((asset) => ({
174
+ ...asset,
175
+ artifacts: Object.fromEntries(
176
+ Object.entries(asset.artifacts).map(([key, artifact]) => [
177
+ key,
178
+ {
179
+ mediaType: artifact.mediaType,
180
+ ...(artifact.assetCodec === undefined ? {} : { assetCodec: artifact.assetCodec }),
181
+ byteLength: artifact.bytes.byteLength,
182
+ },
183
+ ]),
184
+ ),
185
+ })),
186
+ }),
187
+ ),
188
+ );
189
+ for (const [key, artifact] of assets
190
+ .flatMap((asset) =>
191
+ Object.entries(asset.artifacts).map(
192
+ ([localKey, body]) => [`${asset.guid}/${localKey}`, body] as const,
193
+ ),
194
+ )
195
+ .sort(([left], [right]) => left.localeCompare(right))) {
196
+ hash.update(key).update(artifact.bytes);
197
+ }
198
+ return `sha256:${hash.digest('hex')}`;
199
+ }
200
+
201
+ function packageDocument(
202
+ assets: readonly PackageProductAsset[],
203
+ artifactPath: (guid: string, localKey: string) => string,
204
+ ): PackageDocument {
205
+ return {
206
+ schemaVersion: '2.0.0',
207
+ kind: 'internal-text-package',
208
+ assets: assets.map((asset) => ({
209
+ guid: asset.guid,
210
+ kind: asset.kind,
211
+ ...(asset.name === undefined ? {} : { name: asset.name }),
212
+ payload: asset.payload,
213
+ refs: asset.refs,
214
+ artifacts: Object.fromEntries(
215
+ Object.entries(asset.artifacts).map(([key, artifact]) => [
216
+ key,
217
+ {
218
+ path: artifactPath(asset.guid, key),
219
+ mediaType: artifact.mediaType,
220
+ ...(artifact.assetCodec === undefined ? {} : { assetCodec: artifact.assetCodec }),
221
+ contentEncoding: 'identity' as const,
222
+ byteLength: artifact.bytes.byteLength,
223
+ integrity: {
224
+ algorithm: 'sha256' as const,
225
+ digest: `sha256:${createHash('sha256').update(artifact.bytes).digest('hex')}`,
226
+ },
227
+ },
228
+ ]),
229
+ ),
230
+ })),
231
+ };
232
+ }
233
+
234
+ function transportArtifacts(
235
+ assets: readonly PackageProductAsset[],
236
+ artifactPath: PackageFinalizePolicy['artifactPath'],
237
+ ) {
238
+ return assets.flatMap((asset) =>
239
+ Object.entries(asset.artifacts).flatMap(([localKey, artifact]) => [
240
+ {
241
+ guid: asset.guid,
242
+ localKey,
243
+ path: artifactPath(asset.guid, localKey),
244
+ mediaType: artifact.mediaType,
245
+ ...(artifact.assetCodec === undefined ? {} : { assetCodec: artifact.assetCodec }),
246
+ bytes: artifact.bytes,
247
+ },
248
+ ]),
249
+ );
250
+ }
251
+
252
+ /** Finalize a complete producer product without owning producer-specific policy. */
253
+ export async function finalizePackageProduct(
254
+ product: PackageProduct,
255
+ policy: PackageFinalizePolicy,
256
+ ): Promise<PackageFinalizeResult> {
257
+ if (product.sourceRevision.trim().length === 0) {
258
+ return failure(
259
+ 'missing-source-revision',
260
+ 'a non-empty source revision',
261
+ 'rebuild the inventory and provide the producer source revision',
262
+ {},
263
+ );
264
+ }
265
+ const seen = new Set<string>();
266
+ for (const asset of product.assets) {
267
+ const guid = asset.guid.toLowerCase();
268
+ if (seen.has(guid)) {
269
+ return failure(
270
+ 'duplicate-guid',
271
+ 'one terminal product row per GUID',
272
+ 'repair the inventory or producer output before finalization',
273
+ { guid },
274
+ );
275
+ }
276
+ seen.add(guid);
277
+ for (const [key, artifact] of Object.entries(asset.artifacts)) {
278
+ if (!safePath(key) || !(artifact.bytes instanceof Uint8Array)) {
279
+ return failure(
280
+ 'invalid-artifact',
281
+ 'asset-local artifact keys and byte bodies',
282
+ 'repair the producer artifact before finalization',
283
+ { guid, key },
284
+ );
285
+ }
286
+ }
287
+ }
288
+ for (const guid of seen) {
289
+ if (!product.receipts.some((receipt) => receipt.guid.toLowerCase() === guid)) {
290
+ return failure(
291
+ 'missing-receipt',
292
+ 'one receipt for every terminal product GUID',
293
+ 'preserve the producer receipt when handing the product to engine-pack',
294
+ { guid },
295
+ );
296
+ }
297
+ }
298
+ const assets = [...product.assets].sort((left, right) => left.guid.localeCompare(right.guid));
299
+ const document = packageDocument(assets, policy.artifactPath);
300
+ const packageBytes = new TextEncoder().encode(JSON.stringify(sorted(document)));
301
+ if (policy.sink !== undefined) {
302
+ for (const asset of assets) {
303
+ for (const [key, artifact] of Object.entries(asset.artifacts)) {
304
+ const path = policy.artifactPath(asset.guid, key);
305
+ if (!safePath(path)) {
306
+ return failure(
307
+ 'invalid-artifact',
308
+ 'package-relative artifact output paths',
309
+ 'repair the finalizer artifact path policy',
310
+ { guid: asset.guid, key },
311
+ );
312
+ }
313
+ await policy.sink(path, artifact.bytes);
314
+ }
315
+ }
316
+ if (!safePath(policy.packagePath)) {
317
+ return failure(
318
+ 'invalid-artifact',
319
+ 'a package-relative package path',
320
+ 'repair the finalizer package path policy',
321
+ { key: policy.packagePath },
322
+ );
323
+ }
324
+ await policy.sink(policy.packagePath, packageBytes);
325
+ }
326
+ return {
327
+ ok: true,
328
+ value: {
329
+ pack: document,
330
+ packageUrl: packageUrl(policy.base, policy.packagePath),
331
+ digest: digest(product.assets),
332
+ receipts: product.receipts,
333
+ diagnostics: product.diagnostics,
334
+ sourceRevision: product.sourceRevision,
335
+ ...(product.sourceKey === undefined ? {} : { sourceKey: product.sourceKey }),
336
+ },
337
+ };
338
+ }
339
+
340
+ /** Finalize a product-shaped source with the deterministic transport revision. */
341
+ export async function finalizePackageTransportSource(
342
+ input: PackageTransportSource,
343
+ policy: PackageFinalizePolicy,
344
+ ) {
345
+ const sourceRevision = packageTransportRevision(input);
346
+ const product: PackageProduct = {
347
+ assets: input.assets,
348
+ receipts:
349
+ input.receipts ??
350
+ input.assets.map((asset) => ({
351
+ guid: asset.guid,
352
+ origin: 'sourceMeta' as const,
353
+ status: 'succeeded' as const,
354
+ inputFingerprint: sourceRevision,
355
+ })),
356
+ diagnostics: input.diagnostics ?? [],
357
+ sourceRevision,
358
+ ...(input.sourceKey === undefined ? {} : { sourceKey: input.sourceKey }),
359
+ };
360
+ const finalized = await finalizePackageProduct(product, policy);
361
+ if (!finalized.ok) throw finalized.error;
362
+ return {
363
+ pack: finalized.value.pack,
364
+ packageUrl: finalized.value.packageUrl,
365
+ artifacts: transportArtifacts(product.assets, policy.artifactPath),
366
+ digest: finalized.value.digest,
367
+ sourceRevision,
368
+ semantic: JSON.stringify(sorted(finalized.value.pack)),
369
+ };
370
+ }
371
+
372
+ function revisionStable(value: unknown): unknown {
373
+ if (value instanceof Uint8Array) {
374
+ return {
375
+ byteLength: value.byteLength,
376
+ digest: createHash('sha256').update(value).digest('hex'),
377
+ };
378
+ }
379
+ if (Array.isArray(value)) return value.map(revisionStable);
380
+ if (value !== null && typeof value === 'object') {
381
+ return Object.fromEntries(
382
+ Object.entries(value as Record<string, unknown>)
383
+ .sort(([left], [right]) => left.localeCompare(right))
384
+ .map(([key, item]) => [key, revisionStable(item)]),
385
+ );
386
+ }
387
+ return value;
388
+ }
389
+
390
+ export function packageTransportRevision(value: unknown): string {
391
+ return createHash('sha256')
392
+ .update(JSON.stringify(revisionStable(value)))
393
+ .digest('hex');
394
+ }
@@ -1,14 +1,9 @@
1
1
  import { dirname, posix, resolve } from 'node:path';
2
2
  import type { Result } from '@forgeax/engine-types';
3
3
  import { PACK_ERROR_HINTS, err as resultErr, ok as resultOk } from '@forgeax/engine-types';
4
+ import { deriveAssetName } from './deriveAssetName.js';
4
5
  import { PackError } from './errors.js';
5
6
 
6
- function baseName(p: string): string {
7
- const trimmed = p.replace(/[/\\]+$/, '');
8
- const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
9
- return idx === -1 ? trimmed : trimmed.slice(idx + 1);
10
- }
11
-
12
7
  function deriveSourceName(metaFileName: string): string {
13
8
  return metaFileName.replace(/\.meta\.json$/, '');
14
9
  }
@@ -27,7 +22,7 @@ export function resolveAssetSource(
27
22
  ): Result<string, PackError> {
28
23
  if (source === undefined) {
29
24
  const metaDir = dirname(metaPath);
30
- const metaName = baseName(metaPath);
25
+ const metaName = deriveAssetName(metaPath, 1);
31
26
  const derived = deriveSourceName(metaName);
32
27
  return resultOk(resolve(metaDir, derived));
33
28
  }
@@ -0,0 +1,190 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type {
3
+ AssetPublicationEnvelope,
4
+ AssetPublicationExternalEvidence,
5
+ AssetPublicationOutput,
6
+ } from '@forgeax/engine-types';
7
+
8
+ export interface RuntimePackAssetInput {
9
+ readonly guid: string;
10
+ readonly kind: string;
11
+ readonly name?: string;
12
+ readonly payload: unknown;
13
+ readonly refs?: readonly string[];
14
+ readonly artifacts?: Readonly<Record<string, unknown>>;
15
+ }
16
+
17
+ export interface RuntimePackInput {
18
+ readonly assets: readonly RuntimePackAssetInput[];
19
+ }
20
+
21
+ export interface RuntimePackEnvelope {
22
+ readonly schemaVersion: '2.0.0';
23
+ readonly kind: 'internal-text-package';
24
+ readonly scopeId: string;
25
+ readonly generation: number;
26
+ readonly digest: string;
27
+ readonly outputSetDigest: string;
28
+ readonly assets: readonly RuntimePackAsset[];
29
+ }
30
+
31
+ export interface RuntimePackPublication {
32
+ readonly pack: RuntimePackEnvelope;
33
+ readonly publication: AssetPublicationEnvelope;
34
+ }
35
+
36
+ interface RuntimePackAsset extends RuntimePackAssetInput {
37
+ readonly refs: readonly string[];
38
+ readonly artifacts: Readonly<Record<string, unknown>>;
39
+ }
40
+
41
+ export interface RuntimePackPublicationInput {
42
+ readonly pack: RuntimePackInput;
43
+ readonly scopeId: string;
44
+ readonly sourcePath: string;
45
+ readonly sourceRevision: string;
46
+ readonly packageUrl: string;
47
+ readonly inputFingerprint?: string;
48
+ readonly digest?: string;
49
+ readonly generation?: number;
50
+ readonly outputs?: readonly AssetPublicationOutput[];
51
+ readonly externalEvidence?: readonly AssetPublicationExternalEvidence[];
52
+ }
53
+
54
+ function stable(value: unknown): string {
55
+ if (value instanceof Uint8Array) return `bytes:${Buffer.from(value).toString('base64')}`;
56
+ if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
57
+ if (value !== null && typeof value === 'object') {
58
+ const record = value as Record<string, unknown>;
59
+ return `{${Object.keys(record)
60
+ .sort()
61
+ .map((key) => `${JSON.stringify(key)}:${stable(record[key])}`)
62
+ .join(',')}}`;
63
+ }
64
+ return JSON.stringify(value) ?? 'null';
65
+ }
66
+
67
+ function digest(value: unknown): string {
68
+ return `sha256:${createHash('sha256').update(stable(value)).digest('hex')}`;
69
+ }
70
+
71
+ function normalizedAssets(pack: RuntimePackInput): readonly RuntimePackAsset[] {
72
+ return pack.assets.map((asset) => {
73
+ const payload = asset.payload;
74
+ const canonicalPayload =
75
+ payload !== null &&
76
+ typeof payload === 'object' &&
77
+ !Array.isArray(payload) &&
78
+ !(payload instanceof Uint8Array) &&
79
+ typeof (payload as Record<string, unknown>).kind !== 'string'
80
+ ? { ...(payload as Record<string, unknown>), kind: asset.kind }
81
+ : payload;
82
+ return {
83
+ ...asset,
84
+ payload: canonicalPayload,
85
+ refs: [...(asset.refs ?? [])].map((ref) => ref.toLowerCase()),
86
+ artifacts: asset.artifacts ?? {},
87
+ };
88
+ });
89
+ }
90
+
91
+ function outputFor(asset: RuntimePackAsset): AssetPublicationOutput {
92
+ return {
93
+ guid: asset.guid.toLowerCase(),
94
+ sourceKey: asset.guid.toLowerCase(),
95
+ kind: asset.kind,
96
+ digest: digest({
97
+ guid: asset.guid.toLowerCase(),
98
+ kind: asset.kind,
99
+ name: asset.name,
100
+ payload: asset.payload,
101
+ refs: asset.refs,
102
+ artifacts: asset.artifacts,
103
+ }),
104
+ refs: asset.refs,
105
+ };
106
+ }
107
+
108
+ function outputSetDigest(outputs: readonly AssetPublicationOutput[]): string {
109
+ return digest(
110
+ outputs.map((output) => ({
111
+ guid: output.guid.toLowerCase(),
112
+ sourceKey: output.sourceKey,
113
+ kind: output.kind,
114
+ digest: output.digest,
115
+ refs: [...output.refs].map((guid) => guid.toLowerCase()),
116
+ })),
117
+ );
118
+ }
119
+
120
+ function publicationGeneration(
121
+ sourceRevision: string,
122
+ valueDigest: string,
123
+ outputs: string,
124
+ ): number {
125
+ const value = createHash('sha256')
126
+ .update(sourceRevision)
127
+ .update('\n')
128
+ .update(valueDigest)
129
+ .update('\n')
130
+ .update(outputs)
131
+ .digest('hex');
132
+ const generation = Number.parseInt(value.slice(0, 8), 16);
133
+ return generation > 0 ? generation : 1;
134
+ }
135
+
136
+ export function createRuntimePackPublication(
137
+ input: RuntimePackPublicationInput,
138
+ ): RuntimePackPublication {
139
+ const assets = normalizedAssets(input.pack);
140
+ const semantic = {
141
+ schemaVersion: '2.0.0' as const,
142
+ kind: 'internal-text-package' as const,
143
+ assets: [...assets].sort((left, right) =>
144
+ left.guid.toLowerCase().localeCompare(right.guid.toLowerCase()),
145
+ ),
146
+ };
147
+ const valueDigest = input.digest ?? digest(semantic);
148
+ const outputs = input.outputs ?? assets.map(outputFor);
149
+ const outputDigest = outputSetDigest(outputs);
150
+ const generation =
151
+ input.generation ?? publicationGeneration(input.sourceRevision, valueDigest, outputDigest);
152
+ const externalEvidence = input.externalEvidence ?? [];
153
+ const inputFingerprint = input.inputFingerprint ?? input.sourceRevision;
154
+ const publication: AssetPublicationEnvelope = {
155
+ schemaVersion: 'asset-publication/1',
156
+ sourcePath: input.sourcePath,
157
+ sourceRevision: input.sourceRevision,
158
+ generation,
159
+ digest: valueDigest,
160
+ outputSetDigest: outputDigest,
161
+ outputs,
162
+ receipt: {
163
+ schemaVersion: 'asset-publication-receipt/1',
164
+ sourcePath: input.sourcePath,
165
+ sourceRevision: input.sourceRevision,
166
+ inputFingerprint,
167
+ outputDigest: valueDigest,
168
+ outputSetDigest: outputDigest,
169
+ externalEvidence,
170
+ },
171
+ externalEvidence,
172
+ current: {
173
+ generation,
174
+ digest: valueDigest,
175
+ outputSetDigest: outputDigest,
176
+ packageUrl: input.packageUrl,
177
+ receiptKey: inputFingerprint,
178
+ },
179
+ };
180
+ return {
181
+ pack: {
182
+ ...semantic,
183
+ scopeId: input.scopeId,
184
+ generation,
185
+ digest: valueDigest,
186
+ outputSetDigest: outputDigest,
187
+ },
188
+ publication,
189
+ };
190
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { PackV2, PackV2Error, Result } from '@forgeax/engine-types';
2
+ import { err, ok } from '@forgeax/engine-types';
3
+
4
+ import { validatePackV2 } from './schema-compiled.js';
5
+
6
+ export { validateMeta, validatePack, validatePackV2 } from './schema-compiled.js';
7
+
8
+ /**
9
+ * Parse a Pack v2 envelope without loading the Node-only scanner/evidence
10
+ * barrel. Browser runtime consumers should import this subpath.
11
+ */
12
+ export function parsePackV2(value: unknown): Result<PackV2, PackV2Error> {
13
+ if (!validatePackV2(value)) {
14
+ return err({
15
+ code: 'pack-v2-envelope-invalid',
16
+ expected: 'a Pack v2 envelope with unique asset GUIDs and valid descriptors',
17
+ hint: 'validate the pack against packages/pack/schema/pack.schema.json and re-cook it',
18
+ detail: { observed: 'invalid pack', expected: 'schemaVersion 2.0.0' },
19
+ });
20
+ }
21
+
22
+ return ok(value);
23
+ }