@thingd/cli 0.54.0 → 0.56.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.
@@ -1 +1 @@
1
- {"version":3,"file":"interactive.d.ts","sourceRoot":"","sources":["../src/interactive.ts"],"names":[],"mappings":"AAyvEA,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC,CA0FvD"}
1
+ {"version":3,"file":"interactive.d.ts","sourceRoot":"","sources":["../src/interactive.ts"],"names":[],"mappings":"AAu8FA,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC,CA0FvD"}
@@ -69,6 +69,7 @@ let collections = [];
69
69
  let streams = [];
70
70
  let queues = [];
71
71
  let objectsByCollection = new Map();
72
+ const collectionOptions = new Map();
72
73
  const expandedSet = new Set(["cat:collections", "cat:streams", "cat:queues"]);
73
74
  let cursorIndex = 0;
74
75
  let maintenanceCursor = 0;
@@ -80,6 +81,8 @@ let totalActiveJobsCount = 0;
80
81
  let totalDeadJobsCount = 0;
81
82
  let totalLinksCount = 0;
82
83
  let cloudError = null;
84
+ const eventsByStream = new Map();
85
+ const jobsByQueue = new Map();
83
86
  let objectsHistory = [];
84
87
  let eventsHistory = [];
85
88
  let activeJobsHistory = [];
