@cyanheads/eurostat-mcp-server 0.11.2 → 0.11.3

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 (93) hide show
  1. package/AGENTS.md +6 -4
  2. package/CLAUDE.md +6 -4
  3. package/README.md +17 -15
  4. package/changelog/0.11.x/0.11.3.md +45 -0
  5. package/changelog/template.md +10 -3
  6. package/dist/config/server-config.js +1 -1
  7. package/dist/config/server-config.js.map +1 -1
  8. package/dist/mcp-server/tools/definitions/eurostat-browse-themes.tool.d.ts +1 -1
  9. package/dist/mcp-server/tools/definitions/eurostat-browse-themes.tool.js +2 -2
  10. package/dist/mcp-server/tools/definitions/eurostat-browse-themes.tool.js.map +1 -1
  11. package/dist/mcp-server/tools/definitions/eurostat-dataframe-describe.tool.js +2 -2
  12. package/dist/mcp-server/tools/definitions/eurostat-dataframe-describe.tool.js.map +1 -1
  13. package/dist/mcp-server/tools/definitions/eurostat-dataframe-drop.tool.js +1 -1
  14. package/dist/mcp-server/tools/definitions/eurostat-dataframe-drop.tool.js.map +1 -1
  15. package/dist/mcp-server/tools/definitions/eurostat-dataframe-query.tool.js +1 -1
  16. package/dist/mcp-server/tools/definitions/eurostat-dataframe-query.tool.js.map +1 -1
  17. package/dist/mcp-server/tools/definitions/eurostat-download-dataset.tool.d.ts +18 -6
  18. package/dist/mcp-server/tools/definitions/eurostat-download-dataset.tool.d.ts.map +1 -1
  19. package/dist/mcp-server/tools/definitions/eurostat-download-dataset.tool.js +129 -31
  20. package/dist/mcp-server/tools/definitions/eurostat-download-dataset.tool.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/eurostat-get-dataset-info.tool.js +1 -1
  22. package/dist/mcp-server/tools/definitions/eurostat-get-dataset-info.tool.js.map +1 -1
  23. package/dist/mcp-server/tools/definitions/eurostat-get-dimension-values.tool.d.ts +6 -4
  24. package/dist/mcp-server/tools/definitions/eurostat-get-dimension-values.tool.d.ts.map +1 -1
  25. package/dist/mcp-server/tools/definitions/eurostat-get-dimension-values.tool.js +139 -53
  26. package/dist/mcp-server/tools/definitions/eurostat-get-dimension-values.tool.js.map +1 -1
  27. package/dist/mcp-server/tools/definitions/eurostat-query-dataset.tool.d.ts +20 -8
  28. package/dist/mcp-server/tools/definitions/eurostat-query-dataset.tool.d.ts.map +1 -1
  29. package/dist/mcp-server/tools/definitions/eurostat-query-dataset.tool.js +208 -43
  30. package/dist/mcp-server/tools/definitions/eurostat-query-dataset.tool.js.map +1 -1
  31. package/dist/mcp-server/tools/definitions/eurostat-search-datasets.tool.d.ts +16 -6
  32. package/dist/mcp-server/tools/definitions/eurostat-search-datasets.tool.d.ts.map +1 -1
  33. package/dist/mcp-server/tools/definitions/eurostat-search-datasets.tool.js +62 -23
  34. package/dist/mcp-server/tools/definitions/eurostat-search-datasets.tool.js.map +1 -1
  35. package/dist/mcp-server/tools/narrowing-advice.d.ts +64 -3
  36. package/dist/mcp-server/tools/narrowing-advice.d.ts.map +1 -1
  37. package/dist/mcp-server/tools/narrowing-advice.js +160 -4
  38. package/dist/mcp-server/tools/narrowing-advice.js.map +1 -1
  39. package/dist/mcp-server/tools/notice-text.d.ts +18 -0
  40. package/dist/mcp-server/tools/notice-text.d.ts.map +1 -0
  41. package/dist/mcp-server/tools/notice-text.js +40 -0
  42. package/dist/mcp-server/tools/notice-text.js.map +1 -0
  43. package/dist/mcp-server/tools/value-list-paging.d.ts +11 -1
  44. package/dist/mcp-server/tools/value-list-paging.d.ts.map +1 -1
  45. package/dist/mcp-server/tools/value-list-paging.js +13 -1
  46. package/dist/mcp-server/tools/value-list-paging.js.map +1 -1
  47. package/dist/services/eurostat-bulk/eurostat-bulk-service.d.ts +4 -3
  48. package/dist/services/eurostat-bulk/eurostat-bulk-service.d.ts.map +1 -1
  49. package/dist/services/eurostat-bulk/eurostat-bulk-service.js +107 -54
  50. package/dist/services/eurostat-bulk/eurostat-bulk-service.js.map +1 -1
  51. package/dist/services/eurostat-bulk/types.d.ts +27 -4
  52. package/dist/services/eurostat-bulk/types.d.ts.map +1 -1
  53. package/dist/services/eurostat-bulk/types.js.map +1 -1
  54. package/dist/services/eurostat-catalogue/dimension-value-index.d.ts +81 -0
  55. package/dist/services/eurostat-catalogue/dimension-value-index.d.ts.map +1 -0
  56. package/dist/services/eurostat-catalogue/dimension-value-index.js +410 -0
  57. package/dist/services/eurostat-catalogue/dimension-value-index.js.map +1 -0
  58. package/dist/services/eurostat-catalogue/eurostat-catalogue-service.d.ts +87 -7
  59. package/dist/services/eurostat-catalogue/eurostat-catalogue-service.d.ts.map +1 -1
  60. package/dist/services/eurostat-catalogue/eurostat-catalogue-service.js +333 -81
  61. package/dist/services/eurostat-catalogue/eurostat-catalogue-service.js.map +1 -1
  62. package/dist/services/eurostat-catalogue/search-text.d.ts +12 -0
  63. package/dist/services/eurostat-catalogue/search-text.d.ts.map +1 -0
  64. package/dist/services/eurostat-catalogue/search-text.js +31 -0
  65. package/dist/services/eurostat-catalogue/search-text.js.map +1 -0
  66. package/dist/services/eurostat-catalogue/types.d.ts +11 -0
  67. package/dist/services/eurostat-catalogue/types.d.ts.map +1 -1
  68. package/dist/services/eurostat-data/eurostat-data-service.d.ts +26 -5
  69. package/dist/services/eurostat-data/eurostat-data-service.d.ts.map +1 -1
  70. package/dist/services/eurostat-data/eurostat-data-service.js +132 -16
  71. package/dist/services/eurostat-data/eurostat-data-service.js.map +1 -1
  72. package/dist/services/eurostat-data/sdmx-metadata.d.ts.map +1 -1
  73. package/dist/services/eurostat-data/sdmx-metadata.js +4 -20
  74. package/dist/services/eurostat-data/sdmx-metadata.js.map +1 -1
  75. package/dist/services/eurostat-data/types.d.ts +92 -2
  76. package/dist/services/eurostat-data/types.d.ts.map +1 -1
  77. package/dist/services/eurostat-data/types.js.map +1 -1
  78. package/dist/services/eurostat-filters.d.ts +18 -6
  79. package/dist/services/eurostat-filters.d.ts.map +1 -1
  80. package/dist/services/eurostat-filters.js +24 -7
  81. package/dist/services/eurostat-filters.js.map +1 -1
  82. package/dist/services/eurostat-periods.d.ts +54 -1
  83. package/dist/services/eurostat-periods.d.ts.map +1 -1
  84. package/dist/services/eurostat-periods.js +101 -1
  85. package/dist/services/eurostat-periods.js.map +1 -1
  86. package/dist/services/xml-entities.d.ts +13 -0
  87. package/dist/services/xml-entities.d.ts.map +1 -0
  88. package/dist/services/xml-entities.js +31 -0
  89. package/dist/services/xml-entities.js.map +1 -0
  90. package/package.json +5 -5
  91. package/server.json +5 -5
  92. package/skills/eurostat-guide/SKILL.md +6 -6
  93. package/skills/eurostat-guide/references/sql-analysis.md +5 -5
