@clossys/architect 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/dist/assessment.d.ts +9 -0
- package/dist/assessment.d.ts.map +1 -0
- package/dist/assessment.js +98 -0
- package/dist/assessment.js.map +1 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +96 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/ontology/compatibility.d.ts +7 -0
- package/dist/ontology/compatibility.d.ts.map +1 -0
- package/dist/ontology/compatibility.js +135 -0
- package/dist/ontology/compatibility.js.map +1 -0
- package/dist/ontology/define.d.ts +8 -0
- package/dist/ontology/define.d.ts.map +1 -0
- package/dist/ontology/define.js +30 -0
- package/dist/ontology/define.js.map +1 -0
- package/dist/ontology/index.d.ts +9 -0
- package/dist/ontology/index.d.ts.map +1 -0
- package/dist/ontology/index.js +8 -0
- package/dist/ontology/index.js.map +1 -0
- package/dist/ontology/normalize.d.ts +6 -0
- package/dist/ontology/normalize.d.ts.map +1 -0
- package/dist/ontology/normalize.js +60 -0
- package/dist/ontology/normalize.js.map +1 -0
- package/dist/ontology/snapshot.d.ts +14 -0
- package/dist/ontology/snapshot.d.ts.map +1 -0
- package/dist/ontology/snapshot.js +299 -0
- package/dist/ontology/snapshot.js.map +1 -0
- package/dist/ontology/types.d.ts +123 -0
- package/dist/ontology/types.d.ts.map +1 -0
- package/dist/ontology/types.js +16 -0
- package/dist/ontology/types.js.map +1 -0
- package/dist/ontology/validate.d.ts +8 -0
- package/dist/ontology/validate.d.ts.map +1 -0
- package/dist/ontology/validate.js +190 -0
- package/dist/ontology/validate.js.map +1 -0
- package/dist/topology.d.ts +11 -0
- package/dist/topology.d.ts.map +1 -0
- package/dist/topology.js +237 -0
- package/dist/topology.js.map +1 -0
- package/dist/types.d.ts +110 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -0
- package/package.json +69 -0
- package/src/assessment.ts +98 -0
- package/src/cli.ts +74 -0
- package/src/index.ts +39 -0
- package/src/ontology/compatibility.ts +132 -0
- package/src/ontology/define.ts +35 -0
- package/src/ontology/index.ts +29 -0
- package/src/ontology/normalize.ts +63 -0
- package/src/ontology/snapshot.ts +324 -0
- package/src/ontology/types.ts +156 -0
- package/src/ontology/validate.ts +208 -0
- package/src/topology.ts +211 -0
- package/src/types.ts +142 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { defineDomainModel } from "./define.js";
|
|
2
|
+
import type { DomainModel, DomainModelDefinition, DomainTypeDefinition, FieldDefinition, RelationDefinition, ValueTypeDefinition, VocabularyDefinition } from "./types.js";
|
|
3
|
+
|
|
4
|
+
function byId<T extends { id: string }>(left: T, right: T): number {
|
|
5
|
+
return left.id.localeCompare(right.id);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function normalizeField(field: FieldDefinition): FieldDefinition {
|
|
9
|
+
const normalized: FieldDefinition = { id: field.id, valueType: field.valueType, required: field.required === true };
|
|
10
|
+
if (field.label !== undefined) normalized.label = field.label;
|
|
11
|
+
return normalized;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizeType(type: DomainTypeDefinition): DomainTypeDefinition {
|
|
15
|
+
const normalized: DomainTypeDefinition = {
|
|
16
|
+
id: type.id,
|
|
17
|
+
fields: [...(type.fields ?? [])].map(normalizeField).sort(byId),
|
|
18
|
+
};
|
|
19
|
+
if (type.label !== undefined) normalized.label = type.label;
|
|
20
|
+
return normalized;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeRelation(relation: RelationDefinition): RelationDefinition {
|
|
24
|
+
const normalized: RelationDefinition = {
|
|
25
|
+
id: relation.id,
|
|
26
|
+
from: relation.from,
|
|
27
|
+
to: relation.to,
|
|
28
|
+
cardinality: relation.cardinality,
|
|
29
|
+
properties: [...(relation.properties ?? [])].map(normalizeField).sort(byId),
|
|
30
|
+
};
|
|
31
|
+
if (relation.label !== undefined) normalized.label = relation.label;
|
|
32
|
+
return normalized;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeValueType(valueType: ValueTypeDefinition): ValueTypeDefinition {
|
|
36
|
+
const normalized: ValueTypeDefinition = { id: valueType.id, primitive: valueType.primitive };
|
|
37
|
+
if (valueType.label !== undefined) normalized.label = valueType.label;
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeVocabulary(vocabulary: VocabularyDefinition): VocabularyDefinition {
|
|
42
|
+
const normalized: VocabularyDefinition = { id: vocabulary.id, values: [...vocabulary.values].sort() };
|
|
43
|
+
if (vocabulary.label !== undefined) normalized.label = vocabulary.label;
|
|
44
|
+
return normalized;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Returns one canonical ordering and property layout for the same model value. */
|
|
48
|
+
export function normalizeDomainModel(model: DomainModelDefinition): DomainModel {
|
|
49
|
+
const copied = defineDomainModel(model);
|
|
50
|
+
return {
|
|
51
|
+
id: copied.id,
|
|
52
|
+
schemaVersion: copied.schemaVersion,
|
|
53
|
+
valueTypes: copied.valueTypes.map(normalizeValueType).sort(byId),
|
|
54
|
+
vocabularies: copied.vocabularies.map(normalizeVocabulary).sort(byId),
|
|
55
|
+
types: copied.types.map(normalizeType).sort(byId),
|
|
56
|
+
relations: copied.relations.map(normalizeRelation).sort(byId),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Serializes a normalized model as stable, language-neutral JSON followed by one newline. */
|
|
61
|
+
export function serializeDomainModel(model: DomainModelDefinition): string {
|
|
62
|
+
return `${JSON.stringify(normalizeDomainModel(model), null, 2)}\n`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { defineDomainModel } from "./define.js";
|
|
2
|
+
import { validateDomainModel } from "./validate.js";
|
|
3
|
+
import type {
|
|
4
|
+
DomainModelDefinition,
|
|
5
|
+
DomainRecord,
|
|
6
|
+
DomainRecordDefinition,
|
|
7
|
+
DomainRelation,
|
|
8
|
+
DomainRelationDefinition,
|
|
9
|
+
DomainSnapshot,
|
|
10
|
+
DomainSnapshotDefinition,
|
|
11
|
+
DomainSnapshotFinding,
|
|
12
|
+
FieldDefinition,
|
|
13
|
+
PrimitiveValueType,
|
|
14
|
+
RelationCardinality,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
|
|
17
|
+
type UnknownRecord = Record<string, unknown>;
|
|
18
|
+
|
|
19
|
+
interface RelationCandidate {
|
|
20
|
+
index: number;
|
|
21
|
+
type: string;
|
|
22
|
+
from: string;
|
|
23
|
+
to: string;
|
|
24
|
+
identity: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isRecord(value: unknown): value is UnknownRecord {
|
|
28
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
29
|
+
const prototype = Object.getPrototypeOf(value);
|
|
30
|
+
return prototype === Object.prototype || prototype === null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function copyValue(value: unknown): unknown {
|
|
34
|
+
if (Array.isArray(value)) return value.map(copyValue);
|
|
35
|
+
if (!isRecord(value)) return value;
|
|
36
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, copyValue(entry)]));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function copyValues(values: Readonly<Record<string, unknown>> | undefined): Record<string, unknown> {
|
|
40
|
+
return Object.fromEntries(Object.entries(values ?? {}).map(([key, value]) => [key, copyValue(value)]));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Creates a detached snapshot without asserting that its values satisfy a model. */
|
|
44
|
+
export function defineDomainSnapshot(definition: DomainSnapshotDefinition): DomainSnapshot {
|
|
45
|
+
const records: DomainRecord[] = (definition.records ?? []).map((record) => ({
|
|
46
|
+
id: record.id,
|
|
47
|
+
type: record.type,
|
|
48
|
+
values: copyValues(record.values),
|
|
49
|
+
}));
|
|
50
|
+
const relations: DomainRelation[] = (definition.relations ?? []).map((relation) => ({
|
|
51
|
+
type: relation.type,
|
|
52
|
+
from: relation.from,
|
|
53
|
+
to: relation.to,
|
|
54
|
+
values: copyValues(relation.values),
|
|
55
|
+
}));
|
|
56
|
+
return { records, relations };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function finding(rule: string, message: string, path: string): DomainSnapshotFinding {
|
|
60
|
+
return { rule, severity: "error", message, path };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function arrayAt(record: UnknownRecord, key: string, findings: DomainSnapshotFinding[], path: string): unknown[] {
|
|
64
|
+
const value = record[key];
|
|
65
|
+
if (value === undefined) return [];
|
|
66
|
+
if (Array.isArray(value)) return value;
|
|
67
|
+
findings.push(finding("collection-shape", `${key} must be an array when provided.`, path));
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function nonEmptyStringAt(record: UnknownRecord, key: string, findings: DomainSnapshotFinding[], path: string): string | undefined {
|
|
72
|
+
const value = record[key];
|
|
73
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
74
|
+
findings.push(finding("required-string", `${key} must be a non-empty string.`, path));
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function valuesAt(record: UnknownRecord, findings: DomainSnapshotFinding[], path: string): UnknownRecord {
|
|
79
|
+
const value = record.values;
|
|
80
|
+
if (value === undefined) return {};
|
|
81
|
+
if (isRecord(value)) return value;
|
|
82
|
+
findings.push(finding("values-shape", "values must be an object when provided.", path));
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isCalendarDate(value: string): boolean {
|
|
87
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
88
|
+
if (!match) return false;
|
|
89
|
+
const year = Number(match[1]);
|
|
90
|
+
const month = Number(match[2]);
|
|
91
|
+
const day = Number(match[3]);
|
|
92
|
+
if (month < 1 || month > 12 || day < 1) return false;
|
|
93
|
+
const daysInMonth = [31, year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
94
|
+
return day <= (daysInMonth[month - 1] ?? 0);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isDateTime(value: string): boolean {
|
|
98
|
+
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/.exec(value);
|
|
99
|
+
if (!match) return false;
|
|
100
|
+
if (!isCalendarDate(match[1] ?? "")) return false;
|
|
101
|
+
const hour = Number(match[2]);
|
|
102
|
+
const minute = Number(match[3]);
|
|
103
|
+
const second = Number(match[4]);
|
|
104
|
+
if (hour > 23 || minute > 59 || second > 59) return false;
|
|
105
|
+
const offset = match[6] ?? "";
|
|
106
|
+
if (offset !== "Z") {
|
|
107
|
+
const [offsetHour, offsetMinute] = offset.slice(1).split(":").map(Number);
|
|
108
|
+
if (offsetHour === undefined || offsetMinute === undefined || offsetHour > 23 || offsetMinute > 59) return false;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isJsonValue(value: unknown, seen = new WeakSet<object>()): boolean {
|
|
114
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
115
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
116
|
+
if (Array.isArray(value)) {
|
|
117
|
+
if (seen.has(value)) return false;
|
|
118
|
+
seen.add(value);
|
|
119
|
+
return value.every((entry) => isJsonValue(entry, seen));
|
|
120
|
+
}
|
|
121
|
+
if (!isRecord(value)) return false;
|
|
122
|
+
if (seen.has(value)) return false;
|
|
123
|
+
seen.add(value);
|
|
124
|
+
return Object.values(value).every((entry) => isJsonValue(entry, seen));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function matchesPrimitive(value: unknown, primitive: PrimitiveValueType): boolean {
|
|
128
|
+
switch (primitive) {
|
|
129
|
+
case "string": return typeof value === "string";
|
|
130
|
+
case "number": return typeof value === "number" && Number.isFinite(value);
|
|
131
|
+
case "integer": return typeof value === "number" && Number.isInteger(value);
|
|
132
|
+
case "boolean": return typeof value === "boolean";
|
|
133
|
+
case "date": return typeof value === "string" && isCalendarDate(value);
|
|
134
|
+
case "datetime": return typeof value === "string" && isDateTime(value);
|
|
135
|
+
case "json": return isJsonValue(value);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function validateValues(
|
|
140
|
+
values: UnknownRecord,
|
|
141
|
+
fields: readonly FieldDefinition[],
|
|
142
|
+
primitiveByValueType: ReadonlyMap<string, PrimitiveValueType>,
|
|
143
|
+
vocabularyValues: ReadonlyMap<string, ReadonlySet<string>>,
|
|
144
|
+
findings: DomainSnapshotFinding[],
|
|
145
|
+
path: string,
|
|
146
|
+
): void {
|
|
147
|
+
const fieldsById = new Map(fields.map((field) => [field.id, field]));
|
|
148
|
+
for (const key of Object.keys(values)) {
|
|
149
|
+
if (!fieldsById.has(key)) findings.push(finding("unknown-value", `values.${key} is not declared by this type.`, `${path}.values.${key}`));
|
|
150
|
+
}
|
|
151
|
+
for (const field of fields) {
|
|
152
|
+
const value = values[field.id];
|
|
153
|
+
const valuePath = `${path}.values.${field.id}`;
|
|
154
|
+
if (value === undefined) {
|
|
155
|
+
if (field.required === true) findings.push(finding("required-value", `Required value "${field.id}" is missing.`, valuePath));
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const vocabulary = vocabularyValues.get(field.valueType);
|
|
159
|
+
if (vocabulary !== undefined) {
|
|
160
|
+
if (typeof value !== "string" || !vocabulary.has(value)) {
|
|
161
|
+
findings.push(finding("vocabulary-value", `Value for "${field.id}" must be a declared vocabulary member.`, valuePath));
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const primitive = primitiveByValueType.get(field.valueType) ?? field.valueType;
|
|
166
|
+
if (!matchesPrimitive(value, primitive as PrimitiveValueType)) {
|
|
167
|
+
findings.push(finding("value-type", `Value for "${field.id}" does not match "${field.valueType}".`, valuePath));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function cardinalityViolation(cardinality: RelationCardinality, fromCount: number, toCount: number): boolean {
|
|
173
|
+
switch (cardinality) {
|
|
174
|
+
case "one-to-one": return fromCount > 1 || toCount > 1;
|
|
175
|
+
case "one-to-many": return toCount > 1;
|
|
176
|
+
case "many-to-one": return fromCount > 1;
|
|
177
|
+
case "many-to-many": return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Validates a snapshot against a domain model without throwing for malformed
|
|
183
|
+
* input. Model validation runs first so instance findings never rely on an
|
|
184
|
+
* ambiguous declaration.
|
|
185
|
+
*/
|
|
186
|
+
export function validateDomainSnapshot(model: DomainModelDefinition, value: unknown): DomainSnapshotFinding[] {
|
|
187
|
+
const modelFindings = validateDomainModel(model);
|
|
188
|
+
if (modelFindings.length > 0) {
|
|
189
|
+
return modelFindings.map((entry) => ({ ...entry, rule: `model-${entry.rule}`, message: `Domain model: ${entry.message}` }));
|
|
190
|
+
}
|
|
191
|
+
if (!isRecord(value)) return [finding("snapshot-shape", "A domain snapshot must be an object.", "$")];
|
|
192
|
+
|
|
193
|
+
const normalizedModel = defineDomainModel(model);
|
|
194
|
+
const findings: DomainSnapshotFinding[] = [];
|
|
195
|
+
const records = arrayAt(value, "records", findings, "records");
|
|
196
|
+
const relations = arrayAt(value, "relations", findings, "relations");
|
|
197
|
+
const typesById = new Map(normalizedModel.types.map((type) => [type.id, type]));
|
|
198
|
+
const relationsById = new Map(normalizedModel.relations.map((relation) => [relation.id, relation]));
|
|
199
|
+
const primitiveByValueType = new Map(normalizedModel.valueTypes.map((valueType) => [valueType.id, valueType.primitive]));
|
|
200
|
+
const vocabularyValues = new Map(normalizedModel.vocabularies.map((vocabulary) => [vocabulary.id, new Set(vocabulary.values)]));
|
|
201
|
+
const recordTypes = new Map<string, string>();
|
|
202
|
+
|
|
203
|
+
records.forEach((candidate, index) => {
|
|
204
|
+
const path = `records[${index}]`;
|
|
205
|
+
if (!isRecord(candidate)) {
|
|
206
|
+
findings.push(finding("record-shape", "A record must be an object.", path));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const id = nonEmptyStringAt(candidate, "id", findings, `${path}.id`);
|
|
210
|
+
const type = nonEmptyStringAt(candidate, "type", findings, `${path}.type`);
|
|
211
|
+
const values = valuesAt(candidate, findings, `${path}.values`);
|
|
212
|
+
if (id !== undefined) {
|
|
213
|
+
if (recordTypes.has(id)) findings.push(finding("duplicate-record-id", `Duplicate record id "${id}".`, `${path}.id`));
|
|
214
|
+
else if (type !== undefined) recordTypes.set(id, type);
|
|
215
|
+
}
|
|
216
|
+
if (type === undefined) return;
|
|
217
|
+
const typeDefinition = typesById.get(type);
|
|
218
|
+
if (typeDefinition === undefined) {
|
|
219
|
+
findings.push(finding("unknown-record-type", `Record type "${type}" is not declared by the model.`, `${path}.type`));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
validateValues(values, typeDefinition.fields ?? [], primitiveByValueType, vocabularyValues, findings, path);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const relationCandidates: RelationCandidate[] = [];
|
|
226
|
+
relations.forEach((candidate, index) => {
|
|
227
|
+
const path = `relations[${index}]`;
|
|
228
|
+
if (!isRecord(candidate)) {
|
|
229
|
+
findings.push(finding("relation-shape", "A relation must be an object.", path));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const type = nonEmptyStringAt(candidate, "type", findings, `${path}.type`);
|
|
233
|
+
const from = nonEmptyStringAt(candidate, "from", findings, `${path}.from`);
|
|
234
|
+
const to = nonEmptyStringAt(candidate, "to", findings, `${path}.to`);
|
|
235
|
+
const values = valuesAt(candidate, findings, `${path}.values`);
|
|
236
|
+
if (type === undefined || from === undefined || to === undefined) return;
|
|
237
|
+
const relationDefinition = relationsById.get(type);
|
|
238
|
+
if (relationDefinition === undefined) {
|
|
239
|
+
findings.push(finding("unknown-relation-type", `Relation type "${type}" is not declared by the model.`, `${path}.type`));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const fromType = recordTypes.get(from);
|
|
243
|
+
const toType = recordTypes.get(to);
|
|
244
|
+
if (fromType === undefined) findings.push(finding("relation-from-record", `from record "${from}" is not present in this snapshot.`, `${path}.from`));
|
|
245
|
+
else if (fromType !== relationDefinition.from) findings.push(finding("relation-from-type", `from record "${from}" must have type "${relationDefinition.from}".`, `${path}.from`));
|
|
246
|
+
if (toType === undefined) findings.push(finding("relation-to-record", `to record "${to}" is not present in this snapshot.`, `${path}.to`));
|
|
247
|
+
else if (toType !== relationDefinition.to) findings.push(finding("relation-to-type", `to record "${to}" must have type "${relationDefinition.to}".`, `${path}.to`));
|
|
248
|
+
validateValues(values, relationDefinition.properties ?? [], primitiveByValueType, vocabularyValues, findings, path);
|
|
249
|
+
if (fromType === relationDefinition.from && toType === relationDefinition.to) {
|
|
250
|
+
relationCandidates.push({
|
|
251
|
+
index,
|
|
252
|
+
type,
|
|
253
|
+
from,
|
|
254
|
+
to,
|
|
255
|
+
// A snapshot is a graph of facts. Repeating the same fact in input
|
|
256
|
+
// must not turn a valid one-to-one or many-to-one fact into a false
|
|
257
|
+
// cardinality violation.
|
|
258
|
+
identity: `${type}\u0000${from}\u0000${to}\u0000${comparable(values)}`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
for (const relationDefinition of normalizedModel.relations) {
|
|
264
|
+
const seen = new Set<string>();
|
|
265
|
+
const candidates = relationCandidates.filter((candidate) => {
|
|
266
|
+
if (candidate.type !== relationDefinition.id || seen.has(candidate.identity)) return false;
|
|
267
|
+
seen.add(candidate.identity);
|
|
268
|
+
return true;
|
|
269
|
+
});
|
|
270
|
+
const byFrom = new Map<string, number>();
|
|
271
|
+
const byTo = new Map<string, number>();
|
|
272
|
+
for (const candidate of candidates) {
|
|
273
|
+
byFrom.set(candidate.from, (byFrom.get(candidate.from) ?? 0) + 1);
|
|
274
|
+
byTo.set(candidate.to, (byTo.get(candidate.to) ?? 0) + 1);
|
|
275
|
+
}
|
|
276
|
+
for (const candidate of candidates) {
|
|
277
|
+
if (cardinalityViolation(relationDefinition.cardinality, byFrom.get(candidate.from) ?? 0, byTo.get(candidate.to) ?? 0)) {
|
|
278
|
+
findings.push(finding("relation-cardinality", `Relation "${relationDefinition.id}" violates its ${relationDefinition.cardinality} cardinality.`, `relations[${candidate.index}]`));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return findings;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function normalizeValue(value: unknown): unknown {
|
|
287
|
+
if (Array.isArray(value)) return value.map(normalizeValue);
|
|
288
|
+
if (!isRecord(value)) return value;
|
|
289
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, normalizeValue(value[key])]));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function comparable(value: unknown): string {
|
|
293
|
+
try {
|
|
294
|
+
const serialized = JSON.stringify(normalizeValue(value));
|
|
295
|
+
return serialized ?? String(value);
|
|
296
|
+
} catch {
|
|
297
|
+
return String(value);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function normalizeRecord(record: DomainRecordDefinition): DomainRecord {
|
|
302
|
+
return { id: record.id, type: record.type, values: normalizeValue(copyValues(record.values)) as Record<string, unknown> };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function normalizeRelation(relation: DomainRelationDefinition): DomainRelation {
|
|
306
|
+
return { type: relation.type, from: relation.from, to: relation.to, values: normalizeValue(copyValues(relation.values)) as Record<string, unknown> };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Returns one canonical ordering and property layout for the same snapshot value. */
|
|
310
|
+
export function normalizeDomainSnapshot(snapshot: DomainSnapshotDefinition): DomainSnapshot {
|
|
311
|
+
const copied = defineDomainSnapshot(snapshot);
|
|
312
|
+
const records = copied.records.map(normalizeRecord).sort((left, right) =>
|
|
313
|
+
left.id.localeCompare(right.id) || left.type.localeCompare(right.type) || comparable(left.values).localeCompare(comparable(right.values)),
|
|
314
|
+
);
|
|
315
|
+
const relations = copied.relations.map(normalizeRelation).sort((left, right) =>
|
|
316
|
+
left.type.localeCompare(right.type) || left.from.localeCompare(right.from) || left.to.localeCompare(right.to) || comparable(left.values).localeCompare(comparable(right.values)),
|
|
317
|
+
);
|
|
318
|
+
return { records, relations };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Serializes a normalized snapshot as stable, language-neutral JSON followed by one newline. */
|
|
322
|
+
export function serializeDomainSnapshot(snapshot: DomainSnapshotDefinition): string {
|
|
323
|
+
return `${JSON.stringify(normalizeDomainSnapshot(snapshot), null, 2)}\n`;
|
|
324
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/** A scalar representation supplied by this package. */
|
|
2
|
+
export type PrimitiveValueType = "string" | "number" | "integer" | "boolean" | "date" | "datetime" | "json";
|
|
3
|
+
|
|
4
|
+
/** A product-defined scalar type built on one primitive representation. */
|
|
5
|
+
export interface ValueTypeDefinition {
|
|
6
|
+
id: string;
|
|
7
|
+
primitive: PrimitiveValueType;
|
|
8
|
+
label?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A closed set of product-defined string values. */
|
|
12
|
+
export interface VocabularyDefinition {
|
|
13
|
+
id: string;
|
|
14
|
+
values: readonly string[];
|
|
15
|
+
label?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A field's representation: a primitive or the id of a value type or vocabulary. */
|
|
19
|
+
export type FieldValueType = PrimitiveValueType | string;
|
|
20
|
+
|
|
21
|
+
/** One stable field on a domain type or relation. */
|
|
22
|
+
export interface FieldDefinition {
|
|
23
|
+
id: string;
|
|
24
|
+
valueType: FieldValueType;
|
|
25
|
+
required?: boolean;
|
|
26
|
+
label?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A product-owned domain type with stable fields. */
|
|
30
|
+
export interface DomainTypeDefinition {
|
|
31
|
+
id: string;
|
|
32
|
+
fields?: readonly FieldDefinition[];
|
|
33
|
+
label?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The allowed cardinality of a directed relation. */
|
|
37
|
+
export type RelationCardinality = "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many";
|
|
38
|
+
|
|
39
|
+
/** A directed relation that may carry its own stable fields. */
|
|
40
|
+
export interface RelationDefinition {
|
|
41
|
+
id: string;
|
|
42
|
+
from: string;
|
|
43
|
+
to: string;
|
|
44
|
+
cardinality: RelationCardinality;
|
|
45
|
+
properties?: readonly FieldDefinition[];
|
|
46
|
+
label?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A consumer-owned semantic model. `schemaVersion` belongs to that consumer, not this package. */
|
|
50
|
+
export interface DomainModelDefinition {
|
|
51
|
+
id: string;
|
|
52
|
+
schemaVersion: string;
|
|
53
|
+
valueTypes?: readonly ValueTypeDefinition[];
|
|
54
|
+
vocabularies?: readonly VocabularyDefinition[];
|
|
55
|
+
types?: readonly DomainTypeDefinition[];
|
|
56
|
+
relations?: readonly RelationDefinition[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A normalized, immutable-looking model value returned by `defineDomainModel`. */
|
|
60
|
+
export interface DomainModel extends DomainModelDefinition {
|
|
61
|
+
valueTypes: readonly ValueTypeDefinition[];
|
|
62
|
+
vocabularies: readonly VocabularyDefinition[];
|
|
63
|
+
types: readonly DomainTypeDefinition[];
|
|
64
|
+
relations: readonly RelationDefinition[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One observed issue; callers decide whether an error blocks their workflow. */
|
|
68
|
+
export interface DomainModelFinding {
|
|
69
|
+
rule: string;
|
|
70
|
+
severity: "error" | "warning";
|
|
71
|
+
message: string;
|
|
72
|
+
path?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A change detected between two model versions. */
|
|
76
|
+
export interface DomainModelChange {
|
|
77
|
+
kind: "additive" | "breaking";
|
|
78
|
+
subject: "model" | "value-type" | "vocabulary" | "vocabulary-value" | "type" | "field" | "relation" | "relation-property";
|
|
79
|
+
id: string;
|
|
80
|
+
message: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Machine-readable outcome of `compareDomainModels`. */
|
|
84
|
+
export interface DomainModelCompatibilityReport {
|
|
85
|
+
compatible: boolean;
|
|
86
|
+
changes: readonly DomainModelChange[];
|
|
87
|
+
previousFindings: readonly DomainModelFinding[];
|
|
88
|
+
nextFindings: readonly DomainModelFinding[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One product-owned record captured against a declared domain type. */
|
|
92
|
+
export interface DomainRecordDefinition {
|
|
93
|
+
id: string;
|
|
94
|
+
type: string;
|
|
95
|
+
values?: Readonly<Record<string, unknown>>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A detached record with an explicit value object. */
|
|
99
|
+
export interface DomainRecord {
|
|
100
|
+
id: string;
|
|
101
|
+
type: string;
|
|
102
|
+
values: Readonly<Record<string, unknown>>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One directed product-owned relation captured against a declared relation type. */
|
|
106
|
+
export interface DomainRelationDefinition {
|
|
107
|
+
type: string;
|
|
108
|
+
from: string;
|
|
109
|
+
to: string;
|
|
110
|
+
values?: Readonly<Record<string, unknown>>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A detached directed relation with an explicit value object. */
|
|
114
|
+
export interface DomainRelation {
|
|
115
|
+
type: string;
|
|
116
|
+
from: string;
|
|
117
|
+
to: string;
|
|
118
|
+
values: Readonly<Record<string, unknown>>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Authoring input for a product-owned collection of records and relations. */
|
|
122
|
+
export interface DomainSnapshotDefinition {
|
|
123
|
+
records?: readonly DomainRecordDefinition[];
|
|
124
|
+
relations?: readonly DomainRelationDefinition[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A detached domain snapshot with explicit collections. */
|
|
128
|
+
export interface DomainSnapshot {
|
|
129
|
+
records: readonly DomainRecord[];
|
|
130
|
+
relations: readonly DomainRelation[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One observed issue in a snapshot; callers decide whether an error blocks their workflow. */
|
|
134
|
+
export interface DomainSnapshotFinding {
|
|
135
|
+
rule: string;
|
|
136
|
+
severity: "error" | "warning";
|
|
137
|
+
message: string;
|
|
138
|
+
path?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const PRIMITIVE_VALUE_TYPES: readonly PrimitiveValueType[] = [
|
|
142
|
+
"string",
|
|
143
|
+
"number",
|
|
144
|
+
"integer",
|
|
145
|
+
"boolean",
|
|
146
|
+
"date",
|
|
147
|
+
"datetime",
|
|
148
|
+
"json",
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
export const RELATION_CARDINALITIES: readonly RelationCardinality[] = [
|
|
152
|
+
"one-to-one",
|
|
153
|
+
"one-to-many",
|
|
154
|
+
"many-to-one",
|
|
155
|
+
"many-to-many",
|
|
156
|
+
];
|