@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,929 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Explorer = void 0;
7
+ /**
8
+ * The guided MongoLite explorer.
9
+ *
10
+ * The flow is deliberately linear — pick a database, pick a collection, pick
11
+ * something to do — and every step offers real choices derived from the data
12
+ * rather than asking the user to type something they have to know already.
13
+ * MongoDB syntax stays available under "Advanced", and every guided query
14
+ * prints the filter it built, so the CLI teaches the syntax instead of hiding
15
+ * it.
16
+ */
17
+ const node_fs_1 = require("node:fs");
18
+ const node_path_1 = __importDefault(require("node:path"));
19
+ const index_js_1 = require("../index.js");
20
+ const discover_js_1 = require("./discover.js");
21
+ const schema_js_1 = require("./schema.js");
22
+ const filter_builder_js_1 = require("./filter-builder.js");
23
+ const format_js_1 = require("./format.js");
24
+ const ui_js_1 = require("./ui.js");
25
+ class Explorer {
26
+ constructor(options = {}) {
27
+ this.options = options;
28
+ this.client = null;
29
+ this.dbPath = null;
30
+ this.collectionName = null;
31
+ this.schema = null;
32
+ this.lastSql = null;
33
+ this.lastQuery = null;
34
+ /**
35
+ * `-d`/`-c` are a starting point, not a preference. Once they have been
36
+ * honoured they are forgotten, otherwise "open a different database" would
37
+ * take you straight back to the one on the command line.
38
+ */
39
+ this.startingDatabaseUsed = false;
40
+ this.startingCollectionUsed = false;
41
+ this.sampleSize = options.sampleSize ?? 200;
42
+ this.pageSize = options.pageSize ?? 20;
43
+ }
44
+ // ---------------------------------------------------------------- lifecycle
45
+ async run() {
46
+ (0, ui_js_1.info)(`${ui_js_1.style.bold('MongoLite')} ${ui_js_1.style.grey('· interactive explorer')}`);
47
+ (0, ui_js_1.info)(ui_js_1.style.grey('Ctrl+C to quit at any point.\n'));
48
+ try {
49
+ for (;;) {
50
+ if (!this.client) {
51
+ const connected = await this.chooseDatabase();
52
+ if (!connected)
53
+ return;
54
+ }
55
+ if (!this.collectionName) {
56
+ const chosen = await this.chooseCollection();
57
+ if (chosen === 'quit')
58
+ return;
59
+ if (chosen === 'switch-database') {
60
+ await this.disconnect();
61
+ continue;
62
+ }
63
+ }
64
+ const outcome = await this.collectionMenu();
65
+ if (outcome === 'quit')
66
+ return;
67
+ if (outcome === 'switch-collection') {
68
+ this.collectionName = null;
69
+ this.schema = null;
70
+ }
71
+ if (outcome === 'switch-database') {
72
+ await this.disconnect();
73
+ this.collectionName = null;
74
+ this.schema = null;
75
+ }
76
+ }
77
+ }
78
+ finally {
79
+ await this.disconnect();
80
+ }
81
+ }
82
+ async disconnect() {
83
+ if (this.client) {
84
+ try {
85
+ await this.client.close();
86
+ }
87
+ catch {
88
+ // Closing a database we may never have opened is not worth reporting.
89
+ }
90
+ }
91
+ this.client = null;
92
+ this.dbPath = null;
93
+ }
94
+ // ------------------------------------------------------------ step 1: file
95
+ async chooseDatabase() {
96
+ // An explicit -d wins the first time round, as long as it is usable.
97
+ if (this.options.database && !this.startingDatabaseUsed) {
98
+ this.startingDatabaseUsed = true;
99
+ const resolved = (0, discover_js_1.resolveDatabasePath)(this.options.database);
100
+ if ((0, discover_js_1.databaseExists)(resolved)) {
101
+ if (await this.connect(resolved))
102
+ return true;
103
+ }
104
+ else {
105
+ (0, ui_js_1.warn)(`No database at ${ui_js_1.style.bold(resolved)} — pick one below.`);
106
+ }
107
+ }
108
+ for (;;) {
109
+ const candidates = (0, discover_js_1.findDatabaseFiles)(process.cwd());
110
+ const choices = candidates.map((candidate) => ({
111
+ label: candidate.relativePath,
112
+ value: candidate.path,
113
+ hint: `${(0, discover_js_1.formatBytes)(candidate.sizeBytes)} · ${(0, discover_js_1.formatRelativeTime)(candidate.modified)}`,
114
+ }));
115
+ if (choices.length === 0) {
116
+ (0, ui_js_1.warn)('No .db/.sqlite files found in this directory.');
117
+ }
118
+ choices.push({ label: 'Enter a path manually…', value: '__manual__' });
119
+ choices.push({ label: 'Quit', value: '__quit__' });
120
+ let picked;
121
+ try {
122
+ picked = await (0, ui_js_1.select)({
123
+ message: 'Which database do you want to open?',
124
+ choices,
125
+ hint: ' ↑/↓ move · type to filter · enter to open',
126
+ });
127
+ }
128
+ catch (err) {
129
+ if (err instanceof ui_js_1.PromptCancelledError)
130
+ return false;
131
+ throw err;
132
+ }
133
+ if (picked === '__quit__')
134
+ return false;
135
+ const target = picked === '__manual__'
136
+ ? (0, discover_js_1.resolveDatabasePath)(await (0, ui_js_1.text)({
137
+ message: 'Path to the database file',
138
+ defaultValue: node_path_1.default.join(process.cwd(), 'mongolite.db'),
139
+ }))
140
+ : picked;
141
+ if (!(0, discover_js_1.databaseExists)(target)) {
142
+ (0, ui_js_1.error)(`No file at ${target}`);
143
+ continue;
144
+ }
145
+ if (await this.connect(target))
146
+ return true;
147
+ }
148
+ }
149
+ async connect(dbPath) {
150
+ try {
151
+ const client = new index_js_1.MongoLite(dbPath, { verbose: this.options.verbose });
152
+ await client.connect();
153
+ this.client = client;
154
+ this.dbPath = dbPath;
155
+ (0, ui_js_1.success)(`Opened ${ui_js_1.style.bold(node_path_1.default.relative(process.cwd(), dbPath) || dbPath)}`);
156
+ return true;
157
+ }
158
+ catch (err) {
159
+ (0, ui_js_1.error)(`Could not open that database: ${message(err)}`);
160
+ return false;
161
+ }
162
+ }
163
+ // ------------------------------------------------------ step 2: collection
164
+ async chooseCollection() {
165
+ const client = this.requireClient();
166
+ let collections;
167
+ try {
168
+ collections = await (0, discover_js_1.listCollectionInfo)(client.database);
169
+ }
170
+ catch (err) {
171
+ (0, ui_js_1.error)(`Could not read the collections: ${message(err)}`);
172
+ return 'switch-database';
173
+ }
174
+ const usable = collections.filter((collection) => collection.isMongoLite);
175
+ const foreign = collections.filter((collection) => !collection.isMongoLite);
176
+ if (usable.length === 0) {
177
+ (0, ui_js_1.warn)('This database has no MongoLite collections.');
178
+ if (foreign.length > 0) {
179
+ (0, ui_js_1.info)(ui_js_1.style.grey(` It does contain ${foreign.length} other table(s): ${foreign
180
+ .map((table) => table.name)
181
+ .slice(0, 5)
182
+ .join(', ')}`));
183
+ (0, ui_js_1.info)(ui_js_1.style.grey(' You can still inspect them with the raw SQL option.'));
184
+ }
185
+ const next = await (0, ui_js_1.select)({
186
+ message: 'What now?',
187
+ choices: [
188
+ { label: 'Open a different database', value: 'switch-database' },
189
+ { label: 'Run raw SQL against this one', value: 'sql' },
190
+ { label: 'Quit', value: 'quit' },
191
+ ],
192
+ });
193
+ if (next === 'sql') {
194
+ await this.runRawSql();
195
+ return this.chooseCollection();
196
+ }
197
+ return next;
198
+ }
199
+ // An explicit -c only counts the first time, and only if it is really there.
200
+ if (this.options.collection && !this.startingCollectionUsed) {
201
+ this.startingCollectionUsed = true;
202
+ const match = usable.find((collection) => collection.name === this.options.collection);
203
+ if (match) {
204
+ await this.useCollection(match.name);
205
+ return 'ok';
206
+ }
207
+ (0, ui_js_1.warn)(`No collection named "${this.options.collection}" in this database.`);
208
+ }
209
+ const choices = usable.map((collection) => ({
210
+ label: collection.name,
211
+ value: collection.name,
212
+ hint: `${collection.count.toLocaleString()} document${collection.count === 1 ? '' : 's'}`,
213
+ }));
214
+ choices.push({ label: 'Open a different database', value: '__switch__' });
215
+ choices.push({ label: 'Quit', value: '__quit__' });
216
+ let picked;
217
+ try {
218
+ picked = await (0, ui_js_1.select)({ message: 'Which collection?', choices });
219
+ }
220
+ catch (err) {
221
+ if (err instanceof ui_js_1.PromptCancelledError)
222
+ return 'switch-database';
223
+ throw err;
224
+ }
225
+ if (picked === '__quit__')
226
+ return 'quit';
227
+ if (picked === '__switch__')
228
+ return 'switch-database';
229
+ await this.useCollection(picked);
230
+ return 'ok';
231
+ }
232
+ async useCollection(name) {
233
+ this.collectionName = name;
234
+ this.lastQuery = null;
235
+ const collection = this.collection();
236
+ try {
237
+ const sample = (await collection.find({}).limit(this.sampleSize).toArray());
238
+ this.schema = (0, schema_js_1.inferSchema)(sample);
239
+ const total = await collection.countDocuments({});
240
+ const fieldCount = this.schema.fields.length;
241
+ (0, ui_js_1.info)(ui_js_1.style.grey(` ${total.toLocaleString()} documents · ${fieldCount} field${fieldCount === 1 ? '' : 's'} inferred from ${sample.length.toLocaleString()} sampled`));
242
+ if (total === 0) {
243
+ (0, ui_js_1.warn)('This collection is empty, so there is not much to explore yet.');
244
+ }
245
+ }
246
+ catch (err) {
247
+ (0, ui_js_1.warn)(`Could not sample "${name}": ${message(err)}`);
248
+ this.schema = { sampled: 0, fields: [] };
249
+ }
250
+ }
251
+ // -------------------------------------------------------- step 3: what now
252
+ async collectionMenu() {
253
+ const choices = [
254
+ { label: 'Browse documents', value: 'browse', hint: '— page through everything' },
255
+ { label: 'Find documents', value: 'find', hint: '— build a filter step by step' },
256
+ { label: 'Count documents', value: 'count', hint: '— how many match a filter' },
257
+ { label: 'List the values in a field', value: 'distinct', hint: '— distinct values' },
258
+ { label: 'Count documents by field', value: 'group', hint: '— e.g. orders per status' },
259
+ { label: 'Show the fields in this collection', value: 'schema' },
260
+ { label: 'Show indexes', value: 'indexes' },
261
+ { label: 'Advanced', value: 'json-filter', separator: true },
262
+ { label: 'Write a MongoDB filter yourself', value: 'json-filter', hint: '— JSON' },
263
+ { label: 'Run raw SQL', value: 'sql' },
264
+ { label: 'Show the SQL for the last query', value: 'last-sql' },
265
+ { label: ' ', value: 'quit', separator: true },
266
+ { label: 'Switch collection', value: 'switch-collection' },
267
+ { label: 'Open a different database', value: 'switch-database' },
268
+ { label: 'Quit', value: 'quit' },
269
+ ];
270
+ let action;
271
+ try {
272
+ action = await (0, ui_js_1.select)({
273
+ message: `${this.collectionName} — what would you like to do?`,
274
+ choices,
275
+ pageSize: 16,
276
+ });
277
+ }
278
+ catch (err) {
279
+ if (err instanceof ui_js_1.PromptCancelledError)
280
+ return 'switch-collection';
281
+ throw err;
282
+ }
283
+ try {
284
+ switch (action) {
285
+ case 'browse':
286
+ await this.runQuery({ filter: {}, sort: null, description: 'all documents' });
287
+ break;
288
+ case 'find':
289
+ await this.guidedFind();
290
+ break;
291
+ case 'count':
292
+ await this.countFlow();
293
+ break;
294
+ case 'distinct':
295
+ await this.distinctFlow();
296
+ break;
297
+ case 'group':
298
+ await this.groupFlow();
299
+ break;
300
+ case 'schema':
301
+ await this.showSchema();
302
+ break;
303
+ case 'indexes':
304
+ await this.showIndexes();
305
+ break;
306
+ case 'json-filter':
307
+ await this.jsonFilterFlow();
308
+ break;
309
+ case 'sql':
310
+ await this.runRawSql();
311
+ break;
312
+ case 'last-sql':
313
+ this.showLastSql();
314
+ await (0, ui_js_1.pause)();
315
+ break;
316
+ case 'switch-collection':
317
+ return 'switch-collection';
318
+ case 'switch-database':
319
+ return 'switch-database';
320
+ case 'quit':
321
+ return 'quit';
322
+ }
323
+ }
324
+ catch (err) {
325
+ if (!(err instanceof ui_js_1.PromptCancelledError)) {
326
+ (0, ui_js_1.error)(message(err));
327
+ await (0, ui_js_1.pause)();
328
+ }
329
+ }
330
+ return 'stay';
331
+ }
332
+ // ------------------------------------------------------------ guided query
333
+ async guidedFind() {
334
+ const conditions = await this.buildConditions();
335
+ if (conditions === null)
336
+ return;
337
+ let filter = {};
338
+ let description = 'all documents';
339
+ if (conditions.length > 0) {
340
+ const mode = conditions.length === 1
341
+ ? 'all'
342
+ : await (0, ui_js_1.select)({
343
+ message: 'Should documents match…',
344
+ choices: [
345
+ { label: 'all of these conditions', value: 'all', hint: '— AND' },
346
+ { label: 'any of these conditions', value: 'any', hint: '— OR' },
347
+ ],
348
+ });
349
+ filter = (0, filter_builder_js_1.combineConditions)(conditions, mode);
350
+ description = conditions
351
+ .map((condition) => (0, filter_builder_js_1.describeCondition)(condition))
352
+ .join(mode === 'all' ? ' and ' : ' or ');
353
+ }
354
+ const sort = await this.chooseSort();
355
+ await this.runQuery({ filter, sort, description });
356
+ }
357
+ /** Collect conditions until the user is done. Returns null when cancelled. */
358
+ async buildConditions(existing = []) {
359
+ const conditions = [...existing];
360
+ for (;;) {
361
+ if (conditions.length > 0) {
362
+ (0, ui_js_1.heading)('Conditions so far');
363
+ conditions.forEach((condition, index) => {
364
+ (0, ui_js_1.info)(` ${index + 1}. ${(0, filter_builder_js_1.describeCondition)(condition)}`);
365
+ });
366
+ (0, ui_js_1.info)('');
367
+ }
368
+ const choices = [
369
+ {
370
+ label: conditions.length === 0 ? 'Add a condition' : 'Add another condition',
371
+ value: 'add',
372
+ },
373
+ {
374
+ label: conditions.length === 0 ? 'Run without any filter' : 'Run the query',
375
+ value: 'run',
376
+ },
377
+ ];
378
+ if (conditions.length > 0)
379
+ choices.push({ label: 'Remove a condition', value: 'remove' });
380
+ choices.push({ label: 'Cancel', value: 'cancel' });
381
+ let step;
382
+ try {
383
+ step = await (0, ui_js_1.select)({ message: 'Build your query', choices });
384
+ }
385
+ catch (err) {
386
+ if (err instanceof ui_js_1.PromptCancelledError)
387
+ return null;
388
+ throw err;
389
+ }
390
+ if (step === 'cancel')
391
+ return null;
392
+ if (step === 'run')
393
+ return conditions;
394
+ if (step === 'remove') {
395
+ const index = await (0, ui_js_1.select)({
396
+ message: 'Remove which condition?',
397
+ choices: conditions.map((condition, position) => ({
398
+ label: (0, filter_builder_js_1.describeCondition)(condition),
399
+ value: position,
400
+ })),
401
+ });
402
+ conditions.splice(index, 1);
403
+ continue;
404
+ }
405
+ const condition = await this.buildOneCondition();
406
+ if (condition)
407
+ conditions.push(condition);
408
+ }
409
+ }
410
+ async buildOneCondition() {
411
+ const schema = this.schema;
412
+ if (!schema || schema.fields.length === 0) {
413
+ (0, ui_js_1.warn)('No fields were found in this collection, so there is nothing to filter on.');
414
+ return null;
415
+ }
416
+ let field;
417
+ try {
418
+ field = await (0, ui_js_1.select)({
419
+ message: 'Which field?',
420
+ choices: [
421
+ ...schema.fields.map((candidate) => ({
422
+ label: candidate.path,
423
+ value: candidate,
424
+ hint: (0, schema_js_1.describeField)(candidate),
425
+ })),
426
+ { label: 'Type a field name myself…', value: '__custom__' },
427
+ ],
428
+ pageSize: 14,
429
+ });
430
+ }
431
+ catch (err) {
432
+ if (err instanceof ui_js_1.PromptCancelledError)
433
+ return null;
434
+ throw err;
435
+ }
436
+ const resolved = field === '__custom__'
437
+ ? {
438
+ path: await (0, ui_js_1.text)({
439
+ message: 'Field name (dot notation for nested fields, e.g. address.city)',
440
+ }),
441
+ types: ['string'],
442
+ primaryType: 'string',
443
+ count: 0,
444
+ presence: 0,
445
+ examples: [],
446
+ lowCardinality: false,
447
+ }
448
+ : field;
449
+ const operators = (0, filter_builder_js_1.operatorsFor)(resolved);
450
+ let operatorId;
451
+ try {
452
+ operatorId = await (0, ui_js_1.select)({
453
+ message: `${resolved.path} …`,
454
+ choices: operators.map((operator) => ({
455
+ label: operator.label,
456
+ value: operator.id,
457
+ hint: operator.hint ? `— ${operator.hint}` : undefined,
458
+ })),
459
+ pageSize: 14,
460
+ });
461
+ }
462
+ catch (err) {
463
+ if (err instanceof ui_js_1.PromptCancelledError)
464
+ return null;
465
+ throw err;
466
+ }
467
+ const operator = operators.find((candidate) => candidate.id === operatorId);
468
+ if (!operator)
469
+ return null;
470
+ // Values are parsed as the element type for array fields, so
471
+ // "tags contains work" compares against the strings inside the array.
472
+ const valueType = resolved.primaryType === 'array'
473
+ ? (resolved.elementTypes?.find((type) => type !== 'null') ?? 'string')
474
+ : resolved.primaryType;
475
+ const values = [];
476
+ try {
477
+ if (operator.arity === 'one') {
478
+ values.push(await this.askForValue(resolved, valueType, operator.valuePrompt ?? 'Value'));
479
+ }
480
+ else if (operator.arity === 'two') {
481
+ values.push(await this.askForValue(resolved, valueType, operator.valuePrompt ?? 'From'));
482
+ values.push(await this.askForValue(resolved, valueType, operator.secondValuePrompt ?? 'To'));
483
+ }
484
+ else if (operator.arity === 'many') {
485
+ const raw = await (0, ui_js_1.text)({
486
+ message: operator.valuePrompt ?? 'Values (comma separated)',
487
+ validate: (input) => {
488
+ try {
489
+ (0, filter_builder_js_1.parseValueList)(input, valueType);
490
+ return undefined;
491
+ }
492
+ catch (err) {
493
+ return message(err);
494
+ }
495
+ },
496
+ });
497
+ values.push(...(0, filter_builder_js_1.parseValueList)(raw, valueType));
498
+ }
499
+ }
500
+ catch (err) {
501
+ if (err instanceof ui_js_1.PromptCancelledError)
502
+ return null;
503
+ throw err;
504
+ }
505
+ return { field: resolved.path, operator: operatorId, values, fieldType: valueType };
506
+ }
507
+ /**
508
+ * Ask for one value. When the field has few distinct values in the sample we
509
+ * offer them as a menu — no typing, no guessing at spelling or casing.
510
+ */
511
+ async askForValue(field, valueType, prompt) {
512
+ if (field.lowCardinality && field.distinctValues && field.distinctValues.length > 1) {
513
+ const picked = await (0, ui_js_1.select)({
514
+ message: prompt,
515
+ choices: [
516
+ ...field.distinctValues.map((value) => ({
517
+ label: (0, format_js_1.formatCell)(value),
518
+ value,
519
+ })),
520
+ { label: 'Something else…', value: '__other__' },
521
+ ],
522
+ hint: ui_js_1.style.grey(` values seen in ${field.count} sampled document(s)`),
523
+ pageSize: 14,
524
+ });
525
+ if (picked !== '__other__')
526
+ return picked;
527
+ }
528
+ const raw = await (0, ui_js_1.text)({
529
+ message: prompt,
530
+ hint: field.examples.length
531
+ ? ui_js_1.style.grey(` examples: ${field.examples.slice(0, 3).map(format_js_1.formatCell).join(', ')}`)
532
+ : undefined,
533
+ validate: (input) => {
534
+ try {
535
+ (0, filter_builder_js_1.parseValue)(input, valueType);
536
+ return undefined;
537
+ }
538
+ catch (err) {
539
+ return message(err);
540
+ }
541
+ },
542
+ });
543
+ return (0, filter_builder_js_1.parseValue)(raw, valueType);
544
+ }
545
+ async chooseSort() {
546
+ const schema = this.schema;
547
+ if (!schema || schema.fields.length === 0)
548
+ return null;
549
+ const sortable = schema.fields.filter((field) => ['string', 'number', 'date', 'boolean'].includes(field.primaryType));
550
+ if (sortable.length === 0)
551
+ return null;
552
+ const field = await (0, ui_js_1.select)({
553
+ message: 'Sort the results by…',
554
+ choices: [
555
+ { label: "Don't sort", value: null },
556
+ ...sortable.map((candidate) => ({
557
+ label: candidate.path,
558
+ value: candidate.path,
559
+ hint: (0, schema_js_1.describeField)(candidate),
560
+ })),
561
+ ],
562
+ pageSize: 12,
563
+ });
564
+ if (!field)
565
+ return null;
566
+ const direction = await (0, ui_js_1.select)({
567
+ message: `Sort ${field}…`,
568
+ choices: [
569
+ { label: 'ascending', value: 1, hint: '— A→Z, smallest first, oldest first' },
570
+ { label: 'descending', value: -1, hint: '— Z→A, largest first, newest first' },
571
+ ],
572
+ });
573
+ return { [field]: direction };
574
+ }
575
+ // -------------------------------------------------------------- run & view
576
+ async runQuery(query) {
577
+ this.lastQuery = query;
578
+ const collection = this.collection();
579
+ let total;
580
+ try {
581
+ total = await collection.countDocuments(query.filter);
582
+ }
583
+ catch (err) {
584
+ (0, ui_js_1.error)(`That query could not run: ${message(err)}`);
585
+ await (0, ui_js_1.pause)();
586
+ return;
587
+ }
588
+ (0, ui_js_1.heading)('Query');
589
+ (0, ui_js_1.info)(` ${ui_js_1.style.grey('matching')} ${query.description}`);
590
+ (0, ui_js_1.info)(` ${ui_js_1.style.grey('filter')} ${JSON.stringify(query.filter)}`);
591
+ if (query.sort)
592
+ (0, ui_js_1.info)(` ${ui_js_1.style.grey('sort')} ${JSON.stringify(query.sort)}`);
593
+ (0, ui_js_1.info)(` ${ui_js_1.style.grey('matches')} ${ui_js_1.style.bold(total.toLocaleString())} document(s)`);
594
+ if (total === 0) {
595
+ (0, ui_js_1.info)('');
596
+ (0, ui_js_1.info)(ui_js_1.style.grey(' Nothing matched. Try a looser condition, or check the field spelling.'));
597
+ await (0, ui_js_1.pause)();
598
+ return;
599
+ }
600
+ let columns = this.schema ? (0, schema_js_1.defaultColumns)(this.schema) : ['_id'];
601
+ let offset = 0;
602
+ for (;;) {
603
+ const cursor = collection.find(query.filter).skip(offset).limit(this.pageSize);
604
+ if (query.sort)
605
+ cursor.sort(query.sort);
606
+ this.lastSql = cursor.toSQL();
607
+ let docs;
608
+ try {
609
+ docs = (await cursor.toArray());
610
+ }
611
+ catch (err) {
612
+ (0, ui_js_1.error)(`Could not fetch results: ${message(err)}`);
613
+ await (0, ui_js_1.pause)();
614
+ return;
615
+ }
616
+ if (docs.length === 0 && offset > 0) {
617
+ (0, ui_js_1.info)(ui_js_1.style.grey(' No more documents.'));
618
+ offset = Math.max(0, offset - this.pageSize);
619
+ continue;
620
+ }
621
+ (0, ui_js_1.info)('');
622
+ (0, ui_js_1.info)((0, format_js_1.renderTable)(docs, { columns, startIndex: offset }));
623
+ (0, ui_js_1.info)(ui_js_1.style.grey(` showing ${offset + 1}-${offset + docs.length} of ${total.toLocaleString()}`));
624
+ (0, ui_js_1.info)('');
625
+ const choices = [];
626
+ if (offset + docs.length < total) {
627
+ choices.push({ label: `Show the next ${this.pageSize}`, value: 'next' });
628
+ }
629
+ if (offset > 0)
630
+ choices.push({ label: 'Show the previous page', value: 'prev' });
631
+ choices.push({ label: 'View one document in full', value: 'view' });
632
+ choices.push({ label: 'Choose which columns to show', value: 'columns' });
633
+ choices.push({ label: 'Save these results to a JSON file', value: 'export' });
634
+ choices.push({ label: 'Show the SQL this ran', value: 'sql' });
635
+ choices.push({ label: 'Change the filter', value: 'refine' });
636
+ choices.push({ label: 'Back to the menu', value: 'done' });
637
+ let action;
638
+ try {
639
+ action = await (0, ui_js_1.select)({ message: 'Results', choices, pageSize: 12 });
640
+ }
641
+ catch (err) {
642
+ if (err instanceof ui_js_1.PromptCancelledError)
643
+ return;
644
+ throw err;
645
+ }
646
+ switch (action) {
647
+ case 'next':
648
+ offset += this.pageSize;
649
+ break;
650
+ case 'prev':
651
+ offset = Math.max(0, offset - this.pageSize);
652
+ break;
653
+ case 'view': {
654
+ const doc = await (0, ui_js_1.select)({
655
+ message: 'Which document?',
656
+ choices: docs.map((candidate, index) => ({
657
+ label: `${offset + index + 1}. ${(0, format_js_1.formatCell)(candidate._id)}`,
658
+ value: candidate,
659
+ hint: columns
660
+ .filter((column) => column !== '_id')
661
+ .slice(0, 2)
662
+ .map((column) => (0, format_js_1.formatCell)((0, schema_js_1.getPath)(candidate, column)))
663
+ .join(' · '),
664
+ })),
665
+ pageSize: 12,
666
+ });
667
+ (0, ui_js_1.heading)('Document');
668
+ (0, ui_js_1.info)((0, format_js_1.renderDocument)(doc));
669
+ await (0, ui_js_1.pause)();
670
+ break;
671
+ }
672
+ case 'columns':
673
+ columns = await this.chooseColumns(columns);
674
+ break;
675
+ case 'export':
676
+ await this.exportDocuments(query, total);
677
+ break;
678
+ case 'sql':
679
+ this.showLastSql();
680
+ await (0, ui_js_1.pause)();
681
+ break;
682
+ case 'refine':
683
+ return this.guidedFind();
684
+ case 'done':
685
+ return;
686
+ }
687
+ }
688
+ }
689
+ async chooseColumns(current) {
690
+ const schema = this.schema;
691
+ if (!schema)
692
+ return current;
693
+ (0, ui_js_1.info)(ui_js_1.style.grey(` Current columns: ${current.join(', ')}`));
694
+ // No defaultValue: `text` substitutes it for a blank line, which would make
695
+ // "blank for all fields" mean "keep what you already have".
696
+ const raw = await (0, ui_js_1.text)({
697
+ message: 'Columns to show (comma separated, blank for all fields)',
698
+ allowEmpty: true,
699
+ hint: ui_js_1.style.grey(` available: ${schema.fields.map((field) => field.path).join(', ')}`),
700
+ });
701
+ if (raw.trim() === '')
702
+ return schema.fields.map((field) => field.path);
703
+ const columns = raw
704
+ .split(',')
705
+ .map((column) => column.trim())
706
+ .filter((column) => column !== '');
707
+ return columns.length > 0 ? columns : current;
708
+ }
709
+ async exportDocuments(query, total) {
710
+ const limitRaw = await (0, ui_js_1.text)({
711
+ message: `How many documents? (up to ${total})`,
712
+ defaultValue: String(Math.min(total, 1000)),
713
+ validate: (input) => Number.isInteger(Number(input)) && Number(input) > 0 ? undefined : 'Enter a whole number.',
714
+ });
715
+ const limit = Number(limitRaw);
716
+ const file = await (0, ui_js_1.text)({
717
+ message: 'Save to which file?',
718
+ defaultValue: node_path_1.default.join(process.cwd(), `${this.collectionName}-export.json`),
719
+ });
720
+ const cursor = this.collection().find(query.filter).limit(limit);
721
+ if (query.sort)
722
+ cursor.sort(query.sort);
723
+ const docs = await cursor.toArray();
724
+ const target = node_path_1.default.resolve(file);
725
+ if ((0, node_fs_1.existsSync)(target)) {
726
+ const overwrite = await (0, ui_js_1.confirm)(`${target} already exists. Overwrite it?`, false);
727
+ if (!overwrite) {
728
+ (0, ui_js_1.info)(ui_js_1.style.grey(' Not saved.'));
729
+ return;
730
+ }
731
+ }
732
+ (0, node_fs_1.writeFileSync)(target, `${JSON.stringify(docs, null, 2)}\n`, 'utf8');
733
+ (0, ui_js_1.success)(`Wrote ${docs.length.toLocaleString()} document(s) to ${target}`);
734
+ }
735
+ // ------------------------------------------------------------ other actions
736
+ async countFlow() {
737
+ const conditions = await this.buildConditions();
738
+ if (conditions === null)
739
+ return;
740
+ const mode = conditions.length > 1
741
+ ? await (0, ui_js_1.select)({
742
+ message: 'Should documents match…',
743
+ choices: [
744
+ { label: 'all of these conditions', value: 'all', hint: '— AND' },
745
+ { label: 'any of these conditions', value: 'any', hint: '— OR' },
746
+ ],
747
+ })
748
+ : 'all';
749
+ const filter = (0, filter_builder_js_1.combineConditions)(conditions, mode);
750
+ const count = await this.collection().countDocuments(filter);
751
+ (0, ui_js_1.heading)('Count');
752
+ (0, ui_js_1.info)(` ${ui_js_1.style.grey('filter')} ${JSON.stringify(filter)}`);
753
+ (0, ui_js_1.info)(` ${ui_js_1.style.bold(count.toLocaleString())} document(s) match`);
754
+ await (0, ui_js_1.pause)();
755
+ }
756
+ async distinctFlow() {
757
+ const field = await this.pickField('Show the distinct values of which field?');
758
+ if (!field)
759
+ return;
760
+ const values = await this.collection().distinct(field);
761
+ (0, ui_js_1.heading)(`Values of ${field}`);
762
+ if (values.length === 0) {
763
+ (0, ui_js_1.info)(ui_js_1.style.grey(' No values found.'));
764
+ }
765
+ else {
766
+ const shown = values.slice(0, 200);
767
+ shown.forEach((value) => (0, ui_js_1.info)(` ${(0, format_js_1.formatCell)(value)}`));
768
+ (0, ui_js_1.info)(ui_js_1.style.grey(` ${values.length.toLocaleString()} distinct value(s)${values.length > shown.length ? `, showing the first ${shown.length}` : ''}`));
769
+ }
770
+ await (0, ui_js_1.pause)();
771
+ }
772
+ async groupFlow() {
773
+ const field = await this.pickField('Count documents by which field?');
774
+ if (!field)
775
+ return;
776
+ const results = (await this.collection()
777
+ .aggregate([{ $group: { _id: `$${field}`, count: { $sum: 1 } } }, { $sort: { count: -1 } }])
778
+ .toArray());
779
+ (0, ui_js_1.heading)(`Documents per ${field}`);
780
+ if (results.length === 0) {
781
+ (0, ui_js_1.info)(ui_js_1.style.grey(' Nothing to count.'));
782
+ }
783
+ else {
784
+ // `$group` names the grouping key `_id`; label it with the real field so
785
+ // the table reads the way the question was asked.
786
+ const rows = results.map((row) => ({ [field]: row._id, count: row.count }));
787
+ (0, ui_js_1.info)((0, format_js_1.renderTable)(rows, {
788
+ columns: [field, 'count'],
789
+ showRowNumbers: false,
790
+ }));
791
+ (0, ui_js_1.info)(ui_js_1.style.grey(` ${results.length.toLocaleString()} group(s)`));
792
+ }
793
+ await (0, ui_js_1.pause)();
794
+ }
795
+ async pickField(message_) {
796
+ const schema = this.schema;
797
+ if (!schema || schema.fields.length === 0) {
798
+ (0, ui_js_1.warn)('No fields were found in this collection.');
799
+ await (0, ui_js_1.pause)();
800
+ return null;
801
+ }
802
+ try {
803
+ const picked = await (0, ui_js_1.select)({
804
+ message: message_,
805
+ choices: [
806
+ ...schema.fields.map((field) => ({
807
+ label: field.path,
808
+ value: field.path,
809
+ hint: (0, schema_js_1.describeField)(field),
810
+ })),
811
+ { label: 'Type a field name myself…', value: '__custom__' },
812
+ ],
813
+ pageSize: 14,
814
+ });
815
+ if (picked !== '__custom__')
816
+ return picked;
817
+ return await (0, ui_js_1.text)({ message: 'Field name' });
818
+ }
819
+ catch (err) {
820
+ if (err instanceof ui_js_1.PromptCancelledError)
821
+ return null;
822
+ throw err;
823
+ }
824
+ }
825
+ async showSchema() {
826
+ const schema = this.schema;
827
+ (0, ui_js_1.heading)(`Fields in ${this.collectionName}`);
828
+ if (!schema) {
829
+ (0, ui_js_1.info)(ui_js_1.style.grey(' Nothing sampled yet.'));
830
+ }
831
+ else {
832
+ (0, ui_js_1.info)((0, format_js_1.renderSchema)(schema));
833
+ (0, ui_js_1.info)(ui_js_1.style.grey(`\n Inferred from ${schema.sampled.toLocaleString()} sampled document(s) — fields that only appear in rarer documents may be missing.`));
834
+ }
835
+ await (0, ui_js_1.pause)();
836
+ }
837
+ async showIndexes() {
838
+ (0, ui_js_1.heading)(`Indexes on ${this.collectionName}`);
839
+ try {
840
+ const indexes = await this.collection().listIndexes().toArray();
841
+ if (indexes.length === 0) {
842
+ (0, ui_js_1.info)(ui_js_1.style.grey(' No indexes beyond the _id primary key.'));
843
+ }
844
+ else {
845
+ for (const index of indexes) {
846
+ (0, ui_js_1.info)(` ${ui_js_1.style.bold(index.name)} ${ui_js_1.style.grey(JSON.stringify(index.key))}`);
847
+ }
848
+ }
849
+ }
850
+ catch (err) {
851
+ (0, ui_js_1.error)(message(err));
852
+ }
853
+ await (0, ui_js_1.pause)();
854
+ }
855
+ // ------------------------------------------------------------------ escape
856
+ // hatches for people who do know MongoDB / SQL
857
+ async jsonFilterFlow() {
858
+ (0, ui_js_1.info)(ui_js_1.style.grey(' Enter a MongoDB-style filter as JSON, e.g. {"age": {"$gt": 25}, "isActive": true}'));
859
+ const raw = await (0, ui_js_1.text)({
860
+ message: 'Filter',
861
+ defaultValue: '{}',
862
+ validate: (input) => {
863
+ try {
864
+ const parsed = JSON.parse(input);
865
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
866
+ return 'A filter must be a JSON object.';
867
+ }
868
+ return undefined;
869
+ }
870
+ catch (err) {
871
+ return `That is not valid JSON: ${message(err)}`;
872
+ }
873
+ },
874
+ });
875
+ const filter = JSON.parse(raw);
876
+ const sort = await this.chooseSort();
877
+ await this.runQuery({ filter, sort, description: `filter ${raw}` });
878
+ }
879
+ async runRawSql() {
880
+ const client = this.requireClient();
881
+ (0, ui_js_1.info)(ui_js_1.style.grey(` Documents live in a "data" JSON column, e.g. SELECT _id, json_extract(data, '$.name') FROM "${this.collectionName ?? 'collection'}"`));
882
+ const sql = await (0, ui_js_1.multilineText)('SQL to run');
883
+ if (sql.trim() === '')
884
+ return;
885
+ try {
886
+ const rows = await client.database.all(sql);
887
+ (0, ui_js_1.heading)('Rows');
888
+ if (rows.length === 0) {
889
+ (0, ui_js_1.info)(ui_js_1.style.grey(' No rows returned.'));
890
+ }
891
+ else {
892
+ const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
893
+ (0, ui_js_1.info)((0, format_js_1.renderTable)(rows, { columns }));
894
+ (0, ui_js_1.info)(ui_js_1.style.grey(` ${rows.length.toLocaleString()} row(s)`));
895
+ }
896
+ this.lastSql = { sql, params: [] };
897
+ }
898
+ catch (err) {
899
+ (0, ui_js_1.error)(`SQL error: ${message(err)}`);
900
+ }
901
+ await (0, ui_js_1.pause)();
902
+ }
903
+ showLastSql() {
904
+ (0, ui_js_1.heading)('Last query');
905
+ if (!this.lastSql) {
906
+ (0, ui_js_1.info)(ui_js_1.style.grey(' Nothing has run yet.'));
907
+ return;
908
+ }
909
+ if (this.lastQuery) {
910
+ (0, ui_js_1.info)(`${ui_js_1.style.bold('Filter')}\n${JSON.stringify(this.lastQuery.filter, null, 2)}`);
911
+ }
912
+ (0, ui_js_1.info)((0, format_js_1.renderSql)(this.lastSql.sql, this.lastSql.params));
913
+ }
914
+ // ------------------------------------------------------------------ helpers
915
+ requireClient() {
916
+ if (!this.client)
917
+ throw new Error('No database is open.');
918
+ return this.client;
919
+ }
920
+ collection() {
921
+ if (!this.collectionName)
922
+ throw new Error('No collection is selected.');
923
+ return this.requireClient().collection(this.collectionName);
924
+ }
925
+ }
926
+ exports.Explorer = Explorer;
927
+ function message(err) {
928
+ return err instanceof Error ? err.message : String(err);
929
+ }