@@ -242,7 +245,21 @@ async function fetchResourcesFallback() {
242
245
  objectsByCollection.clear();
243
246
  await Promise.all(collections.map(async (col) => {
244
247
  try {
245
- const list = await db.listObjects(col);
248
+ const opts = collectionOptions.get(col);
249
+ const listOpts = {};
250
+ if (opts?.sortBy) {
251
+ listOpts.sortBy = { field: opts.sortBy, direction: opts.sortDir ?? "asc" };
252
+ }
253
+ if (opts?.limit) {
254
+ listOpts.limit = opts.limit;
255
+ }
256
+ if (opts?.offset) {
257
+ listOpts.offset = opts.offset;
258
+ }
259
+ if (opts?.filter) {
260
+ listOpts.filter = opts.filter;
261
+ }
262
+ const list = await db.listObjects(col, Object.keys(listOpts).length > 0 ? listOpts : undefined);
246
263
  objectsByCollection.set(col, list.map((o) => o.id));
247
264
  }
248
265
  catch {
@@ -287,13 +304,50 @@ async function fetchResources() {
287
304
  objectsByCollection.clear();
288
305
  for (const col of collections) {
289
306
  try {
290
- const list = await db.listObjects(col);
307
+ const opts = collectionOptions.get(col);
308
+ const listOpts = {};
309
+ if (opts?.sortBy) {
310
+ listOpts.sortBy = { field: opts.sortBy, direction: opts.sortDir ?? "asc" };
311
+ }
312
+ if (opts?.limit) {
313
+ listOpts.limit = opts.limit;
314
+ }
315
+ if (opts?.offset) {
316
+ listOpts.offset = opts.offset;
317
+ }
318
+ if (opts?.filter) {
319
+ listOpts.filter = opts.filter;
320
+ }
321
+ const list = await db.listObjects(col, Object.keys(listOpts).length > 0 ? listOpts : undefined);
291
322
  objectsByCollection.set(col, list.map((o) => o.id));
292
323
  }
293
324
  catch {
294
325
  objectsByCollection.set(col, []);
295
326
  }
296
327
  }
328
+ // Populate events per stream
329
+ eventsByStream.clear();
330
+ await Promise.all(streams.map(async (s) => {
331
+ try {
332
+ const evts = await db.events.list(s, { limit: 20 });
333
+ eventsByStream.set(s, evts);
334
+ }
335
+ catch {
336
+ eventsByStream.set(s, []);
337
+ }
338
+ }));
339
+ // Populate jobs per queue
340
+ jobsByQueue.clear();
341
+ await Promise.all(queues.map(async (q) => {
342
+ try {
343
+ const active = await db.queue(q).list();
344
+ const dead = await db.queue(q).dead();
345
+ jobsByQueue.set(q, { active, dead });
346
+ }
347
+ catch {
348
+ jobsByQueue.set(q, { active: [], dead: [] });
349
+ }
350
+ }));
297
351
  // Calculate Deltas for Operations Throughput Rates
298
352
  const prevObjects = objectsHistory.length > 0
299
353
  ? (objectsHistory[objectsHistory.length - 1] ?? totalObjects)
@@ -510,15 +564,40 @@ function buildTree() {
510
564
  });
511
565
  }
512
566
  for (const stream of streams) {
567
+ const sOpen = expandedSet.has(`stream:${stream}`);
513
568
  nodes.push({
514
569
  id: `stream:${stream}`,
515
570
  parentId: "cat:streams",
516
571
  type: "stream",
517
- label: `${pc.green("")} ${pc.green(stream)}`,
572
+ label: `${sOpen ? pc.cyan("") : pc.dim("▸")} ${pc.green(stream)}`,
518
573
  depth: 1,
519
- expandable: false,
574
+ expandable: true,
520
575
  ref: { name: stream },
521
576
  });
577
+ if (sOpen) {
578
+ const evts = eventsByStream.get(stream) ?? [];
579
+ if (evts.length === 0) {
580
+ nodes.push({
581
+ id: `empty:evt:${stream}`,
582
+ parentId: `stream:${stream}`,
583
+ type: "status",
584
+ label: pc.dim("(no events)"),
585
+ depth: 2,
586
+ expandable: false,
587
+ });
588
+ }
589
+ for (const evt of evts) {
590
+ nodes.push({
591
+ id: `evt:${stream}:${evt.id}`,
592
+ parentId: `stream:${stream}`,
593
+ type: "event",
594
+ label: `${pc.dim("·")} ${pc.dim(evt.type || "unknown")}`,
595
+ depth: 2,
596
+ expandable: false,
597
+ ref: { stream: stream, eventId: evt.id, eventData: evt },
598
+ });
599
+ }
600
+ }
522
601
  }
523
602
  }
524
603
  // Queues
@@ -541,15 +620,87 @@ function buildTree() {
541
620
  });
542
621
  }
543
622
  for (const q of queues) {
623
+ const qOpen = expandedSet.has(`queue:${q}`);
544
624
  nodes.push({
545
625
  id: `queue:${q}`,
546
626
  parentId: "cat:queues",
547
627
  type: "queue",
548
- label: `${pc.magenta("")} ${pc.magenta(q)}`,
628
+ label: `${qOpen ? pc.cyan("") : pc.dim("▸")} ${pc.magenta(q)}`,
549
629
  depth: 1,
550
- expandable: false,
630
+ expandable: true,
551
631
  ref: { name: q },
552
632
  });
633
+ if (qOpen) {
634
+ const jobData = jobsByQueue.get(q);
635
+ const activeJobs = jobData?.active ?? [];
636
+ const deadJobs = jobData?.dead ?? [];
637
+ // Active jobs subcategory
638
+ const activeOpen = expandedSet.has(`queue:${q}:active`);
639
+ nodes.push({
640
+ id: `queue:${q}:active`,
641
+ parentId: `queue:${q}`,
642
+ type: "category",
643
+ label: `${activeOpen ? pc.cyan("▾") : pc.dim("▸")} ${pc.bold("Active")} ${pc.dim(`(${activeJobs.length})`)}`,
644
+ depth: 2,
645
+ expandable: true,
646
+ });
647
+ if (activeOpen) {
648
+ if (activeJobs.length === 0) {
649
+ nodes.push({
650
+ id: `empty:active:${q}`,
651
+ parentId: `queue:${q}:active`,
652
+ type: "status",
653
+ label: pc.dim("(no active jobs)"),
654
+ depth: 3,
655
+ expandable: false,
656
+ });
657
+ }
658
+ for (const job of activeJobs) {
659
+ nodes.push({
660
+ id: `job:${q}:active:${job.id}`,
661
+ parentId: `queue:${q}:active`,
662
+ type: "job",
663
+ label: `${pc.cyan("●")} ${pc.dim(job.id.slice(0, 12))}`,
664
+ depth: 3,
665
+ expandable: false,
666
+ ref: { queue: q, jobId: job.id, status: "active", jobData: job },
667
+ });
668
+ }
669
+ }
670
+ // Dead jobs subcategory
671
+ const deadOpen = expandedSet.has(`queue:${q}:dead`);
672
+ nodes.push({
673
+ id: `queue:${q}:dead`,
674
+ parentId: `queue:${q}`,
675
+ type: "category",
676
+ label: `${deadOpen ? pc.cyan("▾") : pc.dim("▸")} ${pc.bold("Dead")} ${pc.dim(`(${deadJobs.length})`)}`,
677
+ depth: 2,
678
+ expandable: true,
679
+ });
680
+ if (deadOpen) {
681
+ if (deadJobs.length === 0) {
682
+ nodes.push({
683
+ id: `empty:dead:${q}`,
684
+ parentId: `queue:${q}:dead`,
685
+ type: "status",
686
+ label: pc.dim("(no dead jobs)"),
687
+ depth: 3,
688
+ expandable: false,
689
+ });
690
+ }
691
+ for (const job of deadJobs) {
692
+ nodes.push({
693
+ id: `job:${q}:dead:${job.id}`,
694
+ parentId: `queue:${q}:dead`,
695
+ type: "job",
696
+ label: `${pc.red("○")} ${pc.dim(job.id.slice(0, 12))}`,
697
+ depth: 3,
698
+ expandable: false,
699
+ ref: { queue: q, jobId: job.id, status: "dead", jobData: job },
700
+ });
701
+ }
702
+ }
703
+ }
553
704
  }
554
705
  }
