@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.
- package/README.md +51 -3
- package/dist/.tsbuildinfo +1 -1
- package/dist/__tests__/owner-chain.integration.test.d.ts +2 -0
- package/dist/__tests__/owner-chain.integration.test.d.ts.map +1 -0
- package/dist/__tests__/package-finalizer.contract.test.d.ts +2 -0
- package/dist/__tests__/package-finalizer.contract.test.d.ts.map +1 -0
- package/dist/__tests__/runtime.browser.test.d.ts +2 -0
- package/dist/__tests__/runtime.browser.test.d.ts.map +1 -0
- package/dist/__tests__/scanner-inventory.contract.test.d.ts +2 -0
- package/dist/__tests__/scanner-inventory.contract.test.d.ts.map +1 -0
- package/dist/artifact-path.mjs +53 -0
- package/dist/artifact-path.mjs.map +1 -0
- package/dist/atlas/shelf-pack.d.ts.map +1 -1
- package/dist/build.d.ts +17 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.mjs +10168 -0
- package/dist/build.mjs.map +1 -0
- package/dist/builtin.d.ts +20 -0
- package/dist/builtin.d.ts.map +1 -1
- package/dist/builtin.mjs +141 -0
- package/dist/builtin.mjs.map +1 -0
- package/dist/catalog-builder.d.ts +43 -0
- package/dist/catalog-builder.d.ts.map +1 -0
- package/dist/catalog-delta.d.ts +11 -0
- package/dist/catalog-delta.d.ts.map +1 -0
- package/dist/catalog-projection.d.ts +91 -0
- package/dist/catalog-projection.d.ts.map +1 -0
- package/dist/cli-asset.mjs +231 -484
- package/dist/cli-asset.mjs.map +1 -1
- package/dist/index.mjs +83 -1
- package/dist/index.mjs.map +1 -1
- package/dist/material-cook.d.ts +2 -0
- package/dist/material-cook.d.ts.map +1 -0
- package/dist/material-cook.mjs +193 -0
- package/dist/material-cook.mjs.map +1 -0
- package/dist/mesh-bin-contract.mjs +96 -0
- package/dist/mesh-bin-contract.mjs.map +1 -0
- package/dist/native-cooker-registry.d.ts +19 -0
- package/dist/native-cooker-registry.d.ts.map +1 -1
- package/dist/native-cooker.mjs +37 -0
- package/dist/native-cooker.mjs.map +1 -1
- package/dist/package-finalizer.d.ts +121 -0
- package/dist/package-finalizer.d.ts.map +1 -0
- package/dist/resolve-asset-source.d.ts.map +1 -1
- package/dist/resolve-asset-source.mjs +16 -6
- package/dist/resolve-asset-source.mjs.map +1 -1
- package/dist/runtime-publication.d.ts +44 -0
- package/dist/runtime-publication.d.ts.map +1 -0
- package/dist/runtime.d.ts +8 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.mjs +7261 -0
- package/dist/runtime.mjs.map +1 -0
- package/dist/scanner.d.ts +87 -54
- package/dist/scanner.d.ts.map +1 -1
- package/dist/scanner.mjs +258 -454
- package/dist/scanner.mjs.map +1 -1
- package/dist/scriptable-pack-node.d.ts +3 -8
- package/dist/scriptable-pack-node.d.ts.map +1 -1
- package/dist/scriptable-pack-node.mjs +93 -9
- package/dist/scriptable-pack-node.mjs.map +1 -1
- package/dist/scriptable-pack-worker.mjs +1 -0
- package/dist/scriptable-pack-worker.mjs.map +1 -1
- package/dist/scriptable-pack.d.ts +33 -0
- package/dist/scriptable-pack.d.ts.map +1 -1
- package/dist/scriptable-pack.mjs +91 -2
- package/dist/scriptable-pack.mjs.map +1 -1
- package/dist/topology.d.ts +1 -1
- package/dist/topology.d.ts.map +1 -1
- package/package.json +27 -2
- package/src/__tests__/mesh-bin-consumer-surface.unit.test.ts +2 -1
- package/src/__tests__/owner-chain.integration.test.ts +78 -0
- package/src/__tests__/package-finalizer.contract.test.ts +105 -0
- package/src/__tests__/runtime.browser.test.ts +15 -0
- package/src/__tests__/scanner-inventory.contract.test.ts +167 -0
- package/src/__tests__/scanner-inventory.test.ts +16 -12
- package/src/__tests__/scriptable-pack.unit.test.ts +42 -0
- package/src/atlas/shelf-pack.ts +20 -24
- package/src/build.ts +137 -0
- package/src/builtin.ts +38 -0
- package/src/catalog-builder.ts +313 -0
- package/src/catalog-delta.ts +189 -0
- package/src/catalog-projection.ts +344 -0
- package/src/cli-asset.ts +1 -5
- package/src/index.ts +2 -2
- package/src/material-cook.ts +13 -0
- package/src/native-cooker-registry.ts +72 -0
- package/src/package-finalizer.ts +394 -0
- package/src/resolve-asset-source.ts +2 -7
- package/src/runtime-publication.ts +190 -0
- package/src/runtime.ts +23 -0
- package/src/scanner.ts +289 -290
- package/src/scriptable-pack-node.ts +27 -23
- package/src/scriptable-pack-worker.ts +2 -0
- package/src/scriptable-pack.ts +123 -1
- package/src/topology.ts +2 -1
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { relative } from 'node:path';
|
|
2
|
+
import type { PackIndexEntry, ProviderProvenance } from '@forgeax/engine-types';
|
|
3
|
+
import {
|
|
4
|
+
findReservedAssetKindConflict,
|
|
5
|
+
projectExternalCatalogEntries,
|
|
6
|
+
projectPackageCatalog,
|
|
7
|
+
} from './catalog-projection.js';
|
|
8
|
+
import { loadAssetConfig } from './config.js';
|
|
9
|
+
import { deriveAssetName } from './deriveAssetName.js';
|
|
10
|
+
import { resolveAssetSource } from './resolve-asset-source.js';
|
|
11
|
+
import {
|
|
12
|
+
type MetaInventoryDocument,
|
|
13
|
+
type ScanOptions,
|
|
14
|
+
type ScanSourceDeclaration,
|
|
15
|
+
scanInventory,
|
|
16
|
+
} from './scanner.js';
|
|
17
|
+
|
|
18
|
+
type CatalogImportMeta = Pick<
|
|
19
|
+
MetaInventoryDocument,
|
|
20
|
+
| 'importer'
|
|
21
|
+
| 'packageId'
|
|
22
|
+
| 'provenance'
|
|
23
|
+
| 'revision'
|
|
24
|
+
| 'diagnostics'
|
|
25
|
+
| 'importSettings'
|
|
26
|
+
| 'sourceOverrides'
|
|
27
|
+
| 'subAssets'
|
|
28
|
+
> & {
|
|
29
|
+
readonly source: string;
|
|
30
|
+
readonly sourceRevision?: string;
|
|
31
|
+
readonly buildPack?: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type CatalogBuildErrorCode =
|
|
35
|
+
| 'catalog-meta-schema-invalid'
|
|
36
|
+
| 'catalog-scan-failed'
|
|
37
|
+
| 'catalog-raw-source-unsupported'
|
|
38
|
+
| 'catalog-scriptable-pack-invalid'
|
|
39
|
+
| 'catalog-host-kind-conflict';
|
|
40
|
+
|
|
41
|
+
export interface CatalogBuildError {
|
|
42
|
+
readonly code: CatalogBuildErrorCode;
|
|
43
|
+
readonly path: string;
|
|
44
|
+
readonly message: string;
|
|
45
|
+
readonly expected?: string;
|
|
46
|
+
readonly actual?: string;
|
|
47
|
+
readonly hint?: string;
|
|
48
|
+
readonly subjects?: readonly string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type CatalogAuthority = 'authoritative' | 'degraded';
|
|
52
|
+
|
|
53
|
+
export interface CatalogBuildResult {
|
|
54
|
+
readonly schemaVersion: 'catalog-legacy-v1';
|
|
55
|
+
readonly entries: readonly PackIndexEntry[];
|
|
56
|
+
readonly authority: CatalogAuthority;
|
|
57
|
+
readonly diagnostics: readonly CatalogBuildError[];
|
|
58
|
+
readonly declarations: ReadonlyMap<string, CatalogImportMeta>;
|
|
59
|
+
readonly sourceDeclarations: ReadonlyMap<string, ScanSourceDeclaration>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type CatalogProducerVisibility = (
|
|
63
|
+
input: Pick<MetaInventoryDocument, 'importer' | 'importSettings' | 'subAssets'>,
|
|
64
|
+
) => boolean;
|
|
65
|
+
|
|
66
|
+
export interface CatalogImporterPolicy {
|
|
67
|
+
readonly disposition: 'publish' | 'exclude' | 'missing';
|
|
68
|
+
readonly expected: string;
|
|
69
|
+
readonly hint: string;
|
|
70
|
+
readonly hostProvided: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CatalogBuildProjectionOptions {
|
|
74
|
+
readonly base?: string;
|
|
75
|
+
readonly scanOptions?: ScanOptions;
|
|
76
|
+
readonly importerPolicy: (importer: string) => CatalogImporterPolicy;
|
|
77
|
+
readonly visibility?: CatalogProducerVisibility;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sourcePathFor(cwd: string, path: string): string {
|
|
81
|
+
return relative(cwd, path).replace(/\\/g, '/');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function metaPathForGuid(
|
|
85
|
+
declarations: ReadonlyMap<string, CatalogImportMeta>,
|
|
86
|
+
guid: string,
|
|
87
|
+
): string | undefined {
|
|
88
|
+
const lower = guid.toLowerCase();
|
|
89
|
+
for (const [path, declaration] of declarations) {
|
|
90
|
+
if (declaration.subAssets.some((asset) => asset.guid.toLowerCase() === lower)) return path;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function unsupportedPolicyError(
|
|
96
|
+
path: string,
|
|
97
|
+
importer: string,
|
|
98
|
+
policy: CatalogImporterPolicy,
|
|
99
|
+
): CatalogBuildError {
|
|
100
|
+
return {
|
|
101
|
+
code: 'catalog-raw-source-unsupported',
|
|
102
|
+
path,
|
|
103
|
+
message: `sidecar provider ${JSON.stringify(importer)} cannot publish a Catalog product`,
|
|
104
|
+
expected: policy.expected,
|
|
105
|
+
actual: importer,
|
|
106
|
+
hint: policy.hint,
|
|
107
|
+
subjects: [path],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function projectMeta(
|
|
112
|
+
rawPath: string,
|
|
113
|
+
cwd: string,
|
|
114
|
+
base: string,
|
|
115
|
+
assetPaths: Record<string, string>,
|
|
116
|
+
importerPolicy: (importer: string) => CatalogImporterPolicy,
|
|
117
|
+
sourceDeclaration: Extract<ScanSourceDeclaration, { readonly format: 'meta.json' }>,
|
|
118
|
+
): {
|
|
119
|
+
readonly entries: PackIndexEntry[];
|
|
120
|
+
readonly declaration?: CatalogImportMeta;
|
|
121
|
+
readonly error?: CatalogBuildError;
|
|
122
|
+
} {
|
|
123
|
+
const meta = sourceDeclaration.value;
|
|
124
|
+
const policy = importerPolicy(meta.importer);
|
|
125
|
+
if (policy.disposition !== 'publish') {
|
|
126
|
+
return { entries: [], error: unsupportedPolicyError(rawPath, meta.importer, policy) };
|
|
127
|
+
}
|
|
128
|
+
const resolved = resolveAssetSource(rawPath, meta.source, assetPaths);
|
|
129
|
+
if (!resolved.ok) {
|
|
130
|
+
return {
|
|
131
|
+
entries: [],
|
|
132
|
+
error: {
|
|
133
|
+
code: 'catalog-meta-schema-invalid',
|
|
134
|
+
path: rawPath,
|
|
135
|
+
message: `source resolution failed: ${resolved.error.code} - ${resolved.error.hint}`,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const sourcePath = sourcePathFor(cwd, resolved.value);
|
|
140
|
+
const packageUrl = `${base}/__forgeax-ddc/${meta.subAssets[0]?.guid?.toLowerCase() ?? 'pack'}.pack.json`;
|
|
141
|
+
const declaration: CatalogImportMeta = {
|
|
142
|
+
importer: meta.importer,
|
|
143
|
+
source: resolved.value,
|
|
144
|
+
sourceRevision: sourceDeclaration.sourceRevision,
|
|
145
|
+
...(meta.packageId === undefined ? {} : { packageId: meta.packageId }),
|
|
146
|
+
...(meta.provenance === undefined ? {} : { provenance: meta.provenance }),
|
|
147
|
+
...(meta.revision === undefined ? {} : { revision: meta.revision }),
|
|
148
|
+
...(meta.diagnostics === undefined ? {} : { diagnostics: meta.diagnostics }),
|
|
149
|
+
importSettings: meta.importSettings,
|
|
150
|
+
...(meta.sourceOverrides === undefined ? {} : { sourceOverrides: meta.sourceOverrides }),
|
|
151
|
+
subAssets: meta.subAssets,
|
|
152
|
+
};
|
|
153
|
+
if (policy.hostProvided) {
|
|
154
|
+
const conflict = findReservedAssetKindConflict(meta.subAssets);
|
|
155
|
+
if (conflict !== undefined) {
|
|
156
|
+
return {
|
|
157
|
+
entries: [],
|
|
158
|
+
error: {
|
|
159
|
+
code: 'catalog-host-kind-conflict',
|
|
160
|
+
path: rawPath,
|
|
161
|
+
message: `registered provider ${JSON.stringify(meta.importer)} declares reserved asset kind ${JSON.stringify(conflict.kind)}`,
|
|
162
|
+
expected: 'host provider kinds must not use engine-owned Pack kinds',
|
|
163
|
+
actual: conflict.kind,
|
|
164
|
+
hint: 'rename the provider kind to a provider-owned namespace before rebuilding',
|
|
165
|
+
subjects: [rawPath],
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
declaration,
|
|
172
|
+
entries: projectExternalCatalogEntries(meta, sourcePath, packageUrl, meta.subAssets, (output) =>
|
|
173
|
+
deriveAssetName(resolved.value, meta.subAssets.length, output.name),
|
|
174
|
+
),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function projectSource(
|
|
179
|
+
path: string,
|
|
180
|
+
cwd: string,
|
|
181
|
+
base: string,
|
|
182
|
+
assetPaths: Record<string, string>,
|
|
183
|
+
importerPolicy: (importer: string) => CatalogImporterPolicy,
|
|
184
|
+
visibility: CatalogBuildProjectionOptions['visibility'],
|
|
185
|
+
sourceDeclaration: ScanSourceDeclaration,
|
|
186
|
+
declarations: Map<string, CatalogImportMeta>,
|
|
187
|
+
): { readonly entries: PackIndexEntry[]; readonly error?: CatalogBuildError } {
|
|
188
|
+
if (sourceDeclaration.format === 'pack.ts') {
|
|
189
|
+
const meta = { ...sourceDeclaration.meta, source: sourcePathFor(cwd, path) };
|
|
190
|
+
const anchorGuid = meta.subAssets.map((asset) => asset.guid.toLowerCase()).sort()[0];
|
|
191
|
+
return anchorGuid === undefined
|
|
192
|
+
? { entries: [] }
|
|
193
|
+
: {
|
|
194
|
+
entries: projectExternalCatalogEntries(
|
|
195
|
+
meta,
|
|
196
|
+
sourcePathFor(cwd, path),
|
|
197
|
+
`${base}/__forgeax-ddc/${anchorGuid}.pack.json`,
|
|
198
|
+
meta.subAssets,
|
|
199
|
+
(output) => deriveAssetName(path, meta.subAssets.length, output.name),
|
|
200
|
+
),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (sourceDeclaration.format === 'meta.json') {
|
|
204
|
+
const policy = importerPolicy(sourceDeclaration.value.importer);
|
|
205
|
+
if (
|
|
206
|
+
policy.disposition === 'publish' &&
|
|
207
|
+
visibility?.({
|
|
208
|
+
importer: sourceDeclaration.value.importer,
|
|
209
|
+
importSettings: sourceDeclaration.value.importSettings,
|
|
210
|
+
subAssets: sourceDeclaration.value.subAssets,
|
|
211
|
+
}) === false
|
|
212
|
+
) {
|
|
213
|
+
return { entries: [] };
|
|
214
|
+
}
|
|
215
|
+
const projected = projectMeta(path, cwd, base, assetPaths, importerPolicy, sourceDeclaration);
|
|
216
|
+
if (projected.declaration !== undefined) declarations.set(path, projected.declaration);
|
|
217
|
+
return projected;
|
|
218
|
+
}
|
|
219
|
+
const parsed = sourceDeclaration.value;
|
|
220
|
+
const sourcePath = sourcePathFor(cwd, path);
|
|
221
|
+
const provenance =
|
|
222
|
+
parsed.provenance ??
|
|
223
|
+
({ provider: 'pack', version: parsed.schemaVersion } satisfies ProviderProvenance);
|
|
224
|
+
return {
|
|
225
|
+
entries: projectPackageCatalog(
|
|
226
|
+
parsed.assets.map((asset, sourceIndex) => ({
|
|
227
|
+
...asset,
|
|
228
|
+
sourcePath,
|
|
229
|
+
sourceIndex: asset.sourceIndex ?? sourceIndex,
|
|
230
|
+
...(parsed.packageId === undefined ? {} : { packageId: parsed.packageId }),
|
|
231
|
+
provenance,
|
|
232
|
+
...(parsed.revision === undefined ? {} : { revision: parsed.revision }),
|
|
233
|
+
...(parsed.diagnostics === undefined ? {} : { diagnostics: parsed.diagnostics }),
|
|
234
|
+
name: deriveAssetName(path, parsed.assets.length, asset.name),
|
|
235
|
+
})),
|
|
236
|
+
`${base}/${sourcePath}`,
|
|
237
|
+
),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function warnErrors(errors: readonly CatalogBuildError[]): void {
|
|
242
|
+
for (const error of errors) {
|
|
243
|
+
console.warn(`[forgeax-pack] catalog error ${error.code} @ ${error.path}: ${error.message}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function buildCatalogProjection(
|
|
248
|
+
roots: readonly string[],
|
|
249
|
+
options: CatalogBuildProjectionOptions,
|
|
250
|
+
): Promise<CatalogBuildResult> {
|
|
251
|
+
const empty = (
|
|
252
|
+
authority: CatalogAuthority,
|
|
253
|
+
diagnostics: readonly CatalogBuildError[] = [],
|
|
254
|
+
): CatalogBuildResult => ({
|
|
255
|
+
schemaVersion: 'catalog-legacy-v1',
|
|
256
|
+
entries: [],
|
|
257
|
+
authority,
|
|
258
|
+
diagnostics,
|
|
259
|
+
declarations: new Map(),
|
|
260
|
+
sourceDeclarations: new Map(),
|
|
261
|
+
});
|
|
262
|
+
if (roots.length === 0) return empty('authoritative');
|
|
263
|
+
const cwd = process.cwd();
|
|
264
|
+
const base = (options.base ?? '/').replace(/\/$/, '');
|
|
265
|
+
const scanned = await scanInventory(roots, options.scanOptions);
|
|
266
|
+
if (!scanned.ok) {
|
|
267
|
+
const message =
|
|
268
|
+
scanned.error.detail === undefined
|
|
269
|
+
? scanned.error.message
|
|
270
|
+
: `${scanned.error.message}; detail=${JSON.stringify(scanned.error.detail)}`;
|
|
271
|
+
const error: CatalogBuildError = {
|
|
272
|
+
code: 'catalog-scan-failed',
|
|
273
|
+
path: '<scan>',
|
|
274
|
+
message,
|
|
275
|
+
expected: 'one inventory scan across all Catalog roots to pass Pack scanning',
|
|
276
|
+
actual: message,
|
|
277
|
+
hint: 'repair the roots before treating any Catalog rows as authoritative',
|
|
278
|
+
subjects: [...roots],
|
|
279
|
+
};
|
|
280
|
+
warnErrors([error]);
|
|
281
|
+
return empty('degraded', [error]);
|
|
282
|
+
}
|
|
283
|
+
const { paths, declarations: sourceDeclarations } = scanned.value;
|
|
284
|
+
const { paths: assetPaths } = loadAssetConfig(cwd);
|
|
285
|
+
const entries: PackIndexEntry[] = [];
|
|
286
|
+
const errors: CatalogBuildError[] = [];
|
|
287
|
+
const declarations = new Map<string, CatalogImportMeta>();
|
|
288
|
+
for (const path of paths) {
|
|
289
|
+
const sourceDeclaration = sourceDeclarations.get(path);
|
|
290
|
+
if (sourceDeclaration === undefined) continue;
|
|
291
|
+
const projected = projectSource(
|
|
292
|
+
path,
|
|
293
|
+
cwd,
|
|
294
|
+
base,
|
|
295
|
+
assetPaths,
|
|
296
|
+
options.importerPolicy,
|
|
297
|
+
options.visibility,
|
|
298
|
+
sourceDeclaration,
|
|
299
|
+
declarations,
|
|
300
|
+
);
|
|
301
|
+
entries.push(...projected.entries);
|
|
302
|
+
if (projected.error !== undefined) errors.push(projected.error);
|
|
303
|
+
}
|
|
304
|
+
warnErrors(errors);
|
|
305
|
+
return {
|
|
306
|
+
schemaVersion: 'catalog-legacy-v1',
|
|
307
|
+
entries,
|
|
308
|
+
authority: errors.length === 0 ? 'authoritative' : 'degraded',
|
|
309
|
+
diagnostics: errors,
|
|
310
|
+
declarations,
|
|
311
|
+
sourceDeclarations,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CatalogDelta,
|
|
3
|
+
CatalogDiagnostic,
|
|
4
|
+
CatalogRevisionWindow,
|
|
5
|
+
ImportedOutputDeclaration,
|
|
6
|
+
PackIndexEntry,
|
|
7
|
+
} from '@forgeax/engine-types';
|
|
8
|
+
import { catalogEntryDigest } from '@forgeax/engine-types';
|
|
9
|
+
import { diffTopology } from './topology.js';
|
|
10
|
+
|
|
11
|
+
function rowsByGuid(rows: readonly PackIndexEntry[]): Map<string, PackIndexEntry> {
|
|
12
|
+
return new Map(rows.map((row) => [row.guid.toLowerCase(), row]));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function sameRow(left: PackIndexEntry, right: PackIndexEntry): boolean {
|
|
16
|
+
return catalogEntryDigest(left) === catalogEntryDigest(right);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function topologyGroups(rows: readonly PackIndexEntry[]): Map<string, ImportedOutputDeclaration[]> {
|
|
20
|
+
const groups = new Map<string, ImportedOutputDeclaration[]>();
|
|
21
|
+
for (const row of rows) {
|
|
22
|
+
// Legacy catalog rows have neither producer topology field. Do not turn
|
|
23
|
+
// their incidental catalog position into a false topology claim.
|
|
24
|
+
if (row.packageId === undefined) continue;
|
|
25
|
+
if (row.sourceKey === undefined && row.sourceIndex === undefined) continue;
|
|
26
|
+
const groupId = row.packageId;
|
|
27
|
+
const group = groups.get(groupId) ?? [];
|
|
28
|
+
group.push({
|
|
29
|
+
guid: row.guid,
|
|
30
|
+
...(row.sourceKey !== undefined ? { sourceKey: row.sourceKey } : {}),
|
|
31
|
+
sourceIndex: row.sourceIndex ?? 0,
|
|
32
|
+
kind: row.kind,
|
|
33
|
+
...(row.name !== undefined ? { name: row.name } : {}),
|
|
34
|
+
});
|
|
35
|
+
groups.set(groupId, group);
|
|
36
|
+
}
|
|
37
|
+
return groups;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function revisionDiagnostic(
|
|
41
|
+
code: string,
|
|
42
|
+
expected: string,
|
|
43
|
+
actual: string,
|
|
44
|
+
hint: string,
|
|
45
|
+
rootId?: string,
|
|
46
|
+
): CatalogDiagnostic {
|
|
47
|
+
return {
|
|
48
|
+
code,
|
|
49
|
+
severity: 'blocking',
|
|
50
|
+
authority: 'catalog',
|
|
51
|
+
expected,
|
|
52
|
+
actual,
|
|
53
|
+
hint,
|
|
54
|
+
...(rootId === undefined ? {} : { subject: { type: 'package' as const, id: rootId } }),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function checkRevisionContinuity(
|
|
59
|
+
window: CatalogRevisionWindow,
|
|
60
|
+
hasChanges: boolean,
|
|
61
|
+
): CatalogDiagnostic | undefined {
|
|
62
|
+
const baselineByRoot = new Map(window.baseline.map((point) => [point.rootId, point]));
|
|
63
|
+
const currentByRoot = new Map(window.current.map((point) => [point.rootId, point]));
|
|
64
|
+
|
|
65
|
+
for (const point of window.current) {
|
|
66
|
+
const baseline = baselineByRoot.get(point.rootId);
|
|
67
|
+
if (baseline === undefined) {
|
|
68
|
+
return revisionDiagnostic(
|
|
69
|
+
'catalog-revision-conflict',
|
|
70
|
+
'every current root must have a verified baseline',
|
|
71
|
+
point.rootId,
|
|
72
|
+
'restore the latest verified snapshot for this root before applying the delta',
|
|
73
|
+
point.rootId,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (point.revision < baseline.revision) {
|
|
77
|
+
return revisionDiagnostic(
|
|
78
|
+
'catalog-revision-stale',
|
|
79
|
+
'current revision must not be older than baseline',
|
|
80
|
+
`${baseline.revision} -> ${point.revision}`,
|
|
81
|
+
'discard the stale update and request a fresh catalog snapshot',
|
|
82
|
+
point.rootId,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (point.revision === baseline.revision && hasChanges) {
|
|
86
|
+
return revisionDiagnostic(
|
|
87
|
+
'catalog-revision-conflict',
|
|
88
|
+
'a changed delta must advance the root revision',
|
|
89
|
+
`${baseline.revision} -> ${point.revision}`,
|
|
90
|
+
'keep the verified baseline and serialize concurrent updates before retrying',
|
|
91
|
+
point.rootId,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (point.revision > baseline.revision + 1) {
|
|
95
|
+
return revisionDiagnostic(
|
|
96
|
+
'catalog-revision-conflict',
|
|
97
|
+
'the next revision must be exactly baseline + 1',
|
|
98
|
+
`${baseline.revision} -> ${point.revision}`,
|
|
99
|
+
'request the missing revisions or rebuild from the latest verified snapshot',
|
|
100
|
+
point.rootId,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const point of window.baseline) {
|
|
106
|
+
if (!currentByRoot.has(point.rootId)) {
|
|
107
|
+
return revisionDiagnostic(
|
|
108
|
+
'catalog-revision-conflict',
|
|
109
|
+
'every baseline root must be present in the current revision set',
|
|
110
|
+
point.rootId,
|
|
111
|
+
'do not apply a partial root set over the verified baseline',
|
|
112
|
+
point.rootId,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Derives one neutral delta from consecutive complete catalog projections.
|
|
122
|
+
*
|
|
123
|
+
* The returned fields are canonical POD fields: topology, revision continuity,
|
|
124
|
+
* authority, and diagnostics remain structured so AI consumers do not parse
|
|
125
|
+
* producer or transport messages. Degraded results intentionally contain no
|
|
126
|
+
* added, changed, or removed identity rows.
|
|
127
|
+
*/
|
|
128
|
+
export function calculateCatalogDelta(
|
|
129
|
+
previous: readonly PackIndexEntry[],
|
|
130
|
+
next: readonly PackIndexEntry[],
|
|
131
|
+
revision?: CatalogRevisionWindow,
|
|
132
|
+
): CatalogDelta | undefined {
|
|
133
|
+
const before = rowsByGuid(previous);
|
|
134
|
+
const after = rowsByGuid(next);
|
|
135
|
+
const added: PackIndexEntry[] = [];
|
|
136
|
+
const changed: PackIndexEntry[] = [];
|
|
137
|
+
const removed: string[] = [];
|
|
138
|
+
|
|
139
|
+
for (const [guid, row] of after) {
|
|
140
|
+
const prior = before.get(guid);
|
|
141
|
+
if (prior === undefined) added.push(row);
|
|
142
|
+
else if (!sameRow(prior, row)) changed.push(row);
|
|
143
|
+
}
|
|
144
|
+
for (const guid of before.keys()) {
|
|
145
|
+
if (!after.has(guid)) removed.push(guid);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const previousGroups = topologyGroups(previous);
|
|
149
|
+
const nextGroups = topologyGroups(next);
|
|
150
|
+
const topology = [];
|
|
151
|
+
const groupIds = new Set([...previousGroups.keys(), ...nextGroups.keys()]);
|
|
152
|
+
for (const groupId of groupIds) {
|
|
153
|
+
const diff = diffTopology(previousGroups.get(groupId) ?? [], nextGroups.get(groupId) ?? []);
|
|
154
|
+
if (
|
|
155
|
+
diff.added.length > 0 ||
|
|
156
|
+
diff.removed.length > 0 ||
|
|
157
|
+
diff.changedKind.length > 0 ||
|
|
158
|
+
diff.ambiguous.length > 0
|
|
159
|
+
) {
|
|
160
|
+
topology.push(diff);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const hasChanges =
|
|
165
|
+
added.length > 0 || changed.length > 0 || removed.length > 0 || topology.length > 0;
|
|
166
|
+
if (!hasChanges) return undefined;
|
|
167
|
+
|
|
168
|
+
const delta = { added, changed, removed, ...(topology.length > 0 ? { topology } : {}) };
|
|
169
|
+
if (revision === undefined) return delta;
|
|
170
|
+
|
|
171
|
+
const diagnostic = checkRevisionContinuity(revision, hasChanges);
|
|
172
|
+
if (diagnostic !== undefined) {
|
|
173
|
+
return {
|
|
174
|
+
added: [],
|
|
175
|
+
changed: [],
|
|
176
|
+
removed: [],
|
|
177
|
+
authority: 'degraded',
|
|
178
|
+
diagnostics: [diagnostic],
|
|
179
|
+
revisions: revision,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
...delta,
|
|
185
|
+
authority: 'authoritative',
|
|
186
|
+
diagnostics: [],
|
|
187
|
+
revisions: revision,
|
|
188
|
+
};
|
|
189
|
+
}
|