@lorion-org/composition-graph 1.0.0-beta.0

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/dist/index.js ADDED
@@ -0,0 +1,402 @@
1
+ // src/descriptorMap.ts
2
+ function buildDescriptorMap(descriptors) {
3
+ const descriptorMap = /* @__PURE__ */ new Map();
4
+ for (const descriptor of descriptors) {
5
+ descriptorMap.set(descriptor.id, descriptor);
6
+ }
7
+ return descriptorMap;
8
+ }
9
+ function parseDescriptorIds(input) {
10
+ if (!input) return [];
11
+ const items = Array.isArray(input) ? input : input.split(/[,\s]+/).map((item) => item.trim());
12
+ return Array.from(new Set(items.map((item) => item.trim()).filter(Boolean))).sort();
13
+ }
14
+ function assertKnownDescriptorIds(descriptorMap, ids, label) {
15
+ const missing = [...new Set(ids)].filter((id) => id && !descriptorMap.has(id)).sort();
16
+ if (!missing.length) return;
17
+ throw new Error(`Unknown ${label}: ${missing.join(", ")}`);
18
+ }
19
+
20
+ // src/descriptorGraph.ts
21
+ var createEmptyAdjacency = (descriptorMap) => {
22
+ return new Map(
23
+ Array.from(descriptorMap.keys()).sort().map((id) => [id, []])
24
+ );
25
+ };
26
+ var sortEdges = (edges) => {
27
+ return [...edges].sort(
28
+ (left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to) || left.relation.localeCompare(right.relation)
29
+ );
30
+ };
31
+ var defaultRelationDescriptors = [
32
+ {
33
+ id: "dependencies",
34
+ field: "dependencies"
35
+ }
36
+ ];
37
+ function isVersionConstraintMap(value) {
38
+ return typeof value === "object" && value !== null && !Array.isArray(value);
39
+ }
40
+ function getRelationTargets(descriptor, relationDescriptor) {
41
+ const field = relationDescriptor.field ?? relationDescriptor.id;
42
+ const relationValue = descriptor[field];
43
+ if (!isVersionConstraintMap(relationValue)) return [];
44
+ return Object.keys(relationValue).sort();
45
+ }
46
+ function createOriginType(path) {
47
+ return path.length ? "resolved" : "selected";
48
+ }
49
+ function compareOrigins(left, right) {
50
+ const leftPath = JSON.stringify(left.path);
51
+ const rightPath = JSON.stringify(right.path);
52
+ return left.originType.localeCompare(right.originType) || leftPath.localeCompare(rightPath);
53
+ }
54
+ function buildDescriptorGraph(input) {
55
+ const relationDescriptors = input.relationDescriptors ?? defaultRelationDescriptors;
56
+ const descriptorRegistry = new Map(
57
+ [...relationDescriptors].sort((left, right) => left.id.localeCompare(right.id)).map((descriptor) => [descriptor.id, descriptor])
58
+ );
59
+ const edges = [];
60
+ const outgoing = createEmptyAdjacency(input.descriptorMap);
61
+ const incoming = createEmptyAdjacency(input.descriptorMap);
62
+ for (const descriptor of Array.from(input.descriptorMap.values()).sort(
63
+ (left, right) => left.id.localeCompare(right.id)
64
+ )) {
65
+ for (const relationDescriptor of descriptorRegistry.values()) {
66
+ for (const target of getRelationTargets(descriptor, relationDescriptor)) {
67
+ if (!input.descriptorMap.has(target)) continue;
68
+ const edge = {
69
+ from: descriptor.id,
70
+ to: target,
71
+ relation: relationDescriptor.id,
72
+ source: "descriptor"
73
+ };
74
+ edges.push(edge);
75
+ outgoing.get(descriptor.id)?.push(edge);
76
+ incoming.get(target)?.push(edge);
77
+ }
78
+ }
79
+ }
80
+ for (const [id, nodeEdges] of outgoing) outgoing.set(id, sortEdges(nodeEdges));
81
+ for (const [id, nodeEdges] of incoming) incoming.set(id, sortEdges(nodeEdges));
82
+ return {
83
+ descriptorMap: input.descriptorMap,
84
+ edges: sortEdges(edges),
85
+ outgoing,
86
+ incoming,
87
+ relationDescriptors: descriptorRegistry
88
+ };
89
+ }
90
+ function getTransitiveTargets(input) {
91
+ const allowedRelations = new Set(input.relationIds);
92
+ const visited = /* @__PURE__ */ new Set();
93
+ const stack = [...input.start].sort().reverse();
94
+ while (stack.length) {
95
+ const current = stack.pop();
96
+ if (!current || visited.has(current)) continue;
97
+ visited.add(current);
98
+ for (const edge of input.graph.outgoing.get(current) ?? []) {
99
+ if (!allowedRelations.has(edge.relation) || visited.has(edge.to)) continue;
100
+ stack.push(edge.to);
101
+ }
102
+ }
103
+ return Array.from(visited).filter((id) => input.graph.descriptorMap.has(id)).sort();
104
+ }
105
+ function getIncomingRelationMap(input) {
106
+ const entries = Array.from(input.graph.incoming.entries()).map(([target, edges]) => {
107
+ const sources = edges.filter((edge) => edge.relation === input.relationId).map((edge) => edge.from).sort();
108
+ return [target, sources];
109
+ }).filter(([, sources]) => sources.length > 0).sort(([left], [right]) => left.localeCompare(right));
110
+ return new Map(entries);
111
+ }
112
+ function getDependents(input) {
113
+ if (!input.graph.descriptorMap.has(input.target)) return [];
114
+ const allowedRelations = new Set(input.relationIds);
115
+ const visited = /* @__PURE__ */ new Set();
116
+ const queue = [input.target];
117
+ while (queue.length) {
118
+ const current = queue.shift();
119
+ if (!current || visited.has(current)) continue;
120
+ visited.add(current);
121
+ if (input.transitive === false && current !== input.target) continue;
122
+ for (const edge of input.graph.incoming.get(current) ?? []) {
123
+ if (!allowedRelations.has(edge.relation) || visited.has(edge.from)) continue;
124
+ queue.push(edge.from);
125
+ }
126
+ }
127
+ visited.delete(input.target);
128
+ return Array.from(visited).sort();
129
+ }
130
+ function explainPath(input) {
131
+ if (!input.graph.descriptorMap.has(input.from) || !input.graph.descriptorMap.has(input.to))
132
+ return [];
133
+ if (input.from === input.to) return [];
134
+ const allowedRelations = new Set(input.relationIds);
135
+ const visited = /* @__PURE__ */ new Set([input.from]);
136
+ const queue = [
137
+ { node: input.from, path: [] }
138
+ ];
139
+ while (queue.length) {
140
+ const current = queue.shift();
141
+ if (!current) continue;
142
+ for (const edge of input.graph.outgoing.get(current.node) ?? []) {
143
+ if (!allowedRelations.has(edge.relation) || visited.has(edge.to)) continue;
144
+ const nextPath = [
145
+ ...current.path,
146
+ {
147
+ from: edge.from,
148
+ to: edge.to,
149
+ relation: edge.relation
150
+ }
151
+ ];
152
+ if (edge.to === input.to) return nextPath;
153
+ visited.add(edge.to);
154
+ queue.push({
155
+ node: edge.to,
156
+ path: nextPath
157
+ });
158
+ }
159
+ }
160
+ return [];
161
+ }
162
+ function explainPathsBatch(input) {
163
+ return [...input.pairs].sort((left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to)).map(({ from, to }) => ({
164
+ from,
165
+ to,
166
+ path: explainPath({
167
+ graph: input.graph,
168
+ from,
169
+ to,
170
+ relationIds: input.relationIds
171
+ })
172
+ }));
173
+ }
174
+ function getCompositionProvenance(input) {
175
+ const selected = [...new Set(input.selected)].filter((descriptorId) => input.graph.descriptorMap.has(descriptorId)).sort();
176
+ const baseDescriptors = [...new Set(input.baseDescriptors ?? [])].filter((descriptorId) => input.graph.descriptorMap.has(descriptorId)).sort();
177
+ const descriptorIds = [...new Set(input.descriptorIds)].filter((descriptorId) => input.graph.descriptorMap.has(descriptorId)).sort();
178
+ return descriptorIds.map((descriptorId) => {
179
+ const origins = [];
180
+ if (selected.includes(descriptorId)) {
181
+ origins.push({
182
+ originType: "selected",
183
+ path: []
184
+ });
185
+ }
186
+ if (baseDescriptors.includes(descriptorId)) {
187
+ origins.push({
188
+ originType: "base",
189
+ path: []
190
+ });
191
+ }
192
+ for (const source of [...selected, ...baseDescriptors]) {
193
+ if (source === descriptorId) continue;
194
+ const path = explainPath({
195
+ graph: input.graph,
196
+ from: source,
197
+ to: descriptorId,
198
+ relationIds: input.relationIds
199
+ });
200
+ if (!path.length) continue;
201
+ origins.push({
202
+ originType: createOriginType(path),
203
+ path
204
+ });
205
+ }
206
+ return {
207
+ descriptorId,
208
+ origins: origins.sort(compareOrigins)
209
+ };
210
+ });
211
+ }
212
+
213
+ // src/compositionSelection.ts
214
+ var defaultCompositionPolicy = {
215
+ resolutionRelationIds: ["dependencies"],
216
+ provenanceRelationIds: ["dependencies"],
217
+ inspectionRelationIds: ["dependencies"]
218
+ };
219
+ function resolvePolicy(policy) {
220
+ return {
221
+ ...defaultCompositionPolicy,
222
+ ...policy,
223
+ resolutionRelationIds: policy?.resolutionRelationIds ?? defaultCompositionPolicy.resolutionRelationIds,
224
+ provenanceRelationIds: policy?.provenanceRelationIds ?? defaultCompositionPolicy.provenanceRelationIds,
225
+ inspectionRelationIds: policy?.inspectionRelationIds ?? defaultCompositionPolicy.inspectionRelationIds
226
+ };
227
+ }
228
+ function createCompositionSelection(input) {
229
+ const catalog = input.catalog;
230
+ const descriptorMap = catalog.getDescriptorMap();
231
+ const selected = [...new Set(input.selected ?? [])].filter(Boolean).sort();
232
+ const baseDescriptors = [...new Set(input.baseDescriptors ?? [])].filter(Boolean).sort();
233
+ assertKnownDescriptorIds(descriptorMap, selected, "selected descriptors");
234
+ assertKnownDescriptorIds(descriptorMap, baseDescriptors, "base descriptors");
235
+ const policy = resolvePolicy(input.policy);
236
+ const resolved = catalog.getTransitiveTargets({
237
+ start: [...selected, ...baseDescriptors],
238
+ relationIds: policy.resolutionRelationIds
239
+ });
240
+ let resolvedDescriptorsCache;
241
+ let provenanceCache;
242
+ const getResolvedDescriptors = () => {
243
+ if (!resolvedDescriptorsCache) {
244
+ resolvedDescriptorsCache = resolved.map((descriptorId) => descriptorMap.get(descriptorId)).filter((descriptor) => Boolean(descriptor));
245
+ }
246
+ return resolvedDescriptorsCache;
247
+ };
248
+ const getProvenance = () => {
249
+ if (!provenanceCache) {
250
+ provenanceCache = getCompositionProvenance({
251
+ graph: catalog.getGraph(),
252
+ descriptorIds: resolved,
253
+ selected,
254
+ baseDescriptors,
255
+ relationIds: policy.provenanceRelationIds
256
+ });
257
+ }
258
+ return provenanceCache;
259
+ };
260
+ return {
261
+ getCatalog: () => catalog,
262
+ getGraph: () => catalog.getGraph(),
263
+ getSelected: () => [...selected],
264
+ getBaseDescriptors: () => [...baseDescriptors],
265
+ getResolved: () => [...resolved],
266
+ getResolvedDescriptors,
267
+ getProvenance,
268
+ getDependentsFor: (target, dependentsInput = {}) => {
269
+ return catalog.getDependents({
270
+ target,
271
+ relationIds: dependentsInput.relationIds ?? policy.resolutionRelationIds,
272
+ ...dependentsInput.transitive !== void 0 ? { transitive: dependentsInput.transitive } : {}
273
+ });
274
+ },
275
+ explain: (explainInput) => {
276
+ return catalog.explain({
277
+ from: explainInput.from,
278
+ to: explainInput.to,
279
+ relationIds: explainInput.relationIds ?? policy.inspectionRelationIds
280
+ });
281
+ },
282
+ explainPathsBatch: (batchInput) => {
283
+ return catalog.explainPathsBatch({
284
+ pairs: batchInput.pairs,
285
+ relationIds: batchInput.relationIds ?? policy.inspectionRelationIds
286
+ });
287
+ }
288
+ };
289
+ }
290
+
291
+ // src/descriptorCatalog.ts
292
+ function createRelationRecord(relationIds, values) {
293
+ return Object.fromEntries(
294
+ relationIds.map((relationId) => {
295
+ const entry = values.find(([relation]) => relation === relationId);
296
+ return [relationId, entry?.[1] ?? []];
297
+ })
298
+ );
299
+ }
300
+ function resolveRelationDescriptors(relationDescriptors) {
301
+ const registry = /* @__PURE__ */ new Map();
302
+ for (const relationDescriptor of defaultRelationDescriptors) {
303
+ registry.set(relationDescriptor.id, relationDescriptor);
304
+ }
305
+ for (const relationDescriptor of relationDescriptors ?? []) {
306
+ registry.set(relationDescriptor.id, relationDescriptor);
307
+ }
308
+ return Array.from(registry.values()).sort((left, right) => left.id.localeCompare(right.id));
309
+ }
310
+ function buildDescriptorProfile(descriptor, relationIds, graph) {
311
+ const outgoing = createRelationRecord(
312
+ relationIds,
313
+ relationIds.map((relationId) => {
314
+ const targets = (graph.outgoing.get(descriptor.id) ?? []).filter((edge) => edge.relation === relationId).map((edge) => edge.to).sort();
315
+ return [relationId, targets];
316
+ })
317
+ );
318
+ const incoming = createRelationRecord(
319
+ relationIds,
320
+ relationIds.map((relationId) => {
321
+ const sources = (graph.incoming.get(descriptor.id) ?? []).filter((edge) => edge.relation === relationId).map((edge) => edge.from).sort();
322
+ return [relationId, sources];
323
+ })
324
+ );
325
+ return {
326
+ id: descriptor.id,
327
+ disabled: descriptor.disabled === true,
328
+ capabilities: [...descriptor.capabilities ?? []].sort(),
329
+ ...descriptor.location ? { location: descriptor.location } : {},
330
+ ...typeof descriptor.providesFor === "string" ? { providesFor: descriptor.providesFor } : {},
331
+ outgoing,
332
+ incoming
333
+ };
334
+ }
335
+ function createDescriptorCatalog(input) {
336
+ const descriptorMap = input.descriptorMap ?? buildDescriptorMap(input.descriptors ?? []);
337
+ const relationDescriptors = resolveRelationDescriptors(input.relationDescriptors);
338
+ const graph = buildDescriptorGraph({
339
+ descriptorMap,
340
+ relationDescriptors
341
+ });
342
+ const catalog = {
343
+ getDescriptorMap: () => descriptorMap,
344
+ getAllDescriptors: () => Array.from(descriptorMap.values()),
345
+ getDescriptor: (id) => descriptorMap.get(id),
346
+ getGraph: () => graph,
347
+ getRelationDescriptors: () => Array.from(graph.relationDescriptors.values()),
348
+ getProfiles: (profileInput = {}) => {
349
+ const ids = [...new Set(profileInput.ids ?? [])].filter(Boolean).sort();
350
+ const descriptors = ids.length ? ids.map((id) => descriptorMap.get(id)).filter((descriptor) => Boolean(descriptor)) : Array.from(descriptorMap.values()).filter(
351
+ (descriptor) => profileInput.includeDisabled === true || descriptor.disabled !== true
352
+ ).sort((left, right) => left.id.localeCompare(right.id));
353
+ return descriptors.map(
354
+ (descriptor) => buildDescriptorProfile(descriptor, Array.from(graph.relationDescriptors.keys()), graph)
355
+ );
356
+ },
357
+ getIncomingRelationMap: (relationId) => getIncomingRelationMap({ graph, relationId }),
358
+ getTransitiveTargets: (transitiveInput) => getTransitiveTargets({
359
+ graph,
360
+ start: transitiveInput.start,
361
+ relationIds: transitiveInput.relationIds
362
+ }),
363
+ getDependents: (dependentsInput) => getDependents({
364
+ graph,
365
+ target: dependentsInput.target,
366
+ relationIds: dependentsInput.relationIds,
367
+ ...dependentsInput.transitive !== void 0 ? { transitive: dependentsInput.transitive } : {}
368
+ }),
369
+ explain: (explainInput) => explainPath({
370
+ graph,
371
+ from: explainInput.from,
372
+ to: explainInput.to,
373
+ relationIds: explainInput.relationIds
374
+ }),
375
+ explainPathsBatch: (batchInput) => explainPathsBatch({
376
+ graph,
377
+ pairs: batchInput.pairs,
378
+ relationIds: batchInput.relationIds
379
+ }),
380
+ resolveSelection: (selectionInput) => createCompositionSelection({
381
+ catalog,
382
+ ...selectionInput
383
+ })
384
+ };
385
+ return catalog;
386
+ }
387
+ export {
388
+ assertKnownDescriptorIds,
389
+ buildDescriptorGraph,
390
+ buildDescriptorMap,
391
+ createCompositionSelection,
392
+ createDescriptorCatalog,
393
+ defaultCompositionPolicy,
394
+ defaultRelationDescriptors,
395
+ explainPath,
396
+ explainPathsBatch,
397
+ getCompositionProvenance,
398
+ getDependents,
399
+ getIncomingRelationMap,
400
+ getTransitiveTargets,
401
+ parseDescriptorIds
402
+ };
@@ -0,0 +1,43 @@
1
+ /* eslint-env node */
2
+ import { createDescriptorCatalog } from '../dist/index.js';
3
+
4
+ const catalog = createDescriptorCatalog({
5
+ relationDescriptors: [
6
+ {
7
+ id: 'integrations',
8
+ field: 'integrations',
9
+ },
10
+ ],
11
+ descriptors: [
12
+ {
13
+ id: 'billing',
14
+ version: '1.0.0',
15
+ dependencies: { storage: '*' },
16
+ integrations: { analytics: '*' },
17
+ },
18
+ {
19
+ id: 'storage',
20
+ version: '1.0.0',
21
+ },
22
+ {
23
+ id: 'analytics',
24
+ version: '1.0.0',
25
+ },
26
+ {
27
+ id: 'ui-shell',
28
+ version: '1.0.0',
29
+ dependencies: { router: '*' },
30
+ },
31
+ {
32
+ id: 'router',
33
+ version: '1.0.0',
34
+ },
35
+ ],
36
+ });
37
+
38
+ const selection = catalog.resolveSelection({
39
+ selected: ['billing'],
40
+ baseDescriptors: ['ui-shell'],
41
+ });
42
+
43
+ process.stdout.write(`${selection.getResolved().join(', ')}\n`);
@@ -0,0 +1,52 @@
1
+ import { createDescriptorCatalog } from '@lorion-org/composition-graph';
2
+
3
+ const catalog = createDescriptorCatalog({
4
+ relationDescriptors: [
5
+ {
6
+ id: 'integrations',
7
+ field: 'integrations',
8
+ },
9
+ ],
10
+ descriptors: [
11
+ {
12
+ id: 'billing',
13
+ version: '1.0.0',
14
+ dependencies: { storage: '*' },
15
+ integrations: { analytics: '*' },
16
+ },
17
+ {
18
+ id: 'storage',
19
+ version: '1.0.0',
20
+ },
21
+ {
22
+ id: 'analytics',
23
+ version: '1.0.0',
24
+ },
25
+ {
26
+ id: 'ui-shell',
27
+ version: '1.0.0',
28
+ dependencies: { router: '*' },
29
+ },
30
+ {
31
+ id: 'router',
32
+ version: '1.0.0',
33
+ },
34
+ ],
35
+ });
36
+
37
+ const selection = catalog.resolveSelection({
38
+ selected: ['billing'],
39
+ baseDescriptors: ['ui-shell'],
40
+ });
41
+
42
+ console.log(selection.getResolved());
43
+ // ['analytics', 'billing', 'router', 'storage', 'ui-shell']
44
+
45
+ console.log(selection.getProvenance());
46
+ // [
47
+ // { descriptorId: 'analytics', origins: ['resolved'] },
48
+ // { descriptorId: 'billing', origins: ['selected'] },
49
+ // { descriptorId: 'router', origins: ['resolved'] },
50
+ // { descriptorId: 'storage', origins: ['resolved'] },
51
+ // { descriptorId: 'ui-shell', origins: ['base'] }
52
+ // ]
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@lorion-org/composition-graph",
3
+ "version": "1.0.0-beta.0",
4
+ "description": "Framework-free descriptor catalogs, relation graphs, and composition selection logic.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/lorion-org/lorion.git",
9
+ "directory": "packages/composition-graph"
10
+ },
11
+ "homepage": "https://github.com/lorion-org/lorion/tree/main/packages/composition-graph#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/lorion-org/lorion/issues"
14
+ },
15
+ "type": "module",
16
+ "sideEffects": false,
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "main": "./dist/index.cjs",
21
+ "module": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "import": {
26
+ "types": "./dist/index.d.ts",
27
+ "default": "./dist/index.js"
28
+ },
29
+ "require": {
30
+ "types": "./dist/index.d.cts",
31
+ "default": "./dist/index.cjs"
32
+ }
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "examples",
38
+ "LICENSE"
39
+ ],
40
+ "keywords": [
41
+ "composition",
42
+ "graph",
43
+ "descriptor",
44
+ "catalog"
45
+ ],
46
+ "engines": {
47
+ "node": "^20.19.0 || >=22.12.0"
48
+ },
49
+ "scripts": {
50
+ "build": "tsup src/index.ts --format esm,cjs --dts",
51
+ "clean": "rimraf coverage dist tsconfig.tsbuildinfo",
52
+ "example:composition": "node ./examples/deployment-composition.js",
53
+ "lint": "eslint src --ext .ts",
54
+ "test": "vitest run --root ../.. --config vitest.config.mts packages/composition-graph/src/index.spec.ts",
55
+ "coverage": "node -e \"require('node:fs').mkdirSync('../../coverage/.tmp', { recursive: true })\" && vitest run --coverage --coverage.include=packages/composition-graph/src/**/*.ts --root ../.. --config vitest.config.mts packages/composition-graph/src/index.spec.ts",
56
+ "typecheck": "tsc -p tsconfig.json --noEmit",
57
+ "package:check": "pnpm build && pnpm pack --dry-run && publint"
58
+ }
59
+ }