@@ -3,14 +3,20 @@
3
3
  * provides dataset search and theme tree navigation.
4
4
  * @module services/eurostat-catalogue/eurostat-catalogue-service
5
5
  */
6
- import { invalidParams, notFound, serviceUnavailable, validationError, } from '@cyanheads/mcp-ts-core/errors';
7
- import { decodeCursor, encodeCursor, fetchWithTimeout, withExtra, withRetry, } from '@cyanheads/mcp-ts-core/utils';
6
+ import { invalidParams, McpError, notFound, serviceUnavailable, validationError, } from '@cyanheads/mcp-ts-core/errors';
7
+ import { decodeCursor, encodeCursor, fetchWithTimeout, logger, requestContextService, runDetached, withExtra, withRetry, } from '@cyanheads/mcp-ts-core/utils';
8
8
  import { getServerConfig } from '../../config/server-config.js';
9
9
  import { parseSdmxDataflowList, } from '../eurostat-data/sdmx-metadata.js';
10
10
  import { upstreamOutage } from '../eurostat-outage.js';
11
11
  import { awaitShared } from '../shared-load.js';
12
+ import { fetchDimensionValueIndex } from './dimension-value-index.js';
13
+ import { foldSearchText, occursAtWordStart } from './search-text.js';
12
14
  /** Upper bound on datasets returned per search page, mirroring the tool's `limit` cap. */
13
15
  const MAX_PAGE_SIZE = 100;
