@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
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@forgeax/engine-assets-runtime",
3
+ "version": "0.1.2",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "sideEffects": false,
8
+ "description": "Single-scope Catalog, Pack, typed decoder, dependency graph, and terminal lifecycle for forgeax-engine.",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.mjs"
13
+ },
14
+ "./internal": {
15
+ "types": "./dist/internal.d.ts",
16
+ "import": "./dist/internal.mjs"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "main": "./dist/index.mjs",
21
+ "types": "./dist/index.d.ts",
22
+ "files": [
23
+ "dist",
24
+ "src",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "dependencies": {
29
+ "@forgeax/engine-types": "0.1.2"
30
+ },
31
+ "forgeax": {
32
+ "metrics": {
33
+ "bundle-size": {
34
+ "enabled": true,
35
+ "path": "dist/index.mjs",
36
+ "compression": "gzip"
37
+ },
38
+ "fps": {
39
+ "enabled": false,
40
+ "reason": "asset registry / loader library, no runtime canvas; fps reported by hello-* apps that consume it"
41
+ },
42
+ "bench": {
43
+ "enabled": true,
44
+ "reportSchema": "asset-runtime-core",
45
+ "reportPath": "report/assets-runtime-core.json"
46
+ },
47
+ "gate": {
48
+ "enabled": false,
49
+ "reason": "no package-level binary gate; smoke gate covered by hello-* / learn-render apps that load assets by GUID"
50
+ },
51
+ "spike-report": {
52
+ "enabled": false,
53
+ "reason": "not a spike package; asset cluster extracted from runtime in feat-20260705-runtime-tier2-decomposition"
54
+ }
55
+ }
56
+ },
57
+ "scripts": {
58
+ "build": "tsup",
59
+ "test": "vitest run",
60
+ "bench:asset-runtime-baseline": "node ../../scripts/metrics/asset-runtime-baseline.mjs",
61
+ "bench:asset-runtime-core": "node ../../scripts/metrics/asset-runtime-bench.mjs"
62
+ }
63
+ }
@@ -0,0 +1,113 @@
1
+ import type { AssetLoadError } from '@forgeax/engine-types';
2
+ import { err, ok } from '@forgeax/engine-types';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { AssetGraph } from '../internal/asset-graph.js';
5
+
6
+ type Value = { readonly refs: readonly string[]; readonly value: string };
7
+
8
+ const failure = (guid: string): AssetLoadError => ({
9
+ code: 'asset-dependency-failed',
10
+ expected: 'a readable dependency',
11
+ hint: 'repair the dependency publication and retry',
12
+ detail: { guid, dependencyGuid: guid },
13
+ });
14
+
15
+ describe('AssetGraph', () => {
16
+ it('deduplicates shared reads, bounds concurrency, and promotes a closure atomically', async () => {
17
+ let active = 0;
18
+ let peak = 0;
19
+ const reads = vi.fn(async (guid: string) => {
20
+ active += 1;
21
+ peak = Math.max(peak, active);
22
+ await new Promise((resolve) => setTimeout(resolve, 1));
23
+ active -= 1;
24
+ if (guid === 'root') return ok({ value: guid, refs: ['shared', 'left'] });
25
+ if (guid === 'left') return ok({ value: guid, refs: ['shared'] });
26
+ return ok({ value: guid, refs: [] });
27
+ });
28
+ const graph = new AssetGraph<Value>({ read: reads, maxConcurrentReads: 2 });
29
+
30
+ const [first, second] = await Promise.all([graph.load('root'), graph.load('root')]);
31
+ expect(first).toEqual(second);
32
+ expect(reads.mock.calls.filter(([guid]) => guid === 'shared')).toHaveLength(1);
33
+ expect(peak).toBeLessThanOrEqual(2);
34
+ expect(graph.snapshot().ready).toEqual(['left', 'root', 'shared']);
35
+ expect(graph.snapshot().pending).toBe(0);
36
+ });
37
+
38
+ it('terminates cycles as one SCC and does not deadlock', async () => {
39
+ const reads = vi.fn(async (guid: string) =>
40
+ ok<Value>({ value: guid, refs: [guid === 'a' ? 'b' : 'a'] }),
41
+ );
42
+ const graph = new AssetGraph<Value>({ read: reads, maxConcurrentReads: 1 });
43
+
44
+ expect((await graph.load('a')).ok).toBe(true);
45
+ expect(reads).toHaveBeenCalledTimes(2);
46
+ expect(graph.snapshot().sccs).toContainEqual(['a', 'b']);
47
+ });
48
+
49
+ it('clears failed pending work so a repaired publication can retry', async () => {
50
+ let repaired = false;
51
+ const reads = vi.fn(async (guid: string) => {
52
+ if (guid === 'child' && !repaired) return err(failure(guid));
53
+ return ok<Value>({ value: guid, refs: [] });
54
+ });
55
+ const graph = new AssetGraph<Value>({ read: reads });
56
+
57
+ expect((await graph.load('child')).ok).toBe(false);
58
+ expect(graph.snapshot().pending).toBe(0);
59
+ repaired = true;
60
+ expect((await graph.load('child')).ok).toBe(true);
61
+ expect(reads).toHaveBeenCalledTimes(2);
62
+ });
63
+
64
+ it('fences an in-flight read after publication invalidation', async () => {
65
+ let release: (() => void) | undefined;
66
+ let reads = 0;
67
+ const graph = new AssetGraph<Value>({
68
+ read: async (guid) => {
69
+ reads += 1;
70
+ await new Promise<void>((resolve) => {
71
+ release = resolve;
72
+ });
73
+ return ok<Value>({ value: guid, refs: [] });
74
+ },
75
+ });
76
+
77
+ const first = graph.load('asset');
78
+ graph.invalidate('asset');
79
+ release?.();
80
+ expect(await first).toEqual({
81
+ ok: false,
82
+ error: expect.objectContaining({ code: 'asset-superseded' }),
83
+ });
84
+ expect(reads).toBe(1);
85
+ });
86
+
87
+ it('resolves a queued permit with the terminal dispose result', async () => {
88
+ let release: (() => void) | undefined;
89
+ const graph = new AssetGraph<Value>({
90
+ maxConcurrentReads: 1,
91
+ read: async (guid) => {
92
+ await new Promise<void>((resolve) => {
93
+ release = resolve;
94
+ });
95
+ return ok<Value>({ value: guid, refs: [] });
96
+ },
97
+ });
98
+
99
+ const first = graph.load('first');
100
+ await Promise.resolve();
101
+ const second = graph.load('second');
102
+ graph.dispose();
103
+ expect(await second).toEqual({
104
+ ok: false,
105
+ error: expect.objectContaining({ code: 'asset-runtime-disposed' }),
106
+ });
107
+ release?.();
108
+ expect(await first).toEqual({
109
+ ok: false,
110
+ error: expect.objectContaining({ code: 'asset-runtime-disposed' }),
111
+ });
112
+ });
113
+ });
@@ -0,0 +1,14 @@
1
+ import type { AssetKindPayload } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { defineAssetKind } from '../asset-kind.js';
4
+
5
+ describe('defineAssetKind', () => {
6
+ it('freezes the runtime kind witness while preserving its payload type', () => {
7
+ const kind = defineAssetKind<{ readonly lines: readonly string[] }, 'dialogue'>('dialogue');
8
+ const payload: AssetKindPayload<typeof kind> = { lines: ['hello'] };
9
+
10
+ expect(kind).toEqual({ kind: 'dialogue' });
11
+ expect(Object.isFrozen(kind)).toBe(true);
12
+ expect(payload.lines).toEqual(['hello']);
13
+ });
14
+ });
@@ -0,0 +1,161 @@
1
+ import type { AssetDecoder, CatalogDelta, CatalogEntry } from '@forgeax/engine-types';
2
+ import { ok } from '@forgeax/engine-types';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { defineAssetKind } from '../asset-kind.js';
5
+ import { createCatalogSource } from '../catalog-source.js';
6
+ import { createAssetRegistry } from '../internal/load-asset.js';
7
+
8
+ const ROOT = '00000000-0000-7000-8000-000000000021';
9
+ const CHILD = '00000000-0000-7000-8000-000000000022';
10
+ const PACKAGE_URL = 'https://assets.test/runtime.pack.json';
11
+ const kind = defineAssetKind<unknown, 'host-blob'>('host-blob');
12
+
13
+ function row(guid: string, refs: readonly string[] = [], revision = 1): CatalogEntry {
14
+ return {
15
+ guid,
16
+ packageUrl: PACKAGE_URL,
17
+ kind: kind.kind,
18
+ sourcePath: `fixtures/${guid}`,
19
+ revision: { rootId: 'root', digest: `sha256:catalog-${revision}`, observedAt: revision },
20
+ publication: {
21
+ schemaVersion: 'asset-publication/1',
22
+ sourcePath: `fixtures/${guid}`,
23
+ sourceRevision: 'source-1',
24
+ generation: 1,
25
+ digest: 'sha256:pack',
26
+ outputSetDigest: 'sha256:outputs',
27
+ outputs: [{ guid, sourceKey: guid, kind: kind.kind, digest: `sha256:${guid}`, refs }],
28
+ receipt: {
29
+ schemaVersion: 'asset-publication-receipt/1',
30
+ sourcePath: `fixtures/${guid}`,
31
+ sourceRevision: 'source-1',
32
+ inputFingerprint: 'input-1',
33
+ outputDigest: 'sha256:outputs',
34
+ outputSetDigest: 'sha256:outputs',
35
+ externalEvidence: [],
36
+ },
37
+ externalEvidence: [],
38
+ },
39
+ };
40
+ }
41
+
42
+ describe('AssetRegistry integration', () => {
43
+ it('loads a real Pack closure through the owner decoder and reuses one Pack read', async () => {
44
+ let packReads = 0;
45
+ let artifactReads = 0;
46
+ let publishCatalog: ((delta: CatalogDelta) => void) | undefined;
47
+ const fetcher: typeof globalThis.fetch = async (input) => {
48
+ const url = String(input);
49
+ if (url === PACKAGE_URL) {
50
+ packReads += 1;
51
+ return new Response(
52
+ JSON.stringify({
53
+ schemaVersion: '2.0.0',
54
+ kind: 'internal-text-package',
55
+ scopeId: 'scope',
56
+ generation: 1,
57
+ digest: 'sha256:pack',
58
+ outputSetDigest: 'sha256:outputs',
59
+ assets: [
60
+ {
61
+ guid: ROOT,
62
+ kind: kind.kind,
63
+ payload: { label: 'root' },
64
+ refs: [CHILD],
65
+ artifacts: {
66
+ preview: {
67
+ path: 'preview.bin',
68
+ mediaType: 'application/octet-stream',
69
+ contentEncoding: 'identity',
70
+ byteLength: 3,
71
+ integrity: {
72
+ algorithm: 'sha256',
73
+ digest:
74
+ 'sha256:039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81',
75
+ },
76
+ },
77
+ },
78
+ },
79
+ {
80
+ guid: CHILD,
81
+ kind: kind.kind,
82
+ payload: { label: 'child' },
83
+ refs: [],
84
+ artifacts: {},
85
+ },
86
+ ],
87
+ }),
88
+ );
89
+ }
90
+ if (url === 'https://assets.test/preview.bin') {
91
+ artifactReads += 1;
92
+ return new Response(new Uint8Array([1, 2, 3]));
93
+ }
94
+ return new Response('not found', { status: 404 });
95
+ };
96
+ const decoder: AssetDecoder<unknown> = {
97
+ decode: async ({ envelope, artifacts }) => {
98
+ if (envelope.guid === ROOT) {
99
+ const preview = await artifacts.read(
100
+ envelope.artifacts.preview as NonNullable<typeof envelope.artifacts.preview>,
101
+ );
102
+ if (!preview.ok) return preview;
103
+ return ok({
104
+ child: envelope.refs[0],
105
+ inline: (envelope.payload as { label: string }).label,
106
+ preview: [...preview.value],
107
+ });
108
+ }
109
+ return ok({ inline: (envelope.payload as { label: string }).label });
110
+ },
111
+ };
112
+ const core = createAssetRegistry({
113
+ catalog: createCatalogSource({
114
+ entries: [row(ROOT, [CHILD]), row(CHILD)],
115
+ expectedScope: { scopeId: 'scope', generation: 1 },
116
+ subscribe(listener) {
117
+ publishCatalog = listener;
118
+ return () => {};
119
+ },
120
+ }),
121
+ fetcher,
122
+ maxConcurrentReads: 2,
123
+ });
124
+ core.installDecoder(kind, decoder);
125
+
126
+ const result = await core.load(ROOT, kind);
127
+
128
+ expect(result).toEqual({
129
+ ok: true,
130
+ value: { child: CHILD, inline: 'root', preview: [1, 2, 3] },
131
+ });
132
+ expect(result.ok && Object.isFrozen(result.value)).toBe(true);
133
+ expect(packReads).toBe(1);
134
+ expect(artifactReads).toBe(1);
135
+ expect(core.snapshot().ready).toHaveLength(2);
136
+ expect(core.snapshot().resources).toBe(2);
137
+ publishCatalog?.({
138
+ added: [],
139
+ changed: [row(ROOT, [CHILD], 2)],
140
+ removed: [],
141
+ });
142
+ expect(core.snapshot().resources).toBe(1);
143
+ expect(core.snapshot().ready).toEqual([CHILD]);
144
+
145
+ publishCatalog?.({
146
+ added: [],
147
+ changed: [row(ROOT, [CHILD], 3)],
148
+ removed: [],
149
+ revisions: {
150
+ baseline: [{ rootId: 'root', revision: 1 }],
151
+ current: [{ rootId: 'root', revision: 3 }],
152
+ },
153
+ });
154
+ expect(core.snapshot().resources).toBe(0);
155
+ const recovered = await core.load(ROOT, kind);
156
+ expect(recovered.ok).toBe(true);
157
+ expect(core.snapshot().resources).toBe(2);
158
+ expect(packReads).toBe(1);
159
+ core.dispose();
160
+ });
161
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, expectTypeOf, it } from 'vitest';
2
+ import type { CatalogSource } from '../catalog-source.js';
3
+ import {
4
+ type AssetRegistry,
5
+ type AssetRegistryOptions,
6
+ type AssetRegistrySnapshot,
7
+ createAssetRegistry,
8
+ } from '../index.js';
9
+
10
+ describe('AssetRegistry public API', () => {
11
+ it('binds the factory and options to the root registry interface', () => {
12
+ const options = { catalog: null as unknown as CatalogSource } satisfies AssetRegistryOptions;
13
+ expectTypeOf(createAssetRegistry(options)).toEqualTypeOf<AssetRegistry>();
14
+ });
15
+
16
+ it('exposes the graph observation through registry snapshot types', () => {
17
+ const registry = null as unknown as AssetRegistry;
18
+ expectTypeOf(registry.snapshot()).toEqualTypeOf<AssetRegistrySnapshot>();
19
+ expectTypeOf(registry.snapshot().ready).toEqualTypeOf<readonly string[]>();
20
+ expectTypeOf(registry.snapshot().counters.loads).toEqualTypeOf<number>();
21
+ });
22
+
23
+ it('keeps renderer payload projection out of the five-action root surface', () => {
24
+ const registry = null as unknown as AssetRegistry;
25
+ expectTypeOf(registry.installDecoder).toBeFunction();
26
+ expectTypeOf(registry.load).toBeFunction();
27
+ expectTypeOf(registry.snapshot).toBeFunction();
28
+ expectTypeOf(registry.subscribe).toBeFunction();
29
+ expectTypeOf(registry.dispose).toBeFunction();
30
+ });
31
+ });
@@ -0,0 +1,79 @@
1
+ import type { CatalogEntry } from '@forgeax/engine-types';
2
+ import { ok } from '@forgeax/engine-types';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { defineAssetKind } from '../asset-kind.js';
5
+ import { createCatalogSource } from '../catalog-source.js';
6
+ import { createAssetRegistry } from '../internal/load-asset.js';
7
+
8
+ const GUID = '00000000-0000-7000-8000-000000000011';
9
+ const kind = defineAssetKind<string, 'host-blob'>('host-blob');
10
+
11
+ function row(): CatalogEntry {
12
+ return {
13
+ guid: GUID,
14
+ packageUrl: '/asset.pack.json',
15
+ kind: kind.kind,
16
+ sourcePath: 'test',
17
+ publication: {
18
+ schemaVersion: 'asset-publication/1',
19
+ sourcePath: 'test',
20
+ sourceRevision: 'source-1',
21
+ generation: 1,
22
+ digest: 'sha256:pack',
23
+ outputSetDigest: 'sha256:outputs',
24
+ outputs: [
25
+ { guid: GUID, sourceKey: 'test', kind: kind.kind, digest: 'sha256:asset', refs: [] },
26
+ ],
27
+ receipt: {
28
+ schemaVersion: 'asset-publication-receipt/1',
29
+ sourcePath: 'test',
30
+ sourceRevision: 'source-1',
31
+ inputFingerprint: 'input-1',
32
+ outputDigest: 'sha256:outputs',
33
+ outputSetDigest: 'sha256:outputs',
34
+ externalEvidence: [],
35
+ },
36
+ externalEvidence: [],
37
+ },
38
+ };
39
+ }
40
+
41
+ describe('AssetRegistry lifecycle', () => {
42
+ it('does not promote a late Pack result after dispose', async () => {
43
+ let resolveFetch: ((response: Response) => void) | undefined;
44
+ const fetcher = () =>
45
+ new Promise<Response>((resolve) => {
46
+ resolveFetch = resolve;
47
+ });
48
+ const core = createAssetRegistry({
49
+ catalog: createCatalogSource({ entries: [row()] }),
50
+ fetcher,
51
+ scopeId: 'scope',
52
+ generation: 1,
53
+ });
54
+ core.installDecoder(kind, {
55
+ decode: async ({ envelope }) => ok(envelope.payload as string),
56
+ });
57
+ const request = core.load(GUID, kind);
58
+ core.dispose();
59
+ resolveFetch?.(
60
+ new Response(
61
+ JSON.stringify({
62
+ schemaVersion: '2.0.0',
63
+ kind: 'internal-text-package',
64
+ scopeId: 'scope',
65
+ generation: 1,
66
+ digest: 'sha256:pack',
67
+ outputSetDigest: 'sha256:outputs',
68
+ assets: [{ guid: GUID, kind: kind.kind, payload: 'value', refs: [], artifacts: {} }],
69
+ }),
70
+ ),
71
+ );
72
+ expect(await request).toEqual({
73
+ ok: false,
74
+ error: expect.objectContaining({ code: 'asset-runtime-disposed' }),
75
+ });
76
+ expect(core.snapshot().pending).toBe(0);
77
+ expect(core.snapshot().resources).toBe(0);
78
+ });
79
+ });
@@ -0,0 +1,23 @@
1
+ import { ok } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { AssetGraph } from '../internal/asset-graph.js';
4
+
5
+ describe('asset runtime snapshot counters', () => {
6
+ it('reports no-change loads without new reads and keeps bounded counters', async () => {
7
+ let reads = 0;
8
+ const graph = new AssetGraph<{ readonly refs: readonly string[]; readonly value: string }>({
9
+ read: async (guid) => {
10
+ reads += 1;
11
+ return ok({ value: guid, refs: [] });
12
+ },
13
+ });
14
+ await graph.load('same');
15
+ const before = graph.snapshot();
16
+ await graph.load('same');
17
+ const after = graph.snapshot();
18
+ expect(reads).toBe(1);
19
+ expect(after.counters.noChange).toBe(before.counters.noChange + 1);
20
+ expect(after.resources).toBe(1);
21
+ expect(after.pending).toBe(0);
22
+ });
23
+ });