@semics-tech/mongolite 1.2.0 → 1.3.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.
Files changed (38) hide show
  1. package/README.md +14 -1
  2. package/dist/bin/mongolite-debug.d.ts +6 -1
  3. package/dist/bin/mongolite-debug.js +6 -113
  4. package/dist/bin/mongolite-debug.js.map +1 -1
  5. package/dist/bin/mongolite.d.ts +13 -0
  6. package/dist/bin/mongolite.js +172 -0
  7. package/dist/bin/mongolite.js.map +1 -0
  8. package/dist/cjs/bin/mongolite-debug.js +6 -149
  9. package/dist/cjs/bin/mongolite.js +207 -0
  10. package/dist/cjs/cli/discover.js +156 -0
  11. package/dist/cjs/cli/explorer.js +929 -0
  12. package/dist/cjs/cli/filter-builder.js +276 -0
  13. package/dist/cjs/cli/format.js +161 -0
  14. package/dist/cjs/cli/schema.js +211 -0
  15. package/dist/cjs/cli/ui.js +431 -0
  16. package/dist/cjs/cursors/findCursor.js +20 -8
  17. package/dist/cli/discover.d.ts +40 -0
  18. package/dist/cli/discover.js +146 -0
  19. package/dist/cli/discover.js.map +1 -0
  20. package/dist/cli/explorer.d.ts +59 -0
  21. package/dist/cli/explorer.js +923 -0
  22. package/dist/cli/explorer.js.map +1 -0
  23. package/dist/cli/filter-builder.d.ts +52 -0
  24. package/dist/cli/filter-builder.js +268 -0
  25. package/dist/cli/filter-builder.js.map +1 -0
  26. package/dist/cli/format.d.ts +29 -0
  27. package/dist/cli/format.js +153 -0
  28. package/dist/cli/format.js.map +1 -0
  29. package/dist/cli/schema.d.ts +58 -0
  30. package/dist/cli/schema.js +205 -0
  31. package/dist/cli/schema.js.map +1 -0
  32. package/dist/cli/ui.d.ts +71 -0
  33. package/dist/cli/ui.js +379 -0
  34. package/dist/cli/ui.js.map +1 -0
  35. package/dist/cursors/findCursor.d.ts +12 -0
  36. package/dist/cursors/findCursor.js +20 -8
  37. package/dist/cursors/findCursor.js.map +1 -1
  38. package/package.json +2 -1
