dsh-logicprobe 0.5.0 → 0.5.1
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.en-US.md +3 -2
- package/README.md +3 -2
- package/lib/concurrency-tool.js +34 -34
- package/lib/concurrency.js +76 -76
- package/lib/data-engine.js +930 -930
- package/lib/data-tool.js +61 -61
- package/lib/engine.js +1622 -1622
- package/lib/index.js +274 -274
- package/lib/tool.js +57 -57
- package/lib/types/concurrency-tool.d.ts +8 -8
- package/lib/types/concurrency.d.ts +20 -20
- package/lib/types/data-engine.d.ts +199 -199
- package/lib/types/data-tool.d.ts +10 -10
- package/lib/types/engine.d.ts +171 -171
- package/package.json +82 -82
package/lib/data-engine.js
CHANGED
|
@@ -1,930 +1,930 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
export const DATA_ENGINE_SCHEMA_VERSION = 1;
|
|
3
|
-
const KNOWN_TYPES = new Set([
|
|
4
|
-
'string', 'integer', 'number', 'boolean', 'uuid', 'date', 'datetime', 'timestamp',
|
|
5
|
-
'json', 'enum', 'array', 'object', 'binary',
|
|
6
|
-
]);
|
|
7
|
-
function stableStringify(value) {
|
|
8
|
-
if (value === null || typeof value !== 'object')
|
|
9
|
-
return JSON.stringify(value);
|
|
10
|
-
if (Array.isArray(value))
|
|
11
|
-
return '[' + value.map((item) => stableStringify(item)).join(',') + ']';
|
|
12
|
-
const record = value;
|
|
13
|
-
return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}';
|
|
14
|
-
}
|
|
15
|
-
export function dataModelHash(model) {
|
|
16
|
-
return createHash('sha256').update(stableStringify(model)).digest('hex');
|
|
17
|
-
}
|
|
18
|
-
function bad(errors, path, message) {
|
|
19
|
-
errors.push(path + ': ' + message);
|
|
20
|
-
}
|
|
21
|
-
function isPlainObject(value) {
|
|
22
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
23
|
-
}
|
|
24
|
-
function validateField(input, path, errors, fieldNames) {
|
|
25
|
-
if (!isPlainObject(input)) {
|
|
26
|
-
bad(errors, path, 'must be an object');
|
|
27
|
-
return undefined;
|
|
28
|
-
}
|
|
29
|
-
if (typeof input.name !== 'string' || input.name.length === 0) {
|
|
30
|
-
bad(errors, path + '.name', 'must be a non-empty string');
|
|
31
|
-
}
|
|
32
|
-
else if (fieldNames.has(input.name)) {
|
|
33
|
-
bad(errors, path + '.name', 'duplicate field name ' + input.name);
|
|
34
|
-
}
|
|
35
|
-
else {
|
|
36
|
-
fieldNames.add(input.name);
|
|
37
|
-
}
|
|
38
|
-
if (typeof input.type !== 'string' || input.type.length === 0) {
|
|
39
|
-
bad(errors, path + '.type', 'must be a non-empty string');
|
|
40
|
-
}
|
|
41
|
-
if (input.required !== undefined && typeof input.required !== 'boolean')
|
|
42
|
-
bad(errors, path + '.required', 'must be a boolean');
|
|
43
|
-
if (input.unique !== undefined && typeof input.unique !== 'boolean')
|
|
44
|
-
bad(errors, path + '.unique', 'must be a boolean');
|
|
45
|
-
if (input.nullable !== undefined && typeof input.nullable !== 'boolean')
|
|
46
|
-
bad(errors, path + '.nullable', 'must be a boolean');
|
|
47
|
-
if (input.min !== undefined && typeof input.min !== 'number')
|
|
48
|
-
bad(errors, path + '.min', 'must be a number');
|
|
49
|
-
if (input.max !== undefined && typeof input.max !== 'number')
|
|
50
|
-
bad(errors, path + '.max', 'must be a number');
|
|
51
|
-
if (input.minLength !== undefined && typeof input.minLength !== 'number')
|
|
52
|
-
bad(errors, path + '.minLength', 'must be a number');
|
|
53
|
-
if (input.maxLength !== undefined && typeof input.maxLength !== 'number')
|
|
54
|
-
bad(errors, path + '.maxLength', 'must be a number');
|
|
55
|
-
if (input.monotonic !== undefined && input.monotonic !== 'inc' && input.monotonic !== 'dec')
|
|
56
|
-
bad(errors, path + '.monotonic', "must be 'inc' or 'dec'");
|
|
57
|
-
if (input.pattern !== undefined && typeof input.pattern !== 'string')
|
|
58
|
-
bad(errors, path + '.pattern', 'must be a string');
|
|
59
|
-
if (input.ref !== undefined && typeof input.ref !== 'string')
|
|
60
|
-
bad(errors, path + '.ref', 'must be a string');
|
|
61
|
-
if (input.enum !== undefined && !Array.isArray(input.enum))
|
|
62
|
-
bad(errors, path + '.enum', 'must be an array');
|
|
63
|
-
if (input.items !== undefined)
|
|
64
|
-
validateField(input.items, path + '.items', errors, new Set());
|
|
65
|
-
return input;
|
|
66
|
-
}
|
|
67
|
-
function entityByName(model, name) {
|
|
68
|
-
return model.entities.find((entity) => entity.name === name);
|
|
69
|
-
}
|
|
70
|
-
function fieldByPath(model, entityName, fieldName) {
|
|
71
|
-
return entityByName(model, entityName)?.fields.find((field) => field.name === fieldName);
|
|
72
|
-
}
|
|
73
|
-
function mapFieldPath(mapping, path) {
|
|
74
|
-
return mapping[path] ?? path;
|
|
75
|
-
}
|
|
76
|
-
export function validateDataModel(input) {
|
|
77
|
-
const errors = [];
|
|
78
|
-
if (!isPlainObject(input)) {
|
|
79
|
-
return { ok: false, errors: ['model: must be an object'] };
|
|
80
|
-
}
|
|
81
|
-
const root = input;
|
|
82
|
-
if (root.schemaVersion !== 1)
|
|
83
|
-
bad(errors, 'schemaVersion', 'must be 1');
|
|
84
|
-
if (!Array.isArray(root.entities) || root.entities.length === 0) {
|
|
85
|
-
bad(errors, 'entities', 'must be a non-empty array');
|
|
86
|
-
}
|
|
87
|
-
else {
|
|
88
|
-
const entityNames = new Set();
|
|
89
|
-
root.entities.forEach((entry, index) => {
|
|
90
|
-
const path = 'entities[' + index + ']';
|
|
91
|
-
if (!isPlainObject(entry)) {
|
|
92
|
-
bad(errors, path, 'must be an object');
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
if (typeof entry.name !== 'string' || entry.name.length === 0) {
|
|
96
|
-
bad(errors, path + '.name', 'must be a non-empty string');
|
|
97
|
-
}
|
|
98
|
-
else if (entityNames.has(entry.name)) {
|
|
99
|
-
bad(errors, path + '.name', 'duplicate entity name ' + entry.name);
|
|
100
|
-
}
|
|
101
|
-
else {
|
|
102
|
-
entityNames.add(entry.name);
|
|
103
|
-
}
|
|
104
|
-
if (!Array.isArray(entry.fields) || entry.fields.length === 0) {
|
|
105
|
-
bad(errors, path + '.fields', 'must be a non-empty array');
|
|
106
|
-
}
|
|
107
|
-
else {
|
|
108
|
-
const fieldNames = new Set();
|
|
109
|
-
entry.fields.forEach((field, fieldIndex) => {
|
|
110
|
-
validateField(field, path + '.fields[' + fieldIndex + ']', errors, fieldNames);
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
if (entry.primaryKey !== undefined) {
|
|
114
|
-
if (!Array.isArray(entry.primaryKey))
|
|
115
|
-
bad(errors, path + '.primaryKey', 'must be an array');
|
|
116
|
-
}
|
|
117
|
-
if (entry.uniqueKeys !== undefined) {
|
|
118
|
-
if (!Array.isArray(entry.uniqueKeys))
|
|
119
|
-
bad(errors, path + '.uniqueKeys', 'must be an array');
|
|
120
|
-
}
|
|
121
|
-
if (entry.indexes !== undefined) {
|
|
122
|
-
if (!Array.isArray(entry.indexes))
|
|
123
|
-
bad(errors, path + '.indexes', 'must be an array');
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
if (root.relationships !== undefined) {
|
|
128
|
-
if (!Array.isArray(root.relationships))
|
|
129
|
-
bad(errors, 'relationships', 'must be an array');
|
|
130
|
-
}
|
|
131
|
-
if (root.invariants !== undefined) {
|
|
132
|
-
if (!Array.isArray(root.invariants))
|
|
133
|
-
bad(errors, 'invariants', 'must be an array');
|
|
134
|
-
else
|
|
135
|
-
root.invariants.forEach((entry, index) => {
|
|
136
|
-
const path = 'invariants[' + index + ']';
|
|
137
|
-
if (!isPlainObject(entry)) {
|
|
138
|
-
bad(errors, path, 'must be an object');
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
if (typeof entry.id !== 'string' || entry.id.length === 0)
|
|
142
|
-
bad(errors, path + '.id', 'must be a non-empty string');
|
|
143
|
-
if (typeof entry.description !== 'string')
|
|
144
|
-
bad(errors, path + '.description', 'must be a string');
|
|
145
|
-
const kind = entry.kind;
|
|
146
|
-
if (kind === 'field-required' || kind === 'no-orphan') {
|
|
147
|
-
if (typeof entry.entity !== 'string')
|
|
148
|
-
bad(errors, path + '.entity', 'must be a string');
|
|
149
|
-
if (typeof entry.field !== 'string')
|
|
150
|
-
bad(errors, path + '.field', 'must be a string');
|
|
151
|
-
}
|
|
152
|
-
else if (kind === 'unique') {
|
|
153
|
-
if (!Array.isArray(entry.fields))
|
|
154
|
-
bad(errors, path + '.fields', 'must be an array');
|
|
155
|
-
}
|
|
156
|
-
else if (kind === 'referential-integrity') {
|
|
157
|
-
if (typeof entry.entity !== 'string')
|
|
158
|
-
bad(errors, path + '.entity', 'must be a string');
|
|
159
|
-
if (typeof entry.field !== 'string')
|
|
160
|
-
bad(errors, path + '.field', 'must be a string');
|
|
161
|
-
if (typeof entry.refEntity !== 'string')
|
|
162
|
-
bad(errors, path + '.refEntity', 'must be a string');
|
|
163
|
-
if (entry.refField !== undefined && typeof entry.refField !== 'string')
|
|
164
|
-
bad(errors, path + '.refField', 'must be a string');
|
|
165
|
-
}
|
|
166
|
-
else if (kind === 'range') {
|
|
167
|
-
if (typeof entry.entity !== 'string')
|
|
168
|
-
bad(errors, path + '.entity', 'must be a string');
|
|
169
|
-
if (typeof entry.field !== 'string')
|
|
170
|
-
bad(errors, path + '.field', 'must be a string');
|
|
171
|
-
if (entry.min !== undefined && typeof entry.min !== 'number')
|
|
172
|
-
bad(errors, path + '.min', 'must be a number');
|
|
173
|
-
if (entry.max !== undefined && typeof entry.max !== 'number')
|
|
174
|
-
bad(errors, path + '.max', 'must be a number');
|
|
175
|
-
}
|
|
176
|
-
else if (kind === 'count-equal') {
|
|
177
|
-
if (typeof entry.sourceEntity !== 'string')
|
|
178
|
-
bad(errors, path + '.sourceEntity', 'must be a string');
|
|
179
|
-
if (typeof entry.targetEntity !== 'string')
|
|
180
|
-
bad(errors, path + '.targetEntity', 'must be a string');
|
|
181
|
-
}
|
|
182
|
-
else if (kind === 'field-equal') {
|
|
183
|
-
if (typeof entry.sourceEntity !== 'string')
|
|
184
|
-
bad(errors, path + '.sourceEntity', 'must be a string');
|
|
185
|
-
if (typeof entry.sourceField !== 'string')
|
|
186
|
-
bad(errors, path + '.sourceField', 'must be a string');
|
|
187
|
-
if (typeof entry.targetEntity !== 'string')
|
|
188
|
-
bad(errors, path + '.targetEntity', 'must be a string');
|
|
189
|
-
if (typeof entry.targetField !== 'string')
|
|
190
|
-
bad(errors, path + '.targetField', 'must be a string');
|
|
191
|
-
}
|
|
192
|
-
else if (kind === 'idempotent-copy') {
|
|
193
|
-
if (typeof entry.sourceEntity !== 'string')
|
|
194
|
-
bad(errors, path + '.sourceEntity', 'must be a string');
|
|
195
|
-
if (typeof entry.targetEntity !== 'string')
|
|
196
|
-
bad(errors, path + '.targetEntity', 'must be a string');
|
|
197
|
-
}
|
|
198
|
-
else if (kind === 'idempotent-migration') {
|
|
199
|
-
if (typeof entry.from !== 'string')
|
|
200
|
-
bad(errors, path + '.from', 'must be a string');
|
|
201
|
-
if (typeof entry.to !== 'string')
|
|
202
|
-
bad(errors, path + '.to', 'must be a string');
|
|
203
|
-
}
|
|
204
|
-
else if (kind === 'monotonic') {
|
|
205
|
-
if (typeof entry.entity !== 'string')
|
|
206
|
-
bad(errors, path + '.entity', 'must be a string');
|
|
207
|
-
if (typeof entry.field !== 'string')
|
|
208
|
-
bad(errors, path + '.field', 'must be a string');
|
|
209
|
-
if (entry.direction !== 'inc' && entry.direction !== 'dec')
|
|
210
|
-
bad(errors, path + '.direction', "must be 'inc' or 'dec'");
|
|
211
|
-
}
|
|
212
|
-
else if (kind === 'sequence') {
|
|
213
|
-
if (!Array.isArray(entry.steps) || entry.steps.length === 0)
|
|
214
|
-
bad(errors, path + '.steps', 'must be a non-empty array');
|
|
215
|
-
else
|
|
216
|
-
entry.steps.forEach((step, stepIndex) => {
|
|
217
|
-
if (typeof step !== 'string' || step.length === 0)
|
|
218
|
-
bad(errors, path + '.steps[' + stepIndex + ']', 'must be a non-empty string');
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
else if (kind === 'leads-to') {
|
|
222
|
-
if (typeof entry.entity !== 'string')
|
|
223
|
-
bad(errors, path + '.entity', 'must be a string');
|
|
224
|
-
if (typeof entry.field !== 'string')
|
|
225
|
-
bad(errors, path + '.field', 'must be a string');
|
|
226
|
-
}
|
|
227
|
-
else if (kind === 'atomicity') {
|
|
228
|
-
if (!Array.isArray(entry.steps) || entry.steps.length === 0)
|
|
229
|
-
bad(errors, path + '.steps', 'must be a non-empty array');
|
|
230
|
-
else
|
|
231
|
-
entry.steps.forEach((step, stepIndex) => {
|
|
232
|
-
if (typeof step !== 'string' || step.length === 0)
|
|
233
|
-
bad(errors, path + '.steps[' + stepIndex + ']', 'must be a non-empty string');
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
else {
|
|
237
|
-
bad(errors, path + '.kind', 'unknown invariant kind');
|
|
238
|
-
}
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
if (root.boundaryChecks !== undefined) {
|
|
242
|
-
if (!Array.isArray(root.boundaryChecks))
|
|
243
|
-
bad(errors, 'boundaryChecks', 'must be an array');
|
|
244
|
-
}
|
|
245
|
-
if (errors.length > 0)
|
|
246
|
-
return { ok: false, errors };
|
|
247
|
-
return { ok: true, model: root };
|
|
248
|
-
}
|
|
249
|
-
function checkResult(id, name, findings, detail) {
|
|
250
|
-
const errors = findings.filter((finding) => finding.severity === 'error').length;
|
|
251
|
-
const warnings = findings.filter((finding) => finding.severity === 'warning').length;
|
|
252
|
-
return {
|
|
253
|
-
id,
|
|
254
|
-
name,
|
|
255
|
-
status: findings.length === 0 ? 'pass' : 'fail',
|
|
256
|
-
detail: detail + (errors > 0 ? ' (' + errors + ' errors' + (warnings > 0 ? ', ' + warnings + ' warnings' : '') + ')' : warnings > 0 ? ' (' + warnings + ' warnings)' : ''),
|
|
257
|
-
findings,
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
function allFieldPaths(model) {
|
|
261
|
-
const result = [];
|
|
262
|
-
for (const entity of model.entities) {
|
|
263
|
-
for (const field of entity.fields)
|
|
264
|
-
result.push(entity.name + '.' + field.name);
|
|
265
|
-
}
|
|
266
|
-
return result;
|
|
267
|
-
}
|
|
268
|
-
function entityFieldMap(model) {
|
|
269
|
-
const map = new Map();
|
|
270
|
-
for (const entity of model.entities) {
|
|
271
|
-
for (const field of entity.fields)
|
|
272
|
-
map.set(entity.name + '.' + field.name, field);
|
|
273
|
-
}
|
|
274
|
-
return map;
|
|
275
|
-
}
|
|
276
|
-
function DS1_schemaWellFormed(model) {
|
|
277
|
-
const findings = [];
|
|
278
|
-
const entityNames = new Set();
|
|
279
|
-
for (const entity of model.entities) {
|
|
280
|
-
if (entityNames.has(entity.name)) {
|
|
281
|
-
findings.push({ code: 'DS1_DUPLICATE_ENTITY', severity: 'error', message: 'Duplicate entity name ' + entity.name, evidence: { entity: entity.name } });
|
|
282
|
-
}
|
|
283
|
-
entityNames.add(entity.name);
|
|
284
|
-
const fieldNames = new Set();
|
|
285
|
-
for (const field of entity.fields) {
|
|
286
|
-
if (fieldNames.has(field.name)) {
|
|
287
|
-
findings.push({ code: 'DS1_DUPLICATE_FIELD', severity: 'error', message: 'Duplicate field ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name } });
|
|
288
|
-
}
|
|
289
|
-
fieldNames.add(field.name);
|
|
290
|
-
if (field.type !== undefined && !KNOWN_TYPES.has(field.type) && field.type.trim().length === 0) {
|
|
291
|
-
findings.push({ code: 'DS1_EMPTY_TYPE', severity: 'error', message: 'Field ' + entity.name + '.' + field.name + ' has empty type', evidence: { entity: entity.name, field: field.name } });
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
for (const key of entity.primaryKey ?? []) {
|
|
295
|
-
if (!fieldNames.has(key))
|
|
296
|
-
findings.push({ code: 'DS1_PRIMARY_KEY_MISSING', severity: 'error', message: 'Primary key field ' + key + ' does not exist in entity ' + entity.name, evidence: { entity: entity.name, field: key } });
|
|
297
|
-
}
|
|
298
|
-
for (const keySet of entity.uniqueKeys ?? []) {
|
|
299
|
-
for (const key of keySet) {
|
|
300
|
-
if (!fieldNames.has(key))
|
|
301
|
-
findings.push({ code: 'DS1_UNIQUE_KEY_MISSING', severity: 'error', message: 'Unique key field ' + key + ' does not exist in entity ' + entity.name, evidence: { entity: entity.name, field: key } });
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
for (const keySet of entity.indexes ?? []) {
|
|
305
|
-
for (const key of keySet) {
|
|
306
|
-
if (!fieldNames.has(key))
|
|
307
|
-
findings.push({ code: 'DS1_INDEX_MISSING', severity: 'error', message: 'Index field ' + key + ' does not exist in entity ' + entity.name, evidence: { entity: entity.name, field: key } });
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
return checkResult('DS1', 'Schema Well-Formedness', findings, findings.length === 0 ? 'Schema is well-formed' : 'Schema findings: ' + findings.length);
|
|
312
|
-
}
|
|
313
|
-
function DS2_requiredCompleteness(model, options) {
|
|
314
|
-
const findings = [];
|
|
315
|
-
const afterFields = entityFieldMap(model);
|
|
316
|
-
const migrationFrom = new Set((options.migrationMappings ?? []).map((mapping) => mapping.from));
|
|
317
|
-
const copyTargets = new Set();
|
|
318
|
-
for (const pair of options.copyPairs ?? []) {
|
|
319
|
-
for (const target of Object.values(pair.mapping))
|
|
320
|
-
copyTargets.add(pair.targetEntity + '.' + target);
|
|
321
|
-
}
|
|
322
|
-
const beforePaths = new Set();
|
|
323
|
-
if (options.beforeModel !== undefined) {
|
|
324
|
-
const beforeValidation = validateDataModel(options.beforeModel);
|
|
325
|
-
if (beforeValidation.ok) {
|
|
326
|
-
const mapping = options.fieldMapping ?? {};
|
|
327
|
-
for (const path of allFieldPaths(beforeValidation.model))
|
|
328
|
-
beforePaths.add(mapFieldPath(mapping, path));
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
for (const entity of model.entities) {
|
|
332
|
-
for (const field of entity.fields) {
|
|
333
|
-
if (field.required !== true)
|
|
334
|
-
continue;
|
|
335
|
-
if ((entity.primaryKey ?? []).includes(field.name) && field.nullable === true) {
|
|
336
|
-
findings.push({ code: 'DS2_NULLABLE_PRIMARY_KEY', severity: 'error', message: 'Primary key field ' + entity.name + '.' + field.name + ' must not be nullable', evidence: { entity: entity.name, field: field.name } });
|
|
337
|
-
}
|
|
338
|
-
if (options.beforeModel !== undefined) {
|
|
339
|
-
const path = entity.name + '.' + field.name;
|
|
340
|
-
if (!beforePaths.has(path) && !migrationFrom.has(path) && !copyTargets.has(path)) {
|
|
341
|
-
findings.push({ code: 'DS2_REQUIRED_FIELD_UNSOURCED', severity: 'warning', message: 'Required field ' + path + ' has no explicit migration mapping or copy target in a before/after migration', evidence: { entity: entity.name, field: field.name } });
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
return checkResult('DS2', 'Required-Field Completeness', findings, findings.length === 0 ? 'Required fields are complete' : 'Required-field findings: ' + findings.length);
|
|
347
|
-
}
|
|
348
|
-
function DS3_relationshipIntegrity(model) {
|
|
349
|
-
const findings = [];
|
|
350
|
-
for (const relationship of model.relationships ?? []) {
|
|
351
|
-
const fromEntity = entityByName(model, relationship.fromEntity);
|
|
352
|
-
const toEntity = entityByName(model, relationship.toEntity);
|
|
353
|
-
if (fromEntity === undefined) {
|
|
354
|
-
findings.push({ code: 'DS3_FROM_ENTITY_MISSING', severity: 'error', message: 'Relationship references unknown fromEntity ' + relationship.fromEntity, evidence: { relationship } });
|
|
355
|
-
continue;
|
|
356
|
-
}
|
|
357
|
-
if (toEntity === undefined) {
|
|
358
|
-
findings.push({ code: 'DS3_TO_ENTITY_MISSING', severity: 'error', message: 'Relationship references unknown toEntity ' + relationship.toEntity, evidence: { relationship } });
|
|
359
|
-
continue;
|
|
360
|
-
}
|
|
361
|
-
if (!fromEntity.fields.some((field) => field.name === relationship.fromField)) {
|
|
362
|
-
findings.push({ code: 'DS3_FROM_FIELD_MISSING', severity: 'error', message: 'Relationship field ' + relationship.fromField + ' does not exist in ' + relationship.fromEntity, evidence: { relationship } });
|
|
363
|
-
}
|
|
364
|
-
if (relationship.toField !== undefined && !toEntity.fields.some((field) => field.name === relationship.toField)) {
|
|
365
|
-
findings.push({ code: 'DS3_TO_FIELD_MISSING', severity: 'error', message: 'Relationship target field ' + relationship.toField + ' does not exist in ' + relationship.toEntity, evidence: { relationship } });
|
|
366
|
-
}
|
|
367
|
-
if (relationship.onDelete !== undefined && !['cascade', 'restrict', 'set-null', 'no-action'].includes(relationship.onDelete)) {
|
|
368
|
-
findings.push({ code: 'DS3_INVALID_ON_DELETE', severity: 'error', message: 'Invalid onDelete value ' + relationship.onDelete, evidence: { relationship } });
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
return checkResult('DS3', 'Relationship Integrity', findings, findings.length === 0 ? 'Relationships are valid' : 'Relationship findings: ' + findings.length);
|
|
372
|
-
}
|
|
373
|
-
function DS4_typeNullabilityConsistency(model) {
|
|
374
|
-
const findings = [];
|
|
375
|
-
for (const entity of model.entities) {
|
|
376
|
-
for (const field of entity.fields) {
|
|
377
|
-
if (field.required === true && field.nullable === true) {
|
|
378
|
-
findings.push({ code: 'DS4_REQUIRED_NULLABLE', severity: 'error', message: 'Field ' + entity.name + '.' + field.name + ' is both required and nullable', evidence: { entity: entity.name, field: field.name } });
|
|
379
|
-
}
|
|
380
|
-
if (field.unique === true && field.nullable === true) {
|
|
381
|
-
findings.push({ code: 'DS4_UNIQUE_NULLABLE', severity: 'warning', message: 'Unique field ' + entity.name + '.' + field.name + ' is nullable; uniqueness over NULL may be ambiguous', evidence: { entity: entity.name, field: field.name } });
|
|
382
|
-
}
|
|
383
|
-
if (typeof field.min === 'number' && typeof field.max === 'number' && field.min > field.max) {
|
|
384
|
-
findings.push({ code: 'DS4_MIN_GT_MAX', severity: 'error', message: 'Field ' + entity.name + '.' + field.name + ' has min > max', evidence: { entity: entity.name, field: field.name, min: field.min, max: field.max } });
|
|
385
|
-
}
|
|
386
|
-
if (typeof field.minLength === 'number' && typeof field.maxLength === 'number' && field.minLength > field.maxLength) {
|
|
387
|
-
findings.push({ code: 'DS4_MIN_LENGTH_GT_MAX_LENGTH', severity: 'error', message: 'Field ' + entity.name + '.' + field.name + ' has minLength > maxLength', evidence: { entity: entity.name, field: field.name } });
|
|
388
|
-
}
|
|
389
|
-
if (field.default === null && field.required === true) {
|
|
390
|
-
findings.push({ code: 'DS4_REQUIRED_NULL_DEFAULT', severity: 'warning', message: 'Required field ' + entity.name + '.' + field.name + ' has default null', evidence: { entity: entity.name, field: field.name } });
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
return checkResult('DS4', 'Type/Nullability Consistency', findings, findings.length === 0 ? 'Types and nullability are consistent' : 'Type/nullability findings: ' + findings.length);
|
|
395
|
-
}
|
|
396
|
-
function DA1_nullEmptyInjection(model) {
|
|
397
|
-
const findings = [];
|
|
398
|
-
for (const entity of model.entities) {
|
|
399
|
-
for (const field of entity.fields) {
|
|
400
|
-
if (field.required === true && field.nullable === true) {
|
|
401
|
-
findings.push({ code: 'DA1_NULL_ACCEPTED_ON_REQUIRED', severity: 'error', message: 'Null can be injected into required field ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name } });
|
|
402
|
-
}
|
|
403
|
-
if (field.required === true && field.type === 'string' && field.minLength !== undefined && field.minLength === 0) {
|
|
404
|
-
findings.push({ code: 'DA1_EMPTY_STRING_ALLOWED', severity: 'warning', message: 'Required string field ' + entity.name + '.' + field.name + ' allows empty string', evidence: { entity: entity.name, field: field.name } });
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
return checkResult('DA1', 'Null/Empty Injection', findings, findings.length === 0 ? 'Null/empty injection is rejected' : 'Null/empty findings: ' + findings.length);
|
|
409
|
-
}
|
|
410
|
-
function DA2_boundaryBlast(model) {
|
|
411
|
-
const findings = [];
|
|
412
|
-
for (const entity of model.entities) {
|
|
413
|
-
for (const field of entity.fields) {
|
|
414
|
-
if (typeof field.min === 'number' && typeof field.max === 'number' && field.min > field.max)
|
|
415
|
-
continue;
|
|
416
|
-
if (typeof field.default === 'number') {
|
|
417
|
-
if (field.min !== undefined && field.default < field.min)
|
|
418
|
-
findings.push({ code: 'DA2_DEFAULT_BELOW_MIN', severity: 'error', message: 'Default ' + field.default + ' is below min ' + field.min + ' for ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name } });
|
|
419
|
-
if (field.max !== undefined && field.default > field.max)
|
|
420
|
-
findings.push({ code: 'DA2_DEFAULT_ABOVE_MAX', severity: 'error', message: 'Default ' + field.default + ' is above max ' + field.max + ' for ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name } });
|
|
421
|
-
}
|
|
422
|
-
if (field.enum !== undefined && field.enum.length > 0) {
|
|
423
|
-
if (typeof field.min === 'number') {
|
|
424
|
-
for (const value of field.enum) {
|
|
425
|
-
if (typeof value === 'number' && value < field.min)
|
|
426
|
-
findings.push({ code: 'DA2_ENUM_BELOW_MIN', severity: 'warning', message: 'Enum value ' + value + ' is below min ' + field.min + ' for ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name, value } });
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
if (typeof field.max === 'number') {
|
|
430
|
-
for (const value of field.enum) {
|
|
431
|
-
if (typeof value === 'number' && value > field.max)
|
|
432
|
-
findings.push({ code: 'DA2_ENUM_ABOVE_MAX', severity: 'warning', message: 'Enum value ' + value + ' is above max ' + field.max + ' for ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name, value } });
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
for (const check of model.boundaryChecks ?? []) {
|
|
439
|
-
const field = fieldByPath(model, check.entity, check.field);
|
|
440
|
-
if (field === undefined) {
|
|
441
|
-
findings.push({ code: 'DA2_BOUNDARY_FIELD_MISSING', severity: 'error', message: 'Boundary check references unknown field ' + check.entity + '.' + check.field, evidence: { entity: check.entity, field: check.field } });
|
|
442
|
-
continue;
|
|
443
|
-
}
|
|
444
|
-
for (const value of check.values ?? []) {
|
|
445
|
-
if (typeof value === 'number') {
|
|
446
|
-
if (field.min !== undefined && value < field.min)
|
|
447
|
-
findings.push({ code: 'DA2_BOUNDARY_BELOW_MIN', severity: 'warning', message: 'Boundary value ' + value + ' below min ' + field.min + ' for ' + check.entity + '.' + check.field, evidence: { entity: check.entity, field: check.field, value } });
|
|
448
|
-
if (field.max !== undefined && value > field.max)
|
|
449
|
-
findings.push({ code: 'DA2_BOUNDARY_ABOVE_MAX', severity: 'warning', message: 'Boundary value ' + value + ' above max ' + field.max + ' for ' + check.entity + '.' + check.field, evidence: { entity: check.entity, field: check.field, value } });
|
|
450
|
-
}
|
|
451
|
-
if (field.enum !== undefined && field.enum.length > 0 && !field.enum.includes(value)) {
|
|
452
|
-
findings.push({ code: 'DA2_BOUNDARY_NOT_IN_ENUM', severity: 'warning', message: 'Boundary value ' + String(value) + ' not in enum for ' + check.entity + '.' + check.field, evidence: { entity: check.entity, field: check.field, value } });
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
return checkResult('DA2', 'Boundary Blast', findings, findings.length === 0 ? 'Boundary checks passed' : 'Boundary findings: ' + findings.length);
|
|
457
|
-
}
|
|
458
|
-
function DA3_uniquenessViolation(model) {
|
|
459
|
-
const findings = [];
|
|
460
|
-
for (const entity of model.entities) {
|
|
461
|
-
for (const key of entity.primaryKey ?? []) {
|
|
462
|
-
const field = fieldByPath(model, entity.name, key);
|
|
463
|
-
if (field?.nullable === true)
|
|
464
|
-
findings.push({ code: 'DA3_NULLABLE_PRIMARY_KEY', severity: 'error', message: 'Primary key ' + entity.name + '.' + key + ' is nullable', evidence: { entity: entity.name, field: key } });
|
|
465
|
-
}
|
|
466
|
-
for (const keySet of entity.uniqueKeys ?? []) {
|
|
467
|
-
for (const key of keySet) {
|
|
468
|
-
const field = fieldByPath(model, entity.name, key);
|
|
469
|
-
if (field?.nullable === true)
|
|
470
|
-
findings.push({ code: 'DA3_NULLABLE_UNIQUE_KEY', severity: 'warning', message: 'Unique key ' + entity.name + '.' + key + ' is nullable', evidence: { entity: entity.name, field: key } });
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
return checkResult('DA3', 'Uniqueness Violation', findings, findings.length === 0 ? 'No uniqueness violations' : 'Uniqueness findings: ' + findings.length);
|
|
475
|
-
}
|
|
476
|
-
function DA4_referentialIntegrityViolation(model) {
|
|
477
|
-
const findings = [];
|
|
478
|
-
for (const relationship of model.relationships ?? []) {
|
|
479
|
-
const fromField = fieldByPath(model, relationship.fromEntity, relationship.fromField);
|
|
480
|
-
const toField = relationship.toField === undefined ? undefined : fieldByPath(model, relationship.toEntity, relationship.toField);
|
|
481
|
-
if (fromField !== undefined && toField !== undefined && fromField.type !== toField.type) {
|
|
482
|
-
findings.push({ code: 'DA4_REF_TYPE_MISMATCH', severity: 'warning', message: 'Relationship ' + (relationship.name ?? relationship.fromEntity + '.' + relationship.fromField) + ' links different types ' + fromField.type + ' -> ' + toField.type, evidence: { relationship } });
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
return checkResult('DA4', 'Referential Integrity Violation', findings, findings.length === 0 ? 'No referential integrity violations' : 'Referential integrity findings: ' + findings.length);
|
|
486
|
-
}
|
|
487
|
-
function DA5_migrationCoverage(model, options) {
|
|
488
|
-
const findings = [];
|
|
489
|
-
if (options.beforeModel === undefined) {
|
|
490
|
-
return checkResult('DA5', 'Migration Coverage', [], 'No beforeModel supplied — skipped');
|
|
491
|
-
}
|
|
492
|
-
const beforeValidation = validateDataModel(options.beforeModel);
|
|
493
|
-
if (!beforeValidation.ok) {
|
|
494
|
-
return checkResult('DA5', 'Migration Coverage', [{ code: 'DA5_BEFORE_MODEL_INVALID', severity: 'error', message: 'Before model failed validation: ' + beforeValidation.errors.join('; ') }], 'Before model invalid');
|
|
495
|
-
}
|
|
496
|
-
const before = beforeValidation.model;
|
|
497
|
-
const afterPaths = new Set(allFieldPaths(model));
|
|
498
|
-
const beforePaths = allFieldPaths(before);
|
|
499
|
-
const mapping = options.fieldMapping ?? {};
|
|
500
|
-
const migrationFrom = new Set((options.migrationMappings ?? []).map((mapping) => mapping.from));
|
|
501
|
-
for (const path of beforePaths) {
|
|
502
|
-
const mapped = mapFieldPath(mapping, path);
|
|
503
|
-
if (!afterPaths.has(mapped) && !migrationFrom.has(path)) {
|
|
504
|
-
findings.push({ code: 'DA5_FIELD_NOT_MIGRATED', severity: 'error', message: 'BEFORE field ' + path + ' is not present in AFTER and has no migration mapping', evidence: { beforeField: path, afterField: mapped } });
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
return checkResult('DA5', 'Migration Coverage', findings, findings.length === 0 ? 'All before/after fields are covered by mappings' : 'Migration coverage findings: ' + findings.length);
|
|
508
|
-
}
|
|
509
|
-
function DA6_copyConsistency(model, options) {
|
|
510
|
-
const findings = [];
|
|
511
|
-
const afterFields = entityFieldMap(model);
|
|
512
|
-
for (const pair of options.copyPairs ?? []) {
|
|
513
|
-
const targetEntity = entityByName(model, pair.targetEntity);
|
|
514
|
-
if (targetEntity === undefined) {
|
|
515
|
-
findings.push({ code: 'DA6_TARGET_ENTITY_MISSING', severity: 'error', message: 'Copy pair ' + pair.id + ' references unknown target entity ' + pair.targetEntity, evidence: { pair } });
|
|
516
|
-
continue;
|
|
517
|
-
}
|
|
518
|
-
if (entityByName(model, pair.sourceEntity) === undefined) {
|
|
519
|
-
findings.push({ code: 'DA6_SOURCE_ENTITY_MISSING', severity: 'error', message: 'Copy pair ' + pair.id + ' references unknown source entity ' + pair.sourceEntity, evidence: { pair } });
|
|
520
|
-
}
|
|
521
|
-
const mappedTargets = new Set(Object.values(pair.mapping).map((field) => pair.targetEntity + '.' + field));
|
|
522
|
-
for (const field of targetEntity.fields) {
|
|
523
|
-
if (field.required === true && !mappedTargets.has(pair.targetEntity + '.' + field.name)) {
|
|
524
|
-
findings.push({ code: 'DA6_REQUIRED_TARGET_UNMAPPED', severity: 'error', message: 'Copy pair ' + pair.id + ' does not map required target field ' + pair.targetEntity + '.' + field.name, evidence: { pair, field: field.name } });
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
for (const [sourceField, targetField] of Object.entries(pair.mapping)) {
|
|
528
|
-
if (fieldByPath(model, pair.sourceEntity, sourceField) === undefined) {
|
|
529
|
-
findings.push({ code: 'DA6_SOURCE_FIELD_MISSING', severity: 'error', message: 'Copy pair ' + pair.id + ' maps unknown source field ' + pair.sourceEntity + '.' + sourceField, evidence: { pair, field: sourceField } });
|
|
530
|
-
}
|
|
531
|
-
if (afterFields.has(pair.targetEntity + '.' + targetField) === false) {
|
|
532
|
-
findings.push({ code: 'DA6_TARGET_FIELD_MISSING', severity: 'error', message: 'Copy pair ' + pair.id + ' maps unknown target field ' + pair.targetEntity + '.' + targetField, evidence: { pair, field: targetField } });
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
return checkResult('DA6', 'Copy Consistency', findings, findings.length === 0 ? 'Copy mappings are consistent' : 'Copy consistency findings: ' + findings.length);
|
|
537
|
-
}
|
|
538
|
-
function DA7_rollbackBackupSymmetry(model, options) {
|
|
539
|
-
const findings = [];
|
|
540
|
-
const backupKeys = new Set((options.backupPairs ?? []).map((pair) => pair.sourceEntity + '|' + pair.targetEntity));
|
|
541
|
-
for (const pair of options.copyPairs ?? []) {
|
|
542
|
-
if (!backupKeys.has(pair.sourceEntity + '|' + pair.targetEntity)) {
|
|
543
|
-
findings.push({ code: 'DA7_NO_BACKUP_PAIR', severity: 'warning', message: 'Copy pair ' + pair.id + ' (' + pair.sourceEntity + ' -> ' + pair.targetEntity + ') has no matching backup/restore pair', evidence: { pair } });
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
return checkResult('DA7', 'Rollback/Backup Symmetry', findings, findings.length === 0 ? 'Copy/backup pairs are balanced' : 'Rollback/backup findings: ' + findings.length);
|
|
547
|
-
}
|
|
548
|
-
function DA8_idempotentConstraints(model, options) {
|
|
549
|
-
const findings = [];
|
|
550
|
-
const copyPairs = options.copyPairs ?? [];
|
|
551
|
-
const migrationMappings = options.migrationMappings ?? [];
|
|
552
|
-
for (const invariant of model.invariants ?? []) {
|
|
553
|
-
if (invariant.kind === 'idempotent-copy') {
|
|
554
|
-
const pair = copyPairs.find((entry) => entry.sourceEntity === invariant.sourceEntity && entry.targetEntity === invariant.targetEntity);
|
|
555
|
-
if (pair === undefined) {
|
|
556
|
-
findings.push({
|
|
557
|
-
code: 'DA8_COPY_PAIR_MISSING',
|
|
558
|
-
severity: 'error',
|
|
559
|
-
message: 'Idempotent-copy invariant "' + invariant.id + '" has no matching copy pair for ' + invariant.sourceEntity + ' -> ' + invariant.targetEntity,
|
|
560
|
-
evidence: { invariant },
|
|
561
|
-
});
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
else if (invariant.kind === 'idempotent-migration') {
|
|
565
|
-
const mapping = migrationMappings.find((entry) => entry.from === invariant.from && entry.to === invariant.to);
|
|
566
|
-
if (mapping === undefined) {
|
|
567
|
-
findings.push({
|
|
568
|
-
code: 'DA8_MIGRATION_MAPPING_MISSING',
|
|
569
|
-
severity: 'error',
|
|
570
|
-
message: 'Idempotent-migration invariant "' + invariant.id + '" has no matching migration mapping ' + invariant.from + ' -> ' + invariant.to,
|
|
571
|
-
evidence: { invariant },
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
else if (mapping.transform === 'split' || mapping.transform === 'merge' || mapping.transform === 'drop') {
|
|
575
|
-
findings.push({
|
|
576
|
-
code: 'DA8_NON_IDEMPOTENT_TRANSFORM',
|
|
577
|
-
severity: 'warning',
|
|
578
|
-
message: 'Migration mapping ' + invariant.from + ' -> ' + invariant.to + ' uses non-idempotent transform ' + (mapping.transform ?? 'unknown'),
|
|
579
|
-
evidence: { invariant, mapping },
|
|
580
|
-
});
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
return checkResult('DA8', 'Idempotent Constraints', findings, findings.length === 0 ? 'Idempotency constraints are satisfied' : 'Idempotency findings: ' + findings.length);
|
|
585
|
-
}
|
|
586
|
-
function dataStepRegistry(options) {
|
|
587
|
-
const map = new Map();
|
|
588
|
-
for (const pair of options.copyPairs ?? [])
|
|
589
|
-
map.set(pair.id, { kind: 'copy', copyPair: pair });
|
|
590
|
-
for (const mapping of options.migrationMappings ?? []) {
|
|
591
|
-
const id = mapping.id ?? mapping.from + '->' + mapping.to;
|
|
592
|
-
map.set(id, { kind: 'migration', transform: mapping.transform });
|
|
593
|
-
}
|
|
594
|
-
return map;
|
|
595
|
-
}
|
|
596
|
-
const MONOTONIC_DATA_TYPES = new Set(['integer', 'number', 'date', 'datetime', 'timestamp']);
|
|
597
|
-
function DA9_dataMonotonic(model, options) {
|
|
598
|
-
const findings = [];
|
|
599
|
-
for (const invariant of model.invariants ?? []) {
|
|
600
|
-
if (invariant.kind !== 'monotonic')
|
|
601
|
-
continue;
|
|
602
|
-
const field = fieldByPath(model, invariant.entity, invariant.field);
|
|
603
|
-
if (field === undefined) {
|
|
604
|
-
findings.push({ code: 'DA9_MONOTONIC_FIELD_MISSING', severity: 'error', message: 'Monotonic invariant "' + invariant.id + '" references missing field ' + invariant.entity + '.' + invariant.field, evidence: { invariant } });
|
|
605
|
-
continue;
|
|
606
|
-
}
|
|
607
|
-
if (!MONOTONIC_DATA_TYPES.has(field.type)) {
|
|
608
|
-
findings.push({ code: 'DA9_MONOTONIC_TYPE_UNSUPPORTED', severity: 'warning', message: 'Monotonic field ' + invariant.entity + '.' + invariant.field + ' has type ' + field.type + ', which may not support monotonic ordering', evidence: { invariant } });
|
|
609
|
-
}
|
|
610
|
-
if (field.monotonic !== undefined && field.monotonic !== invariant.direction) {
|
|
611
|
-
findings.push({ code: 'DA9_MONOTONIC_DIRECTION_MISMATCH', severity: 'error', message: 'Monotonic invariant direction ' + invariant.direction + ' conflicts with field monotonic ' + field.monotonic + ' for ' + invariant.entity + '.' + invariant.field, evidence: { invariant } });
|
|
612
|
-
}
|
|
613
|
-
for (const pair of options.copyPairs ?? []) {
|
|
614
|
-
for (const [sourceField, targetField] of Object.entries(pair.mapping)) {
|
|
615
|
-
if (pair.targetEntity + '.' + targetField === invariant.entity + '.' + invariant.field) {
|
|
616
|
-
const source = fieldByPath(model, pair.sourceEntity, sourceField);
|
|
617
|
-
if (source?.monotonic !== undefined && source.monotonic !== invariant.direction) {
|
|
618
|
-
findings.push({ code: 'DA9_MONOTONIC_COPY_OPPOSITE', severity: 'warning', message: 'Copy ' + pair.id + ' maps opposite-monotonic source ' + pair.sourceEntity + '.' + sourceField + ' into monotonic target ' + invariant.entity + '.' + invariant.field, evidence: { invariant, pair } });
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
return checkResult('DA9', 'Data Monotonic', findings, findings.length === 0 ? 'Monotonic data constraints are satisfied' : 'Data monotonic findings: ' + findings.length);
|
|
625
|
-
}
|
|
626
|
-
function DA10_dataSequence(model, options) {
|
|
627
|
-
const findings = [];
|
|
628
|
-
const registry = dataStepRegistry(options);
|
|
629
|
-
for (const invariant of model.invariants ?? []) {
|
|
630
|
-
if (invariant.kind !== 'sequence')
|
|
631
|
-
continue;
|
|
632
|
-
for (const step of invariant.steps) {
|
|
633
|
-
if (!registry.has(step)) {
|
|
634
|
-
findings.push({ code: 'DA10_SEQUENCE_STEP_MISSING', severity: 'error', message: 'Sequence invariant "' + invariant.id + '" references missing step ' + step, evidence: { invariant, step } });
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
return checkResult('DA10', 'Data Sequence', findings, findings.length === 0 ? 'Data sequence steps exist' : 'Data sequence findings: ' + findings.length);
|
|
639
|
-
}
|
|
640
|
-
function DA11_dataLeadsTo(model, options) {
|
|
641
|
-
const findings = [];
|
|
642
|
-
for (const invariant of model.invariants ?? []) {
|
|
643
|
-
if (invariant.kind !== 'leads-to')
|
|
644
|
-
continue;
|
|
645
|
-
const field = fieldByPath(model, invariant.entity, invariant.field);
|
|
646
|
-
if (field === undefined) {
|
|
647
|
-
findings.push({ code: 'DA11_LEADS_TO_FIELD_MISSING', severity: 'error', message: 'Leads-to invariant "' + invariant.id + '" references missing field ' + invariant.entity + '.' + invariant.field, evidence: { invariant } });
|
|
648
|
-
continue;
|
|
649
|
-
}
|
|
650
|
-
if (field.enum !== undefined && field.enum.length > 0) {
|
|
651
|
-
if (!field.enum.includes(invariant.from))
|
|
652
|
-
findings.push({ code: 'DA11_FROM_NOT_IN_ENUM', severity: 'warning', message: 'Leads-to from value ' + String(invariant.from) + ' is not in enum for ' + invariant.entity + '.' + invariant.field, evidence: { invariant } });
|
|
653
|
-
if (!field.enum.includes(invariant.to))
|
|
654
|
-
findings.push({ code: 'DA11_TO_NOT_IN_ENUM', severity: 'warning', message: 'Leads-to to value ' + String(invariant.to) + ' is not in enum for ' + invariant.entity + '.' + invariant.field, evidence: { invariant } });
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
return checkResult('DA11', 'Data Leads-To', findings, findings.length === 0 ? 'Data leads-to constraints are structurally valid' : 'Data leads-to findings: ' + findings.length);
|
|
658
|
-
}
|
|
659
|
-
function DA12_dataAtomicity(model, options) {
|
|
660
|
-
const findings = [];
|
|
661
|
-
const registry = dataStepRegistry(options);
|
|
662
|
-
for (const invariant of model.invariants ?? []) {
|
|
663
|
-
if (invariant.kind !== 'atomicity')
|
|
664
|
-
continue;
|
|
665
|
-
for (const step of invariant.steps) {
|
|
666
|
-
const entry = registry.get(step);
|
|
667
|
-
if (entry === undefined) {
|
|
668
|
-
findings.push({ code: 'DA12_ATOMIC_STEP_MISSING', severity: 'error', message: 'Atomicity invariant "' + invariant.id + '" references missing step ' + step, evidence: { invariant, step } });
|
|
669
|
-
continue;
|
|
670
|
-
}
|
|
671
|
-
if (entry.kind === 'migration' && (entry.transform === 'drop' || entry.transform === 'split' || entry.transform === 'merge')) {
|
|
672
|
-
findings.push({ code: 'DA12_NON_ATOMIC_TRANSFORM', severity: 'warning', message: 'Atomic step ' + step + ' uses non-atomic transform ' + entry.transform, evidence: { invariant, step } });
|
|
673
|
-
}
|
|
674
|
-
if (entry.kind === 'copy' && entry.copyPair !== undefined) {
|
|
675
|
-
const hasBackup = (options.backupPairs ?? []).some((pair) => pair.sourceEntity === entry.copyPair.sourceEntity && pair.targetEntity === entry.copyPair.targetEntity);
|
|
676
|
-
if (!hasBackup)
|
|
677
|
-
findings.push({ code: 'DA12_ATOMIC_COPY_NO_BACKUP', severity: 'warning', message: 'Atomic copy step ' + step + ' has no backup/restore pair', evidence: { invariant, step } });
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
return checkResult('DA12', 'Data Atomicity', findings, findings.length === 0 ? 'Data atomicity constraints are satisfied' : 'Data atomicity findings: ' + findings.length);
|
|
682
|
-
}
|
|
683
|
-
function DD1_dataBehaviorPreservation(before, after, options) {
|
|
684
|
-
const findings = [];
|
|
685
|
-
const afterFields = entityFieldMap(after);
|
|
686
|
-
const mapping = options.fieldMapping ?? {};
|
|
687
|
-
const migrationFrom = new Set((options.migrationMappings ?? []).map((mapping) => mapping.from));
|
|
688
|
-
for (const entity of before.entities) {
|
|
689
|
-
for (const field of entity.fields) {
|
|
690
|
-
const path = entity.name + '.' + field.name;
|
|
691
|
-
const mapped = mapFieldPath(mapping, path);
|
|
692
|
-
const afterField = afterFields.get(mapped);
|
|
693
|
-
if (afterField === undefined) {
|
|
694
|
-
if (!migrationFrom.has(path)) {
|
|
695
|
-
findings.push({ code: 'DD1_FIELD_REMOVED', severity: 'error', message: 'BEFORE field ' + path + ' is missing in AFTER and has no migration mapping', evidence: { beforeField: path, mapped } });
|
|
696
|
-
}
|
|
697
|
-
continue;
|
|
698
|
-
}
|
|
699
|
-
if (field.required === true && afterField.required !== true && !migrationFrom.has(path)) {
|
|
700
|
-
findings.push({ code: 'DD1_REQUIRED_BECAME_OPTIONAL', severity: 'warning', message: 'BEFORE required field ' + path + ' is optional in AFTER', evidence: { beforeField: path, afterField: mapped } });
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
return checkResult('DD1', 'Data Behavior Preservation', findings, findings.length === 0 ? 'BEFORE data behavior is preserved in AFTER' : 'Data behavior findings: ' + findings.length);
|
|
705
|
-
}
|
|
706
|
-
function DD2_dataInvariantContinuity(before, after, options) {
|
|
707
|
-
const findings = [];
|
|
708
|
-
const afterEntities = new Set(after.entities.map((entity) => entity.name));
|
|
709
|
-
const afterFields = entityFieldMap(after);
|
|
710
|
-
const mapping = options.fieldMapping ?? {};
|
|
711
|
-
for (const invariant of before.invariants ?? []) {
|
|
712
|
-
if (invariant.kind === 'field-required' || invariant.kind === 'range' || invariant.kind === 'no-orphan') {
|
|
713
|
-
const mappedField = mapFieldPath(mapping, invariant.entity + '.' + invariant.field);
|
|
714
|
-
const [entityName, fieldName] = mappedField.split('.');
|
|
715
|
-
if (!afterEntities.has(entityName) || !afterFields.has(mappedField)) {
|
|
716
|
-
findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE invariant "' + invariant.id + '" references ' + mappedField + ', missing in AFTER', evidence: { invariant, mappedField } });
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
else if (invariant.kind === 'unique') {
|
|
720
|
-
for (const field of invariant.fields) {
|
|
721
|
-
const mappedField = mapFieldPath(mapping, invariant.entity + '.' + field);
|
|
722
|
-
if (!afterFields.has(mappedField)) {
|
|
723
|
-
findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE unique invariant "' + invariant.id + '" references ' + mappedField + ', missing in AFTER', evidence: { invariant, mappedField } });
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
else if (invariant.kind === 'referential-integrity') {
|
|
728
|
-
const mappedField = mapFieldPath(mapping, invariant.entity + '.' + invariant.field);
|
|
729
|
-
if (!afterFields.has(mappedField)) {
|
|
730
|
-
findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE referential invariant "' + invariant.id + '" references ' + mappedField + ', missing in AFTER', evidence: { invariant, mappedField } });
|
|
731
|
-
}
|
|
732
|
-
if (!afterEntities.has(invariant.refEntity)) {
|
|
733
|
-
findings.push({ code: 'DD2_INVARIANT_ENTITY_MISSING', severity: 'error', message: 'BEFORE referential invariant "' + invariant.id + '" references missing entity ' + invariant.refEntity, evidence: { invariant } });
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
else if (invariant.kind === 'count-equal') {
|
|
737
|
-
if (!afterEntities.has(invariant.sourceEntity))
|
|
738
|
-
findings.push({ code: 'DD2_INVARIANT_ENTITY_MISSING', severity: 'error', message: 'BEFORE count-equal invariant "' + invariant.id + '" references missing source entity ' + invariant.sourceEntity, evidence: { invariant } });
|
|
739
|
-
if (!afterEntities.has(invariant.targetEntity))
|
|
740
|
-
findings.push({ code: 'DD2_INVARIANT_ENTITY_MISSING', severity: 'error', message: 'BEFORE count-equal invariant "' + invariant.id + '" references missing target entity ' + invariant.targetEntity, evidence: { invariant } });
|
|
741
|
-
}
|
|
742
|
-
else if (invariant.kind === 'field-equal') {
|
|
743
|
-
const sourceMapped = mapFieldPath(mapping, invariant.sourceEntity + '.' + invariant.sourceField);
|
|
744
|
-
const targetMapped = mapFieldPath(mapping, invariant.targetEntity + '.' + invariant.targetField);
|
|
745
|
-
if (!afterFields.has(sourceMapped))
|
|
746
|
-
findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE field-equal invariant "' + invariant.id + '" references missing source field ' + sourceMapped, evidence: { invariant, sourceMapped } });
|
|
747
|
-
if (!afterFields.has(targetMapped))
|
|
748
|
-
findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE field-equal invariant "' + invariant.id + '" references missing target field ' + targetMapped, evidence: { invariant, targetMapped } });
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
return checkResult('DD2', 'Data Invariant Continuity', findings, findings.length === 0 ? 'All BEFORE data invariants continue to hold structurally' : 'Data invariant continuity findings: ' + findings.length);
|
|
752
|
-
}
|
|
753
|
-
function DD3_deltaSummary(before, after, options) {
|
|
754
|
-
const findings = [];
|
|
755
|
-
const afterEntities = new Set(after.entities.map((entity) => entity.name));
|
|
756
|
-
const beforeEntities = new Set(before.entities.map((entity) => entity.name));
|
|
757
|
-
const removedEntities = before.entities.filter((entity) => !afterEntities.has(entity.name)).map((entity) => entity.name);
|
|
758
|
-
const addedEntities = after.entities.filter((entity) => !beforeEntities.has(entity.name)).map((entity) => entity.name);
|
|
759
|
-
const mapping = options.fieldMapping ?? {};
|
|
760
|
-
const beforePaths = new Set(allFieldPaths(before));
|
|
761
|
-
const afterPaths = new Set(allFieldPaths(after));
|
|
762
|
-
const removedFields = [];
|
|
763
|
-
const addedFields = [];
|
|
764
|
-
for (const path of beforePaths) {
|
|
765
|
-
const mapped = mapFieldPath(mapping, path);
|
|
766
|
-
if (!afterPaths.has(mapped))
|
|
767
|
-
removedFields.push(path);
|
|
768
|
-
}
|
|
769
|
-
for (const path of afterPaths) {
|
|
770
|
-
let found = false;
|
|
771
|
-
for (const beforePath of beforePaths) {
|
|
772
|
-
if (mapFieldPath(mapping, beforePath) === path) {
|
|
773
|
-
found = true;
|
|
774
|
-
break;
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
if (!found)
|
|
778
|
-
addedFields.push(path);
|
|
779
|
-
}
|
|
780
|
-
for (const entity of removedEntities)
|
|
781
|
-
findings.push({ code: 'DD3_REMOVED_ENTITY', severity: 'warning', message: 'BEFORE entity ' + entity + ' is not present in AFTER', evidence: { entity } });
|
|
782
|
-
for (const field of removedFields)
|
|
783
|
-
findings.push({ code: 'DD3_REMOVED_FIELD', severity: 'warning', message: 'BEFORE field ' + field + ' is not present in AFTER', evidence: { field } });
|
|
784
|
-
const detail = 'Delta: +' + addedEntities.length + ' entities, -' + removedEntities.length + ' entities, +' + addedFields.length + ' fields, -' + removedFields.length + ' fields';
|
|
785
|
-
return checkResult('DD3', 'Delta Summary', findings, detail);
|
|
786
|
-
}
|
|
787
|
-
function DD4_breakingChangeRegression(before, after, options) {
|
|
788
|
-
const findings = [];
|
|
789
|
-
const afterFields = entityFieldMap(after);
|
|
790
|
-
const mapping = options.fieldMapping ?? {};
|
|
791
|
-
const migrationFrom = new Set((options.migrationMappings ?? []).map((mapping) => mapping.from));
|
|
792
|
-
for (const entity of before.entities) {
|
|
793
|
-
for (const field of entity.fields) {
|
|
794
|
-
const path = entity.name + '.' + field.name;
|
|
795
|
-
const mapped = mapFieldPath(mapping, path);
|
|
796
|
-
const afterField = afterFields.get(mapped);
|
|
797
|
-
if (afterField === undefined) {
|
|
798
|
-
if (field.required === true && !migrationFrom.has(path)) {
|
|
799
|
-
findings.push({ code: 'DD4_REQUIRED_FIELD_REMOVED', severity: 'error', message: 'Required field ' + path + ' was removed without migration mapping', evidence: { beforeField: path } });
|
|
800
|
-
}
|
|
801
|
-
continue;
|
|
802
|
-
}
|
|
803
|
-
if (field.required !== true && afterField.required === true && !migrationFrom.has(path)) {
|
|
804
|
-
findings.push({ code: 'DD4_OPTIONAL_BECAME_REQUIRED', severity: 'warning', message: 'Optional field ' + path + ' became required in AFTER', evidence: { beforeField: path, afterField: mapped } });
|
|
805
|
-
}
|
|
806
|
-
if (field.nullable !== false && afterField.nullable === false && !migrationFrom.has(path)) {
|
|
807
|
-
findings.push({ code: 'DD4_NULLABLE_BECAME_NOT_NULL', severity: 'warning', message: 'Nullable field ' + path + ' became non-nullable in AFTER', evidence: { beforeField: path, afterField: mapped } });
|
|
808
|
-
}
|
|
809
|
-
if (field.type !== afterField.type && !migrationFrom.has(path)) {
|
|
810
|
-
findings.push({ code: 'DD4_TYPE_CHANGED', severity: 'warning', message: 'Field type changed from ' + field.type + ' to ' + afterField.type + ' for ' + path, evidence: { beforeField: path, beforeType: field.type, afterType: afterField.type } });
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
return checkResult('DD4', 'Breaking Change Regression', findings, findings.length === 0 ? 'No new breaking changes detected' : 'Breaking change findings: ' + findings.length);
|
|
815
|
-
}
|
|
816
|
-
function buildDataComparisonSummary(before, after, options) {
|
|
817
|
-
const afterEntities = new Set(after.entities.map((entity) => entity.name));
|
|
818
|
-
const beforeEntities = new Set(before.entities.map((entity) => entity.name));
|
|
819
|
-
const addedEntities = after.entities.filter((entity) => !beforeEntities.has(entity.name)).map((entity) => entity.name).sort();
|
|
820
|
-
const removedEntities = before.entities.filter((entity) => !afterEntities.has(entity.name)).map((entity) => entity.name).sort();
|
|
821
|
-
const mapping = options.fieldMapping ?? {};
|
|
822
|
-
const beforePaths = allFieldPaths(before);
|
|
823
|
-
const afterPaths = allFieldPaths(after);
|
|
824
|
-
const afterPathSet = new Set(afterPaths);
|
|
825
|
-
const removedFields = beforePaths.filter((path) => !afterPathSet.has(mapFieldPath(mapping, path))).sort();
|
|
826
|
-
const addedFields = afterPaths.filter((path) => !beforePaths.some((beforePath) => mapFieldPath(mapping, beforePath) === path)).sort();
|
|
827
|
-
const beforeRelationships = (before.relationships ?? []).map((relationship) => relationship.name ?? relationship.fromEntity + '.' + relationship.fromField + '->' + relationship.toEntity);
|
|
828
|
-
const afterRelationships = (after.relationships ?? []).map((relationship) => relationship.name ?? relationship.fromEntity + '.' + relationship.fromField + '->' + relationship.toEntity);
|
|
829
|
-
const removedRelationships = beforeRelationships.filter((name) => !afterRelationships.includes(name)).sort();
|
|
830
|
-
const addedRelationships = afterRelationships.filter((name) => !beforeRelationships.includes(name)).sort();
|
|
831
|
-
return {
|
|
832
|
-
beforeModelHash: dataModelHash(before),
|
|
833
|
-
afterModelHash: dataModelHash(after),
|
|
834
|
-
fieldMapping: mapping,
|
|
835
|
-
beforeEntities: before.entities.length,
|
|
836
|
-
beforeFields: beforePaths.length,
|
|
837
|
-
afterEntities: after.entities.length,
|
|
838
|
-
afterFields: afterPaths.length,
|
|
839
|
-
addedEntities,
|
|
840
|
-
removedEntities,
|
|
841
|
-
addedFields,
|
|
842
|
-
removedFields,
|
|
843
|
-
addedRelationships,
|
|
844
|
-
removedRelationships,
|
|
845
|
-
};
|
|
846
|
-
}
|
|
847
|
-
export function runDataVerification(input, options = {}) {
|
|
848
|
-
const validation = validateDataModel(input);
|
|
849
|
-
if (!validation.ok) {
|
|
850
|
-
return {
|
|
851
|
-
ok: false,
|
|
852
|
-
schemaVersion: 1,
|
|
853
|
-
modelHash: '',
|
|
854
|
-
summary: { entities: 0, fields: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
|
|
855
|
-
checks: [{
|
|
856
|
-
id: 'DATA_MODEL',
|
|
857
|
-
name: 'Data Model Validation',
|
|
858
|
-
status: 'fail',
|
|
859
|
-
detail: 'Data model schema validation failed: ' + validation.errors.length + ' errors',
|
|
860
|
-
findings: validation.errors.map((message) => ({ code: 'DATA_MODEL_INVALID', severity: 'error', message })),
|
|
861
|
-
}],
|
|
862
|
-
};
|
|
863
|
-
}
|
|
864
|
-
const model = validation.model;
|
|
865
|
-
const checks = [
|
|
866
|
-
DS1_schemaWellFormed(model),
|
|
867
|
-
DS2_requiredCompleteness(model, options),
|
|
868
|
-
DS3_relationshipIntegrity(model),
|
|
869
|
-
DS4_typeNullabilityConsistency(model),
|
|
870
|
-
DA1_nullEmptyInjection(model),
|
|
871
|
-
DA2_boundaryBlast(model),
|
|
872
|
-
DA3_uniquenessViolation(model),
|
|
873
|
-
DA4_referentialIntegrityViolation(model),
|
|
874
|
-
DA5_migrationCoverage(model, options),
|
|
875
|
-
DA6_copyConsistency(model, options),
|
|
876
|
-
DA7_rollbackBackupSymmetry(model, options),
|
|
877
|
-
DA8_idempotentConstraints(model, options),
|
|
878
|
-
DA9_dataMonotonic(model, options),
|
|
879
|
-
DA10_dataSequence(model, options),
|
|
880
|
-
DA11_dataLeadsTo(model, options),
|
|
881
|
-
DA12_dataAtomicity(model, options),
|
|
882
|
-
];
|
|
883
|
-
let comparison;
|
|
884
|
-
if (options.beforeModel !== undefined) {
|
|
885
|
-
const beforeValidation = validateDataModel(options.beforeModel);
|
|
886
|
-
if (!beforeValidation.ok) {
|
|
887
|
-
return {
|
|
888
|
-
ok: false,
|
|
889
|
-
schemaVersion: 1,
|
|
890
|
-
modelHash: dataModelHash(model),
|
|
891
|
-
summary: {
|
|
892
|
-
entities: model.entities.length,
|
|
893
|
-
fields: allFieldPaths(model).length,
|
|
894
|
-
errors: beforeValidation.errors.length,
|
|
895
|
-
warnings: 0,
|
|
896
|
-
checksRun: checks.length + 1,
|
|
897
|
-
},
|
|
898
|
-
checks: [
|
|
899
|
-
...checks,
|
|
900
|
-
{
|
|
901
|
-
id: 'BEFORE_DATA_MODEL',
|
|
902
|
-
name: 'Before Data Model Validation',
|
|
903
|
-
status: 'fail',
|
|
904
|
-
detail: 'Before data model schema validation failed: ' + beforeValidation.errors.length + ' errors',
|
|
905
|
-
findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_DATA_MODEL_INVALID', severity: 'error', message })),
|
|
906
|
-
},
|
|
907
|
-
],
|
|
908
|
-
};
|
|
909
|
-
}
|
|
910
|
-
const before = beforeValidation.model;
|
|
911
|
-
checks.push(DD1_dataBehaviorPreservation(before, model, options), DD2_dataInvariantContinuity(before, model, options), DD3_deltaSummary(before, model, options), DD4_breakingChangeRegression(before, model, options));
|
|
912
|
-
comparison = buildDataComparisonSummary(before, model, options);
|
|
913
|
-
}
|
|
914
|
-
const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0);
|
|
915
|
-
const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0);
|
|
916
|
-
return {
|
|
917
|
-
ok: true,
|
|
918
|
-
schemaVersion: 1,
|
|
919
|
-
modelHash: dataModelHash(model),
|
|
920
|
-
summary: {
|
|
921
|
-
entities: model.entities.length,
|
|
922
|
-
fields: allFieldPaths(model).length,
|
|
923
|
-
errors,
|
|
924
|
-
warnings,
|
|
925
|
-
checksRun: checks.length,
|
|
926
|
-
},
|
|
927
|
-
checks,
|
|
928
|
-
...(comparison === undefined ? {} : { comparison }),
|
|
929
|
-
};
|
|
930
|
-
}
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export const DATA_ENGINE_SCHEMA_VERSION = 1;
|
|
3
|
+
const KNOWN_TYPES = new Set([
|
|
4
|
+
'string', 'integer', 'number', 'boolean', 'uuid', 'date', 'datetime', 'timestamp',
|
|
5
|
+
'json', 'enum', 'array', 'object', 'binary',
|
|
6
|
+
]);
|
|
7
|
+
function stableStringify(value) {
|
|
8
|
+
if (value === null || typeof value !== 'object')
|
|
9
|
+
return JSON.stringify(value);
|
|
10
|
+
if (Array.isArray(value))
|
|
11
|
+
return '[' + value.map((item) => stableStringify(item)).join(',') + ']';
|
|
12
|
+
const record = value;
|
|
13
|
+
return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}';
|
|
14
|
+
}
|
|
15
|
+
export function dataModelHash(model) {
|
|
16
|
+
return createHash('sha256').update(stableStringify(model)).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
function bad(errors, path, message) {
|
|
19
|
+
errors.push(path + ': ' + message);
|
|
20
|
+
}
|
|
21
|
+
function isPlainObject(value) {
|
|
22
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
function validateField(input, path, errors, fieldNames) {
|
|
25
|
+
if (!isPlainObject(input)) {
|
|
26
|
+
bad(errors, path, 'must be an object');
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
if (typeof input.name !== 'string' || input.name.length === 0) {
|
|
30
|
+
bad(errors, path + '.name', 'must be a non-empty string');
|
|
31
|
+
}
|
|
32
|
+
else if (fieldNames.has(input.name)) {
|
|
33
|
+
bad(errors, path + '.name', 'duplicate field name ' + input.name);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
fieldNames.add(input.name);
|
|
37
|
+
}
|
|
38
|
+
if (typeof input.type !== 'string' || input.type.length === 0) {
|
|
39
|
+
bad(errors, path + '.type', 'must be a non-empty string');
|
|
40
|
+
}
|
|
41
|
+
if (input.required !== undefined && typeof input.required !== 'boolean')
|
|
42
|
+
bad(errors, path + '.required', 'must be a boolean');
|
|
43
|
+
if (input.unique !== undefined && typeof input.unique !== 'boolean')
|
|
44
|
+
bad(errors, path + '.unique', 'must be a boolean');
|
|
45
|
+
if (input.nullable !== undefined && typeof input.nullable !== 'boolean')
|
|
46
|
+
bad(errors, path + '.nullable', 'must be a boolean');
|
|
47
|
+
if (input.min !== undefined && typeof input.min !== 'number')
|
|
48
|
+
bad(errors, path + '.min', 'must be a number');
|
|
49
|
+
if (input.max !== undefined && typeof input.max !== 'number')
|
|
50
|
+
bad(errors, path + '.max', 'must be a number');
|
|
51
|
+
if (input.minLength !== undefined && typeof input.minLength !== 'number')
|
|
52
|
+
bad(errors, path + '.minLength', 'must be a number');
|
|
53
|
+
if (input.maxLength !== undefined && typeof input.maxLength !== 'number')
|
|
54
|
+
bad(errors, path + '.maxLength', 'must be a number');
|
|
55
|
+
if (input.monotonic !== undefined && input.monotonic !== 'inc' && input.monotonic !== 'dec')
|
|
56
|
+
bad(errors, path + '.monotonic', "must be 'inc' or 'dec'");
|
|
57
|
+
if (input.pattern !== undefined && typeof input.pattern !== 'string')
|
|
58
|
+
bad(errors, path + '.pattern', 'must be a string');
|
|
59
|
+
if (input.ref !== undefined && typeof input.ref !== 'string')
|
|
60
|
+
bad(errors, path + '.ref', 'must be a string');
|
|
61
|
+
if (input.enum !== undefined && !Array.isArray(input.enum))
|
|
62
|
+
bad(errors, path + '.enum', 'must be an array');
|
|
63
|
+
if (input.items !== undefined)
|
|
64
|
+
validateField(input.items, path + '.items', errors, new Set());
|
|
65
|
+
return input;
|
|
66
|
+
}
|
|
67
|
+
function entityByName(model, name) {
|
|
68
|
+
return model.entities.find((entity) => entity.name === name);
|
|
69
|
+
}
|
|
70
|
+
function fieldByPath(model, entityName, fieldName) {
|
|
71
|
+
return entityByName(model, entityName)?.fields.find((field) => field.name === fieldName);
|
|
72
|
+
}
|
|
73
|
+
function mapFieldPath(mapping, path) {
|
|
74
|
+
return mapping[path] ?? path;
|
|
75
|
+
}
|
|
76
|
+
export function validateDataModel(input) {
|
|
77
|
+
const errors = [];
|
|
78
|
+
if (!isPlainObject(input)) {
|
|
79
|
+
return { ok: false, errors: ['model: must be an object'] };
|
|
80
|
+
}
|
|
81
|
+
const root = input;
|
|
82
|
+
if (root.schemaVersion !== 1)
|
|
83
|
+
bad(errors, 'schemaVersion', 'must be 1');
|
|
84
|
+
if (!Array.isArray(root.entities) || root.entities.length === 0) {
|
|
85
|
+
bad(errors, 'entities', 'must be a non-empty array');
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
const entityNames = new Set();
|
|
89
|
+
root.entities.forEach((entry, index) => {
|
|
90
|
+
const path = 'entities[' + index + ']';
|
|
91
|
+
if (!isPlainObject(entry)) {
|
|
92
|
+
bad(errors, path, 'must be an object');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (typeof entry.name !== 'string' || entry.name.length === 0) {
|
|
96
|
+
bad(errors, path + '.name', 'must be a non-empty string');
|
|
97
|
+
}
|
|
98
|
+
else if (entityNames.has(entry.name)) {
|
|
99
|
+
bad(errors, path + '.name', 'duplicate entity name ' + entry.name);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
entityNames.add(entry.name);
|
|
103
|
+
}
|
|
104
|
+
if (!Array.isArray(entry.fields) || entry.fields.length === 0) {
|
|
105
|
+
bad(errors, path + '.fields', 'must be a non-empty array');
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const fieldNames = new Set();
|
|
109
|
+
entry.fields.forEach((field, fieldIndex) => {
|
|
110
|
+
validateField(field, path + '.fields[' + fieldIndex + ']', errors, fieldNames);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (entry.primaryKey !== undefined) {
|
|
114
|
+
if (!Array.isArray(entry.primaryKey))
|
|
115
|
+
bad(errors, path + '.primaryKey', 'must be an array');
|
|
116
|
+
}
|
|
117
|
+
if (entry.uniqueKeys !== undefined) {
|
|
118
|
+
if (!Array.isArray(entry.uniqueKeys))
|
|
119
|
+
bad(errors, path + '.uniqueKeys', 'must be an array');
|
|
120
|
+
}
|
|
121
|
+
if (entry.indexes !== undefined) {
|
|
122
|
+
if (!Array.isArray(entry.indexes))
|
|
123
|
+
bad(errors, path + '.indexes', 'must be an array');
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (root.relationships !== undefined) {
|
|
128
|
+
if (!Array.isArray(root.relationships))
|
|
129
|
+
bad(errors, 'relationships', 'must be an array');
|
|
130
|
+
}
|
|
131
|
+
if (root.invariants !== undefined) {
|
|
132
|
+
if (!Array.isArray(root.invariants))
|
|
133
|
+
bad(errors, 'invariants', 'must be an array');
|
|
134
|
+
else
|
|
135
|
+
root.invariants.forEach((entry, index) => {
|
|
136
|
+
const path = 'invariants[' + index + ']';
|
|
137
|
+
if (!isPlainObject(entry)) {
|
|
138
|
+
bad(errors, path, 'must be an object');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (typeof entry.id !== 'string' || entry.id.length === 0)
|
|
142
|
+
bad(errors, path + '.id', 'must be a non-empty string');
|
|
143
|
+
if (typeof entry.description !== 'string')
|
|
144
|
+
bad(errors, path + '.description', 'must be a string');
|
|
145
|
+
const kind = entry.kind;
|
|
146
|
+
if (kind === 'field-required' || kind === 'no-orphan') {
|
|
147
|
+
if (typeof entry.entity !== 'string')
|
|
148
|
+
bad(errors, path + '.entity', 'must be a string');
|
|
149
|
+
if (typeof entry.field !== 'string')
|
|
150
|
+
bad(errors, path + '.field', 'must be a string');
|
|
151
|
+
}
|
|
152
|
+
else if (kind === 'unique') {
|
|
153
|
+
if (!Array.isArray(entry.fields))
|
|
154
|
+
bad(errors, path + '.fields', 'must be an array');
|
|
155
|
+
}
|
|
156
|
+
else if (kind === 'referential-integrity') {
|
|
157
|
+
if (typeof entry.entity !== 'string')
|
|
158
|
+
bad(errors, path + '.entity', 'must be a string');
|
|
159
|
+
if (typeof entry.field !== 'string')
|
|
160
|
+
bad(errors, path + '.field', 'must be a string');
|
|
161
|
+
if (typeof entry.refEntity !== 'string')
|
|
162
|
+
bad(errors, path + '.refEntity', 'must be a string');
|
|
163
|
+
if (entry.refField !== undefined && typeof entry.refField !== 'string')
|
|
164
|
+
bad(errors, path + '.refField', 'must be a string');
|
|
165
|
+
}
|
|
166
|
+
else if (kind === 'range') {
|
|
167
|
+
if (typeof entry.entity !== 'string')
|
|
168
|
+
bad(errors, path + '.entity', 'must be a string');
|
|
169
|
+
if (typeof entry.field !== 'string')
|
|
170
|
+
bad(errors, path + '.field', 'must be a string');
|
|
171
|
+
if (entry.min !== undefined && typeof entry.min !== 'number')
|
|
172
|
+
bad(errors, path + '.min', 'must be a number');
|
|
173
|
+
if (entry.max !== undefined && typeof entry.max !== 'number')
|
|
174
|
+
bad(errors, path + '.max', 'must be a number');
|
|
175
|
+
}
|
|
176
|
+
else if (kind === 'count-equal') {
|
|
177
|
+
if (typeof entry.sourceEntity !== 'string')
|
|
178
|
+
bad(errors, path + '.sourceEntity', 'must be a string');
|
|
179
|
+
if (typeof entry.targetEntity !== 'string')
|
|
180
|
+
bad(errors, path + '.targetEntity', 'must be a string');
|
|
181
|
+
}
|
|
182
|
+
else if (kind === 'field-equal') {
|
|
183
|
+
if (typeof entry.sourceEntity !== 'string')
|
|
184
|
+
bad(errors, path + '.sourceEntity', 'must be a string');
|
|
185
|
+
if (typeof entry.sourceField !== 'string')
|
|
186
|
+
bad(errors, path + '.sourceField', 'must be a string');
|
|
187
|
+
if (typeof entry.targetEntity !== 'string')
|
|
188
|
+
bad(errors, path + '.targetEntity', 'must be a string');
|
|
189
|
+
if (typeof entry.targetField !== 'string')
|
|
190
|
+
bad(errors, path + '.targetField', 'must be a string');
|
|
191
|
+
}
|
|
192
|
+
else if (kind === 'idempotent-copy') {
|
|
193
|
+
if (typeof entry.sourceEntity !== 'string')
|
|
194
|
+
bad(errors, path + '.sourceEntity', 'must be a string');
|
|
195
|
+
if (typeof entry.targetEntity !== 'string')
|
|
196
|
+
bad(errors, path + '.targetEntity', 'must be a string');
|
|
197
|
+
}
|
|
198
|
+
else if (kind === 'idempotent-migration') {
|
|
199
|
+
if (typeof entry.from !== 'string')
|
|
200
|
+
bad(errors, path + '.from', 'must be a string');
|
|
201
|
+
if (typeof entry.to !== 'string')
|
|
202
|
+
bad(errors, path + '.to', 'must be a string');
|
|
203
|
+
}
|
|
204
|
+
else if (kind === 'monotonic') {
|
|
205
|
+
if (typeof entry.entity !== 'string')
|
|
206
|
+
bad(errors, path + '.entity', 'must be a string');
|
|
207
|
+
if (typeof entry.field !== 'string')
|
|
208
|
+
bad(errors, path + '.field', 'must be a string');
|
|
209
|
+
if (entry.direction !== 'inc' && entry.direction !== 'dec')
|
|
210
|
+
bad(errors, path + '.direction', "must be 'inc' or 'dec'");
|
|
211
|
+
}
|
|
212
|
+
else if (kind === 'sequence') {
|
|
213
|
+
if (!Array.isArray(entry.steps) || entry.steps.length === 0)
|
|
214
|
+
bad(errors, path + '.steps', 'must be a non-empty array');
|
|
215
|
+
else
|
|
216
|
+
entry.steps.forEach((step, stepIndex) => {
|
|
217
|
+
if (typeof step !== 'string' || step.length === 0)
|
|
218
|
+
bad(errors, path + '.steps[' + stepIndex + ']', 'must be a non-empty string');
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
else if (kind === 'leads-to') {
|
|
222
|
+
if (typeof entry.entity !== 'string')
|
|
223
|
+
bad(errors, path + '.entity', 'must be a string');
|
|
224
|
+
if (typeof entry.field !== 'string')
|
|
225
|
+
bad(errors, path + '.field', 'must be a string');
|
|
226
|
+
}
|
|
227
|
+
else if (kind === 'atomicity') {
|
|
228
|
+
if (!Array.isArray(entry.steps) || entry.steps.length === 0)
|
|
229
|
+
bad(errors, path + '.steps', 'must be a non-empty array');
|
|
230
|
+
else
|
|
231
|
+
entry.steps.forEach((step, stepIndex) => {
|
|
232
|
+
if (typeof step !== 'string' || step.length === 0)
|
|
233
|
+
bad(errors, path + '.steps[' + stepIndex + ']', 'must be a non-empty string');
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
bad(errors, path + '.kind', 'unknown invariant kind');
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
if (root.boundaryChecks !== undefined) {
|
|
242
|
+
if (!Array.isArray(root.boundaryChecks))
|
|
243
|
+
bad(errors, 'boundaryChecks', 'must be an array');
|
|
244
|
+
}
|
|
245
|
+
if (errors.length > 0)
|
|
246
|
+
return { ok: false, errors };
|
|
247
|
+
return { ok: true, model: root };
|
|
248
|
+
}
|
|
249
|
+
function checkResult(id, name, findings, detail) {
|
|
250
|
+
const errors = findings.filter((finding) => finding.severity === 'error').length;
|
|
251
|
+
const warnings = findings.filter((finding) => finding.severity === 'warning').length;
|
|
252
|
+
return {
|
|
253
|
+
id,
|
|
254
|
+
name,
|
|
255
|
+
status: findings.length === 0 ? 'pass' : 'fail',
|
|
256
|
+
detail: detail + (errors > 0 ? ' (' + errors + ' errors' + (warnings > 0 ? ', ' + warnings + ' warnings' : '') + ')' : warnings > 0 ? ' (' + warnings + ' warnings)' : ''),
|
|
257
|
+
findings,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function allFieldPaths(model) {
|
|
261
|
+
const result = [];
|
|
262
|
+
for (const entity of model.entities) {
|
|
263
|
+
for (const field of entity.fields)
|
|
264
|
+
result.push(entity.name + '.' + field.name);
|
|
265
|
+
}
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
function entityFieldMap(model) {
|
|
269
|
+
const map = new Map();
|
|
270
|
+
for (const entity of model.entities) {
|
|
271
|
+
for (const field of entity.fields)
|
|
272
|
+
map.set(entity.name + '.' + field.name, field);
|
|
273
|
+
}
|
|
274
|
+
return map;
|
|
275
|
+
}
|
|
276
|
+
function DS1_schemaWellFormed(model) {
|
|
277
|
+
const findings = [];
|
|
278
|
+
const entityNames = new Set();
|
|
279
|
+
for (const entity of model.entities) {
|
|
280
|
+
if (entityNames.has(entity.name)) {
|
|
281
|
+
findings.push({ code: 'DS1_DUPLICATE_ENTITY', severity: 'error', message: 'Duplicate entity name ' + entity.name, evidence: { entity: entity.name } });
|
|
282
|
+
}
|
|
283
|
+
entityNames.add(entity.name);
|
|
284
|
+
const fieldNames = new Set();
|
|
285
|
+
for (const field of entity.fields) {
|
|
286
|
+
if (fieldNames.has(field.name)) {
|
|
287
|
+
findings.push({ code: 'DS1_DUPLICATE_FIELD', severity: 'error', message: 'Duplicate field ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name } });
|
|
288
|
+
}
|
|
289
|
+
fieldNames.add(field.name);
|
|
290
|
+
if (field.type !== undefined && !KNOWN_TYPES.has(field.type) && field.type.trim().length === 0) {
|
|
291
|
+
findings.push({ code: 'DS1_EMPTY_TYPE', severity: 'error', message: 'Field ' + entity.name + '.' + field.name + ' has empty type', evidence: { entity: entity.name, field: field.name } });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const key of entity.primaryKey ?? []) {
|
|
295
|
+
if (!fieldNames.has(key))
|
|
296
|
+
findings.push({ code: 'DS1_PRIMARY_KEY_MISSING', severity: 'error', message: 'Primary key field ' + key + ' does not exist in entity ' + entity.name, evidence: { entity: entity.name, field: key } });
|
|
297
|
+
}
|
|
298
|
+
for (const keySet of entity.uniqueKeys ?? []) {
|
|
299
|
+
for (const key of keySet) {
|
|
300
|
+
if (!fieldNames.has(key))
|
|
301
|
+
findings.push({ code: 'DS1_UNIQUE_KEY_MISSING', severity: 'error', message: 'Unique key field ' + key + ' does not exist in entity ' + entity.name, evidence: { entity: entity.name, field: key } });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
for (const keySet of entity.indexes ?? []) {
|
|
305
|
+
for (const key of keySet) {
|
|
306
|
+
if (!fieldNames.has(key))
|
|
307
|
+
findings.push({ code: 'DS1_INDEX_MISSING', severity: 'error', message: 'Index field ' + key + ' does not exist in entity ' + entity.name, evidence: { entity: entity.name, field: key } });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return checkResult('DS1', 'Schema Well-Formedness', findings, findings.length === 0 ? 'Schema is well-formed' : 'Schema findings: ' + findings.length);
|
|
312
|
+
}
|
|
313
|
+
function DS2_requiredCompleteness(model, options) {
|
|
314
|
+
const findings = [];
|
|
315
|
+
const afterFields = entityFieldMap(model);
|
|
316
|
+
const migrationFrom = new Set((options.migrationMappings ?? []).map((mapping) => mapping.from));
|
|
317
|
+
const copyTargets = new Set();
|
|
318
|
+
for (const pair of options.copyPairs ?? []) {
|
|
319
|
+
for (const target of Object.values(pair.mapping))
|
|
320
|
+
copyTargets.add(pair.targetEntity + '.' + target);
|
|
321
|
+
}
|
|
322
|
+
const beforePaths = new Set();
|
|
323
|
+
if (options.beforeModel !== undefined) {
|
|
324
|
+
const beforeValidation = validateDataModel(options.beforeModel);
|
|
325
|
+
if (beforeValidation.ok) {
|
|
326
|
+
const mapping = options.fieldMapping ?? {};
|
|
327
|
+
for (const path of allFieldPaths(beforeValidation.model))
|
|
328
|
+
beforePaths.add(mapFieldPath(mapping, path));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
for (const entity of model.entities) {
|
|
332
|
+
for (const field of entity.fields) {
|
|
333
|
+
if (field.required !== true)
|
|
334
|
+
continue;
|
|
335
|
+
if ((entity.primaryKey ?? []).includes(field.name) && field.nullable === true) {
|
|
336
|
+
findings.push({ code: 'DS2_NULLABLE_PRIMARY_KEY', severity: 'error', message: 'Primary key field ' + entity.name + '.' + field.name + ' must not be nullable', evidence: { entity: entity.name, field: field.name } });
|
|
337
|
+
}
|
|
338
|
+
if (options.beforeModel !== undefined) {
|
|
339
|
+
const path = entity.name + '.' + field.name;
|
|
340
|
+
if (!beforePaths.has(path) && !migrationFrom.has(path) && !copyTargets.has(path)) {
|
|
341
|
+
findings.push({ code: 'DS2_REQUIRED_FIELD_UNSOURCED', severity: 'warning', message: 'Required field ' + path + ' has no explicit migration mapping or copy target in a before/after migration', evidence: { entity: entity.name, field: field.name } });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return checkResult('DS2', 'Required-Field Completeness', findings, findings.length === 0 ? 'Required fields are complete' : 'Required-field findings: ' + findings.length);
|
|
347
|
+
}
|
|
348
|
+
function DS3_relationshipIntegrity(model) {
|
|
349
|
+
const findings = [];
|
|
350
|
+
for (const relationship of model.relationships ?? []) {
|
|
351
|
+
const fromEntity = entityByName(model, relationship.fromEntity);
|
|
352
|
+
const toEntity = entityByName(model, relationship.toEntity);
|
|
353
|
+
if (fromEntity === undefined) {
|
|
354
|
+
findings.push({ code: 'DS3_FROM_ENTITY_MISSING', severity: 'error', message: 'Relationship references unknown fromEntity ' + relationship.fromEntity, evidence: { relationship } });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (toEntity === undefined) {
|
|
358
|
+
findings.push({ code: 'DS3_TO_ENTITY_MISSING', severity: 'error', message: 'Relationship references unknown toEntity ' + relationship.toEntity, evidence: { relationship } });
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (!fromEntity.fields.some((field) => field.name === relationship.fromField)) {
|
|
362
|
+
findings.push({ code: 'DS3_FROM_FIELD_MISSING', severity: 'error', message: 'Relationship field ' + relationship.fromField + ' does not exist in ' + relationship.fromEntity, evidence: { relationship } });
|
|
363
|
+
}
|
|
364
|
+
if (relationship.toField !== undefined && !toEntity.fields.some((field) => field.name === relationship.toField)) {
|
|
365
|
+
findings.push({ code: 'DS3_TO_FIELD_MISSING', severity: 'error', message: 'Relationship target field ' + relationship.toField + ' does not exist in ' + relationship.toEntity, evidence: { relationship } });
|
|
366
|
+
}
|
|
367
|
+
if (relationship.onDelete !== undefined && !['cascade', 'restrict', 'set-null', 'no-action'].includes(relationship.onDelete)) {
|
|
368
|
+
findings.push({ code: 'DS3_INVALID_ON_DELETE', severity: 'error', message: 'Invalid onDelete value ' + relationship.onDelete, evidence: { relationship } });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return checkResult('DS3', 'Relationship Integrity', findings, findings.length === 0 ? 'Relationships are valid' : 'Relationship findings: ' + findings.length);
|
|
372
|
+
}
|
|
373
|
+
function DS4_typeNullabilityConsistency(model) {
|
|
374
|
+
const findings = [];
|
|
375
|
+
for (const entity of model.entities) {
|
|
376
|
+
for (const field of entity.fields) {
|
|
377
|
+
if (field.required === true && field.nullable === true) {
|
|
378
|
+
findings.push({ code: 'DS4_REQUIRED_NULLABLE', severity: 'error', message: 'Field ' + entity.name + '.' + field.name + ' is both required and nullable', evidence: { entity: entity.name, field: field.name } });
|
|
379
|
+
}
|
|
380
|
+
if (field.unique === true && field.nullable === true) {
|
|
381
|
+
findings.push({ code: 'DS4_UNIQUE_NULLABLE', severity: 'warning', message: 'Unique field ' + entity.name + '.' + field.name + ' is nullable; uniqueness over NULL may be ambiguous', evidence: { entity: entity.name, field: field.name } });
|
|
382
|
+
}
|
|
383
|
+
if (typeof field.min === 'number' && typeof field.max === 'number' && field.min > field.max) {
|
|
384
|
+
findings.push({ code: 'DS4_MIN_GT_MAX', severity: 'error', message: 'Field ' + entity.name + '.' + field.name + ' has min > max', evidence: { entity: entity.name, field: field.name, min: field.min, max: field.max } });
|
|
385
|
+
}
|
|
386
|
+
if (typeof field.minLength === 'number' && typeof field.maxLength === 'number' && field.minLength > field.maxLength) {
|
|
387
|
+
findings.push({ code: 'DS4_MIN_LENGTH_GT_MAX_LENGTH', severity: 'error', message: 'Field ' + entity.name + '.' + field.name + ' has minLength > maxLength', evidence: { entity: entity.name, field: field.name } });
|
|
388
|
+
}
|
|
389
|
+
if (field.default === null && field.required === true) {
|
|
390
|
+
findings.push({ code: 'DS4_REQUIRED_NULL_DEFAULT', severity: 'warning', message: 'Required field ' + entity.name + '.' + field.name + ' has default null', evidence: { entity: entity.name, field: field.name } });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return checkResult('DS4', 'Type/Nullability Consistency', findings, findings.length === 0 ? 'Types and nullability are consistent' : 'Type/nullability findings: ' + findings.length);
|
|
395
|
+
}
|
|
396
|
+
function DA1_nullEmptyInjection(model) {
|
|
397
|
+
const findings = [];
|
|
398
|
+
for (const entity of model.entities) {
|
|
399
|
+
for (const field of entity.fields) {
|
|
400
|
+
if (field.required === true && field.nullable === true) {
|
|
401
|
+
findings.push({ code: 'DA1_NULL_ACCEPTED_ON_REQUIRED', severity: 'error', message: 'Null can be injected into required field ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name } });
|
|
402
|
+
}
|
|
403
|
+
if (field.required === true && field.type === 'string' && field.minLength !== undefined && field.minLength === 0) {
|
|
404
|
+
findings.push({ code: 'DA1_EMPTY_STRING_ALLOWED', severity: 'warning', message: 'Required string field ' + entity.name + '.' + field.name + ' allows empty string', evidence: { entity: entity.name, field: field.name } });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return checkResult('DA1', 'Null/Empty Injection', findings, findings.length === 0 ? 'Null/empty injection is rejected' : 'Null/empty findings: ' + findings.length);
|
|
409
|
+
}
|
|
410
|
+
function DA2_boundaryBlast(model) {
|
|
411
|
+
const findings = [];
|
|
412
|
+
for (const entity of model.entities) {
|
|
413
|
+
for (const field of entity.fields) {
|
|
414
|
+
if (typeof field.min === 'number' && typeof field.max === 'number' && field.min > field.max)
|
|
415
|
+
continue;
|
|
416
|
+
if (typeof field.default === 'number') {
|
|
417
|
+
if (field.min !== undefined && field.default < field.min)
|
|
418
|
+
findings.push({ code: 'DA2_DEFAULT_BELOW_MIN', severity: 'error', message: 'Default ' + field.default + ' is below min ' + field.min + ' for ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name } });
|
|
419
|
+
if (field.max !== undefined && field.default > field.max)
|
|
420
|
+
findings.push({ code: 'DA2_DEFAULT_ABOVE_MAX', severity: 'error', message: 'Default ' + field.default + ' is above max ' + field.max + ' for ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name } });
|
|
421
|
+
}
|
|
422
|
+
if (field.enum !== undefined && field.enum.length > 0) {
|
|
423
|
+
if (typeof field.min === 'number') {
|
|
424
|
+
for (const value of field.enum) {
|
|
425
|
+
if (typeof value === 'number' && value < field.min)
|
|
426
|
+
findings.push({ code: 'DA2_ENUM_BELOW_MIN', severity: 'warning', message: 'Enum value ' + value + ' is below min ' + field.min + ' for ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name, value } });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (typeof field.max === 'number') {
|
|
430
|
+
for (const value of field.enum) {
|
|
431
|
+
if (typeof value === 'number' && value > field.max)
|
|
432
|
+
findings.push({ code: 'DA2_ENUM_ABOVE_MAX', severity: 'warning', message: 'Enum value ' + value + ' is above max ' + field.max + ' for ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name, value } });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
for (const check of model.boundaryChecks ?? []) {
|
|
439
|
+
const field = fieldByPath(model, check.entity, check.field);
|
|
440
|
+
if (field === undefined) {
|
|
441
|
+
findings.push({ code: 'DA2_BOUNDARY_FIELD_MISSING', severity: 'error', message: 'Boundary check references unknown field ' + check.entity + '.' + check.field, evidence: { entity: check.entity, field: check.field } });
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
for (const value of check.values ?? []) {
|
|
445
|
+
if (typeof value === 'number') {
|
|
446
|
+
if (field.min !== undefined && value < field.min)
|
|
447
|
+
findings.push({ code: 'DA2_BOUNDARY_BELOW_MIN', severity: 'warning', message: 'Boundary value ' + value + ' below min ' + field.min + ' for ' + check.entity + '.' + check.field, evidence: { entity: check.entity, field: check.field, value } });
|
|
448
|
+
if (field.max !== undefined && value > field.max)
|
|
449
|
+
findings.push({ code: 'DA2_BOUNDARY_ABOVE_MAX', severity: 'warning', message: 'Boundary value ' + value + ' above max ' + field.max + ' for ' + check.entity + '.' + check.field, evidence: { entity: check.entity, field: check.field, value } });
|
|
450
|
+
}
|
|
451
|
+
if (field.enum !== undefined && field.enum.length > 0 && !field.enum.includes(value)) {
|
|
452
|
+
findings.push({ code: 'DA2_BOUNDARY_NOT_IN_ENUM', severity: 'warning', message: 'Boundary value ' + String(value) + ' not in enum for ' + check.entity + '.' + check.field, evidence: { entity: check.entity, field: check.field, value } });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return checkResult('DA2', 'Boundary Blast', findings, findings.length === 0 ? 'Boundary checks passed' : 'Boundary findings: ' + findings.length);
|
|
457
|
+
}
|
|
458
|
+
function DA3_uniquenessViolation(model) {
|
|
459
|
+
const findings = [];
|
|
460
|
+
for (const entity of model.entities) {
|
|
461
|
+
for (const key of entity.primaryKey ?? []) {
|
|
462
|
+
const field = fieldByPath(model, entity.name, key);
|
|
463
|
+
if (field?.nullable === true)
|
|
464
|
+
findings.push({ code: 'DA3_NULLABLE_PRIMARY_KEY', severity: 'error', message: 'Primary key ' + entity.name + '.' + key + ' is nullable', evidence: { entity: entity.name, field: key } });
|
|
465
|
+
}
|
|
466
|
+
for (const keySet of entity.uniqueKeys ?? []) {
|
|
467
|
+
for (const key of keySet) {
|
|
468
|
+
const field = fieldByPath(model, entity.name, key);
|
|
469
|
+
if (field?.nullable === true)
|
|
470
|
+
findings.push({ code: 'DA3_NULLABLE_UNIQUE_KEY', severity: 'warning', message: 'Unique key ' + entity.name + '.' + key + ' is nullable', evidence: { entity: entity.name, field: key } });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return checkResult('DA3', 'Uniqueness Violation', findings, findings.length === 0 ? 'No uniqueness violations' : 'Uniqueness findings: ' + findings.length);
|
|
475
|
+
}
|
|
476
|
+
function DA4_referentialIntegrityViolation(model) {
|
|
477
|
+
const findings = [];
|
|
478
|
+
for (const relationship of model.relationships ?? []) {
|
|
479
|
+
const fromField = fieldByPath(model, relationship.fromEntity, relationship.fromField);
|
|
480
|
+
const toField = relationship.toField === undefined ? undefined : fieldByPath(model, relationship.toEntity, relationship.toField);
|
|
481
|
+
if (fromField !== undefined && toField !== undefined && fromField.type !== toField.type) {
|
|
482
|
+
findings.push({ code: 'DA4_REF_TYPE_MISMATCH', severity: 'warning', message: 'Relationship ' + (relationship.name ?? relationship.fromEntity + '.' + relationship.fromField) + ' links different types ' + fromField.type + ' -> ' + toField.type, evidence: { relationship } });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return checkResult('DA4', 'Referential Integrity Violation', findings, findings.length === 0 ? 'No referential integrity violations' : 'Referential integrity findings: ' + findings.length);
|
|
486
|
+
}
|
|
487
|
+
function DA5_migrationCoverage(model, options) {
|
|
488
|
+
const findings = [];
|
|
489
|
+
if (options.beforeModel === undefined) {
|
|
490
|
+
return checkResult('DA5', 'Migration Coverage', [], 'No beforeModel supplied — skipped');
|
|
491
|
+
}
|
|
492
|
+
const beforeValidation = validateDataModel(options.beforeModel);
|
|
493
|
+
if (!beforeValidation.ok) {
|
|
494
|
+
return checkResult('DA5', 'Migration Coverage', [{ code: 'DA5_BEFORE_MODEL_INVALID', severity: 'error', message: 'Before model failed validation: ' + beforeValidation.errors.join('; ') }], 'Before model invalid');
|
|
495
|
+
}
|
|
496
|
+
const before = beforeValidation.model;
|
|
497
|
+
const afterPaths = new Set(allFieldPaths(model));
|
|
498
|
+
const beforePaths = allFieldPaths(before);
|
|
499
|
+
const mapping = options.fieldMapping ?? {};
|
|
500
|
+
const migrationFrom = new Set((options.migrationMappings ?? []).map((mapping) => mapping.from));
|
|
501
|
+
for (const path of beforePaths) {
|
|
502
|
+
const mapped = mapFieldPath(mapping, path);
|
|
503
|
+
if (!afterPaths.has(mapped) && !migrationFrom.has(path)) {
|
|
504
|
+
findings.push({ code: 'DA5_FIELD_NOT_MIGRATED', severity: 'error', message: 'BEFORE field ' + path + ' is not present in AFTER and has no migration mapping', evidence: { beforeField: path, afterField: mapped } });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return checkResult('DA5', 'Migration Coverage', findings, findings.length === 0 ? 'All before/after fields are covered by mappings' : 'Migration coverage findings: ' + findings.length);
|
|
508
|
+
}
|
|
509
|
+
function DA6_copyConsistency(model, options) {
|
|
510
|
+
const findings = [];
|
|
511
|
+
const afterFields = entityFieldMap(model);
|
|
512
|
+
for (const pair of options.copyPairs ?? []) {
|
|
513
|
+
const targetEntity = entityByName(model, pair.targetEntity);
|
|
514
|
+
if (targetEntity === undefined) {
|
|
515
|
+
findings.push({ code: 'DA6_TARGET_ENTITY_MISSING', severity: 'error', message: 'Copy pair ' + pair.id + ' references unknown target entity ' + pair.targetEntity, evidence: { pair } });
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (entityByName(model, pair.sourceEntity) === undefined) {
|
|
519
|
+
findings.push({ code: 'DA6_SOURCE_ENTITY_MISSING', severity: 'error', message: 'Copy pair ' + pair.id + ' references unknown source entity ' + pair.sourceEntity, evidence: { pair } });
|
|
520
|
+
}
|
|
521
|
+
const mappedTargets = new Set(Object.values(pair.mapping).map((field) => pair.targetEntity + '.' + field));
|
|
522
|
+
for (const field of targetEntity.fields) {
|
|
523
|
+
if (field.required === true && !mappedTargets.has(pair.targetEntity + '.' + field.name)) {
|
|
524
|
+
findings.push({ code: 'DA6_REQUIRED_TARGET_UNMAPPED', severity: 'error', message: 'Copy pair ' + pair.id + ' does not map required target field ' + pair.targetEntity + '.' + field.name, evidence: { pair, field: field.name } });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
for (const [sourceField, targetField] of Object.entries(pair.mapping)) {
|
|
528
|
+
if (fieldByPath(model, pair.sourceEntity, sourceField) === undefined) {
|
|
529
|
+
findings.push({ code: 'DA6_SOURCE_FIELD_MISSING', severity: 'error', message: 'Copy pair ' + pair.id + ' maps unknown source field ' + pair.sourceEntity + '.' + sourceField, evidence: { pair, field: sourceField } });
|
|
530
|
+
}
|
|
531
|
+
if (afterFields.has(pair.targetEntity + '.' + targetField) === false) {
|
|
532
|
+
findings.push({ code: 'DA6_TARGET_FIELD_MISSING', severity: 'error', message: 'Copy pair ' + pair.id + ' maps unknown target field ' + pair.targetEntity + '.' + targetField, evidence: { pair, field: targetField } });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return checkResult('DA6', 'Copy Consistency', findings, findings.length === 0 ? 'Copy mappings are consistent' : 'Copy consistency findings: ' + findings.length);
|
|
537
|
+
}
|
|
538
|
+
function DA7_rollbackBackupSymmetry(model, options) {
|
|
539
|
+
const findings = [];
|
|
540
|
+
const backupKeys = new Set((options.backupPairs ?? []).map((pair) => pair.sourceEntity + '|' + pair.targetEntity));
|
|
541
|
+
for (const pair of options.copyPairs ?? []) {
|
|
542
|
+
if (!backupKeys.has(pair.sourceEntity + '|' + pair.targetEntity)) {
|
|
543
|
+
findings.push({ code: 'DA7_NO_BACKUP_PAIR', severity: 'warning', message: 'Copy pair ' + pair.id + ' (' + pair.sourceEntity + ' -> ' + pair.targetEntity + ') has no matching backup/restore pair', evidence: { pair } });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return checkResult('DA7', 'Rollback/Backup Symmetry', findings, findings.length === 0 ? 'Copy/backup pairs are balanced' : 'Rollback/backup findings: ' + findings.length);
|
|
547
|
+
}
|
|
548
|
+
function DA8_idempotentConstraints(model, options) {
|
|
549
|
+
const findings = [];
|
|
550
|
+
const copyPairs = options.copyPairs ?? [];
|
|
551
|
+
const migrationMappings = options.migrationMappings ?? [];
|
|
552
|
+
for (const invariant of model.invariants ?? []) {
|
|
553
|
+
if (invariant.kind === 'idempotent-copy') {
|
|
554
|
+
const pair = copyPairs.find((entry) => entry.sourceEntity === invariant.sourceEntity && entry.targetEntity === invariant.targetEntity);
|
|
555
|
+
if (pair === undefined) {
|
|
556
|
+
findings.push({
|
|
557
|
+
code: 'DA8_COPY_PAIR_MISSING',
|
|
558
|
+
severity: 'error',
|
|
559
|
+
message: 'Idempotent-copy invariant "' + invariant.id + '" has no matching copy pair for ' + invariant.sourceEntity + ' -> ' + invariant.targetEntity,
|
|
560
|
+
evidence: { invariant },
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
else if (invariant.kind === 'idempotent-migration') {
|
|
565
|
+
const mapping = migrationMappings.find((entry) => entry.from === invariant.from && entry.to === invariant.to);
|
|
566
|
+
if (mapping === undefined) {
|
|
567
|
+
findings.push({
|
|
568
|
+
code: 'DA8_MIGRATION_MAPPING_MISSING',
|
|
569
|
+
severity: 'error',
|
|
570
|
+
message: 'Idempotent-migration invariant "' + invariant.id + '" has no matching migration mapping ' + invariant.from + ' -> ' + invariant.to,
|
|
571
|
+
evidence: { invariant },
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
else if (mapping.transform === 'split' || mapping.transform === 'merge' || mapping.transform === 'drop') {
|
|
575
|
+
findings.push({
|
|
576
|
+
code: 'DA8_NON_IDEMPOTENT_TRANSFORM',
|
|
577
|
+
severity: 'warning',
|
|
578
|
+
message: 'Migration mapping ' + invariant.from + ' -> ' + invariant.to + ' uses non-idempotent transform ' + (mapping.transform ?? 'unknown'),
|
|
579
|
+
evidence: { invariant, mapping },
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return checkResult('DA8', 'Idempotent Constraints', findings, findings.length === 0 ? 'Idempotency constraints are satisfied' : 'Idempotency findings: ' + findings.length);
|
|
585
|
+
}
|
|
586
|
+
function dataStepRegistry(options) {
|
|
587
|
+
const map = new Map();
|
|
588
|
+
for (const pair of options.copyPairs ?? [])
|
|
589
|
+
map.set(pair.id, { kind: 'copy', copyPair: pair });
|
|
590
|
+
for (const mapping of options.migrationMappings ?? []) {
|
|
591
|
+
const id = mapping.id ?? mapping.from + '->' + mapping.to;
|
|
592
|
+
map.set(id, { kind: 'migration', transform: mapping.transform });
|
|
593
|
+
}
|
|
594
|
+
return map;
|
|
595
|
+
}
|
|
596
|
+
const MONOTONIC_DATA_TYPES = new Set(['integer', 'number', 'date', 'datetime', 'timestamp']);
|
|
597
|
+
function DA9_dataMonotonic(model, options) {
|
|
598
|
+
const findings = [];
|
|
599
|
+
for (const invariant of model.invariants ?? []) {
|
|
600
|
+
if (invariant.kind !== 'monotonic')
|
|
601
|
+
continue;
|
|
602
|
+
const field = fieldByPath(model, invariant.entity, invariant.field);
|
|
603
|
+
if (field === undefined) {
|
|
604
|
+
findings.push({ code: 'DA9_MONOTONIC_FIELD_MISSING', severity: 'error', message: 'Monotonic invariant "' + invariant.id + '" references missing field ' + invariant.entity + '.' + invariant.field, evidence: { invariant } });
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (!MONOTONIC_DATA_TYPES.has(field.type)) {
|
|
608
|
+
findings.push({ code: 'DA9_MONOTONIC_TYPE_UNSUPPORTED', severity: 'warning', message: 'Monotonic field ' + invariant.entity + '.' + invariant.field + ' has type ' + field.type + ', which may not support monotonic ordering', evidence: { invariant } });
|
|
609
|
+
}
|
|
610
|
+
if (field.monotonic !== undefined && field.monotonic !== invariant.direction) {
|
|
611
|
+
findings.push({ code: 'DA9_MONOTONIC_DIRECTION_MISMATCH', severity: 'error', message: 'Monotonic invariant direction ' + invariant.direction + ' conflicts with field monotonic ' + field.monotonic + ' for ' + invariant.entity + '.' + invariant.field, evidence: { invariant } });
|
|
612
|
+
}
|
|
613
|
+
for (const pair of options.copyPairs ?? []) {
|
|
614
|
+
for (const [sourceField, targetField] of Object.entries(pair.mapping)) {
|
|
615
|
+
if (pair.targetEntity + '.' + targetField === invariant.entity + '.' + invariant.field) {
|
|
616
|
+
const source = fieldByPath(model, pair.sourceEntity, sourceField);
|
|
617
|
+
if (source?.monotonic !== undefined && source.monotonic !== invariant.direction) {
|
|
618
|
+
findings.push({ code: 'DA9_MONOTONIC_COPY_OPPOSITE', severity: 'warning', message: 'Copy ' + pair.id + ' maps opposite-monotonic source ' + pair.sourceEntity + '.' + sourceField + ' into monotonic target ' + invariant.entity + '.' + invariant.field, evidence: { invariant, pair } });
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return checkResult('DA9', 'Data Monotonic', findings, findings.length === 0 ? 'Monotonic data constraints are satisfied' : 'Data monotonic findings: ' + findings.length);
|
|
625
|
+
}
|
|
626
|
+
function DA10_dataSequence(model, options) {
|
|
627
|
+
const findings = [];
|
|
628
|
+
const registry = dataStepRegistry(options);
|
|
629
|
+
for (const invariant of model.invariants ?? []) {
|
|
630
|
+
if (invariant.kind !== 'sequence')
|
|
631
|
+
continue;
|
|
632
|
+
for (const step of invariant.steps) {
|
|
633
|
+
if (!registry.has(step)) {
|
|
634
|
+
findings.push({ code: 'DA10_SEQUENCE_STEP_MISSING', severity: 'error', message: 'Sequence invariant "' + invariant.id + '" references missing step ' + step, evidence: { invariant, step } });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return checkResult('DA10', 'Data Sequence', findings, findings.length === 0 ? 'Data sequence steps exist' : 'Data sequence findings: ' + findings.length);
|
|
639
|
+
}
|
|
640
|
+
function DA11_dataLeadsTo(model, options) {
|
|
641
|
+
const findings = [];
|
|
642
|
+
for (const invariant of model.invariants ?? []) {
|
|
643
|
+
if (invariant.kind !== 'leads-to')
|
|
644
|
+
continue;
|
|
645
|
+
const field = fieldByPath(model, invariant.entity, invariant.field);
|
|
646
|
+
if (field === undefined) {
|
|
647
|
+
findings.push({ code: 'DA11_LEADS_TO_FIELD_MISSING', severity: 'error', message: 'Leads-to invariant "' + invariant.id + '" references missing field ' + invariant.entity + '.' + invariant.field, evidence: { invariant } });
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (field.enum !== undefined && field.enum.length > 0) {
|
|
651
|
+
if (!field.enum.includes(invariant.from))
|
|
652
|
+
findings.push({ code: 'DA11_FROM_NOT_IN_ENUM', severity: 'warning', message: 'Leads-to from value ' + String(invariant.from) + ' is not in enum for ' + invariant.entity + '.' + invariant.field, evidence: { invariant } });
|
|
653
|
+
if (!field.enum.includes(invariant.to))
|
|
654
|
+
findings.push({ code: 'DA11_TO_NOT_IN_ENUM', severity: 'warning', message: 'Leads-to to value ' + String(invariant.to) + ' is not in enum for ' + invariant.entity + '.' + invariant.field, evidence: { invariant } });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return checkResult('DA11', 'Data Leads-To', findings, findings.length === 0 ? 'Data leads-to constraints are structurally valid' : 'Data leads-to findings: ' + findings.length);
|
|
658
|
+
}
|
|
659
|
+
function DA12_dataAtomicity(model, options) {
|
|
660
|
+
const findings = [];
|
|
661
|
+
const registry = dataStepRegistry(options);
|
|
662
|
+
for (const invariant of model.invariants ?? []) {
|
|
663
|
+
if (invariant.kind !== 'atomicity')
|
|
664
|
+
continue;
|
|
665
|
+
for (const step of invariant.steps) {
|
|
666
|
+
const entry = registry.get(step);
|
|
667
|
+
if (entry === undefined) {
|
|
668
|
+
findings.push({ code: 'DA12_ATOMIC_STEP_MISSING', severity: 'error', message: 'Atomicity invariant "' + invariant.id + '" references missing step ' + step, evidence: { invariant, step } });
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (entry.kind === 'migration' && (entry.transform === 'drop' || entry.transform === 'split' || entry.transform === 'merge')) {
|
|
672
|
+
findings.push({ code: 'DA12_NON_ATOMIC_TRANSFORM', severity: 'warning', message: 'Atomic step ' + step + ' uses non-atomic transform ' + entry.transform, evidence: { invariant, step } });
|
|
673
|
+
}
|
|
674
|
+
if (entry.kind === 'copy' && entry.copyPair !== undefined) {
|
|
675
|
+
const hasBackup = (options.backupPairs ?? []).some((pair) => pair.sourceEntity === entry.copyPair.sourceEntity && pair.targetEntity === entry.copyPair.targetEntity);
|
|
676
|
+
if (!hasBackup)
|
|
677
|
+
findings.push({ code: 'DA12_ATOMIC_COPY_NO_BACKUP', severity: 'warning', message: 'Atomic copy step ' + step + ' has no backup/restore pair', evidence: { invariant, step } });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return checkResult('DA12', 'Data Atomicity', findings, findings.length === 0 ? 'Data atomicity constraints are satisfied' : 'Data atomicity findings: ' + findings.length);
|
|
682
|
+
}
|
|
683
|
+
function DD1_dataBehaviorPreservation(before, after, options) {
|
|
684
|
+
const findings = [];
|
|
685
|
+
const afterFields = entityFieldMap(after);
|
|
686
|
+
const mapping = options.fieldMapping ?? {};
|
|
687
|
+
const migrationFrom = new Set((options.migrationMappings ?? []).map((mapping) => mapping.from));
|
|
688
|
+
for (const entity of before.entities) {
|
|
689
|
+
for (const field of entity.fields) {
|
|
690
|
+
const path = entity.name + '.' + field.name;
|
|
691
|
+
const mapped = mapFieldPath(mapping, path);
|
|
692
|
+
const afterField = afterFields.get(mapped);
|
|
693
|
+
if (afterField === undefined) {
|
|
694
|
+
if (!migrationFrom.has(path)) {
|
|
695
|
+
findings.push({ code: 'DD1_FIELD_REMOVED', severity: 'error', message: 'BEFORE field ' + path + ' is missing in AFTER and has no migration mapping', evidence: { beforeField: path, mapped } });
|
|
696
|
+
}
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
if (field.required === true && afterField.required !== true && !migrationFrom.has(path)) {
|
|
700
|
+
findings.push({ code: 'DD1_REQUIRED_BECAME_OPTIONAL', severity: 'warning', message: 'BEFORE required field ' + path + ' is optional in AFTER', evidence: { beforeField: path, afterField: mapped } });
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return checkResult('DD1', 'Data Behavior Preservation', findings, findings.length === 0 ? 'BEFORE data behavior is preserved in AFTER' : 'Data behavior findings: ' + findings.length);
|
|
705
|
+
}
|
|
706
|
+
function DD2_dataInvariantContinuity(before, after, options) {
|
|
707
|
+
const findings = [];
|
|
708
|
+
const afterEntities = new Set(after.entities.map((entity) => entity.name));
|
|
709
|
+
const afterFields = entityFieldMap(after);
|
|
710
|
+
const mapping = options.fieldMapping ?? {};
|
|
711
|
+
for (const invariant of before.invariants ?? []) {
|
|
712
|
+
if (invariant.kind === 'field-required' || invariant.kind === 'range' || invariant.kind === 'no-orphan') {
|
|
713
|
+
const mappedField = mapFieldPath(mapping, invariant.entity + '.' + invariant.field);
|
|
714
|
+
const [entityName, fieldName] = mappedField.split('.');
|
|
715
|
+
if (!afterEntities.has(entityName) || !afterFields.has(mappedField)) {
|
|
716
|
+
findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE invariant "' + invariant.id + '" references ' + mappedField + ', missing in AFTER', evidence: { invariant, mappedField } });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
else if (invariant.kind === 'unique') {
|
|
720
|
+
for (const field of invariant.fields) {
|
|
721
|
+
const mappedField = mapFieldPath(mapping, invariant.entity + '.' + field);
|
|
722
|
+
if (!afterFields.has(mappedField)) {
|
|
723
|
+
findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE unique invariant "' + invariant.id + '" references ' + mappedField + ', missing in AFTER', evidence: { invariant, mappedField } });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
else if (invariant.kind === 'referential-integrity') {
|
|
728
|
+
const mappedField = mapFieldPath(mapping, invariant.entity + '.' + invariant.field);
|
|
729
|
+
if (!afterFields.has(mappedField)) {
|
|
730
|
+
findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE referential invariant "' + invariant.id + '" references ' + mappedField + ', missing in AFTER', evidence: { invariant, mappedField } });
|
|
731
|
+
}
|
|
732
|
+
if (!afterEntities.has(invariant.refEntity)) {
|
|
733
|
+
findings.push({ code: 'DD2_INVARIANT_ENTITY_MISSING', severity: 'error', message: 'BEFORE referential invariant "' + invariant.id + '" references missing entity ' + invariant.refEntity, evidence: { invariant } });
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
else if (invariant.kind === 'count-equal') {
|
|
737
|
+
if (!afterEntities.has(invariant.sourceEntity))
|
|
738
|
+
findings.push({ code: 'DD2_INVARIANT_ENTITY_MISSING', severity: 'error', message: 'BEFORE count-equal invariant "' + invariant.id + '" references missing source entity ' + invariant.sourceEntity, evidence: { invariant } });
|
|
739
|
+
if (!afterEntities.has(invariant.targetEntity))
|
|
740
|
+
findings.push({ code: 'DD2_INVARIANT_ENTITY_MISSING', severity: 'error', message: 'BEFORE count-equal invariant "' + invariant.id + '" references missing target entity ' + invariant.targetEntity, evidence: { invariant } });
|
|
741
|
+
}
|
|
742
|
+
else if (invariant.kind === 'field-equal') {
|
|
743
|
+
const sourceMapped = mapFieldPath(mapping, invariant.sourceEntity + '.' + invariant.sourceField);
|
|
744
|
+
const targetMapped = mapFieldPath(mapping, invariant.targetEntity + '.' + invariant.targetField);
|
|
745
|
+
if (!afterFields.has(sourceMapped))
|
|
746
|
+
findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE field-equal invariant "' + invariant.id + '" references missing source field ' + sourceMapped, evidence: { invariant, sourceMapped } });
|
|
747
|
+
if (!afterFields.has(targetMapped))
|
|
748
|
+
findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE field-equal invariant "' + invariant.id + '" references missing target field ' + targetMapped, evidence: { invariant, targetMapped } });
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return checkResult('DD2', 'Data Invariant Continuity', findings, findings.length === 0 ? 'All BEFORE data invariants continue to hold structurally' : 'Data invariant continuity findings: ' + findings.length);
|
|
752
|
+
}
|
|
753
|
+
function DD3_deltaSummary(before, after, options) {
|
|
754
|
+
const findings = [];
|
|
755
|
+
const afterEntities = new Set(after.entities.map((entity) => entity.name));
|
|
756
|
+
const beforeEntities = new Set(before.entities.map((entity) => entity.name));
|
|
757
|
+
const removedEntities = before.entities.filter((entity) => !afterEntities.has(entity.name)).map((entity) => entity.name);
|
|
758
|
+
const addedEntities = after.entities.filter((entity) => !beforeEntities.has(entity.name)).map((entity) => entity.name);
|
|
759
|
+
const mapping = options.fieldMapping ?? {};
|
|
760
|
+
const beforePaths = new Set(allFieldPaths(before));
|
|
761
|
+
const afterPaths = new Set(allFieldPaths(after));
|
|
762
|
+
const removedFields = [];
|
|
763
|
+
const addedFields = [];
|
|
764
|
+
for (const path of beforePaths) {
|
|
765
|
+
const mapped = mapFieldPath(mapping, path);
|
|
766
|
+
if (!afterPaths.has(mapped))
|
|
767
|
+
removedFields.push(path);
|
|
768
|
+
}
|
|
769
|
+
for (const path of afterPaths) {
|
|
770
|
+
let found = false;
|
|
771
|
+
for (const beforePath of beforePaths) {
|
|
772
|
+
if (mapFieldPath(mapping, beforePath) === path) {
|
|
773
|
+
found = true;
|
|
774
|
+
break;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (!found)
|
|
778
|
+
addedFields.push(path);
|
|
779
|
+
}
|
|
780
|
+
for (const entity of removedEntities)
|
|
781
|
+
findings.push({ code: 'DD3_REMOVED_ENTITY', severity: 'warning', message: 'BEFORE entity ' + entity + ' is not present in AFTER', evidence: { entity } });
|
|
782
|
+
for (const field of removedFields)
|
|
783
|
+
findings.push({ code: 'DD3_REMOVED_FIELD', severity: 'warning', message: 'BEFORE field ' + field + ' is not present in AFTER', evidence: { field } });
|
|
784
|
+
const detail = 'Delta: +' + addedEntities.length + ' entities, -' + removedEntities.length + ' entities, +' + addedFields.length + ' fields, -' + removedFields.length + ' fields';
|
|
785
|
+
return checkResult('DD3', 'Delta Summary', findings, detail);
|
|
786
|
+
}
|
|
787
|
+
function DD4_breakingChangeRegression(before, after, options) {
|
|
788
|
+
const findings = [];
|
|
789
|
+
const afterFields = entityFieldMap(after);
|
|
790
|
+
const mapping = options.fieldMapping ?? {};
|
|
791
|
+
const migrationFrom = new Set((options.migrationMappings ?? []).map((mapping) => mapping.from));
|
|
792
|
+
for (const entity of before.entities) {
|
|
793
|
+
for (const field of entity.fields) {
|
|
794
|
+
const path = entity.name + '.' + field.name;
|
|
795
|
+
const mapped = mapFieldPath(mapping, path);
|
|
796
|
+
const afterField = afterFields.get(mapped);
|
|
797
|
+
if (afterField === undefined) {
|
|
798
|
+
if (field.required === true && !migrationFrom.has(path)) {
|
|
799
|
+
findings.push({ code: 'DD4_REQUIRED_FIELD_REMOVED', severity: 'error', message: 'Required field ' + path + ' was removed without migration mapping', evidence: { beforeField: path } });
|
|
800
|
+
}
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
if (field.required !== true && afterField.required === true && !migrationFrom.has(path)) {
|
|
804
|
+
findings.push({ code: 'DD4_OPTIONAL_BECAME_REQUIRED', severity: 'warning', message: 'Optional field ' + path + ' became required in AFTER', evidence: { beforeField: path, afterField: mapped } });
|
|
805
|
+
}
|
|
806
|
+
if (field.nullable !== false && afterField.nullable === false && !migrationFrom.has(path)) {
|
|
807
|
+
findings.push({ code: 'DD4_NULLABLE_BECAME_NOT_NULL', severity: 'warning', message: 'Nullable field ' + path + ' became non-nullable in AFTER', evidence: { beforeField: path, afterField: mapped } });
|
|
808
|
+
}
|
|
809
|
+
if (field.type !== afterField.type && !migrationFrom.has(path)) {
|
|
810
|
+
findings.push({ code: 'DD4_TYPE_CHANGED', severity: 'warning', message: 'Field type changed from ' + field.type + ' to ' + afterField.type + ' for ' + path, evidence: { beforeField: path, beforeType: field.type, afterType: afterField.type } });
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return checkResult('DD4', 'Breaking Change Regression', findings, findings.length === 0 ? 'No new breaking changes detected' : 'Breaking change findings: ' + findings.length);
|
|
815
|
+
}
|
|
816
|
+
function buildDataComparisonSummary(before, after, options) {
|
|
817
|
+
const afterEntities = new Set(after.entities.map((entity) => entity.name));
|
|
818
|
+
const beforeEntities = new Set(before.entities.map((entity) => entity.name));
|
|
819
|
+
const addedEntities = after.entities.filter((entity) => !beforeEntities.has(entity.name)).map((entity) => entity.name).sort();
|
|
820
|
+
const removedEntities = before.entities.filter((entity) => !afterEntities.has(entity.name)).map((entity) => entity.name).sort();
|
|
821
|
+
const mapping = options.fieldMapping ?? {};
|
|
822
|
+
const beforePaths = allFieldPaths(before);
|
|
823
|
+
const afterPaths = allFieldPaths(after);
|
|
824
|
+
const afterPathSet = new Set(afterPaths);
|
|
825
|
+
const removedFields = beforePaths.filter((path) => !afterPathSet.has(mapFieldPath(mapping, path))).sort();
|
|
826
|
+
const addedFields = afterPaths.filter((path) => !beforePaths.some((beforePath) => mapFieldPath(mapping, beforePath) === path)).sort();
|
|
827
|
+
const beforeRelationships = (before.relationships ?? []).map((relationship) => relationship.name ?? relationship.fromEntity + '.' + relationship.fromField + '->' + relationship.toEntity);
|
|
828
|
+
const afterRelationships = (after.relationships ?? []).map((relationship) => relationship.name ?? relationship.fromEntity + '.' + relationship.fromField + '->' + relationship.toEntity);
|
|
829
|
+
const removedRelationships = beforeRelationships.filter((name) => !afterRelationships.includes(name)).sort();
|
|
830
|
+
const addedRelationships = afterRelationships.filter((name) => !beforeRelationships.includes(name)).sort();
|
|
831
|
+
return {
|
|
832
|
+
beforeModelHash: dataModelHash(before),
|
|
833
|
+
afterModelHash: dataModelHash(after),
|
|
834
|
+
fieldMapping: mapping,
|
|
835
|
+
beforeEntities: before.entities.length,
|
|
836
|
+
beforeFields: beforePaths.length,
|
|
837
|
+
afterEntities: after.entities.length,
|
|
838
|
+
afterFields: afterPaths.length,
|
|
839
|
+
addedEntities,
|
|
840
|
+
removedEntities,
|
|
841
|
+
addedFields,
|
|
842
|
+
removedFields,
|
|
843
|
+
addedRelationships,
|
|
844
|
+
removedRelationships,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
export function runDataVerification(input, options = {}) {
|
|
848
|
+
const validation = validateDataModel(input);
|
|
849
|
+
if (!validation.ok) {
|
|
850
|
+
return {
|
|
851
|
+
ok: false,
|
|
852
|
+
schemaVersion: 1,
|
|
853
|
+
modelHash: '',
|
|
854
|
+
summary: { entities: 0, fields: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
|
|
855
|
+
checks: [{
|
|
856
|
+
id: 'DATA_MODEL',
|
|
857
|
+
name: 'Data Model Validation',
|
|
858
|
+
status: 'fail',
|
|
859
|
+
detail: 'Data model schema validation failed: ' + validation.errors.length + ' errors',
|
|
860
|
+
findings: validation.errors.map((message) => ({ code: 'DATA_MODEL_INVALID', severity: 'error', message })),
|
|
861
|
+
}],
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
const model = validation.model;
|
|
865
|
+
const checks = [
|
|
866
|
+
DS1_schemaWellFormed(model),
|
|
867
|
+
DS2_requiredCompleteness(model, options),
|
|
868
|
+
DS3_relationshipIntegrity(model),
|
|
869
|
+
DS4_typeNullabilityConsistency(model),
|
|
870
|
+
DA1_nullEmptyInjection(model),
|
|
871
|
+
DA2_boundaryBlast(model),
|
|
872
|
+
DA3_uniquenessViolation(model),
|
|
873
|
+
DA4_referentialIntegrityViolation(model),
|
|
874
|
+
DA5_migrationCoverage(model, options),
|
|
875
|
+
DA6_copyConsistency(model, options),
|
|
876
|
+
DA7_rollbackBackupSymmetry(model, options),
|
|
877
|
+
DA8_idempotentConstraints(model, options),
|
|
878
|
+
DA9_dataMonotonic(model, options),
|
|
879
|
+
DA10_dataSequence(model, options),
|
|
880
|
+
DA11_dataLeadsTo(model, options),
|
|
881
|
+
DA12_dataAtomicity(model, options),
|
|
882
|
+
];
|
|
883
|
+
let comparison;
|
|
884
|
+
if (options.beforeModel !== undefined) {
|
|
885
|
+
const beforeValidation = validateDataModel(options.beforeModel);
|
|
886
|
+
if (!beforeValidation.ok) {
|
|
887
|
+
return {
|
|
888
|
+
ok: false,
|
|
889
|
+
schemaVersion: 1,
|
|
890
|
+
modelHash: dataModelHash(model),
|
|
891
|
+
summary: {
|
|
892
|
+
entities: model.entities.length,
|
|
893
|
+
fields: allFieldPaths(model).length,
|
|
894
|
+
errors: beforeValidation.errors.length,
|
|
895
|
+
warnings: 0,
|
|
896
|
+
checksRun: checks.length + 1,
|
|
897
|
+
},
|
|
898
|
+
checks: [
|
|
899
|
+
...checks,
|
|
900
|
+
{
|
|
901
|
+
id: 'BEFORE_DATA_MODEL',
|
|
902
|
+
name: 'Before Data Model Validation',
|
|
903
|
+
status: 'fail',
|
|
904
|
+
detail: 'Before data model schema validation failed: ' + beforeValidation.errors.length + ' errors',
|
|
905
|
+
findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_DATA_MODEL_INVALID', severity: 'error', message })),
|
|
906
|
+
},
|
|
907
|
+
],
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
const before = beforeValidation.model;
|
|
911
|
+
checks.push(DD1_dataBehaviorPreservation(before, model, options), DD2_dataInvariantContinuity(before, model, options), DD3_deltaSummary(before, model, options), DD4_breakingChangeRegression(before, model, options));
|
|
912
|
+
comparison = buildDataComparisonSummary(before, model, options);
|
|
913
|
+
}
|
|
914
|
+
const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0);
|
|
915
|
+
const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0);
|
|
916
|
+
return {
|
|
917
|
+
ok: true,
|
|
918
|
+
schemaVersion: 1,
|
|
919
|
+
modelHash: dataModelHash(model),
|
|
920
|
+
summary: {
|
|
921
|
+
entities: model.entities.length,
|
|
922
|
+
fields: allFieldPaths(model).length,
|
|
923
|
+
errors,
|
|
924
|
+
warnings,
|
|
925
|
+
checksRun: checks.length,
|
|
926
|
+
},
|
|
927
|
+
checks,
|
|
928
|
+
...(comparison === undefined ? {} : { comparison }),
|
|
929
|
+
};
|
|
930
|
+
}
|