555
706
  // Links
@@ -636,6 +787,27 @@ async function loadContent(node) {
636
787
  const ref = node.ref;
637
788
  const objs = objectsByCollection.get(ref.name) ?? [];
638
789
  let res = `${pc.bold(ref.name)} ${pc.dim(`(${objs.length} objects)`)}\n\n`;
790
+ // Schema info
791
+ try {
792
+ const schemas = await db.schema(ref.name);
793
+ if (schemas.length > 0) {
794
+ const fields = schemas[0]?.fields ?? [];
795
+ if (fields.length > 0) {
796
+ res += `${pc.bold("Fields")}\n`;
797
+ for (const f of fields) {
798
+ const icon = f.nullable ? pc.dim("⊙") : pc.cyan("◎");
799
+ const sample = f.sampleValues.length > 0
800
+ ? pc.dim(` e.g. ${String(f.sampleValues[0]).slice(0, 20)}`)
801
+ : "";
802
+ res += ` ${icon} ${f.name}: ${f.type}${sample}\n`;
803
+ }
804
+ res += "\n";
805
+ }
806
+ }
807
+ }
808
+ catch {
809
+ // Schema not available for this store
810
+ }
639
811
  if (objs.length === 0) {
640
812
  res += pc.dim("No objects in this collection.");
641
813
  }
@@ -647,43 +819,59 @@ async function loadContent(node) {
647
819
  }
648
820
  else if (node.type === "stream" && node.ref) {
649
821
  const ref = node.ref;
650
- const events = await db.events.list(ref.name);
651
- let res = `${pc.bold(ref.name)} ${pc.dim(`(${events.length} events)`)}\n\n`;
652
- if (events.length === 0) {
653
- res += pc.dim("No events in this stream.");
822
+ const evts = eventsByStream.get(ref.name) ?? [];
823
+ let res = `${pc.bold(ref.name)} ${pc.dim(`(${evts.length} events shown)`)}\n\n`;
824
+ res += pc.dim("Expand to browse individual events, or press [c] to append.\n");
825
+ content = res;
826
+ }
827
+ else if (node.type === "event" && node.ref) {
828
+ const ref = node.ref;
829
+ const evt = ref.eventData;
830
+ let res = `${pc.bold(evt.type || "unknown")} ${pc.dim(evt.id)}\n`;
831
+ res += ` ${pc.dim("Stream:")} ${pc.green(ref.stream)}\n`;
832
+ res += ` ${pc.dim("Created:")} ${pc.dim(evt.createdAt || "—")}\n\n`;
833
+ const display = { ...evt };
834
+ for (const k of ["id", "stream", "sequence", "createdAt", "idempotencyKey"]) {
835
+ delete display[k];
836
+ }
837
+ if (Object.keys(display).length > 0) {
838
+ res += highlightJson(display);
654
839
  }
655
840
  else {
656
- const lines = events.map((e) => {
657
- const ts = e.createdAt ? pc.dim(String(e.createdAt)) : "";
658
- const type = pc.magenta(e.type || "unknown");
659
- return ` ${ts} ${type}`;
660
- });
661
- res += lines.join("\n");
841
+ res += pc.dim("No payload.");
662
842
  }
663
843
  content = res;
664
844
  }
665
845
  else if (node.type === "queue" && node.ref) {
666
846
  const ref = node.ref;
667
- const queue = db.queue(ref.name);
668
- const [active, dead] = await Promise.all([queue.list(), queue.dead()]);
847
+ const jobData = jobsByQueue.get(ref.name);
848
+ const active = jobData?.active ?? [];
849
+ const dead = jobData?.dead ?? [];
669
850
  let res = `${pc.bold(ref.name)}\n\n`;
670
851
  res += `${pc.cyan("Active")} ${pc.dim(`(${active.length})`)}\n`;
671
- if (active.length === 0) {
672
- res += pc.dim(" No jobs\n");
852
+ res += `${pc.red("Dead")} ${pc.dim(`(${dead.length})`)}\n\n`;
853
+ res += pc.dim("Expand Active or Dead to browse jobs, [c] to push new job.");
854
+ content = res;
855
+ }
856
+ else if (node.type === "job" && node.ref) {
857
+ const ref = node.ref;
858
+ const job = ref.jobData;
859
+ let res = `${pc.bold(job.id)} ${pc.yellow(job.status || ref.status)}\n`;
860
+ res += ` ${pc.dim("Queue:")} ${pc.magenta(ref.queue)}\n`;
861
+ res += ` ${pc.dim("Attempts:")} ${job.attempts}/${job.maxAttempts}\n`;
862
+ res += ` ${pc.dim("Created:")} ${pc.dim(job.createdAt || "—")}\n`;
863
+ if (job.lastError) {
864
+ res += ` ${pc.dim("Error:")} ${pc.red(job.lastError)}\n`;
865
+ }
866
+ res += "\n";
867
+ if (job.payload && Object.keys(job.payload).length > 0) {
868
+ res += highlightJson(job.payload);
673
869
  }
674
870
  else {
675
- for (const j of active) {
676
- res += ` ${pc.cyan("●")} ${j.id} ${pc.yellow(j.status)} ${pc.dim(`${j.attempts}/${j.maxAttempts}`)}\n`;
677
- }
871
+ res += pc.dim("No payload.");
678
872
  }
679
- res += `${pc.red("Dead")} ${pc.dim(`(${dead.length})`)}\n`;
680
- if (dead.length === 0) {
681
- res += pc.dim(" No dead jobs\n");
682
- }
683
- else {
684
- for (const j of dead) {
685
- res += ` ${pc.red("○")} ${j.id} ${pc.dim(`${j.attempts}/${j.maxAttempts}`)}\n`;
686
- }
873
+ if (ref.status === "dead") {
874
+ res += `\n\n${pc.dim("[e] Retry (ack) [d] Nack (remove from dead letter)")}`;
687
875
  }
688
876
  content = res;
689
877
  }
@@ -903,7 +1091,7 @@ function draw() {
903
1091
  help = ` ${pc.dim("↑↓")} nav ${pc.dim("enter")} connect ${pc.dim("q")} quit `;
904
1092
  }
905
1093
  else {
906
- help = ` ${pc.dim("↑↓")} nav ${pc.dim("←→")} toggle ${pc.dim("c")} create ${pc.dim("e")} edit ${pc.dim("d")} delete ${pc.dim("/")} search ${pc.dim("n")} neighbors ${pc.dim("i")} info ${pc.dim("r")} refresh ${pc.dim("s")} switch ${pc.dim("l")} logout ${pc.dim("q")} quit `;
1094
+ help = ` ${pc.dim("↑↓")} nav ${pc.dim("←→")} toggle ${pc.dim("c")} create ${pc.dim("e")} edit ${pc.dim("d")} delete ${pc.dim("/")} search ${pc.dim("n")} neighbors ${pc.dim("N")} nlq ${pc.dim("a")} agg ${pc.dim("t")} ts ${pc.dim("o")} options ${pc.dim("b")} batch ${pc.dim("i")} info ${pc.dim("r")} refresh ${pc.dim("s")} switch ${pc.dim("l")} logout ${pc.dim("q")} quit `;
907
1095
  }
908
1096
  buf += `${pc.dim("─".repeat(W))}\n`;
909
1097
  buf += padToWidth(help, W);
@@ -1220,6 +1408,41 @@ async function handleEdit(selected) {
1220
1408
  }
1221
1409
  });
1222
1410
  }
1411
+ else if (selected.type === "job" && selected.ref) {
1412
+ const ref = selected.ref;
1413
+ if (ref.status === "dead") {
1414
+ openForm(`Retry Dead Job: ${ref.jobId.slice(0, 12)}`, [
1415
+ {
1416
+ id: "action",
1417
+ label: "Action",
1418
+ value: "ack",
1419
+ options: ["ack", "nack"],
1420
+ },
1421
+ {
1422
+ id: "error",
1423
+ label: "Error message (for nack)",
1424
+ placeholder: "Optional error",
1425
+ },
1426
+ ], async (vals) => {
1427
+ const action = vals.action || "";
1428
+ if (action === "ack") {
1429
+ await db.queue(ref.queue).ack(ref.jobId);
1430
+ }
1431
+ else if (action === "nack") {
1432
+ await db.queue(ref.queue).nack(ref.jobId, { error: vals.error || "Rejected" });
1433
+ }
1434
+ else {
1435
+ throw new Error("Action must be 'ack' or 'nack'.");
1436
+ }
1437
+ });
1438
+ }
1439
+ else {
1440
+ // Active job — nack to fail it back to ready
1441
+ openForm(`Nack Job: ${ref.jobId.slice(0, 12)}`, [{ id: "error", label: "Error message", placeholder: "Optional" }], async (vals) => {
1442
+ await db.queue(ref.queue).nack(ref.jobId, { error: vals.error || "Nacked" });
1443
+ });
1444
+ }
1445
+ }
1223
1446
  else {
1224
1447
  openForm("Edit Not Supported", [{ id: "msg", label: "Error", value: "Editing is only available for Objects and Queues." }], async () => { });
1225
1448
  }
@@ -1284,6 +1507,33 @@ async function handleDelete(selected) {
1284
1507
  }
1285
1508
  });
