latinfo 0.16.0 → 0.18.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 (2) hide show
  1. package/dist/index.js +331 -76
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -47,7 +47,7 @@ const local_search_1 = require("./local-search");
47
47
  const client_search_1 = require("./client-search");
48
48
  const odis_search_1 = require("./odis-search");
49
49
  const mphf_search_1 = require("./mphf-search");
50
- const VERSION = '0.16.0';
50
+ const VERSION = '0.18.0';
51
51
  const API_URL = process.env.LATINFO_API_URL || 'https://api.latinfo.dev';
52
52
  const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Ov23li5fcQaiCsVtaMKK';
53
53
  const CONFIG_DIR = path_1.default.join(os_1.default.homedir(), '.latinfo');
@@ -748,39 +748,171 @@ function costsSimulate(usersStr, rpmStr, proPctStr) {
748
748
  printCosts({ users, pro_users: proUsers, requests, cf_tier: cfTier, cf_cost: cfCost, revenue, margin, safe: margin >= 0 });
749
749
  }
750
750
  // --- Bench ---
751
- const BENCH_SAMPLES = {
752
- 'pe/ruc': [
753
- '20100047218', '20100070970', '20131312955', '20103343984', '20100059502',
754
- '20512002090', '20100030595', '20504437303', '20601188848', '20100017491',
755
- '20602105251', '20600006805', '20348550455', '20547141826', '20601427564',
756
- '20429423558', '20100192660', '20330791884', '20519398169', '20501503536',
757
- ],
758
- 'pe/search': [
759
- 'banco', 'minera', 'construccion', 'servicios', 'comercial',
760
- 'inversiones', 'grupo empresarial', 'empresa', 'sociedad', 'industria',
761
- 'peru', 'lima', 'consultora', 'transporte', 'holding',
762
- 'desarrollos', 'ingenieria', 'tecnologia', 'salud', 'educacion',
763
- ],
764
- 'pe/oece/tenders': [
765
- 'servicio', 'construccion', 'suministro', 'consultoria', 'mantenimiento',
766
- 'obra', 'adquisicion', 'sistema', 'equipos', 'vehiculos',
767
- 'alimentos', 'seguridad', 'limpieza', 'transporte', 'software',
768
- ],
769
- 'co/nit': [
770
- '0901620698', '0800099778', '0860002264', '0890903938', '0890399995',
771
- '0830114921', '0900544580', '0860030380', '0811046900', '0900073223',
772
- '0800037800', '0800149923', '0800235461', '0800193650', '0812003744',
773
- ],
774
- 'co/search': [
775
- 'banco', 'minera', 'construccion', 'servicios', 'comercializadora',
776
- 'inversiones', 'grupo', 'empresa', 'sociedad', 'fundacion',
777
- 'transporte', 'ingenieria', 'consultoria', 'salud', 'tecnologia',
778
- ],
779
- };
751
+ // --- Search server status ---
752
+ async function searchServerStatus() {
753
+ const { execSync } = await Promise.resolve().then(() => __importStar(require('child_process')));
754
+ const RUNNER = 'f3mt0@100.109.82.87';
755
+ // Test SSH
756
+ try {
757
+ execSync(`ssh -o ConnectTimeout=5 ${RUNNER} "echo OK"`, { stdio: 'pipe' });
758
+ }
759
+ catch {
760
+ console.error(' Linux Mint not reachable. Is it on?');
761
+ process.exit(1);
762
+ }
763
+ // Check health
764
+ try {
765
+ const health = execSync(`ssh ${RUNNER} "curl -s http://localhost:3001/health"`, { encoding: 'utf-8', stdio: 'pipe' }).trim();
766
+ const data = JSON.parse(health);
767
+ if (data.status === 'ok') {
768
+ console.log(`\n Search server: READY`);
769
+ console.log(` Mode: ${data.mode || 'ram'}`);
770
+ console.log(` Sources: ${data.sources.join(', ')}`);
771
+ console.log(` Disk shards: ${data.diskShards || 0}`);
772
+ console.log(` RAM shards: ${data.ramShards || 0}`);
773
+ console.log(` RAM: ${data.ramMB || '?'} MB`);
774
+ // Quick search test
775
+ const t0 = Date.now();
776
+ const testResult = execSync(`ssh ${RUNNER} "curl -s 'http://localhost:3001/search?source=${data.sources[0]}&q=banco'"`, { encoding: 'utf-8', stdio: 'pipe' }).trim();
777
+ const testData = JSON.parse(testResult);
778
+ console.log(`\n Search test (${data.sources[0]}): ${testData.results?.length || 0} results in ${testData.ms}ms`);
779
+ return;
780
+ }
781
+ }
782
+ catch { }
783
+ // Not ready — show download progress
784
+ console.log(`\n Search server: LOADING\n`);
785
+ const diskUsage = execSync(`ssh ${RUNNER} "du -sm /tmp/latinfo-search-data/ 2>/dev/null | awk '{print \\$1}'"`, { encoding: 'utf-8', stdio: 'pipe' }).trim();
786
+ const currentMB = parseInt(diskUsage) || 0;
787
+ const totalExpected = 6400;
788
+ const pct = Math.min(Math.floor(currentMB / totalExpected * 100), 99);
789
+ const filled = Math.floor(pct * 30 / 100);
790
+ const bar = '█'.repeat(filled) + '░'.repeat(30 - filled);
791
+ console.log(` [${bar}] ${currentMB}/${totalExpected} MB (${pct}%)`);
792
+ // Show last shard activity
793
+ const lastLog = execSync(`ssh ${RUNNER} "sudo journalctl -u latinfo-search --no-pager -n 5 2>/dev/null | grep -oE '(Shard [0-9]+|Loading [^ ]+)' | tail -1"`, { encoding: 'utf-8', stdio: 'pipe' }).trim();
794
+ if (lastLog)
795
+ console.log(` Last: ${lastLog}`);
796
+ // Show RAM
797
+ const ram = execSync(`ssh ${RUNNER} "free -h | awk '/Mem:/{print \\$3\"/\"\\$2}'"`, { encoding: 'utf-8', stdio: 'pipe' }).trim();
798
+ console.log(` RAM: ${ram}`);
799
+ }
800
+ // Seed queries used to discover real data from the API
801
+ const SEED_QUERIES = ['banco', 'empresa', 'servicios', 'comercial', 'grupo'];
802
+ function discoverBenchSources(filterSource) {
803
+ const repo = getRepoPath();
804
+ const sourcesDir = path_1.default.join(repo, 'sources');
805
+ if (!fs_1.default.existsSync(sourcesDir))
806
+ return [];
807
+ const yamls = fs_1.default.readdirSync(sourcesDir).filter(f => f.endsWith('.yaml'));
808
+ const sources = [];
809
+ for (const file of yamls) {
810
+ const content = fs_1.default.readFileSync(path_1.default.join(sourcesDir, file), 'utf-8');
811
+ const nameMatch = content.match(/^name:\s*(.+)/m);
812
+ const countryMatch = content.match(/^country:\s*(.+)/m);
813
+ const institutionMatch = content.match(/^institution:\s*(.+)/m);
814
+ const datasetMatch = content.match(/^dataset:\s*(.+)/m);
815
+ const idNameMatch = content.match(/primary_id:\s*\n\s*name:\s*(.+)/);
816
+ const idLenMatch = content.match(/primary_id:[\s\S]*?length:\s*(\d+)/);
817
+ const smokeIdMatch = content.match(/smoke_test:\s*\n\s*id:\s*"?([^"\n]+)"?/);
818
+ if (!nameMatch || !countryMatch || !institutionMatch || !datasetMatch)
819
+ continue;
820
+ const name = nameMatch[1].trim();
821
+ if (filterSource && name !== filterSource)
822
+ continue;
823
+ const routePath = `/${countryMatch[1].trim()}/${institutionMatch[1].trim()}/${datasetMatch[1].trim()}`;
824
+ const primaryId = {
825
+ name: idNameMatch ? idNameMatch[1].trim() : 'id',
826
+ length: idLenMatch ? parseInt(idLenMatch[1]) : 10,
827
+ };
828
+ const smokeId = smokeIdMatch ? smokeIdMatch[1].trim() : undefined;
829
+ const lookupIds = [];
830
+ if (smokeId)
831
+ lookupIds.push(smokeId);
832
+ sources.push({ name, routePath, primaryId, smokeId, lookupIds, searchQueries: [] });
833
+ }
834
+ return sources;
835
+ }
836
+ /** Generate a non-existent ID based on the primary ID pattern */
837
+ function generateFakeId(length) {
838
+ // All 9s — unlikely to be a real ID
839
+ return '9'.repeat(length);
840
+ }
841
+ /** Seed lookup IDs + search queries from real API data */
842
+ async function seedBenchQueries(source, apiKey) {
843
+ const headers = { Authorization: `Bearer ${apiKey}` };
844
+ const names = [];
845
+ // Fetch real records via search
846
+ for (const seed of SEED_QUERIES) {
847
+ try {
848
+ const res = await fetch(`${API_URL}${source.routePath}/search?q=${encodeURIComponent(seed)}&limit=20`, {
849
+ headers, signal: AbortSignal.timeout(10_000),
850
+ });
851
+ if (!res.ok)
852
+ continue;
853
+ const data = await res.json();
854
+ for (const r of data) {
855
+ const id = r.id || r[source.primaryId.name];
856
+ if (id && !source.lookupIds.includes(String(id)))
857
+ source.lookupIds.push(String(id));
858
+ // Extract the search field (first string field that isn't the ID)
859
+ const name = Object.values(r).find((v, i) => typeof v === 'string' && i > 0);
860
+ if (name && name.length > 3)
861
+ names.push(name);
862
+ }
863
+ }
864
+ catch { }
865
+ if (source.lookupIds.length >= 20 && names.length >= 20)
866
+ break;
867
+ }
868
+ // Add non-existent IDs for edge case testing
869
+ for (let i = 0; i < 5; i++) {
870
+ source.lookupIds.push(generateFakeId(source.primaryId.length));
871
+ }
872
+ // Generate diverse search queries from real names
873
+ const uniqueNames = [...new Set(names)];
874
+ for (const name of uniqueNames.slice(0, 10)) {
875
+ // Full name (easy)
876
+ source.searchQueries.push(name);
877
+ }
878
+ for (const name of uniqueNames.slice(0, 10)) {
879
+ // Single token (medium)
880
+ const tokens = name.split(/\s+/).filter(t => t.length >= 3);
881
+ if (tokens.length > 0)
882
+ source.searchQueries.push(tokens[0]);
883
+ }
884
+ for (const name of uniqueNames.slice(0, 10)) {
885
+ // 3-letter prefix (hard)
886
+ const token = name.split(/\s+/).find(t => t.length >= 4);
887
+ if (token)
888
+ source.searchQueries.push(token.slice(0, 3));
889
+ }
890
+ for (const name of uniqueNames.slice(0, 5)) {
891
+ // Multi-token substring (hard)
892
+ const tokens = name.split(/\s+/).filter(t => t.length >= 3);
893
+ if (tokens.length >= 2)
894
+ source.searchQueries.push(tokens.slice(0, 2).join(' '));
895
+ }
896
+ // Non-existent search queries (edge case)
897
+ source.searchQueries.push('xyznonexistent', 'qqq999', 'zzznodata');
898
+ // Fallback: if API returned nothing, use seed queries
899
+ if (source.searchQueries.length < 5) {
900
+ source.searchQueries.push(...SEED_QUERIES);
901
+ }
902
+ }
780
903
  async function benchStress(args) {
781
904
  const config = requireAuth();
782
905
  const durationSec = parseInt(parseFlag(args, '--duration') || '120');
783
906
  const maxVUs = parseInt(parseFlag(args, '--max-vus') || '500');
907
+ const filterSource = parseFlag(args, '--source');
908
+ const benchSources = discoverBenchSources(filterSource || undefined);
909
+ if (benchSources.length === 0) {
910
+ console.error('No sources found. Check sources/ directory.');
911
+ process.exit(1);
912
+ }
913
+ // Seed lookup IDs from API
914
+ console.log(` Seeding queries from ${benchSources.length} sources...`);
915
+ await Promise.all(benchSources.map(s => seedBenchQueries(s, config.api_key)));
784
916
  const stages = [
785
917
  { name: 'warmup', vus: 10, duration: Math.floor(durationSec * 0.08) },
786
918
  { name: 'ramp', vus: Math.floor(maxVUs * 0.5), duration: Math.floor(durationSec * 0.17) },
@@ -789,16 +921,21 @@ async function benchStress(args) {
789
921
  { name: 'hold', vus: maxVUs, duration: Math.floor(durationSec * 0.25) },
790
922
  { name: 'cool', vus: 10, duration: Math.floor(durationSec * 0.08) },
791
923
  ];
792
- const endpoints = [
793
- ...BENCH_SAMPLES['pe/ruc'].map(s => ({ url: `${API_URL}/pe/sunat/padron/ruc/${s}`, type: 'ruc' })),
794
- ...BENCH_SAMPLES['pe/search'].map(s => ({ url: `${API_URL}/pe/sunat/padron/search?q=${encodeURIComponent(s)}`, type: 'search' })),
795
- ...BENCH_SAMPLES['pe/oece/tenders'].slice(0, 10).map(s => ({ url: `${API_URL}/pe/oece/tenders?q=${encodeURIComponent(s)}&limit=5`, type: 'tenders' })),
796
- ...BENCH_SAMPLES['co/nit'].slice(0, 10).map(s => ({ url: `${API_URL}/co/rues/registry/nit/${s}`, type: 'co/nit' })),
797
- ...BENCH_SAMPLES['co/search'].slice(0, 10).map(s => ({ url: `${API_URL}/co/rues/registry/search?q=${encodeURIComponent(s)}`, type: 'co/search' })),
798
- ];
924
+ // Build endpoints dynamically from discovered sources
925
+ const endpoints = [];
926
+ for (const s of benchSources) {
927
+ if (s.lookupIds.length > 0) {
928
+ for (const id of s.lookupIds.slice(0, 10)) {
929
+ endpoints.push({ url: `${API_URL}${s.routePath}/${s.primaryId.name}/${id}`, type: `${s.name}/lookup` });
930
+ }
931
+ }
932
+ for (const q of s.searchQueries.slice(0, 10)) {
933
+ endpoints.push({ url: `${API_URL}${s.routePath}/search?q=${encodeURIComponent(q)}&limit=5`, type: `${s.name}/search` });
934
+ }
935
+ }
799
936
  const headers = { Authorization: `Bearer ${config.api_key}` };
800
937
  const results = [];
801
- console.log(`\n STRESS TEST — ${durationSec}s, max ${maxVUs} VUs, mixed endpoints\n`);
938
+ console.log(`\n STRESS TEST — ${durationSec}s, max ${maxVUs} VUs, ${benchSources.map(s => s.name).join(' + ')}\n`);
802
939
  console.log(` Stage VUs Duration`);
803
940
  for (const s of stages)
804
941
  console.log(` ${s.name.padEnd(10)} ${String(s.vus).padEnd(5)} ${s.duration}s`);
@@ -886,6 +1023,7 @@ async function benchStress(args) {
886
1023
  console.log(`\n${'═'.repeat(60)}`);
887
1024
  console.log(` STRESS TEST RESULTS`);
888
1025
  console.log(`${'═'.repeat(60)}\n`);
1026
+ console.log(` Sources: ${benchSources.map(s => s.name).join(', ')}`);
889
1027
  console.log(` Duration: ${dur}s`);
890
1028
  console.log(` Max VUs: ${maxVUs}`);
891
1029
  console.log(` Total reqs: ${total.toLocaleString()}`);
@@ -918,10 +1056,10 @@ async function benchStress(args) {
918
1056
  }
919
1057
  console.log();
920
1058
  console.log(` Per Endpoint`);
921
- console.log(` ${'Type'.padEnd(15)} ${'Reqs'.padEnd(8)} ${'p50'.padEnd(8)} ${'p95'.padEnd(8)} p99`);
1059
+ console.log(` ${'Type'.padEnd(25)} ${'Reqs'.padEnd(8)} ${'p50'.padEnd(8)} ${'p95'.padEnd(8)} p99`);
922
1060
  for (const [t, d] of Object.entries(allByType)) {
923
1061
  const sl = d.latencies.sort((a, b) => a - b);
924
- console.log(` ${t.padEnd(15)} ${String(d.count).padEnd(8)} ${(pct(sl, 0.5) + 'ms').padEnd(8)} ${(pct(sl, 0.95) + 'ms').padEnd(8)} ${pct(sl, 0.99)}ms`);
1062
+ console.log(` ${t.padEnd(25)} ${String(d.count).padEnd(8)} ${(pct(sl, 0.5) + 'ms').padEnd(8)} ${(pct(sl, 0.95) + 'ms').padEnd(8)} ${pct(sl, 0.99)}ms`);
925
1063
  }
926
1064
  console.log();
927
1065
  console.log(` HTTP Status: ${Object.entries(allStat).map(([s, c]) => `${s}: ${c}`).join(' ')}`);
@@ -996,44 +1134,48 @@ async function bench(args) {
996
1134
  const count = countIdx !== -1 ? parseInt(args[countIdx + 1]) || 100 : 100;
997
1135
  const concurrencyIdx = args.indexOf('--concurrency');
998
1136
  const concurrency = concurrencyIdx !== -1 ? parseInt(args[concurrencyIdx + 1]) || 10 : 10;
999
- const countryIdx = args.indexOf('--country');
1000
- const country = countryIdx !== -1 ? args[countryIdx + 1] : 'pe';
1001
- const typeIdx = args.indexOf('--type');
1002
- const type = typeIdx !== -1 ? args[typeIdx + 1] : (country === 'pe' ? 'ruc' : 'nit');
1137
+ const filterSource = parseFlag(args, '--source');
1138
+ const searchOnly = args.includes('--search-only');
1139
+ const lookupOnly = args.includes('--lookup-only');
1003
1140
  const config = requireAuth();
1004
- const key = `${country}/${type}`;
1005
- const samples = BENCH_SAMPLES[key];
1006
- if (!samples) {
1007
- console.error(`Unknown combination: --country ${country} --type ${type}`);
1008
- console.error(`Supported: ${Object.keys(BENCH_SAMPLES).map(k => '--country ' + k.replace('/', ' --type ')).join(', ')}`);
1141
+ const benchSources = discoverBenchSources(filterSource || undefined);
1142
+ if (benchSources.length === 0) {
1143
+ console.error('No sources found. Check sources/ directory or use --source <name>.');
1009
1144
  process.exit(1);
1010
1145
  }
1011
- const ROUTE_MAP = {
1012
- 'pe/ruc': '/pe/sunat/padron/ruc',
1013
- 'pe/search': '/pe/sunat/padron/search',
1014
- 'pe/oece/tenders': '/pe/oece/tenders',
1015
- 'co/nit': '/co/rues/registry/nit',
1016
- 'co/search': '/co/rues/registry/search',
1017
- };
1018
- const getUrl = (sample) => {
1019
- const route = ROUTE_MAP[key];
1020
- if (type === 'search' || type === 'oece/tenders')
1021
- return `${API_URL}${route}?q=${encodeURIComponent(sample)}&limit=5`;
1022
- return `${API_URL}${route}/${sample}`;
1023
- };
1024
- const tasks = Array.from({ length: count }, (_, i) => samples[i % samples.length]);
1146
+ // Seed queries + IDs from real API data
1025
1147
  if (!jsonFlag)
1026
- console.log(`\nBenchmarking /${key} ${count} requests, ${concurrency} concurrent\n`);
1148
+ console.log(` Seeding queries from ${benchSources.length} sources...`);
1149
+ await Promise.all(benchSources.map(s => seedBenchQueries(s, config.api_key)));
1150
+ // Build URL list
1151
+ const urls = [];
1152
+ for (const s of benchSources) {
1153
+ if (!searchOnly && s.lookupIds.length > 0) {
1154
+ for (const id of s.lookupIds) {
1155
+ urls.push({ url: `${API_URL}${s.routePath}/${s.primaryId.name}/${id}`, type: `${s.name}/lookup` });
1156
+ }
1157
+ }
1158
+ if (!lookupOnly) {
1159
+ for (const q of s.searchQueries) {
1160
+ urls.push({ url: `${API_URL}${s.routePath}/search?q=${encodeURIComponent(q)}&limit=5`, type: `${s.name}/search` });
1161
+ }
1162
+ }
1163
+ }
1164
+ const sourceNames = benchSources.map(s => s.name).join(', ');
1165
+ if (!jsonFlag)
1166
+ console.log(`\n Bench: ${sourceNames} — ${count} requests, ${concurrency} concurrent\n`);
1027
1167
  const latencies = [];
1028
1168
  const statusCounts = {};
1169
+ const byType = {};
1029
1170
  let completed = 0;
1030
1171
  const startAll = Date.now();
1172
+ const tasks = Array.from({ length: count }, (_, i) => urls[i % urls.length]);
1031
1173
  for (let i = 0; i < tasks.length; i += concurrency) {
1032
1174
  const batch = tasks.slice(i, i + concurrency);
1033
- await Promise.all(batch.map(async (sample) => {
1175
+ await Promise.all(batch.map(async (task) => {
1034
1176
  const t0 = Date.now();
1035
1177
  try {
1036
- const res = await fetch(getUrl(sample), {
1178
+ const res = await fetch(task.url, {
1037
1179
  headers: { Authorization: `Bearer ${config.api_key}` },
1038
1180
  signal: AbortSignal.timeout(15_000),
1039
1181
  });
@@ -1044,6 +1186,10 @@ async function bench(args) {
1044
1186
  latencies.push(Date.now() - t0);
1045
1187
  statusCounts[0] = (statusCounts[0] || 0) + 1;
1046
1188
  }
1189
+ if (!byType[task.type])
1190
+ byType[task.type] = { count: 0, latencies: [] };
1191
+ byType[task.type].count++;
1192
+ byType[task.type].latencies.push(latencies[latencies.length - 1]);
1047
1193
  completed++;
1048
1194
  if (process.stdout.isTTY && !jsonFlag) {
1049
1195
  const pct = Math.floor(completed / count * 30);
@@ -1060,11 +1206,14 @@ async function bench(args) {
1060
1206
  const rps = (count / (totalMs / 1000)).toFixed(1);
1061
1207
  if (jsonFlag) {
1062
1208
  console.log(JSON.stringify({
1063
- endpoint: key, count, concurrency,
1064
- total_ms: totalMs,
1065
- rps: parseFloat(rps),
1066
- status: statusCounts,
1209
+ sources: benchSources.map(s => s.name), count, concurrency,
1210
+ total_ms: totalMs, rps: parseFloat(rps), status: statusCounts,
1067
1211
  latency: { p50: p(0.5), p95: p(0.95), p99: p(0.99), max: latencies[latencies.length - 1] },
1212
+ byType: Object.fromEntries(Object.entries(byType).map(([t, d]) => {
1213
+ const sl = d.latencies.sort((a, b) => a - b);
1214
+ const pp = (pct) => sl[Math.floor(sl.length * pct)] ?? 0;
1215
+ return [t, { count: d.count, p50: pp(0.5), p95: pp(0.95), p99: pp(0.99) }];
1216
+ })),
1068
1217
  }));
1069
1218
  return;
1070
1219
  }
@@ -1080,6 +1229,7 @@ async function bench(args) {
1080
1229
  rateLimit ? `429: ${rateLimit}` : '',
1081
1230
  errors ? `Errors: ${errors}` : '',
1082
1231
  ].filter(Boolean).join(' ');
1232
+ console.log(` Sources: ${sourceNames}`);
1083
1233
  console.log(` Total: ${(totalMs / 1000).toFixed(1)}s Req/sec: ${rps}`);
1084
1234
  console.log(` Status: ${statusLine}`);
1085
1235
  console.log();
@@ -1087,6 +1237,65 @@ async function bench(args) {
1087
1237
  console.log(` p95: ${p(0.95)}ms`);
1088
1238
  console.log(` p99: ${p(0.99)}ms`);
1089
1239
  console.log(` max: ${latencies[latencies.length - 1]}ms`);
1240
+ if (Object.keys(byType).length > 1) {
1241
+ console.log();
1242
+ console.log(` Per Endpoint`);
1243
+ console.log(` ${'Type'.padEnd(25)} ${'Reqs'.padEnd(8)} ${'p50'.padEnd(8)} ${'p95'.padEnd(8)} p99`);
1244
+ for (const [t, d] of Object.entries(byType)) {
1245
+ const sl = d.latencies.sort((a, b) => a - b);
1246
+ const pp = (pct) => sl[Math.floor(sl.length * pct)] ?? 0;
1247
+ console.log(` ${t.padEnd(25)} ${String(d.count).padEnd(8)} ${(pp(0.5) + 'ms').padEnd(8)} ${(pp(0.95) + 'ms').padEnd(8)} ${pp(0.99)}ms`);
1248
+ }
1249
+ }
1250
+ }
1251
+ /** Run production bench: 500 concurrent against api.latinfo.dev, returns success rate */
1252
+ async function benchProduction(sourceName, apiKey, concurrency = 500) {
1253
+ const benchSources = discoverBenchSources(sourceName);
1254
+ if (benchSources.length === 0)
1255
+ throw new Error(`Source ${sourceName} not found`);
1256
+ const source = benchSources[0];
1257
+ // Seed IDs
1258
+ await seedBenchQueries(source, apiKey);
1259
+ // Build mixed URL list (search + lookup)
1260
+ const urls = [];
1261
+ for (const q of source.searchQueries) {
1262
+ urls.push(`${API_URL}${source.routePath}/search?q=${encodeURIComponent(q)}&limit=5`);
1263
+ }
1264
+ for (const id of source.lookupIds) {
1265
+ urls.push(`${API_URL}${source.routePath}/${source.primaryId.name}/${id}`);
1266
+ }
1267
+ const total = concurrency;
1268
+ const headers = { Authorization: `Bearer ${apiKey}` };
1269
+ let idx = 0, success = 0, fails = 0;
1270
+ const lats = [];
1271
+ const t0 = Date.now();
1272
+ async function worker() {
1273
+ while (idx < total) {
1274
+ const i = idx++;
1275
+ const url = urls[i % urls.length];
1276
+ const start = Date.now();
1277
+ try {
1278
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(15_000) });
1279
+ lats.push(Date.now() - start);
1280
+ if (res.status === 200 || res.status === 404)
1281
+ success++;
1282
+ else
1283
+ fails++;
1284
+ }
1285
+ catch {
1286
+ lats.push(Date.now() - start);
1287
+ fails++;
1288
+ }
1289
+ }
1290
+ }
1291
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
1292
+ lats.sort((a, b) => a - b);
1293
+ const p = (pct) => lats[Math.floor(lats.length * pct)] ?? 0;
1294
+ return {
1295
+ success_rate: (success / total) * 100,
1296
+ p50: p(0.5), p95: p(0.95), p99: p(0.99),
1297
+ qps: Math.round(total / ((Date.now() - t0) / 1000)),
1298
+ };
1090
1299
  }
1091
1300
  // --- Licitaciones ---
1092
1301
  function parseFlag(args, flag) {
@@ -2356,18 +2565,61 @@ async function pipePublish(args) {
2356
2565
  catch {
2357
2566
  console.log(`[pipe] Could not trigger workflow automatically.`);
2358
2567
  }
2359
- // 4. Restart search server
2360
- console.log(`[pipe] Restarting search server on Linux Mint...`);
2568
+ // 4. Update search server SOURCES env + restart
2569
+ console.log(`[pipe] Updating search server SOURCES on Linux Mint...`);
2570
+ try {
2571
+ // Read current SOURCES from systemd service
2572
+ const currentSources = run(`ssh -o ConnectTimeout=10 ${RUNNER} "grep -oP 'SOURCES=\\K.*' /etc/systemd/system/latinfo-search.service 2>/dev/null || echo ''"`, {
2573
+ encoding: 'utf-8', stdio: 'pipe',
2574
+ }).trim().replace(/^"|"$/g, '');
2575
+ const sourceList = currentSources ? currentSources.split(',').map(s => s.trim()) : [];
2576
+ if (!sourceList.includes(sourceName)) {
2577
+ sourceList.push(sourceName);
2578
+ const newSources = sourceList.join(',');
2579
+ console.log(`[pipe] Adding ${sourceName} to SOURCES: ${newSources}`);
2580
+ run(`ssh ${RUNNER} "sudo sed -i 's|^Environment=.*SOURCES=.*|Environment=SOURCES=${newSources}|' /etc/systemd/system/latinfo-search.service && sudo systemctl daemon-reload"`, { stdio: 'pipe' });
2581
+ }
2582
+ else {
2583
+ console.log(`[pipe] ${sourceName} already in SOURCES.`);
2584
+ }
2585
+ run(`ssh ${RUNNER} "sudo systemctl restart latinfo-search"`, { stdio: 'inherit' });
2586
+ console.log(`[pipe] Search server restarted.`);
2587
+ }
2588
+ catch {
2589
+ console.log(`[pipe] Could not update search server (not critical).`);
2590
+ }
2591
+ // 5. Production bench: 500 concurrent against api.latinfo.dev
2592
+ console.log(`\n[pipe] Running production bench (500 concurrent)...`);
2361
2593
  try {
2362
- run(`ssh ${RUNNER} "sudo systemctl restart latinfo-search 2>/dev/null || echo 'No service yet'"`, { stdio: 'inherit' });
2594
+ const config = loadConfig();
2595
+ if (!config?.api_key)
2596
+ throw new Error('No API key');
2597
+ const bench = await benchProduction(sourceName, config.api_key, 500);
2598
+ console.log(`\n Production bench: ${bench.qps} q/s, ${bench.success_rate.toFixed(1)}% success`);
2599
+ console.log(` p50: ${bench.p50}ms p95: ${bench.p95}ms p99: ${bench.p99}ms`);
2600
+ if (bench.success_rate < 99.9) {
2601
+ console.error(`\n[pipe] PRODUCTION BENCH FAILED — ${bench.success_rate.toFixed(1)}% < 99.9%`);
2602
+ console.error(`[pipe] Rolling back...`);
2603
+ try {
2604
+ run(`git revert HEAD --no-edit && git push`, { cwd: repo, stdio: 'pipe' });
2605
+ run(`npx wrangler deploy`, { cwd: repo, stdio: 'pipe' });
2606
+ }
2607
+ catch { }
2608
+ status.publish = { passed: false, timestamp: new Date().toISOString() };
2609
+ savePipeStatus(status);
2610
+ process.exit(1);
2611
+ }
2612
+ status.publish = { passed: true, timestamp: new Date().toISOString(), bench: { concurrent: 500, success_rate: bench.success_rate, p50: bench.p50, p95: bench.p95, p99: bench.p99 } };
2363
2613
  }
2364
- catch { }
2614
+ catch (e) {
2615
+ console.log(`[pipe] Skipping production bench (no API key or network error).`);
2616
+ status.publish = { passed: true, timestamp: new Date().toISOString() };
2617
+ }
2618
+ savePipeStatus(status);
2365
2619
  console.log(`\n[pipe] Gate 4 PASSED ✓`);
2366
2620
  console.log(`[pipe] ${sourceName} is LIVE`);
2367
2621
  console.log(` API: https://api.latinfo.dev/${sourceName.replace(/-/g, '/')}/`);
2368
2622
  console.log(` CLI: latinfo ${sourceName.replace(/-/g, ' ')}`);
2369
- status.publish = { passed: true, timestamp: new Date().toISOString() };
2370
- savePipeStatus(status);
2371
2623
  }
2372
2624
  async function pipeStatus(args) {
2373
2625
  const [sourceName] = args;
@@ -2965,6 +3217,9 @@ else {
2965
3217
  case 'bench':
2966
3218
  bench(args).catch(e => { console.error(e); process.exit(1); });
2967
3219
  break;
3220
+ case 'search-server':
3221
+ searchServerStatus().catch(e => { console.error(e); process.exit(1); });
3222
+ break;
2968
3223
  case 'pipe':
2969
3224
  pipe(args).catch(e => { console.error(e); process.exit(1); });
2970
3225
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latinfo",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Tax registry & procurement API for Latin America. Query RUC, DNI, NIT, licitaciones from Peru & Colombia. Offline MPHF search, full OCDS data, updated daily.",
5
5
  "homepage": "https://latinfo.dev",
6
6
  "repository": {