@memberjunction/web-search-engine 0.0.1-seed.1 → 6.2.0-edge.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +183 -0
  2. package/README.md +343 -3
  3. package/dist/BaseWebSearchProvider.d.ts +89 -0
  4. package/dist/BaseWebSearchProvider.d.ts.map +1 -0
  5. package/dist/BaseWebSearchProvider.js +130 -0
  6. package/dist/BaseWebSearchProvider.js.map +1 -0
  7. package/dist/WebSearchEngine.d.ts +107 -0
  8. package/dist/WebSearchEngine.d.ts.map +1 -0
  9. package/dist/WebSearchEngine.js +396 -0
  10. package/dist/WebSearchEngine.js.map +1 -0
  11. package/dist/index.d.ts +28 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +43 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/operations/WebSearchQueryOperation.d.ts +8 -0
  16. package/dist/operations/WebSearchQueryOperation.d.ts.map +1 -0
  17. package/dist/operations/WebSearchQueryOperation.js +72 -0
  18. package/dist/operations/WebSearchQueryOperation.js.map +1 -0
  19. package/dist/providers/BraveWebSearchProvider.d.ts +41 -0
  20. package/dist/providers/BraveWebSearchProvider.d.ts.map +1 -0
  21. package/dist/providers/BraveWebSearchProvider.js +169 -0
  22. package/dist/providers/BraveWebSearchProvider.js.map +1 -0
  23. package/dist/providers/DuckDuckGoWebSearchProvider.d.ts +45 -0
  24. package/dist/providers/DuckDuckGoWebSearchProvider.d.ts.map +1 -0
  25. package/dist/providers/DuckDuckGoWebSearchProvider.js +116 -0
  26. package/dist/providers/DuckDuckGoWebSearchProvider.js.map +1 -0
  27. package/dist/providers/GoogleCustomSearchWebSearchProvider.d.ts +36 -0
  28. package/dist/providers/GoogleCustomSearchWebSearchProvider.d.ts.map +1 -0
  29. package/dist/providers/GoogleCustomSearchWebSearchProvider.js +125 -0
  30. package/dist/providers/GoogleCustomSearchWebSearchProvider.js.map +1 -0
  31. package/dist/providers/PerplexityWebSearchProvider.d.ts +40 -0
  32. package/dist/providers/PerplexityWebSearchProvider.d.ts.map +1 -0
  33. package/dist/providers/PerplexityWebSearchProvider.js +148 -0
  34. package/dist/providers/PerplexityWebSearchProvider.js.map +1 -0
  35. package/dist/providers/TavilyWebSearchProvider.d.ts +30 -0
  36. package/dist/providers/TavilyWebSearchProvider.d.ts.map +1 -0
  37. package/dist/providers/TavilyWebSearchProvider.js +126 -0
  38. package/dist/providers/TavilyWebSearchProvider.js.map +1 -0
  39. package/dist/providers/httpFailure.d.ts +29 -0
  40. package/dist/providers/httpFailure.d.ts.map +1 -0
  41. package/dist/providers/httpFailure.js +136 -0
  42. package/dist/providers/httpFailure.js.map +1 -0
  43. package/dist/types.d.ts +214 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +17 -0
  46. package/dist/types.js.map +1 -0
  47. package/package.json +28 -5