@@ -0,0 +1,276 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.operatorsFor = operatorsFor;
4
+ exports.escapeRegex = escapeRegex;
5
+ exports.parseValue = parseValue;
6
+ exports.parseValueList = parseValueList;
7
+ exports.compileCondition = compileCondition;
8
+ exports.combineConditions = combineConditions;
9
+ exports.describeCondition = describeCondition;
10
+ const EXISTENCE = [
11
+ { id: 'exists', label: 'has a value', arity: 'none' },
12
+ { id: 'notExists', label: 'is missing or empty', arity: 'none' },
13
+ ];
14
+ const STRING_OPERATORS = [
15
+ { id: 'eq', label: 'is', arity: 'one', valuePrompt: 'Value' },
16
+ { id: 'ne', label: 'is not', arity: 'one', valuePrompt: 'Value' },
17
+ { id: 'contains', label: 'contains the text', arity: 'one', valuePrompt: 'Text' },
18
+ { id: 'startsWith', label: 'starts with', arity: 'one', valuePrompt: 'Prefix' },
19
+ { id: 'endsWith', label: 'ends with', arity: 'one', valuePrompt: 'Suffix' },
20
+ { id: 'in', label: 'is one of', arity: 'many', valuePrompt: 'Values (comma separated)' },
21
+ { id: 'nin', label: 'is none of', arity: 'many', valuePrompt: 'Values (comma separated)' },
22
+ {
23
+ id: 'regex',
24
+ label: 'matches a regular expression',
25
+ arity: 'one',
26
+ valuePrompt: 'Pattern',
27
+ hint: 'advanced',
28
+ },
29
+ ];
30
+ const NUMBER_OPERATORS = [
31
+ { id: 'eq', label: 'equals', arity: 'one', valuePrompt: 'Number' },
32
+ { id: 'ne', label: 'does not equal', arity: 'one', valuePrompt: 'Number' },
33
+ { id: 'gt', label: 'is greater than', arity: 'one', valuePrompt: 'Number' },
34
+ { id: 'gte', label: 'is at least', arity: 'one', valuePrompt: 'Number' },
35
+ { id: 'lt', label: 'is less than', arity: 'one', valuePrompt: 'Number' },
36
+ { id: 'lte', label: 'is at most', arity: 'one', valuePrompt: 'Number' },
37
+ {
38
+ id: 'between',
39
+ label: 'is between',
40
+ arity: 'two',
41
+ valuePrompt: 'From (inclusive)',
42
+ secondValuePrompt: 'To (inclusive)',
43
+ },
44
+ { id: 'in', label: 'is one of', arity: 'many', valuePrompt: 'Numbers (comma separated)' },
45
+ ];
46
+ const DATE_OPERATORS = [
47
+ { id: 'eq', label: 'is exactly', arity: 'one', valuePrompt: 'Date (e.g. 2024-01-31)' },
48
+ { id: 'gt', label: 'is after', arity: 'one', valuePrompt: 'Date (e.g. 2024-01-31)' },
49
+ { id: 'gte', label: 'is on or after', arity: 'one', valuePrompt: 'Date' },
50
+ { id: 'lt', label: 'is before', arity: 'one', valuePrompt: 'Date' },
51
+ { id: 'lte', label: 'is on or before', arity: 'one', valuePrompt: 'Date' },
52
+ {
53
+ id: 'between',
54
+ label: 'is between',
55
+ arity: 'two',
56
+ valuePrompt: 'From date',
57
+ secondValuePrompt: 'To date',
58
+ },
59
+ ];
60
+ const BOOLEAN_OPERATORS = [
61
+ { id: 'isTrue', label: 'is true', arity: 'none' },
62
+ { id: 'isFalse', label: 'is false', arity: 'none' },
63
+ ];
64
+ const ARRAY_OPERATORS = [
65
+ { id: 'arrayContains', label: 'contains', arity: 'one', valuePrompt: 'Value' },
66
+ {
67
+ id: 'arrayContainsAny',
68
+ label: 'contains any of',
69
+ arity: 'many',
70
+ valuePrompt: 'Values (comma separated)',
71
+ },
72
+ {
73
+ id: 'arrayContainsAll',
74
+ label: 'contains all of',
75
+ arity: 'many',
76
+ valuePrompt: 'Values (comma separated)',
77
+ },
78
+ { id: 'size', label: 'has exactly N items', arity: 'one', valuePrompt: 'Number of items' },
79
+ ];
80
+ const OBJECT_OPERATORS = [
81
+ { id: 'jsonEquals', label: 'equals this JSON', arity: 'one', valuePrompt: 'JSON value' },
82
+ ];
83
+ /** The conditions worth offering for a given field. */
84
+ function operatorsFor(field) {
85
+ const byType = {
86
+ string: STRING_OPERATORS,
87
+ number: NUMBER_OPERATORS,
88
+ date: DATE_OPERATORS,
89
+ boolean: BOOLEAN_OPERATORS,
90
+ array: ARRAY_OPERATORS,
91
+ object: OBJECT_OPERATORS,
92
+ null: STRING_OPERATORS,
93
+ };
94
+ const primary = byType[field.primaryType] ?? STRING_OPERATORS;
95
+ // Mixed-type fields also get the string operators, which are the most forgiving.
96
+ const mixed = field.types.length > 1 && field.primaryType !== 'string'
97
+ ? STRING_OPERATORS.filter((op) => !primary.some((existing) => existing.id === op.id))
98
+ : [];
99
+ return [...primary, ...mixed, ...EXISTENCE];
100
+ }
101
+ /** Escape a literal string so it can be embedded in a regular expression. */
102
+ function escapeRegex(value) {
103
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
104
+ }
105
+ /**
106
+ * Coerce user-typed text into the type the field appears to hold.
107
+ * Throws a message suitable for showing straight back to the user.
108
+ */
109
+ function parseValue(input, type) {
110
+ const trimmed = input.trim();
111
+ switch (type) {
112
+ case 'number': {
113
+ const parsed = Number(trimmed);
114
+ if (trimmed === '' || Number.isNaN(parsed)) {
115
+ throw new Error(`"${input}" is not a number.`);
116
+ }
117
+ return parsed;
118
+ }
119
+ case 'boolean': {
120
+ const lowered = trimmed.toLowerCase();
121
+ if (['true', 'yes', 'y', '1'].includes(lowered))
122
+ return true;
123
+ if (['false', 'no', 'n', '0'].includes(lowered))
124
+ return false;
125
+ throw new Error(`"${input}" is not true or false.`);
126
+ }
127
+ case 'date': {
128
+ const parsed = new Date(trimmed);
129
+ if (Number.isNaN(parsed.getTime())) {
130
+ throw new Error(`"${input}" is not a date I can read. Try 2024-01-31.`);
131
+ }
132
+ // Documents store dates as ISO strings, so compare as ISO strings.
133
+ return /^\d{4}-\d{2}-\d{2}$/.test(trimmed)
134
+ ? `${trimmed}T00:00:00.000Z`
135
+ : parsed.toISOString();
136
+ }
137
+ case 'object':
138
+ case 'array': {
139
+ try {
140
+ return JSON.parse(trimmed);
141
+ }
142
+ catch {
143
+ return trimmed; // Fall back to a plain scalar - useful for "array contains".
144
+ }
145
+ }
146
+ default: {
147
+ // Strings stay strings, but "true"/"42" typed against a mixed field are
148
+ // usually meant literally, so no coercion here.
149
+ return trimmed;
150
+ }
151
+ }
152
+ }
153
+ /** Split a comma-separated list, parsing each entry as `type`. */
154
+ function parseValueList(input, type) {
155
+ const parts = input
156
+ .split(',')
157
+ .map((part) => part.trim())
158
+ .filter((part) => part !== '');
159
+ if (parts.length === 0)
160
+ throw new Error('Enter at least one value.');
161
+ return parts.map((part) => parseValue(part, type));
162
+ }
163
+ /** Compile one condition into the equivalent MongoLite filter fragment. */
164
+ function compileCondition(condition) {
165
+ const { field, operator, values } = condition;
166
+ const [first, second] = values;
167
+ switch (operator) {
168
+ case 'eq':
169
+ return { [field]: { $eq: first } };
170
+ case 'ne':
171
+ return { [field]: { $ne: first } };
172
+ case 'gt':
173
+ return { [field]: { $gt: first } };
174
+ case 'gte':
175
+ return { [field]: { $gte: first } };
176
+ case 'lt':
177
+ return { [field]: { $lt: first } };
178
+ case 'lte':
179
+ return { [field]: { $lte: first } };
180
+ case 'between':
181
+ return { [field]: { $gte: first, $lte: second } };
182
+ case 'in':
183
+ return { [field]: { $in: values } };
184
+ case 'nin':
185
+ return { [field]: { $nin: values } };
186
+ case 'contains':
187
+ return { [field]: { $regex: escapeRegex(String(first)), $options: 'i' } };
188
+ case 'startsWith':
189
+ return { [field]: { $regex: `^${escapeRegex(String(first))}`, $options: 'i' } };
190
+ case 'endsWith':
191
+ return { [field]: { $regex: `${escapeRegex(String(first))}$`, $options: 'i' } };
192
+ case 'regex':
193
+ return { [field]: { $regex: String(first) } };
194
+ case 'isTrue':
195
+ return { [field]: { $eq: true } };
196
+ case 'isFalse':
197
+ return { [field]: { $eq: false } };
198
+ case 'exists':
199
+ return { [field]: { $exists: true } };
200
+ case 'notExists':
201
+ return { [field]: { $exists: false } };
202
+ // `$in` compiles to a json_each() containment check, which is what makes
203
+ // these work element-wise on array fields.
204
+ case 'arrayContains':
205
+ return { [field]: { $in: [first] } };
206
+ case 'arrayContainsAny':
207
+ return { [field]: { $in: values } };
208
+ case 'arrayContainsAll':
209
+ return { [field]: { $all: values } };
210
+ case 'size':
211
+ return { [field]: { $size: Number(first) } };
212
+ case 'jsonEquals':
213
+ return { [field]: first };
214
+ default:
215
+ throw new Error(`Unknown condition: ${operator}`);
216
+ }
217
+ }
218
+ /**
219
+ * Combine conditions into one filter.
220
+ * "All" merges into a flat object when the fields are distinct (the filter a
221
+ * person would have written by hand) and falls back to `$and` when a field is
222
+ * constrained more than once.
223
+ */
224
+ function combineConditions(conditions, mode = 'all') {
225
+ if (conditions.length === 0)
226
+ return {};
227
+ const compiled = conditions.map(compileCondition);
228
+ if (compiled.length === 1 && mode === 'all')
229
+ return compiled[0];
230
+ if (mode === 'any')
231
+ return { $or: compiled };
232
+ const fields = conditions.map((condition) => condition.field);
233
+ const hasDuplicateField = new Set(fields).size !== fields.length;
234
+ if (hasDuplicateField)
235
+ return { $and: compiled };
236
+ return Object.assign({}, ...compiled);
237
+ }
238
+ /**
239
+ * Every operator across every type, for label lookups.
240
+ * A condition's `fieldType` is the type of its *values* — the element type for
241
+ * array fields — so looking a label up by that type alone would miss the array
242
+ * operators.
243
+ */
244
+ const ALL_OPERATORS = [
245
+ ...STRING_OPERATORS,
246
+ ...NUMBER_OPERATORS,
247
+ ...DATE_OPERATORS,
248
+ ...BOOLEAN_OPERATORS,
249
+ ...ARRAY_OPERATORS,
250
+ ...OBJECT_OPERATORS,
251
+ ...EXISTENCE,
252
+ ];
253
+ /** Render a condition the way it was phrased in the menu, for review lists. */
254
+ function describeCondition(condition, operators = operatorsFor({
255
+ primaryType: condition.fieldType,
256
+ types: [condition.fieldType],
257
+ })) {
258
+ const operator = operators.find((candidate) => candidate.id === condition.operator) ??
259
+ ALL_OPERATORS.find((candidate) => candidate.id === condition.operator);
260
+ const label = operator?.label ?? condition.operator;
261
+ if (condition.values.length === 0)
262
+ return `${condition.field} ${label}`;
263
+ if (condition.values.length === 2 && condition.operator === 'between') {
264
+ return `${condition.field} ${label} ${format(condition.values[0])} and ${format(condition.values[1])}`;
265
+ }
266
+ return `${condition.field} ${label} ${condition.values.map(format).join(', ')}`;
267
+ }
268
+ function format(value) {
269
+ if (typeof value === 'string')
270
+ return `"${value}"`;
271
+ if (value === null)
272
+ return 'null';
273
+ if (typeof value === 'object')
274
+ return JSON.stringify(value);
275
+ return String(value);
276
+ }
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatCell = formatCell;
4
+ exports.renderTable = renderTable;
5
+ exports.renderDocument = renderDocument;
6
+ exports.renderSchema = renderSchema;
7
+ exports.renderSql = renderSql;
8
+ exports.describeFilter = describeFilter;
9
+ exports.wrapText = wrapText;
10
+ /**
11
+ * Rendering helpers: turning documents into something readable in a terminal.
12
+ */
13
+ const schema_js_1 = require("./schema.js");
14
+ const ui_js_1 = require("./ui.js");
15
+ /** Render a value as a single-line cell. */
16
+ function formatCell(value) {
17
+ if (value === undefined)
18
+ return '';
19
+ if (value === null)
20
+ return 'null';
21
+ if (value instanceof Date)
22
+ return value.toISOString();
23
+ if (Array.isArray(value)) {
24
+ if (value.length === 0)
25
+ return '[]';
26
+ const inner = value
27
+ .map((item) => (typeof item === 'object' && item !== null ? '{…}' : String(item)))
28
+ .join(', ');
29
+ return `[${inner}]`;
30
+ }
31
+ if (typeof value === 'object')
32
+ return JSON.stringify(value);
33
+ return String(value);
34
+ }
35
+ /**
36
+ * Render documents as an aligned table. Columns are sized to their content and
37
+ * then shrunk proportionally if the table would overflow the terminal.
38
+ */
39
+ function renderTable(docs, options) {
40
+ const { columns } = options;
41
+ if (columns.length === 0)
42
+ return ui_js_1.style.grey('No columns to show.');
43
+ const maxColumnWidth = options.maxColumnWidth ?? 40;
44
+ const startIndex = options.startIndex ?? 0;
45
+ const showRowNumbers = options.showRowNumbers ?? true;
46
+ const headers = showRowNumbers ? ['#', ...columns] : [...columns];
47
+ const rows = docs.map((doc, index) => {
48
+ const cells = columns.map((column) => formatCell((0, schema_js_1.getPath)(doc, column)));
49
+ return showRowNumbers ? [String(startIndex + index + 1), ...cells] : cells;
50
+ });
51
+ // Natural width per column, capped.
52
+ const widths = headers.map((header, columnIndex) => {
53
+ const longestCell = rows.reduce((max, row) => Math.max(max, (row[columnIndex] ?? '').length), 0);
54
+ return Math.min(Math.max(header.length, longestCell), maxColumnWidth);
55
+ });
56
+ // Shrink the widest columns until the whole table fits the terminal.
57
+ const separatorWidth = 3;
58
+ const available = (0, ui_js_1.terminalWidth)() - 1;
59
+ let total = widths.reduce((sum, width) => sum + width, 0) + separatorWidth * (widths.length - 1);
60
+ while (total > available) {
61
+ const widest = widths.indexOf(Math.max(...widths));
62
+ if (widths[widest] <= 6)
63
+ break;
64
+ widths[widest] -= 1;
65
+ total -= 1;
66
+ }
67
+ const line = (cells, colourise) => cells
68
+ .map((cell, index) => {
69
+ const clipped = (0, ui_js_1.truncate)(cell, widths[index]);
70
+ const padded = clipped.padEnd(widths[index]);
71
+ return colourise ? colourise(padded) : padded;
72
+ })
73
+ .join(ui_js_1.style.grey(' │ '))
74
+ .trimEnd();
75
+ const output = [];
76
+ output.push(line(headers, ui_js_1.style.bold));
77
+ output.push(ui_js_1.style.grey(widths.map((width) => '─'.repeat(width)).join('─┼─')));
78
+ for (const row of rows)
79
+ output.push(line(row));
80
+ return output.join('\n');
81
+ }
82
+ /** Pretty-print one document, with the `_id` called out first. */
83
+ function renderDocument(doc) {
84
+ return JSON.stringify(doc, null, 2);
85
+ }
86
+ /** Render an inferred schema as a table of fields. */
87
+ function renderSchema(schema) {
88
+ if (schema.fields.length === 0) {
89
+ return ui_js_1.style.grey('No fields found - the collection looks empty.');
90
+ }
91
+ const rows = schema.fields.map((field) => {
92
+ const type = field.primaryType === 'array' && field.elementTypes?.length
93
+ ? `array<${field.elementTypes.filter((t) => t !== 'null').join('|') || 'any'}>`
94
+ : field.types.join('|');
95
+ const example = field.examples.length
96
+ ? formatCell(field.examples[0])
97
+ : field.types.includes('null')
98
+ ? 'null'
99
+ : '';
100
+ return {
101
+ field: field.path,
102
+ type,
103
+ present: `${Math.round(field.presence * 100)}%`,
104
+ example,
105
+ };
106
+ });
107
+ const widths = {
108
+ field: Math.min(Math.max(5, ...rows.map((r) => r.field.length)), 40),
109
+ type: Math.min(Math.max(4, ...rows.map((r) => r.type.length)), 24),
110
+ present: 7,
111
+ };
112
+ const exampleWidth = Math.max(10, (0, ui_js_1.terminalWidth)() - widths.field - widths.type - widths.present - 10);
113
+ const output = [];
114
+ output.push(ui_js_1.style.bold(`${'field'.padEnd(widths.field)} ${'type'.padEnd(widths.type)} ${'present'.padEnd(widths.present)} example`));
115
+ for (const row of rows) {
116
+ output.push(`${(0, ui_js_1.truncate)(row.field, widths.field).padEnd(widths.field)} ${ui_js_1.style.cyan((0, ui_js_1.truncate)(row.type, widths.type).padEnd(widths.type))} ${ui_js_1.style.grey(row.present.padEnd(widths.present))} ${ui_js_1.style.grey((0, ui_js_1.truncate)(row.example, exampleWidth))}`);
117
+ }
118
+ return output.join('\n');
119
+ }
120
+ /** Show a generated SQL statement with its bound parameters. */
121
+ function renderSql(sql, params) {
122
+ const lines = [ui_js_1.style.bold('SQL'), sql];
123
+ if (params.length > 0) {
124
+ lines.push(ui_js_1.style.bold('Parameters'), JSON.stringify(params));
125
+ }
126
+ return lines.join('\n');
127
+ }
128
+ /** Describe a filter in plain English, for confirming what is about to run. */
129
+ function describeFilter(filter) {
130
+ if (!filter || Object.keys(filter).length === 0)
131
+ return 'all documents';
132
+ return JSON.stringify(filter);
133
+ }
134
+ /** Wrap long text to the terminal width, preserving existing newlines. */
135
+ function wrapText(text, width = (0, ui_js_1.terminalWidth)() - 2) {
136
+ return text
137
+ .split('\n')
138
+ .map((paragraph) => {
139
+ if ((0, ui_js_1.displayWidth)(paragraph) <= width)
140
+ return paragraph;
141
+ const words = paragraph.split(' ');
142
+ const lines = [];
143
+ let current = '';
144
+ for (const word of words) {
145
+ if (current === '') {
146
+ current = word;
147
+ }
148
+ else if ((0, ui_js_1.displayWidth)(`${current} ${word}`) <= width) {
149
+ current = `${current} ${word}`;
150
+ }
151
+ else {
152
+ lines.push(current);
153
+ current = word;
154
+ }
155
+ }
156
+ if (current)
157
+ lines.push(current);
158
+ return lines.join('\n');
159
+ })
160
+ .join('\n');
161
+ }
@@ -0,0 +1,211 @@
1
+ "use strict";
2
+ /**
3
+ * Schema inference for the guided CLI.
4
+ *
5
+ * MongoLite stores documents as JSON, so there is no declared schema to read.
6
+ * We sample documents and derive the field paths, their types, how often they
7
+ * appear and a handful of example values. That is enough to offer the user a
8
+ * list of "columns" to filter and sort on without them knowing anything about
9
+ * the shape of the data up front.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.valueType = valueType;
13
+ exports.inferSchema = inferSchema;
14
+ exports.defaultColumns = defaultColumns;
15
+ exports.getPath = getPath;
16
+ exports.describeField = describeField;
17
+ const MAX_SAMPLES = 5;
18
+ const MAX_DISTINCT = 25;
19
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/;
20
+ /** Classify a single JSON value into one of our display types. */
21
+ function valueType(value) {
22
+ if (value === null || value === undefined)
23
+ return 'null';
24
+ if (value instanceof Date)
25
+ return 'date';
26
+ if (Array.isArray(value))
27
+ return 'array';
28
+ if (typeof value === 'string')
29
+ return ISO_DATE.test(value) ? 'date' : 'string';
30
+ if (typeof value === 'number')
31
+ return 'number';
32
+ if (typeof value === 'boolean')
33
+ return 'boolean';
34
+ if (typeof value === 'object')
35
+ return 'object';
36
+ return 'string';
37
+ }
38
+ function accumulator(path) {
39
+ return {
40
+ path,
41
+ typeCounts: new Map(),
42
+ count: 0,
43
+ examples: [],
44
+ distinct: new Map(),
45
+ distinctOverflow: false,
46
+ elementTypes: new Set(),
47
+ elementFields: new Set(),
48
+ };
49
+ }
50
+ function isPlainObject(value) {
51
+ return (typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date));
52
+ }
53
+ function recordValue(acc, value) {
54
+ const type = valueType(value);
55
+ acc.count += 1;
56
+ acc.typeCounts.set(type, (acc.typeCounts.get(type) ?? 0) + 1);
57
+ if (acc.examples.length < MAX_SAMPLES && value !== null && value !== undefined) {
58
+ const alreadySeen = acc.examples.some((existing) => stableKey(existing) === stableKey(value));
59
+ if (!alreadySeen)
60
+ acc.examples.push(value);
61
+ }
62
+ // Track distinct values for scalars only - object/array equality is not worth
63
+ // the noise, and a "pick a value" prompt only makes sense for scalars.
64
+ if (type === 'string' || type === 'number' || type === 'boolean' || type === 'date') {
65
+ if (!acc.distinctOverflow) {
66
+ const key = stableKey(value);
67
+ if (!acc.distinct.has(key)) {
68
+ if (acc.distinct.size >= MAX_DISTINCT) {
69
+ acc.distinctOverflow = true;
70
+ acc.distinct.clear();
71
+ }
72
+ else {
73
+ acc.distinct.set(key, value);
74
+ }
75
+ }
76
+ }
77
+ }
78
+ if (Array.isArray(value)) {
79
+ for (const element of value) {
80
+ acc.elementTypes.add(valueType(element));
81
+ if (isPlainObject(element)) {
82
+ for (const key of Object.keys(element))
83
+ acc.elementFields.add(key);
84
+ }
85
+ }
86
+ }
87
+ }
88
+ function stableKey(value) {
89
+ if (value instanceof Date)
90
+ return `date:${value.toISOString()}`;
91
+ if (typeof value === 'object' && value !== null)
92
+ return `json:${JSON.stringify(value)}`;
93
+ return `${typeof value}:${String(value)}`;
94
+ }
95
+ function walk(doc, prefix, depth, maxDepth, fields) {
96
+ for (const [key, value] of Object.entries(doc)) {
97
+ const path = prefix ? `${prefix}.${key}` : key;
98
+ let acc = fields.get(path);
99
+ if (!acc) {
100
+ acc = accumulator(path);
101
+ fields.set(path, acc);
102
+ }
103
+ recordValue(acc, value);
104
+ if (isPlainObject(value) && depth < maxDepth) {
105
+ walk(value, path, depth + 1, maxDepth, fields);
106
+ }
107
+ }
108
+ }
109
+ /**
110
+ * Infer a schema from a sample of documents.
111
+ * Fields are ordered by how commonly they appear, then alphabetically, with
112
+ * `_id` pinned first so result tables always lead with the identifier.
113
+ */
114
+ function inferSchema(docs, options = {}) {
115
+ const maxDepth = options.maxDepth ?? 3;
116
+ const fields = new Map();
117
+ for (const doc of docs) {
118
+ if (!isPlainObject(doc))
119
+ continue;
120
+ walk(doc, '', 1, maxDepth, fields);
121
+ }
122
+ const total = docs.length || 1;
123
+ const inferred = [...fields.values()].map((acc) => {
124
+ const types = [...acc.typeCounts.entries()].sort((a, b) => b[1] - a[1]).map(([type]) => type);
125
+ // Prefer a concrete type over `null` when a field is sometimes empty.
126
+ const primaryType = types.find((t) => t !== 'null') ?? types[0] ?? 'null';
127
+ const distinctValues = acc.distinctOverflow ? undefined : [...acc.distinct.values()];
128
+ return {
129
+ path: acc.path,
130
+ types,
131
+ primaryType,
132
+ count: acc.count,
133
+ presence: acc.count / total,
134
+ examples: acc.examples,
135
+ elementTypes: acc.elementTypes.size ? [...acc.elementTypes] : undefined,
136
+ elementFields: acc.elementFields.size ? [...acc.elementFields].sort() : undefined,
137
+ lowCardinality: Boolean(distinctValues && distinctValues.length > 0 && distinctValues.length <= 12),
138
+ distinctValues,
139
+ };
140
+ });
141
+ inferred.sort((a, b) => {
142
+ if (a.path === '_id')
143
+ return -1;
144
+ if (b.path === '_id')
145
+ return 1;
146
+ const depthDiff = a.path.split('.').length - b.path.split('.').length;
147
+ if (depthDiff !== 0)
148
+ return depthDiff;
149
+ if (b.count !== a.count)
150
+ return b.count - a.count;
151
+ return a.path.localeCompare(b.path);
152
+ });
153
+ return { sampled: docs.length, fields: inferred };
154
+ }
155
+ /**
156
+ * Pick a sensible default set of columns for a results table: shallow, common,
157
+ * scalar-ish fields, leading with `_id`.
158
+ */
159
+ function defaultColumns(schema, max = 6) {
160
+ const scalarish = ['string', 'number', 'boolean', 'date'];
161
+ const candidates = schema.fields.filter((field) => field.path !== '_id' &&
162
+ !field.path.includes('.') &&
163
+ field.presence >= 0.5 &&
164
+ scalarish.includes(field.primaryType));
165
+ const columns = candidates.slice(0, max - 1).map((field) => field.path);
166
+ const hasId = schema.fields.some((field) => field.path === '_id');
167
+ if (columns.length === 0) {
168
+ // Nothing scalar and shallow - fall back to whatever we do have.
169
+ const fallback = schema.fields
170
+ .filter((field) => field.path !== '_id' && !field.path.includes('.'))
171
+ .slice(0, max - 1)
172
+ .map((field) => field.path);
173
+ return hasId ? ['_id', ...fallback] : fallback;
174
+ }
175
+ return hasId ? ['_id', ...columns] : columns;
176
+ }
177
+ /** Read a dotted path out of a document, descending through objects. */
178
+ function getPath(doc, path) {
179
+ return path.split('.').reduce((current, segment) => {
180
+ if (current === null || current === undefined)
181
+ return undefined;
182
+ if (Array.isArray(current)) {
183
+ const index = Number.parseInt(segment, 10);
184
+ return Number.isInteger(index) ? current[index] : undefined;
185
+ }
186
+ if (typeof current === 'object')
187
+ return current[segment];
188
+ return undefined;
189
+ }, doc);
190
+ }
191
+ /** Human-readable one-line summary of a field, used as a menu hint. */
192
+ function describeField(field) {
193
+ const parts = [];
194
+ const type = field.primaryType === 'array' && field.elementTypes?.length
195
+ ? `array of ${field.elementTypes.filter((t) => t !== 'null').join('/') || 'values'}`
196
+ : field.primaryType;
197
+ parts.push(type);
198
+ if (field.presence < 0.999) {
199
+ parts.push(`in ${Math.round(field.presence * 100)}% of docs`);
200
+ }
201
+ if (field.examples.length > 0) {
202
+ const example = field.examples[0];
203
+ const rendered = example instanceof Date
204
+ ? example.toISOString()
205
+ : typeof example === 'object'
206
+ ? JSON.stringify(example)
207
+ : String(example);
208
+ parts.push(`e.g. ${rendered.length > 30 ? `${rendered.slice(0, 29)}…` : rendered}`);
209
+ }
210
+ return `— ${parts.join(', ')}`;
211
+ }