@smartdoca/search-host 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,389 @@
1
+ import { sameSearchSource, searchIndexNames, searchSourceKey, } from "./naming.js";
2
+ import { SearchSourceRegistry } from "./registry.js";
3
+ export class SearchSourceNotFoundError extends Error {
4
+ source;
5
+ constructor(source) {
6
+ super(`Search source is not registered: ${typeof source === "string"
7
+ ? source
8
+ : `${source.pluginId}/${source.sourceId}`}`);
9
+ this.source = source;
10
+ this.name = "SearchSourceNotFoundError";
11
+ }
12
+ }
13
+ function compareText(left, right) {
14
+ return left < right ? -1 : left > right ? 1 : 0;
15
+ }
16
+ function copyDescriptor(descriptor) {
17
+ return {
18
+ pluginId: descriptor.pluginId,
19
+ sourceId: descriptor.sourceId,
20
+ schemaVersion: descriptor.schemaVersion,
21
+ renderer: { ...descriptor.renderer },
22
+ };
23
+ }
24
+ function validatePagination(offset, limit) {
25
+ if (!Number.isSafeInteger(offset) || offset < 0)
26
+ throw new RangeError("offset must be a non-negative safe integer");
27
+ if (!Number.isSafeInteger(limit) || limit < 0)
28
+ throw new RangeError("limit must be a non-negative safe integer");
29
+ }
30
+ function compactIds(ids) {
31
+ const unique = new Set();
32
+ for (const id of ids) {
33
+ if (typeof id !== "string" || id.trim().length === 0)
34
+ throw new TypeError("Document IDs must be non-empty strings");
35
+ unique.add(id);
36
+ }
37
+ return [...unique].sort(compareText);
38
+ }
39
+ function validateProjection(projection) {
40
+ if (typeof projection.id !== "string" || projection.id.trim().length === 0)
41
+ throw new TypeError("Projection IDs must be non-empty strings");
42
+ if (typeof projection.text !== "string")
43
+ throw new TypeError("Projection text must be a string");
44
+ }
45
+ function hydrationMap(hydration) {
46
+ if (!Array.isArray(hydration))
47
+ return hydration;
48
+ const values = new Map();
49
+ for (const item of hydration)
50
+ values.set(item.id, item.value);
51
+ return values;
52
+ }
53
+ function abortError() {
54
+ const error = new Error("Search rebuild was aborted");
55
+ error.name = "AbortError";
56
+ return error;
57
+ }
58
+ class DefaultSearchHost {
59
+ registry;
60
+ #provider;
61
+ #namespace;
62
+ #now;
63
+ #rebuildBatchSize;
64
+ #locks = new Map();
65
+ #sequence = 0;
66
+ constructor(provider, options) {
67
+ this.#provider = provider;
68
+ this.registry = options.registry ?? new SearchSourceRegistry();
69
+ this.#namespace = options.namespace ?? "doca_search";
70
+ this.#now = options.now ?? (() => new Date());
71
+ this.#rebuildBatchSize = options.rebuildBatchSize ?? 100;
72
+ if (!Number.isSafeInteger(this.#rebuildBatchSize) ||
73
+ this.#rebuildBatchSize < 1)
74
+ throw new RangeError("rebuildBatchSize must be a positive safe integer");
75
+ }
76
+ indexNames(source) {
77
+ return searchIndexNames(this.#requireSource(source).descriptor, this.#namespace);
78
+ }
79
+ async upsertProjections(request) {
80
+ const source = this.#requireSource(request.source);
81
+ const key = searchSourceKey(source.descriptor);
82
+ const projections = [...request.projections];
83
+ for (const projection of projections)
84
+ validateProjection(projection);
85
+ await this.#exclusive(key, async () => {
86
+ const indexName = await this.#ensureIndex(source);
87
+ await this.#provider.upsertProjections(indexName, projections);
88
+ await this.#provider.deleteTombstones(key, projections.map((projection) => projection.id));
89
+ });
90
+ }
91
+ async deleteProjections(request) {
92
+ const source = this.#requireSource(request.source);
93
+ const key = searchSourceKey(source.descriptor);
94
+ const documentIds = compactIds(request.documentIds);
95
+ await this.#exclusive(key, async () => {
96
+ await this.#provider.putTombstones(documentIds.map((documentId) => ({
97
+ sourceKey: key,
98
+ documentId,
99
+ deletedAt: (request.deletedAt ?? this.#now()).toISOString(),
100
+ })));
101
+ const active = await this.#provider.resolveAlias(searchIndexNames(source.descriptor, this.#namespace).alias);
102
+ if (active)
103
+ await this.#provider.deleteProjections(active, documentIds);
104
+ });
105
+ }
106
+ async rebuild(request) {
107
+ const source = this.#requireSource(request.source);
108
+ if (!source.projections)
109
+ throw new TypeError(`Search source ${searchSourceKey(source.descriptor)} does not provide projections()`);
110
+ const key = searchSourceKey(source.descriptor);
111
+ return this.#exclusive(key, async () => {
112
+ if (request.signal?.aborted)
113
+ throw abortError();
114
+ const names = searchIndexNames(source.descriptor, this.#namespace);
115
+ const version = request.version ?? this.#nextVersion("rebuild");
116
+ const indexName = names.version(version);
117
+ let created = false;
118
+ let published = false;
119
+ try {
120
+ await this.#provider.createIndex(indexName, source.descriptor);
121
+ created = true;
122
+ const iterable = await source.projections({
123
+ context: request.context,
124
+ signal: request.signal,
125
+ });
126
+ let count = 0;
127
+ let batch = [];
128
+ const flush = async () => {
129
+ if (!batch.length)
130
+ return;
131
+ await this.#provider.upsertProjections(indexName, batch);
132
+ batch = [];
133
+ };
134
+ for await (const output of iterable) {
135
+ if (request.signal?.aborted)
136
+ throw abortError();
137
+ const projections = Array.isArray(output) ? output : [output];
138
+ for (const projection of projections) {
139
+ validateProjection(projection);
140
+ batch.push(projection);
141
+ count++;
142
+ if (batch.length >= this.#rebuildBatchSize)
143
+ await flush();
144
+ }
145
+ }
146
+ await flush();
147
+ if (request.signal?.aborted)
148
+ throw abortError();
149
+ const tombstones = await this.#provider.listTombstones(key);
150
+ if (tombstones.length)
151
+ await this.#provider.deleteProjections(indexName, tombstones.map((tombstone) => tombstone.documentId));
152
+ const switched = await this.#provider.swapAlias(names.alias, indexName);
153
+ published = true;
154
+ if (switched.previousIndex &&
155
+ switched.previousIndex !== switched.activeIndex &&
156
+ !request.retainPrevious)
157
+ await this.#provider.deleteIndex(switched.previousIndex);
158
+ return {
159
+ alias: names.alias,
160
+ indexName: switched.activeIndex,
161
+ previousIndex: switched.previousIndex,
162
+ projectionCount: count,
163
+ };
164
+ }
165
+ catch (error) {
166
+ if (created && !published)
167
+ await this.#provider.deleteIndex(indexName);
168
+ throw error;
169
+ }
170
+ });
171
+ }
172
+ async switchIndex(request) {
173
+ const source = this.#requireSource(request.source);
174
+ const key = searchSourceKey(source.descriptor);
175
+ return this.#exclusive(key, async () => {
176
+ const metadata = await this.#provider.inspectIndex(request.indexName);
177
+ if (!metadata)
178
+ throw new Error(`Search index does not exist: ${request.indexName}`);
179
+ if (!sameSearchSource(metadata.descriptor, source.descriptor))
180
+ throw new Error(`Search index ${request.indexName} belongs to a different source or schema`);
181
+ const alias = searchIndexNames(source.descriptor, this.#namespace).alias;
182
+ const switched = await this.#provider.swapAlias(alias, request.indexName);
183
+ if (request.deletePrevious &&
184
+ switched.previousIndex &&
185
+ switched.previousIndex !== switched.activeIndex)
186
+ await this.#provider.deleteIndex(switched.previousIndex);
187
+ return {
188
+ alias,
189
+ indexName: switched.activeIndex,
190
+ previousIndex: switched.previousIndex,
191
+ };
192
+ });
193
+ }
194
+ async query(request) {
195
+ const offset = request.offset ?? 0;
196
+ const limit = request.limit ?? 20;
197
+ validatePagination(offset, limit);
198
+ const sources = request.sources
199
+ ? [
200
+ ...new Map(request.sources.map((reference) => {
201
+ const source = this.#requireSource(reference);
202
+ return [searchSourceKey(source.descriptor), source];
203
+ })).values(),
204
+ ]
205
+ : this.registry.list();
206
+ const sourceResults = await Promise.all(sources.map((source) => this.#querySource(source, request)));
207
+ const failures = sourceResults
208
+ .flatMap((result) => (result.failure ? [result.failure] : []))
209
+ .sort((left, right) => {
210
+ const sourceOrder = compareText(searchSourceKey(left.source), searchSourceKey(right.source));
211
+ return sourceOrder || compareText(left.stage, right.stage);
212
+ });
213
+ const ranked = [];
214
+ for (const result of sourceResults) {
215
+ if (!result.values.length)
216
+ continue;
217
+ const scores = result.values.map((value) => value.rawScore);
218
+ const minimum = Math.min(...scores);
219
+ const maximum = Math.max(...scores);
220
+ for (const value of result.values) {
221
+ const score = maximum === minimum
222
+ ? 1
223
+ : (value.rawScore - minimum) / (maximum - minimum);
224
+ ranked.push({
225
+ id: value.id,
226
+ source: value.source,
227
+ renderer: { ...value.source.renderer },
228
+ providerScore: value.rawScore,
229
+ score,
230
+ value: value.value,
231
+ });
232
+ }
233
+ }
234
+ ranked.sort((left, right) => {
235
+ const scoreOrder = right.score - left.score;
236
+ if (scoreOrder)
237
+ return scoreOrder;
238
+ const sourceOrder = compareText(searchSourceKey(left.source), searchSourceKey(right.source));
239
+ return sourceOrder || compareText(left.id, right.id);
240
+ });
241
+ return {
242
+ items: ranked.slice(offset, offset + limit),
243
+ failures,
244
+ offset,
245
+ limit,
246
+ total: ranked.length,
247
+ };
248
+ }
249
+ async dispose() {
250
+ await this.registry.dispose();
251
+ }
252
+ async #querySource(source, request) {
253
+ const descriptor = copyDescriptor(source.descriptor);
254
+ const fail = (stage, error) => ({
255
+ values: [],
256
+ failure: { source: descriptor, stage, error },
257
+ });
258
+ const names = searchIndexNames(descriptor, this.#namespace);
259
+ let providerQuery;
260
+ try {
261
+ providerQuery = source.prepareQuery
262
+ ? await source.prepareQuery({
263
+ context: request.context,
264
+ query: request.query,
265
+ })
266
+ : { query: request.query };
267
+ if (providerQuery === null)
268
+ return { values: [] };
269
+ if (providerQuery.limit !== undefined &&
270
+ (!Number.isSafeInteger(providerQuery.limit) || providerQuery.limit < 0))
271
+ throw new RangeError("Search provider query limit must be a non-negative safe integer");
272
+ }
273
+ catch (error) {
274
+ return fail("prepare", error);
275
+ }
276
+ let indexName;
277
+ try {
278
+ indexName = await this.#provider.resolveAlias(names.alias);
279
+ if (!indexName)
280
+ return { values: [] };
281
+ const metadata = await this.#provider.inspectIndex(indexName);
282
+ if (!metadata || !sameSearchSource(metadata.descriptor, descriptor))
283
+ throw new Error(`Active index is missing or incompatible: ${indexName}`);
284
+ }
285
+ catch (error) {
286
+ return fail("index", error);
287
+ }
288
+ let hits;
289
+ try {
290
+ const raw = await this.#provider.queryIndex(indexName, providerQuery);
291
+ const scopedCandidates = providerQuery.candidateIds
292
+ ? new Set(compactIds(providerQuery.candidateIds))
293
+ : undefined;
294
+ const byId = new Map();
295
+ for (const hit of raw) {
296
+ if (typeof hit.id !== "string" ||
297
+ hit.id.trim().length === 0 ||
298
+ !Number.isFinite(hit.score))
299
+ throw new TypeError("Search provider returned an invalid hit");
300
+ if (scopedCandidates && !scopedCandidates.has(hit.id))
301
+ continue;
302
+ byId.set(hit.id, Math.max(byId.get(hit.id) ?? -Infinity, hit.score));
303
+ }
304
+ hits = [...byId.entries()]
305
+ .map(([id, score]) => ({ id, score }))
306
+ .sort((left, right) => right.score - left.score || compareText(left.id, right.id));
307
+ }
308
+ catch (error) {
309
+ return fail("query", error);
310
+ }
311
+ let authorized;
312
+ try {
313
+ const candidateIds = hits.map((hit) => hit.id);
314
+ const candidates = new Set(candidateIds);
315
+ const allowed = new Set(await source.authorize({
316
+ context: request.context,
317
+ query: request.query,
318
+ candidateIds,
319
+ }));
320
+ authorized = hits.filter((hit) => candidates.has(hit.id) && allowed.has(hit.id));
321
+ }
322
+ catch (error) {
323
+ return fail("authorize", error);
324
+ }
325
+ try {
326
+ const ids = authorized.map((hit) => hit.id);
327
+ const hydration = hydrationMap(await source.hydrate({
328
+ context: request.context,
329
+ query: request.query,
330
+ ids,
331
+ }));
332
+ const key = searchSourceKey(descriptor);
333
+ return {
334
+ values: authorized.flatMap((hit) => hydration.has(hit.id)
335
+ ? [
336
+ {
337
+ id: hit.id,
338
+ key,
339
+ rawScore: hit.score,
340
+ source: descriptor,
341
+ value: hydration.get(hit.id),
342
+ },
343
+ ]
344
+ : []),
345
+ };
346
+ }
347
+ catch (error) {
348
+ return fail("hydrate", error);
349
+ }
350
+ }
351
+ #requireSource(source) {
352
+ const registered = this.registry.get(source);
353
+ if (!registered)
354
+ throw new SearchSourceNotFoundError(source);
355
+ return registered;
356
+ }
357
+ async #ensureIndex(source) {
358
+ const names = searchIndexNames(source.descriptor, this.#namespace);
359
+ const active = await this.#provider.resolveAlias(names.alias);
360
+ if (active) {
361
+ const metadata = await this.#provider.inspectIndex(active);
362
+ if (metadata && sameSearchSource(metadata.descriptor, source.descriptor))
363
+ return active;
364
+ }
365
+ const indexName = names.version(this.#nextVersion("bootstrap"));
366
+ await this.#provider.createIndex(indexName, source.descriptor);
367
+ return (await this.#provider.swapAlias(names.alias, indexName)).activeIndex;
368
+ }
369
+ #nextVersion(prefix) {
370
+ this.#sequence++;
371
+ return `${prefix}-${this.#now().getTime().toString(36)}-${this.#sequence.toString(36)}`;
372
+ }
373
+ async #exclusive(key, action) {
374
+ const previous = this.#locks.get(key) ?? Promise.resolve();
375
+ const run = previous.then(action);
376
+ const tail = run.then(() => undefined, () => undefined);
377
+ this.#locks.set(key, tail);
378
+ try {
379
+ return await run;
380
+ }
381
+ finally {
382
+ if (this.#locks.get(key) === tail)
383
+ this.#locks.delete(key);
384
+ }
385
+ }
386
+ }
387
+ export function createSearchHost(provider, options = {}) {
388
+ return new DefaultSearchHost(provider, options);
389
+ }
@@ -0,0 +1,141 @@
1
+ export type MaybePromise<T> = T | Promise<T>;
2
+ export interface SearchResultRenderer {
3
+ readonly kind: string;
4
+ readonly version?: number;
5
+ }
6
+ export interface SearchSourceDescriptor {
7
+ readonly pluginId: string;
8
+ readonly sourceId: string;
9
+ readonly schemaVersion: number;
10
+ readonly renderer: SearchResultRenderer;
11
+ }
12
+ export interface SearchProjection {
13
+ readonly id: string;
14
+ readonly text: string;
15
+ readonly metadata?: Readonly<Record<string, unknown>>;
16
+ }
17
+ export interface SearchAuthorizationRequest<TContext> {
18
+ readonly context: TContext;
19
+ readonly query: string;
20
+ readonly candidateIds: readonly string[];
21
+ }
22
+ export interface SearchHydrationRequest<TContext> {
23
+ readonly context: TContext;
24
+ readonly query: string;
25
+ readonly ids: readonly string[];
26
+ }
27
+ export interface SearchProjectionSnapshotRequest<TContext> {
28
+ readonly context: TContext;
29
+ readonly signal?: AbortSignal;
30
+ }
31
+ export interface SearchQueryPreparationRequest<TContext> {
32
+ readonly context: TContext;
33
+ readonly query: string;
34
+ }
35
+ export interface HydratedSearchValue<TValue = unknown> {
36
+ readonly id: string;
37
+ readonly value: TValue;
38
+ }
39
+ export type SearchHydration<TValue = unknown> = ReadonlyMap<string, TValue> | readonly HydratedSearchValue<TValue>[];
40
+ export type SearchProjectionBatch = SearchProjection | readonly SearchProjection[];
41
+ /**
42
+ * A source owns authorization and hydration. The host deliberately receives
43
+ * only opaque IDs from an index, so indexed fields can never bypass the source.
44
+ */
45
+ export interface SearchSource<TContext = unknown, TValue = unknown> {
46
+ readonly descriptor: SearchSourceDescriptor;
47
+ /**
48
+ * Narrows provider work to the source-owned query scope. Returning null
49
+ * makes the source inapplicable to this request. Authorization still runs
50
+ * after the provider returns and is never replaced by candidateIds.
51
+ */
52
+ prepareQuery?(request: SearchQueryPreparationRequest<TContext>): MaybePromise<SearchProviderQuery | null>;
53
+ authorize(request: SearchAuthorizationRequest<TContext>): MaybePromise<Iterable<string>>;
54
+ hydrate(request: SearchHydrationRequest<TContext>): MaybePromise<SearchHydration<TValue>>;
55
+ projections?(request: SearchProjectionSnapshotRequest<TContext>): Iterable<SearchProjectionBatch> | AsyncIterable<SearchProjectionBatch> | Promise<Iterable<SearchProjectionBatch> | AsyncIterable<SearchProjectionBatch>>;
56
+ }
57
+ export interface SearchSourceEffectResource<TContext = unknown, TValue = unknown> {
58
+ readonly source: SearchSource<TContext, TValue>;
59
+ readonly dispose?: () => MaybePromise<void>;
60
+ }
61
+ export interface SearchSourceEffect<TContext = unknown, TValue = unknown> {
62
+ acquire(): MaybePromise<SearchSourceEffectResource<TContext, TValue>>;
63
+ }
64
+ export interface SearchIndexMetadata {
65
+ readonly name: string;
66
+ readonly descriptor: SearchSourceDescriptor;
67
+ readonly createdAt: string;
68
+ }
69
+ export interface SearchIndexLifecycle {
70
+ createIndex(name: string, descriptor: SearchSourceDescriptor): Promise<void>;
71
+ deleteIndex(name: string): Promise<void>;
72
+ inspectIndex(name: string): Promise<SearchIndexMetadata | undefined>;
73
+ resolveAlias(alias: string): Promise<string | undefined>;
74
+ swapAlias(alias: string, indexName: string): Promise<SearchAliasSwap>;
75
+ }
76
+ /**
77
+ * Providers may implement aliases as pointers or as stable physical indexes
78
+ * whose contents are atomically swapped. activeIndex makes both models
79
+ * explicit to the host.
80
+ */
81
+ export interface SearchAliasSwap {
82
+ readonly activeIndex: string;
83
+ readonly previousIndex?: string;
84
+ }
85
+ export interface SearchProjectionIndex {
86
+ upsertProjections(indexName: string, projections: readonly SearchProjection[]): Promise<void>;
87
+ deleteProjections(indexName: string, documentIds: readonly string[]): Promise<void>;
88
+ }
89
+ export interface SearchProviderHit {
90
+ readonly id: string;
91
+ readonly score: number;
92
+ }
93
+ export interface SearchProviderQuery {
94
+ readonly query: string;
95
+ readonly candidateIds?: readonly string[];
96
+ readonly limit?: number;
97
+ readonly semantic?: boolean;
98
+ readonly rankingScoreThreshold?: number;
99
+ }
100
+ export interface SearchIndexQuery {
101
+ queryIndex(indexName: string, request: SearchProviderQuery): Promise<readonly SearchProviderHit[]>;
102
+ }
103
+ export interface SearchTombstone {
104
+ readonly sourceKey: string;
105
+ readonly documentId: string;
106
+ readonly deletedAt: string;
107
+ }
108
+ export interface SearchTombstoneStore {
109
+ putTombstones(tombstones: readonly SearchTombstone[]): Promise<void>;
110
+ deleteTombstones(sourceKey: string, documentIds: readonly string[]): Promise<void>;
111
+ listTombstones(sourceKey: string): Promise<readonly SearchTombstone[]>;
112
+ }
113
+ /**
114
+ * Concrete engines implement this boundary. SearchHost has no dependency on
115
+ * any engine SDK or wire format.
116
+ */
117
+ export interface SearchProvider extends SearchIndexLifecycle, SearchProjectionIndex, SearchIndexQuery, SearchTombstoneStore {
118
+ }
119
+ export interface SearchResultItem<TValue = unknown> {
120
+ readonly id: string;
121
+ readonly source: SearchSourceDescriptor;
122
+ readonly renderer: SearchResultRenderer;
123
+ /** Provider-local score retained for source-specific reranking. */
124
+ readonly providerScore: number;
125
+ /** Per-source normalized score used for federation. */
126
+ readonly score: number;
127
+ readonly value: TValue;
128
+ }
129
+ export type SearchFailureStage = "index" | "prepare" | "query" | "authorize" | "hydrate";
130
+ export interface SearchSourceFailure {
131
+ readonly source: SearchSourceDescriptor;
132
+ readonly stage: SearchFailureStage;
133
+ readonly error: unknown;
134
+ }
135
+ export interface FederatedSearchResult<TValue = unknown> {
136
+ readonly items: readonly SearchResultItem<TValue>[];
137
+ readonly failures: readonly SearchSourceFailure[];
138
+ readonly offset: number;
139
+ readonly limit: number;
140
+ readonly total: number;
141
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@smartdoca/search-host",
3
+ "version": "0.1.0",
4
+ "license": "AGPL-3.0-only",
5
+ "type": "module",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ },
12
+ "./memory": {
13
+ "types": "./dist/memory-provider.d.ts",
14
+ "import": "./dist/memory-provider.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ }
23
+ }