@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.
- package/dist/LICENSE +661 -0
- package/dist/LICENSING.md +57 -0
- package/dist/LICENSING.zh-CN.md +48 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/memory-provider.d.ts +26 -0
- package/dist/memory-provider.js +163 -0
- package/dist/naming.d.ts +14 -0
- package/dist/naming.js +46 -0
- package/dist/registry.d.ts +27 -0
- package/dist/registry.js +95 -0
- package/dist/search-host.d.ts +64 -0
- package/dist/search-host.js +389 -0
- package/dist/types.d.ts +141 -0
- package/dist/types.js +1 -0
- package/package.json +23 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Licensing
|
|
2
|
+
|
|
3
|
+
[简体中文](LICENSING.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
Doca uses a dual-licensing model for code owned by the Doca copyright holders.
|
|
6
|
+
|
|
7
|
+
## Open-source license
|
|
8
|
+
|
|
9
|
+
Unless a file or package says otherwise, Doca-owned code in this repository is
|
|
10
|
+
available under the GNU Affero General Public License version 3 only
|
|
11
|
+
(`AGPL-3.0-only`). The full license text is in [LICENSE](LICENSE).
|
|
12
|
+
|
|
13
|
+
The AGPL permits commercial use. When its conditions apply, it requires the
|
|
14
|
+
corresponding source to remain available under the AGPL, including for modified
|
|
15
|
+
versions offered to users over a network. “Open source” and “free of charge” are
|
|
16
|
+
not the same thing, and the AGPL obligations do not depend on whether an operator
|
|
17
|
+
charges money.
|
|
18
|
+
|
|
19
|
+
## Commercial license
|
|
20
|
+
|
|
21
|
+
Organizations that want to use Doca-owned code in a proprietary product without
|
|
22
|
+
the AGPL obligations may obtain a separate commercial license from the Doca
|
|
23
|
+
copyright holders. A commercial license exists only when both parties have
|
|
24
|
+
entered into a written agreement. This repository does not itself grant that
|
|
25
|
+
alternative license.
|
|
26
|
+
|
|
27
|
+
## Packages and editors
|
|
28
|
+
|
|
29
|
+
The same licensing direction applies to Doca-owned packages and editors,
|
|
30
|
+
including the Doca plugin SDK and the Slate, presentation, spreadsheet,
|
|
31
|
+
Markdown, and canvas editor packages. Each released npm artifact must:
|
|
32
|
+
|
|
33
|
+
- carry an accurate license field and license notice;
|
|
34
|
+
- link to the public source repository and the exact source tag for that version;
|
|
35
|
+
- include the build scripts and dependency lock needed to produce the artifact;
|
|
36
|
+
- preserve all notices and license obligations of third-party components.
|
|
37
|
+
|
|
38
|
+
The historical archives under `vendor/` are development and recovery inputs.
|
|
39
|
+
They are not the long-term public distribution channel. Before the public
|
|
40
|
+
release, active editor dependencies must be rebuilt from their public tagged
|
|
41
|
+
sources, published to npm, and consumed by Doca through registry versions.
|
|
42
|
+
|
|
43
|
+
## Third-party software and assets
|
|
44
|
+
|
|
45
|
+
The dual license covers only material for which the Doca copyright holders can
|
|
46
|
+
grant these rights. Third-party libraries, fonts, media, generated artifacts,
|
|
47
|
+
and other bundled material retain their own licenses. Their notices and source
|
|
48
|
+
or attribution requirements must be followed independently.
|
|
49
|
+
|
|
50
|
+
No trademark rights are granted by either the AGPL or this licensing summary.
|
|
51
|
+
|
|
52
|
+
## Contributions
|
|
53
|
+
|
|
54
|
+
Dual licensing requires the project to retain the rights needed to offer future
|
|
55
|
+
commercial licenses. See [CONTRIBUTING.md](CONTRIBUTING.md) before submitting
|
|
56
|
+
code. This document is a project policy summary, not a substitute for the
|
|
57
|
+
license text or legal advice.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# 许可说明
|
|
2
|
+
|
|
3
|
+
[English](LICENSING.md)
|
|
4
|
+
|
|
5
|
+
Doca 对其版权所有者拥有权利的代码采用双许可模式。
|
|
6
|
+
|
|
7
|
+
## 开源许可证
|
|
8
|
+
|
|
9
|
+
除非文件或包中另有说明,本仓库内由 Doca 版权所有者拥有的代码默认采用
|
|
10
|
+
GNU Affero General Public License version 3 only(`AGPL-3.0-only`)。完整条款见
|
|
11
|
+
[LICENSE](LICENSE)。
|
|
12
|
+
|
|
13
|
+
AGPL 允许商业使用。当其条件适用时,分发软件或将修改后的版本作为网络服务提供给
|
|
14
|
+
用户,需要继续按 AGPL 提供对应源码。是否收费并不是 AGPL 义务的判断条件,“开源”
|
|
15
|
+
也不等于“免费”。
|
|
16
|
+
|
|
17
|
+
## 商业许可证
|
|
18
|
+
|
|
19
|
+
希望在闭源产品中使用 Doca 自研代码、且不希望承担 AGPL 义务的组织,可以向 Doca
|
|
20
|
+
版权所有者申请单独的商业许可证。只有双方签署书面协议后,商业许可证才成立;本仓库
|
|
21
|
+
本身不授予该替代许可证。
|
|
22
|
+
|
|
23
|
+
## 子包与编辑器
|
|
24
|
+
|
|
25
|
+
同一许可方向适用于 Doca 自研的子包和编辑器,包括插件 SDK,以及 Slate 富文本、
|
|
26
|
+
演示文稿、表格、Markdown 和画板编辑器。每个正式发布的 npm 制品必须:
|
|
27
|
+
|
|
28
|
+
- 使用准确的许可证字段并携带许可证说明;
|
|
29
|
+
- 链接公开源码仓库以及与该版本精确对应的 Git tag;
|
|
30
|
+
- 提供生成该制品所需的构建脚本和依赖锁文件;
|
|
31
|
+
- 保留所有第三方组件要求的许可证及声明。
|
|
32
|
+
|
|
33
|
+
`vendor/` 中的历史压缩包是开发和恢复输入,不作为长期公开分发渠道。正式开源前,
|
|
34
|
+
当前使用的编辑器依赖应从公开且已打 tag 的源码重新构建并发布到 npm,Doca 再改为
|
|
35
|
+
使用 registry 版本。
|
|
36
|
+
|
|
37
|
+
## 第三方软件与素材
|
|
38
|
+
|
|
39
|
+
双许可只覆盖 Doca 版权所有者有权授予许可的内容。第三方库、字体、媒体、生成产物及
|
|
40
|
+
其他随附内容继续适用各自许可证,其声明、源码提供或署名要求需要独立履行。
|
|
41
|
+
|
|
42
|
+
AGPL 和本文均不授予商标使用权。
|
|
43
|
+
|
|
44
|
+
## 外部贡献
|
|
45
|
+
|
|
46
|
+
双许可要求项目持续保有提供商业许可证所需的权利。提交代码前请阅读
|
|
47
|
+
[CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md)。本文只是项目政策摘要,不能替代
|
|
48
|
+
许可证原文或专业法律意见。
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SearchAliasSwap, SearchIndexMetadata, SearchProjection, SearchProvider, SearchProviderHit, SearchProviderQuery, SearchSourceDescriptor, SearchTombstone } from "./types.js";
|
|
2
|
+
export interface InMemorySearchProviderOptions {
|
|
3
|
+
readonly now?: () => Date;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* A deterministic provider intended for unit tests and local composition. It
|
|
7
|
+
* implements the same engine-neutral lifecycle used by production adapters.
|
|
8
|
+
*/
|
|
9
|
+
export declare class InMemorySearchProvider implements SearchProvider {
|
|
10
|
+
#private;
|
|
11
|
+
constructor(options?: InMemorySearchProviderOptions);
|
|
12
|
+
createIndex(name: string, descriptor: SearchSourceDescriptor): Promise<void>;
|
|
13
|
+
deleteIndex(name: string): Promise<void>;
|
|
14
|
+
inspectIndex(name: string): Promise<SearchIndexMetadata | undefined>;
|
|
15
|
+
resolveAlias(alias: string): Promise<string | undefined>;
|
|
16
|
+
swapAlias(alias: string, indexName: string): Promise<SearchAliasSwap>;
|
|
17
|
+
upsertProjections(indexName: string, projections: readonly SearchProjection[]): Promise<void>;
|
|
18
|
+
deleteProjections(indexName: string, documentIds: readonly string[]): Promise<void>;
|
|
19
|
+
queryIndex(indexName: string, request: SearchProviderQuery): Promise<readonly SearchProviderHit[]>;
|
|
20
|
+
putTombstones(tombstones: readonly SearchTombstone[]): Promise<void>;
|
|
21
|
+
deleteTombstones(sourceKey: string, documentIds: readonly string[]): Promise<void>;
|
|
22
|
+
listTombstones(sourceKey: string): Promise<readonly SearchTombstone[]>;
|
|
23
|
+
setQueryFailure(indexName: string, error?: unknown): void;
|
|
24
|
+
listIndexes(): readonly string[];
|
|
25
|
+
getProjection(indexName: string, documentId: string): SearchProjection | undefined;
|
|
26
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
function compareText(left, right) {
|
|
2
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
3
|
+
}
|
|
4
|
+
function copyDescriptor(descriptor) {
|
|
5
|
+
return {
|
|
6
|
+
pluginId: descriptor.pluginId,
|
|
7
|
+
sourceId: descriptor.sourceId,
|
|
8
|
+
schemaVersion: descriptor.schemaVersion,
|
|
9
|
+
renderer: { ...descriptor.renderer },
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function copyProjection(projection) {
|
|
13
|
+
return {
|
|
14
|
+
id: projection.id,
|
|
15
|
+
text: projection.text,
|
|
16
|
+
metadata: projection.metadata ? { ...projection.metadata } : undefined,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function occurrences(haystack, needle) {
|
|
20
|
+
let count = 0;
|
|
21
|
+
let offset = 0;
|
|
22
|
+
while (offset <= haystack.length - needle.length) {
|
|
23
|
+
const match = haystack.indexOf(needle, offset);
|
|
24
|
+
if (match < 0)
|
|
25
|
+
break;
|
|
26
|
+
count++;
|
|
27
|
+
offset = match + needle.length;
|
|
28
|
+
}
|
|
29
|
+
return count;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A deterministic provider intended for unit tests and local composition. It
|
|
33
|
+
* implements the same engine-neutral lifecycle used by production adapters.
|
|
34
|
+
*/
|
|
35
|
+
export class InMemorySearchProvider {
|
|
36
|
+
#indices = new Map();
|
|
37
|
+
#aliases = new Map();
|
|
38
|
+
#tombstones = new Map();
|
|
39
|
+
#queryFailures = new Map();
|
|
40
|
+
#now;
|
|
41
|
+
constructor(options = {}) {
|
|
42
|
+
this.#now = options.now ?? (() => new Date());
|
|
43
|
+
}
|
|
44
|
+
async createIndex(name, descriptor) {
|
|
45
|
+
if (this.#indices.has(name))
|
|
46
|
+
throw new Error(`Search index already exists: ${name}`);
|
|
47
|
+
this.#indices.set(name, {
|
|
48
|
+
metadata: {
|
|
49
|
+
name,
|
|
50
|
+
descriptor: copyDescriptor(descriptor),
|
|
51
|
+
createdAt: this.#now().toISOString(),
|
|
52
|
+
},
|
|
53
|
+
documents: new Map(),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async deleteIndex(name) {
|
|
57
|
+
this.#indices.delete(name);
|
|
58
|
+
for (const [alias, target] of this.#aliases)
|
|
59
|
+
if (target === name)
|
|
60
|
+
this.#aliases.delete(alias);
|
|
61
|
+
this.#queryFailures.delete(name);
|
|
62
|
+
}
|
|
63
|
+
async inspectIndex(name) {
|
|
64
|
+
const metadata = this.#indices.get(name)?.metadata;
|
|
65
|
+
return metadata
|
|
66
|
+
? { ...metadata, descriptor: copyDescriptor(metadata.descriptor) }
|
|
67
|
+
: undefined;
|
|
68
|
+
}
|
|
69
|
+
async resolveAlias(alias) {
|
|
70
|
+
return this.#aliases.get(alias);
|
|
71
|
+
}
|
|
72
|
+
async swapAlias(alias, indexName) {
|
|
73
|
+
if (!this.#indices.has(indexName))
|
|
74
|
+
throw new Error(`Search index does not exist: ${indexName}`);
|
|
75
|
+
const previous = this.#aliases.get(alias);
|
|
76
|
+
this.#aliases.set(alias, indexName);
|
|
77
|
+
return {
|
|
78
|
+
activeIndex: indexName,
|
|
79
|
+
...(previous ? { previousIndex: previous } : {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
async upsertProjections(indexName, projections) {
|
|
83
|
+
const index = this.#requireIndex(indexName);
|
|
84
|
+
for (const projection of projections)
|
|
85
|
+
index.documents.set(projection.id, copyProjection(projection));
|
|
86
|
+
}
|
|
87
|
+
async deleteProjections(indexName, documentIds) {
|
|
88
|
+
const index = this.#requireIndex(indexName);
|
|
89
|
+
for (const id of documentIds)
|
|
90
|
+
index.documents.delete(id);
|
|
91
|
+
}
|
|
92
|
+
async queryIndex(indexName, request) {
|
|
93
|
+
const resolved = this.#aliases.get(indexName) ?? indexName;
|
|
94
|
+
if (this.#queryFailures.has(resolved))
|
|
95
|
+
throw this.#queryFailures.get(resolved);
|
|
96
|
+
const index = this.#requireIndex(resolved);
|
|
97
|
+
const query = request.query.normalize("NFKC").toLowerCase();
|
|
98
|
+
const terms = query.trim() ? query.trim().split(/\s+/u) : [];
|
|
99
|
+
const candidates = request.candidateIds
|
|
100
|
+
? new Set(request.candidateIds)
|
|
101
|
+
: undefined;
|
|
102
|
+
const hits = [];
|
|
103
|
+
for (const projection of index.documents.values()) {
|
|
104
|
+
if (candidates && !candidates.has(projection.id))
|
|
105
|
+
continue;
|
|
106
|
+
const text = projection.text.normalize("NFKC").toLowerCase();
|
|
107
|
+
const score = terms.length === 0
|
|
108
|
+
? 1
|
|
109
|
+
: terms.reduce((total, term) => total + occurrences(text, term), 0);
|
|
110
|
+
if (score > 0)
|
|
111
|
+
hits.push({ id: projection.id, score });
|
|
112
|
+
}
|
|
113
|
+
const ranked = hits.sort((left, right) => right.score - left.score || compareText(left.id, right.id));
|
|
114
|
+
return request.limit === undefined
|
|
115
|
+
? ranked
|
|
116
|
+
: ranked.slice(0, request.limit);
|
|
117
|
+
}
|
|
118
|
+
async putTombstones(tombstones) {
|
|
119
|
+
for (const tombstone of tombstones) {
|
|
120
|
+
let source = this.#tombstones.get(tombstone.sourceKey);
|
|
121
|
+
if (!source) {
|
|
122
|
+
source = new Map();
|
|
123
|
+
this.#tombstones.set(tombstone.sourceKey, source);
|
|
124
|
+
}
|
|
125
|
+
source.set(tombstone.documentId, { ...tombstone });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async deleteTombstones(sourceKey, documentIds) {
|
|
129
|
+
const source = this.#tombstones.get(sourceKey);
|
|
130
|
+
if (!source)
|
|
131
|
+
return;
|
|
132
|
+
for (const id of documentIds)
|
|
133
|
+
source.delete(id);
|
|
134
|
+
if (source.size === 0)
|
|
135
|
+
this.#tombstones.delete(sourceKey);
|
|
136
|
+
}
|
|
137
|
+
async listTombstones(sourceKey) {
|
|
138
|
+
return [...(this.#tombstones.get(sourceKey)?.values() ?? [])]
|
|
139
|
+
.sort((left, right) => compareText(left.documentId, right.documentId))
|
|
140
|
+
.map((tombstone) => ({ ...tombstone }));
|
|
141
|
+
}
|
|
142
|
+
setQueryFailure(indexName, error) {
|
|
143
|
+
const resolved = this.#aliases.get(indexName) ?? indexName;
|
|
144
|
+
if (error === undefined)
|
|
145
|
+
this.#queryFailures.delete(resolved);
|
|
146
|
+
else
|
|
147
|
+
this.#queryFailures.set(resolved, error);
|
|
148
|
+
}
|
|
149
|
+
listIndexes() {
|
|
150
|
+
return [...this.#indices.keys()].sort(compareText);
|
|
151
|
+
}
|
|
152
|
+
getProjection(indexName, documentId) {
|
|
153
|
+
const resolved = this.#aliases.get(indexName) ?? indexName;
|
|
154
|
+
const projection = this.#indices.get(resolved)?.documents.get(documentId);
|
|
155
|
+
return projection ? copyProjection(projection) : undefined;
|
|
156
|
+
}
|
|
157
|
+
#requireIndex(name) {
|
|
158
|
+
const index = this.#indices.get(name);
|
|
159
|
+
if (!index)
|
|
160
|
+
throw new Error(`Search index does not exist: ${name}`);
|
|
161
|
+
return index;
|
|
162
|
+
}
|
|
163
|
+
}
|
package/dist/naming.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SearchSourceDescriptor } from "./types.js";
|
|
2
|
+
export declare function validateSearchSourceDescriptor(descriptor: SearchSourceDescriptor): void;
|
|
3
|
+
export declare function encodeIndexComponent(value: string): string;
|
|
4
|
+
export declare function searchSourceKey(descriptor: Pick<SearchSourceDescriptor, "pluginId" | "sourceId">): string;
|
|
5
|
+
export interface SearchIndexNames {
|
|
6
|
+
readonly alias: string;
|
|
7
|
+
readonly version: (version: string) => string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Alias names are stable across schema changes; physical names carry both the
|
|
11
|
+
* schema and rebuild version so an alias swap can publish atomically.
|
|
12
|
+
*/
|
|
13
|
+
export declare function searchIndexNames(descriptor: SearchSourceDescriptor, namespace?: string): SearchIndexNames;
|
|
14
|
+
export declare function sameSearchSource(left: SearchSourceDescriptor, right: SearchSourceDescriptor): boolean;
|
package/dist/naming.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
function requireText(value, field) {
|
|
3
|
+
if (value.trim().length === 0)
|
|
4
|
+
throw new TypeError(`${field} must be a non-empty string`);
|
|
5
|
+
}
|
|
6
|
+
export function validateSearchSourceDescriptor(descriptor) {
|
|
7
|
+
requireText(descriptor.pluginId, "pluginId");
|
|
8
|
+
requireText(descriptor.sourceId, "sourceId");
|
|
9
|
+
requireText(descriptor.renderer.kind, "renderer.kind");
|
|
10
|
+
if (!Number.isSafeInteger(descriptor.schemaVersion) ||
|
|
11
|
+
descriptor.schemaVersion < 1)
|
|
12
|
+
throw new TypeError("schemaVersion must be a positive safe integer");
|
|
13
|
+
if (descriptor.renderer.version !== undefined &&
|
|
14
|
+
(!Number.isSafeInteger(descriptor.renderer.version) ||
|
|
15
|
+
descriptor.renderer.version < 1))
|
|
16
|
+
throw new TypeError("renderer.version must be a positive safe integer");
|
|
17
|
+
}
|
|
18
|
+
export function encodeIndexComponent(value) {
|
|
19
|
+
requireText(value, "index component");
|
|
20
|
+
const bytes = encoder.encode(value);
|
|
21
|
+
return `${bytes.length}_${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
22
|
+
}
|
|
23
|
+
export function searchSourceKey(descriptor) {
|
|
24
|
+
return `${encodeIndexComponent(descriptor.pluginId)}/${encodeIndexComponent(descriptor.sourceId)}`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Alias names are stable across schema changes; physical names carry both the
|
|
28
|
+
* schema and rebuild version so an alias swap can publish atomically.
|
|
29
|
+
*/
|
|
30
|
+
export function searchIndexNames(descriptor, namespace = "doca_search") {
|
|
31
|
+
validateSearchSourceDescriptor(descriptor);
|
|
32
|
+
const prefix = [
|
|
33
|
+
encodeIndexComponent(namespace),
|
|
34
|
+
encodeIndexComponent(descriptor.pluginId),
|
|
35
|
+
encodeIndexComponent(descriptor.sourceId),
|
|
36
|
+
].join("__");
|
|
37
|
+
return {
|
|
38
|
+
alias: `${prefix}__current`,
|
|
39
|
+
version: (version) => `${prefix}__schema_${descriptor.schemaVersion}__version_${encodeIndexComponent(version)}`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function sameSearchSource(left, right) {
|
|
43
|
+
return (left.pluginId === right.pluginId &&
|
|
44
|
+
left.sourceId === right.sourceId &&
|
|
45
|
+
left.schemaVersion === right.schemaVersion);
|
|
46
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { MaybePromise, SearchSource, SearchSourceEffect } from "./types.js";
|
|
2
|
+
export declare class DuplicateSearchSourceError extends Error {
|
|
3
|
+
readonly sourceKey: string;
|
|
4
|
+
constructor(sourceKey: string);
|
|
5
|
+
}
|
|
6
|
+
export declare class DisposedSearchSourceRegistryError extends Error {
|
|
7
|
+
constructor();
|
|
8
|
+
}
|
|
9
|
+
export interface SearchSourceLease<TContext = unknown, TValue = unknown> {
|
|
10
|
+
readonly key: string;
|
|
11
|
+
readonly source: SearchSource<TContext, TValue>;
|
|
12
|
+
dispose(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The registry owns each installed effect until its lease or the registry is
|
|
16
|
+
* disposed. Duplicate effect acquisitions are rolled back immediately.
|
|
17
|
+
*/
|
|
18
|
+
export declare class SearchSourceRegistry<TContext = unknown> {
|
|
19
|
+
#private;
|
|
20
|
+
register<TValue>(source: SearchSource<TContext, TValue>, cleanup?: () => MaybePromise<void>): SearchSourceLease<TContext, TValue>;
|
|
21
|
+
install<TValue>(effect: SearchSourceEffect<TContext, TValue>): Promise<SearchSourceLease<TContext, TValue>>;
|
|
22
|
+
get(source: string | Pick<SearchSource<TContext>["descriptor"], "pluginId" | "sourceId">): SearchSource<TContext, any> | undefined;
|
|
23
|
+
list(): readonly SearchSource<TContext, any>[];
|
|
24
|
+
get size(): number;
|
|
25
|
+
get disposed(): boolean;
|
|
26
|
+
dispose(): Promise<void>;
|
|
27
|
+
}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { searchSourceKey, validateSearchSourceDescriptor } from "./naming.js";
|
|
2
|
+
export class DuplicateSearchSourceError extends Error {
|
|
3
|
+
sourceKey;
|
|
4
|
+
constructor(sourceKey) {
|
|
5
|
+
super(`Search source is already registered: ${sourceKey}`);
|
|
6
|
+
this.name = "DuplicateSearchSourceError";
|
|
7
|
+
this.sourceKey = sourceKey;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class DisposedSearchSourceRegistryError extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super("Search source registry has been disposed");
|
|
13
|
+
this.name = "DisposedSearchSourceRegistryError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function compareText(left, right) {
|
|
17
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The registry owns each installed effect until its lease or the registry is
|
|
21
|
+
* disposed. Duplicate effect acquisitions are rolled back immediately.
|
|
22
|
+
*/
|
|
23
|
+
export class SearchSourceRegistry {
|
|
24
|
+
#entries = new Map();
|
|
25
|
+
#disposed = false;
|
|
26
|
+
register(source, cleanup) {
|
|
27
|
+
if (this.#disposed)
|
|
28
|
+
throw new DisposedSearchSourceRegistryError();
|
|
29
|
+
validateSearchSourceDescriptor(source.descriptor);
|
|
30
|
+
if (typeof source.authorize !== "function" ||
|
|
31
|
+
typeof source.hydrate !== "function")
|
|
32
|
+
throw new TypeError("Search sources must provide authorize and hydrate");
|
|
33
|
+
const key = searchSourceKey(source.descriptor);
|
|
34
|
+
if (this.#entries.has(key))
|
|
35
|
+
throw new DuplicateSearchSourceError(key);
|
|
36
|
+
const token = Symbol(key);
|
|
37
|
+
let active = true;
|
|
38
|
+
const release = async () => {
|
|
39
|
+
if (!active)
|
|
40
|
+
return;
|
|
41
|
+
active = false;
|
|
42
|
+
const current = this.#entries.get(key);
|
|
43
|
+
if (current?.token === token)
|
|
44
|
+
this.#entries.delete(key);
|
|
45
|
+
await cleanup?.();
|
|
46
|
+
};
|
|
47
|
+
this.#entries.set(key, { token, source, release });
|
|
48
|
+
return { key, source, dispose: release };
|
|
49
|
+
}
|
|
50
|
+
async install(effect) {
|
|
51
|
+
const resource = await effect.acquire();
|
|
52
|
+
try {
|
|
53
|
+
return this.register(resource.source, resource.dispose);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (!resource.dispose)
|
|
57
|
+
throw error;
|
|
58
|
+
try {
|
|
59
|
+
await resource.dispose();
|
|
60
|
+
}
|
|
61
|
+
catch (disposeError) {
|
|
62
|
+
throw new AggregateError([error, disposeError], "Search source installation and rollback both failed");
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
get(source) {
|
|
68
|
+
const key = typeof source === "string" ? source : searchSourceKey(source);
|
|
69
|
+
return this.#entries.get(key)?.source;
|
|
70
|
+
}
|
|
71
|
+
list() {
|
|
72
|
+
return [...this.#entries.entries()]
|
|
73
|
+
.sort(([left], [right]) => compareText(left, right))
|
|
74
|
+
.map(([, entry]) => entry.source);
|
|
75
|
+
}
|
|
76
|
+
get size() {
|
|
77
|
+
return this.#entries.size;
|
|
78
|
+
}
|
|
79
|
+
get disposed() {
|
|
80
|
+
return this.#disposed;
|
|
81
|
+
}
|
|
82
|
+
async dispose() {
|
|
83
|
+
if (this.#disposed)
|
|
84
|
+
return;
|
|
85
|
+
this.#disposed = true;
|
|
86
|
+
const entries = [...this.#entries.values()];
|
|
87
|
+
this.#entries.clear();
|
|
88
|
+
const results = await Promise.allSettled(entries.map((entry) => entry.release()));
|
|
89
|
+
const failures = results
|
|
90
|
+
.filter((result) => result.status === "rejected")
|
|
91
|
+
.map((result) => result.reason);
|
|
92
|
+
if (failures.length)
|
|
93
|
+
throw new AggregateError(failures, "Failed to dispose search sources");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type SearchIndexNames } from "./naming.js";
|
|
2
|
+
import { SearchSourceRegistry } from "./registry.js";
|
|
3
|
+
import type { FederatedSearchResult, SearchProjection, SearchProvider, SearchSourceDescriptor } from "./types.js";
|
|
4
|
+
export type SearchSourceReference = string | Pick<SearchSourceDescriptor, "pluginId" | "sourceId">;
|
|
5
|
+
export interface ProjectionUpsertRequest {
|
|
6
|
+
readonly source: SearchSourceReference;
|
|
7
|
+
readonly projections: readonly SearchProjection[];
|
|
8
|
+
}
|
|
9
|
+
export interface ProjectionDeleteRequest {
|
|
10
|
+
readonly source: SearchSourceReference;
|
|
11
|
+
readonly documentIds: readonly string[];
|
|
12
|
+
readonly deletedAt?: Date;
|
|
13
|
+
}
|
|
14
|
+
export interface SearchRebuildRequest<TContext> {
|
|
15
|
+
readonly source: SearchSourceReference;
|
|
16
|
+
readonly context: TContext;
|
|
17
|
+
readonly version?: string;
|
|
18
|
+
readonly retainPrevious?: boolean;
|
|
19
|
+
readonly signal?: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
export interface SearchRebuildResult {
|
|
22
|
+
readonly alias: string;
|
|
23
|
+
readonly indexName: string;
|
|
24
|
+
readonly previousIndex?: string;
|
|
25
|
+
readonly projectionCount: number;
|
|
26
|
+
}
|
|
27
|
+
export interface SearchIndexSwitchRequest {
|
|
28
|
+
readonly source: SearchSourceReference;
|
|
29
|
+
readonly indexName: string;
|
|
30
|
+
readonly deletePrevious?: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface SearchIndexSwitchResult {
|
|
33
|
+
readonly alias: string;
|
|
34
|
+
readonly indexName: string;
|
|
35
|
+
readonly previousIndex?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface FederatedSearchRequest<TContext> {
|
|
38
|
+
readonly query: string;
|
|
39
|
+
readonly context: TContext;
|
|
40
|
+
readonly sources?: readonly SearchSourceReference[];
|
|
41
|
+
readonly offset?: number;
|
|
42
|
+
readonly limit?: number;
|
|
43
|
+
}
|
|
44
|
+
export interface SearchHostOptions<TContext> {
|
|
45
|
+
readonly registry?: SearchSourceRegistry<TContext>;
|
|
46
|
+
readonly namespace?: string;
|
|
47
|
+
readonly now?: () => Date;
|
|
48
|
+
readonly rebuildBatchSize?: number;
|
|
49
|
+
}
|
|
50
|
+
export interface SearchHost<TContext = unknown> {
|
|
51
|
+
readonly registry: SearchSourceRegistry<TContext>;
|
|
52
|
+
indexNames(source: SearchSourceReference): SearchIndexNames;
|
|
53
|
+
upsertProjections(request: ProjectionUpsertRequest): Promise<void>;
|
|
54
|
+
deleteProjections(request: ProjectionDeleteRequest): Promise<void>;
|
|
55
|
+
rebuild(request: SearchRebuildRequest<TContext>): Promise<SearchRebuildResult>;
|
|
56
|
+
switchIndex(request: SearchIndexSwitchRequest): Promise<SearchIndexSwitchResult>;
|
|
57
|
+
query<TValue = unknown>(request: FederatedSearchRequest<TContext>): Promise<FederatedSearchResult<TValue>>;
|
|
58
|
+
dispose(): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
export declare class SearchSourceNotFoundError extends Error {
|
|
61
|
+
readonly source: SearchSourceReference;
|
|
62
|
+
constructor(source: SearchSourceReference);
|
|
63
|
+
}
|
|
64
|
+
export declare function createSearchHost<TContext = unknown>(provider: SearchProvider, options?: SearchHostOptions<TContext>): SearchHost<TContext>;
|