@swirl-search/backstage-plugin-search-backend-module-swirl 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+
3
+ var pluginSearchBackendNode = require('@backstage/plugin-search-backend-node');
4
+
5
+ const sleep = (ms) => new Promise((resolve) => {
6
+ setTimeout(resolve, ms);
7
+ });
8
+ class SwirlIndexer extends pluginSearchBackendNode.BatchSearchEngineIndexer {
9
+ type;
10
+ client;
11
+ logger;
12
+ maxRetries;
13
+ retryBaseDelayMs;
14
+ generation;
15
+ numRecords = 0;
16
+ settled = false;
17
+ constructor(options) {
18
+ super({ batchSize: options.batchSize });
19
+ this.type = options.type;
20
+ this.client = options.client;
21
+ this.logger = options.logger.child({ documentType: options.type });
22
+ this.maxRetries = options.maxRetries ?? 3;
23
+ this.retryBaseDelayMs = options.retryBaseDelayMs ?? 250;
24
+ }
25
+ async initialize() {
26
+ const token = await this.client.mintToken();
27
+ const result = await this.client.request({
28
+ url: this.client.url(
29
+ `/swirl/index/${encodeURIComponent(this.type)}/begin/`
30
+ ),
31
+ method: "POST",
32
+ token
33
+ });
34
+ if (!result.ok) {
35
+ throw new Error(
36
+ `SWIRL refused to open a generation for ${this.type}: ${describe(
37
+ result
38
+ )}`
39
+ );
40
+ }
41
+ const generation = result.body?.generation;
42
+ if (generation === void 0 || generation === null) {
43
+ throw new Error(
44
+ `SWIRL opened a generation for ${this.type} but returned no generation id`
45
+ );
46
+ }
47
+ this.generation = String(generation);
48
+ this.logger.info(
49
+ `Opened SWIRL index generation ${this.generation} for ${this.type}`
50
+ );
51
+ }
52
+ async index(documents) {
53
+ const result = await this.postWithRetry(this.generationUrl("docs/"), {
54
+ documents
55
+ });
56
+ if (!result.ok) {
57
+ throw new Error(
58
+ `SWIRL rejected a batch of ${documents.length} ${this.type} documents: ${describe(result)}`
59
+ );
60
+ }
61
+ this.numRecords += documents.length;
62
+ }
63
+ async finalize() {
64
+ if (this.numRecords === 0) {
65
+ this.logger.warn(
66
+ `Index for ${this.type} was not replaced: indexer received 0 documents`
67
+ );
68
+ await this.abort();
69
+ return;
70
+ }
71
+ const token = await this.client.mintToken();
72
+ const result = await this.client.request({
73
+ url: this.generationUrl("finalize/"),
74
+ method: "POST",
75
+ token
76
+ });
77
+ if (!result.ok) {
78
+ throw new Error(
79
+ `SWIRL failed to finalize generation ${this.generation} of ${this.type}: ${describe(result)}`
80
+ );
81
+ }
82
+ this.settled = true;
83
+ this.logger.info(
84
+ `Finalized SWIRL index generation ${this.generation} for ${this.type} with ${this.numRecords} documents`
85
+ );
86
+ }
87
+ /**
88
+ * Covers the case where the failure happened elsewhere in the indexing
89
+ * pipeline, in which case finalize is never called and the open generation
90
+ * would otherwise block the next run. `BatchSearchEngineIndexer` has no
91
+ * error hook, so this follows the approach the Postgres engine takes.
92
+ *
93
+ * @internal
94
+ */
95
+ async _destroy(error, done) {
96
+ if (!error) {
97
+ done();
98
+ return;
99
+ }
100
+ await this.abort();
101
+ done(error);
102
+ }
103
+ /** Best effort: an abort that fails is logged, never rethrown. */
104
+ async abort() {
105
+ if (this.settled || !this.generation) {
106
+ return;
107
+ }
108
+ this.settled = true;
109
+ try {
110
+ const token = await this.client.mintToken();
111
+ await this.client.request({
112
+ url: this.generationUrl("abort/"),
113
+ method: "POST",
114
+ token
115
+ });
116
+ this.logger.info(
117
+ `Aborted SWIRL index generation ${this.generation} for ${this.type}`
118
+ );
119
+ } catch (e) {
120
+ this.logger.warn(
121
+ `Could not abort SWIRL index generation ${this.generation} for ${this.type}: ${e}`
122
+ );
123
+ }
124
+ }
125
+ generationUrl(suffix) {
126
+ return this.client.url(
127
+ `/swirl/index/${encodeURIComponent(this.type)}/${encodeURIComponent(
128
+ this.generation
129
+ )}/${suffix}`
130
+ );
131
+ }
132
+ async postWithRetry(url, body) {
133
+ let lastError;
134
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
135
+ if (attempt > 0) {
136
+ await sleep(this.retryBaseDelayMs * 2 ** (attempt - 1));
137
+ }
138
+ try {
139
+ const token = await this.client.mintToken();
140
+ const result = await this.client.request({
141
+ url,
142
+ method: "POST",
143
+ token,
144
+ body
145
+ });
146
+ if (result.status < 500) {
147
+ return result;
148
+ }
149
+ lastError = new Error(describe(result));
150
+ this.logger.warn(
151
+ `SWIRL returned ${result.status} for ${this.type}, attempt ${attempt + 1} of ${this.maxRetries + 1}`
152
+ );
153
+ } catch (e) {
154
+ lastError = e;
155
+ this.logger.warn(
156
+ `SWIRL request for ${this.type} failed, attempt ${attempt + 1} of ${this.maxRetries + 1}: ${e}`
157
+ );
158
+ }
159
+ }
160
+ throw new Error(
161
+ `SWIRL request for ${this.type} failed after ${this.maxRetries + 1} attempts: ${lastError}`
162
+ );
163
+ }
164
+ }
165
+ function describe(result) {
166
+ const detail = typeof result.body === "string" ? result.body : JSON.stringify(result.body ?? {});
167
+ return `HTTP ${result.status} ${detail}`;
168
+ }
169
+
170
+ exports.SwirlIndexer = SwirlIndexer;
171
+ //# sourceMappingURL=SwirlIndexer.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SwirlIndexer.cjs.js","sources":["../../src/engines/SwirlIndexer.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 { LoggerService } from '@backstage/backend-plugin-api';\nimport { BatchSearchEngineIndexer } from '@backstage/plugin-search-backend-node';\nimport { IndexableDocument } from '@backstage/plugin-search-common';\nimport { SwirlClient, SwirlRequestResult } from './SwirlClient';\n\n/**\n * Options for {@link SwirlIndexer}.\n *\n * @public\n */\nexport type SwirlIndexerOptions = {\n type: string;\n batchSize: number;\n client: SwirlClient;\n logger: LoggerService;\n /** Retries after the first attempt, on 5xx and transport errors. Default 3. */\n maxRetries?: number;\n /** First backoff step in ms; doubles per retry. Default 250. */\n retryBaseDelayMs?: number;\n};\n\nconst sleep = (ms: number) =>\n new Promise<void>(resolve => {\n setTimeout(resolve, ms);\n });\n\n/**\n * Writes one generation of documents of a single type into SWIRL, using the\n * generation lifecycle of the SWIRL ingest API: begin, docs, then finalize or\n * abort. The live generation is only replaced by a successful finalize, so a\n * stream that dies half way through leaves the served index untouched.\n *\n * @public\n */\nexport class SwirlIndexer extends BatchSearchEngineIndexer {\n private readonly type: string;\n private readonly client: SwirlClient;\n private readonly logger: LoggerService;\n private readonly maxRetries: number;\n private readonly retryBaseDelayMs: number;\n\n private generation?: string;\n private numRecords = 0;\n private settled = false;\n\n constructor(options: SwirlIndexerOptions) {\n super({ batchSize: options.batchSize });\n this.type = options.type;\n this.client = options.client;\n this.logger = options.logger.child({ documentType: options.type });\n this.maxRetries = options.maxRetries ?? 3;\n this.retryBaseDelayMs = options.retryBaseDelayMs ?? 250;\n }\n\n async initialize(): Promise<void> {\n const token = await this.client.mintToken();\n const result = await this.client.request({\n url: this.client.url(\n `/swirl/index/${encodeURIComponent(this.type)}/begin/`,\n ),\n method: 'POST',\n token,\n });\n\n if (!result.ok) {\n throw new Error(\n `SWIRL refused to open a generation for ${this.type}: ${describe(\n result,\n )}`,\n );\n }\n\n const generation = result.body?.generation;\n if (generation === undefined || generation === null) {\n throw new Error(\n `SWIRL opened a generation for ${this.type} but returned no generation id`,\n );\n }\n\n this.generation = String(generation);\n this.logger.info(\n `Opened SWIRL index generation ${this.generation} for ${this.type}`,\n );\n }\n\n async index(documents: IndexableDocument[]): Promise<void> {\n const result = await this.postWithRetry(this.generationUrl('docs/'), {\n documents,\n });\n\n if (!result.ok) {\n throw new Error(\n `SWIRL rejected a batch of ${documents.length} ${\n this.type\n } documents: ${describe(result)}`,\n );\n }\n\n this.numRecords += documents.length;\n }\n\n async finalize(): Promise<void> {\n // Mirror the zero document guard the other engines apply: an empty\n // collator run must not wipe the index that is currently being served.\n if (this.numRecords === 0) {\n this.logger.warn(\n `Index for ${this.type} was not replaced: indexer received 0 documents`,\n );\n await this.abort();\n return;\n }\n\n const token = await this.client.mintToken();\n const result = await this.client.request({\n url: this.generationUrl('finalize/'),\n method: 'POST',\n token,\n });\n\n if (!result.ok) {\n throw new Error(\n `SWIRL failed to finalize generation ${this.generation} of ${\n this.type\n }: ${describe(result)}`,\n );\n }\n\n this.settled = true;\n\n this.logger.info(\n `Finalized SWIRL index generation ${this.generation} for ${this.type} with ${this.numRecords} documents`,\n );\n }\n\n /**\n * Covers the case where the failure happened elsewhere in the indexing\n * pipeline, in which case finalize is never called and the open generation\n * would otherwise block the next run. `BatchSearchEngineIndexer` has no\n * error hook, so this follows the approach the Postgres engine takes.\n *\n * @internal\n */\n async _destroy(error: Error | null, done: (error?: Error | null) => void) {\n if (!error) {\n done();\n return;\n }\n\n await this.abort();\n done(error);\n }\n\n /** Best effort: an abort that fails is logged, never rethrown. */\n private async abort(): Promise<void> {\n if (this.settled || !this.generation) {\n return;\n }\n this.settled = true;\n\n try {\n const token = await this.client.mintToken();\n await this.client.request({\n url: this.generationUrl('abort/'),\n method: 'POST',\n token,\n });\n this.logger.info(\n `Aborted SWIRL index generation ${this.generation} for ${this.type}`,\n );\n } catch (e) {\n this.logger.warn(\n `Could not abort SWIRL index generation ${this.generation} for ${this.type}: ${e}`,\n );\n }\n }\n\n private generationUrl(suffix: string): string {\n return this.client.url(\n `/swirl/index/${encodeURIComponent(this.type)}/${encodeURIComponent(\n this.generation!,\n )}/${suffix}`,\n );\n }\n\n private async postWithRetry(\n url: string,\n body: unknown,\n ): Promise<SwirlRequestResult> {\n let lastError: unknown;\n\n for (let attempt = 0; attempt <= this.maxRetries; attempt++) {\n if (attempt > 0) {\n await sleep(this.retryBaseDelayMs * 2 ** (attempt - 1));\n }\n\n try {\n const token = await this.client.mintToken();\n const result = await this.client.request({\n url,\n method: 'POST',\n token,\n body,\n });\n\n if (result.status < 500) {\n return result;\n }\n\n lastError = new Error(describe(result));\n this.logger.warn(\n `SWIRL returned ${result.status} for ${this.type}, attempt ${\n attempt + 1\n } of ${this.maxRetries + 1}`,\n );\n } catch (e) {\n lastError = e;\n this.logger.warn(\n `SWIRL request for ${this.type} failed, attempt ${attempt + 1} of ${\n this.maxRetries + 1\n }: ${e}`,\n );\n }\n }\n\n throw new Error(\n `SWIRL request for ${this.type} failed after ${\n this.maxRetries + 1\n } attempts: ${lastError}`,\n );\n }\n}\n\nfunction describe(result: SwirlRequestResult): string {\n const detail =\n typeof result.body === 'string'\n ? result.body\n : JSON.stringify(result.body ?? {});\n return `HTTP ${result.status} ${detail}`;\n}\n"],"names":["BatchSearchEngineIndexer"],"mappings":"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"}
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ var pluginSearchBackendNode = require('@backstage/plugin-search-backend-node');
4
+
5
+ class SwirlNoopIndexer extends pluginSearchBackendNode.BatchSearchEngineIndexer {
6
+ type;
7
+ logger;
8
+ numRecords = 0;
9
+ constructor(options) {
10
+ super({ batchSize: 100 });
11
+ this.type = options.type;
12
+ this.logger = options.logger.child({ documentType: options.type });
13
+ }
14
+ async initialize() {
15
+ }
16
+ async index(documents) {
17
+ this.numRecords += documents.length;
18
+ }
19
+ async finalize() {
20
+ if (this.numRecords > 0) {
21
+ this.logger.warn(
22
+ `Discarded ${this.numRecords} documents written to ${this.type}: the federated type is not indexed`
23
+ );
24
+ }
25
+ }
26
+ }
27
+
28
+ exports.SwirlNoopIndexer = SwirlNoopIndexer;
29
+ //# sourceMappingURL=SwirlNoopIndexer.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SwirlNoopIndexer.cjs.js","sources":["../../src/engines/SwirlNoopIndexer.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 { LoggerService } from '@backstage/backend-plugin-api';\nimport { BatchSearchEngineIndexer } from '@backstage/plugin-search-backend-node';\nimport { IndexableDocument } from '@backstage/plugin-search-common';\n\n/**\n * Options for {@link SwirlNoopIndexer}.\n *\n * @public\n */\nexport type SwirlNoopIndexerOptions = {\n type: string;\n logger: LoggerService;\n};\n\n/**\n * The indexer handed back for the federated document type. The federated lane\n * has nothing to index: its collator yields zero documents and exists only so\n * the type is registered. Anything written here is dropped, so a stray\n * document can never reach the SWIRL ingest API under this type.\n *\n * @public\n */\nexport class SwirlNoopIndexer extends BatchSearchEngineIndexer {\n private readonly type: string;\n private readonly logger: LoggerService;\n private numRecords = 0;\n\n constructor(options: SwirlNoopIndexerOptions) {\n super({ batchSize: 100 });\n this.type = options.type;\n this.logger = options.logger.child({ documentType: options.type });\n }\n\n async initialize(): Promise<void> {}\n\n async index(documents: IndexableDocument[]): Promise<void> {\n this.numRecords += documents.length;\n }\n\n async finalize(): Promise<void> {\n if (this.numRecords > 0) {\n this.logger.warn(\n `Discarded ${this.numRecords} documents written to ${this.type}: the federated type is not indexed`,\n );\n }\n }\n}\n"],"names":["BatchSearchEngineIndexer"],"mappings":";;;;AAsCO,MAAM,yBAAyBA,gDAAA,CAAyB;AAAA,EAC5C,IAAA;AAAA,EACA,MAAA;AAAA,EACT,UAAA,GAAa,CAAA;AAAA,EAErB,YAAY,OAAA,EAAkC;AAC5C,IAAA,KAAA,CAAM,EAAE,SAAA,EAAW,GAAA,EAAK,CAAA;AACxB,IAAA,IAAA,CAAK,OAAO,OAAA,CAAQ,IAAA;AACpB,IAAA,IAAA,CAAK,MAAA,GAAS,QAAQ,MAAA,CAAO,KAAA,CAAM,EAAE,YAAA,EAAc,OAAA,CAAQ,MAAM,CAAA;AAAA,EACnE;AAAA,EAEA,MAAM,UAAA,GAA4B;AAAA,EAAC;AAAA,EAEnC,MAAM,MAAM,SAAA,EAA+C;AACzD,IAAA,IAAA,CAAK,cAAc,SAAA,CAAU,MAAA;AAAA,EAC/B;AAAA,EAEA,MAAM,QAAA,GAA0B;AAC9B,IAAA,IAAI,IAAA,CAAK,aAAa,CAAA,EAAG;AACvB,MAAA,IAAA,CAAK,MAAA,CAAO,IAAA;AAAA,QACV,CAAA,UAAA,EAAa,IAAA,CAAK,UAAU,CAAA,sBAAA,EAAyB,KAAK,IAAI,CAAA,mCAAA;AAAA,OAChE;AAAA,IACF;AAAA,EACF;AACF;;"}
@@ -0,0 +1,377 @@
1
+ 'use strict';
2
+
3
+ var node_crypto = require('node:crypto');
4
+ var SwirlClient = require('./SwirlClient.cjs.js');
5
+ var SwirlIndexer = require('./SwirlIndexer.cjs.js');
6
+ var SwirlNoopIndexer = require('./SwirlNoopIndexer.cjs.js');
7
+ var types = require('./types.cjs.js');
8
+
9
+ class SwirlSearchEngine {
10
+ options;
11
+ logger;
12
+ client;
13
+ preTag;
14
+ postTag;
15
+ constructor(options, deps) {
16
+ this.options = options;
17
+ this.logger = deps.logger;
18
+ this.client = new SwirlClient.SwirlClient({
19
+ baseUrl: options.baseUrl,
20
+ auth: deps.auth,
21
+ audience: options.audience,
22
+ timeoutMs: options.queryTimeoutMs,
23
+ fetchImpl: deps.fetchImpl
24
+ });
25
+ const tag = node_crypto.randomUUID();
26
+ this.preTag = `<${tag}>`;
27
+ this.postTag = `</${tag}>`;
28
+ }
29
+ static async fromConfig(config, deps) {
30
+ const engine = new SwirlSearchEngine(readSwirlConfig(config), deps);
31
+ await engine.pushTuning();
32
+ return engine;
33
+ }
34
+ /**
35
+ * Mirrors the app-config tuning block to SWIRL so that relevance is
36
+ * configured in one place. A SWIRL that is not up yet, or an older SWIRL
37
+ * that does not know the endpoint, must not stop the backend from booting.
38
+ *
39
+ * SWIRL answers with the effective tuning in its own flat form plus
40
+ * `accepted_keys`, naming every key it took in the shape it was sent, and a
41
+ * `bm25` notice when it stored BM25 parameters it cannot apply. Both are
42
+ * logged, because a tuning block that is accepted by Backstage and then
43
+ * quietly dropped by SWIRL is exactly the failure this call exists to make
44
+ * visible. A 400 names the keys SWIRL did not recognise; that is a warning,
45
+ * not a boot failure.
46
+ */
47
+ async pushTuning() {
48
+ try {
49
+ const token = await this.client.mintToken();
50
+ const result = await this.client.request({
51
+ url: this.client.url("/swirl/index/config/"),
52
+ method: "POST",
53
+ token,
54
+ body: this.options.tuning
55
+ });
56
+ if (!result.ok) {
57
+ const rejected = rejectedTuningKeys(result.body);
58
+ const detail = rejected.length ? ` SWIRL did not recognise: ${rejected.join(", ")}.` : describeTuningError(result.body);
59
+ this.logger.warn(
60
+ `SWIRL rejected the relevance tuning block: HTTP ${result.status}.${detail} SWIRL keeps its current tuning.`
61
+ );
62
+ return;
63
+ }
64
+ const body = result.body ?? {};
65
+ const accepted = Array.isArray(body.accepted_keys) ? body.accepted_keys.map(String) : [];
66
+ this.logger.info(
67
+ accepted.length ? `Mirrored the relevance tuning block to SWIRL; SWIRL accepted: ${accepted.join(
68
+ ", "
69
+ )}` : "Mirrored the relevance tuning block to SWIRL; SWIRL reported no accepted tuning keys"
70
+ );
71
+ if (typeof body.bm25 === "string" && body.bm25) {
72
+ this.logger.warn(
73
+ `SWIRL stored the bm25 tuning values but reports them "${body.bm25}", so search.swirl.tuning.bm25 has no effect on ranking.`
74
+ );
75
+ }
76
+ } catch (e) {
77
+ this.logger.warn(
78
+ `Could not send the relevance tuning block to SWIRL at ${this.options.baseUrl}: ${e}. SWIRL keeps its current tuning.`
79
+ );
80
+ }
81
+ }
82
+ translator(query, options) {
83
+ const pageSize = query.pageLimit || 25;
84
+ const cursor = decodePageCursor(query.pageCursor);
85
+ const federated = options.federatedEnabled && (query.types === void 0 || query.types.includes(types.SWIRL_FEDERATED_TYPE));
86
+ const indexTypes = query.types?.filter(
87
+ (type) => type !== types.SWIRL_FEDERATED_TYPE
88
+ );
89
+ return {
90
+ term: query.term ?? "",
91
+ indexTypes,
92
+ federated,
93
+ filters: query.filters ?? {},
94
+ pageSize,
95
+ cursor
96
+ };
97
+ }
98
+ setTranslator(translator) {
99
+ this.translator = translator;
100
+ }
101
+ async getIndexer(type) {
102
+ if (type === types.SWIRL_FEDERATED_TYPE) {
103
+ return new SwirlNoopIndexer.SwirlNoopIndexer({ type, logger: this.logger });
104
+ }
105
+ return new SwirlIndexer.SwirlIndexer({
106
+ type,
107
+ batchSize: this.options.indexerBatchSize,
108
+ client: this.client,
109
+ logger: this.logger
110
+ });
111
+ }
112
+ async query(query, options) {
113
+ const concrete = this.translator(query, {
114
+ federatedEnabled: this.options.federated.enabled
115
+ });
116
+ const token = await this.resolveToken(options);
117
+ const result = concrete.cursor ? await this.fetchResultPage(concrete, concrete.cursor, token) : await this.fetchFirstPage(concrete, token);
118
+ this.assertIndexPresent(result);
119
+ if (!result.ok) {
120
+ throw new Error(
121
+ `SWIRL returned HTTP ${result.status} for the query ${JSON.stringify(
122
+ concrete.term
123
+ )}`
124
+ );
125
+ }
126
+ const body = result.body ?? {};
127
+ const page = concrete.cursor?.p ?? 0;
128
+ const searchId = concrete.cursor?.s ?? body.info?.search?.id;
129
+ const swirlResults = body.results ?? [];
130
+ const results = swirlResults.map(
131
+ (entry, index) => this.toIndexableResult(entry, page * concrete.pageSize + index + 1)
132
+ );
133
+ const hasNextPage = searchId !== void 0 && swirlResults.length >= concrete.pageSize;
134
+ return {
135
+ results,
136
+ numberOfResults: body.info?.results?.found_total ?? body.info?.results?.retrieved_total ?? void 0,
137
+ nextPageCursor: hasNextPage ? encodePageCursor({ s: searchId, p: page + 1 }) : void 0,
138
+ previousPageCursor: page > 0 && searchId !== void 0 ? encodePageCursor({ s: searchId, p: page - 1 }) : void 0
139
+ };
140
+ }
141
+ /**
142
+ * Page 0 federates: SWIRL runs the query across the Backstage index and,
143
+ * when the federated lane is active, the connected providers too.
144
+ */
145
+ async fetchFirstPage(concrete, token) {
146
+ const providers = [types.SWIRL_INDEX_PROVIDER_TAG];
147
+ if (concrete.federated) {
148
+ providers.push(...this.options.federated.providerTags);
149
+ }
150
+ return this.client.request({
151
+ url: this.client.url("/swirl/search/", {
152
+ qs: concrete.term,
153
+ providers: providers.join(","),
154
+ backstage_types: concrete.indexTypes?.join(",") ?? "",
155
+ backstage_filters: JSON.stringify(concrete.filters),
156
+ backstage_timeout_ms: concrete.federated ? this.options.federated.timeoutMs : void 0,
157
+ results_requested: concrete.pageSize,
158
+ rag: "false"
159
+ }),
160
+ method: "GET",
161
+ token,
162
+ timeoutMs: this.options.queryTimeoutMs
163
+ });
164
+ }
165
+ /**
166
+ * Page N is a database read in SWIRL, not a second federation. That keeps
167
+ * the paging loop in Backstage's AuthorizedSearchEngine cheap.
168
+ */
169
+ async fetchResultPage(concrete, cursor, token) {
170
+ return this.client.request({
171
+ url: this.client.url("/swirl/results/", {
172
+ search_id: String(cursor.s),
173
+ page: cursor.p + 1,
174
+ results_requested: concrete.pageSize
175
+ }),
176
+ method: "GET",
177
+ token,
178
+ timeoutMs: this.options.queryTimeoutMs
179
+ });
180
+ }
181
+ /**
182
+ * The search router hands the engine a plugin token minted per request,
183
+ * carrying the caller's identity in its `obo` claim; that token is what
184
+ * SWIRL verifies. Programmatic callers that reach the engine directly get
185
+ * a freshly minted one instead.
186
+ */
187
+ async resolveToken(options) {
188
+ if (options && "token" in options && options.token) {
189
+ return options.token;
190
+ }
191
+ const credentials = options && "credentials" in options ? options.credentials : void 0;
192
+ return this.client.mintToken(credentials);
193
+ }
194
+ /**
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.
200
+ */
201
+ assertIndexPresent(result) {
202
+ const body = result.body;
203
+ if (result.status === 404 && body?.error === "missing_index") {
204
+ throw missingIndexError(body?.types);
205
+ }
206
+ for (const message of body?.messages ?? []) {
207
+ if (typeof message !== "string" || !message.includes("__MISSING_INDEX__")) {
208
+ continue;
209
+ }
210
+ let parsed;
211
+ try {
212
+ parsed = JSON.parse(message);
213
+ } catch {
214
+ continue;
215
+ }
216
+ if (parsed?.type === "__MISSING_INDEX__") {
217
+ throw missingIndexError(parsed.types);
218
+ }
219
+ }
220
+ }
221
+ toIndexableResult(entry, rank) {
222
+ const backstage = entry.payload?.backstage;
223
+ const indexed = backstage?.type !== void 0 && backstage?.document !== void 0;
224
+ return {
225
+ type: indexed ? backstage.type : types.SWIRL_FEDERATED_TYPE,
226
+ document: indexed ? backstage.document : {
227
+ // Stripped defensively. SWIRL's relevancy processor writes the
228
+ // marked up text back over `title` and `body`, which is what its
229
+ // own UI renders; a Backstage renderer shows document text as
230
+ // plain text, so the markers arrived on screen as literal
231
+ // `<em>`. Current SWIRL keeps these fields clean, older ones do
232
+ // not, and the engine has to be safe against both.
233
+ title: this.stripMarkers(entry.title),
234
+ text: this.stripMarkers(entry.body),
235
+ location: entry.url ?? "",
236
+ source: entry.searchprovider ?? "",
237
+ // Federated results are not in any Backstage index, so SWIRL's
238
+ // score is the only ranking signal a renderer can show. Indexed
239
+ // documents are handed back exactly as Backstage collated them.
240
+ score: types.swirlResultScore(entry)
241
+ },
242
+ rank,
243
+ highlight: this.toHighlight(entry)
244
+ };
245
+ }
246
+ toHighlight(entry) {
247
+ if (!this.options.highlight.enabled) {
248
+ return { preTag: this.preTag, postTag: this.postTag, fields: {} };
249
+ }
250
+ const fields = {};
251
+ const title = this.rewriteHighlight(entry.title_hit_highlights);
252
+ const text = this.rewriteHighlight(entry.body_hit_highlights);
253
+ if (title) {
254
+ fields.title = title;
255
+ }
256
+ if (text) {
257
+ fields.text = text;
258
+ }
259
+ return { preTag: this.preTag, postTag: this.postTag, fields };
260
+ }
261
+ /** Removes the configured marker pair, leaving the text it wrapped. */
262
+ stripMarkers(value) {
263
+ if (!value) {
264
+ return "";
265
+ }
266
+ const { startMarker, endMarker } = this.options.highlight;
267
+ return value.split(startMarker).join("").split(endMarker).join("");
268
+ }
269
+ /**
270
+ * SWIRL wraps hits in a configurable marker pair, `<em>` and `</em>` out of
271
+ * the box. Backstage expects the engine's own per-instance tags instead, so
272
+ * that a document body containing the marker cannot forge a highlight.
273
+ *
274
+ * The `maxChars` budget counts visible characters, not tags, and the walk
275
+ * never emits an unbalanced tag: a snippet cut short inside a hit closes it.
276
+ */
277
+ rewriteHighlight(highlights) {
278
+ const raw = (highlights ?? []).find((value) => Boolean(value));
279
+ if (!raw) {
280
+ return void 0;
281
+ }
282
+ const { startMarker, endMarker, maxChars } = this.options.highlight;
283
+ const pattern = new RegExp(
284
+ `${escapeRegExp(startMarker)}([\\s\\S]*?)${escapeRegExp(endMarker)}`,
285
+ "g"
286
+ );
287
+ let out = "";
288
+ let budget = maxChars;
289
+ let cursor = 0;
290
+ const take = (value, hit) => {
291
+ if (budget <= 0 || !value) {
292
+ return;
293
+ }
294
+ const kept = value.slice(0, budget);
295
+ budget -= kept.length;
296
+ out += hit ? `${this.preTag}${kept}${this.postTag}` : kept;
297
+ };
298
+ for (const match of raw.matchAll(pattern)) {
299
+ const at = match.index ?? 0;
300
+ take(raw.slice(cursor, at), false);
301
+ take(match[1], true);
302
+ cursor = at + match[0].length;
303
+ }
304
+ take(raw.slice(cursor), false);
305
+ return out;
306
+ }
307
+ }
308
+ function escapeRegExp(value) {
309
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
310
+ }
311
+ function rejectedTuningKeys(body) {
312
+ const detail = typeof body?.detail === "string" ? body.detail : "";
313
+ const match = detail.match(/unknown tuning key\(s\):\s*(.*)/i);
314
+ if (!match) {
315
+ return [];
316
+ }
317
+ return match[1].split(/\.\s*Known keys/i)[0].replace(/\.\s*$/, "").split(",").map((key) => key.trim()).filter(Boolean);
318
+ }
319
+ function describeTuningError(body) {
320
+ const detail = typeof body?.detail === "string" ? body.detail : "";
321
+ return detail ? ` ${detail}` : "";
322
+ }
323
+ function missingIndexError(types$1) {
324
+ const named = Array.isArray(types$1) && types$1.length ? types$1.join(", ") : void 0;
325
+ const error = new Error(
326
+ 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
+ );
328
+ error.name = types.MISSING_INDEX_ERROR_NAME;
329
+ return error;
330
+ }
331
+ function decodePageCursor(pageCursor) {
332
+ if (!pageCursor) {
333
+ return void 0;
334
+ }
335
+ const decoded = JSON.parse(
336
+ Buffer.from(pageCursor, "base64").toString("utf-8")
337
+ );
338
+ if (decoded === null || typeof decoded !== "object" || decoded.s === void 0 || typeof decoded.p !== "number" || decoded.p < 0) {
339
+ throw new Error("Invalid page cursor");
340
+ }
341
+ return { s: decoded.s, p: decoded.p };
342
+ }
343
+ function encodePageCursor(cursor) {
344
+ return Buffer.from(JSON.stringify(cursor), "utf-8").toString("base64");
345
+ }
346
+ function readSwirlConfig(config) {
347
+ const swirl = config.getConfig("search.swirl");
348
+ const federated = swirl.getOptionalConfig("federated");
349
+ const highlight = swirl.getOptionalConfig("highlight");
350
+ const tuning = swirl.getOptionalConfig("tuning");
351
+ return {
352
+ baseUrl: swirl.getString("baseUrl"),
353
+ audience: swirl.getOptionalString("audience") ?? "search",
354
+ indexerBatchSize: swirl.getOptionalNumber("indexerBatchSize") ?? 500,
355
+ queryTimeoutMs: swirl.getOptionalNumber("queryTimeoutMs") ?? 8e3,
356
+ federated: {
357
+ enabled: federated?.getOptionalBoolean("enabled") ?? true,
358
+ providerTags: federated?.getOptionalStringArray("providerTags") ?? [
359
+ "backstage"
360
+ ],
361
+ timeoutMs: federated?.getOptionalNumber("timeoutMs") ?? 5e3
362
+ },
363
+ tuning: tuning?.get() ?? {},
364
+ highlight: {
365
+ enabled: highlight?.getOptionalBoolean("enabled") ?? true,
366
+ maxChars: highlight?.getOptionalNumber("maxChars") ?? 200,
367
+ startMarker: highlight?.getOptionalString("startMarker") ?? types.SWIRL_HIGHLIGHT_START_MARKER,
368
+ endMarker: highlight?.getOptionalString("endMarker") ?? types.SWIRL_HIGHLIGHT_END_MARKER
369
+ }
370
+ };
371
+ }
372
+
373
+ exports.SwirlSearchEngine = SwirlSearchEngine;
374
+ exports.decodePageCursor = decodePageCursor;
375
+ exports.encodePageCursor = encodePageCursor;
376
+ exports.readSwirlConfig = readSwirlConfig;
377
+ //# sourceMappingURL=SwirlSearchEngine.cjs.js.map