@m6d/cortex-cli 0.1.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/README.md +175 -0
- package/bin/cortex.js +52 -0
- package/package.json +41 -0
- package/src/cc/client.ts +295 -0
- package/src/cc/flags.ts +135 -0
- package/src/cc/output.ts +77 -0
- package/src/cc/profiles.ts +105 -0
- package/src/cli.ts +56 -0
- package/src/commands/cc.ts +504 -0
- package/src/commands/graph.ts +229 -0
- package/src/commands/new.ts +333 -0
- package/src/commands/swagger.ts +261 -0
- package/src/config/load.ts +122 -0
- package/src/config/validate.ts +46 -0
- package/src/contracts/charts/chart-spec.ts +201 -0
- package/src/contracts/charts/index.ts +7 -0
- package/src/contracts/client-tools/index.ts +82 -0
- package/src/contracts/egress/index.ts +161 -0
- package/src/contracts/egress/scrub.ts +59 -0
- package/src/contracts/graph/clients/embed.ts +45 -0
- package/src/contracts/graph/clients/neo4j.ts +85 -0
- package/src/contracts/graph/helpers.ts +57 -0
- package/src/contracts/graph/index.ts +30 -0
- package/src/contracts/graph/schema.ts +65 -0
- package/src/contracts/graph/types.ts +125 -0
- package/src/contracts/markdown/index.ts +39 -0
- package/src/contracts/rich-text/index.ts +220 -0
- package/src/contracts/runtime/api.ts +328 -0
- package/src/contracts/runtime/auth.ts +41 -0
- package/src/contracts/runtime/dataset-sql-guide.ts +67 -0
- package/src/contracts/runtime/dataset-sql.ts +2522 -0
- package/src/contracts/runtime/datasets.fixtures.json +2849 -0
- package/src/contracts/runtime/datasets.ts +1391 -0
- package/src/contracts/runtime/grammar.ts +161 -0
- package/src/contracts/runtime/index.ts +14 -0
- package/src/contracts/runtime/signature.ts +237 -0
- package/src/contracts/runtime/sync.ts +172 -0
- package/src/contracts/runtime/triggers.ts +224 -0
- package/src/contracts/wire/index.ts +264 -0
- package/src/graph/expand-domains.ts +288 -0
- package/src/graph/generate-cypher.ts +201 -0
- package/src/graph/seed.ts +207 -0
- package/src/graph/validate.ts +78 -0
- package/src/scaffold/features.ts +190 -0
- package/src/scaffold/files.ts +411 -0
- package/src/scaffold/ports.ts +86 -0
- package/src/scaffold/templates/domains-README.md.txt +16 -0
- package/src/scaffold/templates/eslint.config.mjs.txt +20 -0
- package/src/scaffold/templates/fallowrc.jsonc.txt +42 -0
- package/src/scaffold/templates/gitignore.txt +19 -0
- package/src/scaffold/templates/index.ts.txt +18 -0
- package/src/scaffold/templates/prettier.config.mjs.txt +5 -0
- package/src/scaffold/templates/tsconfig.json.txt +25 -0
- package/src/swagger/extract-endpoints.ts +266 -0
- package/src/swagger/generated-block.ts +74 -0
- package/src/swagger/openapi-schema.ts +161 -0
- package/src/swagger/parse-response.ts +88 -0
- package/src/swagger/types.ts +25 -0
- package/src/ui/prompts.ts +125 -0
- package/src/ui/report.ts +107 -0
- package/tsconfig.json +46 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConceptDef,
|
|
3
|
+
DomainDef,
|
|
4
|
+
EndpointDef,
|
|
5
|
+
RuleDef,
|
|
6
|
+
ServiceDef,
|
|
7
|
+
} from "@cortex/contracts/graph";
|
|
8
|
+
|
|
9
|
+
type DomainCollections = {
|
|
10
|
+
concepts: ConceptDef[];
|
|
11
|
+
endpoints: EndpointDef[];
|
|
12
|
+
services: ServiceDef[];
|
|
13
|
+
rules: RuleDef[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type OwnershipMaps = {
|
|
17
|
+
concepts: WeakMap<ConceptDef, string>;
|
|
18
|
+
endpoints: WeakMap<EndpointDef, string>;
|
|
19
|
+
services: WeakMap<ServiceDef, string>;
|
|
20
|
+
rules: WeakMap<RuleDef, string>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function expandDomains(domains: Record<string, DomainDef>) {
|
|
24
|
+
const ownership = createOwnershipMaps(domains);
|
|
25
|
+
const expanded: Record<string, DomainDef> = {};
|
|
26
|
+
|
|
27
|
+
for (const [domainKey, domain] of Object.entries(domains)) {
|
|
28
|
+
const collections = cloneCollections(domain);
|
|
29
|
+
const pendingConcepts = [...collections.concepts];
|
|
30
|
+
const pendingEndpoints = [...collections.endpoints];
|
|
31
|
+
const pendingServices = [...collections.services];
|
|
32
|
+
|
|
33
|
+
while (
|
|
34
|
+
pendingConcepts.length > 0 ||
|
|
35
|
+
pendingEndpoints.length > 0 ||
|
|
36
|
+
pendingServices.length > 0
|
|
37
|
+
) {
|
|
38
|
+
const concept = pendingConcepts.pop();
|
|
39
|
+
if (concept) {
|
|
40
|
+
includeConcept({
|
|
41
|
+
domainKey,
|
|
42
|
+
concept,
|
|
43
|
+
ownership,
|
|
44
|
+
collections,
|
|
45
|
+
pendingConcepts,
|
|
46
|
+
pendingEndpoints,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const endpoint = pendingEndpoints.pop();
|
|
51
|
+
if (endpoint) {
|
|
52
|
+
includeEndpoint({
|
|
53
|
+
domainKey,
|
|
54
|
+
endpoint,
|
|
55
|
+
ownership,
|
|
56
|
+
collections,
|
|
57
|
+
pendingConcepts,
|
|
58
|
+
pendingEndpoints,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const service = pendingServices.pop();
|
|
63
|
+
if (service) {
|
|
64
|
+
includeService({
|
|
65
|
+
domainKey,
|
|
66
|
+
service,
|
|
67
|
+
ownership,
|
|
68
|
+
collections,
|
|
69
|
+
pendingConcepts,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
expanded[domainKey] = {
|
|
75
|
+
...domain,
|
|
76
|
+
concepts: collections.concepts,
|
|
77
|
+
endpoints: collections.endpoints,
|
|
78
|
+
services: collections.services,
|
|
79
|
+
rules: collections.rules,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return expanded;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function createOwnershipMaps(domains: Record<string, DomainDef>) {
|
|
87
|
+
const ownership: OwnershipMaps = {
|
|
88
|
+
concepts: new WeakMap<ConceptDef, string>(),
|
|
89
|
+
endpoints: new WeakMap<EndpointDef, string>(),
|
|
90
|
+
services: new WeakMap<ServiceDef, string>(),
|
|
91
|
+
rules: new WeakMap<RuleDef, string>(),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
for (const [domainKey, domain] of Object.entries(domains)) {
|
|
95
|
+
for (const concept of domain.concepts ?? []) {
|
|
96
|
+
assignOwner(ownership.concepts, concept, domainKey);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const endpoint of domain.endpoints ?? []) {
|
|
100
|
+
assignOwner(ownership.endpoints, endpoint, domainKey);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const service of domain.services ?? []) {
|
|
104
|
+
assignOwner(ownership.services, service, domainKey);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const rule of domain.rules ?? []) {
|
|
108
|
+
assignOwner(ownership.rules, rule, domainKey);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return ownership;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function assignOwner<T extends object>(owners: WeakMap<T, string>, value: T, domainKey: string) {
|
|
116
|
+
if (!owners.has(value)) {
|
|
117
|
+
owners.set(value, domainKey);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function cloneCollections(domain: DomainDef) {
|
|
122
|
+
return {
|
|
123
|
+
concepts: [...(domain.concepts ?? [])],
|
|
124
|
+
endpoints: [...(domain.endpoints ?? [])],
|
|
125
|
+
services: [...(domain.services ?? [])],
|
|
126
|
+
rules: [...(domain.rules ?? [])],
|
|
127
|
+
} satisfies DomainCollections;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function includeConcept(options: {
|
|
131
|
+
domainKey: string;
|
|
132
|
+
concept: ConceptDef;
|
|
133
|
+
ownership: OwnershipMaps;
|
|
134
|
+
collections: DomainCollections;
|
|
135
|
+
pendingConcepts: ConceptDef[];
|
|
136
|
+
pendingEndpoints: EndpointDef[];
|
|
137
|
+
}) {
|
|
138
|
+
const { domainKey, concept, ownership, collections, pendingConcepts, pendingEndpoints } =
|
|
139
|
+
options;
|
|
140
|
+
|
|
141
|
+
includeRule(domainKey, concept.governedBy ?? [], ownership, collections);
|
|
142
|
+
|
|
143
|
+
if (shouldAttach(ownership.concepts, concept, domainKey)) {
|
|
144
|
+
pushUnique(collections.concepts, concept);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (
|
|
148
|
+
concept.parentConcept &&
|
|
149
|
+
shouldAttach(ownership.concepts, concept.parentConcept, domainKey)
|
|
150
|
+
) {
|
|
151
|
+
if (pushUnique(collections.concepts, concept.parentConcept)) {
|
|
152
|
+
pendingConcepts.push(concept.parentConcept);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const endpoint of collections.endpoints) {
|
|
157
|
+
if (referencesConcept(endpoint, concept)) {
|
|
158
|
+
includeEndpoint({
|
|
159
|
+
domainKey,
|
|
160
|
+
endpoint,
|
|
161
|
+
ownership,
|
|
162
|
+
collections,
|
|
163
|
+
pendingConcepts,
|
|
164
|
+
pendingEndpoints,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function includeEndpoint(options: {
|
|
171
|
+
domainKey: string;
|
|
172
|
+
endpoint: EndpointDef;
|
|
173
|
+
ownership: OwnershipMaps;
|
|
174
|
+
collections: DomainCollections;
|
|
175
|
+
pendingConcepts: ConceptDef[];
|
|
176
|
+
pendingEndpoints: EndpointDef[];
|
|
177
|
+
}) {
|
|
178
|
+
const { domainKey, endpoint, ownership, collections, pendingConcepts, pendingEndpoints } =
|
|
179
|
+
options;
|
|
180
|
+
|
|
181
|
+
if (shouldAttach(ownership.endpoints, endpoint, domainKey)) {
|
|
182
|
+
pushUnique(collections.endpoints, endpoint);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
includeRule(domainKey, endpoint.governedBy ?? [], ownership, collections);
|
|
186
|
+
|
|
187
|
+
// An endpoint reaches concepts three ways and other endpoints one way, but the
|
|
188
|
+
// handling is identical: take what this domain owns, and queue whatever is new.
|
|
189
|
+
collectOwned({
|
|
190
|
+
items: [
|
|
191
|
+
...(endpoint.queries ?? []),
|
|
192
|
+
...(endpoint.mutates ?? []),
|
|
193
|
+
...(endpoint.returns ?? []).map((returned) => returned.concept),
|
|
194
|
+
],
|
|
195
|
+
owners: ownership.concepts,
|
|
196
|
+
collection: collections.concepts,
|
|
197
|
+
pending: pendingConcepts,
|
|
198
|
+
domainKey,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
collectOwned({
|
|
202
|
+
items: (endpoint.dependsOn ?? []).map((dependency) => dependency.endpoint),
|
|
203
|
+
owners: ownership.endpoints,
|
|
204
|
+
collection: collections.endpoints,
|
|
205
|
+
pending: pendingEndpoints,
|
|
206
|
+
domainKey,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Only what this domain owns is attached, and only the first sighting is queued —
|
|
212
|
+
* re-queueing something already collected would walk the graph forever.
|
|
213
|
+
*/
|
|
214
|
+
function collectOwned<T extends object>(options: {
|
|
215
|
+
items: T[];
|
|
216
|
+
owners: WeakMap<T, string>;
|
|
217
|
+
collection: T[];
|
|
218
|
+
pending: T[];
|
|
219
|
+
domainKey: string;
|
|
220
|
+
}) {
|
|
221
|
+
const { items, owners, collection, pending, domainKey } = options;
|
|
222
|
+
|
|
223
|
+
for (const item of items) {
|
|
224
|
+
if (shouldAttach(owners, item, domainKey) && pushUnique(collection, item)) {
|
|
225
|
+
pending.push(item);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function includeService(options: {
|
|
231
|
+
domainKey: string;
|
|
232
|
+
service: ServiceDef;
|
|
233
|
+
ownership: OwnershipMaps;
|
|
234
|
+
collections: DomainCollections;
|
|
235
|
+
pendingConcepts: ConceptDef[];
|
|
236
|
+
}) {
|
|
237
|
+
const { domainKey, service, ownership, collections, pendingConcepts } = options;
|
|
238
|
+
|
|
239
|
+
if (shouldAttach(ownership.services, service, domainKey)) {
|
|
240
|
+
pushUnique(collections.services, service);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
includeRule(domainKey, service.governedBy ?? [], ownership, collections);
|
|
244
|
+
|
|
245
|
+
if (
|
|
246
|
+
shouldAttach(ownership.concepts, service.belongsTo, domainKey) &&
|
|
247
|
+
pushUnique(collections.concepts, service.belongsTo)
|
|
248
|
+
) {
|
|
249
|
+
pendingConcepts.push(service.belongsTo);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function includeRule(
|
|
254
|
+
domainKey: string,
|
|
255
|
+
rules: readonly RuleDef[],
|
|
256
|
+
ownership: OwnershipMaps,
|
|
257
|
+
collections: DomainCollections,
|
|
258
|
+
) {
|
|
259
|
+
for (const rule of rules) {
|
|
260
|
+
if (shouldAttach(ownership.rules, rule, domainKey)) {
|
|
261
|
+
pushUnique(collections.rules, rule);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function shouldAttach<T extends object>(owners: WeakMap<T, string>, value: T, domainKey: string) {
|
|
267
|
+
const owner = owners.get(value);
|
|
268
|
+
return owner == null || owner === domainKey;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function pushUnique<T extends object>(values: T[], value: T) {
|
|
272
|
+
if (values.includes(value)) {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
values.push(value);
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function referencesConcept(endpoint: EndpointDef, concept: ConceptDef) {
|
|
281
|
+
return (
|
|
282
|
+
endpoint.queries?.includes(concept) ||
|
|
283
|
+
endpoint.mutates?.includes(concept) ||
|
|
284
|
+
endpoint.returns?.some(function (returned) {
|
|
285
|
+
return returned.concept === concept;
|
|
286
|
+
})
|
|
287
|
+
);
|
|
288
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transforms declarative DomainDef into idempotent MERGE-based Cypher.
|
|
3
|
+
*
|
|
4
|
+
* Returns two phases:
|
|
5
|
+
* - `nodes` -- MERGE statements that create/update Domain, Concept, Endpoint,
|
|
6
|
+
* Service, and Rule nodes.
|
|
7
|
+
* - `edges` -- MATCH+MERGE statements that wire up relationships between nodes.
|
|
8
|
+
*
|
|
9
|
+
* The caller MUST run all `nodes` from every domain before running any `edges`,
|
|
10
|
+
* because edges may reference nodes defined in a different domain (e.g. a Rule
|
|
11
|
+
* in the leaves domain that GOVERNS a Concept created by the servicing domain).
|
|
12
|
+
*
|
|
13
|
+
* Every label, relationship type and property name comes from the `GraphSchema`
|
|
14
|
+
* the caller passes in — the project's own copy, so what this writes is by
|
|
15
|
+
* construction what that project's resolver reads back.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
ConceptDef,
|
|
20
|
+
DomainDef,
|
|
21
|
+
EndpointDef,
|
|
22
|
+
GraphSchema,
|
|
23
|
+
RuleDef,
|
|
24
|
+
ServiceDef,
|
|
25
|
+
} from "@cortex/contracts/graph";
|
|
26
|
+
|
|
27
|
+
export type GeneratedCypher = {
|
|
28
|
+
nodes: string[];
|
|
29
|
+
edges: string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function generateCypher(data: DomainDef, s: GraphSchema) {
|
|
33
|
+
const nodes: string[] = [domainNode(data, s)];
|
|
34
|
+
const edges: string[] = [];
|
|
35
|
+
|
|
36
|
+
for (const concept of data.concepts ?? []) {
|
|
37
|
+
nodes.push(conceptNode(concept, s));
|
|
38
|
+
edges.push(...conceptEdges(concept, data.name, s));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const endpoint of data.endpoints ?? []) {
|
|
42
|
+
nodes.push(endpointNode(endpoint, s));
|
|
43
|
+
edges.push(...endpointEdges(endpoint, s));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const service of data.services ?? []) {
|
|
47
|
+
nodes.push(serviceNode(service, s));
|
|
48
|
+
edges.push(...serviceEdges(service, s));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const rule of data.rules ?? []) {
|
|
52
|
+
nodes.push(ruleNode(rule, s));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { nodes, edges } satisfies GeneratedCypher;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function domainNode(data: DomainDef, s: GraphSchema) {
|
|
59
|
+
return `MERGE (d:${s.label.domain} {${s.prop.name}: '${esc(data.name)}'})
|
|
60
|
+
SET d.${s.prop.description} = '${esc(data.description)}'${metadataClause("d", data.metadata, s)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function conceptNode(concept: ConceptDef, s: GraphSchema) {
|
|
64
|
+
const aliases =
|
|
65
|
+
concept.aliases && concept.aliases.length > 0
|
|
66
|
+
? `, c.${s.prop.aliases} = [${concept.aliases.map((alias) => `'${esc(alias)}'`).join(", ")}]`
|
|
67
|
+
: "";
|
|
68
|
+
|
|
69
|
+
return `MERGE (c:${s.label.concept} {${s.prop.name}: '${esc(concept.name)}'})
|
|
70
|
+
SET c.${s.prop.description} = '${esc(concept.description)}'${aliases}${metadataClause("c", concept.metadata, s)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function conceptEdges(concept: ConceptDef, domainName: string, s: GraphSchema) {
|
|
74
|
+
const edges = [
|
|
75
|
+
`MATCH (c:${conceptRef(concept, s)})
|
|
76
|
+
MATCH (d:${s.label.domain} {${s.prop.name}: '${esc(domainName)}'})
|
|
77
|
+
MERGE (c)-[:${s.rel.belongsTo}]->(d)`,
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
if (concept.parentConcept) {
|
|
81
|
+
edges.push(
|
|
82
|
+
`MATCH (child:${conceptRef(concept, s)})
|
|
83
|
+
MATCH (parent:${conceptRef(concept.parentConcept, s)})
|
|
84
|
+
MERGE (child)-[:${s.rel.specializes}]->(parent)`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
edges.push(...governedByEdges(concept.governedBy, conceptRef(concept, s), s));
|
|
89
|
+
|
|
90
|
+
return edges;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function endpointNode(endpoint: EndpointDef, s: GraphSchema) {
|
|
94
|
+
return `MERGE (e:${endpointRef(endpoint, s)})
|
|
95
|
+
SET e.${s.prop.name} = '${esc(endpoint.name)}',
|
|
96
|
+
e.${s.prop.description} = '${esc(endpoint.description)}',
|
|
97
|
+
e.${s.prop.params} = '${jsonStr(endpoint.params)}',
|
|
98
|
+
e.${s.prop.body} = '${jsonStr(endpoint.body)}',
|
|
99
|
+
e.${s.prop.response} = '${jsonStr(endpoint.response)}',
|
|
100
|
+
e.${s.prop.propertiesDescriptions} = '${jsonStr(endpoint.propertiesDescriptions)}',
|
|
101
|
+
e.${s.prop.successStatus} = ${endpoint.successStatus},
|
|
102
|
+
e.${s.prop.errorStatuses} = [${endpoint.errorStatuses.join(", ")}]${metadataClause("e", endpoint.metadata, s)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function endpointEdges(endpoint: EndpointDef, s: GraphSchema) {
|
|
106
|
+
const edges: string[] = [];
|
|
107
|
+
const self = endpointRef(endpoint, s);
|
|
108
|
+
|
|
109
|
+
for (const [concepts, relationship] of [
|
|
110
|
+
[endpoint.queries, s.rel.queriedVia],
|
|
111
|
+
[endpoint.mutates, s.rel.mutatedVia],
|
|
112
|
+
] as const) {
|
|
113
|
+
for (const concept of concepts ?? []) {
|
|
114
|
+
edges.push(
|
|
115
|
+
`MATCH (c:${conceptRef(concept, s)})
|
|
116
|
+
MATCH (e:${self})
|
|
117
|
+
MERGE (c)-[:${relationship}]->(e)`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const returned of endpoint.returns ?? []) {
|
|
123
|
+
// The field says which part of the response carries the concept; without
|
|
124
|
+
// one the whole body is the concept.
|
|
125
|
+
const field = returned.field ? ` SET r.${s.prop.field} = '${esc(returned.field)}'` : "";
|
|
126
|
+
edges.push(
|
|
127
|
+
`MATCH (e:${self})
|
|
128
|
+
MATCH (c:${conceptRef(returned.concept, s)})
|
|
129
|
+
MERGE (e)-[r:${s.rel.returns}]->(c)${field}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
edges.push(...governedByEdges(endpoint.governedBy, self, s));
|
|
134
|
+
|
|
135
|
+
for (const dependency of endpoint.dependsOn ?? []) {
|
|
136
|
+
edges.push(
|
|
137
|
+
`MATCH (e:${self})
|
|
138
|
+
MATCH (dep:${endpointRef(dependency.endpoint, s)})
|
|
139
|
+
MERGE (e)-[d:${s.rel.dependsOn}]->(dep)
|
|
140
|
+
SET d.${s.prop.paramName} = '${esc(dependency.paramName)}', d.${s.prop.fromField} = '${esc(dependency.fromField)}'`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return edges;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function serviceNode(service: ServiceDef, s: GraphSchema) {
|
|
148
|
+
return `MERGE (s:${serviceRef(service, s)})
|
|
149
|
+
SET s.${s.prop.name} = '${esc(service.name)}',
|
|
150
|
+
s.${s.prop.description} = '${esc(service.description)}'${metadataClause("s", service.metadata, s)}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function serviceEdges(service: ServiceDef, s: GraphSchema) {
|
|
154
|
+
return [
|
|
155
|
+
`MATCH (c:${conceptRef(service.belongsTo, s)})
|
|
156
|
+
MATCH (s:${serviceRef(service, s)})
|
|
157
|
+
MERGE (c)-[:${s.rel.requestedVia}]->(s)`,
|
|
158
|
+
...governedByEdges(service.governedBy, serviceRef(service, s), s),
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function ruleNode(rule: RuleDef, s: GraphSchema) {
|
|
163
|
+
return `MERGE (r:${s.label.rule} {${s.prop.name}: '${esc(rule.name)}'})
|
|
164
|
+
SET r.${s.prop.description} = '${esc(rule.description)}'${metadataClause("r", rule.metadata, s)}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Rules govern concepts, endpoints and services identically; only the target differs. */
|
|
168
|
+
function governedByEdges(rules: RuleDef[] | undefined, targetRef: string, s: GraphSchema) {
|
|
169
|
+
return (rules ?? []).map(
|
|
170
|
+
(rule) => `MATCH (r:${s.label.rule} {${s.prop.name}: '${esc(rule.name)}'})
|
|
171
|
+
MATCH (t:${targetRef})
|
|
172
|
+
MERGE (r)-[:${s.rel.governs}]->(t)`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Endpoints are keyed on path plus method, so both must appear in every match. */
|
|
177
|
+
function endpointRef(endpoint: Pick<EndpointDef, "path" | "method">, s: GraphSchema) {
|
|
178
|
+
return `${s.label.endpoint} {${s.prop.path}: '${esc(endpoint.path)}', ${s.prop.method}: '${endpoint.method}'}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function conceptRef(concept: Pick<ConceptDef, "name">, s: GraphSchema) {
|
|
182
|
+
return `${s.label.concept} {${s.prop.name}: '${esc(concept.name)}'}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function serviceRef(service: Pick<ServiceDef, "builtInId">, s: GraphSchema) {
|
|
186
|
+
return `${s.label.service} {${s.prop.builtInId}: '${esc(service.builtInId)}'}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Omitted entirely when absent — an empty assignment is not valid Cypher. */
|
|
190
|
+
function metadataClause(alias: string, metadata: unknown, s: GraphSchema) {
|
|
191
|
+
return metadata ? `, ${alias}.${s.prop.metadata} = '${jsonStr(metadata)}'` : "";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Backslashes first, so escaping a quote cannot itself be escaped away. */
|
|
195
|
+
function esc(value: string) {
|
|
196
|
+
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function jsonStr(value: unknown) {
|
|
200
|
+
return esc(JSON.stringify(value));
|
|
201
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed orchestrator for the knowledge graph: validates domains, generates
|
|
3
|
+
* Cypher, executes it against Neo4j, and embeds every concept.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
Neo4jQueryError,
|
|
8
|
+
type DomainDef,
|
|
9
|
+
type EmbedFn,
|
|
10
|
+
type GraphSchema,
|
|
11
|
+
type Neo4jClient,
|
|
12
|
+
} from "@cortex/contracts/graph";
|
|
13
|
+
|
|
14
|
+
import { expandDomains } from "./expand-domains";
|
|
15
|
+
import { generateCypher, type GeneratedCypher } from "./generate-cypher";
|
|
16
|
+
import { validateDomain } from "./validate";
|
|
17
|
+
|
|
18
|
+
type SeedEmbedding = {
|
|
19
|
+
embed: EmbedFn;
|
|
20
|
+
dimension: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type SeedGraphConfig = {
|
|
24
|
+
neo4j: Neo4jClient;
|
|
25
|
+
embedding: SeedEmbedding;
|
|
26
|
+
/** `GRAPH_SCHEMA`, passed rather than imported so tests can drive a fake one. */
|
|
27
|
+
schema: GraphSchema;
|
|
28
|
+
/** Stamped onto the graph, so the server can refuse a shape it cannot read. */
|
|
29
|
+
schemaVersion: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type StatementFailure = {
|
|
33
|
+
/** The Neo4j error code where there was one, its message otherwise. */
|
|
34
|
+
error: string;
|
|
35
|
+
/** The statement as generated, untruncated — the caller decides how much to show. */
|
|
36
|
+
statement: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type DomainSeedResult = {
|
|
40
|
+
nodes: number;
|
|
41
|
+
edges: number;
|
|
42
|
+
failed: number;
|
|
43
|
+
failures: StatementFailure[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type SeedGraphResult = {
|
|
47
|
+
nodes: number;
|
|
48
|
+
edges: number;
|
|
49
|
+
failed: number;
|
|
50
|
+
/** Keyed by the same names the config declares under `knowledge.domains`. */
|
|
51
|
+
domains: Record<string, DomainSeedResult>;
|
|
52
|
+
embeddings: { concepts: number; dimension: number };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Nodes for every domain run before any edge does, so a statement carries the
|
|
56
|
+
// domain it came from rather than being attributed by the loop that runs it.
|
|
57
|
+
type TaggedStatement = { domain: string; statement: string };
|
|
58
|
+
|
|
59
|
+
/** Runs one statement; the failure string is Neo4j's error code when it has one. */
|
|
60
|
+
async function runStatement(client: Neo4jClient, statement: string) {
|
|
61
|
+
try {
|
|
62
|
+
await client.query(statement);
|
|
63
|
+
return undefined;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error instanceof Neo4jQueryError)
|
|
66
|
+
return /^\d+$/.test(error.code) ? error.message : error.code;
|
|
67
|
+
return error instanceof Error ? error.message : String(error);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Maps, not objects: a domain is named by the user, and `constructor` is a legal
|
|
72
|
+
// domain name that on a plain record reads back `Object.prototype.constructor` —
|
|
73
|
+
// a function, so `?? 0` never fires and `.push` throws.
|
|
74
|
+
async function runPhase(client: Neo4jClient, statements: TaggedStatement[]) {
|
|
75
|
+
const succeeded = new Map<string, number>();
|
|
76
|
+
const failures = new Map<string, StatementFailure[]>();
|
|
77
|
+
|
|
78
|
+
for (const { domain, statement } of statements) {
|
|
79
|
+
const error = await runStatement(client, statement);
|
|
80
|
+
if (error === undefined) {
|
|
81
|
+
succeeded.set(domain, (succeeded.get(domain) ?? 0) + 1);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const collected = failures.get(domain) ?? [];
|
|
86
|
+
collected.push({ error, statement });
|
|
87
|
+
failures.set(domain, collected);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { succeeded, failures };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function generateEmbeddings(
|
|
94
|
+
client: Neo4jClient,
|
|
95
|
+
embeddingConfig: SeedEmbedding,
|
|
96
|
+
s: GraphSchema,
|
|
97
|
+
) {
|
|
98
|
+
// 1. Create the vector index (idempotent)
|
|
99
|
+
await client.query(
|
|
100
|
+
`CREATE VECTOR INDEX ${s.conceptEmbeddingsIndex} IF NOT EXISTS
|
|
101
|
+
FOR (c:${s.label.concept}) ON (c.${s.prop.embedding})
|
|
102
|
+
OPTIONS {indexConfig: {
|
|
103
|
+
\`vector.dimensions\`: ${embeddingConfig.dimension},
|
|
104
|
+
\`vector.similarity_function\`: 'cosine'
|
|
105
|
+
}}`,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// 2. Fetch all concepts
|
|
109
|
+
const concepts = (await client.query(
|
|
110
|
+
`MATCH (c:${s.label.concept}) RETURN c.${s.prop.name} AS name, c.${s.prop.description} AS description`,
|
|
111
|
+
)) as { name: string; description: string }[];
|
|
112
|
+
|
|
113
|
+
if (concepts.length === 0) {
|
|
114
|
+
return { concepts: 0, dimension: embeddingConfig.dimension };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 3. Generate embeddings for every concept in one request
|
|
118
|
+
const embeddings = await embeddingConfig.embed(concepts.map((c) => c.description));
|
|
119
|
+
|
|
120
|
+
// 4. Store the embeddings in the graph
|
|
121
|
+
await Promise.all(
|
|
122
|
+
concepts.map((concept, idx) =>
|
|
123
|
+
client.query(
|
|
124
|
+
`MATCH (c:${s.label.concept} {${s.prop.name}: $name})
|
|
125
|
+
SET c.${s.prop.embedding} = $embedding`,
|
|
126
|
+
{ name: concept.name, embedding: embeddings[idx]! },
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
return { concepts: concepts.length, dimension: embeddingConfig.dimension };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function seedGraph(config: SeedGraphConfig, domains: Record<string, DomainDef>) {
|
|
135
|
+
const expandedDomains = expandDomains(domains);
|
|
136
|
+
|
|
137
|
+
// Validate all domains
|
|
138
|
+
const validationErrors = Object.entries(expandedDomains).flatMap(([name, domain]) =>
|
|
139
|
+
validateDomain(domain).map((err) => `[${name}] ${err}`),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if (validationErrors.length) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Validation failed with ${validationErrors.length} error(s):\n${validationErrors.join("\n")}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const client = config.neo4j;
|
|
149
|
+
const targets = Object.entries(expandedDomains);
|
|
150
|
+
|
|
151
|
+
// Generate Cypher for all target domains
|
|
152
|
+
const allCypher: [string, GeneratedCypher][] = targets.map(([name, data]) => [
|
|
153
|
+
name,
|
|
154
|
+
generateCypher(data, config.schema),
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
// Stamped before anything else is written, so an interrupted seed still
|
|
158
|
+
// leaves the graph labelled with the schema it was written against.
|
|
159
|
+
await client.query(
|
|
160
|
+
`MERGE (g:${config.schema.label.marker})
|
|
161
|
+
SET g.${config.schema.prop.version} = ${config.schemaVersion}`,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Nodes across every domain first, so cross-domain references resolve.
|
|
165
|
+
const allNodes = allCypher.flatMap(([domain, c]) => tag(domain, c.nodes));
|
|
166
|
+
const nodes = await runPhase(client, allNodes);
|
|
167
|
+
|
|
168
|
+
// Edges once every node exists.
|
|
169
|
+
const allEdges = allCypher.flatMap(([domain, c]) => tag(domain, c.edges));
|
|
170
|
+
const edges = await runPhase(client, allEdges);
|
|
171
|
+
|
|
172
|
+
// Embeddings for every concept.
|
|
173
|
+
const embeddings = await generateEmbeddings(client, config.embedding, config.schema);
|
|
174
|
+
|
|
175
|
+
const domainResults: Record<string, DomainSeedResult> = Object.fromEntries(
|
|
176
|
+
targets.map(([name]) => {
|
|
177
|
+
const failures = [
|
|
178
|
+
...(nodes.failures.get(name) ?? []),
|
|
179
|
+
...(edges.failures.get(name) ?? []),
|
|
180
|
+
];
|
|
181
|
+
return [
|
|
182
|
+
name,
|
|
183
|
+
{
|
|
184
|
+
nodes: nodes.succeeded.get(name) ?? 0,
|
|
185
|
+
edges: edges.succeeded.get(name) ?? 0,
|
|
186
|
+
failed: failures.length,
|
|
187
|
+
failures,
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// The aggregates stay the headline number; per-domain is the breakdown of it.
|
|
194
|
+
return Object.values(domainResults).reduce<SeedGraphResult>(
|
|
195
|
+
(total, domain) => ({
|
|
196
|
+
...total,
|
|
197
|
+
nodes: total.nodes + domain.nodes,
|
|
198
|
+
edges: total.edges + domain.edges,
|
|
199
|
+
failed: total.failed + domain.failed,
|
|
200
|
+
}),
|
|
201
|
+
{ nodes: 0, edges: 0, failed: 0, domains: domainResults, embeddings },
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function tag(domain: string, statements: string[]) {
|
|
206
|
+
return statements.map((statement) => ({ domain, statement }) satisfies TaggedStatement);
|
|
207
|
+
}
|