@tikoci/rosetta 0.4.0 → 0.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "RouterOS documentation as SQLite FTS5 — RAG search + command glossary via MCP",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/db.ts CHANGED
@@ -345,10 +345,6 @@ export function initDb() {
345
345
  export function getDbStats() {
346
346
  const count = (sql: string) =>
347
347
  Number((db.prepare(sql).get() as { c: number }).c ?? 0);
348
- const scalar = (sql: string) => {
349
- const row = db.prepare(sql).get() as { v: string | null } | null;
350
- return row?.v ?? null;
351
- };
352
348
  return {
353
349
  db_path: DB_PATH,
354
350
  pages: count("SELECT COUNT(*) AS c FROM pages"),
@@ -363,8 +359,27 @@ export function getDbStats() {
363
359
  changelogs: count("SELECT COUNT(*) AS c FROM changelogs"),
364
360
  changelog_versions: count("SELECT COUNT(DISTINCT version) AS c FROM changelogs"),
365
361
  ros_versions: count("SELECT COUNT(*) AS c FROM ros_versions"),
366
- ros_version_min: scalar("SELECT MIN(version) AS v FROM ros_versions"),
367
- ros_version_max: scalar("SELECT MAX(version) AS v FROM ros_versions"),
362
+ ...(() => {
363
+ // Semantic version sort SQL MIN/MAX is lexicographic ("7.10" < "7.9")
364
+ const versions = (db.prepare("SELECT version FROM ros_versions").all() as Array<{ version: string }>).map((r) => r.version);
365
+ if (versions.length === 0) return { ros_version_min: null, ros_version_max: null };
366
+ const norm = (v: string) => {
367
+ const clean = v.replace(/beta\d*/, "").replace(/rc\d*/, "");
368
+ const parts = clean.split(".").map(Number);
369
+ const suffix = v.includes("beta") ? 0 : v.includes("rc") ? 1 : 2;
370
+ return { parts, suffix };
371
+ };
372
+ const cmp = (a: string, b: string) => {
373
+ const na = norm(a), nb = norm(b);
374
+ for (let i = 0; i < Math.max(na.parts.length, nb.parts.length); i++) {
375
+ const d = (na.parts[i] ?? 0) - (nb.parts[i] ?? 0);
376
+ if (d !== 0) return d;
377
+ }
378
+ return na.suffix - nb.suffix;
379
+ };
380
+ versions.sort(cmp);
381
+ return { ros_version_min: versions[0], ros_version_max: versions[versions.length - 1] };
382
+ })(),
368
383
  doc_export: "2026-03-25 (Confluence HTML)",
369
384
  };
370
385
  }
@@ -212,7 +212,7 @@ describe("HTTP transport: session lifecycle", () => {
212
212
 
213
213
  const result = (messages[0] as Record<string, unknown>).result as Record<string, unknown>;
214
214
  const tools = result.tools as Array<{ name: string }>;
215
- expect(tools.length).toBe(11);
215
+ expect(tools.length).toBe(12);
216
216
 
217
217
  const toolNames = tools.map((t) => t.name).sort();
218
218
  expect(toolNames).toContain("routeros_search");
@@ -402,8 +402,8 @@ describe("HTTP transport: multi-session", () => {
402
402
  const tools1 = ((msgs1[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
403
403
  const tools2 = ((msgs2[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
404
404
 
405
- expect(tools1.length).toBe(11);
406
- expect(tools2.length).toBe(11);
405
+ expect(tools1.length).toBe(12);
406
+ expect(tools2.length).toBe(12);
407
407
  });
408
408
 
409
409
  test("deleting one session does not affect another", async () => {
@@ -425,7 +425,7 @@ describe("HTTP transport: multi-session", () => {
425
425
  // Client2 still works
426
426
  const msgs = await mcpRequest(server.url, client2.sessionId, "tools/list", 2);
427
427
  const tools = ((msgs[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
428
- expect(tools.length).toBe(11);
428
+ expect(tools.length).toBe(12);
429
429
 
430
430
  // Client1 is gone
431
431
  const resp = await fetch(server.url, {
package/src/mcp.ts CHANGED
@@ -138,6 +138,8 @@ const {
138
138
  searchCallouts,
139
139
  searchChangelogs,
140
140
  searchDevices,
141
+ searchDeviceTests,
142
+ getTestResultMeta,
141
143
  searchPages,
142
144
  searchProperties,
143
145
  } = await import("./query.ts");
@@ -672,6 +674,7 @@ Shows bus topology and per-port bandwidth limits — useful for understanding So
672
674
  - SMIPS: lowest-end (hAP lite)
673
675
 
674
676
  Workflow — combine with other tools:
677
+ → routeros_search_tests: cross-device performance ranking (all 125 devices at once, e.g., 512B routing benchmark)
675
678
  → routeros_search: find documentation for features relevant to a device
676
679
  → routeros_command_tree: check commands available for a feature
677
680
  → routeros_current_versions: check latest firmware for the device
@@ -757,6 +760,115 @@ Data: 144 products, March 2026 snapshot. Not all MikroTik products ever made —
757
760
  },
758
761
  );
759
762
 
763
+ // ---- routeros_search_tests ----
764
+
765
+ server.registerTool(
766
+ "routeros_search_tests",
767
+ {
768
+ description: `Query device performance test results across all devices.
769
+
770
+ Returns throughput benchmarks from mikrotik.com product pages — one call replaces
771
+ what would otherwise require 125+ individual device lookups.
772
+
773
+ **Data:** 2,874 test results across 125 devices (March 2026).
774
+ - Ethernet: bridging/routing throughput at 64/512/1518 byte packets
775
+ - IPSec: tunnel throughput with AES/SHA cipher configurations
776
+ - Results include kpps (packets/sec) and Mbps
777
+
778
+ **Common queries:**
779
+ - Routing performance ranking: test_type="ethernet", mode="Routing", configuration="25 ip filter rules", packet_size=512
780
+ - Bridge performance: test_type="ethernet", mode="Bridging", configuration="25 bridge filter"
781
+ - IPSec throughput: test_type="ipsec", mode="Single tunnel", configuration="AES-128-CBC"
782
+
783
+ **Configuration matching:** Uses LIKE (substring) — "25 ip filter" matches "25 ip filter rules".
784
+ Note: some devices use slightly different names (e.g., "25 bridge filter" vs "25 bridge filter rules").
785
+
786
+ **Tip:** Call with no filters first to see available test_types, modes, configurations, and packet_sizes via the metadata field.
787
+
788
+ Workflow:
789
+ → routeros_device_lookup: get full specs + block diagram for a specific device from results
790
+ → routeros_search: find documentation about features relevant to the test type`,
791
+ inputSchema: {
792
+ test_type: z
793
+ .string()
794
+ .optional()
795
+ .describe("Filter: 'ethernet' or 'ipsec'"),
796
+ mode: z
797
+ .string()
798
+ .optional()
799
+ .describe("Filter: e.g., 'Routing', 'Bridging', 'Single tunnel', '256 tunnels'"),
800
+ configuration: z
801
+ .string()
802
+ .optional()
803
+ .describe("Filter (substring match): e.g., '25 ip filter rules', 'AES-128-CBC + SHA1', 'none (fast path)'"),
804
+ packet_size: z
805
+ .number()
806
+ .int()
807
+ .optional()
808
+ .describe("Filter: packet size in bytes (64, 512, 1400, 1518)"),
809
+ sort_by: z
810
+ .enum(["mbps", "kpps"])
811
+ .optional()
812
+ .default("mbps")
813
+ .describe("Sort results by throughput metric (default: mbps)"),
814
+ limit: z
815
+ .number()
816
+ .int()
817
+ .min(1)
818
+ .max(200)
819
+ .optional()
820
+ .default(50)
821
+ .describe("Max results (default 50, max 200)"),
822
+ },
823
+ },
824
+ async ({ test_type, mode, configuration, packet_size, sort_by, limit }) => {
825
+ const hasFilters = test_type || mode || configuration || packet_size;
826
+
827
+ if (!hasFilters) {
828
+ // Discovery mode: return available filter values
829
+ const meta = getTestResultMeta();
830
+ return {
831
+ content: [{
832
+ type: "text",
833
+ text: JSON.stringify({
834
+ message: "No filters provided. Here are the available values — use these to build your query:",
835
+ ...meta,
836
+ hint: "Common query: test_type='ethernet', mode='Routing', configuration='25 ip filter rules', packet_size=512",
837
+ }, null, 2),
838
+ }],
839
+ };
840
+ }
841
+
842
+ const result = searchDeviceTests(
843
+ { test_type, mode, configuration, packet_size, sort_by },
844
+ limit,
845
+ );
846
+
847
+ if (result.results.length === 0) {
848
+ const hints = [
849
+ "Call with no filters to see available test types, modes, and configurations",
850
+ configuration ? `Try a shorter configuration substring (e.g., "25 ip filter" instead of the full string)` : null,
851
+ ].filter(Boolean);
852
+ return {
853
+ content: [{
854
+ type: "text",
855
+ text: `No test results matched the filters.\n\nTry:\n${hints.map((h) => `- ${h}`).join("\n")}`,
856
+ }],
857
+ };
858
+ }
859
+
860
+ return {
861
+ content: [{
862
+ type: "text",
863
+ text: JSON.stringify({
864
+ ...result,
865
+ has_more: result.total > result.results.length,
866
+ }, null, 2),
867
+ }],
868
+ };
869
+ },
870
+ );
871
+
760
872
  // ---- routeros_current_versions ----
761
873
 
762
874
  server.registerTool(
package/src/query.test.ts CHANGED
@@ -13,7 +13,7 @@ import { beforeAll, describe, expect, test } from "bun:test";
13
13
  process.env.DB_PATH = ":memory:";
14
14
 
15
15
  // Dynamic imports so the env-var assignment above is visible to db.ts
16
- const { db, initDb } = await import("./db.ts");
16
+ const { db, initDb, getDbStats } = await import("./db.ts");
17
17
  const {
18
18
  extractTerms,
19
19
  buildFtsQuery,
@@ -25,6 +25,8 @@ const {
25
25
  searchChangelogs,
26
26
  checkCommandVersions,
27
27
  searchDevices,
28
+ searchDeviceTests,
29
+ getTestResultMeta,
28
30
  } = await import("./query.ts");
29
31
  const { parseChangelog } = await import("./extract-changelogs.ts");
30
32
 
@@ -69,6 +71,10 @@ beforeAll(() => {
69
71
 
70
72
  db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
71
73
  VALUES ('7.22', 'stable', 0, '2024-01-01T00:00:00Z')`);
74
+ db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
75
+ VALUES ('7.9', 'stable', 0, '2023-01-01T00:00:00Z')`);
76
+ db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
77
+ VALUES ('7.10.2', 'stable', 0, '2023-06-01T00:00:00Z')`);
72
78
 
73
79
  db.run(`INSERT INTO commands
74
80
  (id, path, name, type, parent_path, page_id, description, ros_version)
@@ -80,6 +86,8 @@ beforeAll(() => {
80
86
 
81
87
  db.run(`INSERT INTO command_versions (command_path, ros_version)
82
88
  VALUES ('/ip/dhcp-server', '7.22')`);
89
+ db.run(`INSERT INTO command_versions (command_path, ros_version)
90
+ VALUES ('/ip/dhcp-server', '7.9')`);
83
91
 
84
92
  // Device fixtures for searchDevices tests
85
93
  db.run(`INSERT INTO devices
@@ -635,8 +643,8 @@ describe("searchCallouts", () => {
635
643
  describe("checkCommandVersions", () => {
636
644
  test("returns versions for known command path", () => {
637
645
  const res = checkCommandVersions("/ip/dhcp-server");
638
- expect(res.versions).toEqual(["7.22"]);
639
- expect(res.first_seen).toBe("7.22");
646
+ expect(res.versions).toEqual(["7.9", "7.22"]);
647
+ expect(res.first_seen).toBe("7.9");
640
648
  expect(res.last_seen).toBe("7.22");
641
649
  });
642
650
 
@@ -827,6 +835,91 @@ describe("searchDevices", () => {
827
835
  expect(res.results[0].product_url).toBeNull();
828
836
  expect(res.results[0].block_diagram_url).toBeNull();
829
837
  });
838
+
839
+ test("has_more is false when all results fit", () => {
840
+ const res = searchDevices("", { architecture: "ARM 64bit" }, 50);
841
+ expect(res.has_more).toBe(false);
842
+ });
843
+
844
+ test("has_more is true when results are truncated", () => {
845
+ // We have multiple devices; limit to 1 with a broad filter
846
+ const res = searchDevices("", { architecture: "ARM 64bit" }, 1);
847
+ expect(res.results).toHaveLength(1);
848
+ expect(res.has_more).toBe(true);
849
+ });
850
+
851
+ test("single FTS match attaches test_results", () => {
852
+ // "hAP ax3" as FTS should find exactly one match and attach test results
853
+ const res = searchDevices("hAP ax3");
854
+ if (res.mode === "exact" || res.results.length === 1) {
855
+ expect(res.results[0].test_results).toBeDefined();
856
+ }
857
+ });
858
+ });
859
+
860
+ // ---------------------------------------------------------------------------
861
+ // searchDeviceTests: cross-device test queries
862
+ // ---------------------------------------------------------------------------
863
+
864
+ describe("searchDeviceTests", () => {
865
+ test("returns all test results with no filters", () => {
866
+ const res = searchDeviceTests({});
867
+ expect(res.results.length).toBe(3);
868
+ expect(res.total).toBe(3);
869
+ });
870
+
871
+ test("filters by test_type", () => {
872
+ const res = searchDeviceTests({ test_type: "ethernet" });
873
+ expect(res.results.every((r) => r.test_type === "ethernet")).toBe(true);
874
+ expect(res.results.length).toBe(2);
875
+ });
876
+
877
+ test("filters by test_type and mode", () => {
878
+ const res = searchDeviceTests({ test_type: "ipsec", mode: "Single tunnel" });
879
+ expect(res.results).toHaveLength(1);
880
+ expect(res.results[0].configuration).toBe("AES-128-CBC + SHA1");
881
+ });
882
+
883
+ test("configuration uses LIKE matching", () => {
884
+ const res = searchDeviceTests({ configuration: "25 ip filter" });
885
+ expect(res.results).toHaveLength(1);
886
+ expect(res.results[0].configuration).toBe("25 ip filter rules");
887
+ });
888
+
889
+ test("filters by packet_size", () => {
890
+ const res = searchDeviceTests({ packet_size: 512 });
891
+ expect(res.results.every((r) => r.packet_size === 512)).toBe(true);
892
+ });
893
+
894
+ test("sorts by mbps descending by default", () => {
895
+ const res = searchDeviceTests({ test_type: "ethernet" });
896
+ if (res.results.length >= 2) {
897
+ const mbps = res.results.map((r) => r.throughput_mbps ?? 0);
898
+ expect(mbps[0]).toBeGreaterThanOrEqual(mbps[1]);
899
+ }
900
+ });
901
+
902
+ test("includes device info in results", () => {
903
+ const res = searchDeviceTests({ test_type: "ethernet" });
904
+ expect(res.results[0].product_name).toBeDefined();
905
+ expect(res.results[0].architecture).toBeDefined();
906
+ });
907
+
908
+ test("respects limit", () => {
909
+ const res = searchDeviceTests({}, 1);
910
+ expect(res.results).toHaveLength(1);
911
+ expect(res.total).toBe(3);
912
+ });
913
+ });
914
+
915
+ describe("getTestResultMeta", () => {
916
+ test("returns distinct values", () => {
917
+ const meta = getTestResultMeta();
918
+ expect(meta.test_types).toContain("ethernet");
919
+ expect(meta.test_types).toContain("ipsec");
920
+ expect(meta.modes).toContain("Routing");
921
+ expect(meta.packet_sizes).toContain(512);
922
+ });
830
923
  });
831
924
 
832
925
  // ---------------------------------------------------------------------------
@@ -1037,3 +1130,16 @@ describe("schema", () => {
1037
1130
  expect(triggers).toContain("changelogs_au");
1038
1131
  });
1039
1132
  });
1133
+
1134
+ // ---------------------------------------------------------------------------
1135
+ // getDbStats: version range uses semantic sort (not lexicographic)
1136
+ // ---------------------------------------------------------------------------
1137
+
1138
+ describe("getDbStats", () => {
1139
+ test("version range is semantically sorted (7.9 < 7.10.2 < 7.22)", () => {
1140
+ const stats = getDbStats();
1141
+ // Fixtures have 7.9, 7.10.2, 7.22 — lexicographic MIN would give "7.10.2", not "7.9"
1142
+ expect(stats.ros_version_min).toBe("7.9");
1143
+ expect(stats.ros_version_max).toBe("7.22");
1144
+ });
1145
+ });
package/src/query.ts CHANGED
@@ -764,14 +764,14 @@ export function searchDevices(
764
764
  query: string,
765
765
  filters: DeviceFilters = {},
766
766
  limit = 10,
767
- ): { results: DeviceResult[]; mode: "exact" | "fts" | "like" | "filter" | "fts+or"; total: number } {
767
+ ): { results: DeviceResult[]; mode: "exact" | "fts" | "like" | "filter" | "fts+or"; total: number; has_more: boolean } {
768
768
  // 1. Try exact match on product_name or product_code
769
769
  if (query) {
770
770
  const exact = db
771
771
  .prepare(`${DEVICE_SELECT} WHERE product_name = ? COLLATE NOCASE OR product_code = ? COLLATE NOCASE`)
772
772
  .all(query, query) as DeviceResult[];
773
773
  if (exact.length > 0) {
774
- return { results: attachTestResults(exact), mode: "exact", total: exact.length };
774
+ return { results: attachTestResults(exact), mode: "exact", total: exact.length, has_more: false };
775
775
  }
776
776
  }
777
777
 
@@ -789,12 +789,17 @@ export function searchDevices(
789
789
  () => "(d.product_name LIKE ? COLLATE NOCASE OR d.product_code LIKE ? COLLATE NOCASE)",
790
790
  );
791
791
  const likeParams = likeTerms.flatMap((t) => [t, t]);
792
+ // Fetch limit+1 to detect truncation
792
793
  const likeSql = `${DEVICE_SELECT} d WHERE ${likeConditions.join(" AND ")} ORDER BY d.product_name LIMIT ?`;
793
- const likeResults = db.prepare(likeSql).all(...likeParams, limit) as DeviceResult[];
794
+ const likeResults = db.prepare(likeSql).all(...likeParams, limit + 1) as DeviceResult[];
794
795
  if (likeResults.length > 0) {
796
+ const hasMore = likeResults.length > limit;
797
+ const trimmed = hasMore ? likeResults.slice(0, limit) : likeResults;
795
798
  // Attach test results for small result sets (likely specific device lookups)
796
- if (likeResults.length <= 5) attachTestResults(likeResults);
797
- return { results: likeResults, mode: "like", total: likeResults.length };
799
+ if (trimmed.length <= 5) attachTestResults(trimmed);
800
+ // Single match in any mode gets test results
801
+ else if (trimmed.length === 1) attachTestResults(trimmed);
802
+ return { results: trimmed, mode: "like", total: trimmed.length, has_more: hasMore };
798
803
  }
799
804
  }
800
805
  }
@@ -848,9 +853,13 @@ export function searchDevices(
848
853
  WHERE devices_fts MATCH ?${filterWhere}
849
854
  ORDER BY rank LIMIT ?`;
850
855
  try {
851
- const results = db.prepare(sql).all(ftsQuery, ...params, limit) as DeviceResult[];
856
+ const results = db.prepare(sql).all(ftsQuery, ...params, limit + 1) as DeviceResult[];
852
857
  if (results.length > 0) {
853
- return { results, mode: "fts", total: results.length };
858
+ const hasMore = results.length > limit;
859
+ const trimmed = hasMore ? results.slice(0, limit) : results;
860
+ // Single FTS match gets test results (same behavior as exact)
861
+ if (trimmed.length === 1) attachTestResults(trimmed);
862
+ return { results: trimmed, mode: "fts", total: trimmed.length, has_more: hasMore };
854
863
  }
855
864
  } catch { /* fall through to OR */ }
856
865
 
@@ -858,9 +867,12 @@ export function searchDevices(
858
867
  if (terms.length > 1) {
859
868
  const orQuery = buildDeviceFtsQuery(terms, "OR");
860
869
  try {
861
- const results = db.prepare(sql).all(orQuery, ...params, limit) as DeviceResult[];
870
+ const results = db.prepare(sql).all(orQuery, ...params, limit + 1) as DeviceResult[];
862
871
  if (results.length > 0) {
863
- return { results, mode: "fts+or", total: results.length };
872
+ const hasMore = results.length > limit;
873
+ const trimmed = hasMore ? results.slice(0, limit) : results;
874
+ if (trimmed.length === 1) attachTestResults(trimmed);
875
+ return { results: trimmed, mode: "fts+or", total: trimmed.length, has_more: hasMore };
864
876
  }
865
877
  } catch { /* fall through */ }
866
878
  }
@@ -870,11 +882,98 @@ export function searchDevices(
870
882
  // 4. Filter-only (no FTS query)
871
883
  if (whereClauses.length > 0) {
872
884
  const sql = `${DEVICE_SELECT} d WHERE ${whereClauses.join(" AND ")} ORDER BY d.product_name LIMIT ?`;
873
- const results = db.prepare(sql).all(...params, limit) as DeviceResult[];
874
- return { results, mode: "filter", total: results.length };
885
+ const results = db.prepare(sql).all(...params, limit + 1) as DeviceResult[];
886
+ const hasMore = results.length > limit;
887
+ const trimmed = hasMore ? results.slice(0, limit) : results;
888
+ return { results: trimmed, mode: "filter", total: trimmed.length, has_more: hasMore };
875
889
  }
876
890
 
877
- return { results: [], mode: "fts", total: 0 };
891
+ return { results: [], mode: "fts", total: 0, has_more: false };
892
+ }
893
+
894
+ // ── Cross-device test result queries ──
895
+
896
+ export type DeviceTestRow = {
897
+ product_name: string;
898
+ product_code: string | null;
899
+ architecture: string;
900
+ cpu: string | null;
901
+ cpu_cores: number | null;
902
+ cpu_frequency: string | null;
903
+ test_type: string;
904
+ mode: string;
905
+ configuration: string;
906
+ packet_size: number;
907
+ throughput_kpps: number | null;
908
+ throughput_mbps: number | null;
909
+ };
910
+
911
+ export function searchDeviceTests(
912
+ filters: {
913
+ test_type?: string;
914
+ mode?: string;
915
+ configuration?: string;
916
+ packet_size?: number;
917
+ sort_by?: "mbps" | "kpps";
918
+ },
919
+ limit = 50,
920
+ ): { results: DeviceTestRow[]; total: number } {
921
+ const where: string[] = [];
922
+ const params: (string | number)[] = [];
923
+
924
+ if (filters.test_type) {
925
+ where.push("t.test_type = ?");
926
+ params.push(filters.test_type);
927
+ }
928
+ if (filters.mode) {
929
+ where.push("t.mode = ?");
930
+ params.push(filters.mode);
931
+ }
932
+ if (filters.configuration) {
933
+ where.push("t.configuration LIKE ?");
934
+ params.push(`%${filters.configuration}%`);
935
+ }
936
+ if (filters.packet_size) {
937
+ where.push("t.packet_size = ?");
938
+ params.push(filters.packet_size);
939
+ }
940
+
941
+ const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
942
+ const orderCol = filters.sort_by === "kpps" ? "t.throughput_kpps" : "t.throughput_mbps";
943
+
944
+ // Total count (before limit)
945
+ const totalSql = `SELECT COUNT(*) AS c FROM device_test_results t
946
+ JOIN devices d ON d.id = t.device_id ${whereClause}`;
947
+ const total = Number((db.prepare(totalSql).get(...params) as { c: number }).c);
948
+
949
+ const sql = `SELECT d.product_name, d.product_code, d.architecture, d.cpu,
950
+ d.cpu_cores, d.cpu_frequency,
951
+ t.test_type, t.mode, t.configuration, t.packet_size,
952
+ t.throughput_kpps, t.throughput_mbps
953
+ FROM device_test_results t
954
+ JOIN devices d ON d.id = t.device_id
955
+ ${whereClause}
956
+ ORDER BY ${orderCol} DESC NULLS LAST
957
+ LIMIT ?`;
958
+
959
+ const results = db.prepare(sql).all(...params, limit) as DeviceTestRow[];
960
+ return { results, total };
961
+ }
962
+
963
+ /** Get distinct values for test result fields (for discovery). */
964
+ export function getTestResultMeta(): {
965
+ test_types: string[];
966
+ modes: string[];
967
+ configurations: string[];
968
+ packet_sizes: number[];
969
+ } {
970
+ const col = (sql: string) => (db.prepare(sql).all() as Array<{ v: string }>).map((r) => r.v);
971
+ return {
972
+ test_types: col("SELECT DISTINCT test_type AS v FROM device_test_results ORDER BY v"),
973
+ modes: col("SELECT DISTINCT mode AS v FROM device_test_results ORDER BY v"),
974
+ configurations: col("SELECT DISTINCT configuration AS v FROM device_test_results ORDER BY v"),
975
+ packet_sizes: (db.prepare("SELECT DISTINCT packet_size AS v FROM device_test_results ORDER BY v DESC").all() as Array<{ v: number }>).map((r) => r.v),
976
+ };
878
977
  }
879
978
 
880
979
  const VERSION_CHANNELS = ["stable", "long-term", "testing", "development"] as const;