package/LICENSE ADDED
@@ -0,0 +1,183 @@
1
+ Business Source License 1.1
2
+
3
+ License text copyright (c) 2024 MariaDB plc, All Rights Reserved.
4
+ "Business Source License" is a trademark of MariaDB plc.
5
+
6
+ -----------------------------------------------------------------------------
7
+
8
+ Parameters
9
+
10
+ Licensor: Blue Cypress, Inc.
11
+
12
+ Licensed Work: MemberJunction.
13
+ The Licensed Work is (c) 2023-2026 Blue Cypress, Inc.
14
+
15
+ Additional Use Grant: Subject to the terms of this License, Licensor grants
16
+ you the following additional rights to make Production
17
+ Use of the Licensed Work.
18
+
19
+ 1. Internal Use
20
+
21
+ You may make production use of the Licensed Work for
22
+ your own internal business or organizational operations.
23
+
24
+ 2. Nonprofit Use
25
+
26
+ If you are a Nonprofit, you may make production use of
27
+ the Licensed Work for the operations and activities of
28
+ your Organizational Family.
29
+
30
+ 3. MemberJunction Certified Program Use
31
+
32
+ If you are authorized by Licensor under the
33
+ MemberJunction Certified Program to provide professional
34
+ services using the Licensed Work, you may make
35
+ production use of the Licensed Work in providing such
36
+ professional services to a client, provided that:
37
+
38
+ (a) the Licensed Work is deployed in, and the applicable
39
+ production use occurs within, an environment owned,
40
+ leased, licensed, subscribed to, or otherwise controlled
41
+ by that client; and
42
+
43
+ (b) the production use is for that client's own internal
44
+ business or organizational operations or is otherwise
45
+ independently permitted to that client under this
46
+ Additional Use Grant.
47
+
48
+ 4. Definitions Applicable to the Additional Use Grant
49
+
50
+ "Affiliate" means, with respect to a specified Person,
51
+ any other Person that directly or indirectly Controls,
52
+ is Controlled by, or is under common Control with such
53
+ specified Person.
54
+
55
+ "Control" (including the terms "Controls," "Controlled
56
+ by," and "under common Control with") means the direct
57
+ or indirect possession of the power to direct or cause
58
+ the direction of the management and policies of a
59
+ Person, whether through ownership of voting interests,
60
+ by contract, or otherwise.
61
+
62
+ "Organizational Family" means, with respect to a Person,
63
+ (a) such Person and its Affiliates, and (b) any
64
+ nonprofit organization, governmental entity, chapter,
65
+ division, local affiliate, regional affiliate, state
66
+ affiliate, national affiliate, or other entity that is
67
+ formally affiliated with such Person through governing
68
+ documents, a charter, bylaws, a membership agreement, or
69
+ another written organizational instrument, and is
70
+ recognized under such documents as part of the same
71
+ organizational structure.
72
+
73
+ "Nonprofit" means a Person recognized by the Internal
74
+ Revenue Service as exempt from federal income taxation
75
+ under Section 501(c)(3), 501(c)(4), 501(c)(5), or
76
+ 501(c)(6) of the Internal Revenue Code, or a foreign
77
+ organization recognized under substantially equivalent
78
+ laws.
79
+
80
+ A Person claiming eligibility as a Nonprofit shall, upon
81
+ Licensor's reasonable request, provide documentation
82
+ reasonably sufficient to demonstrate that it qualifies
83
+ as a Nonprofit. If such Person materially misrepresents,
84
+ or is unable to demonstrate, its qualification as a
85
+ Nonprofit, the rights granted to such Person under
86
+ Section 2 of this Additional Use Grant shall terminate.
87
+
88
+ "MemberJunction Certified Program" means Licensor's
89
+ then-current program for certifying and authorizing a
90
+ Person to provide professional services using the
91
+ Licensed Work.
92
+
93
+ "Person" means any individual, corporation, limited
94
+ liability company, partnership, association, nonprofit
95
+ organization, governmental entity, or other legal or
96
+ organizational entity.
97
+
98
+ Change Date: Four (4) years from the date the Licensed Work is first
99
+ made available.
100
+
101
+ Change License: MIT License.
102
+
103
+ For information about alternative licensing arrangements for the Licensed
104
+ Work, please contact Blue Cypress, Inc.
105
+
106
+ -----------------------------------------------------------------------------
107
+
108
+ Terms
109
+
110
+ The Licensor hereby grants you the right to copy, modify, create derivative
111
+ works, redistribute, and make non-production use of the Licensed Work. The
112
+ Licensor may make an Additional Use Grant, above, permitting limited
113
+ production use.
114
+
115
+ Effective on the Change Date, or the fourth anniversary of the first publicly
116
+ available distribution of a specific version of the Licensed Work under this
117
+ License, whichever comes first, the Licensor hereby grants you rights under
118
+ the terms of the Change License, and the rights granted in the paragraph
119
+ above terminate.
120
+
121
+ If your use of the Licensed Work does not comply with the requirements
122
+ currently in effect as described in this License, you must purchase a
123
+ commercial license from the Licensor, its affiliated entities, or authorized
124
+ resellers, or you must refrain from using the Licensed Work.
125
+
126
+ All copies of the original and modified Licensed Work, and derivative works
127
+ of the Licensed Work, are subject to this License. This License applies
128
+ separately for each version of the Licensed Work and the Change Date may vary
129
+ for each version of the Licensed Work released by Licensor.
130
+
131
+ You must conspicuously display this License on each original or modified copy
132
+ of the Licensed Work. If you receive the Licensed Work in original or
133
+ modified form from a third party, the terms and conditions set forth in this
134
+ License apply to your use of that work.
135
+
136
+ Any use of the Licensed Work in violation of this License will automatically
137
+ terminate your rights under this License for the current and all other
138
+ versions of the Licensed Work.
139
+
140
+ This License does not grant you any right in any trademark or logo of
141
+ Licensor or its affiliates (provided that you may use a trademark or logo of
142
+ Licensor as expressly required by this License).
143
+
144
+ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
145
+ AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
146
+ EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
147
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
148
+ TITLE.
149
+
150
+ MariaDB hereby grants you permission to use this License's text to license
151
+ your works, and to refer to it using the trademark "Business Source License",
152
+ as long as you comply with the Covenants of Licensor below.
153
+
154
+ -----------------------------------------------------------------------------
155
+
156
+ Covenants of Licensor
157
+
158
+ In consideration of the right to use this License's text and the "Business
159
+ Source License" name and trademark, Licensor covenants to MariaDB, and to all
160
+ other recipients of the licensed work to be provided by Licensor:
161
+
162
+ 1. To specify as the Change License the GPL Version 2.0 or any later version,
163
+ or a license that is compatible with GPL Version 2.0 or a later version,
164
+ where "compatible" means that software provided under the Change License
165
+ can be included in a program with software provided under GPL Version 2.0
166
+ or a later version. Licensor may specify additional Change Licenses without
167
+ limitation.
168
+
169
+ 2. To either: (a) specify an additional grant of rights to use that does not
170
+ impose any additional restriction on the right granted in this License, as
171
+ the Additional Use Grant; or (b) insert the text "None".
172
+
173
+ 3. To specify a Change Date.
174
+
175
+ 4. Not to modify this License in any other way.
176
+
177
+ -----------------------------------------------------------------------------
178
+
179
+ Notice
180
+
181
+ The Business Source License (this document, or the "License") is not an Open
182
+ Source license. However, the Licensed Work will eventually be made available
183
+ under an Open Source License, as stated in this License.
package/README.md CHANGED
@@ -1,4 +1,344 @@
1
1
  # @memberjunction/web-search-engine
2
- Seed release published over OIDC to prove trusted publishing is configured for
3
- this package. It contains no code and is tagged `seed`, never `latest`.
4
- Real releases are published by .github/workflows/publish.yml.
2
+
3
+ Metadata-driven **web search** for MemberJunction: a set of external search vendors behind one
4
+ interface, with administrator-controlled priority and automatic failover.
5
+
6
+ The administrator picks the vendor. The agent never does.
7
+
8
+ ```typescript
9
+ import { WebSearchEngine } from '@memberjunction/web-search-engine';
10
+
11
+ await WebSearchEngine.Instance.Config(false, contextUser);
12
+ const result = await WebSearchEngine.Instance.Search({ Query: 'association management trends' }, contextUser);
13
+
14
+ if (result.Success) {
15
+ for (const hit of result.Hits) console.log(hit.Title, hit.URL);
16
+ console.log('served by', result.ProviderUsed);
17
+ }
18
+ ```
19
+
20
+ ---
21
+
22
+ ## Why this exists
23
+
24
+ Google discontinues the Custom Search JSON API on **2027-01-01** and has already closed it to new
25
+ customers. Microsoft retired the Bing Search APIs on **2025-08-11**. Both replaced a commodity SERP
26
+ API with an answer-shaped grounding product locked to their own agent stack. Selling raw ranked web
27
+ results to developers is a business both companies have chosen to exit.
28
+
29
+ The lesson is not "pick a different vendor". It is that **any single web-search dependency has an
30
+ expiry date**, so the architecture has to make the next shutdown a configuration change.
31
+
32
+ Before this package, MJ had four independent web-search Actions — Google, Tavily, Perplexity,
33
+ DuckDuckGo — with three incompatible output contracts (`Items`, `Results`, `Content`). Nothing could
34
+ transparently fall back onto a different shape, so "Perplexity as a backup" was never actually a
35
+ backup. Swapping vendors meant editing every agent that named one.
36
+
37
+ Now it is a row in a table.
38
+
39
+ ---
40
+
41
+ ## How it differs from `@memberjunction/search-engine`
42
+
43
+ They sound alike and are not interchangeable. **`search-engine` searches content MJ owns**;
44
+ this package searches the open web.
45
+
46
+ | | `search-engine` | `web-search-engine` |
47
+ |---|---|---|
48
+ | Searches | entity records, vectors, full-text, storage files | the public web, via external vendors |
49
+ | Result identity | `EntityName` + `RecordID` — a row you can open | a URL |
50
+ | Result type | `SearchResultItem` | `WebSearchHit` |
51
+ | Multiple sources | queried **together**, fused with RRF | tried **in order**, first success wins |
52
+ | Cost per query | a database query | real money at a vendor |
53
+ | Failure mode | a slow query | a rate limit, an expired key, a discontinued API |
54
+ | Provider table | `__mj.SearchProvider` | `__mj.WebSearchProvider` |
55
+
56
+ The decisive detail is the result contract: `SearchResultItem` **requires** `ID`, `EntityName` and
57
+ `RecordID` — the primary key of a source record — and the pipeline ends in `SearchEnricher`, which
58
+ resolves entity icons and record display names by entity lookup. A web hit has none of those.
59
+ Fitting one in would mean lying in the type and dragging it through enrichment designed to decorate
60
+ entity records.
61
+
62
+ What the two **do** share is the plugin pattern, and this package reuses all of it: a provider table
63
+ carrying `DriverClass` / `Priority` / `Status` / `CredentialID`, ClassFactory resolution, and the
64
+ `Initialize()` → `CheckAvailability()` → `IsAvailable()` lifecycle.
65
+
66
+ > **Blending the two** — web results and internal records in one ranked list — is reachable through
67
+ > the existing RRF fusion, but it needs a result type that does not require `EntityName`. That is a
68
+ > deliberate future step, not a side effect of this package.
69
+
70
+ ---
71
+
72
+ ## Three ways in
73
+
74
+ Pick by who is calling.
75
+
76
+ | Caller | Use | Why |
77
+ |---|---|---|
78
+ | Server-side TypeScript | `WebSearchEngine.Instance.Search()` | Direct, typed, no indirection |
79
+ | Browser / Angular | the `WebSearch.Query` Remote Operation | Keys never leave the server; typed end to end |
80
+ | An AI agent | the `Web Search` **Action** | Discoverable from metadata, one tool instead of four |
81
+
82
+ All three land in the same engine and obey the same provider configuration.
83
+
84
+ ---
85
+
86
+ ## Who uses this
87
+
88
+ **No agent or skill binds a search vendor directly.** Every agent and skill that does web lookups — including the `Web Research` skill — binds the provider-neutral `Web Search` Action, and leaves `Provider` unset so the engine can fail over.
89
+
90
+ `Google Custom Search` and `Perplexity Search` remain registered for direct or manual execution, but carry **no agent or skill bindings**. Anything new binds `Web Search`, never a vendor.
91
+
92
+ Verify at any time:
93
+ ```bash
94
+ grep -rn "Google Custom Search\|Perplexity Search" metadata/agents/ metadata/ai-skills/
95
+ # no output = no vendor bindings remain
96
+ ```
97
+
98
+ ---
99
+
100
+ ## Selection: how a provider is chosen
101
+
102
+ ### Without an explicit provider
103
+
104
+ 1. Filter to providers that are `Active`, report themselves available, and can do what was asked
105
+ 2. Order by `Priority` **ascending — lower wins**
106
+ 3. Try each in turn until one succeeds
107
+
108
+ Failover is **conditional**, and the condition is the part that matters:
109
+
110
+ | Failure | Engine does | Why |
111
+ |---|---|---|
112
+ | `transient` — rate limit, 5xx, timeout, bad key | try the next provider | another vendor may well succeed |
113
+ | `permanent` — the request itself is rejected | **stop** | every other vendor will reject it too; retrying costs four more paid calls and buries the real error behind four identical ones |
114
+
115
+ **Zero hits is a success.** A narrow query legitimately matches nothing. Treating that as failure
116
+ makes the engine retry a query that will keep returning nothing, at four vendors' expense.
117
+
118
+ ### With an explicit provider
119
+
120
+ `Provider` pins the search to one vendor, by `Name` or `DriverClass`, case-insensitively. There is
121
+ **no failover** — the call fails, with a code that says exactly why:
122
+
123
+ | Code | Meaning | Fix |
124
+ |---|---|---|
125
+ | `PROVIDER_NOT_FOUND` | no such provider in metadata | add the row |
126
+ | `PROVIDER_NOT_ACTIVE` | the row exists, `Status` is not `Active` | flip the status |
127
+ | `PROVIDER_UNAVAILABLE` | Active, but not operational — usually no credential | set the key |
128
+ | `PROVIDER_LACKS_CAPABILITY` | e.g. `IncludeAnswer` on a hits-only provider | ask a different provider, or drop the flag |
129
+
130
+ Those are four different problems with four different fixes, which is why they are four codes and
131
+ not one. And the reason a pinned provider never silently falls back: a substitution is invisible.
132
+ Results keep arriving, from a vendor nobody chose, until something eventually surfaces it — usually
133
+ an invoice.
134
+
135
+ ### `Attempts` — read this one
136
+
137
+ Every provider tried is recorded in `result.Attempts`, **including on the success path**.
138
+
139
+ If the primary provider rate-limits every call and the secondary quietly serves everything, the
140
+ system looks completely healthy. Nothing else makes that visible while the spend moves to a vendor
141
+ nobody selected.
142
+
143
+ ```typescript
144
+ const result = await WebSearchEngine.Instance.Search({ Query: 'q' }, user);
145
+ // result.ProviderUsed === 'Tavily'
146
+ // result.Attempts === [
147
+ // { ProviderName: 'Brave', Succeeded: false, FailureKind: 'transient', ErrorMessage: '429 …' },
148
+ // { ProviderName: 'Tavily', Succeeded: true, HitCount: 10 },
149
+ // ]
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Configuring providers
155
+
156
+ Rows live in `__mj.WebSearchProvider`, seeded from `metadata/web-search-providers/`.
157
+
158
+ | Column | Purpose |
159
+ |---|---|
160
+ | `Name` | Admin-facing name, and what `Provider` matches. Unique |
161
+ | `DriverClass` | ClassFactory key — `@RegisterClass(BaseWebSearchProvider, …)` |
162
+ | `Status` | `Pending` / `Active` / `Terminated`. Only `Active` loads |
163
+ | `Priority` | **Lower is tried first** |
164
+ | `CredentialID` | FK to `Credential`; falls back to an env var when null |
165
+ | `ProviderConfig` | JSON of non-secret driver settings |
166
+ | `MaxResultsOverride` | Per-provider cap for pay-per-query vendors |
167
+ | `AllowResultCaching` | Defaults to **0**. See *Caching rights*, below |
168
+ | `DisplayName` / `Icon` / `Comments` | Admin UI and notes |
169
+
170
+ **Capabilities are not columns.** Whether a driver can synthesize an answer or honour a domain
171
+ filter is a property of the *implementation*, declared on the class — exactly as `search-engine`
172
+ declares `SourceType`. The table holds configuration: on/off, order, credentials, limits.
173
+
174
+ To change the primary vendor, edit one row's `Priority` and run `mj sync push`. No code change, no
175
+ agent change, no redeploy.
176
+
177
+ ### Bundled drivers
178
+
179
+ | Driver | Default priority | Answer | Notes |
180
+ |---|---|---|---|
181
+ | `BraveWebSearchProvider` | 10 | no | **Own index** — not a Google or Bing reseller, so it fails independently of everything else here. Default primary |
182
+ | `TavilyWebSearchProvider` | 20 | yes | Returns extracted page content rather than snippets plus links. Aggregator |
183
+ | `PerplexityWebSearchProvider` | 30 | yes | Hits from the raw `/search` endpoint; Sonar only when an answer is asked for |
184
+ | `GoogleCustomSearchWebSearchProvider` | 40 | no | **Discontinued 2027-01-01.** Present so existing keys keep working through the transition |
185
+ | `DuckDuckGoWebSearchProvider` | 90 | no | Keyless last resort, Instant Answer API only. Answers a minority of queries — treat reliance on it as *unconfigured*. No HTML fallback, [by design](#why-duckduckgo-has-no-html-fallback) |
186
+
187
+ ### Why DuckDuckGo has no HTML fallback
188
+
189
+ An earlier revision fell back to fetching and regex-parsing DuckDuckGo's HTML results page, which
190
+ answered far more queries than the Instant Answer API does. It was removed deliberately.
191
+
192
+ Running regular expressions over remote HTML is running them over **attacker-influenceable input**,
193
+ and CodeQL flagged the parser with two high-severity findings: polynomial backtracking in the result
194
+ patterns (the `[^"]*…[^"]*` shape) and incomplete sanitization in its tag-stripping.
195
+
196
+ Hardening those patterns would have *narrowed* the surface; deleting the parser *removes* it. For a
197
+ fallback inside a last-resort provider this engine already tells operators not to rely on, the
198
+ capability was not worth the exposure — and "the regex looks safe now" is exactly the kind of claim
199
+ that is true when written and wrong later.
200
+
201
+ **If you need real web coverage, configure a provider with an API.** That is what the rest of this
202
+ package is for.
203
+
204
+ ### Credentials
205
+
206
+ Each driver reads its key from the linked `Credential` record first, then falls back to an
207
+ environment variable:
208
+
209
+ | Driver | Credential key(s) | Environment fallback |
210
+ |---|---|---|
211
+ | Brave | `apiKey` | `BRAVE_SEARCH_API_KEY` |
212
+ | Tavily | `apiKey` | `TAVILY_API_KEY` |
213
+ | Perplexity | `apiKey` | `PERPLEXITY_API_KEY` |
214
+ | Google Custom Search | `apiKey`, `cx` | `GOOGLE_CUSTOM_SEARCH_API_KEY`, `GOOGLE_CUSTOM_SEARCH_CX` |
215
+ | DuckDuckGo | — | — |
216
+
217
+ The fallback exists so a host upgrading from the per-vendor Actions does not have to migrate its
218
+ secrets into the Credential store on the same day it gains the engine.
219
+
220
+ A driver with no key **self-disables** rather than throwing. An unconfigured vendor is a normal
221
+ state in a product shipped to many hosts, not an error — the engine simply routes around it.
222
+
223
+ ### ⚠️ Caching rights
224
+
225
+ `AllowResultCaching` defaults to `0`, and that default is deliberate.
226
+
227
+ Caching rights differ sharply between vendors: some sell result storage as a distinct plan tier,
228
+ others forbid persistent caching outright, and at least one major grounding product's terms prohibit
229
+ caching, syndicating or analysing results at all. Violating any of it is **silent** — nothing fails,
230
+ you are simply in breach.
231
+
232
+ Nothing in this engine caches today. The column exists so that the first caching layer reads a
233
+ per-provider gate instead of inventing one, and so the contractual answer lives next to the
234
+ credential it applies to. **Confirm your plan's terms before setting it to 1.**
235
+
236
+ ---
237
+
238
+ ## Writing a driver
239
+
240
+ ```typescript
241
+ import { RegisterClass } from '@memberjunction/global';
242
+ import { BaseWebSearchProvider, WebSearchCapabilities } from '@memberjunction/web-search-engine';
243
+
244
+ @RegisterClass(BaseWebSearchProvider, 'AcmeWebSearchProvider')
245
+ export class AcmeWebSearchProvider extends BaseWebSearchProvider {
246
+ public readonly Capabilities: WebSearchCapabilities = {
247
+ Answer: false, DomainFilter: true, Freshness: true, Region: true, MaxResultsCap: 25,
248
+ };
249
+
250
+ private apiKey: string | undefined;
251
+
252
+ public async CheckAvailability(): Promise<void> {
253
+ this.apiKey = this.GetSecret('apiKey', 'ACME_API_KEY');
254
+ this.apiKey ? this.MarkAvailable() : this.MarkUnavailable('No Acme API key.');
255
+ }
256
+
257
+ public async ExecuteSearch(params: WebSearchParams): Promise<WebSearchProviderResponse> {
258
+ try {
259
+ const response = await HttpGet<AcmeResponse>(ENDPOINT, { /* … */ });
260
+ return { Success: true, Hits: (response.Data?.results ?? []).map(toHit) };
261
+ } catch (e) {
262
+ return classifyHttpFailure(e, 'Acme'); // sets FailureKind for you
263
+ }
264
+ }
265
+ }
266
+ ```
267
+
268
+ Four rules, each of which has a failure mode if ignored:
269
+
270
+ 1. **Declare capabilities honestly.** Claiming `DomainFilter: true` and then ignoring the filter
271
+ returns confident results for a different question than the one asked.
272
+ 2. **Classify failures correctly.** `classifyHttpFailure` handles the common cases; the distinction
273
+ that matters is 400/422 (`permanent`) versus everything else (`transient`).
274
+ 3. **Self-disable, don't throw**, when the driver is simply not configured on this host.
275
+ 4. **Export a `Load…()` anchor** and call it from the package index. Bundlers drop a module whose
276
+ exports are unused, and a dropped module never runs its decorator — so the driver silently
277
+ vanishes from `ClassFactory` and the engine reports "no registered driver" for a `DriverClass`
278
+ that plainly exists in the source.
279
+
280
+ ---
281
+
282
+ ## Result codes
283
+
284
+ | Code | `Success` | Meaning |
285
+ |---|:---:|---|
286
+ | `SUCCESS` | ✅ | Served. `Hits` may legitimately be empty |
287
+ | `MISSING_QUERY` | ❌ | `Query` was blank |
288
+ | `NO_PROVIDERS_CONFIGURED` | ❌ | No provider rows at all — a setup problem, not a search failure |
289
+ | `PROVIDER_LOAD_FAILED` | ❌ | The provider list could not be **read** — query failed, entity missing from metadata, or no permission. Distinct from the row above on purpose: a failed read also leaves the list empty, and telling an operator to "add a record" when the read never succeeded sends them to fix configuration that is fine |
290
+ | `NO_ELIGIBLE_PROVIDER` | ❌ | Providers exist, none is available and capable of this request |
291
+ | `PROVIDER_NOT_FOUND` / `_NOT_ACTIVE` / `_UNAVAILABLE` / `_LACKS_CAPABILITY` | ❌ | An explicit `Provider` could not serve — see the table above |
292
+ | `ALL_PROVIDERS_FAILED` | ❌ | Every eligible provider was tried; see `Attempts` |
293
+ | `INVALID_REQUEST` | ❌ | The request itself was rejected; retrying elsewhere cannot help |
294
+
295
+ `Search()` **never throws** for a search-level failure — branch on `Success`, as with `RunView`.
296
+ Exceptions escaping a driver are caught, recorded as a transient attempt, and failed over.
297
+
298
+ ---
299
+
300
+ ## Setup after the migration
301
+
302
+ The migration, its CodeGen tail and the Remote Operation's server half are all committed, so a
303
+ local database only needs the schema applied and the metadata seeded:
304
+
305
+ ```bash
306
+ pnpm run mj:migrate # creates __mj.WebSearchProvider
307
+ pnpm mj sync push --dir metadata # seeds the provider rows, the Action, the scopes
308
+ pnpm install && pnpm run build
309
+ ```
310
+
311
+ **Migrate before pushing metadata.** `metadata/web-search-providers/` writes into
312
+ `__mj.WebSearchProvider`, so the table has to exist first. (A different ordering constraint —
313
+ metadata before `mj codegen`, because remote operations generate from rows rather than schema —
314
+ applies when regenerating, not when applying what is already committed.)
315
+
316
+ Verify before committing:
317
+
318
+ ```bash
319
+ node .github/scripts/check-migration-entityfield-sequence.mjs # apply-time MAX(Sequence)+1, never a literal
320
+ npm run check:codegen-tail # generated entity/resolvers/form are committed
321
+ ```
322
+
323
+ The PostgreSQL counterpart is **not** authored per-PR — conversion is build-engineer work at release
324
+ time.
325
+
326
+ ---
327
+
328
+ ## Testing
329
+
330
+ ```bash
331
+ cd packages/WebSearchEngine && pnpm test
332
+ ```
333
+
334
+ The selection tests are the ones that matter. Each pins a property with a way of passing while
335
+ broken: conditional failover, an explicit provider never being substituted, and `Attempts` being
336
+ recorded on the **success** path.
337
+
338
+ ---
339
+
340
+ ## Related
341
+
342
+ - [`guides/SEARCH_OVERVIEW_GUIDE.md`](../../guides/SEARCH_OVERVIEW_GUIDE.md) — which search API to reach for
343
+ - [`@memberjunction/search-engine`](../SearchEngine) — internal search over content MJ owns
344
+ - [`packages/Actions/CLAUDE.md`](../Actions/CLAUDE.md) — why the Action is a thin wrapper
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @fileoverview Base class for web search drivers.
3
+ *
4
+ * Each driver wraps one external search vendor and normalises its response into
5
+ * {@link WebSearchHit}. Drivers are discovered at runtime via `@RegisterClass` and the
6
+ * `MJ: Web Search Providers` metadata entity, whose `DriverClass` column is the registration key.
7
+ *
8
+ * @module @memberjunction/web-search-engine
9
+ */
10
+ import { UserInfo } from '@memberjunction/core';
11
+ import { RegisteredWebSearchProviderInfo, WebSearchCapabilities, WebSearchParams, WebSearchProviderConfig, WebSearchProviderResponse } from './types.js';
12
+ /**
13
+ * Abstract base for a web search driver.
14
+ *
15
+ * Subclasses register with `@RegisterClass(BaseWebSearchProvider, 'DriverClassName')` to become
16
+ * discoverable by {@link WebSearchEngine}.
17
+ *
18
+ * Lifecycle, per provider record, at engine config time:
19
+ * 1. `WebSearchEngine` loads Active `MJ: Web Search Providers` records
20
+ * 2. ClassFactory creates an instance from `DriverClass`
21
+ * 3. {@link Initialize} receives the record's configuration and any decrypted credential values
22
+ * 4. {@link CheckAvailability} verifies the driver is actually operational
23
+ * 5. If {@link IsAvailable} is true, the driver joins the selection pool
24
+ *
25
+ * A driver that cannot work — no API key, missing optional dependency — must **self-disable in
26
+ * `CheckAvailability` rather than throw**. An unconfigured vendor is a normal state in a product
27
+ * shipped to many hosts, not an error, and the engine simply routes around it.
28
+ */
29
+ export declare abstract class BaseWebSearchProvider {
30
+ /** What this driver can do. Declared by the class, never read from the database. */
31
+ abstract readonly Capabilities: WebSearchCapabilities;
32
+ /** Configuration from the metadata record, set during {@link Initialize}. */
33
+ protected config: WebSearchProviderConfig | null;
34
+ private _available;
35
+ private _unavailableReason;
36
+ /**
37
+ * Receive configuration from the provider's metadata record.
38
+ *
39
+ * The default stores it; override to do additional setup, and call `super.Initialize(...)`.
40
+ */
41
+ Initialize(config: WebSearchProviderConfig, _contextUser: UserInfo): Promise<void>;
42
+ /**
43
+ * Determine whether this driver can serve requests, calling {@link MarkAvailable} or
44
+ * {@link MarkUnavailable} exactly once.
45
+ *
46
+ * Must not throw for the ordinary "not configured here" case — see the class note.
47
+ */
48
+ abstract CheckAvailability(contextUser: UserInfo): Promise<void>;
49
+ /** Whether this driver may participate in searches. False until `CheckAvailability` says otherwise. */
50
+ IsAvailable(): boolean;
51
+ /** Why this driver is unavailable, for diagnostics. Null when it is available. */
52
+ get UnavailableReason(): string | null;
53
+ /** Execute a search. Called only when {@link IsAvailable} is true. */
54
+ abstract ExecuteSearch(params: WebSearchParams, contextUser: UserInfo): Promise<WebSearchProviderResponse>;
55
+ /** Mark this driver operational. Call from {@link CheckAvailability}. */
56
+ protected MarkAvailable(): void;
57
+ /** Mark this driver unusable, with a reason an administrator can act on. */
58
+ protected MarkUnavailable(reason: string): void;
59
+ /**
60
+ * Read a credential value, preferring the decrypted `MJ: Credentials` record and falling back
61
+ * to an environment variable.
62
+ *
63
+ * The fallback exists because the web search actions this engine supersedes read their keys
64
+ * from `mj.config.cjs` / the environment, and a host upgrading should not have to migrate its
65
+ * secrets into the Credential store on the same day it gains the engine.
66
+ *
67
+ * @param credentialKey - key within the credential record's decrypted values, e.g. `apiKey`
68
+ * @param envVarName - environment variable consulted when no credential value is present
69
+ */
70
+ protected GetSecret(credentialKey: string, envVarName: string): string | undefined;
71
+ /** Read a non-secret setting from the record's `ProviderConfig` JSON. */
72
+ protected GetConfigValue<T>(key: string): T | undefined;
73
+ /**
74
+ * The effective result cap: the caller's request, bounded by the metadata override (when set)
75
+ * and then by the vendor's own hard cap.
76
+ *
77
+ * Clamped rather than rejected — a caller asking for 50 wants as many as possible, and most
78
+ * vendors reject the whole request when asked for more than they serve.
79
+ */
80
+ protected ResolveMaxResults(requested: number | undefined, fallback?: number): number;
81
+ /**
82
+ * Enumerate every registered driver with its capabilities, for admin UI dropdowns.
83
+ *
84
+ * Instantiates each registration to read `Capabilities`, so a driver's constructor must stay
85
+ * side-effect free.
86
+ */
87
+ static GetAvailableProviders(): RegisteredWebSearchProviderInfo[];
88
+ }
89
+ //# sourceMappingURL=BaseWebSearchProvider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BaseWebSearchProvider.d.ts","sourceRoot":"","sources":["../src/BaseWebSearchProvider.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAY,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAE1D,OAAO,EACH,+BAA+B,EAC/B,qBAAqB,EACrB,eAAe,EACf,uBAAuB,EACvB,yBAAyB,EAC5B,MAAM,SAAS,CAAC;AAEjB;;;;;;;;;;;;;;;;GAgBG;AACH,8BAAsB,qBAAqB;IACvC,oFAAoF;IACpF,kBAAyB,YAAY,EAAE,qBAAqB,CAAC;IAE7D,6EAA6E;IAC7E,SAAS,CAAC,MAAM,EAAE,uBAAuB,GAAG,IAAI,CAAQ;IAExD,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,kBAAkB,CAAuB;IAEjD;;;;OAIG;IACU,UAAU,CAAC,MAAM,EAAE,uBAAuB,EAAE,YAAY,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAI/F;;;;;OAKG;aACa,iBAAiB,CAAC,WAAW,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAEvE,uGAAuG;IAChG,WAAW,IAAI,OAAO;IAI7B,kFAAkF;IAClF,IAAW,iBAAiB,IAAI,MAAM,GAAG,IAAI,CAE5C;IAED,sEAAsE;aACtD,aAAa,CACzB,MAAM,EAAE,eAAe,EACvB,WAAW,EAAE,QAAQ,GACtB,OAAO,CAAC,yBAAyB,CAAC;IAErC,yEAAyE;IACzE,SAAS,CAAC,aAAa,IAAI,IAAI;IAK/B,4EAA4E;IAC5E,SAAS,CAAC,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAK/C;;;;;;;;;;OAUG;IACH,SAAS,CAAC,SAAS,CAAC,aAAa,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IASlF,yEAAyE;IACzE,SAAS,CAAC,cAAc,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS;IAKvD;;;;;;OAMG;IACH,SAAS,CAAC,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,SAAK,GAAG,MAAM;IAMjF;;;;;OAKG;WACW,qBAAqB,IAAI,+BAA+B,EAAE;CA+B3E"}