@crewhaus/tool-schema 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Row-shaped validation: the same checks as the single-value path, but
3
+ * reported the way a data-quality gate needs them — per row, with a summary
4
+ * that a pipeline can branch on without reading the detail.
5
+ *
6
+ * The three questions this file answers are the three that stop a bad batch:
7
+ * does every row match the schema, is the key actually unique, and does every
8
+ * foreign key point at something that exists.
9
+ */
10
+ import { type Schema, type ValidationError } from "./jsonschema";
11
+ export type RowFailure = {
12
+ /** 0-based index of the row in the input array. */
13
+ row: number;
14
+ /** The row's value at `idField`, when one was given — for a human reading the report. */
15
+ id: string | null;
16
+ errors: ValidationError[];
17
+ };
18
+ export type RecordsReport = {
19
+ ok: boolean;
20
+ total: number;
21
+ passed: number;
22
+ failed: number;
23
+ /** Failures, capped at `maxFailedRows`. */
24
+ failures: RowFailure[];
25
+ /** True when the failure list was cut short; `failed` is still the true count. */
26
+ truncated: boolean;
27
+ /** How many rows failed on each keyword+path pair, worst first. */
28
+ topIssues: Array<{
29
+ path: string;
30
+ keyword: string;
31
+ rows: number;
32
+ example: string;
33
+ }>;
34
+ unsupportedKeywords: string[];
35
+ };
36
+ export type ValidateRecordsOptions = {
37
+ assertFormat: boolean;
38
+ /** Errors kept per row. */
39
+ maxErrorsPerRow: number;
40
+ /** Failing rows detailed in the report. */
41
+ maxFailedRows: number;
42
+ /** A field whose value identifies the row in the report. */
43
+ idField: string | null;
44
+ };
45
+ export declare const DEFAULT_RECORDS_OPTIONS: ValidateRecordsOptions;
46
+ /**
47
+ * Validate every row against one schema. The summary is the point: a caller
48
+ * gates on `ok` or on `failed`, and only reads `failures` when it has to.
49
+ * `topIssues` groups failures by what went wrong rather than by which row,
50
+ * which is how a broken feed is usually diagnosed — one field, every row.
51
+ */
52
+ export declare function validateRecords(rows: unknown[], schema: Schema, options?: Partial<ValidateRecordsOptions>): RecordsReport;
53
+ export type DuplicateGroup = {
54
+ key: string;
55
+ rows: number[];
56
+ };
57
+ export type DuplicateReport = {
58
+ ok: boolean;
59
+ total: number;
60
+ /** Rows skipped because they are not objects or lack one of the key fields. */
61
+ unkeyed: Array<{
62
+ row: number;
63
+ reason: string;
64
+ }>;
65
+ distinctKeys: number;
66
+ duplicates: DuplicateGroup[];
67
+ truncated: boolean;
68
+ };
69
+ /**
70
+ * Find rows that share a key. The key may be several fields, in which case
71
+ * their values are combined — the composite-key case a real uniqueness
72
+ * constraint usually is.
73
+ *
74
+ * Values are compared canonically, so `{a: 1, b: 2}` and `{b: 2, a: 1}` are
75
+ * the same key. With `caseInsensitive`, string values are lowercased first,
76
+ * which is what an email or username constraint normally means.
77
+ */
78
+ export declare function findDuplicates(rows: unknown[], keyFields: string[], options?: {
79
+ caseInsensitive?: boolean;
80
+ maxGroups?: number;
81
+ }): DuplicateReport;
82
+ export type ReferenceReport = {
83
+ ok: boolean;
84
+ total: number;
85
+ checked: number;
86
+ /** Rows whose reference resolves to nothing in the allowed set. */
87
+ dangling: Array<{
88
+ row: number;
89
+ value: string;
90
+ reason: string;
91
+ }>;
92
+ /** Allowed values no row referred to — usually fine, sometimes the real finding. */
93
+ unreferenced: string[];
94
+ truncated: boolean;
95
+ };
96
+ /**
97
+ * Check that every row's reference field points at a value that exists.
98
+ *
99
+ * The allowed set is given either directly as values or as the values of a
100
+ * field in a parent array, which is the shape a join actually has. A row
101
+ * whose reference field is missing is reported unless `allowMissing` is set,
102
+ * because an absent foreign key and a broken one are different findings and
103
+ * the caller should say which one is acceptable.
104
+ */
105
+ export declare function checkReferences(rows: unknown[], field: string, allowed: unknown[], options?: {
106
+ allowMissing?: boolean;
107
+ reportUnreferenced?: boolean;
108
+ maxDangling?: number;
109
+ }): ReferenceReport;
110
+ /**
111
+ * Collect the distinct values of a field across rows, canonically. Used to
112
+ * build the allowed set for {@link checkReferences} from a parent table.
113
+ */
114
+ export declare function collectFieldValues(rows: unknown[], field: string): unknown[];
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Row-shaped validation: the same checks as the single-value path, but
3
+ * reported the way a data-quality gate needs them — per row, with a summary
4
+ * that a pipeline can branch on without reading the detail.
5
+ *
6
+ * The three questions this file answers are the three that stop a bad batch:
7
+ * does every row match the schema, is the key actually unique, and does every
8
+ * foreign key point at something that exists.
9
+ */
10
+ import { validateValue } from "./jsonschema";
11
+ import { canonicalize, getPath, isPlainObject, preview, typeOf } from "./value";
12
+ /** Joins a path and a keyword into one map key; a character no path contains. */
13
+ const ISSUE_SEPARATOR = String.fromCharCode(0);
14
+ export const DEFAULT_RECORDS_OPTIONS = {
15
+ assertFormat: false,
16
+ maxErrorsPerRow: 10,
17
+ maxFailedRows: 50,
18
+ idField: null,
19
+ };
20
+ function rowId(row, idField) {
21
+ if (idField === null)
22
+ return null;
23
+ try {
24
+ const found = getPath(row, idField);
25
+ return found.found ? preview(found.value, 60) : null;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ /**
32
+ * Validate every row against one schema. The summary is the point: a caller
33
+ * gates on `ok` or on `failed`, and only reads `failures` when it has to.
34
+ * `topIssues` groups failures by what went wrong rather than by which row,
35
+ * which is how a broken feed is usually diagnosed — one field, every row.
36
+ */
37
+ export function validateRecords(rows, schema, options = {}) {
38
+ const opts = { ...DEFAULT_RECORDS_OPTIONS, ...options };
39
+ const failures = [];
40
+ const issues = new Map();
41
+ const unsupported = new Set();
42
+ let failed = 0;
43
+ rows.forEach((row, index) => {
44
+ const result = validateValue(row, schema, {
45
+ assertFormat: opts.assertFormat,
46
+ maxErrors: opts.maxErrorsPerRow,
47
+ });
48
+ for (const keyword of result.unsupportedKeywords)
49
+ unsupported.add(keyword);
50
+ if (result.valid)
51
+ return;
52
+ failed += 1;
53
+ if (failures.length < opts.maxFailedRows) {
54
+ failures.push({ row: index, id: rowId(row, opts.idField), errors: result.errors });
55
+ }
56
+ // Count each (path, keyword) pair once per row, not once per error.
57
+ const seen = new Set();
58
+ for (const error of result.errors) {
59
+ const key = `${error.path}${ISSUE_SEPARATOR}${error.keyword}`;
60
+ if (seen.has(key))
61
+ continue;
62
+ seen.add(key);
63
+ const existing = issues.get(key);
64
+ if (existing === undefined) {
65
+ issues.set(key, {
66
+ path: error.path,
67
+ keyword: error.keyword,
68
+ rows: 1,
69
+ example: error.message,
70
+ });
71
+ }
72
+ else {
73
+ existing.rows += 1;
74
+ }
75
+ }
76
+ });
77
+ // Ordered by code unit rather than with `localeCompare`, which reads the
78
+ // runtime's default locale — an environment read, and the one thing that
79
+ // could make this package answer differently on two machines.
80
+ const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
81
+ const topIssues = [...issues.values()].sort((a, b) => b.rows - a.rows || byCodeUnit(a.path, b.path) || byCodeUnit(a.keyword, b.keyword));
82
+ return {
83
+ ok: failed === 0,
84
+ total: rows.length,
85
+ passed: rows.length - failed,
86
+ failed,
87
+ failures,
88
+ truncated: failed > failures.length,
89
+ topIssues,
90
+ unsupportedKeywords: [...unsupported].sort(),
91
+ };
92
+ }
93
+ /**
94
+ * Find rows that share a key. The key may be several fields, in which case
95
+ * their values are combined — the composite-key case a real uniqueness
96
+ * constraint usually is.
97
+ *
98
+ * Values are compared canonically, so `{a: 1, b: 2}` and `{b: 2, a: 1}` are
99
+ * the same key. With `caseInsensitive`, string values are lowercased first,
100
+ * which is what an email or username constraint normally means.
101
+ */
102
+ export function findDuplicates(rows, keyFields, options = {}) {
103
+ const caseInsensitive = options.caseInsensitive ?? false;
104
+ const maxGroups = options.maxGroups ?? 50;
105
+ const buckets = new Map();
106
+ const unkeyed = [];
107
+ rows.forEach((row, index) => {
108
+ if (!isPlainObject(row)) {
109
+ unkeyed.push({ row: index, reason: `row is a ${typeOf(row)}, not an object` });
110
+ return;
111
+ }
112
+ const parts = [];
113
+ for (const field of keyFields) {
114
+ let resolution;
115
+ try {
116
+ resolution = getPath(row, field);
117
+ }
118
+ catch (err) {
119
+ unkeyed.push({ row: index, reason: `bad path "${field}": ${err.message}` });
120
+ return;
121
+ }
122
+ if (!resolution.found) {
123
+ unkeyed.push({ row: index, reason: `no value at "${field}"` });
124
+ return;
125
+ }
126
+ const value = resolution.value;
127
+ parts.push(caseInsensitive && typeof value === "string" ? value.toLowerCase() : value);
128
+ }
129
+ const key = canonicalize(parts);
130
+ const bucket = buckets.get(key);
131
+ if (bucket === undefined)
132
+ buckets.set(key, [index]);
133
+ else
134
+ bucket.push(index);
135
+ });
136
+ const all = [];
137
+ for (const [key, indices] of buckets) {
138
+ if (indices.length > 1)
139
+ all.push({ key, rows: indices });
140
+ }
141
+ all.sort((a, b) => b.rows.length - a.rows.length || (a.rows[0] ?? 0) - (b.rows[0] ?? 0));
142
+ return {
143
+ ok: all.length === 0 && unkeyed.length === 0,
144
+ total: rows.length,
145
+ unkeyed,
146
+ distinctKeys: buckets.size,
147
+ duplicates: all.slice(0, maxGroups),
148
+ truncated: all.length > maxGroups,
149
+ };
150
+ }
151
+ /**
152
+ * Check that every row's reference field points at a value that exists.
153
+ *
154
+ * The allowed set is given either directly as values or as the values of a
155
+ * field in a parent array, which is the shape a join actually has. A row
156
+ * whose reference field is missing is reported unless `allowMissing` is set,
157
+ * because an absent foreign key and a broken one are different findings and
158
+ * the caller should say which one is acceptable.
159
+ */
160
+ export function checkReferences(rows, field, allowed, options = {}) {
161
+ const allowMissing = options.allowMissing ?? false;
162
+ const maxDangling = options.maxDangling ?? 50;
163
+ // Keyed canonically, but the original value is kept so the report can show
164
+ // `"eu-west-1"` rather than its canonical encoding.
165
+ const allowedByKey = new Map();
166
+ for (const value of allowed)
167
+ allowedByKey.set(canonicalize(value), value);
168
+ const used = new Set();
169
+ const dangling = [];
170
+ let checked = 0;
171
+ rows.forEach((row, index) => {
172
+ let resolution;
173
+ try {
174
+ resolution = getPath(row, field);
175
+ }
176
+ catch (err) {
177
+ dangling.push({ row: index, value: "", reason: `bad path: ${err.message}` });
178
+ return;
179
+ }
180
+ if (!resolution.found || resolution.value === null) {
181
+ if (!allowMissing) {
182
+ dangling.push({ row: index, value: "", reason: `no value at "${field}"` });
183
+ }
184
+ return;
185
+ }
186
+ checked += 1;
187
+ const key = canonicalize(resolution.value);
188
+ if (allowedByKey.has(key)) {
189
+ used.add(key);
190
+ return;
191
+ }
192
+ dangling.push({
193
+ row: index,
194
+ value: preview(resolution.value, 60),
195
+ reason: "not present in the allowed set",
196
+ });
197
+ });
198
+ const unreferenced = options.reportUnreferenced === true
199
+ ? [...allowedByKey.entries()].filter(([k]) => !used.has(k)).map(([, v]) => preview(v, 60))
200
+ : [];
201
+ return {
202
+ ok: dangling.length === 0,
203
+ total: rows.length,
204
+ checked,
205
+ dangling: dangling.slice(0, maxDangling),
206
+ unreferenced,
207
+ truncated: dangling.length > maxDangling,
208
+ };
209
+ }
210
+ /**
211
+ * Collect the distinct values of a field across rows, canonically. Used to
212
+ * build the allowed set for {@link checkReferences} from a parent table.
213
+ */
214
+ export function collectFieldValues(rows, field) {
215
+ const seen = new Set();
216
+ const out = [];
217
+ for (const row of rows) {
218
+ let resolution;
219
+ try {
220
+ resolution = getPath(row, field);
221
+ }
222
+ catch {
223
+ continue;
224
+ }
225
+ if (!resolution.found)
226
+ continue;
227
+ const key = canonicalize(resolution.value);
228
+ if (seen.has(key))
229
+ continue;
230
+ seen.add(key);
231
+ out.push(resolution.value);
232
+ }
233
+ return out;
234
+ }
@@ -0,0 +1,26 @@
1
+ export type Compat = "compatible" | "breaking" | "unknown";
2
+ export type SchemaChange = {
3
+ /** JSON Pointer into the schema, e.g. `/properties/user/properties/id`. */
4
+ path: string;
5
+ keyword: string;
6
+ detail: string;
7
+ compat: Compat;
8
+ };
9
+ export type SchemaDiffResult = {
10
+ /** The two schemas are structurally the same document, key order aside. */
11
+ identical: boolean;
12
+ /** `compatible` only when nothing is breaking and nothing is undecided. */
13
+ verdict: Compat;
14
+ backwardCompatible: boolean;
15
+ changes: SchemaChange[];
16
+ counts: {
17
+ compatible: number;
18
+ breaking: number;
19
+ unknown: number;
20
+ };
21
+ };
22
+ /**
23
+ * Diff two schemas and classify each change. See the module comment for what
24
+ * "compatible" means here and for the cases deliberately left `unknown`.
25
+ */
26
+ export declare function diffSchemas(before: unknown, after: unknown): SchemaDiffResult;