@forgeax/engine-assets-runtime 0.1.2

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 (71) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +213 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/asset-graph-red.integration.test.d.ts +2 -0
  5. package/dist/__tests__/asset-graph-red.integration.test.d.ts.map +1 -0
  6. package/dist/__tests__/asset-kind.test.d.ts +2 -0
  7. package/dist/__tests__/asset-kind.test.d.ts.map +1 -0
  8. package/dist/__tests__/asset-registry-core.integration.test.d.ts +2 -0
  9. package/dist/__tests__/asset-registry-core.integration.test.d.ts.map +1 -0
  10. package/dist/__tests__/asset-registry-public-api.test-d.d.ts +2 -0
  11. package/dist/__tests__/asset-registry-public-api.test-d.d.ts.map +1 -0
  12. package/dist/__tests__/asset-runtime-core-lifecycle.integration.test.d.ts +2 -0
  13. package/dist/__tests__/asset-runtime-core-lifecycle.integration.test.d.ts.map +1 -0
  14. package/dist/__tests__/asset-runtime-snapshot.unit.test.d.ts +2 -0
  15. package/dist/__tests__/asset-runtime-snapshot.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/catalog-session-red.unit.test.d.ts +2 -0
  17. package/dist/__tests__/catalog-session-red.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/decode-image-mime-owner.test.d.ts +2 -0
  19. package/dist/__tests__/decode-image-mime-owner.test.d.ts.map +1 -0
  20. package/dist/__tests__/registry-lifecycle-red.integration.test.d.ts +2 -0
  21. package/dist/__tests__/registry-lifecycle-red.integration.test.d.ts.map +1 -0
  22. package/dist/asset-kind.d.ts +4 -0
  23. package/dist/asset-kind.d.ts.map +1 -0
  24. package/dist/catalog-source.d.ts +23 -0
  25. package/dist/catalog-source.d.ts.map +1 -0
  26. package/dist/index.d.ts +5 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.mjs +1240 -0
  29. package/dist/index.mjs.map +1 -0
  30. package/dist/internal/artifact-cache.d.ts +17 -0
  31. package/dist/internal/artifact-cache.d.ts.map +1 -0
  32. package/dist/internal/asset-graph.d.ts +70 -0
  33. package/dist/internal/asset-graph.d.ts.map +1 -0
  34. package/dist/internal/catalog-session.d.ts +60 -0
  35. package/dist/internal/catalog-session.d.ts.map +1 -0
  36. package/dist/internal/decoder-registry.d.ts +19 -0
  37. package/dist/internal/decoder-registry.d.ts.map +1 -0
  38. package/dist/internal/immutable-payload.d.ts +9 -0
  39. package/dist/internal/immutable-payload.d.ts.map +1 -0
  40. package/dist/internal/load-asset.d.ts +31 -0
  41. package/dist/internal/load-asset.d.ts.map +1 -0
  42. package/dist/internal/pack-reader.d.ts +18 -0
  43. package/dist/internal/pack-reader.d.ts.map +1 -0
  44. package/dist/internal/validate-runtime-row.d.ts +7 -0
  45. package/dist/internal/validate-runtime-row.d.ts.map +1 -0
  46. package/dist/internal.d.ts +2 -0
  47. package/dist/internal.d.ts.map +1 -0
  48. package/dist/internal.mjs +15 -0
  49. package/dist/internal.mjs.map +1 -0
  50. package/package.json +63 -0
  51. package/src/__tests__/asset-graph-red.integration.test.ts +113 -0
  52. package/src/__tests__/asset-kind.test.ts +14 -0
  53. package/src/__tests__/asset-registry-core.integration.test.ts +161 -0
  54. package/src/__tests__/asset-registry-public-api.test-d.ts +31 -0
  55. package/src/__tests__/asset-runtime-core-lifecycle.integration.test.ts +79 -0
  56. package/src/__tests__/asset-runtime-snapshot.unit.test.ts +23 -0
  57. package/src/__tests__/catalog-session-red.unit.test.ts +276 -0
  58. package/src/__tests__/decode-image-mime-owner.test.ts +41 -0
  59. package/src/__tests__/registry-lifecycle-red.integration.test.ts +80 -0
  60. package/src/asset-kind.ts +6 -0
  61. package/src/catalog-source.ts +145 -0
  62. package/src/index.ts +22 -0
  63. package/src/internal/artifact-cache.ts +65 -0
  64. package/src/internal/asset-graph.ts +420 -0
  65. package/src/internal/catalog-session.ts +345 -0
  66. package/src/internal/decoder-registry.ts +175 -0
  67. package/src/internal/immutable-payload.ts +20 -0
  68. package/src/internal/load-asset.ts +254 -0
  69. package/src/internal/pack-reader.ts +183 -0
  70. package/src/internal/validate-runtime-row.ts +51 -0
  71. package/src/internal.ts +4 -0
