@haystackeditor/cli 0.17.1 → 0.19.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,419 @@
1
+ /** FIXED CONTRACT — mirror of the PII-free database profile shapes for the
2
+ * published CLI, whose build cannot import repository files outside `src`.
3
+ * The authority is `infra/lambda/haystack-design-verifier-shared/onboarding_parity_contracts.ts`
4
+ * (frozen at 886d044106ebe41ba778b8392d927cd47cef826c, then format v2: the
5
+ * oddities' `jsonPath` and `types`, added to both together): `DatabaseProfileV1`,
6
+ * `TableProfileV1`, `ColumnProfileV1`, `ColumnKindV1`, `QuantilesV1`,
7
+ * `DatabaseProfileRulesV1`, `DatabaseProfileUploadRequestV1` and
8
+ * `DatabaseProfileUploadV1`, plus `ArtifactReference` from
9
+ * `fleet_bake_contracts.ts`. The shapes are restated field for field; the
10
+ * comments that carry the privacy rules are restated where the CLI enforces
11
+ * them.
12
+ *
13
+ * The parser below is strict: a field the format does not define is refused,
14
+ * because `haystack db profile show` renders exactly the defined fields and a
15
+ * profile must never carry something the customer was not shown. It also
16
+ * refuses an index definition that still holds a literal.
17
+ */
18
+ import { redactSqlLiterals, SqlLiteralError } from './db-profile-sql-literals.js';
19
+ export const COLUMN_KINDS = [
20
+ 'category', 'personal', 'free-text', 'identifier', 'number',
21
+ 'time', 'boolean', 'json', 'binary', 'other',
22
+ ];
23
+ export const ODDITY_KINDS = [
24
+ 'type-mismatch', 'oversize', 'invalid-encoding', 'unexpected-null', 'shape-outlier', 'missing-json-key',
25
+ ];
26
+ /** JSON value types, as jsonb_typeof names them. */
27
+ export const JSON_TYPE_NAMES = ['object', 'array', 'string', 'number', 'boolean', 'null'];
28
+ /** Type names an oddity's `types` may hold: the JSON types, plus 'iso-date'
29
+ * and 'text' for text that is mostly numbers or ISO dates, and 'finite' and
30
+ * 'nan-or-infinity' for a number column holding NaN or infinity. */
31
+ export const ODDITY_TYPE_NAMES = [...JSON_TYPE_NAMES, 'iso-date', 'text', 'finite', 'nan-or-infinity'];
32
+ /** v2 added the oddities' `jsonPath` and `types`. */
33
+ export const DATABASE_PROFILE_VERSION = 'dv-database-profile-v2';
34
+ /** No published CLI wrote v1, so a v1 file is refused rather than migrated. */
35
+ export const OLDER_PROFILE_MESSAGE = 'this profile was written by an older haystack CLI; run haystack db profile again';
36
+ export const DATABASE_PROFILE_UPLOAD_REQUEST_VERSION = 'dv-database-profile-upload-request-v1';
37
+ export const DATABASE_PROFILE_UPLOAD_VERSION = 'dv-database-profile-upload-v1';
38
+ export class DatabaseProfileFormatError extends Error {
39
+ constructor(message) {
40
+ super(message);
41
+ this.name = 'DatabaseProfileFormatError';
42
+ }
43
+ }
44
+ /* ------------------------------------------------------------ strict parse */
45
+ function fail(message) {
46
+ throw new DatabaseProfileFormatError(message);
47
+ }
48
+ function record(value, label, keys) {
49
+ if (!value || typeof value !== 'object' || Array.isArray(value))
50
+ fail(`${label} must be an object.`);
51
+ const fields = value;
52
+ for (const key of Object.keys(fields)) {
53
+ if (!keys.includes(key)) {
54
+ fail(`${label} has a field the profile format does not define ("${key}"); remove it so nothing is sent that \`haystack db profile show\` does not display.`);
55
+ }
56
+ }
57
+ for (const key of keys) {
58
+ if (!(key in fields))
59
+ fail(`${label} is missing "${key}".`);
60
+ }
61
+ return fields;
62
+ }
63
+ function text(value, label) {
64
+ if (typeof value !== 'string')
65
+ fail(`${label} must be a string.`);
66
+ return value;
67
+ }
68
+ function nonEmptyText(value, label) {
69
+ const parsed = text(value, label);
70
+ if (parsed.length === 0)
71
+ fail(`${label} must not be empty.`);
72
+ return parsed;
73
+ }
74
+ function bool(value, label) {
75
+ if (typeof value !== 'boolean')
76
+ fail(`${label} must be true or false.`);
77
+ return value;
78
+ }
79
+ function finite(value, label) {
80
+ if (typeof value !== 'number' || !Number.isFinite(value))
81
+ fail(`${label} must be a finite number.`);
82
+ return value;
83
+ }
84
+ function count(value, label) {
85
+ const parsed = finite(value, label);
86
+ if (!Number.isInteger(parsed) || parsed < 0)
87
+ fail(`${label} must be a non-negative integer.`);
88
+ return parsed;
89
+ }
90
+ function positiveInteger(value, label) {
91
+ const parsed = count(value, label);
92
+ if (parsed < 1)
93
+ fail(`${label} must be at least 1.`);
94
+ return parsed;
95
+ }
96
+ function fraction(value, label) {
97
+ const parsed = finite(value, label);
98
+ if (parsed < 0 || parsed > 1)
99
+ fail(`${label} must be between 0 and 1.`);
100
+ return parsed;
101
+ }
102
+ function nonNegative(value, label) {
103
+ const parsed = finite(value, label);
104
+ if (parsed < 0)
105
+ fail(`${label} must not be negative.`);
106
+ return parsed;
107
+ }
108
+ function list(value, label) {
109
+ if (!Array.isArray(value))
110
+ fail(`${label} must be an array.`);
111
+ return value;
112
+ }
113
+ function oneOf(value, label, allowed) {
114
+ if (typeof value !== 'string' || !allowed.includes(value)) {
115
+ fail(`${label} must be one of ${allowed.join(', ')}.`);
116
+ }
117
+ return value;
118
+ }
119
+ function isoTime(value, label) {
120
+ const parsed = nonEmptyText(value, label);
121
+ if (Number.isNaN(Date.parse(parsed)))
122
+ fail(`${label} must be an ISO-8601 time.`);
123
+ return parsed;
124
+ }
125
+ export function parseQuantiles(value, label) {
126
+ // Exactly p5..p95: a profile that carries min, max or any other extreme is refused.
127
+ const fields = record(value, label, ['p5', 'p25', 'p50', 'p75', 'p95']);
128
+ const quantiles = {
129
+ p5: finite(fields.p5, `${label}.p5`),
130
+ p25: finite(fields.p25, `${label}.p25`),
131
+ p50: finite(fields.p50, `${label}.p50`),
132
+ p75: finite(fields.p75, `${label}.p75`),
133
+ p95: finite(fields.p95, `${label}.p95`),
134
+ };
135
+ const ordered = [quantiles.p5, quantiles.p25, quantiles.p50, quantiles.p75, quantiles.p95];
136
+ for (let index = 1; index < ordered.length; index += 1) {
137
+ if (ordered[index] < ordered[index - 1])
138
+ fail(`${label} must be non-decreasing from p5 to p95.`);
139
+ }
140
+ return quantiles;
141
+ }
142
+ function parseRules(value, label) {
143
+ const fields = record(value, label, ['categoryMaxDistinct', 'categoryMinOwners', 'ownerCounting']);
144
+ const owner = fields.ownerCounting;
145
+ if (!owner || typeof owner !== 'object' || Array.isArray(owner))
146
+ fail(`${label}.ownerCounting must be an object.`);
147
+ const kind = owner.kind;
148
+ let ownerCounting;
149
+ if (kind === 'owner-table') {
150
+ const table = record(owner, `${label}.ownerCounting`, ['kind', 'schema', 'table']);
151
+ ownerCounting = {
152
+ kind: 'owner-table',
153
+ schema: nonEmptyText(table.schema, `${label}.ownerCounting.schema`),
154
+ table: nonEmptyText(table.table, `${label}.ownerCounting.table`),
155
+ };
156
+ }
157
+ else if (kind === 'rows') {
158
+ const rows = record(owner, `${label}.ownerCounting`, ['kind', 'reason']);
159
+ ownerCounting = { kind: 'rows', reason: nonEmptyText(rows.reason, `${label}.ownerCounting.reason`) };
160
+ }
161
+ else {
162
+ fail(`${label}.ownerCounting.kind must be owner-table or rows.`);
163
+ }
164
+ return {
165
+ categoryMaxDistinct: positiveInteger(fields.categoryMaxDistinct, `${label}.categoryMaxDistinct`),
166
+ categoryMinOwners: positiveInteger(fields.categoryMinOwners, `${label}.categoryMinOwners`),
167
+ ownerCounting,
168
+ };
169
+ }
170
+ function parseColumn(value, label) {
171
+ const fields = record(value, label, [
172
+ 'name', 'dataType', 'notNull', 'nullFraction', 'distinctEstimate', 'avgWidthBytes', 'kind',
173
+ 'personalReason', 'categories', 'lengthQuantiles', 'numberQuantiles', 'booleanTrueFraction', 'shapes', 'jsonKeys', 'oddities',
174
+ ]);
175
+ const kind = oneOf(fields.kind, `${label}.kind`, COLUMN_KINDS);
176
+ const personal = kind === 'personal' || kind === 'free-text';
177
+ const personalReason = fields.personalReason === null ? null : nonEmptyText(fields.personalReason, `${label}.personalReason`);
178
+ if (personal && personalReason === null)
179
+ fail(`${label} is ${kind} but has no personalReason.`);
180
+ if (!personal && personalReason !== null)
181
+ fail(`${label} has a personalReason but its kind is ${kind}.`);
182
+ let categories = null;
183
+ if (fields.categories !== null) {
184
+ if (kind !== 'category')
185
+ fail(`${label} carries category values but its kind is ${kind}; values leave only for category columns.`);
186
+ categories = list(fields.categories, `${label}.categories`).map((item, index) => {
187
+ const entry = record(item, `${label}.categories[${index}]`, ['value', 'fraction', 'source']);
188
+ return {
189
+ value: text(entry.value, `${label}.categories[${index}].value`),
190
+ fraction: fraction(entry.fraction, `${label}.categories[${index}].fraction`),
191
+ source: oneOf(entry.source, `${label}.categories[${index}].source`, ['schema', 'data']),
192
+ };
193
+ });
194
+ }
195
+ else if (kind === 'category') {
196
+ fail(`${label} is a category column, so categories must be an array (empty when no value may leave).`);
197
+ }
198
+ let booleanTrueFraction = null;
199
+ if (fields.booleanTrueFraction !== null) {
200
+ if (kind !== 'boolean')
201
+ fail(`${label} carries booleanTrueFraction but its kind is ${kind}; it is only for boolean columns.`);
202
+ booleanTrueFraction = fraction(fields.booleanTrueFraction, `${label}.booleanTrueFraction`);
203
+ }
204
+ const jsonKeys = list(fields.jsonKeys, `${label}.jsonKeys`).map((item, index) => {
205
+ const entry = record(item, `${label}.jsonKeys[${index}]`, ['path', 'types', 'presentFraction']);
206
+ return {
207
+ path: nonEmptyText(entry.path, `${label}.jsonKeys[${index}].path`),
208
+ types: list(entry.types, `${label}.jsonKeys[${index}].types`)
209
+ .map((type, typeIndex) => nonEmptyText(type, `${label}.jsonKeys[${index}].types[${typeIndex}]`)),
210
+ presentFraction: fraction(entry.presentFraction, `${label}.jsonKeys[${index}].presentFraction`),
211
+ };
212
+ });
213
+ const keyPaths = new Set(jsonKeys.map(key => key.path));
214
+ return {
215
+ name: nonEmptyText(fields.name, `${label}.name`),
216
+ dataType: nonEmptyText(fields.dataType, `${label}.dataType`),
217
+ notNull: bool(fields.notNull, `${label}.notNull`),
218
+ nullFraction: fraction(fields.nullFraction, `${label}.nullFraction`),
219
+ distinctEstimate: nonNegative(fields.distinctEstimate, `${label}.distinctEstimate`),
220
+ avgWidthBytes: nonNegative(fields.avgWidthBytes, `${label}.avgWidthBytes`),
221
+ kind,
222
+ personalReason,
223
+ categories,
224
+ lengthQuantiles: fields.lengthQuantiles === null ? null : parseQuantiles(fields.lengthQuantiles, `${label}.lengthQuantiles`),
225
+ numberQuantiles: fields.numberQuantiles === null ? null : parseQuantiles(fields.numberQuantiles, `${label}.numberQuantiles`),
226
+ booleanTrueFraction,
227
+ shapes: list(fields.shapes, `${label}.shapes`).map((item, index) => {
228
+ const entry = record(item, `${label}.shapes[${index}]`, ['shape', 'fraction']);
229
+ return {
230
+ shape: text(entry.shape, `${label}.shapes[${index}].shape`),
231
+ fraction: fraction(entry.fraction, `${label}.shapes[${index}].fraction`),
232
+ };
233
+ }),
234
+ jsonKeys,
235
+ oddities: list(fields.oddities, `${label}.oddities`).map((item, index) => parseOddity(item, `${label}.oddities[${index}]`, keyPaths)),
236
+ };
237
+ }
238
+ /** One oddity: `jsonPath` must be one of the column's jsonKeys paths (so it
239
+ * passed the same owner and personal-key checks), set for a missing-json-key
240
+ * and never for oversize, invalid-encoding or shape-outlier; `types` is set
241
+ * exactly for a type-mismatch, with names from ODDITY_TYPE_NAMES. */
242
+ function parseOddity(value, label, keyPaths) {
243
+ const entry = record(value, label, ['kind', 'rowCount', 'jsonPath', 'types', 'description']);
244
+ const kind = oneOf(entry.kind, `${label}.kind`, ODDITY_KINDS);
245
+ let jsonPath = null;
246
+ if (entry.jsonPath !== null) {
247
+ jsonPath = nonEmptyText(entry.jsonPath, `${label}.jsonPath`);
248
+ if (!keyPaths.has(jsonPath))
249
+ fail(`${label}.jsonPath is not one of the column's jsonKeys paths; an oddity names only a key path the profile already shows.`);
250
+ if (kind !== 'type-mismatch' && kind !== 'unexpected-null' && kind !== 'missing-json-key')
251
+ fail(`${label} is ${kind}, which is about the whole value, so its jsonPath must be null.`);
252
+ }
253
+ else if (kind === 'missing-json-key') {
254
+ fail(`${label} is a missing-json-key, so its jsonPath must name the key.`);
255
+ }
256
+ let types = null;
257
+ if (entry.types !== null) {
258
+ if (kind !== 'type-mismatch')
259
+ fail(`${label} carries types but its kind is ${kind}; types are only for type-mismatch.`);
260
+ const pair = record(entry.types, `${label}.types`, ['expected', 'found']);
261
+ const found = list(pair.found, `${label}.types.found`).map((name, index) => oneOf(name, `${label}.types.found[${index}]`, ODDITY_TYPE_NAMES));
262
+ if (found.length === 0)
263
+ fail(`${label}.types.found must name at least one type.`);
264
+ types = { expected: oneOf(pair.expected, `${label}.types.expected`, ODDITY_TYPE_NAMES), found };
265
+ }
266
+ else if (kind === 'type-mismatch') {
267
+ fail(`${label} is a type-mismatch, so types must give the expected and found types.`);
268
+ }
269
+ return {
270
+ kind,
271
+ rowCount: count(entry.rowCount, `${label}.rowCount`),
272
+ jsonPath,
273
+ types,
274
+ description: nonEmptyText(entry.description, `${label}.description`),
275
+ };
276
+ }
277
+ function tagged(value, label) {
278
+ if (!value || typeof value !== 'object' || Array.isArray(value))
279
+ fail(`${label} must be an object.`);
280
+ const fields = value;
281
+ return { fields, status: fields.status };
282
+ }
283
+ function parseSampling(value, label) {
284
+ const { status } = tagged(value, label);
285
+ if (status === 'full-scan') {
286
+ record(value, label, ['status']);
287
+ return { status: 'full-scan' };
288
+ }
289
+ if (status === 'sampled') {
290
+ const fields = record(value, label, ['status', 'percent', 'seed']);
291
+ const percent = finite(fields.percent, `${label}.percent`);
292
+ if (!(percent > 0 && percent < 100))
293
+ fail(`${label}.percent must be above 0 and below 100.`);
294
+ return { status: 'sampled', percent, seed: count(fields.seed, `${label}.seed`) };
295
+ }
296
+ return fail(`${label}.status must be full-scan or sampled.`);
297
+ }
298
+ function parseDeadRows(value, label) {
299
+ const { status } = tagged(value, label);
300
+ if (status === 'measured') {
301
+ const fields = record(value, label, ['status', 'fraction']);
302
+ return { status: 'measured', fraction: fraction(fields.fraction, `${label}.fraction`) };
303
+ }
304
+ if (status === 'unavailable') {
305
+ const fields = record(value, label, ['status', 'reason']);
306
+ return { status: 'unavailable', reason: nonEmptyText(fields.reason, `${label}.reason`) };
307
+ }
308
+ return fail(`${label}.status must be measured or unavailable.`);
309
+ }
310
+ function parseIndexDefinition(value, label) {
311
+ const definition = nonEmptyText(value, label);
312
+ let redacted;
313
+ try {
314
+ redacted = redactSqlLiterals(definition);
315
+ }
316
+ catch (error) {
317
+ if (error instanceof SqlLiteralError)
318
+ fail(`${label} cannot be checked for literal values: ${error.message}.`);
319
+ throw error;
320
+ }
321
+ if (redacted !== definition)
322
+ fail(`${label} still contains a literal value; every literal in an index definition must be replaced with ?.`);
323
+ return definition;
324
+ }
325
+ function parseTable(value, label) {
326
+ const fields = record(value, label, [
327
+ 'schema', 'name', 'rowCount', 'rowCountSource', 'sampling', 'tableBytes', 'indexBytes', 'toastBytes', 'deadRows',
328
+ 'columns', 'foreignKeys', 'indexes',
329
+ ]);
330
+ const schema = nonEmptyText(fields.schema, `${label}.schema`);
331
+ const name = nonEmptyText(fields.name, `${label}.name`);
332
+ const at = `${label} (${schema}.${name})`;
333
+ return {
334
+ schema,
335
+ name,
336
+ rowCount: count(fields.rowCount, `${at}.rowCount`),
337
+ rowCountSource: oneOf(fields.rowCountSource, `${at}.rowCountSource`, ['exact', 'estimate']),
338
+ sampling: parseSampling(fields.sampling, `${at}.sampling`),
339
+ tableBytes: count(fields.tableBytes, `${at}.tableBytes`),
340
+ indexBytes: count(fields.indexBytes, `${at}.indexBytes`),
341
+ toastBytes: count(fields.toastBytes, `${at}.toastBytes`),
342
+ deadRows: parseDeadRows(fields.deadRows, `${at}.deadRows`),
343
+ columns: list(fields.columns, `${at}.columns`).map((item, index) => parseColumn(item, `${at}.columns[${index}]`)),
344
+ foreignKeys: list(fields.foreignKeys, `${at}.foreignKeys`).map((item, index) => {
345
+ const key = `${at}.foreignKeys[${index}]`;
346
+ const entry = record(item, key, ['name', 'columns', 'references', 'childrenPerParent', 'orphanFraction']);
347
+ const references = record(entry.references, `${key}.references`, ['schema', 'table', 'columns']);
348
+ return {
349
+ name: nonEmptyText(entry.name, `${key}.name`),
350
+ columns: list(entry.columns, `${key}.columns`).map((column, columnIndex) => nonEmptyText(column, `${key}.columns[${columnIndex}]`)),
351
+ references: {
352
+ schema: nonEmptyText(references.schema, `${key}.references.schema`),
353
+ table: nonEmptyText(references.table, `${key}.references.table`),
354
+ columns: list(references.columns, `${key}.references.columns`)
355
+ .map((column, columnIndex) => nonEmptyText(column, `${key}.references.columns[${columnIndex}]`)),
356
+ },
357
+ childrenPerParent: parseQuantiles(entry.childrenPerParent, `${key}.childrenPerParent`),
358
+ orphanFraction: fraction(entry.orphanFraction, `${key}.orphanFraction`),
359
+ };
360
+ }),
361
+ indexes: list(fields.indexes, `${at}.indexes`).map((item, index) => {
362
+ const entry = record(item, `${at}.indexes[${index}]`, ['name', 'definition']);
363
+ return {
364
+ name: nonEmptyText(entry.name, `${at}.indexes[${index}].name`),
365
+ definition: parseIndexDefinition(entry.definition, `${at}.indexes[${index}].definition`),
366
+ };
367
+ }),
368
+ };
369
+ }
370
+ /** Strictly parse a `DatabaseProfileV1`. */
371
+ export function parseDatabaseProfile(value, label = 'profile') {
372
+ const fields = record(value, label, [
373
+ 'version', 'profilerVersion', 'createdAt', 'engine', 'engineVersion', 'settings', 'rules', 'tables',
374
+ ]);
375
+ if (fields.version === 'dv-database-profile-v1')
376
+ fail(`${label}: ${OLDER_PROFILE_MESSAGE}.`);
377
+ if (fields.version !== DATABASE_PROFILE_VERSION) {
378
+ fail(`${label}.version must be ${DATABASE_PROFILE_VERSION}; this file is not a Haystack database profile this CLI understands.`);
379
+ }
380
+ return {
381
+ version: DATABASE_PROFILE_VERSION,
382
+ profilerVersion: nonEmptyText(fields.profilerVersion, `${label}.profilerVersion`),
383
+ createdAt: isoTime(fields.createdAt, `${label}.createdAt`),
384
+ engine: oneOf(fields.engine, `${label}.engine`, ['postgres']),
385
+ engineVersion: nonEmptyText(fields.engineVersion, `${label}.engineVersion`),
386
+ settings: list(fields.settings, `${label}.settings`).map((item, index) => {
387
+ const entry = record(item, `${label}.settings[${index}]`, ['name', 'value']);
388
+ return {
389
+ name: nonEmptyText(entry.name, `${label}.settings[${index}].name`),
390
+ value: text(entry.value, `${label}.settings[${index}].value`),
391
+ };
392
+ }),
393
+ rules: parseRules(fields.rules, `${label}.rules`),
394
+ tables: list(fields.tables, `${label}.tables`).map((item, index) => parseTable(item, `${label}.tables[${index}]`)),
395
+ };
396
+ }
397
+ /** Strictly parse the server's `DatabaseProfileUploadV1` response. */
398
+ export function parseDatabaseProfileUpload(value, label = 'upload response') {
399
+ const fields = record(value, label, ['version', 'tenantId', 'repositoryId', 'dependencyId', 'profile', 'uploadedAt']);
400
+ if (fields.version !== DATABASE_PROFILE_UPLOAD_VERSION) {
401
+ fail(`${label}.version must be ${DATABASE_PROFILE_UPLOAD_VERSION}.`);
402
+ }
403
+ const artifact = record(fields.profile, `${label}.profile`, ['artifactId', 'uri', 'sha256']);
404
+ const sha256 = nonEmptyText(artifact.sha256, `${label}.profile.sha256`);
405
+ if (!/^[0-9a-f]{64}$/u.test(sha256))
406
+ fail(`${label}.profile.sha256 must be 64 lowercase hex characters.`);
407
+ return {
408
+ version: DATABASE_PROFILE_UPLOAD_VERSION,
409
+ tenantId: nonEmptyText(fields.tenantId, `${label}.tenantId`),
410
+ repositoryId: nonEmptyText(fields.repositoryId, `${label}.repositoryId`),
411
+ dependencyId: fields.dependencyId === null ? null : nonEmptyText(fields.dependencyId, `${label}.dependencyId`),
412
+ profile: {
413
+ artifactId: nonEmptyText(artifact.artifactId, `${label}.profile.artifactId`),
414
+ uri: nonEmptyText(artifact.uri, `${label}.profile.uri`),
415
+ sha256,
416
+ },
417
+ uploadedAt: isoTime(fields.uploadedAt, `${label}.uploadedAt`),
418
+ };
419
+ }