@tikoci/rosetta 0.4.0 → 0.4.2

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/src/query.ts CHANGED
@@ -25,6 +25,8 @@ export type SearchResponse = {
25
25
  total: number;
26
26
  };
27
27
 
28
+ type CsvScalar = string | number | null;
29
+
28
30
  const DEFAULT_LIMIT = 8;
29
31
  const MAX_TERMS = 8;
30
32
  const MIN_TERM_LENGTH = 2;
@@ -85,6 +87,21 @@ const COMPOUND_TERMS: [string, string][] = [
85
87
  ["address", "list"],
86
88
  ];
87
89
 
90
+ function escapeCsv(value: CsvScalar): string {
91
+ if (value === null) return "";
92
+ const text = String(value);
93
+ return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
94
+ }
95
+
96
+ function rowsToCsv<T extends Record<string, CsvScalar>>(
97
+ rows: T[],
98
+ columns: Array<keyof T & string>,
99
+ ): string {
100
+ const header = columns.join(",");
101
+ const body = rows.map((row) => columns.map((column) => escapeCsv(row[column] ?? null)).join(",")).join("\n");
102
+ return body ? `${header}\n${body}\n` : `${header}\n`;
103
+ }
104
+
88
105
  export function extractTerms(question: string): string[] {
89
106
  return question
90
107
  .toLowerCase()
@@ -577,6 +594,79 @@ function runCalloutsFtsQuery(
577
594
  }
578
595
  }
579
596
 
597
+ /** Diff two RouterOS versions — which command paths were added/removed between them. */
598
+ export type CommandDiffResult = {
599
+ from_version: string;
600
+ to_version: string;
601
+ path_prefix: string | null;
602
+ added: string[];
603
+ removed: string[];
604
+ added_count: number;
605
+ removed_count: number;
606
+ note: string | null;
607
+ };
608
+
609
+ export function diffCommandVersions(
610
+ fromVersion: string,
611
+ toVersion: string,
612
+ pathPrefix?: string,
613
+ ): CommandDiffResult {
614
+ const allVersionRows = db
615
+ .prepare("SELECT version FROM ros_versions")
616
+ .all() as Array<{ version: string }>;
617
+ const knownVersions = allVersionRows.map((r) => r.version).sort(compareVersions);
618
+
619
+ const notes: string[] = [];
620
+ if (knownVersions.length > 0 && !knownVersions.includes(fromVersion)) {
621
+ notes.push(`Version ${fromVersion} is not in the tracked range (${knownVersions[0]}–${knownVersions[knownVersions.length - 1]}). Results may be incomplete.`);
622
+ }
623
+ if (knownVersions.length > 0 && !knownVersions.includes(toVersion)) {
624
+ notes.push(`Version ${toVersion} is not in the tracked range (${knownVersions[0]}–${knownVersions[knownVersions.length - 1]}). Results may be incomplete.`);
625
+ }
626
+
627
+ const prefix = pathPrefix || null; // treat empty string same as undefined
628
+ // Match the prefix itself OR any sub-path under it
629
+ const prefixFilter = prefix ? " AND (command_path = ? OR command_path LIKE ? || '/%')" : "";
630
+ const prefixParams = (v: string) => prefix ? [v, prefix, prefix] : [v];
631
+
632
+ type Row = { command_path: string };
633
+
634
+ const addedRows = db
635
+ .prepare(
636
+ `SELECT DISTINCT cv_to.command_path
637
+ FROM command_versions cv_to
638
+ WHERE cv_to.ros_version = ?${prefixFilter}
639
+ AND cv_to.command_path NOT IN (
640
+ SELECT command_path FROM command_versions WHERE ros_version = ?${prefixFilter}
641
+ )
642
+ ORDER BY cv_to.command_path`,
643
+ )
644
+ .all(...prefixParams(toVersion), ...prefixParams(fromVersion)) as Row[];
645
+
646
+ const removedRows = db
647
+ .prepare(
648
+ `SELECT DISTINCT cv_from.command_path
649
+ FROM command_versions cv_from
650
+ WHERE cv_from.ros_version = ?${prefixFilter}
651
+ AND cv_from.command_path NOT IN (
652
+ SELECT command_path FROM command_versions WHERE ros_version = ?${prefixFilter}
653
+ )
654
+ ORDER BY cv_from.command_path`,
655
+ )
656
+ .all(...prefixParams(fromVersion), ...prefixParams(toVersion)) as Row[];
657
+
658
+ return {
659
+ from_version: fromVersion,
660
+ to_version: toVersion,
661
+ path_prefix: prefix,
662
+ added: addedRows.map((r) => r.command_path),
663
+ removed: removedRows.map((r) => r.command_path),
664
+ added_count: addedRows.length,
665
+ removed_count: removedRows.length,
666
+ note: notes.length > 0 ? notes.join(" ") : null,
667
+ };
668
+ }
669
+
580
670
  /** Check which RouterOS versions include a given command path. */