@@ -0,0 +1,254 @@
1
+ import type {
2
+ Asset,
3
+ AssetArtifactReader,
4
+ AssetDecoder,
5
+ AssetDecoderInput,
6
+ AssetDecoderLease,
7
+ AssetKind,
8
+ AssetLoadError,
9
+ BuiltinAssetKind,
10
+ BuiltinAssetPayload,
11
+ Result,
12
+ } from '@forgeax/engine-types';
13
+ import { err, ok } from '@forgeax/engine-types';
14
+ import type { CatalogSource } from '../catalog-source.js';
15
+ import { ArtifactCache } from './artifact-cache.js';
16
+ import { AssetGraph, type AssetGraphSnapshot } from './asset-graph.js';
17
+ import { CatalogSession, type CatalogSessionOptions } from './catalog-session.js';
18
+ import { DecoderRegistry } from './decoder-registry.js';
19
+ import { freezeRuntimePayload } from './immutable-payload.js';
20
+ import { PackReader } from './pack-reader.js';
21
+ import { validateRuntimeRow } from './validate-runtime-row.js';
22
+
23
+ export interface AssetRegistryOptions extends CatalogSessionOptions {
24
+ readonly catalog: CatalogSource;
25
+ readonly fetcher?: typeof globalThis.fetch;
26
+ readonly maxConcurrentReads?: number;
27
+ }
28
+
29
+ export interface AssetLoadOptions {
30
+ readonly signal?: AbortSignal;
31
+ }
32
+
33
+ export type AssetRegistrySnapshot = AssetGraphSnapshot;
34
+
35
+ export type AssetRegistrySnapshotCounters = AssetRegistrySnapshot['counters'];
36
+
37
+ export interface AssetRegistry {
38
+ installDecoder<P, K extends string>(
39
+ kind: AssetKind<P, K>,
40
+ decoder: AssetDecoder<P>,
41
+ ): AssetDecoderLease;
42
+ load<K extends BuiltinAssetKind>(
43
+ guid: string,
44
+ kind: K,
45
+ options?: AssetLoadOptions,
46
+ ): Promise<Result<BuiltinAssetPayload<K>, AssetLoadError>>;
47
+ load<P, K extends string>(
48
+ guid: string,
49
+ kind: AssetKind<P, K>,
50
+ options?: AssetLoadOptions,
51
+ ): Promise<Result<P, AssetLoadError>>;
52
+ snapshot(): AssetRegistrySnapshot;
53
+ subscribe(listener: (snapshot: AssetRegistrySnapshot) => void): () => void;
54
+ dispose(): void;
55
+ }
56
+
57
+ /** Internal render projection; the root Registry keeps only its five actions. */
58
+ export interface AssetRegistryResolver {
59
+ readonly epoch: number;
60
+ lookup<T extends Asset>(guid: string): T | undefined;
61
+ guidOf(asset: Asset): string | undefined;
62
+ }
63
+
64
+ const REGISTRY_RESOLVER = Symbol.for('forgeax.assets-runtime.registry-resolver');
65
+ type RegistryWithResolver = AssetRegistry & {
66
+ [REGISTRY_RESOLVER]?: AssetRegistryResolver;
67
+ };
68
+
69
+ export function getAssetRegistryResolver(registry: AssetRegistry): AssetRegistryResolver {
70
+ const resolver = (registry as RegistryWithResolver)[REGISTRY_RESOLVER];
71
+ if (resolver === undefined) {
72
+ throw new TypeError('AssetRegistry resolver is not owned by this asset runtime');
73
+ }
74
+ return resolver;
75
+ }
76
+
77
+ function missing(guid: string): AssetLoadError {
78
+ return {
79
+ code: 'asset-not-found',
80
+ expected: `the current Catalog to contain GUID "${guid}"`,
81
+ hint: 'inspect the producer Catalog and rebuild the missing publication',
82
+ detail: { guid },
83
+ };
84
+ }
85
+
86
+ function mismatch(guid: string, expectedKind: string, actualKind: string): AssetLoadError {
87
+ return {
88
+ code: 'asset-kind-mismatch',
89
+ expected: `Catalog kind "${actualKind}" to match "${expectedKind}"`,
90
+ hint: 'pass the Catalog kind or the matching custom AssetKind token',
91
+ detail: { guid, expectedKind, actualKind },
92
+ };
93
+ }
94
+
95
+ function artifactError(guid: string, reason: string): AssetLoadError {
96
+ return {
97
+ code: 'asset-integrity-failed',
98
+ expected: 'the verified artifact byte length and digest',
99
+ hint: 'verify the artifact digest and recook the Pack',
100
+ detail: {
101
+ guid,
102
+ artifactKey: reason,
103
+ expectedDigest: 'descriptor integrity',
104
+ actualDigest: reason,
105
+ },
106
+ };
107
+ }
108
+
109
+ const ASSET_GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
110
+
111
+ function invalidGuid(guid: string): AssetLoadError {
112
+ return {
113
+ code: 'asset-guid-invalid',
114
+ expected: 'a 36-character dash-form asset GUID',
115
+ hint: 'pass the producer GUID from the current Catalog row',
116
+ detail: { guid },
117
+ };
118
+ }
119
+
120
+ export function createAssetRegistry(options: AssetRegistryOptions): AssetRegistry {
121
+ const session = new CatalogSession(options.catalog, options);
122
+ const reader = new PackReader(options.fetcher === undefined ? {} : { fetcher: options.fetcher });
123
+ const cache = new ArtifactCache();
124
+ const decoders = new DecoderRegistry(
125
+ options.scopeId === undefined ? {} : { scopeId: options.scopeId },
126
+ );
127
+ const graph = new AssetGraph<{ readonly value: unknown; readonly refs: readonly string[] }>({
128
+ ...(options.maxConcurrentReads === undefined
129
+ ? {}
130
+ : { maxConcurrentReads: options.maxConcurrentReads }),
131
+ read: async (guid, signal) => {
132
+ const row = session.current(guid);
133
+ if (row === undefined) return err(missing(guid));
134
+ const validated = validateRuntimeRow(row);
135
+ if (!validated.ok) return validated;
136
+ const publication = validated.value.publication;
137
+ const tuple = {
138
+ scopeId: session.snapshot().scopeId,
139
+ generation: publication.generation,
140
+ digest: publication.digest,
141
+ outputSetDigest: publication.outputSetDigest,
142
+ };
143
+ const pack = await reader.read(row.packageUrl, tuple, signal);
144
+ if (!pack.ok) return pack;
145
+ const envelope = pack.value.assets.find(
146
+ (asset) => asset.guid.toLowerCase() === guid.toLowerCase(),
147
+ );
148
+ if (envelope === undefined) return err(missing(guid));
149
+ const artifacts: AssetArtifactReader = {
150
+ read: (descriptor) => {
151
+ const integrity = descriptor.integrity;
152
+ if (integrity === undefined)
153
+ return Promise.resolve(err(artifactError(guid, descriptor.path)));
154
+ const key = `${tuple.scopeId}:${tuple.generation}:${tuple.outputSetDigest}:${integrity.digest}`;
155
+ return cache.read(key, async () => {
156
+ const url = resolveArtifactUrl(row.packageUrl, descriptor.path);
157
+ let response: Response;
158
+ try {
159
+ response = await (options.fetcher ?? globalThis.fetch)(url, { signal });
160
+ } catch {
161
+ return err(artifactError(guid, descriptor.path));
162
+ }
163
+ if (!response.ok) return err(artifactError(guid, descriptor.path));
164
+ const bytes = new Uint8Array(await response.arrayBuffer());
165
+ if (descriptor.byteLength === undefined || bytes.byteLength !== descriptor.byteLength) {
166
+ return err(artifactError(guid, descriptor.path));
167
+ }
168
+ const actualDigest = await sha256(bytes);
169
+ if (actualDigest !== descriptor.integrity?.digest.toLowerCase()) {
170
+ return err(artifactError(guid, `${descriptor.path}:${actualDigest}`));
171
+ }
172
+ return ok(bytes);
173
+ });
174
+ },
175
+ };
176
+ const input: AssetDecoderInput<unknown> = { envelope, artifacts, signal };
177
+ const decoded = await decoders.loadByKind(envelope.kind, input);
178
+ if (!decoded.ok) return decoded;
179
+ return ok({ value: freezeRuntimePayload(decoded.value), refs: envelope.refs });
180
+ },
181
+ });
182
+
183
+ let catalogEpoch = session.snapshot().epoch;
184
+ const unsubscribeCatalog = session.subscribe((snapshot) => {
185
+ if (snapshot.epoch === catalogEpoch) return;
186
+ catalogEpoch = snapshot.epoch;
187
+ graph.invalidateForCatalogChange(
188
+ snapshot.stale ? undefined : [...snapshot.changed, ...snapshot.removed],
189
+ );
190
+ });
191
+
192
+ const registry: AssetRegistry = {
193
+ installDecoder: (kind, decoder) => decoders.install(kind, decoder),
194
+ load: (async (
195
+ guid: string,
196
+ kind: BuiltinAssetKind | AssetKind<unknown, string>,
197
+ loadOptions: AssetLoadOptions = {},
198
+ ): Promise<Result<unknown, AssetLoadError>> => {
199
+ if (!ASSET_GUID_PATTERN.test(guid)) return err(invalidGuid(guid));
200
+ const expectedKind = typeof kind === 'string' ? kind : kind.kind;
201
+ const started = await session.start();
202
+ if (!started.ok) return err(started.error);
203
+ if (session.snapshot().stale) {
204
+ const reconciled = await session.reconcile();
205
+ if (!reconciled.ok) return err(reconciled.error);
206
+ const discontinuity = session.discontinuity();
207
+ if (discontinuity !== undefined) return err(discontinuity);
208
+ }
209
+ const row = session.current(guid);
210
+ if (row === undefined) return err(missing(guid));
211
+ if (row.kind !== expectedKind) return err(mismatch(guid, expectedKind, row.kind));
212
+ const result = await graph.load(guid, loadOptions.signal);
213
+ return result.ok ? ok(result.value.value) : result;
214
+ }) as AssetRegistry['load'],
215
+ snapshot: () => graph.snapshot(),
216
+ subscribe: (listener) => graph.subscribe(listener),
217
+ dispose: () => {
218
+ graph.dispose();
219
+ unsubscribeCatalog();
220
+ decoders.dispose();
221
+ cache.clear();
222
+ session.dispose();
223
+ },
224
+ };
225
+ const resolver: AssetRegistryResolver = {
226
+ get epoch() {
227
+ return graph.snapshot().epoch;
228
+ },
229
+ lookup: <T extends Asset>(guid: string) => graph.lookup(guid) as T | undefined,
230
+ guidOf: (asset: Asset) => graph.guidOf(asset),
231
+ };
232
+ Object.defineProperty(registry, REGISTRY_RESOLVER, {
233
+ value: resolver,
234
+ enumerable: false,
235
+ });
236
+ return registry;
237
+ }
238
+
239
+ async function sha256(bytes: Uint8Array): Promise<string> {
240
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes as unknown as BufferSource);
241
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) =>
242
+ byte.toString(16).padStart(2, '0'),
243
+ ).join('')}`;
244
+ }
245
+
246
+ function resolveArtifactUrl(packageUrl: string, path: string): string {
247
+ const separator = packageUrl.lastIndexOf('/');
248
+ const packageDirectory = separator < 0 ? '' : packageUrl.slice(0, separator + 1);
249
+ try {
250
+ return new URL(path, packageDirectory).toString();
251
+ } catch {
252
+ return `${packageDirectory}${path.replace(/^\//, '')}`;
253
+ }
254
+ }
@@ -0,0 +1,183 @@
1
+ import type {
2
+ AssetEnvelopeV2,
3
+ AssetLoadError,
4
+ AssetPublicationTuple,
5
+ PackV2,
6
+ Result,
7
+ } from '@forgeax/engine-types';
8
+ import { err, ok } from '@forgeax/engine-types';
9
+
10
+ export interface PackReaderOptions {
11
+ readonly fetcher?: typeof globalThis.fetch;
12
+ }
13
+
14
+ export type VerifiedPack = PackV2<unknown>;
15
+
16
+ function invalid(guid: string, reason: string): Result<never, AssetLoadError> {
17
+ return err({
18
+ code: 'asset-package-invalid',
19
+ expected: 'a verified Pack v2 envelope with complete runtime artifacts',
20
+ hint: 're-cook the Pack v2 publication and retry the current tuple',
21
+ detail: { guid, reason },
22
+ });
23
+ }
24
+
25
+ function frozen<T>(value: T): T {
26
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
27
+ Object.freeze(value);
28
+ for (const child of Object.values(value as Record<string, unknown>)) frozen(child);
29
+ }
30
+ return value;
31
+ }
32
+
33
+ function sameTuple(left: AssetPublicationTuple, right: AssetPublicationTuple): boolean {
34
+ return (
35
+ left.scopeId === right.scopeId &&
36
+ left.generation === right.generation &&
37
+ left.digest === right.digest &&
38
+ left.outputSetDigest === right.outputSetDigest
39
+ );
40
+ }
41
+
42
+ export class PackReader {
43
+ private readonly fetcher: typeof globalThis.fetch | undefined;
44
+ private readonly verified = new Map<string, VerifiedPack>();
45
+ private readonly pending = new Map<string, Promise<Result<VerifiedPack, AssetLoadError>>>();
46
+
47
+ constructor(options: PackReaderOptions = {}) {
48
+ // An injected fetcher is an explicit transport lease and is fixed for the
49
+ // reader lifetime. With no injection, resolve the host fetch at read time:
50
+ // App assembly can precede a dev/test host installing its transport.
51
+ this.fetcher = options.fetcher?.bind(globalThis);
52
+ }
53
+
54
+ async read(
55
+ packageUrl: string,
56
+ expected: AssetPublicationTuple,
57
+ signal: AbortSignal,
58
+ ): Promise<Result<VerifiedPack, AssetLoadError>> {
59
+ if (signal.aborted) return err(this.cancelled(packageUrl));
60
+ const key = this.key(packageUrl, expected);
61
+ const cached = this.verified.get(key);
62
+ if (cached !== undefined) return ok(cached);
63
+ const active = this.pending.get(key);
64
+ if (active !== undefined) return active;
65
+ const request = this.fetchAndVerify(packageUrl, expected, signal);
66
+ this.pending.set(key, request);
67
+ const result = await request;
68
+ this.pending.delete(key);
69
+ if (result.ok) this.verified.set(key, result.value);
70
+ return result;
71
+ }
72
+
73
+ private async fetchAndVerify(
74
+ packageUrl: string,
75
+ expected: AssetPublicationTuple,
76
+ signal: AbortSignal,
77
+ ): Promise<Result<VerifiedPack, AssetLoadError>> {
78
+ let response: Response;
79
+ try {
80
+ const fetcher = this.fetcher ?? globalThis.fetch.bind(globalThis);
81
+ response = await fetcher(packageUrl, { signal });
82
+ } catch (_cause) {
83
+ if (signal.aborted) return err(this.cancelled(packageUrl));
84
+ return err({
85
+ code: 'asset-fetch-failed',
86
+ expected: 'HTTP 200 for the current Pack URL',
87
+ hint: 'verify the package locator and republish the Pack',
88
+ detail: { guid: packageUrl, packageUrl },
89
+ });
90
+ }
91
+ if (!response.ok) {
92
+ return err({
93
+ code: 'asset-fetch-failed',
94
+ expected: 'HTTP 200 for the current Pack URL',
95
+ hint: 'verify the package locator and republish the Pack',
96
+ detail: { guid: packageUrl, packageUrl },
97
+ });
98
+ }
99
+ let value: unknown;
100
+ try {
101
+ value = await response.json();
102
+ } catch {
103
+ return invalid(packageUrl, 'invalid JSON');
104
+ }
105
+ return this.verify(value, expected);
106
+ }
107
+
108
+ private key(packageUrl: string, tuple: AssetPublicationTuple): string {
109
+ return `${packageUrl}\u0000${tuple.scopeId}\u0000${tuple.generation}\u0000${tuple.digest}\u0000${tuple.outputSetDigest}`;
110
+ }
111
+
112
+ verify(value: unknown, expected: AssetPublicationTuple): Result<VerifiedPack, AssetLoadError> {
113
+ if (value === null || typeof value !== 'object') return invalid('', 'Pack is not an object');
114
+ const pack = value as Partial<PackV2<unknown>>;
115
+ if (pack.schemaVersion !== '2.0.0' || pack.kind !== 'internal-text-package') {
116
+ return invalid('', 'schemaVersion or kind');
117
+ }
118
+ if (
119
+ typeof pack.scopeId !== 'string' ||
120
+ !Number.isSafeInteger(pack.generation) ||
121
+ typeof pack.digest !== 'string' ||
122
+ typeof pack.outputSetDigest !== 'string' ||
123
+ !sameTuple(pack as AssetPublicationTuple, expected)
124
+ ) {
125
+ return invalid('', 'publication tuple mismatch');
126
+ }
127
+ if (!Array.isArray(pack.assets)) return invalid('', 'assets');
128
+ const guids = new Set<string>();
129
+ for (const raw of pack.assets) {
130
+ const asset = raw as Partial<AssetEnvelopeV2<unknown>>;
131
+ const guid = typeof asset.guid === 'string' ? asset.guid : '';
132
+ if (guid.length === 0 || guids.has(guid.toLowerCase()))
133
+ return invalid(guid, 'duplicate guid');
134
+ guids.add(guid.toLowerCase());
135
+ if (
136
+ typeof asset.kind !== 'string' ||
137
+ asset.payload === undefined ||
138
+ !Array.isArray(asset.refs) ||
139
+ asset.refs.some((ref) => typeof ref !== 'string') ||
140
+ asset.artifacts === null ||
141
+ typeof asset.artifacts !== 'object'
142
+ ) {
143
+ return invalid(guid, 'asset envelope fields');
144
+ }
145
+ for (const [artifactKey, descriptor] of Object.entries(asset.artifacts)) {
146
+ if (!this.validArtifact(guid, artifactKey, descriptor)) {
147
+ return invalid(guid, `artifact ${artifactKey}`);
148
+ }
149
+ }
150
+ }
151
+ return ok(frozen(pack as VerifiedPack));
152
+ }
153
+
154
+ private validArtifact(_guid: string, key: string, value: unknown): boolean {
155
+ if (value === null || typeof value !== 'object') return false;
156
+ const descriptor = value as Record<string, unknown>;
157
+ const integrity = descriptor.integrity;
158
+ return (
159
+ key.length > 0 &&
160
+ typeof descriptor.path === 'string' &&
161
+ descriptor.path.length > 0 &&
162
+ typeof descriptor.mediaType === 'string' &&
163
+ descriptor.mediaType.length > 0 &&
164
+ (descriptor.contentEncoding === 'identity' || descriptor.contentEncoding === 'zstd') &&
165
+ Number.isSafeInteger(descriptor.byteLength) &&
166
+ Number(descriptor.byteLength) >= 0 &&
167
+ integrity !== null &&
168
+ typeof integrity === 'object' &&
169
+ (integrity as Record<string, unknown>).algorithm === 'sha256' &&
170
+ typeof (integrity as Record<string, unknown>).digest === 'string' &&
171
+ /^sha256:[0-9a-f]{64}$/i.test(String((integrity as Record<string, unknown>).digest))
172
+ );
173
+ }
174
+
175
+ private cancelled(guid: string): AssetLoadError {
176
+ return {
177
+ code: 'asset-load-cancelled',
178
+ expected: 'the request AbortSignal to remain live while reading the Pack',
179
+ hint: 'retry with a live AbortSignal when the request is still needed',
180
+ detail: { guid },
181
+ };
182
+ }
183
+ }
@@ -0,0 +1,51 @@
1
+ import type { CatalogEntry } from '@forgeax/engine-types';
2
+ import { type AssetLoadError, err, ok, type Result } from '@forgeax/engine-types';
3
+
4
+ export interface RuntimeCatalogRow extends CatalogEntry {
5
+ readonly publication: NonNullable<CatalogEntry['publication']>;
6
+ }
7
+
8
+ function invalidRow(guid: string, reason: string): Result<never, AssetLoadError> {
9
+ return err({
10
+ code: 'asset-package-invalid',
11
+ expected: 'one complete current runtime Catalog row',
12
+ hint: 'rebuild the producer Catalog and publish one Pack v2 tuple',
13
+ detail: { guid, reason },
14
+ });
15
+ }
16
+
17
+ export function validateRuntimeRow(row: CatalogEntry): Result<RuntimeCatalogRow, AssetLoadError> {
18
+ if (row === null || typeof row !== 'object') return invalidRow('', 'row');
19
+ const candidate = row as Partial<CatalogEntry>;
20
+ const guid = typeof candidate.guid === 'string' ? candidate.guid : '';
21
+ if (guid.trim().length === 0) return invalidRow(guid, 'guid');
22
+ if (typeof candidate.kind !== 'string' || candidate.kind.trim().length === 0)
23
+ return invalidRow(guid, 'kind');
24
+ if (typeof candidate.packageUrl !== 'string' || candidate.packageUrl.trim().length === 0)
25
+ return invalidRow(guid, 'packageUrl');
26
+ if (typeof candidate.sourcePath !== 'string' || candidate.sourcePath.trim().length === 0)
27
+ return invalidRow(guid, 'sourcePath');
28
+ if (!Number.isSafeInteger(candidate.publication?.generation))
29
+ return invalidRow(guid, 'publication generation');
30
+ if (candidate.publication === undefined) return invalidRow(guid, 'publication');
31
+ const publication = candidate.publication;
32
+ if (
33
+ publication.schemaVersion !== 'asset-publication/1' ||
34
+ typeof publication.sourcePath !== 'string' ||
35
+ publication.sourcePath.trim().length === 0 ||
36
+ typeof publication.sourceRevision !== 'string' ||
37
+ publication.sourceRevision.trim().length === 0 ||
38
+ publication.generation < 0 ||
39
+ typeof publication.digest !== 'string' ||
40
+ publication.digest.trim().length === 0 ||
41
+ typeof publication.outputSetDigest !== 'string' ||
42
+ publication.outputSetDigest.trim().length === 0 ||
43
+ !Array.isArray(publication.outputs) ||
44
+ publication.receipt === null ||
45
+ typeof publication.receipt !== 'object' ||
46
+ !Array.isArray(publication.externalEvidence)
47
+ ) {
48
+ return invalidRow(guid, 'publication tuple');
49
+ }
50
+ return ok(Object.freeze({ ...candidate, guid, publication }) as RuntimeCatalogRow);
51
+ }
@@ -0,0 +1,4 @@
1
+ export {
2
+ type AssetRegistryResolver,
3
+ getAssetRegistryResolver,
4
+ } from './internal/load-asset.js';