16
+ /** Most distinct words a search that matches no catalogue text looks for in dimension values. */
17
+ export const MAX_VALUE_SEARCH_WORDS = 10;
18
+ /** Most distinct words, in query order, a search that matches nothing names as held by no dataset. */
19
+ export const MAX_UNMATCHED_WORD_CHECKS = 32;
14
20
  /**
15
21
  * How long a failed refresh keeps the stale cache in service before upstream is
16
22
  * tried again. `withRetry` bounds one load; this bounds the attempt rate, so
@@ -45,12 +51,30 @@ const COMEXT_PARENT_FOLDERS = new Set(Object.values(COMEXT_FOLDERS).map((f) => f
45
51
  /** A dataset code on the Comext host's scheme, as a lowercased search token. */
46
52
  const DS_CODE_TOKEN = /^ds-\d+$/;
47
53
  /**
48
- * Whitespace beside punctuation other than `_`, removed from search text so `Rev.2` finds
49
- * `Rev. 2` and `2015=100` finds `2015 = 100`. A run is taken whole after a punctuation mark,
50
- * or from its first blank when one follows it — never retried from inside a run, which keeps
51
- * a long run of blanks linear.
54
+ * The tier test for one search over `words`, whitespace-free tokens as `search()` splits a
55
+ * query. The returned function ranks a dataset from its label, folded by
56
+ * {@link foldSearchText}, and its code, never its breadcrumb, so every TOC placement of a
57
+ * code ranks alike. The phrase is the words joined by single spaces and folded the same way;
58
+ * it must start where no letter or digit precedes it, so "employment rate" is not found
59
+ * inside "Unemployment rate". The word test is the substring test matching applies.
52
60
  */