581
671
  export function checkCommandVersions(
582
672
  commandPath: string,
@@ -678,6 +768,31 @@ export type DeviceTestResult = {
678
768
  throughput_mbps: number | null;
679
769
  };
680
770
 
771
+ /** Map Unicode superscript digits to ASCII equivalents for product name matching.
772
+ * MikroTik uses ² and ³ in product names (hAP ax³, hAP ac²), but users type ASCII. */
773
+ const SUPERSCRIPT_TO_ASCII: [string, string][] = [
774
+ ['\u00B9', '1'], // ¹
775
+ ['\u00B2', '2'], // ²
776
+ ['\u00B3', '3'], // ³
777
+ ];
778
+
779
+ /** SQL expression to normalize Unicode superscript digits in a column to ASCII.
780
+ * Wraps the column in nested REPLACE calls. */
781
+ const NORMALIZE_PRODUCT_NAME = (col: string) =>
782
+ SUPERSCRIPT_TO_ASCII.reduce(
783
+ (expr, [sup, ascii]) => `REPLACE(${expr}, '${sup}', '${ascii}')`,
784
+ col,
785
+ );
786
+
787
+ /** Normalize a device query: replace Unicode superscript digits with ASCII. */
788
+ export function normalizeDeviceQuery(query: string): string {
789
+ let result = query;
790
+ for (const [sup, ascii] of SUPERSCRIPT_TO_ASCII) {
791
+ result = result.replaceAll(sup, ascii);
792
+ }
793
+ return result;
794
+ }
795
+
681
796
  export type DeviceResult = {
682
797
  id: number;
683
798
  product_name: string;
@@ -759,42 +874,114 @@ function buildDeviceFtsQuery(terms: string[], mode: "AND" | "OR"): string {
759
874
  return parts.join(mode === "AND" ? " AND " : " OR ");
760
875
  }
761
876
 
877
+ /** Generate a disambiguation note when multiple devices matched a partial query.
878
+ * Helps the MCP client present meaningful choices to the user. */
879
+ function disambiguationNote(query: string, results: DeviceResult[]): string {
880
+ const names = results.map((r) => r.product_name);
881
+ // Find common prefix
882
+ const shortest = names.reduce((a, b) => (a.length < b.length ? a : b));
883
+ let prefix = "";
884
+ for (let i = 0; i < shortest.length; i++) {
885
+ if (names.every((n) => n[i]?.toLowerCase() === shortest[i]?.toLowerCase())) {
886
+ prefix += shortest[i];
887
+ } else break;
888
+ }
889
+ prefix = prefix.trim();
890
+ // Summarize key differences
891
+ const diffs: string[] = [];
892
+ const enclosures = new Set(names.map((n) => {
893
+ if (/\bOUT\b/i.test(n)) return "outdoor";
894
+ if (/\bIN\b/i.test(n)) return "indoor";
895
+ return null;
896
+ }).filter(Boolean));
897
+ if (enclosures.size > 1) diffs.push("enclosure (indoor/outdoor)");
898
+ const hasPoe = results.map((r) => !!(r.poe_in || r.poe_out));
899
+ if (hasPoe.includes(true) && hasPoe.includes(false)) diffs.push("PoE support");
900
+ const hasPoeOut = results.map((r) => !!r.poe_out);
901
+ if (!diffs.includes("PoE support") && hasPoeOut.includes(true) && hasPoeOut.includes(false)) diffs.push("PoE output");
902
+ const hasWireless = results.map((r) => !!(r.wireless_24_chains || r.wireless_5_chains));
903
+ if (hasWireless.includes(true) && hasWireless.includes(false)) diffs.push("wireless");
904
+ const lteCapable = results.map((r) => (r.sim_slots ?? 0) > 0);
905
+ if (lteCapable.includes(true) && lteCapable.includes(false)) diffs.push("LTE/cellular");
906
+ const family = prefix || query;
907
+ const diffStr = diffs.length > 0 ? ` Key differences: ${diffs.join(", ")}.` : "";
908
+ return `${results.length} devices match "${family}".${diffStr} Use the full product name for a specific device.`;
909
+ }
910
+
762
911
  /** Look up a device by exact name or product code, then fall back to LIKE/FTS + filters. */
763
912
  export function searchDevices(
764
913
  query: string,
765
914
  filters: DeviceFilters = {},
766
915
  limit = 10,
767
- ): { results: DeviceResult[]; mode: "exact" | "fts" | "like" | "filter" | "fts+or"; total: number } {
768
- // 1. Try exact match on product_name or product_code
769
- if (query) {
916
+ ): { results: DeviceResult[]; mode: "exact" | "fts" | "like" | "filter" | "fts+or"; total: number; has_more: boolean; note?: string } {
917
+ // Normalize Unicode superscripts ASCII digits for all matching stages.
918
+ // Users type "ax3" for "ax³", "ac2" for "ac²" — normalize once, use everywhere.
919
+ const q = normalizeDeviceQuery(query);
920
+ const normalizedName = NORMALIZE_PRODUCT_NAME('product_name');
921
+
922
+ // 1. Try exact match on product_name or product_code.
923
+ // Compares normalized query against both raw and superscript-normalized product_name.
924
+ if (q) {
770
925
  const exact = db
771
- .prepare(`${DEVICE_SELECT} WHERE product_name = ? COLLATE NOCASE OR product_code = ? COLLATE NOCASE`)
772
- .all(query, query) as DeviceResult[];
926
+ .prepare(`${DEVICE_SELECT} WHERE product_name = ? COLLATE NOCASE OR product_code = ? COLLATE NOCASE OR ${normalizedName} = ? COLLATE NOCASE`)
927
+ .all(q, q, q) as DeviceResult[];
773
928
  if (exact.length > 0) {
774
- return { results: attachTestResults(exact), mode: "exact", total: exact.length };
929
+ return { results: attachTestResults(exact), mode: "exact", total: exact.length, has_more: false };
775
930
  }
776
931
  }
777
932
 
778
933
  // 2. LIKE-based prefix/substring match on product_name and product_code.
934
+ // Splits on whitespace, dashes, and underscores so that dash-separated
935
+ // queries like "rb5009-out" still find "RB5009UPr+S+OUT".
779
936
  // For 144 rows this is instant and catches model number substrings
780
937
  // that FTS5 token matching misses (e.g. "RB1100" → "RB1100AHx4").
781
- if (query) {
782
- const likeTerms = query
783
- .trim()
784
- .split(/\s+/)
785
- .filter((t) => t.length >= 2)
938
+ // Also normalizes product_name in SQL so "ax3" matches "ax³".
939
+ if (q) {
940
+ const rawTerms = q.trim().split(/[\s\-_]+/);
941
+ const longTerms = rawTerms.filter((t) => t.length >= 2);
942
+ // Preserve single-digit terms (version numbers like "3" in "hap ax 3")
943
+ // but only when accompanied by longer terms to avoid overly broad matches.
944
+ const digitTerms = rawTerms.filter((t) => t.length === 1 && /^\d$/.test(t));
945
+ const likeTerms = (longTerms.length > 0 ? [...longTerms, ...digitTerms] : longTerms)
786
946
  .map((t) => `%${t}%`);
787
947
  if (likeTerms.length > 0) {
788
948
  const likeConditions = likeTerms.map(
789
- () => "(d.product_name LIKE ? COLLATE NOCASE OR d.product_code LIKE ? COLLATE NOCASE)",
949
+ () => `(${normalizedName} LIKE ? COLLATE NOCASE OR d.product_code LIKE ? COLLATE NOCASE)`,
790
950
  );
791
951
  const likeParams = likeTerms.flatMap((t) => [t, t]);
952
+ // Fetch limit+1 to detect truncation
792
953
  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[];
954
+ const likeResults = db.prepare(likeSql).all(...likeParams, limit + 1) as DeviceResult[];
794
955
  if (likeResults.length > 0) {
956
+ const hasMore = likeResults.length > limit;
957
+ const trimmed = hasMore ? likeResults.slice(0, limit) : likeResults;
795
958
  // 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 };
959
+ if (trimmed.length <= 5) attachTestResults(trimmed);
960
+ // Single match in any mode gets test results
961
+ else if (trimmed.length === 1) attachTestResults(trimmed);
962
+ const note = trimmed.length > 1 ? disambiguationNote(q, trimmed) : undefined;
963
+ return { results: trimmed, mode: "like", total: trimmed.length, has_more: hasMore, note };
964
+ }
965
+ }
966
+ }
967
+
968
+ // 2b. Slug-normalized LIKE: strip all separators from both query and product_url slug.
969
+ // Handles concatenated AKAs ("hapax3", "fiberboxplus", "wapaxlte7") and superscript
970
+ // queries ("hap ax3" → slug hap_ax3 → stripped hapax3). Anchors to /product/ prefix
971
+ // to avoid matching domain or path components.
972
+ if (q) {
973
+ const slugQuery = q.toLowerCase().replace(/[^a-z0-9]/g, "");
974
+ if (slugQuery.length >= 5) {
975
+ const slugPattern = `%/product/%${slugQuery}%`;
976
+ const slugSql = `${DEVICE_SELECT} d WHERE d.product_url IS NOT NULL AND REPLACE(LOWER(d.product_url), '_', '') LIKE ? ORDER BY d.product_name LIMIT ?`;
977
+ const slugResults = db.prepare(slugSql).all(slugPattern, limit + 1) as DeviceResult[];
978
+ if (slugResults.length > 0) {
979
+ const hasMore = slugResults.length > limit;
980
+ const trimmed = hasMore ? slugResults.slice(0, limit) : slugResults;
981
+ if (trimmed.length <= 5) attachTestResults(trimmed);
982
+ else if (trimmed.length === 1) attachTestResults(trimmed);
983
+ const note = trimmed.length > 1 ? disambiguationNote(q, trimmed) : undefined;
984
+ return { results: trimmed, mode: "like", total: trimmed.length, has_more: hasMore, note };
798
985
  }
799
986
  }
800
987
  }
@@ -829,7 +1016,7 @@ export function searchDevices(
829
1016
  whereClauses.push("d.sim_slots > 0");
830
1017
  }
831
1018
 
832
- const terms = query ? extractTerms(query) : [];
1019
+ const terms = q ? extractTerms(q) : [];
833
1020
 
834
1021
  if (terms.length > 0) {
835
1022
  // FTS with filters — use prefix matching for device model numbers
@@ -848,9 +1035,13 @@ export function searchDevices(
848
1035
  WHERE devices_fts MATCH ?${filterWhere}
849
1036
  ORDER BY rank LIMIT ?`;
850
1037
  try {
851
- const results = db.prepare(sql).all(ftsQuery, ...params, limit) as DeviceResult[];
1038
+ const results = db.prepare(sql).all(ftsQuery, ...params, limit + 1) as DeviceResult[];
852
1039
  if (results.length > 0) {
853
- return { results, mode: "fts", total: results.length };
1040
+ const hasMore = results.length > limit;
1041
+ const trimmed = hasMore ? results.slice(0, limit) : results;
1042
+ // Single FTS match gets test results (same behavior as exact)
1043
+ if (trimmed.length === 1) attachTestResults(trimmed);
1044
+ return { results: trimmed, mode: "fts", total: trimmed.length, has_more: hasMore };
854
1045
  }
855
1046
  } catch { /* fall through to OR */ }
856
1047
 
@@ -858,9 +1049,12 @@ export function searchDevices(
858
1049
  if (terms.length > 1) {
859
1050
  const orQuery = buildDeviceFtsQuery(terms, "OR");
860
1051
  try {
861
- const results = db.prepare(sql).all(orQuery, ...params, limit) as DeviceResult[];
1052
+ const results = db.prepare(sql).all(orQuery, ...params, limit + 1) as DeviceResult[];
862
1053
  if (results.length > 0) {
863
- return { results, mode: "fts+or", total: results.length };
1054
+ const hasMore = results.length > limit;
1055
+ const trimmed = hasMore ? results.slice(0, limit) : results;
1056
+ if (trimmed.length === 1) attachTestResults(trimmed);
1057
+ return { results: trimmed, mode: "fts+or", total: trimmed.length, has_more: hasMore };
864
1058
  }
865
1059
  } catch { /* fall through */ }
866
1060
  }
@@ -870,11 +1064,214 @@ export function searchDevices(
870
1064
  // 4. Filter-only (no FTS query)
871
1065
  if (whereClauses.length > 0) {
872
1066
  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 };
1067
+ const results = db.prepare(sql).all(...params, limit + 1) as DeviceResult[];
1068
+ const hasMore = results.length > limit;
1069
+ const trimmed = hasMore ? results.slice(0, limit) : results;
1070
+ return { results: trimmed, mode: "filter", total: trimmed.length, has_more: hasMore };
875
1071
  }
876
1072
 
877
- return { results: [], mode: "fts", total: 0 };
1073
+ return { results: [], mode: "fts", total: 0, has_more: false };
1074
+ }
1075
+
1076
+ // ── Cross-device test result queries ──
1077
+
1078
+ export type DeviceTestRow = {
1079
+ product_name: string;
1080
+ product_code: string | null;
1081
+ architecture: string;
1082
+ test_type: string;
1083
+ mode: string;
1084
+ configuration: string;
1085
+ packet_size: number;
1086
+ throughput_kpps: number | null;
1087
+ throughput_mbps: number | null;
1088
+ };
1089
+
1090
+ type DeviceTestFilters = {
1091
+ test_type?: string;
1092
+ mode?: string;
1093
+ configuration?: string;
1094
+ packet_size?: number;
1095
+ sort_by?: "mbps" | "kpps";
1096
+ };
1097
+
1098
+ function buildTestWhereClause(filters: DeviceTestFilters): { whereClause: string; params: (string | number)[] } {
1099
+ const where: string[] = [];
1100
+ const params: (string | number)[] = [];
1101
+
1102
+ if (filters.test_type) {
1103
+ where.push("t.test_type = ?");
1104
+ params.push(filters.test_type);
1105
+ }
1106
+ if (filters.mode) {
1107
+ where.push("t.mode = ?");
1108
+ params.push(filters.mode);
1109
+ }
1110
+ if (filters.configuration) {
1111
+ where.push("t.configuration LIKE ?");
1112
+ params.push(`%${filters.configuration}%`);
1113
+ }
1114
+ if (filters.packet_size) {
1115
+ where.push("t.packet_size = ?");
1116
+ params.push(filters.packet_size);
1117
+ }
1118
+
1119
+ return {
1120
+ whereClause: where.length > 0 ? `WHERE ${where.join(" AND ")}` : "",
1121
+ params,
1122
+ };
1123
+ }
1124
+
1125
+ export function searchDeviceTests(
1126
+ filters: DeviceTestFilters,
1127
+ limit = 50,
1128
+ ): { results: DeviceTestRow[]; total: number } {
1129
+ const { whereClause, params } = buildTestWhereClause(filters);
1130
+ const orderCol = filters.sort_by === "kpps" ? "t.throughput_kpps" : "t.throughput_mbps";
1131
+
1132
+ // Total count (before limit)
1133
+ const totalSql = `SELECT COUNT(*) AS c FROM device_test_results t
1134
+ JOIN devices d ON d.id = t.device_id ${whereClause}`;
1135
+ const total = Number((db.prepare(totalSql).get(...params) as { c: number }).c);
1136
+
1137
+ const sql = `SELECT d.product_name, d.product_code, d.architecture,
1138
+ t.test_type, t.mode, t.configuration, t.packet_size,
1139
+ t.throughput_kpps, t.throughput_mbps
1140
+ FROM device_test_results t
1141
+ JOIN devices d ON d.id = t.device_id
1142
+ ${whereClause}
1143
+ ORDER BY ${orderCol} DESC NULLS LAST
1144
+ LIMIT ?`;
1145
+
1146
+ const results = db.prepare(sql).all(...params, limit) as DeviceTestRow[];
1147
+ return { results, total };
1148
+ }
1149
+
1150
+ type DeviceTestCsvRow = {
1151
+ product_name: string;
1152
+ product_code: string | null;
1153
+ architecture: string | null;
1154
+ cpu: string | null;
1155
+ cpu_cores: number | null;
1156
+ cpu_frequency: string | null;
1157
+ test_type: string;
1158
+ mode: string;
1159
+ configuration: string;
1160
+ packet_size: number;
1161
+ throughput_kpps: number | null;
1162
+ throughput_mbps: number | null;
1163
+ product_url: string | null;
1164
+ };
1165
+
1166
+ export function exportDeviceTestsCsv(): string {
1167
+ const rows = db.prepare(`SELECT d.product_name, d.product_code, d.architecture,
1168
+ d.cpu, d.cpu_cores, d.cpu_frequency,
1169
+ t.test_type, t.mode, t.configuration, t.packet_size,
1170
+ t.throughput_kpps, t.throughput_mbps,
1171
+ d.product_url
1172
+ FROM device_test_results t
1173
+ JOIN devices d ON d.id = t.device_id
1174
+ ORDER BY d.product_name, t.test_type, t.mode, t.configuration, t.packet_size DESC`).all() as DeviceTestCsvRow[];
1175
+
1176
+ return rowsToCsv(rows, [
1177
+ "product_name",
1178
+ "product_code",
1179
+ "architecture",
1180
+ "cpu",
1181
+ "cpu_cores",
1182
+ "cpu_frequency",
1183
+ "test_type",
1184
+ "mode",
1185
+ "configuration",
1186
+ "packet_size",
1187
+ "throughput_kpps",
1188
+ "throughput_mbps",
1189
+ "product_url",
1190
+ ]);
1191
+ }
1192
+
1193
+ type DeviceCsvRow = {
1194
+ product_name: string;
1195
+ product_code: string | null;
1196
+ architecture: string | null;
1197
+ cpu: string | null;
1198
+ cpu_cores: number | null;
1199
+ cpu_frequency: string | null;
1200
+ license_level: number | null;
1201
+ operating_system: string | null;
1202
+ ram: string | null;
1203
+ ram_mb: number | null;
1204
+ storage: string | null;
1205
+ storage_mb: number | null;
1206
+ dimensions: string | null;
1207
+ poe_in: string | null;
1208
+ poe_out: string | null;
1209
+ max_power_w: number | null;
1210
+ wireless_24_chains: number | null;
1211
+ wireless_5_chains: number | null;
1212
+ eth_fast: number | null;
1213
+ eth_gigabit: number | null;
1214
+ eth_2500: number | null;
1215
+ sfp_ports: number | null;
1216
+ sfp_plus_ports: number | null;
1217
+ eth_multigig: number | null;
1218
+ usb_ports: number | null;
1219
+ sim_slots: number | null;
1220
+ msrp_usd: number | null;
1221
+ product_url: string | null;
1222
+ block_diagram_url: string | null;
1223
+ };
1224
+
1225
+ export function exportDevicesCsv(): string {
1226
+ const rows = db.prepare(`${DEVICE_SELECT} ORDER BY product_name`).all() as DeviceCsvRow[];
1227
+
1228
+ return rowsToCsv(rows, [
1229
+ "product_name",
1230
+ "product_code",
1231
+ "architecture",
1232
+ "cpu",
1233
+ "cpu_cores",
1234
+ "cpu_frequency",
1235
+ "license_level",
1236
+ "operating_system",
1237
+ "ram",
1238
+ "ram_mb",
1239
+ "storage",
1240
+ "storage_mb",
1241
+ "dimensions",
1242
+ "poe_in",
1243
+ "poe_out",
1244
+ "max_power_w",
1245
+ "wireless_24_chains",
1246
+ "wireless_5_chains",
1247
+ "eth_fast",
1248
+ "eth_gigabit",
1249
+ "eth_2500",
1250
+ "sfp_ports",
1251
+ "sfp_plus_ports",
1252
+ "eth_multigig",
1253
+ "usb_ports",
1254
+ "sim_slots",
1255
+ "msrp_usd",
1256
+ "product_url",
1257
+ "block_diagram_url",
1258
+ ]);
1259
+ }
1260
+
1261
+ /** Get distinct values for test result fields (for discovery). */
1262
+ export function getTestResultMeta(): {
1263
+ test_types: string[];
1264
+ modes: string[];
1265
+ configurations: string[];
1266
+ packet_sizes: number[];
1267
+ } {
1268
+ const col = (sql: string) => (db.prepare(sql).all() as Array<{ v: string }>).map((r) => r.v);
1269
+ return {
1270
+ test_types: col("SELECT DISTINCT test_type AS v FROM device_test_results ORDER BY v"),
1271
+ modes: col("SELECT DISTINCT mode AS v FROM device_test_results ORDER BY v"),
1272
+ configurations: col("SELECT DISTINCT configuration AS v FROM device_test_results ORDER BY v"),
1273
+ 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),
1274
+ };
878
1275
  }
879
1276
 
880
1277
  const VERSION_CHANNELS = ["stable", "long-term", "testing", "development"] as const;
package/src/setup.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  import { execSync } from "node:child_process";
11
11
  import { existsSync, writeFileSync } from "node:fs";
12
12
  import { gunzipSync } from "bun";
13
- import { detectMode, resolveBaseDir, resolveDbPath, resolveVersion } from "./paths.ts";
13
+ import { detectMode, resolveBaseDir, resolveDbPath, resolveVersion, SCHEMA_VERSION } from "./paths.ts";
14
14
 
15
15
  declare const REPO_URL: string;
16
16
 
@@ -84,8 +84,13 @@ export async function runSetup(force = false) {
84
84
  const db = new sqlite(dbPath, { readonly: true });
85
85
  const row = db.prepare("SELECT COUNT(*) AS c FROM pages").get() as { c: number };
86
86
  const cmdRow = db.prepare("SELECT COUNT(*) AS c FROM commands WHERE type='cmd'").get() as { c: number };
87
+ const versionRow = db.prepare("PRAGMA user_version").get() as { user_version: number };
87
88
  db.close();
88
- console.log(`✓ Database ready (${row.c} pages, ${cmdRow.c} commands)`);
89
+ if (versionRow.user_version !== SCHEMA_VERSION) {
90
+ console.warn(` Warning: DB schema version is ${versionRow.user_version}, expected ${SCHEMA_VERSION}.`);
91
+ console.warn(` The downloaded DB may be incompatible with this version of rosetta.`);
92
+ }
93
+ console.log(`✓ Database ready (${row.c} pages, ${cmdRow.c} commands, schema v${versionRow.user_version})`);
89
94
  } catch (e) {
90
95
  console.error(`✗ Database validation failed: ${e}`);
91
96
  const retryCmd = mode === "compiled" ? "rosetta" : mode === "package" ? "bunx @tikoci/rosetta" : "bun run src/setup.ts";