@swirl-search/backstage-plugin-search-backend-module-swirl 0.1.0 → 0.1.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/README.md CHANGED
@@ -13,6 +13,39 @@ Identity travels with the query. The search router mints a plugin token for each
13
13
 
14
14
  ## Install
15
15
 
16
+ ### First, allow the scope in `.yarnrc.yml`
17
+
18
+ A fresh `@backstage/create-app` ships this:
19
+
20
+ ```yaml
21
+ # .yarnrc.yml, as create-app writes it
22
+ nodeLinker: node-modules
23
+ npmMinimalAgeGate: 3d
24
+ npmPreapprovedPackages:
25
+ - '@backstage/*'
26
+ ```
27
+
28
+ `npmMinimalAgeGate: 3d` is a Yarn 4 supply-chain control that refuses any
29
+ package published in the last 72 hours unless its scope is preapproved. For the
30
+ first three days after every release of this package, `yarn add` fails with
31
+ `YN0016: All versions satisfying "x.y.z" are quarantined`, whether or not you
32
+ pin the version. Add the scope:
33
+
34
+ ```yaml
35
+ # .yarnrc.yml
36
+ nodeLinker: node-modules
37
+ npmMinimalAgeGate: 3d
38
+ npmPreapprovedPackages:
39
+ - '@backstage/*'
40
+ - '@swirl-search/*'
41
+ ```
42
+
43
+ Preapproving the scope is the Backstage-native escape hatch. Do not set
44
+ `npmMinimalAgeGate: 0`; that turns the control off for every package in the
45
+ repository.
46
+
47
+ ### Then install
48
+
16
49
  ```sh
17
50
  # from your Backstage root
18
51
  yarn --cwd packages/backend add @swirl-search/backstage-plugin-search-backend-module-swirl
@@ -63,7 +96,8 @@ search:
63
96
  # Per-query federation timeout in ms, passed to SWIRL. Default 5000.
64
97
  timeoutMs: 5000
65
98
 
66
- # Relevance tuning, mirrored to SWIRL on startup.
99
+ # Relevance tuning, mirrored to SWIRL on startup. Nested camelCase, which
100
+ # is the only shape config.d.ts accepts; see the note below the block.
67
101
  tuning:
68
102
  fieldBoosts:
69
103
  titleExact: 3
@@ -77,6 +111,8 @@ search:
77
111
  fuzzy:
78
112
  enabled: true
79
113
  distance: 1
114
+ # Stored by SWIRL, not applied by the current engine version. Setting it
115
+ # changes no ranking; the module logs a warning at startup saying so.
80
116
  bm25:
81
117
  k1: 1.2
82
118
  b: 0.75
@@ -96,6 +132,22 @@ search:
96
132
 
97
133
  The whole block is `@visibility backend`; none of it reaches the browser.
98
134
 
135
+ ### Write the tuning keys in camelCase
136
+
137
+ SWIRL accepts both the nested camelCase names above and its own flat
138
+ snake_case names (`title_exact_boost`, `ngram_min`, `fuzzy_enabled` and the
139
+ rest) on the tuning endpoint. This package's `config.d.ts` declares only the
140
+ camelCase shape, so a repository that runs `backstage-cli config:check
141
+ --strict` - standard Backstage practice, and standard in CI - rejects the flat
142
+ names:
143
+
144
+ ```
145
+ Config must NOT have additional properties { additionalProperty=title_exact_boost } at /search/swirl/tuning
146
+ ```
147
+
148
+ Write camelCase. SWIRL folds those names onto its own before applying them, so
149
+ nothing is lost, and the engine logs the keys SWIRL accepted at startup.
150
+
99
151
  ## How it talks to SWIRL
100
152
 
101
153
  ### Indexing
@@ -135,7 +187,12 @@ On startup the module posts the `tuning` block to `POST /swirl/index/config/`, s
135
187
 
136
188
  ### Errors
137
189
 
138
- When SWIRL reports that a requested type has no live index, the engine throws an error named `MissingIndexError` rather than returning an empty page, so the cause is visible instead of looking like a query that simply matched nothing. SWIRL reports this either as a `404` whose body is `{"error": "missing_index", "types": [...]}`, or as a structured `{"type": "__MISSING_INDEX__", "types": [...]}` entry in the response `messages` array.
190
+ SWIRL reports a type with no live index either as a `404` whose body is `{"error": "missing_index", "types": [...]}`, or as a structured `{"type": "__MISSING_INDEX__", "types": [...]}` entry in the response `messages` array. The engine treats the two forms the same way, and what it does with them depends on how much of the query the report accounts for:
191
+
192
+ - **Every type the query asked for is missing.** The engine throws an error named `MissingIndexError`, so the cause is visible instead of looking like a query that simply matched nothing. A query that named no types is measured against every type the search backend has handed this engine an indexer for.
193
+ - **Only some of them are missing.** The engine logs at debug and answers normally: the results that did come back if there are any, an empty page if there are not.
194
+
195
+ The second case is the ordinary one on a real portal. A type can be legitimately and permanently empty - TechDocs on a portal with no mkdocs content is the everyday example, where the collator's zero-document abort correctly leaves it unindexed - and under `permission.enabled` the search router puts every registered type on every query. A search that matches nothing has to come back as "no results", not as an error page.
139
196
 
140
197
  ## Highlighting
141
198
 
@@ -102,10 +102,11 @@
102
102
  "b": {
103
103
  "type": "number"
104
104
  }
105
- }
105
+ },
106
+ "description": "Stored by SWIRL, not applied by the current engine version: the Tantivy lane does not expose BM25 parameters, and SWIRL says so in its answer to the startup tuning call, which the module logs as a warning. Kept in the schema so that configs which already set it keep validating; setting it has no effect on ranking."
106
107
  }
107
108
  },
108
- "description": "Relevance tuning, mirrored to SWIRL on startup"
109
+ "description": "Relevance tuning, mirrored to SWIRL on startup.\n\nThese are the nested camelCase names, and they are the only shape this schema accepts. SWIRL also takes its own flat snake_case names (title_exact_boost, ngram_min, fuzzy_enabled and the rest) over the same endpoint, but writing those here fails `backstage-cli config:check --strict`, which is standard practice in a Backstage repo. SWIRL folds the camelCase names onto its own, so what you write here is what SWIRL applies; write camelCase."
109
110
  },
110
111
  "highlight": {
111
112
  "type": "object",
@@ -12,6 +12,14 @@ class SwirlSearchEngine {
12
12
  client;
13
13
  preTag;
14
14
  postTag;
15
+ /**
16
+ * Every document type the search backend has handed this engine an indexer
17
+ * for. It is the yardstick for a missing index report on a query that named
18
+ * no types of its own. The federated type is deliberately left out: nothing
19
+ * is ever written to a SWIRL index under it, so it can never be part of a
20
+ * missing set.
21
+ */
22
+ indexedTypes = /* @__PURE__ */ new Set();
15
23
  constructor(options, deps) {
16
24
  this.options = options;
17
25
  this.logger = deps.logger;
@@ -102,6 +110,7 @@ class SwirlSearchEngine {
102
110
  if (type === types.SWIRL_FEDERATED_TYPE) {
103
111
  return new SwirlNoopIndexer.SwirlNoopIndexer({ type, logger: this.logger });
104
112
  }
113
+ this.indexedTypes.add(type);
105
114
  return new SwirlIndexer.SwirlIndexer({
106
115
  type,
107
116
  batchSize: this.options.indexerBatchSize,
@@ -115,7 +124,25 @@ class SwirlSearchEngine {
115
124
  });
116
125
  const token = await this.resolveToken(options);
117
126
  const result = concrete.cursor ? await this.fetchResultPage(concrete, concrete.cursor, token) : await this.fetchFirstPage(concrete, token);
118
- this.assertIndexPresent(result);
127
+ const missing = this.missingIndexTypes(result);
128
+ if (missing) {
129
+ if (this.coversEveryRequestedType(missing, concrete.indexTypes)) {
130
+ throw missingIndexError(missing);
131
+ }
132
+ const stillIndexed = this.requestedTypes(concrete.indexTypes).filter(
133
+ (type) => !missing.includes(type)
134
+ );
135
+ this.logger.debug(
136
+ `SWIRL has no live index for ${missing.join(
137
+ ", "
138
+ )}, but this query also covers ${stillIndexed.join(
139
+ ", "
140
+ )}, so the partial miss is reported as an empty page rather than an error.`
141
+ );
142
+ if (!result.ok) {
143
+ return { results: [], numberOfResults: 0 };
144
+ }
145
+ }
119
146
  if (!result.ok) {
120
147
  throw new Error(
121
148
  `SWIRL returned HTTP ${result.status} for the query ${JSON.stringify(
@@ -192,16 +219,17 @@ class SwirlSearchEngine {
192
219
  return this.client.mintToken(credentials);
193
220
  }
194
221
  /**
195
- * SWIRL reports a type with no live index either as a 404 with an
196
- * `missing_index` error body, or as a structured `__MISSING_INDEX__` entry
197
- * in the response messages. Either way the caller asked for something that
198
- * has never been indexed, which is worth saying out loud rather than
199
- * returning an empty result set or a bare 500.
222
+ * The document types SWIRL reported as having no live index, or undefined
223
+ * when it reported none.
224
+ *
225
+ * SWIRL says this either as a 404 with a `missing_index` error body, or as
226
+ * a structured `__MISSING_INDEX__` entry in the response messages. The two
227
+ * forms mean the same thing and are treated the same way.
200
228
  */
201
- assertIndexPresent(result) {
229
+ missingIndexTypes(result) {
202
230
  const body = result.body;
203
231
  if (result.status === 404 && body?.error === "missing_index") {
204
- throw missingIndexError(body?.types);
232
+ return typeList(body?.types);
205
233
  }
206
234
  for (const message of body?.messages ?? []) {
207
235
  if (typeof message !== "string" || !message.includes("__MISSING_INDEX__")) {
@@ -214,9 +242,40 @@ class SwirlSearchEngine {
214
242
  continue;
215
243
  }
216
244
  if (parsed?.type === "__MISSING_INDEX__") {
217
- throw missingIndexError(parsed.types);
245
+ return typeList(parsed.types);
218
246
  }
219
247
  }
248
+ return void 0;
249
+ }
250
+ /**
251
+ * The types this query is entitled to hear about. A query that named types
252
+ * is measured against those; one that named none is measured against every
253
+ * type this engine has been given an indexer for.
254
+ */
255
+ requestedTypes(indexTypes) {
256
+ return indexTypes?.length ? indexTypes : [...this.indexedTypes];
257
+ }
258
+ /**
259
+ * Whether a missing index report accounts for the whole query.
260
+ *
261
+ * A type that is legitimately and permanently empty - TechDocs on a portal
262
+ * with no mkdocs content is the everyday case - must not turn a search that
263
+ * simply matched nothing into an error, because under `permission.enabled`
264
+ * the search router puts every registered type on every query. So the loud
265
+ * `MissingIndexError` is kept for the case it was written for: nothing the
266
+ * caller asked for is indexed at all. A partial miss is a soft condition.
267
+ *
268
+ * With nothing to measure against - no types requested and no indexer ever
269
+ * handed out, or a report that names no types - the loud answer stands,
270
+ * because there is no evidence that anything else was searched.
271
+ */
272
+ coversEveryRequestedType(missing, indexTypes) {
273
+ const requested = this.requestedTypes(indexTypes);
274
+ if (!requested.length || !missing.length) {
275
+ return true;
276
+ }
277
+ const gone = new Set(missing);
278
+ return requested.every((type) => gone.has(type));
220
279
  }
221
280
  toIndexableResult(entry, rank) {
222
281
  const backstage = entry.payload?.backstage;
@@ -320,8 +379,11 @@ function describeTuningError(body) {
320
379
  const detail = typeof body?.detail === "string" ? body.detail : "";
321
380
  return detail ? ` ${detail}` : "";
322
381
  }
382
+ function typeList(types) {
383
+ return Array.isArray(types) ? types.map(String).filter(Boolean) : [];
384
+ }
323
385
  function missingIndexError(types$1) {
324
- const named = Array.isArray(types$1) && types$1.length ? types$1.join(", ") : void 0;
386
+ const named = types$1.length ? types$1.join(", ") : void 0;
325
387
  const error = new Error(
326
388
  named ? `SWIRL has no live index for the requested document type(s): ${named}. Wait for the collator to run, or check the SWIRL ingest logs.` : "SWIRL has no live index for one of the requested document types. Wait for the collator to run, or check the SWIRL ingest logs."
327
389
  );
@@ -1 +1 @@
1
- {"version":3,"file":"SwirlSearchEngine.cjs.js","sources":["../../src/engines/SwirlSearchEngine.ts"],"sourcesContent":["/*\n * Copyright 2026 SWIRL AI Connect\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { randomUUID } from 'node:crypto';\nimport { Writable } from 'node:stream';\nimport {\n AuthService,\n BackstageCredentials,\n LoggerService,\n} from '@backstage/backend-plugin-api';\nimport { Config } from '@backstage/config';\nimport {\n QueryRequestOptions,\n SearchEngine,\n} from '@backstage/plugin-search-backend-node';\nimport {\n IndexableResult,\n IndexableResultSet,\n SearchQuery,\n} from '@backstage/plugin-search-common';\nimport { SwirlClient, SwirlRequestResult } from './SwirlClient';\nimport { SwirlIndexer } from './SwirlIndexer';\nimport { SwirlNoopIndexer } from './SwirlNoopIndexer';\nimport {\n MISSING_INDEX_ERROR_NAME,\n SWIRL_FEDERATED_TYPE,\n SWIRL_HIGHLIGHT_END_MARKER,\n SWIRL_HIGHLIGHT_START_MARKER,\n SWIRL_INDEX_PROVIDER_TAG,\n SwirlEngineConfig,\n SwirlPageCursor,\n SwirlResponse,\n SwirlResult,\n swirlResultScore,\n} from './types';\n\n/**\n * The SWIRL query the engine is about to run, after translation.\n *\n * @public\n */\nexport type ConcreteSwirlQuery = {\n term: string;\n /** Backstage document types read from the SWIRL index. */\n indexTypes?: string[];\n /** Whether the federated providers take part in this query. */\n federated: boolean;\n /** Field filters, forwarded to SWIRL as JSON. */\n filters: Record<string, unknown>;\n /** Results per page. */\n pageSize: number;\n /** Decoded page cursor, absent on page 0. */\n cursor?: SwirlPageCursor;\n};\n\n/**\n * Options handed to a SWIRL query translator.\n *\n * @public\n */\nexport type SwirlQueryTranslatorOptions = {\n federatedEnabled: boolean;\n};\n\n/**\n * SWIRL specific query translator.\n *\n * @public\n */\nexport type SwirlQueryTranslator = (\n query: SearchQuery,\n options: SwirlQueryTranslatorOptions,\n) => ConcreteSwirlQuery;\n\n/**\n * Options to instantiate {@link SwirlSearchEngine}.\n *\n * @public\n */\nexport type SwirlSearchEngineOptions = {\n logger: LoggerService;\n auth: AuthService;\n /** Injectable for tests. Defaults to the global fetch. */\n fetchImpl?: typeof fetch;\n};\n\n/**\n * A Backstage search engine backed by SWIRL. Indexed Backstage documents are\n * served from the SWIRL index; results from connected sources arrive in the\n * same response under the `swirl-federated` type.\n *\n * @public\n */\nexport class SwirlSearchEngine implements SearchEngine {\n private readonly options: SwirlEngineConfig;\n private readonly logger: LoggerService;\n private readonly client: SwirlClient;\n private readonly preTag: string;\n private readonly postTag: string;\n\n private constructor(\n options: SwirlEngineConfig,\n deps: SwirlSearchEngineOptions,\n ) {\n this.options = options;\n this.logger = deps.logger;\n this.client = new SwirlClient({\n baseUrl: options.baseUrl,\n auth: deps.auth,\n audience: options.audience,\n timeoutMs: options.queryTimeoutMs,\n fetchImpl: deps.fetchImpl,\n });\n\n const tag = randomUUID();\n this.preTag = `<${tag}>`;\n this.postTag = `</${tag}>`;\n }\n\n static async fromConfig(\n config: Config,\n deps: SwirlSearchEngineOptions,\n ): Promise<SwirlSearchEngine> {\n const engine = new SwirlSearchEngine(readSwirlConfig(config), deps);\n await engine.pushTuning();\n return engine;\n }\n\n /**\n * Mirrors the app-config tuning block to SWIRL so that relevance is\n * configured in one place. A SWIRL that is not up yet, or an older SWIRL\n * that does not know the endpoint, must not stop the backend from booting.\n *\n * SWIRL answers with the effective tuning in its own flat form plus\n * `accepted_keys`, naming every key it took in the shape it was sent, and a\n * `bm25` notice when it stored BM25 parameters it cannot apply. Both are\n * logged, because a tuning block that is accepted by Backstage and then\n * quietly dropped by SWIRL is exactly the failure this call exists to make\n * visible. A 400 names the keys SWIRL did not recognise; that is a warning,\n * not a boot failure.\n */\n private async pushTuning(): Promise<void> {\n try {\n const token = await this.client.mintToken();\n const result = await this.client.request({\n url: this.client.url('/swirl/index/config/'),\n method: 'POST',\n token,\n body: this.options.tuning,\n });\n\n if (!result.ok) {\n const rejected = rejectedTuningKeys(result.body);\n const detail = rejected.length\n ? ` SWIRL did not recognise: ${rejected.join(', ')}.`\n : describeTuningError(result.body);\n this.logger.warn(\n `SWIRL rejected the relevance tuning block: HTTP ${result.status}.${detail} SWIRL keeps its current tuning.`,\n );\n return;\n }\n\n const body = (result.body ?? {}) as {\n accepted_keys?: unknown;\n bm25?: unknown;\n };\n const accepted = Array.isArray(body.accepted_keys)\n ? body.accepted_keys.map(String)\n : [];\n\n this.logger.info(\n accepted.length\n ? `Mirrored the relevance tuning block to SWIRL; SWIRL accepted: ${accepted.join(\n ', ',\n )}`\n : 'Mirrored the relevance tuning block to SWIRL; SWIRL reported no accepted tuning keys',\n );\n\n if (typeof body.bm25 === 'string' && body.bm25) {\n this.logger.warn(\n `SWIRL stored the bm25 tuning values but reports them \"${body.bm25}\", so search.swirl.tuning.bm25 has no effect on ranking.`,\n );\n }\n } catch (e) {\n this.logger.warn(\n `Could not send the relevance tuning block to SWIRL at ${this.options.baseUrl}: ${e}. SWIRL keeps its current tuning.`,\n );\n }\n }\n\n translator(\n query: SearchQuery,\n options: SwirlQueryTranslatorOptions,\n ): ConcreteSwirlQuery {\n const pageSize = query.pageLimit || 25;\n const cursor = decodePageCursor(query.pageCursor);\n\n // The federated lane runs when the caller did not narrow by type, or\n // asked for the federated type by name. Under permissions the router\n // always passes the full list of registered types, which is why the\n // federated type has to be registered at all.\n const federated =\n options.federatedEnabled &&\n (query.types === undefined || query.types.includes(SWIRL_FEDERATED_TYPE));\n\n const indexTypes = query.types?.filter(\n type => type !== SWIRL_FEDERATED_TYPE,\n );\n\n return {\n term: query.term ?? '',\n indexTypes,\n federated,\n filters: (query.filters as Record<string, unknown>) ?? {},\n pageSize,\n cursor,\n };\n }\n\n setTranslator(translator: SwirlQueryTranslator) {\n this.translator = translator;\n }\n\n async getIndexer(type: string): Promise<Writable> {\n if (type === SWIRL_FEDERATED_TYPE) {\n return new SwirlNoopIndexer({ type, logger: this.logger });\n }\n\n return new SwirlIndexer({\n type,\n batchSize: this.options.indexerBatchSize,\n client: this.client,\n logger: this.logger,\n });\n }\n\n async query(\n query: SearchQuery,\n options?: QueryRequestOptions,\n ): Promise<IndexableResultSet> {\n const concrete = this.translator(query, {\n federatedEnabled: this.options.federated.enabled,\n });\n\n const token = await this.resolveToken(options);\n const result = concrete.cursor\n ? await this.fetchResultPage(concrete, concrete.cursor, token)\n : await this.fetchFirstPage(concrete, token);\n\n this.assertIndexPresent(result);\n\n if (!result.ok) {\n throw new Error(\n `SWIRL returned HTTP ${result.status} for the query ${JSON.stringify(\n concrete.term,\n )}`,\n );\n }\n\n const body = (result.body ?? {}) as SwirlResponse;\n const page = concrete.cursor?.p ?? 0;\n const searchId = concrete.cursor?.s ?? body.info?.search?.id;\n const swirlResults = body.results ?? [];\n\n const results = swirlResults.map((entry, index) =>\n this.toIndexableResult(entry, page * concrete.pageSize + index + 1),\n );\n\n const hasNextPage =\n searchId !== undefined && swirlResults.length >= concrete.pageSize;\n\n return {\n results,\n numberOfResults:\n body.info?.results?.found_total ??\n body.info?.results?.retrieved_total ??\n undefined,\n nextPageCursor: hasNextPage\n ? encodePageCursor({ s: searchId!, p: page + 1 })\n : undefined,\n previousPageCursor:\n page > 0 && searchId !== undefined\n ? encodePageCursor({ s: searchId, p: page - 1 })\n : undefined,\n };\n }\n\n /**\n * Page 0 federates: SWIRL runs the query across the Backstage index and,\n * when the federated lane is active, the connected providers too.\n */\n private async fetchFirstPage(\n concrete: ConcreteSwirlQuery,\n token: string,\n ): Promise<SwirlRequestResult> {\n const providers = [SWIRL_INDEX_PROVIDER_TAG];\n if (concrete.federated) {\n providers.push(...this.options.federated.providerTags);\n }\n\n return this.client.request({\n url: this.client.url('/swirl/search/', {\n qs: concrete.term,\n providers: providers.join(','),\n backstage_types: concrete.indexTypes?.join(',') ?? '',\n backstage_filters: JSON.stringify(concrete.filters),\n backstage_timeout_ms: concrete.federated\n ? this.options.federated.timeoutMs\n : undefined,\n results_requested: concrete.pageSize,\n rag: 'false',\n }),\n method: 'GET',\n token,\n timeoutMs: this.options.queryTimeoutMs,\n });\n }\n\n /**\n * Page N is a database read in SWIRL, not a second federation. That keeps\n * the paging loop in Backstage's AuthorizedSearchEngine cheap.\n */\n private async fetchResultPage(\n concrete: ConcreteSwirlQuery,\n cursor: SwirlPageCursor,\n token: string,\n ): Promise<SwirlRequestResult> {\n return this.client.request({\n url: this.client.url('/swirl/results/', {\n search_id: String(cursor.s),\n page: cursor.p + 1,\n results_requested: concrete.pageSize,\n }),\n method: 'GET',\n token,\n timeoutMs: this.options.queryTimeoutMs,\n });\n }\n\n /**\n * The search router hands the engine a plugin token minted per request,\n * carrying the caller's identity in its `obo` claim; that token is what\n * SWIRL verifies. Programmatic callers that reach the engine directly get\n * a freshly minted one instead.\n */\n private async resolveToken(options?: QueryRequestOptions): Promise<string> {\n if (options && 'token' in options && options.token) {\n return options.token;\n }\n\n const credentials =\n options && 'credentials' in options\n ? (options.credentials as BackstageCredentials)\n : undefined;\n\n return this.client.mintToken(credentials);\n }\n\n /**\n * SWIRL reports a type with no live index either as a 404 with an\n * `missing_index` error body, or as a structured `__MISSING_INDEX__` entry\n * in the response messages. Either way the caller asked for something that\n * has never been indexed, which is worth saying out loud rather than\n * returning an empty result set or a bare 500.\n */\n private assertIndexPresent(result: SwirlRequestResult): void {\n const body = result.body;\n\n if (result.status === 404 && body?.error === 'missing_index') {\n throw missingIndexError(body?.types);\n }\n\n for (const message of body?.messages ?? []) {\n if (\n typeof message !== 'string' ||\n !message.includes('__MISSING_INDEX__')\n ) {\n continue;\n }\n\n // SWIRL banner text and other free form strings share this array, so a\n // message that is not JSON is simply not one of ours.\n let parsed: any;\n try {\n parsed = JSON.parse(message);\n } catch {\n continue;\n }\n\n if (parsed?.type === '__MISSING_INDEX__') {\n throw missingIndexError(parsed.types);\n }\n }\n }\n\n private toIndexableResult(entry: SwirlResult, rank: number): IndexableResult {\n const backstage = entry.payload?.backstage;\n const indexed =\n backstage?.type !== undefined && backstage?.document !== undefined;\n\n return {\n type: indexed ? backstage!.type : SWIRL_FEDERATED_TYPE,\n document: indexed\n ? (backstage!.document as any)\n : {\n // Stripped defensively. SWIRL's relevancy processor writes the\n // marked up text back over `title` and `body`, which is what its\n // own UI renders; a Backstage renderer shows document text as\n // plain text, so the markers arrived on screen as literal\n // `<em>`. Current SWIRL keeps these fields clean, older ones do\n // not, and the engine has to be safe against both.\n title: this.stripMarkers(entry.title),\n text: this.stripMarkers(entry.body),\n location: entry.url ?? '',\n source: entry.searchprovider ?? '',\n // Federated results are not in any Backstage index, so SWIRL's\n // score is the only ranking signal a renderer can show. Indexed\n // documents are handed back exactly as Backstage collated them.\n score: swirlResultScore(entry),\n },\n rank,\n highlight: this.toHighlight(entry),\n };\n }\n\n private toHighlight(entry: SwirlResult) {\n if (!this.options.highlight.enabled) {\n return { preTag: this.preTag, postTag: this.postTag, fields: {} };\n }\n\n // Only the hit highlight lists. A marker sitting in the plain title or\n // body is not a hit - SWIRL keeps its hits in these two lists - and using\n // the plain field here would let a document forge its own highlight.\n const fields: Record<string, string> = {};\n const title = this.rewriteHighlight(entry.title_hit_highlights);\n const text = this.rewriteHighlight(entry.body_hit_highlights);\n\n if (title) {\n fields.title = title;\n }\n if (text) {\n fields.text = text;\n }\n\n return { preTag: this.preTag, postTag: this.postTag, fields };\n }\n\n /** Removes the configured marker pair, leaving the text it wrapped. */\n private stripMarkers(value: string | undefined): string {\n if (!value) {\n return '';\n }\n const { startMarker, endMarker } = this.options.highlight;\n return value.split(startMarker).join('').split(endMarker).join('');\n }\n\n /**\n * SWIRL wraps hits in a configurable marker pair, `<em>` and `</em>` out of\n * the box. Backstage expects the engine's own per-instance tags instead, so\n * that a document body containing the marker cannot forge a highlight.\n *\n * The `maxChars` budget counts visible characters, not tags, and the walk\n * never emits an unbalanced tag: a snippet cut short inside a hit closes it.\n */\n private rewriteHighlight(highlights?: string[]): string | undefined {\n const raw = (highlights ?? []).find(value => Boolean(value));\n if (!raw) {\n return undefined;\n }\n\n const { startMarker, endMarker, maxChars } = this.options.highlight;\n const pattern = new RegExp(\n `${escapeRegExp(startMarker)}([\\\\s\\\\S]*?)${escapeRegExp(endMarker)}`,\n 'g',\n );\n\n let out = '';\n let budget = maxChars;\n let cursor = 0;\n\n const take = (value: string, hit: boolean) => {\n if (budget <= 0 || !value) {\n return;\n }\n const kept = value.slice(0, budget);\n budget -= kept.length;\n out += hit ? `${this.preTag}${kept}${this.postTag}` : kept;\n };\n\n for (const match of raw.matchAll(pattern)) {\n const at = match.index ?? 0;\n take(raw.slice(cursor, at), false);\n take(match[1], true);\n cursor = at + match[0].length;\n }\n take(raw.slice(cursor), false);\n\n return out;\n }\n}\n\nfunction escapeRegExp(value: string): string {\n return value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\n/**\n * The keys SWIRL named in a 400 from POST /swirl/index/config/. SWIRL answers\n * an unrecognised key with a detail line that starts\n * \"unknown tuning key(s): a, b.\" rather than dropping it in silence.\n */\nfunction rejectedTuningKeys(body: any): string[] {\n const detail = typeof body?.detail === 'string' ? body.detail : '';\n const match = detail.match(/unknown tuning key\\(s\\):\\s*(.*)/i);\n if (!match) {\n return [];\n }\n // The detail continues \"... Known keys are ...\", and a nested key such as\n // fuzzy.bogus has a dot in it, so cut on that phrase rather than on a dot.\n return match[1]\n .split(/\\.\\s*Known keys/i)[0]\n .replace(/\\.\\s*$/, '')\n .split(',')\n .map((key: string) => key.trim())\n .filter(Boolean);\n}\n\n/** Whatever SWIRL said about a tuning block it would not take. */\nfunction describeTuningError(body: any): string {\n const detail = typeof body?.detail === 'string' ? body.detail : '';\n return detail ? ` ${detail}` : '';\n}\n\nfunction missingIndexError(types?: unknown): Error {\n const named =\n Array.isArray(types) && types.length ? types.join(', ') : undefined;\n const error = new Error(\n named\n ? `SWIRL has no live index for the requested document type(s): ${named}. Wait for the collator to run, or check the SWIRL ingest logs.`\n : 'SWIRL has no live index for one of the requested document types. Wait for the collator to run, or check the SWIRL ingest logs.',\n );\n error.name = MISSING_INDEX_ERROR_NAME;\n return error;\n}\n\n/** @public */\nexport function decodePageCursor(\n pageCursor?: string,\n): SwirlPageCursor | undefined {\n if (!pageCursor) {\n return undefined;\n }\n\n const decoded = JSON.parse(\n Buffer.from(pageCursor, 'base64').toString('utf-8'),\n );\n if (\n decoded === null ||\n typeof decoded !== 'object' ||\n decoded.s === undefined ||\n typeof decoded.p !== 'number' ||\n decoded.p < 0\n ) {\n throw new Error('Invalid page cursor');\n }\n\n return { s: decoded.s, p: decoded.p };\n}\n\n/** @public */\nexport function encodePageCursor(cursor: SwirlPageCursor): string {\n return Buffer.from(JSON.stringify(cursor), 'utf-8').toString('base64');\n}\n\n/** @public */\nexport function readSwirlConfig(config: Config): SwirlEngineConfig {\n const swirl = config.getConfig('search.swirl');\n const federated = swirl.getOptionalConfig('federated');\n const highlight = swirl.getOptionalConfig('highlight');\n const tuning = swirl.getOptionalConfig('tuning');\n\n return {\n baseUrl: swirl.getString('baseUrl'),\n audience: swirl.getOptionalString('audience') ?? 'search',\n indexerBatchSize: swirl.getOptionalNumber('indexerBatchSize') ?? 500,\n queryTimeoutMs: swirl.getOptionalNumber('queryTimeoutMs') ?? 8000,\n federated: {\n enabled: federated?.getOptionalBoolean('enabled') ?? true,\n providerTags: federated?.getOptionalStringArray('providerTags') ?? [\n 'backstage',\n ],\n timeoutMs: federated?.getOptionalNumber('timeoutMs') ?? 5000,\n },\n tuning: (tuning?.get() as SwirlEngineConfig['tuning']) ?? {},\n highlight: {\n enabled: highlight?.getOptionalBoolean('enabled') ?? true,\n maxChars: highlight?.getOptionalNumber('maxChars') ?? 200,\n startMarker:\n highlight?.getOptionalString('startMarker') ??\n SWIRL_HIGHLIGHT_START_MARKER,\n endMarker:\n highlight?.getOptionalString('endMarker') ?? SWIRL_HIGHLIGHT_END_MARKER,\n },\n };\n}\n"],"names":["SwirlClient","randomUUID","SWIRL_FEDERATED_TYPE","SwirlNoopIndexer","SwirlIndexer","SWIRL_INDEX_PROVIDER_TAG","swirlResultScore","types","MISSING_INDEX_ERROR_NAME","SWIRL_HIGHLIGHT_START_MARKER","SWIRL_HIGHLIGHT_END_MARKER"],"mappings":"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"}
1
+ {"version":3,"file":"SwirlSearchEngine.cjs.js","sources":["../../src/engines/SwirlSearchEngine.ts"],"sourcesContent":["/*\n * Copyright 2026 SWIRL AI Connect\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { randomUUID } from 'node:crypto';\nimport { Writable } from 'node:stream';\nimport {\n AuthService,\n BackstageCredentials,\n LoggerService,\n} from '@backstage/backend-plugin-api';\nimport { Config } from '@backstage/config';\nimport {\n QueryRequestOptions,\n SearchEngine,\n} from '@backstage/plugin-search-backend-node';\nimport {\n IndexableResult,\n IndexableResultSet,\n SearchQuery,\n} from '@backstage/plugin-search-common';\nimport { SwirlClient, SwirlRequestResult } from './SwirlClient';\nimport { SwirlIndexer } from './SwirlIndexer';\nimport { SwirlNoopIndexer } from './SwirlNoopIndexer';\nimport {\n MISSING_INDEX_ERROR_NAME,\n SWIRL_FEDERATED_TYPE,\n SWIRL_HIGHLIGHT_END_MARKER,\n SWIRL_HIGHLIGHT_START_MARKER,\n SWIRL_INDEX_PROVIDER_TAG,\n SwirlEngineConfig,\n SwirlPageCursor,\n SwirlResponse,\n SwirlResult,\n swirlResultScore,\n} from './types';\n\n/**\n * The SWIRL query the engine is about to run, after translation.\n *\n * @public\n */\nexport type ConcreteSwirlQuery = {\n term: string;\n /** Backstage document types read from the SWIRL index. */\n indexTypes?: string[];\n /** Whether the federated providers take part in this query. */\n federated: boolean;\n /** Field filters, forwarded to SWIRL as JSON. */\n filters: Record<string, unknown>;\n /** Results per page. */\n pageSize: number;\n /** Decoded page cursor, absent on page 0. */\n cursor?: SwirlPageCursor;\n};\n\n/**\n * Options handed to a SWIRL query translator.\n *\n * @public\n */\nexport type SwirlQueryTranslatorOptions = {\n federatedEnabled: boolean;\n};\n\n/**\n * SWIRL specific query translator.\n *\n * @public\n */\nexport type SwirlQueryTranslator = (\n query: SearchQuery,\n options: SwirlQueryTranslatorOptions,\n) => ConcreteSwirlQuery;\n\n/**\n * Options to instantiate {@link SwirlSearchEngine}.\n *\n * @public\n */\nexport type SwirlSearchEngineOptions = {\n logger: LoggerService;\n auth: AuthService;\n /** Injectable for tests. Defaults to the global fetch. */\n fetchImpl?: typeof fetch;\n};\n\n/**\n * A Backstage search engine backed by SWIRL. Indexed Backstage documents are\n * served from the SWIRL index; results from connected sources arrive in the\n * same response under the `swirl-federated` type.\n *\n * @public\n */\nexport class SwirlSearchEngine implements SearchEngine {\n private readonly options: SwirlEngineConfig;\n private readonly logger: LoggerService;\n private readonly client: SwirlClient;\n private readonly preTag: string;\n private readonly postTag: string;\n\n /**\n * Every document type the search backend has handed this engine an indexer\n * for. It is the yardstick for a missing index report on a query that named\n * no types of its own. The federated type is deliberately left out: nothing\n * is ever written to a SWIRL index under it, so it can never be part of a\n * missing set.\n */\n private readonly indexedTypes = new Set<string>();\n\n private constructor(\n options: SwirlEngineConfig,\n deps: SwirlSearchEngineOptions,\n ) {\n this.options = options;\n this.logger = deps.logger;\n this.client = new SwirlClient({\n baseUrl: options.baseUrl,\n auth: deps.auth,\n audience: options.audience,\n timeoutMs: options.queryTimeoutMs,\n fetchImpl: deps.fetchImpl,\n });\n\n const tag = randomUUID();\n this.preTag = `<${tag}>`;\n this.postTag = `</${tag}>`;\n }\n\n static async fromConfig(\n config: Config,\n deps: SwirlSearchEngineOptions,\n ): Promise<SwirlSearchEngine> {\n const engine = new SwirlSearchEngine(readSwirlConfig(config), deps);\n await engine.pushTuning();\n return engine;\n }\n\n /**\n * Mirrors the app-config tuning block to SWIRL so that relevance is\n * configured in one place. A SWIRL that is not up yet, or an older SWIRL\n * that does not know the endpoint, must not stop the backend from booting.\n *\n * SWIRL answers with the effective tuning in its own flat form plus\n * `accepted_keys`, naming every key it took in the shape it was sent, and a\n * `bm25` notice when it stored BM25 parameters it cannot apply. Both are\n * logged, because a tuning block that is accepted by Backstage and then\n * quietly dropped by SWIRL is exactly the failure this call exists to make\n * visible. A 400 names the keys SWIRL did not recognise; that is a warning,\n * not a boot failure.\n */\n private async pushTuning(): Promise<void> {\n try {\n const token = await this.client.mintToken();\n const result = await this.client.request({\n url: this.client.url('/swirl/index/config/'),\n method: 'POST',\n token,\n body: this.options.tuning,\n });\n\n if (!result.ok) {\n const rejected = rejectedTuningKeys(result.body);\n const detail = rejected.length\n ? ` SWIRL did not recognise: ${rejected.join(', ')}.`\n : describeTuningError(result.body);\n this.logger.warn(\n `SWIRL rejected the relevance tuning block: HTTP ${result.status}.${detail} SWIRL keeps its current tuning.`,\n );\n return;\n }\n\n const body = (result.body ?? {}) as {\n accepted_keys?: unknown;\n bm25?: unknown;\n };\n const accepted = Array.isArray(body.accepted_keys)\n ? body.accepted_keys.map(String)\n : [];\n\n this.logger.info(\n accepted.length\n ? `Mirrored the relevance tuning block to SWIRL; SWIRL accepted: ${accepted.join(\n ', ',\n )}`\n : 'Mirrored the relevance tuning block to SWIRL; SWIRL reported no accepted tuning keys',\n );\n\n if (typeof body.bm25 === 'string' && body.bm25) {\n this.logger.warn(\n `SWIRL stored the bm25 tuning values but reports them \"${body.bm25}\", so search.swirl.tuning.bm25 has no effect on ranking.`,\n );\n }\n } catch (e) {\n this.logger.warn(\n `Could not send the relevance tuning block to SWIRL at ${this.options.baseUrl}: ${e}. SWIRL keeps its current tuning.`,\n );\n }\n }\n\n translator(\n query: SearchQuery,\n options: SwirlQueryTranslatorOptions,\n ): ConcreteSwirlQuery {\n const pageSize = query.pageLimit || 25;\n const cursor = decodePageCursor(query.pageCursor);\n\n // The federated lane runs when the caller did not narrow by type, or\n // asked for the federated type by name. Under permissions the router\n // always passes the full list of registered types, which is why the\n // federated type has to be registered at all.\n const federated =\n options.federatedEnabled &&\n (query.types === undefined || query.types.includes(SWIRL_FEDERATED_TYPE));\n\n const indexTypes = query.types?.filter(\n type => type !== SWIRL_FEDERATED_TYPE,\n );\n\n return {\n term: query.term ?? '',\n indexTypes,\n federated,\n filters: (query.filters as Record<string, unknown>) ?? {},\n pageSize,\n cursor,\n };\n }\n\n setTranslator(translator: SwirlQueryTranslator) {\n this.translator = translator;\n }\n\n async getIndexer(type: string): Promise<Writable> {\n if (type === SWIRL_FEDERATED_TYPE) {\n return new SwirlNoopIndexer({ type, logger: this.logger });\n }\n\n this.indexedTypes.add(type);\n\n return new SwirlIndexer({\n type,\n batchSize: this.options.indexerBatchSize,\n client: this.client,\n logger: this.logger,\n });\n }\n\n async query(\n query: SearchQuery,\n options?: QueryRequestOptions,\n ): Promise<IndexableResultSet> {\n const concrete = this.translator(query, {\n federatedEnabled: this.options.federated.enabled,\n });\n\n const token = await this.resolveToken(options);\n const result = concrete.cursor\n ? await this.fetchResultPage(concrete, concrete.cursor, token)\n : await this.fetchFirstPage(concrete, token);\n\n const missing = this.missingIndexTypes(result);\n if (missing) {\n if (this.coversEveryRequestedType(missing, concrete.indexTypes)) {\n throw missingIndexError(missing);\n }\n\n const stillIndexed = this.requestedTypes(concrete.indexTypes).filter(\n type => !missing.includes(type),\n );\n this.logger.debug(\n `SWIRL has no live index for ${missing.join(\n ', ',\n )}, but this query also covers ${stillIndexed.join(\n ', ',\n )}, so the partial miss is reported as an empty page rather than an error.`,\n );\n\n // The hard form is a 404 and carries no envelope to read. The soft form\n // rides along with an ordinary 200, so whatever SWIRL did find falls\n // through to the normal path below, empty result list included.\n if (!result.ok) {\n return { results: [], numberOfResults: 0 };\n }\n }\n\n if (!result.ok) {\n throw new Error(\n `SWIRL returned HTTP ${result.status} for the query ${JSON.stringify(\n concrete.term,\n )}`,\n );\n }\n\n const body = (result.body ?? {}) as SwirlResponse;\n const page = concrete.cursor?.p ?? 0;\n const searchId = concrete.cursor?.s ?? body.info?.search?.id;\n const swirlResults = body.results ?? [];\n\n const results = swirlResults.map((entry, index) =>\n this.toIndexableResult(entry, page * concrete.pageSize + index + 1),\n );\n\n const hasNextPage =\n searchId !== undefined && swirlResults.length >= concrete.pageSize;\n\n return {\n results,\n numberOfResults:\n body.info?.results?.found_total ??\n body.info?.results?.retrieved_total ??\n undefined,\n nextPageCursor: hasNextPage\n ? encodePageCursor({ s: searchId!, p: page + 1 })\n : undefined,\n previousPageCursor:\n page > 0 && searchId !== undefined\n ? encodePageCursor({ s: searchId, p: page - 1 })\n : undefined,\n };\n }\n\n /**\n * Page 0 federates: SWIRL runs the query across the Backstage index and,\n * when the federated lane is active, the connected providers too.\n */\n private async fetchFirstPage(\n concrete: ConcreteSwirlQuery,\n token: string,\n ): Promise<SwirlRequestResult> {\n const providers = [SWIRL_INDEX_PROVIDER_TAG];\n if (concrete.federated) {\n providers.push(...this.options.federated.providerTags);\n }\n\n return this.client.request({\n url: this.client.url('/swirl/search/', {\n qs: concrete.term,\n providers: providers.join(','),\n backstage_types: concrete.indexTypes?.join(',') ?? '',\n backstage_filters: JSON.stringify(concrete.filters),\n backstage_timeout_ms: concrete.federated\n ? this.options.federated.timeoutMs\n : undefined,\n results_requested: concrete.pageSize,\n rag: 'false',\n }),\n method: 'GET',\n token,\n timeoutMs: this.options.queryTimeoutMs,\n });\n }\n\n /**\n * Page N is a database read in SWIRL, not a second federation. That keeps\n * the paging loop in Backstage's AuthorizedSearchEngine cheap.\n */\n private async fetchResultPage(\n concrete: ConcreteSwirlQuery,\n cursor: SwirlPageCursor,\n token: string,\n ): Promise<SwirlRequestResult> {\n return this.client.request({\n url: this.client.url('/swirl/results/', {\n search_id: String(cursor.s),\n page: cursor.p + 1,\n results_requested: concrete.pageSize,\n }),\n method: 'GET',\n token,\n timeoutMs: this.options.queryTimeoutMs,\n });\n }\n\n /**\n * The search router hands the engine a plugin token minted per request,\n * carrying the caller's identity in its `obo` claim; that token is what\n * SWIRL verifies. Programmatic callers that reach the engine directly get\n * a freshly minted one instead.\n */\n private async resolveToken(options?: QueryRequestOptions): Promise<string> {\n if (options && 'token' in options && options.token) {\n return options.token;\n }\n\n const credentials =\n options && 'credentials' in options\n ? (options.credentials as BackstageCredentials)\n : undefined;\n\n return this.client.mintToken(credentials);\n }\n\n /**\n * The document types SWIRL reported as having no live index, or undefined\n * when it reported none.\n *\n * SWIRL says this either as a 404 with a `missing_index` error body, or as\n * a structured `__MISSING_INDEX__` entry in the response messages. The two\n * forms mean the same thing and are treated the same way.\n */\n private missingIndexTypes(result: SwirlRequestResult): string[] | undefined {\n const body = result.body;\n\n if (result.status === 404 && body?.error === 'missing_index') {\n return typeList(body?.types);\n }\n\n for (const message of body?.messages ?? []) {\n if (\n typeof message !== 'string' ||\n !message.includes('__MISSING_INDEX__')\n ) {\n continue;\n }\n\n // SWIRL banner text and other free form strings share this array, so a\n // message that is not JSON is simply not one of ours.\n let parsed: any;\n try {\n parsed = JSON.parse(message);\n } catch {\n continue;\n }\n\n if (parsed?.type === '__MISSING_INDEX__') {\n return typeList(parsed.types);\n }\n }\n\n return undefined;\n }\n\n /**\n * The types this query is entitled to hear about. A query that named types\n * is measured against those; one that named none is measured against every\n * type this engine has been given an indexer for.\n */\n private requestedTypes(indexTypes?: string[]): string[] {\n return indexTypes?.length ? indexTypes : [...this.indexedTypes];\n }\n\n /**\n * Whether a missing index report accounts for the whole query.\n *\n * A type that is legitimately and permanently empty - TechDocs on a portal\n * with no mkdocs content is the everyday case - must not turn a search that\n * simply matched nothing into an error, because under `permission.enabled`\n * the search router puts every registered type on every query. So the loud\n * `MissingIndexError` is kept for the case it was written for: nothing the\n * caller asked for is indexed at all. A partial miss is a soft condition.\n *\n * With nothing to measure against - no types requested and no indexer ever\n * handed out, or a report that names no types - the loud answer stands,\n * because there is no evidence that anything else was searched.\n */\n private coversEveryRequestedType(\n missing: string[],\n indexTypes?: string[],\n ): boolean {\n const requested = this.requestedTypes(indexTypes);\n if (!requested.length || !missing.length) {\n return true;\n }\n\n const gone = new Set(missing);\n return requested.every(type => gone.has(type));\n }\n\n private toIndexableResult(entry: SwirlResult, rank: number): IndexableResult {\n const backstage = entry.payload?.backstage;\n const indexed =\n backstage?.type !== undefined && backstage?.document !== undefined;\n\n return {\n type: indexed ? backstage!.type : SWIRL_FEDERATED_TYPE,\n document: indexed\n ? (backstage!.document as any)\n : {\n // Stripped defensively. SWIRL's relevancy processor writes the\n // marked up text back over `title` and `body`, which is what its\n // own UI renders; a Backstage renderer shows document text as\n // plain text, so the markers arrived on screen as literal\n // `<em>`. Current SWIRL keeps these fields clean, older ones do\n // not, and the engine has to be safe against both.\n title: this.stripMarkers(entry.title),\n text: this.stripMarkers(entry.body),\n location: entry.url ?? '',\n source: entry.searchprovider ?? '',\n // Federated results are not in any Backstage index, so SWIRL's\n // score is the only ranking signal a renderer can show. Indexed\n // documents are handed back exactly as Backstage collated them.\n score: swirlResultScore(entry),\n },\n rank,\n highlight: this.toHighlight(entry),\n };\n }\n\n private toHighlight(entry: SwirlResult) {\n if (!this.options.highlight.enabled) {\n return { preTag: this.preTag, postTag: this.postTag, fields: {} };\n }\n\n // Only the hit highlight lists. A marker sitting in the plain title or\n // body is not a hit - SWIRL keeps its hits in these two lists - and using\n // the plain field here would let a document forge its own highlight.\n const fields: Record<string, string> = {};\n const title = this.rewriteHighlight(entry.title_hit_highlights);\n const text = this.rewriteHighlight(entry.body_hit_highlights);\n\n if (title) {\n fields.title = title;\n }\n if (text) {\n fields.text = text;\n }\n\n return { preTag: this.preTag, postTag: this.postTag, fields };\n }\n\n /** Removes the configured marker pair, leaving the text it wrapped. */\n private stripMarkers(value: string | undefined): string {\n if (!value) {\n return '';\n }\n const { startMarker, endMarker } = this.options.highlight;\n return value.split(startMarker).join('').split(endMarker).join('');\n }\n\n /**\n * SWIRL wraps hits in a configurable marker pair, `<em>` and `</em>` out of\n * the box. Backstage expects the engine's own per-instance tags instead, so\n * that a document body containing the marker cannot forge a highlight.\n *\n * The `maxChars` budget counts visible characters, not tags, and the walk\n * never emits an unbalanced tag: a snippet cut short inside a hit closes it.\n */\n private rewriteHighlight(highlights?: string[]): string | undefined {\n const raw = (highlights ?? []).find(value => Boolean(value));\n if (!raw) {\n return undefined;\n }\n\n const { startMarker, endMarker, maxChars } = this.options.highlight;\n const pattern = new RegExp(\n `${escapeRegExp(startMarker)}([\\\\s\\\\S]*?)${escapeRegExp(endMarker)}`,\n 'g',\n );\n\n let out = '';\n let budget = maxChars;\n let cursor = 0;\n\n const take = (value: string, hit: boolean) => {\n if (budget <= 0 || !value) {\n return;\n }\n const kept = value.slice(0, budget);\n budget -= kept.length;\n out += hit ? `${this.preTag}${kept}${this.postTag}` : kept;\n };\n\n for (const match of raw.matchAll(pattern)) {\n const at = match.index ?? 0;\n take(raw.slice(cursor, at), false);\n take(match[1], true);\n cursor = at + match[0].length;\n }\n take(raw.slice(cursor), false);\n\n return out;\n }\n}\n\nfunction escapeRegExp(value: string): string {\n return value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\n/**\n * The keys SWIRL named in a 400 from POST /swirl/index/config/. SWIRL answers\n * an unrecognised key with a detail line that starts\n * \"unknown tuning key(s): a, b.\" rather than dropping it in silence.\n */\nfunction rejectedTuningKeys(body: any): string[] {\n const detail = typeof body?.detail === 'string' ? body.detail : '';\n const match = detail.match(/unknown tuning key\\(s\\):\\s*(.*)/i);\n if (!match) {\n return [];\n }\n // The detail continues \"... Known keys are ...\", and a nested key such as\n // fuzzy.bogus has a dot in it, so cut on that phrase rather than on a dot.\n return match[1]\n .split(/\\.\\s*Known keys/i)[0]\n .replace(/\\.\\s*$/, '')\n .split(',')\n .map((key: string) => key.trim())\n .filter(Boolean);\n}\n\n/** Whatever SWIRL said about a tuning block it would not take. */\nfunction describeTuningError(body: any): string {\n const detail = typeof body?.detail === 'string' ? body.detail : '';\n return detail ? ` ${detail}` : '';\n}\n\n/** The `types` array off a SWIRL missing index report, normalised. */\nfunction typeList(types: unknown): string[] {\n return Array.isArray(types) ? types.map(String).filter(Boolean) : [];\n}\n\nfunction missingIndexError(types: string[]): Error {\n const named = types.length ? types.join(', ') : undefined;\n const error = new Error(\n named\n ? `SWIRL has no live index for the requested document type(s): ${named}. Wait for the collator to run, or check the SWIRL ingest logs.`\n : 'SWIRL has no live index for one of the requested document types. Wait for the collator to run, or check the SWIRL ingest logs.',\n );\n error.name = MISSING_INDEX_ERROR_NAME;\n return error;\n}\n\n/** @public */\nexport function decodePageCursor(\n pageCursor?: string,\n): SwirlPageCursor | undefined {\n if (!pageCursor) {\n return undefined;\n }\n\n const decoded = JSON.parse(\n Buffer.from(pageCursor, 'base64').toString('utf-8'),\n );\n if (\n decoded === null ||\n typeof decoded !== 'object' ||\n decoded.s === undefined ||\n typeof decoded.p !== 'number' ||\n decoded.p < 0\n ) {\n throw new Error('Invalid page cursor');\n }\n\n return { s: decoded.s, p: decoded.p };\n}\n\n/** @public */\nexport function encodePageCursor(cursor: SwirlPageCursor): string {\n return Buffer.from(JSON.stringify(cursor), 'utf-8').toString('base64');\n}\n\n/** @public */\nexport function readSwirlConfig(config: Config): SwirlEngineConfig {\n const swirl = config.getConfig('search.swirl');\n const federated = swirl.getOptionalConfig('federated');\n const highlight = swirl.getOptionalConfig('highlight');\n const tuning = swirl.getOptionalConfig('tuning');\n\n return {\n baseUrl: swirl.getString('baseUrl'),\n audience: swirl.getOptionalString('audience') ?? 'search',\n indexerBatchSize: swirl.getOptionalNumber('indexerBatchSize') ?? 500,\n queryTimeoutMs: swirl.getOptionalNumber('queryTimeoutMs') ?? 8000,\n federated: {\n enabled: federated?.getOptionalBoolean('enabled') ?? true,\n providerTags: federated?.getOptionalStringArray('providerTags') ?? [\n 'backstage',\n ],\n timeoutMs: federated?.getOptionalNumber('timeoutMs') ?? 5000,\n },\n tuning: (tuning?.get() as SwirlEngineConfig['tuning']) ?? {},\n highlight: {\n enabled: highlight?.getOptionalBoolean('enabled') ?? true,\n maxChars: highlight?.getOptionalNumber('maxChars') ?? 200,\n startMarker:\n highlight?.getOptionalString('startMarker') ??\n SWIRL_HIGHLIGHT_START_MARKER,\n endMarker:\n highlight?.getOptionalString('endMarker') ?? SWIRL_HIGHLIGHT_END_MARKER,\n },\n };\n}\n"],"names":["SwirlClient","randomUUID","SWIRL_FEDERATED_TYPE","SwirlNoopIndexer","SwirlIndexer","SWIRL_INDEX_PROVIDER_TAG","swirlResultScore","types","MISSING_INDEX_ERROR_NAME","SWIRL_HIGHLIGHT_START_MARKER","SWIRL_HIGHLIGHT_END_MARKER"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"types.cjs.js","sources":["../../src/engines/types.ts"],"sourcesContent":["/*\n * Copyright 2026 SWIRL AI Connect\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The document type registered by the federated lane. Results that did not\n * come from the Backstage index are returned under this type.\n *\n * @public\n */\nexport const SWIRL_FEDERATED_TYPE = 'swirl-federated';\n\n/**\n * The SWIRL SearchProvider tag that serves the Backstage index (the Tantivy\n * lane). Always included in the provider list sent to SWIRL.\n *\n * @public\n */\nexport const SWIRL_INDEX_PROVIDER_TAG = 'backstage-index';\n\n/**\n * Name given to the error thrown when SWIRL reports that one of the requested\n * document types has no live index. The search router surfaces the name\n * instead of collapsing it into a generic 500.\n *\n * @public\n */\nexport const MISSING_INDEX_ERROR_NAME = 'MissingIndexError';\n\n/**\n * Options resolved from `search.swirl` in app-config.\n *\n * @public\n */\nexport type SwirlEngineConfig = {\n baseUrl: string;\n audience: string;\n indexerBatchSize: number;\n queryTimeoutMs: number;\n federated: {\n enabled: boolean;\n providerTags: string[];\n timeoutMs: number;\n };\n tuning: SwirlTuning;\n highlight: {\n enabled: boolean;\n maxChars: number;\n startMarker: string;\n endMarker: string;\n };\n};\n\n/**\n * The marker pair SWIRL wraps hits in out of the box, from\n * `SWIRL_HIGHLIGHT_START_CHAR` and `SWIRL_HIGHLIGHT_END_CHAR`.\n *\n * @public\n */\nexport const SWIRL_HIGHLIGHT_START_MARKER = '<em>';\n\n/**\n * @public\n */\nexport const SWIRL_HIGHLIGHT_END_MARKER = '</em>';\n\n/**\n * The relevance tuning block mirrored to SWIRL on startup.\n *\n * @public\n */\nexport type SwirlTuning = {\n fieldBoosts?: { titleExact?: number; titleNgram?: number; text?: number };\n ngram?: { min?: number; max?: number };\n stemmer?: string;\n stopwords?: string[];\n fuzzy?: { enabled?: boolean; distance?: number };\n bm25?: { k1?: number; b?: number };\n};\n\n/**\n * The `payload.backstage` block written by the SWIRL Tantivy connector for\n * documents that came from the Backstage index.\n *\n * @public\n */\nexport type SwirlBackstagePayload = {\n type: string;\n document: Record<string, any>;\n};\n\n/**\n * One entry of the `results` array in a SWIRL response envelope.\n *\n * @public\n */\nexport type SwirlResult = {\n title?: string;\n body?: string;\n url?: string;\n searchprovider?: string;\n swirl_rank?: number;\n swirl_score?: number;\n title_hit_highlights?: string[];\n body_hit_highlights?: string[];\n payload?: Record<string, any> & {\n backstage?: SwirlBackstagePayload;\n /**\n * The provider's own score. SWIRL's MappingResultProcessor sweeps keys it\n * does not recognise off the top level and into the payload, so the\n * Tantivy score arrives here rather than beside `swirl_score`.\n */\n searchprovider_score?: number;\n };\n};\n\n/**\n * Reads the score off a SWIRL result. The provider score lives in the payload\n * because SWIRL moves unrecognised top level keys there; `swirl_score`, which\n * the mixer sets, is the fallback.\n *\n * @public\n */\nexport function swirlResultScore(result: SwirlResult): number | undefined {\n return result.payload?.searchprovider_score ?? result.swirl_score;\n}\n\n/**\n * The SWIRL response envelope returned by `/swirl/search/` and\n * `/swirl/results/`.\n *\n * @public\n */\nexport type SwirlResponse = {\n messages?: string[];\n info?: {\n search?: { id?: number | string };\n results?: {\n found_total?: number;\n retrieved_total?: number;\n next_page?: string;\n prev_page?: string;\n };\n [provider: string]: any;\n };\n results?: SwirlResult[];\n};\n\n/**\n * The cursor the engine hands back to Backstage between pages. Encoded as\n * base64 JSON so that page N is a cheap `/swirl/results/` read rather than a\n * second federation.\n *\n * @public\n */\nexport type SwirlPageCursor = {\n /** SWIRL search id */\n s: number | string;\n /** zero based page number */\n p: number;\n};\n"],"names":[],"mappings":";;AAsBO,MAAM,oBAAA,GAAuB;AAQ7B,MAAM,wBAAA,GAA2B;AASjC,MAAM,wBAAA,GAA2B;AAgCjC,MAAM,4BAAA,GAA+B;AAKrC,MAAM,0BAAA,GAA6B;AA2DnC,SAAS,iBAAiB,MAAA,EAAyC;AACxE,EAAA,OAAO,MAAA,CAAO,OAAA,EAAS,oBAAA,IAAwB,MAAA,CAAO,WAAA;AACxD;;;;;;;"}
1
+ {"version":3,"file":"types.cjs.js","sources":["../../src/engines/types.ts"],"sourcesContent":["/*\n * Copyright 2026 SWIRL AI Connect\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The document type registered by the federated lane. Results that did not\n * come from the Backstage index are returned under this type.\n *\n * @public\n */\nexport const SWIRL_FEDERATED_TYPE = 'swirl-federated';\n\n/**\n * The SWIRL SearchProvider tag that serves the Backstage index (the Tantivy\n * lane). Always included in the provider list sent to SWIRL.\n *\n * @public\n */\nexport const SWIRL_INDEX_PROVIDER_TAG = 'backstage-index';\n\n/**\n * Name given to the error thrown when SWIRL reports that one of the requested\n * document types has no live index. The search router surfaces the name\n * instead of collapsing it into a generic 500.\n *\n * @public\n */\nexport const MISSING_INDEX_ERROR_NAME = 'MissingIndexError';\n\n/**\n * Options resolved from `search.swirl` in app-config.\n *\n * @public\n */\nexport type SwirlEngineConfig = {\n baseUrl: string;\n audience: string;\n indexerBatchSize: number;\n queryTimeoutMs: number;\n federated: {\n enabled: boolean;\n providerTags: string[];\n timeoutMs: number;\n };\n tuning: SwirlTuning;\n highlight: {\n enabled: boolean;\n maxChars: number;\n startMarker: string;\n endMarker: string;\n };\n};\n\n/**\n * The marker pair SWIRL wraps hits in out of the box, from\n * `SWIRL_HIGHLIGHT_START_CHAR` and `SWIRL_HIGHLIGHT_END_CHAR`.\n *\n * @public\n */\nexport const SWIRL_HIGHLIGHT_START_MARKER = '<em>';\n\n/**\n * @public\n */\nexport const SWIRL_HIGHLIGHT_END_MARKER = '</em>';\n\n/**\n * The relevance tuning block mirrored to SWIRL on startup.\n *\n * @public\n */\nexport type SwirlTuning = {\n fieldBoosts?: { titleExact?: number; titleNgram?: number; text?: number };\n ngram?: { min?: number; max?: number };\n stemmer?: string;\n stopwords?: string[];\n fuzzy?: { enabled?: boolean; distance?: number };\n /** Stored by SWIRL, not applied by the current engine version. */\n bm25?: { k1?: number; b?: number };\n};\n\n/**\n * The `payload.backstage` block written by the SWIRL Tantivy connector for\n * documents that came from the Backstage index.\n *\n * @public\n */\nexport type SwirlBackstagePayload = {\n type: string;\n document: Record<string, any>;\n};\n\n/**\n * One entry of the `results` array in a SWIRL response envelope.\n *\n * @public\n */\nexport type SwirlResult = {\n title?: string;\n body?: string;\n url?: string;\n searchprovider?: string;\n swirl_rank?: number;\n swirl_score?: number;\n title_hit_highlights?: string[];\n body_hit_highlights?: string[];\n payload?: Record<string, any> & {\n backstage?: SwirlBackstagePayload;\n /**\n * The provider's own score. SWIRL's MappingResultProcessor sweeps keys it\n * does not recognise off the top level and into the payload, so the\n * Tantivy score arrives here rather than beside `swirl_score`.\n */\n searchprovider_score?: number;\n };\n};\n\n/**\n * Reads the score off a SWIRL result. The provider score lives in the payload\n * because SWIRL moves unrecognised top level keys there; `swirl_score`, which\n * the mixer sets, is the fallback.\n *\n * @public\n */\nexport function swirlResultScore(result: SwirlResult): number | undefined {\n return result.payload?.searchprovider_score ?? result.swirl_score;\n}\n\n/**\n * The SWIRL response envelope returned by `/swirl/search/` and\n * `/swirl/results/`.\n *\n * @public\n */\nexport type SwirlResponse = {\n messages?: string[];\n info?: {\n search?: { id?: number | string };\n results?: {\n found_total?: number;\n retrieved_total?: number;\n next_page?: string;\n prev_page?: string;\n };\n [provider: string]: any;\n };\n results?: SwirlResult[];\n};\n\n/**\n * The cursor the engine hands back to Backstage between pages. Encoded as\n * base64 JSON so that page N is a cheap `/swirl/results/` read rather than a\n * second federation.\n *\n * @public\n */\nexport type SwirlPageCursor = {\n /** SWIRL search id */\n s: number | string;\n /** zero based page number */\n p: number;\n};\n"],"names":[],"mappings":";;AAsBO,MAAM,oBAAA,GAAuB;AAQ7B,MAAM,wBAAA,GAA2B;AASjC,MAAM,wBAAA,GAA2B;AAgCjC,MAAM,4BAAA,GAA+B;AAKrC,MAAM,0BAAA,GAA6B;AA4DnC,SAAS,iBAAiB,MAAA,EAAyC;AACxE,EAAA,OAAO,MAAA,CAAO,OAAA,EAAS,oBAAA,IAAwB,MAAA,CAAO,WAAA;AACxD;;;;;;;"}
package/dist/index.d.ts CHANGED
@@ -89,6 +89,7 @@ type SwirlTuning = {
89
89
  enabled?: boolean;
90
90
  distance?: number;
91
91
  };
92
+ /** Stored by SWIRL, not applied by the current engine version. */
92
93
  bm25?: {
93
94
  k1?: number;
94
95
  b?: number;
@@ -287,6 +288,14 @@ declare class SwirlSearchEngine implements SearchEngine {
287
288
  private readonly client;
288
289
  private readonly preTag;
289
290
  private readonly postTag;
291
+ /**
292
+ * Every document type the search backend has handed this engine an indexer
293
+ * for. It is the yardstick for a missing index report on a query that named
294
+ * no types of its own. The federated type is deliberately left out: nothing
295
+ * is ever written to a SWIRL index under it, so it can never be part of a
296
+ * missing set.
297
+ */
298
+ private readonly indexedTypes;
290
299
  private constructor();
291
300
  static fromConfig(config: Config, deps: SwirlSearchEngineOptions): Promise<SwirlSearchEngine>;
292
301
  /**
@@ -325,13 +334,35 @@ declare class SwirlSearchEngine implements SearchEngine {
325
334
  */
326
335
  private resolveToken;
327
336
  /**
328
- * SWIRL reports a type with no live index either as a 404 with an
329
- * `missing_index` error body, or as a structured `__MISSING_INDEX__` entry
330
- * in the response messages. Either way the caller asked for something that
331
- * has never been indexed, which is worth saying out loud rather than
332
- * returning an empty result set or a bare 500.
337
+ * The document types SWIRL reported as having no live index, or undefined
338
+ * when it reported none.
339
+ *
340
+ * SWIRL says this either as a 404 with a `missing_index` error body, or as
341
+ * a structured `__MISSING_INDEX__` entry in the response messages. The two
342
+ * forms mean the same thing and are treated the same way.
343
+ */
344
+ private missingIndexTypes;
345
+ /**
346
+ * The types this query is entitled to hear about. A query that named types
347
+ * is measured against those; one that named none is measured against every
348
+ * type this engine has been given an indexer for.
349
+ */
350
+ private requestedTypes;
351
+ /**
352
+ * Whether a missing index report accounts for the whole query.
353
+ *
354
+ * A type that is legitimately and permanently empty - TechDocs on a portal
355
+ * with no mkdocs content is the everyday case - must not turn a search that
356
+ * simply matched nothing into an error, because under `permission.enabled`
357
+ * the search router puts every registered type on every query. So the loud
358
+ * `MissingIndexError` is kept for the case it was written for: nothing the
359
+ * caller asked for is indexed at all. A partial miss is a soft condition.
360
+ *
361
+ * With nothing to measure against - no types requested and no indexer ever
362
+ * handed out, or a report that names no types - the loud answer stands,
363
+ * because there is no evidence that anything else was searched.
333
364
  */
334
- private assertIndexPresent;
365
+ private coversEveryRequestedType;
335
366
  private toIndexableResult;
336
367
  private toHighlight;
337
368
  /** Removes the configured marker pair, leaving the text it wrapped. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swirl-search/backstage-plugin-search-backend-module-swirl",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A Backstage search backend module that uses SWIRL as the search engine, for indexed Backstage documents and federated results from connected sources.",
5
5
  "backstage": {
6
6
  "role": "backend-plugin-module",