53
- const SPACE_BESIDE_PUNCTUATION = /(?<=[^\p{L}\p{N}_\s])\s+|(?<!\s)\s+(?=[^\p{L}\p{N}_\s])/gu;
61
+ export function searchTierOf(words) {
62
+ const phrase = foldSearchText(words.join(' '));
63
+ const distinct = [...new Set(words.map((word) => word.toLowerCase()))];
64
+ return (label, code) => {
65
+ if (occursAtWordStart(label, phrase))
66
+ return 0;
67
+ const lowerCode = code.toLowerCase();
68
+ return distinct.every((word) => label.includes(word) || lowerCode.includes(word)) ? 1 : 2;
69
+ };
70
+ }
71
+ /** The number of set bits of a mask of query words. */
72
+ function wordCount(mask) {
73
+ let count = 0;
74
+ for (let rest = mask; rest !== 0; rest &= rest - 1)
75
+ count += 1;
76
+ return count;
77
+ }
54
78
  export class EurostatCatalogueService {
55
79
  cache;
56
80
  /** Shared in-flight refresh so concurrent callers past the TTL trigger one fetch. */
@@ -63,6 +87,11 @@ export class EurostatCatalogueService {
63
87
  * past its TTL that the refresh could not replace.
64
88
  */
65
89
  refreshRetryAt = 0;
90
+ /**
91
+ * The dimension value index a search that matches no catalogue text reads. Undefined until
92
+ * a TOC load starts the first index load.
93
+ */
94
+ valueIndex;
66
95
  // config and storage accepted to match the standard service init pattern;
67
96
  // this service uses only the Eurostat public API and per-request config.
68
97
  // biome-ignore lint/complexity/noUselessConstructor: standard init pattern
@@ -86,6 +115,8 @@ export class EurostatCatalogueService {
86
115
  return awaitShared(ctx.signal, () => this.loadShared(ctx));
87
116
  }
88
117
  async loadShared(ctx) {
118
+ if (this.valueIndex)
119
+ this.loadValueIndex(this.valueIndex);
89
120
  const cached = this.cache;
90
121
  const now = Date.now();
91
122
  if (cached &&
@@ -128,8 +159,12 @@ export class EurostatCatalogueService {
128
159
  * Only the TOC is required. A Comext list that fails to load leaves the catalogue
129
160
  * answering from the TOC alone: the failure is logged, and the merge is retried
130
161
  * after `REFRESH_RETRY_COOLDOWN_MS` without re-fetching the TOC.
162
+ *
163
+ * Every TOC load also starts the dimension value index load, which nothing awaits.
131
164
  */
132
165
  async fetchAndParseToc(ctx) {
166
+ this.valueIndex ??= { failures: 0, retryAt: 0 };
167
+ this.loadValueIndex(this.valueIndex);
133
168
  const comext = this.fetchComextFlows(ctx).then((flows) => ({ flows }), (error) => ({ error }));
134
169
  const tocEntries = await this.fetchTocEntries(ctx);
135
170
  const loaded = await comext;
@@ -162,11 +197,52 @@ export class EurostatCatalogueService {
162
197
  retryAfterMs: REFRESH_RETRY_COOLDOWN_MS,
163
198
  });
164
199
  }
200
+ /**
201
+ * Start a dimension value index load unless one is in flight, a failed load's backoff has
202
+ * not elapsed, or the index in service is younger than the TOC's TTL. Catalogue calls never
203
+ * wait for it: the load runs detached from the calling request and its trace, logs to the
204
+ * server log, and settles into the slot. A failure keeps the previous index in service and
205
+ * holds off the next attempt for `REFRESH_RETRY_COOLDOWN_MS`, doubling with each failure in
206
+ * a row up to the TOC's TTL.
207
+ */
208
+ loadValueIndex(slot) {
209
+ const now = Date.now();
210
+ const { tocCacheTtlMs } = getServerConfig();
211
+ if (slot.loading || now < slot.retryAt)
212
+ return;
213
+ if (slot.index && now - slot.index.loadedAt < tocCacheTtlMs)
214
+ return;
215
+ const context = requestContextService.createRequestContext({
216
+ operation: 'loadDimensionValueIndex',
217
+ });
218
+ slot.loading = runDetached(() => fetchDimensionValueIndex(context))
219
+ .then((index) => {
220
+ slot.index = index;
221
+ slot.failures = 0;
222
+ slot.retryAt = 0;
223
+ logger.info('Dimension value index loaded', withExtra(context, index.size));
224
+ }, (error) => {
225
+ slot.failures += 1;
226
+ const retryAfterMs = Math.min(REFRESH_RETRY_COOLDOWN_MS * 2 ** (slot.failures - 1), tocCacheTtlMs);
227
+ slot.retryAt = Date.now() + retryAfterMs;
228
+ logger.warning(slot.index
229
+ ? 'Dimension value index refresh failed — the previous index stays in service'
230
+ : 'Dimension value index unavailable — searches that match no catalogue text do not read dimension values', withExtra(context, {
231
+ error: error instanceof Error ? error.message : String(error),
232
+ failures: slot.failures,
233
+ retryAfterMs,
234
+ }));
235
+ })
236
+ .finally(() => {
237
+ delete slot.loading;
238
+ });
239
+ }
165
240
  /**
166
241
  * Assemble a catalogue snapshot. `flows` undefined means the Comext list failed to
167
242
  * load, which arms the merge retry. `loadedAt` is the TOC's load time — the TTL and
168
- * the cursor generation both key on it. A later merge only appends entries, so a
169
- * cursor paged over the TOC alone still addresses the same leading matches.
243
+ * the cursor generation both key on it. A later merge only appends entries, and
244
+ * `search()` pages entries appended after a walk began behind the ones it began over, so
245
+ * a cursor paged over the TOC alone still pages every match once.
170
246
  */
171
247
  buildCache(tocEntries, flows, loadedAt) {
172
248
  const entries = flows ? this.mergeComextFlows(tocEntries, flows) : tocEntries;
@@ -258,8 +334,17 @@ export class EurostatCatalogueService {
258
334
  return body;
259
335
  }, { operation: 'fetchToc', context: fetchCtx, baseDelayMs: 1000, deadlineMs: requestTimeoutMs }).catch((error) => {
260
336
  // The load is shared and runs without a caller's signal, so nothing here is a cancellation.
261
- throw (upstreamOutage(error, "Eurostat's catalogue endpoint", 'the table-of-contents read', {}) ??
262
- error);
337
+ const outage = upstreamOutage(error, "Eurostat's catalogue endpoint", 'the table-of-contents read', {});
338
+ if (outage)
339
+ throw outage;
340
+ // The TOC URL carries no caller input, so any other HTTP status is a fault on Eurostat's side too.
341
+ const status = error instanceof McpError ? error.data?.status : undefined;
342
+ if (typeof status === 'number') {
343
+ throw serviceUnavailable(`Eurostat's catalogue endpoint answered HTTP ${status} to the table-of-contents read.`,
344
+ // 408 and 429 are transient however long they last; any other status repeats until Eurostat changes it.
345
+ { reason: 'upstream_fault', status, retryable: status === 408 || status === 429 }, { cause: error });
346
+ }
347
+ throw error;
263
348
  });
264
349
  const entries = this.parseToc(text);
265
350
  ctx.log.info('TOC loaded', { entryCount: entries.length });
@@ -516,30 +601,53 @@ export class EurostatCatalogueService {
516
601
  * Decode a search cursor and confirm it belongs to this search. Malformed,
517
602
  * cross-query and superseded-snapshot cursors are rejected identically —
518
603
  * the caller's recovery is the same in all three cases, and the cursor is
519
- * opaque, so telling them apart would only describe its internal shape.
604
+ * opaque, so telling them apart would only describe its internal shape. `entryCount`,
605
+ * the snapshot's length when the walk began, can be no more than its length now.
606
+ * `valueIndex`, present on a walk over rows found through dimension values, is checked
607
+ * once `search()` knows which rows it pages.
520
608
  */
521
- decodeSearchCursor(cursor, queryKey, generation, ctx) {
609
+ decodeSearchCursor(cursor, queryKey, generation, entryCount, ctx) {
522
610
  try {
523
611
  const state = decodeCursor(cursor, ctx);
524
- if (state.query === queryKey && state.generation === generation)
525
- return state;
612
+ const walked = state.entryCount;
613
+ if (state.query === queryKey &&
614
+ state.generation === generation &&
615
+ typeof walked === 'number' &&
616
+ Number.isInteger(walked) &&
617
+ walked >= 0 &&
618
+ walked <= entryCount) {
619
+ return { ...state, entryCount: walked };
620
+ }
526
621
  }
527
622
  catch {
528
623
  // Undecodable cursor — same rejection as one that decodes but does not match.
529
624
  }
530
- throw invalidParams('Unusable pagination cursor: it is malformed, was issued for a different query, or was issued against a catalogue snapshot that has since refreshed. Repeat the search without a cursor, then page with the nextCursor it returns.', { reason: 'invalid_cursor' });
625
+ throw invalidCursor();
626
+ }
627
+ /** One search result row for the TOC placement `entries[i]`. */
628
+ toDatasetResult(entries, e, i) {
629
+ return {
630
+ code: e.code,
631
+ label: e.label,
632
+ type: e.type,
633
+ ...(e.dataStart && { dataStart: e.dataStart }),
634
+ ...(e.dataEnd && { dataEnd: e.dataEnd }),
635
+ ...(e.lastUpdated && { lastUpdated: e.lastUpdated }),
636
+ ...(e.obsCount !== undefined && { obsCount: e.obsCount }),
637
+ themePath: this.buildPath(entries, i),
638
+ };
531
639
  }
532
640
  /**
533
- * Each dataset's search text — label, breadcrumb, and code, lowercased, with whitespace
534
- * beside punctuation removed — indexed like `entries`. Folders, which search never
641
+ * Each dataset's search text, indexed like `entries`. Folders, which search never
535
642
  * returns, are undefined.
536
643
  */
537
644
  buildSearchText(entries) {
538
645
  return entries.map((e, i) => e.type === 'folder'
539
646
  ? undefined
540
- : `${e.label} ${this.buildPath(entries, i).join(' ')} ${e.code}`
541
- .toLowerCase()
542
- .replace(SPACE_BESIDE_PUNCTUATION, ''));
647
+ : {
648
+ label: foldSearchText(e.label),
649
+ text: foldSearchText(`${e.label} ${this.buildPath(entries, i).join(' ')} ${e.code}`),
650
+ });
543
651
  }
544
652
  /**
545
653
  * For a query that matched nothing, the words whose removal alone finds matches, with
@@ -549,28 +657,42 @@ export class EurostatCatalogueService {
549
657
  * typed. One pass: a placement missing exactly one word rescues its code for that
550
658
  * word, and a code counts once per word whichever placement rescued it — so each count
551
659
  * equals the `totalMatches` of the same search without that word.
660
+ *
661
+ * `unmatchedWords` is drawn from the first {@link MAX_UNMATCHED_WORD_CHECKS} distinct
662
+ * words. `unmatchedWordsPartial` is set when a word past them is held by no text the pass
663
+ * read, so it may be one no dataset carries.
552
664
  */
553
665
  countWithoutEachWord(toc, texts, words) {
666
+ const tokens = [...words.keys()];
554
667
  const rescued = new Map();
555
- const unmatched = new Set(words.keys());
668
+ /** The words checked against every text, while no text read so far holds them. */
669
+ const unmatched = new Set(tokens.slice(0, MAX_UNMATCHED_WORD_CHECKS));
670
+ /** The words past those, while no text read so far holds them. */
671
+ const unchecked = new Set(tokens.slice(MAX_UNMATCHED_WORD_CHECKS));
556
672
  toc.entries.forEach((e, i) => {
557
- const text = texts[i];
673
+ const text = texts[i]?.text;
558
674
  if (text === undefined)
559
675
  return;
560
676
  let missing;
561
677
  let misses = 0;
562
- for (const token of words.keys()) {
563
- // Past a second miss nothing is rescued; only a word not yet seen anywhere needs checking.
564
- if (misses > 1 && !unmatched.has(token))
565
- continue;
678
+ for (const token of tokens) {
566
679
  if (text.includes(token)) {
567
680
  unmatched.delete(token);
681
+ unchecked.delete(token);
682
+ }
683
+ else if (++misses > 1) {
684
+ break;
568
685
  }
569
686
  else {
570
- misses += 1;
571
687
  missing = token;
572
688
  }
573
689
  }
690
+ // Past a second miss nothing is rescued; the checked words this text may hold still count.
691
+ if (misses > 1) {
692
+ for (const token of unmatched)
693
+ if (text.includes(token))
694
+ unmatched.delete(token);
695
+ }
574
696
  if (misses !== 1 || missing === undefined)
575
697
  return;
576
698
  const codes = rescued.get(missing) ?? new Set();
@@ -585,8 +707,78 @@ export class EurostatCatalogueService {
585
707
  ...(wordRemovalCounts.length > 0 && { wordRemovalCounts }),
586
708
  ...(unmatchedWords.length > 0 && { unmatchedWords }),
587
709
  ...(unmatched.size === words.size && { allWordsUnmatched: true }),
710
+ ...(unchecked.size > 0 && { unmatchedWordsPartial: true }),
588
711
  };
589
712
  }
713
+ /**
714
+ * The datasets a query that matched no catalogue text finds when a word may also occur
715
+ * at a word start of the English label of a value the metabase lists for the dataset.
716
+ * Every word must still be found, in the dataset's own text or in a value's label. Each
717
+ * code keeps its placement whose text holds the most words, the first such in TOC order.
718
+ *
719
+ * Ordered by fewer words found only through values; then rows where one value's label
720
+ * carries all of those words; then {@link searchTierOf} over the tokens the dataset's text
721
+ * holds, in query order; then TOC order. `marks` holds, for each word found through
722
+ * values, which labels carry it. `words` are the query's distinct tokens, at most
723
+ * {@link MAX_VALUE_SEARCH_WORDS}.
724
+ */
725
+ searchValues(index, toc, texts, tokens, words) {
726
+ const marksOf = new Map();
727
+ let live = [];
728
+ toc.entries.forEach((e, i) => {
729
+ const dataset = texts[i] && index.datasetOf(e.code);
730
+ if (dataset !== undefined)
731
+ live.push({ dataset, i, through: 0 });
732
+ });
733
+ for (let k = 0; k < words.length && live.length > 0; k++) {
734
+ const word = words[k];
735
+ const found = new Map();
736
+ live = live.filter((row) => {
737
+ if (texts[row.i]?.text.includes(word))
738
+ return true;
739
+ let has = found.get(row.dataset);
740
+ if (has === undefined) {
741
+ if (!marksOf.has(k))
742
+ marksOf.set(k, index.labelsAtWordStart(word));
743
+ const marks = marksOf.get(k);
744
+ has = marks !== undefined && index.hasValue(row.dataset, marks);
745
+ found.set(row.dataset, has);
746
+ }
747
+ if (has)
748
+ row.through |= 1 << k;
749
+ return has;
750
+ });
751
+ }
752
+ const best = new Map();
753
+ for (const row of live) {
754
+ const code = toc.entries[row.i].code;
755
+ const kept = best.get(code);
756
+ if (!kept || wordCount(row.through) < wordCount(kept.through))
757
+ best.set(code, row);
758
+ }
759
+ const wordIndex = new Map(words.map((word, k) => [word, k]));
760
+ const tierOfThrough = new Map();
761
+ return [...best.values()]
762
+ .map((row) => {
763
+ const marks = words.flatMap((_, k) => row.through & (1 << k) ? [marksOf.get(k)] : []);
764
+ let tierOf = tierOfThrough.get(row.through);
765
+ if (!tierOf) {
766
+ tierOf = searchTierOf(tokens.filter((token) => (row.through & (1 << wordIndex.get(token))) === 0));
767
+ tierOfThrough.set(row.through, tierOf);
768
+ }
769
+ const e = toc.entries[row.i];
770
+ return {
771
+ ...row,
772
+ marks,
773
+ sharesOne: marks.length === 1 || index.hasValueMarkedByAll(row.dataset, marks),
774
+ tier: tierOf(texts[row.i].label, e.code),
775
+ };
776
+ })
777
+ .sort((a, b) => a.marks.length - b.marks.length ||
778
+ Number(b.sharesOne) - Number(a.sharesOne) ||
779
+ a.tier - b.tier ||
780
+ a.i - b.i);
781
+ }
590
782
  /**
591
783
  * Search datasets by keyword. The query is tokenized on whitespace and every
592
784
  * token must match (AND), case-insensitively, somewhere in a combined
@@ -601,18 +793,36 @@ export class EurostatCatalogueService {
601
793
  * several TOC branches keeps its first matching placement as the canonical
602
794
  * `themePath`, so totals and page slots count unique query targets.
603
795
  *
796
+ * Matches are ordered by {@link searchTierOf} over the tokens — label phrase, then
797
+ * every word in label or code, then breadcrumb-only — and by that first matching
798
+ * placement within a tier. A tier reads the label and code alone, so it is the same
799
+ * whichever placement supplied the `themePath`.
800
+ *
604
801
  * Results are paginated with an opaque cursor (`limit` = page size, capped at
605
802
  * 100) bound to the normalized query and the catalogue snapshot that produced
606
803
  * it. An unusable cursor — malformed, from another query, or from a catalogue
607
804
  * since refreshed — is rejected as `invalid_cursor` instead of silently paging
608
- * a different result set.
805
+ * a different result set. A Comext merge that lands mid-walk keeps the snapshot,
806
+ * so the cursor also carries the snapshot's length when the walk began: the
807
+ * datasets the merge appended page after every match the walk began over, each
808
+ * match still once.
809
+ *
810
+ * When no dataset's text holds every token, {@link searchValues} looks for the tokens
811
+ * the text lacks in the English labels of the dataset's dimension values. Its rows
812
+ * carry `matchedValues` and the result `matchedThroughValues`, and their cursor also
813
+ * binds the value index snapshot (`valueIndex`), so an index refresh mid-walk rejects it
814
+ * like a catalogue refresh. No search waits for the index: until one has loaded,
815
+ * `valuesNotSearched` is `index_unavailable`; past {@link MAX_VALUE_SEARCH_WORDS}
816
+ * distinct tokens it is `too_many_words`.
609
817
  *
610
818
  * An empty match reports the `DS-*` codes the query names that neither source lists
611
819
  * (`undisseminatedCodes`), so the caller can be told the collection is not
612
820
  * disseminated rather than to broaden the search, and — for two or more distinct
613
821
  * words — each word whose removal alone finds matches (`wordRemovalCounts`) and each
614
822
  * word no dataset's search text contains (`unmatchedWords`, as first typed), with
615
- * `allWordsUnmatched` when that is every word. `comextListMissing` flags that the
823
+ * `allWordsUnmatched` when that is every word. `unmatchedWords` reads the first
824
+ * {@link MAX_UNMATCHED_WORD_CHECKS} distinct words; `unmatchedWordsPartial` flags a query
825
+ * with a later word that may be one no dataset carries. `comextListMissing` flags that the
616
826
  * Comext list is not merged right now, so the `DS-*` collections were not searched and
617
827
  * absence from that list proves nothing; `catalogueLoadedAt` marks a catalogue a
618
828
  * failed refresh could not replace.
@@ -622,72 +832,114 @@ export class EurostatCatalogueService {
622
832
  const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
623
833
  if (tokens.length === 0)
624
834
  return { datasets: [], totalMatches: 0 };
625
- toc.searchText ??= this.buildSearchText(toc.entries);
626
- const texts = toc.searchText;
627
- const seen = new Set();
628
- const matched = toc.entries.flatMap((e, i) => {
629
- const text = texts[i];
630
- if (text === undefined || seen.has(e.code) || !tokens.every((t) => text.includes(t))) {
631
- return [];
632
- }
633
- seen.add(e.code);
634
- return [{ e, i }];
635
- });
636
835
  /** Cursor identity: the normalized query plus the catalogue snapshot it was paged over. */
637
836
  const queryKey = tokens.join(' ');
638
837
  const generation = toc.loadedAt.getTime();
639
838
  let offset = 0;
640
839
  let pageSize = limit;
840
+ /** The snapshot's length when this walk began; any entry past it came from a Comext merge since. */
841
+ let entryCount = toc.entries.length;
842
+ /** The value index snapshot a walk over rows found through dimension values began on. */
843
+ let walkedValueIndex;
641
844
  if (cursor) {
642
- const state = this.decodeSearchCursor(cursor, queryKey, generation, ctx);
845
+ const state = this.decodeSearchCursor(cursor, queryKey, generation, entryCount, ctx);
643
846
  offset = state.offset;
644
847
  pageSize = Math.min(state.limit, MAX_PAGE_SIZE);
848
+ entryCount = state.entryCount;
849
+ walkedValueIndex = state.valueIndex;
645
850
  }
646
- const datasets = matched.slice(offset, offset + pageSize).map(({ e, i }) => ({
647
- code: e.code,
648
- label: e.label,
649
- type: e.type,
650
- ...(e.dataStart && { dataStart: e.dataStart }),
651
- ...(e.dataEnd && { dataEnd: e.dataEnd }),
652
- ...(e.lastUpdated && { lastUpdated: e.lastUpdated }),
653
- ...(e.obsCount !== undefined && { obsCount: e.obsCount }),
654
- themePath: this.buildPath(toc.entries, i),
655
- }));
656
- let undisseminatedCodes = [];
657
- let blockingWords = {};
658
- if (matched.length === 0) {
659
- undisseminatedCodes = tokens
660
- .filter((t) => DS_CODE_TOKEN.test(t) && !toc.codeIndex.has(t.toUpperCase()))
661
- .map((t) => t.toUpperCase());
662
- // The typed words line up with the tokens: lowercasing neither adds nor removes whitespace.
663
- const typed = query.split(/\s+/).filter(Boolean);
664
- const words = new Map();
665
- tokens.forEach((token, n) => {
666
- if (!words.has(token))
667
- words.set(token, typed[n] ?? token);
668
- });
669
- if (words.size > 1)
670
- blockingWords = this.countWithoutEachWord(toc, texts, words);
671
- }
672
- const nextOffset = offset + pageSize;
673
- return {
674
- datasets,
675
- totalMatches: matched.length,
676
- ...(undisseminatedCodes.length > 0 && { undisseminatedCodes }),
677
- ...blockingWords,
851
+ const sources = {
678
852
  ...(toc.comextRetryAt !== undefined && { comextListMissing: true }),
679
853
  ...this.staleness(toc),
680
- ...(nextOffset < matched.length && {
681
- nextCursor: encodeCursor({
682
- offset: nextOffset,
683
- limit: pageSize,
684
- query: queryKey,
685
- generation,
686
- }),
854
+ };
855
+ const nextOffset = offset + pageSize;
856
+ const nextCursor = (total, valueIndex) => nextOffset < total && {
857
+ nextCursor: encodeCursor({
858
+ offset: nextOffset,
859
+ limit: pageSize,
860
+ query: queryKey,
861
+ generation,
862
+ entryCount,
863
+ ...(valueIndex !== undefined && { valueIndex }),
687
864
  }),
688
865
  };
866
+ toc.searchText ??= this.buildSearchText(toc.entries);
867
+ const texts = toc.searchText;
868
+ const tierOf = searchTierOf(tokens);
869
+ const distinct = [...new Set(tokens)];
870
+ const seen = new Set();
871
+ const matched = toc.entries
872
+ .flatMap((e, i) => {
873
+ const text = texts[i];
874
+ if (text === undefined ||
875
+ seen.has(e.code) ||
876
+ !distinct.every((t) => text.text.includes(t))) {
877
+ return [];
878
+ }
879
+ seen.add(e.code);
880
+ return [{ e, i, appended: i >= entryCount, tier: tierOf(text.label, e.code) }];
881
+ })
882
+ .sort((a, b) => Number(a.appended) - Number(b.appended) || a.tier - b.tier);
883
+ if (matched.length > 0) {
884
+ if (walkedValueIndex !== undefined)
885
+ throw invalidCursor();
886
+ return {
887
+ datasets: matched
888
+ .slice(offset, offset + pageSize)
889
+ .map(({ e, i }) => this.toDatasetResult(toc.entries, e, i)),
890
+ totalMatches: matched.length,
891
+ ...sources,
892
+ ...nextCursor(matched.length),
893
+ };
894
+ }
895
+ const index = this.valueIndex?.index;
896
+ const valuesNotSearched = distinct.length > MAX_VALUE_SEARCH_WORDS
897
+ ? 'too_many_words'
898
+ : this.valueIndex && !index
899
+ ? 'index_unavailable'
900
+ : undefined;
901
+ if (cursor && (walkedValueIndex === undefined || walkedValueIndex !== index?.loadedAt)) {
902
+ throw invalidCursor();
903
+ }
904
+ if (index && !valuesNotSearched) {
905
+ const rows = this.searchValues(index, toc, texts, tokens, distinct);
906
+ if (rows.length > 0) {
907
+ return {
908
+ datasets: rows.slice(offset, offset + pageSize).map(({ dataset, i, marks }) => ({
909
+ ...this.toDatasetResult(toc.entries, toc.entries[i], i),
910
+ matchedValues: index.valuesMarked(dataset, marks),
911
+ })),
912
+ totalMatches: rows.length,
913
+ matchedThroughValues: true,
914
+ ...sources,
915
+ ...nextCursor(rows.length, index.loadedAt),
916
+ };
917
+ }
918
+ }
919
+ const undisseminatedCodes = tokens
920
+ .filter((t) => DS_CODE_TOKEN.test(t) && !toc.codeIndex.has(t.toUpperCase()))
921
+ .map((t) => t.toUpperCase());
922
+ // The typed words line up with the tokens: lowercasing neither adds nor removes whitespace.
923
+ const typed = query.split(/\s+/).filter(Boolean);
924
+ const words = new Map();
925
+ tokens.forEach((token, n) => {
926
+ if (!words.has(token))
927
+ words.set(token, typed[n] ?? token);
928
+ });
929
+ return {
930
+ datasets: [],
931
+ totalMatches: 0,
932
+ ...(undisseminatedCodes.length > 0 && { undisseminatedCodes }),
933
+ ...(words.size > 1 && this.countWithoutEachWord(toc, texts, words)),
934
+ ...(valuesNotSearched && { valuesNotSearched }),
935
+ ...sources,
936
+ };
689
937
  }
690
938
  }
939
+ /** The one rejection for every unusable search cursor. */
940
+ function invalidCursor() {
941
+ return invalidParams('Unusable pagination cursor: it is malformed, was issued for a different query, or was issued against a catalogue snapshot that has since refreshed. Repeat the search without a cursor, then page with the nextCursor it returns.', { reason: 'invalid_cursor' });
942
+ }
691
943
  /**
692
944
  * An SDMX timestamp (`2026-09-15T11:00:00+0200`) in the TOC's `dd.mm.yyyy` form, the
693
945
  * date read as published rather than shifted across time zones. Anything else is