1286
1509
  }
1510
+ else if (selected.type === "job" && selected.ref) {
1511
+ const ref = selected.ref;
1512
+ if (ref.status === "dead") {
1513
+ openForm(`Remove Dead Job: ${ref.jobId.slice(0, 12)}`, [
1514
+ {
1515
+ id: "action",
1516
+ label: "Action",
1517
+ value: "nack",
1518
+ options: ["ack", "nack"],
1519
+ },
1520
+ { id: "confirm", label: 'Type "yes" to confirm', placeholder: "yes" },
1521
+ ], async (vals) => {
1522
+ if ((vals.confirm || "").toLowerCase() !== "yes") {
1523
+ throw new Error("Canceled");
1524
+ }
1525
+ if (vals.action === "ack") {
1526
+ await db.queue(ref.queue).ack(ref.jobId);
1527
+ }
1528
+ else {
1529
+ await db.queue(ref.queue).nack(ref.jobId, { error: "Removed from dead letter" });
1530
+ }
1531
+ });
1532
+ }
1533
+ else {
1534
+ openForm("Delete Not For Active Jobs", [{ id: "msg", label: "Info", value: "Use [e] to nack an active job back to ready state." }], async () => { });
1535
+ }
1536
+ }
1287
1537
  else {
1288
1538
  openForm("Delete Not Supported", [
1289
1539
  {
@@ -1461,6 +1711,316 @@ async function handleNeighbors(selected) {
1461
1711
  draw();
1462
1712
  });
1463
1713
  }
1714
+ async function handleAggregate(selected) {
1715
+ const defaultCol = selected?.type === "collection"
1716
+ ? (selected.ref?.name ?? "")
1717
+ : selected?.type === "object"
1718
+ ? (selected.ref?.collection ?? "")
1719
+ : "";
1720
+ openForm("Aggregate", [
1721
+ {
1722
+ id: "function",
1723
+ label: "Function",
1724
+ value: "count",
1725
+ options: ["count", "sum", "avg", "min", "max"],
1726
+ },
1727
+ {
1728
+ id: "collection",
1729
+ label: "Collection",
1730
+ value: defaultCol,
1731
+ options: collections,
1732
+ allowCustom: true,
1733
+ },
1734
+ { id: "field", label: "Field (for sum/avg/min/max)", placeholder: "field name" },
1735
+ { id: "groupBy", label: "Group By (optional field)", placeholder: "field name" },
1736
+ { id: "filter", label: "Filter (optional JSON)", placeholder: '{"status":"active"}' },
1737
+ ], async (vals) => {
1738
+ const func = vals.function || "count";
1739
+ const collection = (vals.collection || "").trim();
1740
+ if (!collection) {
1741
+ throw new Error("Collection is required.");
1742
+ }
1743
+ const field = (vals.field || "").trim() || undefined;
1744
+ const groupBy = (vals.groupBy || "").trim() || undefined;
1745
+ const filter = vals.filter?.trim() ? JSON.parse(vals.filter.trim()) : undefined;
1746
+ const options = { groupBy, filter };
1747
+ const a = db.aggregate;
1748
+ const result = func === "count"
1749
+ ? await a.count(collection, options)
1750
+ : func === "sum"
1751
+ ? await a.sum(collection, field, options)
1752
+ : func === "avg"
1753
+ ? await a.avg(collection, field, options)
1754
+ : func === "min"
1755
+ ? await a.min(collection, field, options)
1756
+ : await a.max(collection, field, options);
1757
+ const lines = [
1758
+ ` ${pc.bold("Aggregate")} ${pc.cyan(func)} ${pc.dim(`on ${collection}`)}`,
1759
+ "",
1760
+ ` ${pc.dim("Total:")} ${pc.bold(String(result.total))}`,
1761
+ "",
1762
+ ];
1763
+ if (result.groups && result.groups.length > 0) {
1764
+ lines.push(` ${pc.bold("Groups")}`);
1765
+ const maxVal = Math.max(...result.groups.map((g) => g.value));
1766
+ for (const g of result.groups) {
1767
+ const barLen = Math.max(1, Math.round((g.value / maxVal) * 20));
1768
+ const bar = pc.cyan("█".repeat(barLen));
1769
+ lines.push(` ${g.key}: ${bar} ${pc.dim(String(g.value))}`);
1770
+ }
1771
+ }
1772
+ viewerLines = lines;
1773
+ loadedItemId = "aggregate_result";
1774
+ draw();
1775
+ });
1776
+ }
1777
+ async function handleTimeseries(selected) {
1778
+ const defaultCol = selected?.type === "collection"
1779
+ ? (selected.ref?.name ?? "")
1780
+ : selected?.type === "object"
1781
+ ? (selected.ref?.collection ?? "")
1782
+ : "";
1783
+ openForm("Time Series", [
1784
+ {
1785
+ id: "function",
1786
+ label: "Function",
1787
+ value: "count",
1788
+ options: ["count", "sum", "avg", "min", "max"],
1789
+ },
1790
+ {
1791
+ id: "collection",
1792
+ label: "Collection",
1793
+ value: defaultCol,
1794
+ options: collections,
1795
+ allowCustom: true,
1796
+ },
1797
+ { id: "field", label: "Field (optional)", placeholder: "field name" },
1798
+ {
1799
+ id: "bucket",
1800
+ label: "Bucket",
1801
+ value: "day",
1802
+ options: ["hour", "day", "week", "month"],
1803
+ },
1804
+ { id: "from", label: "From (ISO date, optional)", placeholder: "2024-01-01" },
1805
+ { id: "to", label: "To (ISO date, optional)", placeholder: "2024-12-31" },
1806
+ { id: "filter", label: "Filter (optional JSON)", placeholder: '{"status":"active"}' },
1807
+ ], async (vals) => {
1808
+ const func = vals.function || "count";
1809
+ const collection = (vals.collection || "").trim();
1810
+ if (!collection) {
1811
+ throw new Error("Collection is required.");
1812
+ }
1813
+ const field = (vals.field || "").trim() || undefined;
1814
+ const bucket = (vals.bucket || "day");
1815
+ const from = (vals.from || "").trim() || undefined;
1816
+ const to = (vals.to || "").trim() || undefined;
1817
+ const filter = vals.filter?.trim() ? JSON.parse(vals.filter.trim()) : undefined;
1818
+ const result = await db.timeseries(collection, {
1819
+ function: func,
1820
+ field,
1821
+ bucket,
1822
+ from,
1823
+ to,
1824
+ filter,
1825
+ });
1826
+ const lines = [
1827
+ ` ${pc.bold("Time Series")} ${pc.cyan(func)} ${pc.dim(`on ${collection}, ${bucket}ly`)}`,
1828
+ "",
1829
+ ];
1830
+ if (result.buckets.length === 0) {
1831
+ lines.push(pc.dim("No data for the selected range."));
1832
+ }
1833
+ else {
1834
+ const maxVal = Math.max(...result.buckets.map((b) => b.value));
1835
+ for (const b of result.buckets) {
1836
+ const barLen = Math.max(1, Math.round((b.value / maxVal) * 20));
1837
+ const bar = pc.green("█".repeat(barLen));
1838
+ lines.push(` ${b.label}: ${bar} ${pc.dim(String(b.value))}`);
1839
+ }
1840
+ }
1841
+ viewerLines = lines;
1842
+ loadedItemId = "timeseries_result";
1843
+ draw();
1844
+ });
1845
+ }
1846
+ async function handleNlq(selected) {
1847
+ const defaultCol = selected?.type === "collection"
1848
+ ? (selected.ref?.name ?? "")
1849
+ : selected?.type === "object"
1850
+ ? (selected.ref?.collection ?? "")
1851
+ : "";
1852
+ openForm("Natural Language Query", [
1853
+ { id: "question", label: "Question", placeholder: "How many active users?" },
1854
+ {
1855
+ id: "collection",
1856
+ label: "Collection (optional)",
1857
+ value: defaultCol,
1858
+ options: collections,
1859
+ allowCustom: true,
1860
+ },
1861
+ ], async (vals) => {
1862
+ const question = (vals.question || "").trim();
1863
+ if (!question) {
1864
+ throw new Error("Question is required.");
1865
+ }
1866
+ const collection = (vals.collection || "").trim() || undefined;
1867
+ const result = await db.nlq.query(question, {
1868
+ collection,
1869
+ });
1870
+ const intent = result.intent;
1871
+ const lines = [
1872
+ ` ${pc.bold("NLQ Result")}`,
1873
+ ` ${pc.dim(`Question: ${question}`)}`,
1874
+ "",
1875
+ ` ${result.answer}`,
1876
+ "",
1877
+ ];
1878
+ if (intent) {
1879
+ lines.push(` ${pc.dim("Interpreted as:")} ${intent.action} ${pc.dim("on")} ${pc.cyan(intent.collection)}`);
1880
+ if (intent.function) {
1881
+ lines.push(` ${pc.dim("Function:")} ${intent.function}`);
1882
+ }
1883
+ if (intent.field) {
1884
+ lines.push(` ${pc.dim("Field:")} ${intent.field}`);
1885
+ }
1886
+ if (intent.query) {
1887
+ lines.push(` ${pc.dim("Query:")} ${intent.query}`);
1888
+ }
1889
+ }
1890
+ if (result.data !== undefined && result.data !== null) {
1891
+ lines.push("", highlightJson(result.data));
1892
+ }
1893
+ viewerLines = lines;
1894
+ loadedItemId = "nlq_result";
1895
+ draw();
1896
+ });
1897
+ }
1898
+ async function handleCollectionOptions(selected) {
1899
+ const colName = selected?.type === "collection"
1900
+ ? (selected.ref?.name ?? "")
1901
+ : selected?.type === "object"
1902
+ ? (selected.ref?.collection ?? "")
1903
+ : "";
1904
+ if (!colName) {
1905
+ viewerLines = [pc.yellow("Select a collection first, then press [o] to set listing options.")];
1906
+ loadedItemId = "options_info";
1907
+ draw();
1908
+ return;
1909
+ }
1910
+ const current = collectionOptions.get(colName) ?? {};
1911
+ openForm(`Listing Options: ${colName}`, [
1912
+ {
1913
+ id: "sortBy",
1914
+ label: "Sort By",
1915
+ value: current.sortBy ?? "",
1916
+ options: ["", "id", "created_at", "updated_at", "version"],
1917
+ allowCustom: true,
1918
+ },
1919
+ {
1920
+ id: "sortDir",
1921
+ label: "Sort Direction",
1922
+ value: current.sortDir ?? "asc",
1923
+ options: ["asc", "desc"],
1924
+ },
1925
+ { id: "limit", label: "Limit", value: String(current.limit ?? ""), placeholder: "50" },
1926
+ { id: "offset", label: "Offset", value: String(current.offset ?? ""), placeholder: "0" },
1927
+ {
1928
+ id: "filter",
1929
+ label: "Filter (JSON)",
1930
+ value: current.filter ? JSON.stringify(current.filter) : "",
1931
+ placeholder: '{"status":"active"}',
1932
+ },
1933
+ ], async (vals) => {
1934
+ const opts = {};
1935
+ if (vals.sortBy) {
1936
+ opts.sortBy = vals.sortBy;
1937
+ opts.sortDir = vals.sortDir || "asc";
1938
+ }
1939
+ if (vals.limit) {
1940
+ opts.limit = parseInt(vals.limit, 10) || 50;
1941
+ }
1942
+ if (vals.offset) {
1943
+ opts.offset = parseInt(vals.offset, 10) || 0;
1944
+ }
1945
+ if (vals.filter?.trim()) {
1946
+ opts.filter = JSON.parse(vals.filter.trim());
1947
+ }
1948
+ if (Object.keys(opts).length > 0) {
1949
+ collectionOptions.set(colName, opts);
1950
+ }
1951
+ else {
1952
+ collectionOptions.delete(colName);
1953
+ }
1954
+ await fetchResources();
1955
+ const tree = buildTree();
1956
+ const idx = tree.findIndex((n) => n.id === `col:${colName}`);
1957
+ if (idx !== -1) {
1958
+ cursorIndex = idx;
1959
+ }
1960
+ const n = tree[cursorIndex];
1961
+ if (n) {
1962
+ scheduleLoad(n);
1963
+ }
1964
+ });
1965
+ }
1966
+ async function handleBatchOps(selected) {
1967
+ const colName = selected?.type === "collection"
1968
+ ? (selected.ref?.name ?? "")
1969
+ : selected?.type === "object"
1970
+ ? (selected.ref?.collection ?? "")
1971
+ : "";
1972
+ if (!colName) {
1973
+ viewerLines = [pc.yellow("Select a collection first, then press [b] for batch operations.")];
1974
+ loadedItemId = "batch_info";
1975
+ draw();
1976
+ return;
1977
+ }
1978
+ openForm(`Batch Ops: ${colName}`, [
1979
+ {
1980
+ id: "action",
1981
+ label: "Action",
1982
+ value: "put",
1983
+ options: ["put", "delete"],
1984
+ },
1985
+ {
1986
+ id: "input",
1987
+ label: "JSON File Path or IDs (comma-sep for delete)",
1988
+ placeholder: "/path/to/file.json or id1,id2,id3",
1989
+ },
1990
+ ], async (vals) => {
1991
+ const action = vals.action || "";
1992
+ const input = (vals.input || "").trim();
1993
+ if (!input) {
1994
+ throw new Error("Input is required.");
1995
+ }
1996
+ if (action === "put") {
1997
+ const data = JSON.parse(await fs.promises.readFile(input, "utf-8"));
1998
+ const objects = Array.isArray(data) ? data : (data.objects ?? [data]);
1999
+ const result = await db.putBatch(colName, objects);
2000
+ viewerLines = [
2001
+ ` ${pc.bold("Batch Put Complete")}`,
2002
+ ` ${pc.dim(`Collection: ${colName}`)}`,
2003
+ ` ${pc.dim(`Objects: ${result.length}`)}`,
2004
+ ];
2005
+ loadedItemId = "batch_result";
2006
+ draw();
2007
+ }
2008
+ else if (action === "delete") {
2009
+ const ids = input
2010
+ .split(",")
2011
+ .map((s) => s.trim())
2012
+ .filter(Boolean);
2013
+ const count = await db.deleteBatch(colName, ids);
2014
+ viewerLines = [
2015
+ ` ${pc.bold("Batch Delete Complete")}`,
2016
+ ` ${pc.dim(`Collection: ${colName}`)}`,
2017
+ ` ${pc.dim(`Deleted: ${count} objects`)}`,
2018
+ ];
2019
+ loadedItemId = "batch_result";
2020
+ draw();
2021
+ }
2022
+ });
2023
+ }
1464
2024
  async function handleMaintenance() {
1465
2025
  // Cycle through maintenance operations with each press of 'm'
1466
2026
  const operations = ["health", "checkpoint", "backup"];
@@ -1756,9 +2316,24 @@ function setupKeypress() {
1756
2316
  else if (str === "i" || str === "I") {
1757
2317
  await handleInfo();
1758
2318
  }
1759
- else if (str === "n" || str === "N") {
2319
+ else if (str === "n") {
1760
2320
  await handleNeighbors(tree[cursorIndex]);
1761
2321
  }
2322
+ else if (str === "N") {
2323
+ await handleNlq(tree[cursorIndex]);
2324
+ }
2325
+ else if (str === "a" || str === "A") {
2326
+ await handleAggregate(tree[cursorIndex]);
2327
+ }
2328
+ else if (str === "t" || str === "T") {
2329
+ await handleTimeseries(tree[cursorIndex]);
2330
+ }
2331
+ else if (str === "o" || str === "O") {
2332
+ await handleCollectionOptions(tree[cursorIndex]);
2333
+ }
2334
+ else if (str === "b" || str === "B") {
2335
+ await handleBatchOps(tree[cursorIndex]);
2336
+ }
1762
2337
  else if (str === "m" || str === "M") {
1763
2338
  await handleMaintenance();
1764
2339
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thingd/cli",
3
- "version": "0.54.0",
3
+ "version": "0.56.0",
4
4
  "description": "CLI, Interactive TUI Dashboard, and MCP server for thingd — a fast object-first data engine for applications and AI agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://engine.thingd.cloud",
@@ -45,7 +45,7 @@
45
45
  "cli-table3": "^0.6.5",
46
46
  "picocolors": "^1.1.1",
47
47
  "zod": "^4.4.3",
48
- "@thingd/sdk": "0.54.0"
48
+ "@thingd/sdk": "0.56.0"
49
49
  },
50
50
  "engines": {
51
51
  "node": ">=24.0.0"