@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.
- package/LICENSE +202 -0
- package/README.md +213 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/asset-graph-red.integration.test.d.ts +2 -0
- package/dist/__tests__/asset-graph-red.integration.test.d.ts.map +1 -0
- package/dist/__tests__/asset-kind.test.d.ts +2 -0
- package/dist/__tests__/asset-kind.test.d.ts.map +1 -0
- package/dist/__tests__/asset-registry-core.integration.test.d.ts +2 -0
- package/dist/__tests__/asset-registry-core.integration.test.d.ts.map +1 -0
- package/dist/__tests__/asset-registry-public-api.test-d.d.ts +2 -0
- package/dist/__tests__/asset-registry-public-api.test-d.d.ts.map +1 -0
- package/dist/__tests__/asset-runtime-core-lifecycle.integration.test.d.ts +2 -0
- package/dist/__tests__/asset-runtime-core-lifecycle.integration.test.d.ts.map +1 -0
- package/dist/__tests__/asset-runtime-snapshot.unit.test.d.ts +2 -0
- package/dist/__tests__/asset-runtime-snapshot.unit.test.d.ts.map +1 -0
- package/dist/__tests__/catalog-session-red.unit.test.d.ts +2 -0
- package/dist/__tests__/catalog-session-red.unit.test.d.ts.map +1 -0
- package/dist/__tests__/decode-image-mime-owner.test.d.ts +2 -0
- package/dist/__tests__/decode-image-mime-owner.test.d.ts.map +1 -0
- package/dist/__tests__/registry-lifecycle-red.integration.test.d.ts +2 -0
- package/dist/__tests__/registry-lifecycle-red.integration.test.d.ts.map +1 -0
- package/dist/asset-kind.d.ts +4 -0
- package/dist/asset-kind.d.ts.map +1 -0
- package/dist/catalog-source.d.ts +23 -0
- package/dist/catalog-source.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1240 -0
- package/dist/index.mjs.map +1 -0
- package/dist/internal/artifact-cache.d.ts +17 -0
- package/dist/internal/artifact-cache.d.ts.map +1 -0
- package/dist/internal/asset-graph.d.ts +70 -0
- package/dist/internal/asset-graph.d.ts.map +1 -0
- package/dist/internal/catalog-session.d.ts +60 -0
- package/dist/internal/catalog-session.d.ts.map +1 -0
- package/dist/internal/decoder-registry.d.ts +19 -0
- package/dist/internal/decoder-registry.d.ts.map +1 -0
- package/dist/internal/immutable-payload.d.ts +9 -0
- package/dist/internal/immutable-payload.d.ts.map +1 -0
- package/dist/internal/load-asset.d.ts +31 -0
- package/dist/internal/load-asset.d.ts.map +1 -0
- package/dist/internal/pack-reader.d.ts +18 -0
- package/dist/internal/pack-reader.d.ts.map +1 -0
- package/dist/internal/validate-runtime-row.d.ts +7 -0
- package/dist/internal/validate-runtime-row.d.ts.map +1 -0
- package/dist/internal.d.ts +2 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.mjs +15 -0
- package/dist/internal.mjs.map +1 -0
- package/package.json +63 -0
- package/src/__tests__/asset-graph-red.integration.test.ts +113 -0
- package/src/__tests__/asset-kind.test.ts +14 -0
- package/src/__tests__/asset-registry-core.integration.test.ts +161 -0
- package/src/__tests__/asset-registry-public-api.test-d.ts +31 -0
- package/src/__tests__/asset-runtime-core-lifecycle.integration.test.ts +79 -0
- package/src/__tests__/asset-runtime-snapshot.unit.test.ts +23 -0
- package/src/__tests__/catalog-session-red.unit.test.ts +276 -0
- package/src/__tests__/decode-image-mime-owner.test.ts +41 -0
- package/src/__tests__/registry-lifecycle-red.integration.test.ts +80 -0
- package/src/asset-kind.ts +6 -0
- package/src/catalog-source.ts +145 -0
- package/src/index.ts +22 -0
- package/src/internal/artifact-cache.ts +65 -0
- package/src/internal/asset-graph.ts +420 -0
- package/src/internal/catalog-session.ts +345 -0
- package/src/internal/decoder-registry.ts +175 -0
- package/src/internal/immutable-payload.ts +20 -0
- package/src/internal/load-asset.ts +254 -0
- package/src/internal/pack-reader.ts +183 -0
- package/src/internal/validate-runtime-row.ts +51 -0
- package/src/internal.ts +4 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type { CatalogDelta, CatalogEntry } from '@forgeax/engine-types';
|
|
2
|
+
import { ok } from '@forgeax/engine-types';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { createCatalogSource } from '../catalog-source.js';
|
|
5
|
+
import { ArtifactCache } from '../internal/artifact-cache.js';
|
|
6
|
+
import { CatalogSession } from '../internal/catalog-session.js';
|
|
7
|
+
import { PackReader } from '../internal/pack-reader.js';
|
|
8
|
+
|
|
9
|
+
const GUID = '00000000-0000-7000-8000-000000000001';
|
|
10
|
+
|
|
11
|
+
function entry(guid = GUID, revision = 1): CatalogEntry {
|
|
12
|
+
return {
|
|
13
|
+
guid,
|
|
14
|
+
packageUrl: '/runtime.pack.json',
|
|
15
|
+
kind: 'host-blob',
|
|
16
|
+
sourcePath: 'test',
|
|
17
|
+
revision: { rootId: 'root', digest: `sha256:revision-${revision}`, observedAt: revision },
|
|
18
|
+
publication: {
|
|
19
|
+
schemaVersion: 'asset-publication/1',
|
|
20
|
+
sourcePath: 'test',
|
|
21
|
+
sourceRevision: `source-${revision}`,
|
|
22
|
+
generation: 1,
|
|
23
|
+
digest: 'sha256:pack',
|
|
24
|
+
outputSetDigest: 'sha256:outputs',
|
|
25
|
+
outputs: [{ guid, sourceKey: guid, kind: 'host-blob', digest: 'sha256:asset', refs: [] }],
|
|
26
|
+
receipt: {
|
|
27
|
+
schemaVersion: 'asset-publication-receipt/1',
|
|
28
|
+
sourcePath: 'test',
|
|
29
|
+
sourceRevision: `source-${revision}`,
|
|
30
|
+
inputFingerprint: 'input',
|
|
31
|
+
outputDigest: 'sha256:outputs',
|
|
32
|
+
outputSetDigest: 'sha256:outputs',
|
|
33
|
+
externalEvidence: [],
|
|
34
|
+
},
|
|
35
|
+
externalEvidence: [],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('CatalogSession', () => {
|
|
41
|
+
it('rejects a Catalog row without a complete publication at the runtime boundary', async () => {
|
|
42
|
+
const invalidRow = { ...entry() };
|
|
43
|
+
delete (invalidRow as { publication?: CatalogEntry['publication'] }).publication;
|
|
44
|
+
const session = new CatalogSession(createCatalogSource({ entries: [invalidRow] }));
|
|
45
|
+
|
|
46
|
+
expect(await session.start()).toEqual({
|
|
47
|
+
ok: false,
|
|
48
|
+
error: expect.objectContaining({ code: 'asset-package-invalid' }),
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('turns a thrown Catalog enumeration into a structured runtime failure', async () => {
|
|
53
|
+
const session = new CatalogSession({
|
|
54
|
+
enumerate: async () => {
|
|
55
|
+
throw new Error('catalog transport unavailable');
|
|
56
|
+
},
|
|
57
|
+
subscribe: () => () => {},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await expect(session.start()).resolves.toMatchObject({
|
|
61
|
+
ok: false,
|
|
62
|
+
error: { code: 'asset-package-invalid' },
|
|
63
|
+
});
|
|
64
|
+
expect(session.snapshot().stale).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('binds one scope and rejects a delta from another scope', async () => {
|
|
68
|
+
let publish: ((delta: CatalogDelta) => void) | undefined;
|
|
69
|
+
const source = createCatalogSource({
|
|
70
|
+
entries: [entry()],
|
|
71
|
+
expectedScope: { scopeId: 'scope-a', generation: 4 },
|
|
72
|
+
subscribe(listener) {
|
|
73
|
+
publish = listener;
|
|
74
|
+
return () => {};
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const session = new CatalogSession(source);
|
|
78
|
+
|
|
79
|
+
expect((await session.start()).ok).toBe(true);
|
|
80
|
+
expect(session.snapshot().scopeId).toBe('scope-a');
|
|
81
|
+
expect(session.snapshot().generation).toBe(4);
|
|
82
|
+
publish?.({
|
|
83
|
+
scopeId: 'scope-b',
|
|
84
|
+
generation: 4,
|
|
85
|
+
added: [entry('00000000-0000-7000-8000-000000000002', 2)],
|
|
86
|
+
changed: [],
|
|
87
|
+
removed: [],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(session.snapshot().entries).toHaveLength(1);
|
|
91
|
+
expect(session.snapshot().diagnostics.map((item) => item.code)).toContain(
|
|
92
|
+
'catalog-scope-mismatch',
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('serializes reconcile and preserves continuity across ordered deltas', async () => {
|
|
97
|
+
const deltas: CatalogDelta[] = [];
|
|
98
|
+
const source = createCatalogSource({
|
|
99
|
+
entries: [entry()],
|
|
100
|
+
subscribe(listener) {
|
|
101
|
+
deltas.push({
|
|
102
|
+
added: [],
|
|
103
|
+
changed: [entry(GUID, 2)],
|
|
104
|
+
removed: [],
|
|
105
|
+
revisions: {
|
|
106
|
+
baseline: [{ rootId: 'root', revision: 1 }],
|
|
107
|
+
current: [{ rootId: 'root', revision: 2 }],
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
listener(deltas[0] as CatalogDelta);
|
|
111
|
+
return () => {};
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
const session = new CatalogSession(source, { scopeId: 'scope', generation: 1 });
|
|
115
|
+
|
|
116
|
+
await session.start();
|
|
117
|
+
expect(session.current(GUID)?.revision?.observedAt).toBe(2);
|
|
118
|
+
expect((await session.reconcile()).ok).toBe(true);
|
|
119
|
+
expect(session.snapshot().epoch).toBeGreaterThan(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('marks a discontinuous delta and clears the terminal observation on reconcile', async () => {
|
|
123
|
+
let publish: ((delta: CatalogDelta) => void) | undefined;
|
|
124
|
+
const source = createCatalogSource({
|
|
125
|
+
entries: [entry()],
|
|
126
|
+
subscribe(listener) {
|
|
127
|
+
publish = listener;
|
|
128
|
+
return () => {};
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
const session = new CatalogSession(source, { scopeId: 'scope', generation: 1 });
|
|
132
|
+
|
|
133
|
+
await session.start();
|
|
134
|
+
publish?.({
|
|
135
|
+
added: [],
|
|
136
|
+
changed: [entry(GUID, 3)],
|
|
137
|
+
removed: [],
|
|
138
|
+
revisions: {
|
|
139
|
+
baseline: [{ rootId: 'root', revision: 1 }],
|
|
140
|
+
current: [{ rootId: 'root', revision: 3 }],
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(session.snapshot().stale).toBe(true);
|
|
145
|
+
expect(session.snapshot().epoch).toBeGreaterThan(0);
|
|
146
|
+
expect(session.discontinuity()).toMatchObject({ code: 'catalog-discontinuous' });
|
|
147
|
+
|
|
148
|
+
expect((await session.reconcile()).ok).toBe(true);
|
|
149
|
+
expect(session.snapshot().stale).toBe(false);
|
|
150
|
+
expect(session.snapshot().diagnostics).toEqual([]);
|
|
151
|
+
expect(session.discontinuity()).toBeUndefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('does not expose mutable session state', async () => {
|
|
155
|
+
const session = new CatalogSession(createCatalogSource({ entries: [entry()] }), {
|
|
156
|
+
scopeId: 'scope',
|
|
157
|
+
generation: 1,
|
|
158
|
+
});
|
|
159
|
+
await session.start();
|
|
160
|
+
const snapshot = session.snapshot();
|
|
161
|
+
expect(Object.isFrozen(snapshot)).toBe(true);
|
|
162
|
+
expect(Object.isFrozen(snapshot.entries)).toBe(true);
|
|
163
|
+
expect(ok(snapshot.entries[0])).toEqual(ok(snapshot.entries[0]));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('records observer failures without starving later observers', async () => {
|
|
167
|
+
const session = new CatalogSession(createCatalogSource({ entries: [entry()] }));
|
|
168
|
+
const seen: number[] = [];
|
|
169
|
+
session.subscribe(() => {
|
|
170
|
+
throw new Error('observer failure');
|
|
171
|
+
});
|
|
172
|
+
session.subscribe((snapshot) => seen.push(snapshot.entries.length));
|
|
173
|
+
|
|
174
|
+
await session.start();
|
|
175
|
+
|
|
176
|
+
expect(seen).toContain(1);
|
|
177
|
+
expect(session.snapshot().listenerFailures).toBe(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('accepts only the current Pack v2 tuple and complete artifact descriptors', async () => {
|
|
181
|
+
const tuple = {
|
|
182
|
+
scopeId: 'scope',
|
|
183
|
+
generation: 1,
|
|
184
|
+
digest: 'sha256:pack',
|
|
185
|
+
outputSetDigest: 'sha256:outputs',
|
|
186
|
+
};
|
|
187
|
+
const reader = new PackReader();
|
|
188
|
+
const result = reader.verify(
|
|
189
|
+
{
|
|
190
|
+
schemaVersion: '2.0.0',
|
|
191
|
+
kind: 'internal-text-package',
|
|
192
|
+
...tuple,
|
|
193
|
+
assets: [
|
|
194
|
+
{
|
|
195
|
+
guid: GUID,
|
|
196
|
+
kind: 'host-blob',
|
|
197
|
+
payload: { value: 'ok' },
|
|
198
|
+
refs: [],
|
|
199
|
+
artifacts: {
|
|
200
|
+
body: {
|
|
201
|
+
path: 'body.bin',
|
|
202
|
+
mediaType: 'application/octet-stream',
|
|
203
|
+
contentEncoding: 'identity',
|
|
204
|
+
byteLength: 2,
|
|
205
|
+
integrity: {
|
|
206
|
+
algorithm: 'sha256',
|
|
207
|
+
digest: 'sha256:230d8358dc8e8890b4c58deeb62912ee2f20357ae92a5cc861b98e68fe31acb5',
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
tuple,
|
|
215
|
+
);
|
|
216
|
+
expect(result.ok).toBe(true);
|
|
217
|
+
if (result.ok) expect(Object.isFrozen(result.value.assets[0])).toBe(true);
|
|
218
|
+
expect(reader.verify({ schemaVersion: '1.0.0' }, tuple).ok).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('resolves a late-installed host fetch when no transport was injected', async () => {
|
|
222
|
+
const tuple = {
|
|
223
|
+
scopeId: 'scope',
|
|
224
|
+
generation: 1,
|
|
225
|
+
digest: 'sha256:pack',
|
|
226
|
+
outputSetDigest: 'sha256:outputs',
|
|
227
|
+
};
|
|
228
|
+
const pack = {
|
|
229
|
+
schemaVersion: '2.0.0',
|
|
230
|
+
kind: 'internal-text-package',
|
|
231
|
+
...tuple,
|
|
232
|
+
assets: [
|
|
233
|
+
{
|
|
234
|
+
guid: GUID,
|
|
235
|
+
kind: 'host-blob',
|
|
236
|
+
payload: { value: 'late-fetch' },
|
|
237
|
+
refs: [],
|
|
238
|
+
artifacts: {},
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
};
|
|
242
|
+
const originalFetch = globalThis.fetch;
|
|
243
|
+
try {
|
|
244
|
+
const reader = new PackReader();
|
|
245
|
+
globalThis.fetch = (async () => new Response(JSON.stringify(pack))) as typeof fetch;
|
|
246
|
+
const result = await reader.read('/late.pack.json', tuple, new AbortController().signal);
|
|
247
|
+
expect(result.ok).toBe(true);
|
|
248
|
+
} finally {
|
|
249
|
+
globalThis.fetch = originalFetch;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('deduplicates content-addressed artifact reads and removes rejected entries', async () => {
|
|
254
|
+
const cache = new ArtifactCache();
|
|
255
|
+
let reads = 0;
|
|
256
|
+
const read = () => {
|
|
257
|
+
reads += 1;
|
|
258
|
+
return Promise.resolve(ok(new Uint8Array([1, 2, 3])));
|
|
259
|
+
};
|
|
260
|
+
await Promise.all([cache.read('sha256:body', read), cache.read('sha256:body', read)]);
|
|
261
|
+
expect(reads).toBe(1);
|
|
262
|
+
expect(cache.snapshot().hits).toBe(1);
|
|
263
|
+
await cache.read('sha256:failed', () =>
|
|
264
|
+
Promise.resolve({
|
|
265
|
+
ok: false as const,
|
|
266
|
+
error: {
|
|
267
|
+
code: 'asset-fetch-failed' as const,
|
|
268
|
+
expected: 'bytes',
|
|
269
|
+
hint: 'retry',
|
|
270
|
+
detail: { guid: GUID, packageUrl: '/body.bin' },
|
|
271
|
+
},
|
|
272
|
+
} as never),
|
|
273
|
+
);
|
|
274
|
+
expect(cache.snapshot().pending).toBe(0);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import type { decodeImageBytes } from '@forgeax/engine-image';
|
|
5
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
type ExpectedSupportedMime = 'image/png' | 'image/jpeg';
|
|
8
|
+
type PublicMime = Parameters<typeof decodeImageBytes>[1];
|
|
9
|
+
|
|
10
|
+
const decoderSource = readFileSync(
|
|
11
|
+
new URL('../../../image/src/runtime/decode-image-bytes.ts', import.meta.url),
|
|
12
|
+
'utf8',
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
describe('decodeImageBytes supported MIME owner', () => {
|
|
16
|
+
it('keeps the supported MIME vocabulary and public boundary exact', () => {
|
|
17
|
+
expectTypeOf<ExpectedSupportedMime>().toEqualTypeOf<'image/png' | 'image/jpeg'>();
|
|
18
|
+
expectTypeOf<PublicMime>().toEqualTypeOf<string>();
|
|
19
|
+
expectTypeOf<string>().toEqualTypeOf<PublicMime>();
|
|
20
|
+
|
|
21
|
+
const acceptsSupportedMime = (mime: ExpectedSupportedMime): ExpectedSupportedMime => mime;
|
|
22
|
+
acceptsSupportedMime('image/png');
|
|
23
|
+
acceptsSupportedMime('image/jpeg');
|
|
24
|
+
// @ts-expect-error Unsupported MIME values remain outside the private vocabulary.
|
|
25
|
+
acceptsSupportedMime('image/gif');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('derives the private type guard from one readonly MIME owner', () => {
|
|
29
|
+
expect(decoderSource).toContain(
|
|
30
|
+
"const SUPPORTED_MIMES = ['image/png', 'image/jpeg'] as const;",
|
|
31
|
+
);
|
|
32
|
+
expect(decoderSource).toContain('type SupportedMime = (typeof SUPPORTED_MIMES)[number];');
|
|
33
|
+
expect(decoderSource).toContain(
|
|
34
|
+
'function isSupportedMime(mime: string): mime is SupportedMime',
|
|
35
|
+
);
|
|
36
|
+
expect(decoderSource).toContain(
|
|
37
|
+
'return SUPPORTED_MIMES.some((supportedMime) => supportedMime === mime);',
|
|
38
|
+
);
|
|
39
|
+
expect(decoderSource).not.toContain("return mime === 'image/png' || mime === 'image/jpeg';");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ok } from '@forgeax/engine-types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { defineAssetKind } from '../asset-kind.js';
|
|
4
|
+
import { AssetGraph } from '../internal/asset-graph.js';
|
|
5
|
+
import { DecoderRegistry } from '../internal/decoder-registry.js';
|
|
6
|
+
|
|
7
|
+
const kind = defineAssetKind<string, 'host-blob'>('host-blob');
|
|
8
|
+
|
|
9
|
+
describe('asset runtime terminal lifecycle', () => {
|
|
10
|
+
it('shares leases for one decoder identity and rejects a different owner', () => {
|
|
11
|
+
const decoders = new DecoderRegistry({ scopeId: 'scope' });
|
|
12
|
+
const decoder = { decode: async () => ok('value') };
|
|
13
|
+
const first = decoders.install(kind, decoder);
|
|
14
|
+
const second = decoders.install(kind, decoder);
|
|
15
|
+
|
|
16
|
+
expect(decoders.has(kind)).toBe(true);
|
|
17
|
+
expect(() => decoders.install(kind, { decode: async () => ok('other') })).toThrow(
|
|
18
|
+
'duplicate decoder kind "host-blob"',
|
|
19
|
+
);
|
|
20
|
+
first.dispose();
|
|
21
|
+
expect(decoders.has(kind)).toBe(true);
|
|
22
|
+
second.dispose();
|
|
23
|
+
expect(decoders.has(kind)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('normalizes decoder throws and preserves the terminal error shape', async () => {
|
|
27
|
+
const decoders = new DecoderRegistry({ scopeId: 'scope' });
|
|
28
|
+
const lease = decoders.install(kind, {
|
|
29
|
+
async decode() {
|
|
30
|
+
throw new Error('boom');
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
const result = await decoders.decode(kind, {
|
|
34
|
+
envelope: { guid: 'guid', kind: kind.kind, payload: 'raw', refs: [], artifacts: {} },
|
|
35
|
+
artifacts: { read: async () => ok(new Uint8Array()) },
|
|
36
|
+
signal: new AbortController().signal,
|
|
37
|
+
});
|
|
38
|
+
lease.dispose();
|
|
39
|
+
expect(result.ok).toBe(false);
|
|
40
|
+
if (!result.ok) expect(result.error.code).toBe('asset-decode-failed');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('drops a late non-cancellable result after dispose and zeroes resources', async () => {
|
|
44
|
+
let resolve: ((value: { ok: true; value: string }) => void) | undefined;
|
|
45
|
+
const pending = new Promise<{ ok: true; value: string }>((done) => {
|
|
46
|
+
resolve = done;
|
|
47
|
+
});
|
|
48
|
+
const graph = new AssetGraph<{ readonly refs: readonly string[]; readonly value: string }>({
|
|
49
|
+
read: async (guid) => {
|
|
50
|
+
await pending;
|
|
51
|
+
return ok({ value: guid, refs: [] });
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const request = graph.load('late');
|
|
55
|
+
graph.dispose();
|
|
56
|
+
resolve?.({ ok: true, value: 'late' });
|
|
57
|
+
expect(await request).toEqual({
|
|
58
|
+
ok: false,
|
|
59
|
+
error: expect.objectContaining({ code: 'asset-runtime-disposed' }),
|
|
60
|
+
});
|
|
61
|
+
expect(graph.snapshot().resources).toBe(0);
|
|
62
|
+
expect(graph.snapshot().pending).toBe(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns detached frozen observations and isolates listener failure', async () => {
|
|
66
|
+
const graph = new AssetGraph<{ readonly refs: readonly string[]; readonly value: string }>({
|
|
67
|
+
read: async (guid) => ok({ value: guid, refs: [] }),
|
|
68
|
+
});
|
|
69
|
+
const seen: string[] = [];
|
|
70
|
+
graph.subscribe(() => {
|
|
71
|
+
throw new Error('observer failure');
|
|
72
|
+
});
|
|
73
|
+
graph.subscribe((snapshot) => seen.push(snapshot.ready.join(',')));
|
|
74
|
+
await graph.load('asset');
|
|
75
|
+
const snapshot = graph.snapshot();
|
|
76
|
+
expect(Object.isFrozen(snapshot)).toBe(true);
|
|
77
|
+
expect(seen).toContain('asset');
|
|
78
|
+
expect(snapshot.counters.listenerFailures).toBeGreaterThan(0);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AssetKind } from '@forgeax/engine-types';
|
|
2
|
+
|
|
3
|
+
/** Create the type witness shared by one custom decoder lease and its loads. */
|
|
4
|
+
export function defineAssetKind<P, K extends string = string>(kind: K): AssetKind<P, K> {
|
|
5
|
+
return Object.freeze({ kind }) as AssetKind<P, K>;
|
|
6
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ASSET_ERROR_HINTS,
|
|
3
|
+
AssetError,
|
|
4
|
+
type CatalogDelta,
|
|
5
|
+
type CatalogEntry,
|
|
6
|
+
err,
|
|
7
|
+
ok,
|
|
8
|
+
type ResourceRevision,
|
|
9
|
+
type Result,
|
|
10
|
+
type RuntimeAssetBinding,
|
|
11
|
+
} from '@forgeax/engine-types';
|
|
12
|
+
export type CatalogListener = (delta: CatalogDelta) => void;
|
|
13
|
+
|
|
14
|
+
/** Read-only catalog source backed by static entries or a canonical URL. */
|
|
15
|
+
export interface CatalogSource {
|
|
16
|
+
enumerate(): Promise<Result<readonly CatalogEntry[], AssetError>>;
|
|
17
|
+
subscribe(listener: CatalogListener): () => void;
|
|
18
|
+
readonly expectedScope?: Pick<RuntimeAssetBinding, 'scopeId' | 'generation'>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create a read-only catalog source while preserving the schema SSOT.
|
|
23
|
+
*
|
|
24
|
+
* Static and fetched sources use the same `CatalogEntry` and revision fields;
|
|
25
|
+
* an expected revision rejects unverified data before it reaches consumers.
|
|
26
|
+
*/
|
|
27
|
+
export function createCatalogSource(options: {
|
|
28
|
+
readonly url?: string;
|
|
29
|
+
readonly entries?: readonly CatalogEntry[];
|
|
30
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
31
|
+
readonly expectedRevision?: ResourceRevision;
|
|
32
|
+
readonly expectedScope?: Pick<RuntimeAssetBinding, 'scopeId' | 'generation'>;
|
|
33
|
+
readonly subscribe?: (listener: CatalogListener) => () => void;
|
|
34
|
+
}): CatalogSource {
|
|
35
|
+
const entries = options.entries;
|
|
36
|
+
return {
|
|
37
|
+
async enumerate() {
|
|
38
|
+
if (entries !== undefined) {
|
|
39
|
+
if (options.expectedRevision === undefined) return ok(entries);
|
|
40
|
+
const actualRevisions = entries.flatMap((entry) =>
|
|
41
|
+
entry.revision === undefined ? [] : [entry.revision],
|
|
42
|
+
);
|
|
43
|
+
const matches =
|
|
44
|
+
actualRevisions.length > 0 &&
|
|
45
|
+
actualRevisions.every(
|
|
46
|
+
(revision) =>
|
|
47
|
+
revision.digest === options.expectedRevision?.digest &&
|
|
48
|
+
revision.observedAt === options.expectedRevision?.observedAt &&
|
|
49
|
+
revision.rootId === options.expectedRevision?.rootId,
|
|
50
|
+
);
|
|
51
|
+
if (!matches) {
|
|
52
|
+
return err(
|
|
53
|
+
new AssetError({
|
|
54
|
+
code: 'asset-parse-failed',
|
|
55
|
+
expected: 'static catalog entries to carry the expected producer revision',
|
|
56
|
+
hint: 'restore a verified catalog revision before applying the source',
|
|
57
|
+
detail: { expectedRevision: options.expectedRevision, actualRevisions } as never,
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return ok(entries);
|
|
62
|
+
}
|
|
63
|
+
if (options.url === undefined) {
|
|
64
|
+
return err(
|
|
65
|
+
new AssetError({
|
|
66
|
+
code: 'catalog-source-unconfigured',
|
|
67
|
+
expected: 'a configured catalog source',
|
|
68
|
+
hint: ASSET_ERROR_HINTS['catalog-source-unconfigured'],
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const response = await (options.fetch ?? globalThis.fetch)(options.url);
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
return err(
|
|
75
|
+
new AssetError({
|
|
76
|
+
code: 'asset-fetch-failed',
|
|
77
|
+
expected: 'the configured Catalog URL to return HTTP 200',
|
|
78
|
+
hint: 'verify the producer Catalog URL and republish the current snapshot',
|
|
79
|
+
detail: { sourcePath: options.url },
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
let raw: unknown;
|
|
84
|
+
try {
|
|
85
|
+
raw = await response.json();
|
|
86
|
+
} catch {
|
|
87
|
+
return err(
|
|
88
|
+
new AssetError({
|
|
89
|
+
code: 'asset-parse-failed',
|
|
90
|
+
expected: 'the configured Catalog URL to return JSON',
|
|
91
|
+
hint: 'repair the producer Catalog before loading the current publication',
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const rows = scopedRows(raw, options.expectedScope);
|
|
96
|
+
if (!rows.ok) return rows;
|
|
97
|
+
const result = rows.value.map((row) => ({ ...row }));
|
|
98
|
+
return ok(result);
|
|
99
|
+
},
|
|
100
|
+
subscribe(listener) {
|
|
101
|
+
return options.subscribe?.(listener) ?? (() => {});
|
|
102
|
+
},
|
|
103
|
+
...(options.expectedScope === undefined ? {} : { expectedScope: options.expectedScope }),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function scopedRows(
|
|
108
|
+
raw: unknown,
|
|
109
|
+
expectedScope: Pick<RuntimeAssetBinding, 'scopeId' | 'generation'> | undefined,
|
|
110
|
+
): Result<readonly CatalogEntry[], AssetError> {
|
|
111
|
+
if (Array.isArray(raw)) return ok(raw as readonly CatalogEntry[]);
|
|
112
|
+
if (raw === null || typeof raw !== 'object') return parseRowsError('Catalog JSON rows');
|
|
113
|
+
const snapshot = raw as Record<string, unknown>;
|
|
114
|
+
if (!Array.isArray(snapshot.entries)) return parseRowsError('Catalog JSON entries');
|
|
115
|
+
if (expectedScope !== undefined) {
|
|
116
|
+
if (
|
|
117
|
+
snapshot.scopeId !== expectedScope.scopeId ||
|
|
118
|
+
snapshot.generation !== expectedScope.generation ||
|
|
119
|
+
snapshot.authority !== 'authoritative'
|
|
120
|
+
) {
|
|
121
|
+
return err(
|
|
122
|
+
new AssetError({
|
|
123
|
+
code: 'asset-parse-failed',
|
|
124
|
+
expected: 'the Catalog snapshot to match the active scope and generation',
|
|
125
|
+
hint: 'restore the authoritative producer Catalog for the active runtime scope',
|
|
126
|
+
detail: {
|
|
127
|
+
expectedScope,
|
|
128
|
+
actualScope: { scopeId: snapshot.scopeId, generation: snapshot.generation },
|
|
129
|
+
} as never,
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return ok(snapshot.entries as readonly CatalogEntry[]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseRowsError(expected: string): Result<never, AssetError> {
|
|
138
|
+
return err(
|
|
139
|
+
new AssetError({
|
|
140
|
+
code: 'asset-parse-failed',
|
|
141
|
+
expected,
|
|
142
|
+
hint: 'repair the producer Catalog before loading the current publication',
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
AssetDecoder,
|
|
3
|
+
AssetDecoderInput,
|
|
4
|
+
AssetDecoderLease,
|
|
5
|
+
AssetKind,
|
|
6
|
+
AssetLoadError,
|
|
7
|
+
AssetPublicationTuple,
|
|
8
|
+
CatalogEntry,
|
|
9
|
+
PackV2,
|
|
10
|
+
Result,
|
|
11
|
+
} from '@forgeax/engine-types';
|
|
12
|
+
|
|
13
|
+
export { defineAssetKind } from './asset-kind.js';
|
|
14
|
+
export { type CatalogListener, type CatalogSource, createCatalogSource } from './catalog-source.js';
|
|
15
|
+
export {
|
|
16
|
+
type AssetLoadOptions,
|
|
17
|
+
type AssetRegistry,
|
|
18
|
+
type AssetRegistryOptions,
|
|
19
|
+
type AssetRegistrySnapshot,
|
|
20
|
+
type AssetRegistrySnapshotCounters,
|
|
21
|
+
createAssetRegistry,
|
|
22
|
+
} from './internal/load-asset.js';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { AssetLoadError, Result } from '@forgeax/engine-types';
|
|
2
|
+
|
|
3
|
+
export interface ArtifactCacheSnapshot {
|
|
4
|
+
readonly entries: number;
|
|
5
|
+
readonly pending: number;
|
|
6
|
+
readonly hits: number;
|
|
7
|
+
readonly misses: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class ArtifactCache {
|
|
11
|
+
private readonly values = new Map<string, Uint8Array>();
|
|
12
|
+
private readonly pending = new Map<string, Promise<Result<Uint8Array, AssetLoadError>>>();
|
|
13
|
+
private hits = 0;
|
|
14
|
+
private misses = 0;
|
|
15
|
+
|
|
16
|
+
read(
|
|
17
|
+
contentAddress: string,
|
|
18
|
+
reader: () => Promise<Result<Uint8Array, AssetLoadError>>,
|
|
19
|
+
): Promise<Result<Uint8Array, AssetLoadError>> {
|
|
20
|
+
const value = this.values.get(contentAddress);
|
|
21
|
+
if (value !== undefined) {
|
|
22
|
+
this.hits += 1;
|
|
23
|
+
return Promise.resolve({ ok: true, value: new Uint8Array(value) } as Result<
|
|
24
|
+
Uint8Array,
|
|
25
|
+
AssetLoadError
|
|
26
|
+
>);
|
|
27
|
+
}
|
|
28
|
+
const pending = this.pending.get(contentAddress);
|
|
29
|
+
if (pending !== undefined) {
|
|
30
|
+
this.hits += 1;
|
|
31
|
+
return pending;
|
|
32
|
+
}
|
|
33
|
+
this.misses += 1;
|
|
34
|
+
const request = Promise.resolve()
|
|
35
|
+
.then(reader)
|
|
36
|
+
.then((result) => {
|
|
37
|
+
if (result.ok) this.values.set(contentAddress, new Uint8Array(result.value));
|
|
38
|
+
return result;
|
|
39
|
+
})
|
|
40
|
+
.finally(() => {
|
|
41
|
+
if (this.pending.get(contentAddress) === request) this.pending.delete(contentAddress);
|
|
42
|
+
});
|
|
43
|
+
this.pending.set(contentAddress, request);
|
|
44
|
+
return request;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
clear(contentAddress?: string): void {
|
|
48
|
+
if (contentAddress === undefined) {
|
|
49
|
+
this.values.clear();
|
|
50
|
+
this.pending.clear();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
this.values.delete(contentAddress);
|
|
54
|
+
this.pending.delete(contentAddress);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
snapshot(): ArtifactCacheSnapshot {
|
|
58
|
+
return Object.freeze({
|
|
59
|
+
entries: this.values.size,
|
|
60
|
+
pending: this.pending.size,
|
|
61
|
+
hits: this.hits,
|
|
62
|
+
misses: this.misses,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|