@haystackeditor/cli 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +208 -26
- package/dist/commands/case-batch-contract.js +2 -2
- package/dist/commands/case-batch.js +7 -17
- package/dist/commands/crawl-contract.js +3 -0
- package/dist/commands/db-profile-contract.js +419 -0
- package/dist/commands/db-profile-database.js +289 -0
- package/dist/commands/db-profile-distinct.js +97 -0
- package/dist/commands/db-profile-owners.js +94 -0
- package/dist/commands/db-profile-privacy.js +450 -0
- package/dist/commands/db-profile-show.js +218 -0
- package/dist/commands/db-profile-sql-literals.js +140 -0
- package/dist/commands/db-profile-sql.js +362 -0
- package/dist/commands/db-profile-upload.js +88 -0
- package/dist/commands/db-profile.js +1020 -0
- package/dist/commands/fleet-policy-contract.js +28 -0
- package/dist/commands/fleet-policy.js +305 -0
- package/dist/commands/precompute-delivery-worker.js +17 -3
- package/dist/commands/precompute-delivery.js +16 -6
- package/dist/commands/verify-hosted.js +20 -0
- package/dist/commands/verify-precompute.js +140 -95
- package/dist/commands/verify.js +523 -52
- package/dist/index.js +173 -21
- package/dist/schema.js +1 -0
- package/dist/triage/astra.js +5 -2
- package/dist/triage/runner.js +1 -1
- package/dist/utils/verify-base.js +31 -0
- package/package.json +4 -1
- package/schemas/verify.v1.json +217 -0
- package/dist/commands/combination-search-hook-contract.js +0 -1
- package/dist/commands/combination-search-hook.js +0 -139
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
export function typeFamily(base) {
|
|
2
|
+
if (base.typtype === 'e')
|
|
3
|
+
return 'enum';
|
|
4
|
+
if (base.typname === 'json' || base.typname === 'jsonb')
|
|
5
|
+
return 'json';
|
|
6
|
+
if (base.typname === 'bytea')
|
|
7
|
+
return 'binary';
|
|
8
|
+
if (base.typname === 'uuid')
|
|
9
|
+
return 'uuid';
|
|
10
|
+
if (['inet', 'cidr', 'macaddr', 'macaddr8'].includes(base.typname))
|
|
11
|
+
return 'network';
|
|
12
|
+
switch (base.typcategory) {
|
|
13
|
+
case 'S': return 'text';
|
|
14
|
+
case 'N': return 'number';
|
|
15
|
+
case 'D':
|
|
16
|
+
case 'T': return 'time';
|
|
17
|
+
case 'B': return 'boolean';
|
|
18
|
+
case 'I': return 'network';
|
|
19
|
+
default: return 'other';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/* ------------------------------------------------------------ column names */
|
|
23
|
+
/** Split `shippingAddress_line1` into `shipping`, `address`, `line`, `1`. */
|
|
24
|
+
export function columnNameTokens(name) {
|
|
25
|
+
return name
|
|
26
|
+
.replace(/([a-z0-9])([A-Z])/gu, '$1 $2')
|
|
27
|
+
.replace(/([A-Za-z])([0-9])/gu, '$1 $2')
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.split(/[^a-z0-9]+/u)
|
|
30
|
+
.filter(token => token.length > 0);
|
|
31
|
+
}
|
|
32
|
+
/** Ordered: the first rule that matches gives the reason. */
|
|
33
|
+
export const PERSONAL_NAME_RULES = [
|
|
34
|
+
{
|
|
35
|
+
kind: 'personal', what: 'a password, secret, token or payment credential',
|
|
36
|
+
substrings: ['password', 'passwd', 'passphrase', 'secret', 'apikey', 'privatekey', 'accesskey', 'credential',
|
|
37
|
+
'creditcard', 'cardnumber', 'accountnumber', 'routingnumber', 'sortcode', 'iban'],
|
|
38
|
+
tokens: ['token', 'tokens', 'pwd', 'pin', 'otp', 'salt', 'cvv', 'cvc', 'jwt', 'session', 'cookie'],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
kind: 'personal', what: 'a government identifier',
|
|
42
|
+
substrings: ['socialsecurity', 'passport', 'nationalid', 'nationalinsurance', 'taxid', 'taxnumber',
|
|
43
|
+
'driverslicense', 'driverlicense', 'driverslicence', 'driverlicence', 'licensenumber', 'licencenumber',
|
|
44
|
+
'identitycard', 'identitynumber', 'idcard'],
|
|
45
|
+
tokens: ['ssn', 'sin', 'nin', 'tin', 'aadhaar', 'cpf', 'nhs'],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
kind: 'personal', what: 'a date of birth',
|
|
49
|
+
substrings: ['birth'],
|
|
50
|
+
tokens: ['dob', 'birthday', 'bday', 'born'],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
kind: 'personal', what: 'an email address',
|
|
54
|
+
substrings: ['email'],
|
|
55
|
+
tokens: ['mail'],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
kind: 'personal', what: 'a phone number',
|
|
59
|
+
substrings: ['phone', 'mobile', 'msisdn', 'whatsapp'],
|
|
60
|
+
tokens: ['tel', 'fax', 'cell', 'sms'],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
kind: 'personal', what: 'a network address or device identifier',
|
|
64
|
+
substrings: ['ipaddress', 'ipaddr', 'macaddress', 'useragent', 'remoteaddr', 'hostname'],
|
|
65
|
+
tokens: ['ip', 'ipv4', 'ipv6', 'mac'],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
kind: 'personal', what: 'a postal address or location',
|
|
69
|
+
substrings: ['address', 'street', 'postalcode', 'postcode', 'zipcode', 'latitude', 'longitude', 'geolocation',
|
|
70
|
+
'coordinates', 'birthplace'],
|
|
71
|
+
tokens: ['addr', 'zip', 'postal', 'city', 'town', 'county', 'province', 'suburb', 'country', 'lat', 'lng', 'lon',
|
|
72
|
+
'geo', 'location'],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
kind: 'personal', what: "a person's name or handle",
|
|
76
|
+
substrings: ['firstname', 'lastname', 'fullname', 'givenname', 'familyname', 'middlename', 'maidenname',
|
|
77
|
+
'displayname', 'username', 'nickname', 'surname', 'forename'],
|
|
78
|
+
tokens: ['name', 'names', 'fname', 'lname', 'login', 'handle', 'initials', 'signature'],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
kind: 'free-text', what: 'free text',
|
|
82
|
+
substrings: ['description', 'biography', 'instruction', 'transcript'],
|
|
83
|
+
tokens: ['note', 'notes', 'comment', 'comments', 'desc', 'message', 'messages', 'msg', 'body', 'content', 'text',
|
|
84
|
+
'bio', 'about', 'summary', 'remark', 'remarks', 'feedback', 'memo', 'subject', 'title', 'caption', 'review',
|
|
85
|
+
'reply', 'answer', 'question', 'prompt', 'story'],
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
/** A name that starts like `is_`/`has_` or ends like `_at`/`_count`/`_type`
|
|
89
|
+
* describes the personal field rather than holding it (`email_verified_at`,
|
|
90
|
+
* `phone_type`, `has_notes`). The value-shape tests still run on these. */
|
|
91
|
+
const DESCRIBING_PREFIXES = ['is', 'has', 'can', 'should', 'allow', 'allows', 'uses', 'needs', 'wants'];
|
|
92
|
+
const DESCRIBING_SUFFIXES = ['at', 'verified', 'confirmed', 'count', 'type', 'kind', 'status', 'enabled', 'disabled',
|
|
93
|
+
'expires', 'expiry', 'changed', 'updated', 'sent', 'version', 'provider', 'template', 'length', 'size', 'format',
|
|
94
|
+
'locale', 'language', 'consent', 'optin'];
|
|
95
|
+
export function classifyColumnName(name) {
|
|
96
|
+
const tokens = columnNameTokens(name);
|
|
97
|
+
if (tokens.length === 0)
|
|
98
|
+
return null;
|
|
99
|
+
if (tokens.length > 1 && (DESCRIBING_PREFIXES.includes(tokens[0]) || DESCRIBING_SUFFIXES.includes(tokens[tokens.length - 1]))) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const compact = tokens.join('');
|
|
103
|
+
for (const rule of PERSONAL_NAME_RULES) {
|
|
104
|
+
const substring = rule.substrings.find(word => compact.includes(word));
|
|
105
|
+
const token = rule.tokens.find(word => tokens.includes(word));
|
|
106
|
+
const matched = substring ?? token;
|
|
107
|
+
if (matched !== undefined) {
|
|
108
|
+
return { kind: rule.kind, reason: `column name suggests ${rule.what} (matches "${matched}")` };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
export function classifyColumnType(family, dataType) {
|
|
114
|
+
if (family === 'network') {
|
|
115
|
+
return { kind: 'personal', reason: `column type ${dataType} holds network or device addresses` };
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
export const VALUE_SHAPE_TESTS = [
|
|
120
|
+
{
|
|
121
|
+
id: 'email', threshold: 0.05, what: 'email addresses',
|
|
122
|
+
predicate: v => `${v} ~ '^[^@[:space:]]+@[^@[:space:]]+\\.[^@[:space:]]+$'`,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: 'phone', threshold: 0.2, what: 'phone numbers',
|
|
126
|
+
predicate: v => `(${v} ~ '^\\+?[0-9 ().-]{7,20}$' AND ${v} ~ '([0-9][^0-9]*){7}')`,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 'network', threshold: 0.05, what: 'IP addresses',
|
|
130
|
+
predicate: v => `(${v} ~ '^[0-9]{1,3}(\\.[0-9]{1,3}){3}$' OR ${v} ~ '^[0-9A-Fa-f]{0,4}(:[0-9A-Fa-f]{0,4}){2,7}$')`,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: 'secret', threshold: 0.3, what: 'secrets, tokens or password hashes',
|
|
134
|
+
predicate: v => `((${v} ~ '^[A-Za-z0-9+/_=.$:-]{20,}$' AND ${v} ~ '[A-Z]' AND ${v} ~ '[a-z]' AND ${v} ~ '[0-9]')`
|
|
135
|
+
+ ` OR ${v} ~ '^[0-9A-Fa-f]{32,}$' OR ${v} ~ '^\\$(2[aby]|argon2|scrypt|pbkdf2)')`,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: 'government-id', threshold: 0.05, what: 'government identifiers (SSN shape)',
|
|
139
|
+
predicate: v => `${v} ~ '^[0-9]{3}-[0-9]{2}-[0-9]{4}$'`,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'card', threshold: 0.05, what: 'payment card numbers',
|
|
143
|
+
predicate: v => `(${v} ~ '^[0-9][0-9 -]{11,21}[0-9]$' AND ${v} ~ '([0-9][^0-9]*){13}')`,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: 'person-name', threshold: 0.5, what: "people's names (two or more capitalised words)",
|
|
147
|
+
predicate: v => `${v} ~ '^[[:upper:]][[:lower:]]+([ ''-][[:upper:]][[:lower:]]+)+$'`,
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
/**
|
|
151
|
+
* The shapes above except person-name (common category values such as "New
|
|
152
|
+
* York" have it) as single-value tests (the same patterns, in JavaScript),
|
|
153
|
+
* for values about to leave. A column is personal only when a threshold share
|
|
154
|
+
* of its values has a shape, so a category column can still hold a common
|
|
155
|
+
* value of one of these shapes ("2031-07-19" is phone-shaped, a shared API
|
|
156
|
+
* token is secret-shaped). Such a value never leaves, whatever its column's
|
|
157
|
+
* type (see `keptValueProblem`): the profiler drops it from the kept values
|
|
158
|
+
* and withholds any JSON key path with a key of such a shape, and the upload
|
|
159
|
+
* server refuses a profile that keeps one.
|
|
160
|
+
* Values are tested trimmed. Both sides use this one list.
|
|
161
|
+
*/
|
|
162
|
+
export const KEPT_VALUE_PERSONAL_SHAPES = [
|
|
163
|
+
{ id: 'email', what: 'an email address', test: value => /^[^@\s]+@[^@\s]+\.[^@\s]+$/u.test(value) },
|
|
164
|
+
{ id: 'phone', what: 'a phone number', test: value => /^\+?[0-9 ().-]{7,20}$/u.test(value) && /([0-9][^0-9]*){7}/u.test(value) },
|
|
165
|
+
{
|
|
166
|
+
id: 'network', what: 'an IP address',
|
|
167
|
+
test: value => /^[0-9]{1,3}(\.[0-9]{1,3}){3}$/u.test(value) || /^[0-9A-Fa-f]{0,4}(:[0-9A-Fa-f]{0,4}){2,7}$/u.test(value),
|
|
168
|
+
},
|
|
169
|
+
{ id: 'government-id', what: 'a government identifier', test: value => /^[0-9]{3}-[0-9]{2}-[0-9]{4}$/u.test(value) },
|
|
170
|
+
{ id: 'card', what: 'a payment card number', test: value => /^[0-9][0-9 -]{11,21}[0-9]$/u.test(value) && /([0-9][^0-9]*){13}/u.test(value) },
|
|
171
|
+
{
|
|
172
|
+
id: 'secret', what: 'a secret, token or password hash',
|
|
173
|
+
test: value => (/^[A-Za-z0-9+/_=.$:-]{20,}$/u.test(value) && /[A-Z]/u.test(value) && /[a-z]/u.test(value) && /[0-9]/u.test(value))
|
|
174
|
+
|| /^[0-9A-Fa-f]{32,}$/u.test(value) || /^\$(2[aby]|argon2|scrypt|pbkdf2)/u.test(value),
|
|
175
|
+
},
|
|
176
|
+
];
|
|
177
|
+
/** What personal shape a value about to leave has, or null. */
|
|
178
|
+
export function keptValuePersonalShape(value) {
|
|
179
|
+
const trimmed = value.trim();
|
|
180
|
+
return KEPT_VALUE_PERSONAL_SHAPES.find(shape => shape.test(trimmed))?.what ?? null;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The personal shape a JSON key path shows, if any: its whole text, or any run
|
|
184
|
+
* of consecutive dot-separated segments (with `[]` element markers dropped).
|
|
185
|
+
* A key may itself hold a dot, so the runs cover every key the path can have
|
|
186
|
+
* been made of, and also keys that only read as personal together
|
|
187
|
+
* (`stats.2024.01.15` holds `2024.01.15`, a phone number's shape). The
|
|
188
|
+
* profiler keeps no path this names, and the upload check refuses one, with
|
|
189
|
+
* this same function, so a freshly written profile always passes it.
|
|
190
|
+
*/
|
|
191
|
+
export function jsonPathPersonalShape(path) {
|
|
192
|
+
const whole = keptValuePersonalShape(path);
|
|
193
|
+
if (whole)
|
|
194
|
+
return whole;
|
|
195
|
+
const segments = path.split('.').map(segment => segment.replace(/(\[\])+$/u, ''));
|
|
196
|
+
for (let start = 0; start < segments.length; start += 1) {
|
|
197
|
+
for (let end = start + 1; end <= segments.length; end += 1) {
|
|
198
|
+
const shape = keptValuePersonalShape(segments.slice(start, end).join('.'));
|
|
199
|
+
if (shape)
|
|
200
|
+
return shape;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const DATE_TEXT = '[0-9]{4,}-[0-9]{2}-[0-9]{2}';
|
|
206
|
+
const TIME_TEXT = '[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?';
|
|
207
|
+
const ZONE_TEXT = '[+-][0-9]{2}(:[0-9]{2}){0,2}';
|
|
208
|
+
/**
|
|
209
|
+
* The declared types (as format_type prints them) whose data values may
|
|
210
|
+
* leave, and the text Postgres gives such a value (`value::text`, ISO
|
|
211
|
+
* DateStyle). A value that does not fit its column's declared type is not a
|
|
212
|
+
* value of that column (a file can say `integer` over an email address), and
|
|
213
|
+
* a data value of any other type (arrays, domains, enums, extension types)
|
|
214
|
+
* never leaves: enum labels leave as schema values.
|
|
215
|
+
*/
|
|
216
|
+
export const KEPT_VALUE_TYPES = [
|
|
217
|
+
{ dataType: /^(text|character varying|character|citext|name)(\([0-9]+\))?$/u, value: /^[\s\S]*$/u },
|
|
218
|
+
{ dataType: /^(smallint|integer|bigint)$/u, value: /^-?[0-9]+$/u },
|
|
219
|
+
{
|
|
220
|
+
dataType: /^(numeric(\([0-9]+(,[0-9]+)?\))?|real|double precision)$/u,
|
|
221
|
+
value: /^(-?([0-9]+(\.[0-9]*)?|\.[0-9]+)([eE][-+]?[0-9]+)?|NaN|-?Infinity)$/u,
|
|
222
|
+
},
|
|
223
|
+
{ dataType: /^boolean$/u, value: /^(true|false)$/u },
|
|
224
|
+
{ dataType: /^date$/u, value: new RegExp(`^(${DATE_TEXT}( BC)?|-?infinity)$`, 'u') },
|
|
225
|
+
{ dataType: /^time(\([0-6]\))? without time zone$/u, value: new RegExp(`^${TIME_TEXT}$`, 'u') },
|
|
226
|
+
{ dataType: /^time(\([0-6]\))? with time zone$/u, value: new RegExp(`^${TIME_TEXT}${ZONE_TEXT}$`, 'u') },
|
|
227
|
+
{
|
|
228
|
+
dataType: /^timestamp(\([0-6]\))? without time zone$/u,
|
|
229
|
+
value: new RegExp(`^(${DATE_TEXT} ${TIME_TEXT}( BC)?|-?infinity)$`, 'u'),
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
dataType: /^timestamp(\([0-6]\))? with time zone$/u,
|
|
233
|
+
value: new RegExp(`^(${DATE_TEXT} ${TIME_TEXT}${ZONE_TEXT}( BC)?|-?infinity)$`, 'u'),
|
|
234
|
+
},
|
|
235
|
+
];
|
|
236
|
+
/**
|
|
237
|
+
* Why a data value about to leave from a column of the declared `dataType`
|
|
238
|
+
* may not: it has a personal shape (whatever the type: a number of seven or
|
|
239
|
+
* more digits is phone-shaped), its column's type is not one whose data
|
|
240
|
+
* values may leave, or it does not fit that type. Null when it may leave.
|
|
241
|
+
* The profiler drops such values and the upload server refuses them, by this
|
|
242
|
+
* one rule. The reason never quotes the value.
|
|
243
|
+
*/
|
|
244
|
+
export function keptValueProblem(dataType, value) {
|
|
245
|
+
const shape = keptValuePersonalShape(value);
|
|
246
|
+
if (shape !== null)
|
|
247
|
+
return `looks like ${shape}`;
|
|
248
|
+
const type = KEPT_VALUE_TYPES.find(candidate => candidate.dataType.test(dataType));
|
|
249
|
+
if (type === undefined)
|
|
250
|
+
return `is data of type ${dataType}, whose values never leave`;
|
|
251
|
+
if (!type.value.test(value))
|
|
252
|
+
return `does not fit its column's type (${dataType})`;
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
/** Free text: many words per value. Measured as the average number of words
|
|
256
|
+
* (spaces + 1) and the share of values with four or more words. */
|
|
257
|
+
export const FREE_TEXT_AVERAGE_WORDS = 3.5;
|
|
258
|
+
export const FREE_TEXT_FOUR_WORD_SHARE = 0.25;
|
|
259
|
+
export function averageWordsSql(value) {
|
|
260
|
+
return `avg(length(btrim(${value})) - length(replace(btrim(${value}), ' ', '')) + 1)`;
|
|
261
|
+
}
|
|
262
|
+
export function fourWordsPredicate(value) {
|
|
263
|
+
return `${value} ~ '[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]'`;
|
|
264
|
+
}
|
|
265
|
+
export function classifyValueShapes(measured) {
|
|
266
|
+
if (measured.nonNull === 0)
|
|
267
|
+
return null;
|
|
268
|
+
for (const test of VALUE_SHAPE_TESTS) {
|
|
269
|
+
const share = measured.matches[test.id] / measured.nonNull;
|
|
270
|
+
if (share >= test.threshold) {
|
|
271
|
+
return { kind: 'personal', reason: `${formatShare(share)} of values look like ${test.what}` };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const fourWordShare = measured.fourWordValues / measured.nonNull;
|
|
275
|
+
if ((measured.averageWords !== null && measured.averageWords >= FREE_TEXT_AVERAGE_WORDS)
|
|
276
|
+
|| fourWordShare >= FREE_TEXT_FOUR_WORD_SHARE) {
|
|
277
|
+
const words = measured.averageWords === null ? 'unknown' : measured.averageWords.toFixed(1);
|
|
278
|
+
return {
|
|
279
|
+
kind: 'free-text',
|
|
280
|
+
reason: `values look like free text (${words} words on average; ${formatShare(fourWordShare)} have four or more words)`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
/** A share as a percentage that never rounds a partial share to 0% or 100%. */
|
|
286
|
+
export function formatShare(share) {
|
|
287
|
+
const percent = share * 100;
|
|
288
|
+
if (percent <= 0 || percent >= 100)
|
|
289
|
+
return `${Math.round(percent)}%`;
|
|
290
|
+
if (percent < 0.1)
|
|
291
|
+
return '<0.1%';
|
|
292
|
+
if (percent > 99.9)
|
|
293
|
+
return '>99.9%';
|
|
294
|
+
return `${percent.toFixed(percent >= 10 && percent <= 90 ? 0 : 1)}%`;
|
|
295
|
+
}
|
|
296
|
+
/* ------------------------------------------------------------ shapes */
|
|
297
|
+
/**
|
|
298
|
+
* Character-class shape of a text value, computed in SQL: uppercase letter
|
|
299
|
+
* runs become `A`, lowercase and uncased letter runs `a`, digit runs `9`,
|
|
300
|
+
* whitespace runs one space; ASCII punctuation is kept; anything else becomes
|
|
301
|
+
* `?`; then repeated characters collapse. `Zoë O'Neil-Smith 42` becomes
|
|
302
|
+
* `Aa? A'Aa-Aa 9` (or `Aa A'Aa-Aa 9` where the database locale knows ë).
|
|
303
|
+
*/
|
|
304
|
+
export function shapeSql(value) {
|
|
305
|
+
const classes = `regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(${value}, `
|
|
306
|
+
+ `'[[:upper:]]+', chr(1), 'g'), '[[:lower:]]+', chr(2), 'g'), '[[:alpha:]]+', chr(2), 'g'), `
|
|
307
|
+
+ `'[[:digit:]]+', chr(3), 'g'), '[[:space:]]+', ' ', 'g')`;
|
|
308
|
+
const named = `translate(${classes}, chr(1) || chr(2) || chr(3), 'Aa9')`;
|
|
309
|
+
const bounded = `regexp_replace(${named}, '[^Aa9 \\x21-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\x7e]', '?', 'g')`;
|
|
310
|
+
return `regexp_replace(${bounded}, '(.)\\1+', '\\1', 'g')`;
|
|
311
|
+
}
|
|
312
|
+
const IDENTIFIER_NAME = /(^id$|_id$|Id$|_uuid$|_guid$)/u;
|
|
313
|
+
export function classifyColumnFacts(facts) {
|
|
314
|
+
const personal = classifyColumnType(facts.family, facts.dataType)
|
|
315
|
+
?? classifyColumnName(facts.name)
|
|
316
|
+
?? (facts.valueShapes === null ? null : classifyValueShapes(facts.valueShapes));
|
|
317
|
+
if (personal !== null)
|
|
318
|
+
return { kind: personal.kind, personalReason: personal.reason };
|
|
319
|
+
if (facts.family === 'boolean')
|
|
320
|
+
return { kind: 'boolean', personalReason: null };
|
|
321
|
+
if (facts.family === 'json')
|
|
322
|
+
return { kind: 'json', personalReason: null };
|
|
323
|
+
if (facts.family === 'binary')
|
|
324
|
+
return { kind: 'binary', personalReason: null };
|
|
325
|
+
if (facts.family === 'uuid' || facts.isKey || IDENTIFIER_NAME.test(facts.name)) {
|
|
326
|
+
return { kind: 'identifier', personalReason: null };
|
|
327
|
+
}
|
|
328
|
+
if (facts.family === 'time')
|
|
329
|
+
return { kind: 'time', personalReason: null };
|
|
330
|
+
if (facts.schemaValues !== null)
|
|
331
|
+
return { kind: 'category', personalReason: null };
|
|
332
|
+
if (facts.family === 'text' || facts.family === 'number' || facts.family === 'enum') {
|
|
333
|
+
return { kind: 'distinct-decides', otherwise: facts.family === 'number' ? 'number' : 'other' };
|
|
334
|
+
}
|
|
335
|
+
return { kind: 'other', personalReason: null };
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Settle a column by its distinct count. `exactDistinct` must be the exact
|
|
339
|
+
* number of distinct non-null values in the whole table, never a planner or
|
|
340
|
+
* sample estimate: an underestimate would let a high-cardinality column keep
|
|
341
|
+
* values. A count known only to exceed `categoryMaxDistinct` may be passed as
|
|
342
|
+
* any number above it.
|
|
343
|
+
*/
|
|
344
|
+
export function decideByDistinct(verdict, exactDistinct, categoryMaxDistinct) {
|
|
345
|
+
if (exactDistinct >= 1 && exactDistinct <= categoryMaxDistinct)
|
|
346
|
+
return { kind: 'category', personalReason: null };
|
|
347
|
+
return { kind: verdict.otherwise, personalReason: null };
|
|
348
|
+
}
|
|
349
|
+
export function classifyColumn(input) {
|
|
350
|
+
const verdict = classifyColumnFacts(input);
|
|
351
|
+
return verdict.kind === 'distinct-decides' ? decideByDistinct(verdict, input.exactDistinct, input.categoryMaxDistinct) : verdict;
|
|
352
|
+
}
|
|
353
|
+
/* ------------------------------------------------------------ CHECK lists */
|
|
354
|
+
/**
|
|
355
|
+
* The allowed values of a single-column CHECK constraint, when the
|
|
356
|
+
* constraint is exactly a value list (`col IN (...)`, printed by Postgres as
|
|
357
|
+
* `col = ANY (ARRAY[...])`, or an OR of `col = literal`). Any other shape
|
|
358
|
+
* (ranges, functions, AND-ed conditions, NOT IN) is not a value list and
|
|
359
|
+
* returns null. Only the definition text is read; no data.
|
|
360
|
+
*/
|
|
361
|
+
export function checkConstraintValues(definition, column) {
|
|
362
|
+
const body = definition.trim().match(/^CHECK \((.*)\)$/su)?.[1];
|
|
363
|
+
if (body === undefined)
|
|
364
|
+
return null;
|
|
365
|
+
const columnRef = `(?:\\(*${escapeRegExp(quoteIdentifierForMatch(column))}\\)*(?:::[a-z ]+(?:\\[\\])?)?)`;
|
|
366
|
+
const literal = `(?:'(?:[^']|'')*'(?:::[a-z ]+)?|-?[0-9]+(?:\\.[0-9]+)?)`;
|
|
367
|
+
const anyForm = new RegExp(`^\\(*${columnRef} = ANY \\(+ARRAY\\[(${literal}(?:, ${literal})*)\\](?:\\)*::[a-z ]+\\[\\])?\\)+$`, 'u');
|
|
368
|
+
const anyMatch = body.match(anyForm);
|
|
369
|
+
if (anyMatch)
|
|
370
|
+
return literalList(anyMatch[1]);
|
|
371
|
+
const equality = `\\(*${columnRef} = (${literal})\\)*`;
|
|
372
|
+
const terms = splitTopLevelOr(stripOuterParens(body));
|
|
373
|
+
if (terms === null)
|
|
374
|
+
return null;
|
|
375
|
+
const values = [];
|
|
376
|
+
for (const term of terms) {
|
|
377
|
+
const match = term.match(new RegExp(`^${equality}$`, 'u'));
|
|
378
|
+
if (!match)
|
|
379
|
+
return null;
|
|
380
|
+
values.push(...literalList(match[1]));
|
|
381
|
+
}
|
|
382
|
+
return values.length > 0 ? values : null;
|
|
383
|
+
}
|
|
384
|
+
function quoteIdentifierForMatch(column) {
|
|
385
|
+
return /^[a-z_][a-z0-9_$]*$/u.test(column) ? column : `"${column.replace(/"/gu, '""')}"`;
|
|
386
|
+
}
|
|
387
|
+
function escapeRegExp(value) {
|
|
388
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
389
|
+
}
|
|
390
|
+
function literalList(items) {
|
|
391
|
+
const values = [];
|
|
392
|
+
const pattern = /'((?:[^']|'')*)'(?:::[a-z ]+)?|(-?[0-9]+(?:\.[0-9]+)?)/gu;
|
|
393
|
+
for (const match of items.matchAll(pattern)) {
|
|
394
|
+
values.push(match[1] !== undefined ? match[1].replace(/''/gu, "'") : match[2]);
|
|
395
|
+
}
|
|
396
|
+
return values;
|
|
397
|
+
}
|
|
398
|
+
function stripOuterParens(value) {
|
|
399
|
+
let current = value.trim();
|
|
400
|
+
while (current.startsWith('(') && current.endsWith(')') && closingParen(current, 0) === current.length - 1) {
|
|
401
|
+
current = current.slice(1, -1).trim();
|
|
402
|
+
}
|
|
403
|
+
return current;
|
|
404
|
+
}
|
|
405
|
+
function closingParen(value, open) {
|
|
406
|
+
let depth = 0;
|
|
407
|
+
let quoted = false;
|
|
408
|
+
for (let index = open; index < value.length; index += 1) {
|
|
409
|
+
const char = value[index];
|
|
410
|
+
if (char === "'")
|
|
411
|
+
quoted = !quoted;
|
|
412
|
+
if (quoted)
|
|
413
|
+
continue;
|
|
414
|
+
if (char === '(')
|
|
415
|
+
depth += 1;
|
|
416
|
+
if (char === ')') {
|
|
417
|
+
depth -= 1;
|
|
418
|
+
if (depth === 0)
|
|
419
|
+
return index;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return -1;
|
|
423
|
+
}
|
|
424
|
+
function splitTopLevelOr(value) {
|
|
425
|
+
const terms = [];
|
|
426
|
+
let depth = 0;
|
|
427
|
+
let quoted = false;
|
|
428
|
+
let start = 0;
|
|
429
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
430
|
+
const char = value[index];
|
|
431
|
+
if (char === "'")
|
|
432
|
+
quoted = !quoted;
|
|
433
|
+
if (quoted)
|
|
434
|
+
continue;
|
|
435
|
+
if (char === '(')
|
|
436
|
+
depth += 1;
|
|
437
|
+
if (char === ')')
|
|
438
|
+
depth -= 1;
|
|
439
|
+
if (depth < 0)
|
|
440
|
+
return null;
|
|
441
|
+
if (depth === 0 && value.startsWith(' OR ', index)) {
|
|
442
|
+
terms.push(value.slice(start, index).trim());
|
|
443
|
+
start = index + 4;
|
|
444
|
+
}
|
|
445
|
+
if (depth === 0 && value.startsWith(' AND ', index))
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
terms.push(value.slice(start).trim());
|
|
449
|
+
return terms;
|
|
450
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `haystack db profile show <file>` — a plain-English review of exactly what
|
|
3
|
+
* `haystack db profile upload` would send. Every string the file carries
|
|
4
|
+
* (column names and types, category values, shapes, JSON key paths, odd-row
|
|
5
|
+
* descriptions, index definitions, settings) is printed; the strict parser
|
|
6
|
+
* refuses a file with fields this review does not print.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { parseDatabaseProfile, } from './db-profile-contract.js';
|
|
10
|
+
import { describeReach, ownerReach } from './db-profile-owners.js';
|
|
11
|
+
import { formatShare as pct } from './db-profile-privacy.js';
|
|
12
|
+
export function readDatabaseProfileFile(file) {
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = readFileSync(file, 'utf8');
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
throw new Error(`Could not read ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
19
|
+
}
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = JSON.parse(raw);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
throw new Error(`${file} is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
26
|
+
}
|
|
27
|
+
return parseDatabaseProfile(parsed, file);
|
|
28
|
+
}
|
|
29
|
+
const PERSONAL_KINDS = new Set(['personal', 'free-text']);
|
|
30
|
+
function count(value) {
|
|
31
|
+
return value.toLocaleString('en-US');
|
|
32
|
+
}
|
|
33
|
+
function bytes(value) {
|
|
34
|
+
const units = ['B', 'kB', 'MB', 'GB', 'TB'];
|
|
35
|
+
let scaled = value;
|
|
36
|
+
let unit = 0;
|
|
37
|
+
while (scaled >= 1024 && unit < units.length - 1) {
|
|
38
|
+
scaled /= 1024;
|
|
39
|
+
unit += 1;
|
|
40
|
+
}
|
|
41
|
+
return `${unit === 0 ? scaled.toFixed(0) : scaled.toFixed(1)} ${units[unit]}`;
|
|
42
|
+
}
|
|
43
|
+
function quote(value) {
|
|
44
|
+
return JSON.stringify(value);
|
|
45
|
+
}
|
|
46
|
+
function quantileText(quantiles, format) {
|
|
47
|
+
return [quantiles.p5, quantiles.p25, quantiles.p50, quantiles.p75, quantiles.p95].map(format).join(' / ');
|
|
48
|
+
}
|
|
49
|
+
function numberFormat(column) {
|
|
50
|
+
const type = column.dataType.toLowerCase();
|
|
51
|
+
if (type === 'date')
|
|
52
|
+
return value => new Date(value * 1000).toISOString().slice(0, 10);
|
|
53
|
+
if (type.startsWith('timestamp'))
|
|
54
|
+
return value => new Date(value * 1000).toISOString().replace('.000Z', 'Z');
|
|
55
|
+
if (type.startsWith('time') || type.startsWith('interval'))
|
|
56
|
+
return value => `${value}s`;
|
|
57
|
+
return value => String(value);
|
|
58
|
+
}
|
|
59
|
+
function kindLabel(kind) {
|
|
60
|
+
return kind === 'free-text' ? 'free text' : kind;
|
|
61
|
+
}
|
|
62
|
+
function renderColumn(column, rules, fullScan, ownersCountable) {
|
|
63
|
+
const lines = [];
|
|
64
|
+
const categories = column.categories ?? [];
|
|
65
|
+
let headline;
|
|
66
|
+
if (PERSONAL_KINDS.has(column.kind)) {
|
|
67
|
+
headline = `PERSONAL (${kindLabel(column.kind)}), values never leave: ${column.personalReason}`;
|
|
68
|
+
}
|
|
69
|
+
else if (column.kind === 'category' && categories.length > 0) {
|
|
70
|
+
const sources = new Set(categories.map(entry => entry.source));
|
|
71
|
+
headline = sources.has('schema')
|
|
72
|
+
? 'category, KEEPS VALUES declared by the schema (enum type or CHECK list), with their share of rows'
|
|
73
|
+
: `category, KEEPS VALUES that are each shared by at least ${rules.categoryMinOwners} owners`;
|
|
74
|
+
}
|
|
75
|
+
else if (column.kind === 'category') {
|
|
76
|
+
headline = `category, keeps no values (none is shared by at least ${rules.categoryMinOwners} owners without looking like personal data, or owners cannot be counted for this table)`;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
headline = `${kindLabel(column.kind)}, numbers only`;
|
|
80
|
+
}
|
|
81
|
+
lines.push(` ${column.name} (${column.dataType}${column.notNull ? ', not null' : ''}): ${headline}`);
|
|
82
|
+
if (categories.length > 0) {
|
|
83
|
+
const kept = categories.reduce((sum, entry) => sum + entry.fraction, 0);
|
|
84
|
+
lines.push(` values: ${categories.map(entry => `${quote(entry.value)} ${pct(entry.fraction)}`).join(', ')}`);
|
|
85
|
+
if (categories.every(entry => entry.source === 'data') && kept < 0.9995) {
|
|
86
|
+
lines.push(` withheld: ${pct(1 - kept)} of non-null values (values shared by too few owners, or shaped like personal data)`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// A table read in full has exact distinct counts; a sampled one mostly estimates.
|
|
90
|
+
lines.push(` ${pct(column.nullFraction)} null, ${fullScan ? '' : 'about '}${count(column.distinctEstimate)} distinct, ${column.avgWidthBytes} bytes on average`);
|
|
91
|
+
if (column.lengthQuantiles !== null) {
|
|
92
|
+
lines.push(` length in bytes, 5th/25th/50th/75th/95th percentile: ${quantileText(column.lengthQuantiles, value => String(value))}`);
|
|
93
|
+
}
|
|
94
|
+
if (column.numberQuantiles !== null) {
|
|
95
|
+
lines.push(` values, 5th/25th/50th/75th/95th percentile: ${quantileText(column.numberQuantiles, numberFormat(column))}`);
|
|
96
|
+
}
|
|
97
|
+
if (column.booleanTrueFraction !== null) {
|
|
98
|
+
lines.push(` true in ${pct(column.booleanTrueFraction)} of non-null values`);
|
|
99
|
+
}
|
|
100
|
+
else if (column.kind === 'boolean') {
|
|
101
|
+
lines.push(ownersCountable
|
|
102
|
+
? ` share of true not sent: the column has no values, or fewer than ${rules.categoryMinOwners} owners have one`
|
|
103
|
+
: ' share of true not sent: owners cannot be counted for this table');
|
|
104
|
+
}
|
|
105
|
+
if (column.shapes.length > 0) {
|
|
106
|
+
lines.push(` shapes: ${column.shapes.map(entry => `${quote(entry.shape)} ${pct(entry.fraction)}`).join(', ')}`);
|
|
107
|
+
}
|
|
108
|
+
for (const key of column.jsonKeys) {
|
|
109
|
+
lines.push(` JSON key ${quote(key.path)}: ${key.types.join(' or ')}, in ${pct(key.presentFraction)} of rows`);
|
|
110
|
+
}
|
|
111
|
+
for (const oddity of column.oddities) {
|
|
112
|
+
const at = oddity.jsonPath === null ? '' : ` at JSON key ${quote(oddity.jsonPath)}`;
|
|
113
|
+
const types = oddity.types === null ? '' : `, expected ${oddity.types.expected}, found ${oddity.types.found.join(' or ')}`;
|
|
114
|
+
lines.push(` odd rows (${oddity.kind}, ${count(oddity.rowCount)}${at}${types}): ${oddity.description}`);
|
|
115
|
+
}
|
|
116
|
+
return lines;
|
|
117
|
+
}
|
|
118
|
+
function tableEdges(profile) {
|
|
119
|
+
const profiled = new Set(profile.tables.map(table => `${table.schema}.${table.name}`));
|
|
120
|
+
return profile.tables.flatMap(table => table.foreignKeys
|
|
121
|
+
.filter(key => profiled.has(`${key.references.schema}.${key.references.table}`))
|
|
122
|
+
.map(key => ({
|
|
123
|
+
name: key.name,
|
|
124
|
+
from: { schema: table.schema, table: table.name },
|
|
125
|
+
columns: key.columns,
|
|
126
|
+
to: { schema: key.references.schema, table: key.references.table },
|
|
127
|
+
referencedColumns: key.references.columns,
|
|
128
|
+
})));
|
|
129
|
+
}
|
|
130
|
+
function renderTable(table, profile, edges) {
|
|
131
|
+
const owner = profile.rules.ownerCounting.kind === 'owner-table'
|
|
132
|
+
? { schema: profile.rules.ownerCounting.schema, table: profile.rules.ownerCounting.table }
|
|
133
|
+
: null;
|
|
134
|
+
const rowSource = table.rowCountSource === 'exact' ? 'counted exactly' : "the planner's estimate";
|
|
135
|
+
const dead = table.deadRows.status === 'measured'
|
|
136
|
+
? `${pct(table.deadRows.fraction)} dead rows`
|
|
137
|
+
: `dead rows not measured: ${table.deadRows.reason}`;
|
|
138
|
+
const fullScan = table.sampling.status === 'full-scan';
|
|
139
|
+
const reach = ownerReach({ schema: table.schema, table: table.name }, owner, edges);
|
|
140
|
+
const reading = table.sampling.status === 'full-scan'
|
|
141
|
+
? 'every row'
|
|
142
|
+
: `a ${table.sampling.percent}% TABLESAMPLE SYSTEM sample (seed ${table.sampling.seed}), so row counts in odd-row findings are counts in that sample`;
|
|
143
|
+
const lines = [
|
|
144
|
+
'',
|
|
145
|
+
`TABLE ${table.schema}.${table.name}`,
|
|
146
|
+
` ${count(table.rowCount)} rows (${rowSource}); table ${bytes(table.tableBytes)}, indexes ${bytes(table.indexBytes)}, TOAST ${bytes(table.toastBytes)}`,
|
|
147
|
+
` ${dead}`,
|
|
148
|
+
` Measured from ${reading}`,
|
|
149
|
+
` Owners: ${describeReach(reach, owner)}`,
|
|
150
|
+
];
|
|
151
|
+
const keeping = table.columns.filter(column => (column.categories ?? []).length > 0);
|
|
152
|
+
const personal = table.columns.filter(column => PERSONAL_KINDS.has(column.kind));
|
|
153
|
+
const rest = table.columns.filter(column => !keeping.includes(column) && !personal.includes(column));
|
|
154
|
+
lines.push(` Columns that keep values (${keeping.length}):`);
|
|
155
|
+
lines.push(...(keeping.length === 0 ? [' none'] : keeping.flatMap(column => renderColumn(column, profile.rules, fullScan, reach.kind !== 'unreachable'))));
|
|
156
|
+
lines.push(` Personal columns, whose values never leave (${personal.length}):`);
|
|
157
|
+
lines.push(...(personal.length === 0 ? [' none'] : personal.flatMap(column => renderColumn(column, profile.rules, fullScan, reach.kind !== 'unreachable'))));
|
|
158
|
+
lines.push(` Other columns, numbers and shapes only (${rest.length}):`);
|
|
159
|
+
lines.push(...(rest.length === 0 ? [' none'] : rest.flatMap(column => renderColumn(column, profile.rules, fullScan, reach.kind !== 'unreachable'))));
|
|
160
|
+
lines.push(` Foreign keys (${table.foreignKeys.length}):`);
|
|
161
|
+
for (const key of table.foreignKeys) {
|
|
162
|
+
lines.push(` ${key.name}: ${key.columns.join(', ')} -> ${key.references.schema}.${key.references.table}(${key.references.columns.join(', ')}); `
|
|
163
|
+
+ `children per parent, 5th-95th percentile: ${quantileText(key.childrenPerParent, value => String(value))}; ${pct(key.orphanFraction)} orphans`);
|
|
164
|
+
}
|
|
165
|
+
lines.push(` Index definitions, written by your developers, every literal value replaced with ? (${table.indexes.length}):`);
|
|
166
|
+
for (const index of table.indexes)
|
|
167
|
+
lines.push(` ${index.name}: ${index.definition}`);
|
|
168
|
+
return lines;
|
|
169
|
+
}
|
|
170
|
+
export function renderDatabaseProfile(profile, file) {
|
|
171
|
+
const rules = profile.rules;
|
|
172
|
+
const columns = profile.tables.flatMap(table => table.columns);
|
|
173
|
+
const keeping = columns.filter(column => (column.categories ?? []).length > 0);
|
|
174
|
+
const fromSchema = keeping.filter(column => (column.categories ?? []).some(entry => entry.source === 'schema')).length;
|
|
175
|
+
const personal = columns.filter(column => PERSONAL_KINDS.has(column.kind)).length;
|
|
176
|
+
const oddRows = columns.reduce((sum, column) => sum + column.oddities.length, 0);
|
|
177
|
+
const ownerLine = rules.ownerCounting.kind === 'owner-table'
|
|
178
|
+
? `Owners are distinct rows of ${rules.ownerCounting.schema}.${rules.ownerCounting.table}, reached from each table by following its foreign keys.`
|
|
179
|
+
: `Owners are rows: ${rules.ownerCounting.reason}`;
|
|
180
|
+
const edges = tableEdges(profile);
|
|
181
|
+
return [
|
|
182
|
+
`Haystack database profile: ${file}`,
|
|
183
|
+
`Made by ${profile.profilerVersion} at ${profile.createdAt} from ${profile.engine} ${profile.engineVersion} (format ${profile.version}).`,
|
|
184
|
+
'',
|
|
185
|
+
'WHAT THIS FILE SENDS',
|
|
186
|
+
'This file is everything `haystack db profile upload` sends, and it holds no rows. A column\'s',
|
|
187
|
+
'values appear below only when all three rules hold:',
|
|
188
|
+
` 1. the column has at most ${rules.categoryMaxDistinct} distinct values (a category column);`,
|
|
189
|
+
' 2. the column is not personal: names, emails, phones, addresses, free text, secrets,',
|
|
190
|
+
' network addresses, dates of birth, government IDs. When in doubt it is personal;',
|
|
191
|
+
` 3. each value is shared by at least ${rules.categoryMinOwners} owners.`,
|
|
192
|
+
'Values declared by the schema itself (enum labels, CHECK value lists) need no data and are',
|
|
193
|
+
'listed with their share of rows.',
|
|
194
|
+
ownerLine,
|
|
195
|
+
'Everything else is numbers (row counts, sizes, null shares, distinct counts, average widths,',
|
|
196
|
+
'the share of true in yes/no columns, and 5th to 95th percentiles, never the minimum or maximum;',
|
|
197
|
+
`both only where at least ${rules.categoryMinOwners} owners have a value), character shapes and JSON key`,
|
|
198
|
+
`paths each shared by at least ${rules.categoryMinOwners} owners, and descriptions of odd rows that never quote a value.`,
|
|
199
|
+
'Index definitions are sent with every literal value replaced with ?.',
|
|
200
|
+
'In shapes, A is a run of capital letters, a a run of small letters, 9 a run of digits, and',
|
|
201
|
+
'? any other character; punctuation is kept.',
|
|
202
|
+
'',
|
|
203
|
+
'SUMMARY',
|
|
204
|
+
` ${profile.tables.length} tables, ${columns.length} columns.`,
|
|
205
|
+
` ${keeping.length} columns keep values (${fromSchema} from the schema, ${keeping.length - fromSchema} from the data).`,
|
|
206
|
+
` ${personal} columns are personal and keep no values.`,
|
|
207
|
+
` ${oddRows} odd-row findings, counts only.`,
|
|
208
|
+
'',
|
|
209
|
+
'SERVER SETTINGS (planner and storage only)',
|
|
210
|
+
...profile.settings.map(setting => ` ${setting.name} = ${setting.value}`),
|
|
211
|
+
...profile.tables.flatMap(table => renderTable(table, profile, edges)),
|
|
212
|
+
'',
|
|
213
|
+
].join('\n');
|
|
214
|
+
}
|
|
215
|
+
export async function databaseProfileShowCommand(file) {
|
|
216
|
+
const profile = readDatabaseProfileFile(file);
|
|
217
|
+
process.stdout.write(renderDatabaseProfile(profile, file));
|
|
218
|
+
}
|