@kernl-sdk/turbopuffer 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/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-check-types.log +60 -0
- package/CHANGELOG.md +33 -0
- package/LICENSE +201 -0
- package/README.md +60 -0
- package/dist/__tests__/convert.test.d.ts +2 -0
- package/dist/__tests__/convert.test.d.ts.map +1 -0
- package/dist/__tests__/convert.test.js +346 -0
- package/dist/__tests__/filter.test.d.ts +8 -0
- package/dist/__tests__/filter.test.d.ts.map +1 -0
- package/dist/__tests__/filter.test.js +649 -0
- package/dist/__tests__/filters.integration.test.d.ts +8 -0
- package/dist/__tests__/filters.integration.test.d.ts.map +1 -0
- package/dist/__tests__/filters.integration.test.js +502 -0
- package/dist/__tests__/integration/filters.integration.test.d.ts +8 -0
- package/dist/__tests__/integration/filters.integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration/filters.integration.test.js +475 -0
- package/dist/__tests__/integration/integration.test.d.ts +2 -0
- package/dist/__tests__/integration/integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration/integration.test.js +329 -0
- package/dist/__tests__/integration/lifecycle.integration.test.d.ts +8 -0
- package/dist/__tests__/integration/lifecycle.integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration/lifecycle.integration.test.js +370 -0
- package/dist/__tests__/integration/memory.integration.test.d.ts +2 -0
- package/dist/__tests__/integration/memory.integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration/memory.integration.test.js +287 -0
- package/dist/__tests__/integration/query.integration.test.d.ts +8 -0
- package/dist/__tests__/integration/query.integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration/query.integration.test.js +385 -0
- package/dist/__tests__/integration.test.d.ts +2 -0
- package/dist/__tests__/integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration.test.js +343 -0
- package/dist/__tests__/lifecycle.integration.test.d.ts +8 -0
- package/dist/__tests__/lifecycle.integration.test.d.ts.map +1 -0
- package/dist/__tests__/lifecycle.integration.test.js +385 -0
- package/dist/__tests__/query.integration.test.d.ts +8 -0
- package/dist/__tests__/query.integration.test.d.ts.map +1 -0
- package/dist/__tests__/query.integration.test.js +423 -0
- package/dist/__tests__/query.test.d.ts +8 -0
- package/dist/__tests__/query.test.d.ts.map +1 -0
- package/dist/__tests__/query.test.js +472 -0
- package/dist/convert/document.d.ts +20 -0
- package/dist/convert/document.d.ts.map +1 -0
- package/dist/convert/document.js +72 -0
- package/dist/convert/filter.d.ts +15 -0
- package/dist/convert/filter.d.ts.map +1 -0
- package/dist/convert/filter.js +109 -0
- package/dist/convert/index.d.ts +8 -0
- package/dist/convert/index.d.ts.map +1 -0
- package/dist/convert/index.js +7 -0
- package/dist/convert/query.d.ts +22 -0
- package/dist/convert/query.d.ts.map +1 -0
- package/dist/convert/query.js +111 -0
- package/dist/convert/schema.d.ts +39 -0
- package/dist/convert/schema.d.ts.map +1 -0
- package/dist/convert/schema.js +124 -0
- package/dist/convert.d.ts +68 -0
- package/dist/convert.d.ts.map +1 -0
- package/dist/convert.js +333 -0
- package/dist/handle.d.ts +34 -0
- package/dist/handle.d.ts.map +1 -0
- package/dist/handle.js +72 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/search.d.ts +85 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +167 -0
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +57 -0
- package/src/__tests__/convert.test.ts +425 -0
- package/src/__tests__/filter.test.ts +730 -0
- package/src/__tests__/integration/filters.integration.test.ts +558 -0
- package/src/__tests__/integration/integration.test.ts +399 -0
- package/src/__tests__/integration/lifecycle.integration.test.ts +464 -0
- package/src/__tests__/integration/memory.integration.test.ts +353 -0
- package/src/__tests__/integration/query.integration.test.ts +471 -0
- package/src/__tests__/query.test.ts +636 -0
- package/src/convert/document.ts +95 -0
- package/src/convert/filter.ts +123 -0
- package/src/convert/index.ts +8 -0
- package/src/convert/query.ts +151 -0
- package/src/convert/schema.ts +163 -0
- package/src/handle.ts +104 -0
- package/src/index.ts +31 -0
- package/src/search.ts +207 -0
- package/src/types.ts +14 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +15 -0
package/dist/search.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turbopuffer SearchIndex implementation.
|
|
3
|
+
*
|
|
4
|
+
* Adapts the Turbopuffer vector database to the kernl SearchIndex interface.
|
|
5
|
+
* Turbopuffer uses "namespaces" as the equivalent of our "indexes".
|
|
6
|
+
*
|
|
7
|
+
* @see https://turbopuffer.com/docs
|
|
8
|
+
*/
|
|
9
|
+
import Turbopuffer from "@turbopuffer/turbopuffer";
|
|
10
|
+
import { CursorPage } from "@kernl-sdk/shared";
|
|
11
|
+
import { TurbopufferIndexHandle } from "./handle.js";
|
|
12
|
+
import { INDEX_SCHEMA, SIMILARITY } from "./convert.js";
|
|
13
|
+
/**
|
|
14
|
+
* Turbopuffer search index adapter.
|
|
15
|
+
*
|
|
16
|
+
* Implements the kernl SearchIndex interface backed by Turbopuffer's
|
|
17
|
+
* vector database. Supports vector search, full-text search (BM25),
|
|
18
|
+
* and hybrid queries.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* const tpuf = turbopuffer({
|
|
23
|
+
* apiKey: "your-api-key",
|
|
24
|
+
* region: "us-east-1",
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* const docs = tpuf.index("my-index");
|
|
28
|
+
*
|
|
29
|
+
* await docs.upsert({
|
|
30
|
+
* id: "doc-1",
|
|
31
|
+
* text: "Hello world",
|
|
32
|
+
* vector: [0.1, 0.2, ...],
|
|
33
|
+
* });
|
|
34
|
+
*
|
|
35
|
+
* const hits = await docs.query({
|
|
36
|
+
* query: [{ vector: [0.1, 0.2, ...] }],
|
|
37
|
+
* topK: 10,
|
|
38
|
+
* });
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export class TurbopufferSearchIndex {
|
|
42
|
+
id = "turbopuffer";
|
|
43
|
+
client;
|
|
44
|
+
/**
|
|
45
|
+
* Create a new Turbopuffer search index.
|
|
46
|
+
*/
|
|
47
|
+
constructor(config) {
|
|
48
|
+
this.client = new Turbopuffer({
|
|
49
|
+
apiKey: config.apiKey,
|
|
50
|
+
region: config.region,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/* ---- Index lifecycle ---- */
|
|
54
|
+
/**
|
|
55
|
+
* Create a new index (namespace) in Turbopuffer.
|
|
56
|
+
*/
|
|
57
|
+
async createIndex(params) {
|
|
58
|
+
const ns = this.client.namespace(params.id);
|
|
59
|
+
const schema = INDEX_SCHEMA.encode(params.schema);
|
|
60
|
+
const placeholder = "__kernl_placeholder__";
|
|
61
|
+
const vfield = params.schema.vector;
|
|
62
|
+
const vector = vfield?.type === "vector";
|
|
63
|
+
// implicit creation via first write
|
|
64
|
+
await ns.write({
|
|
65
|
+
schema,
|
|
66
|
+
distance_metric: vector
|
|
67
|
+
? SIMILARITY.encode(vfield.similarity)
|
|
68
|
+
: undefined,
|
|
69
|
+
upsert_rows: [
|
|
70
|
+
{
|
|
71
|
+
id: placeholder,
|
|
72
|
+
vector: vector ? new Array(vfield.dimensions).fill(0) : undefined,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
await ns.write({ deletes: [placeholder] });
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* List indexes (namespaces) with optional pagination and prefix filtering.
|
|
80
|
+
*/
|
|
81
|
+
async listIndexes(params = {}) {
|
|
82
|
+
const loader = async (p) => {
|
|
83
|
+
const page = await this.client.namespaces({
|
|
84
|
+
prefix: p.prefix,
|
|
85
|
+
cursor: p.cursor,
|
|
86
|
+
page_size: p.limit,
|
|
87
|
+
});
|
|
88
|
+
const indexes = [];
|
|
89
|
+
for await (const ns of page) {
|
|
90
|
+
indexes.push({ id: ns.id });
|
|
91
|
+
if (p.limit !== undefined && indexes.length >= p.limit) {
|
|
92
|
+
break; // respect limit if specified
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
data: indexes,
|
|
97
|
+
next: page.next_cursor ?? null,
|
|
98
|
+
last: !page.next_cursor,
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
const response = await loader(params);
|
|
102
|
+
return new CursorPage({
|
|
103
|
+
params,
|
|
104
|
+
response,
|
|
105
|
+
loader,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Get metadata and statistics about an index (namespace).
|
|
110
|
+
*/
|
|
111
|
+
async describeIndex(id) {
|
|
112
|
+
const ns = this.client.namespace(id);
|
|
113
|
+
const metadata = await ns.metadata();
|
|
114
|
+
return {
|
|
115
|
+
id,
|
|
116
|
+
count: metadata.approx_row_count,
|
|
117
|
+
sizeb: metadata.approx_logical_bytes,
|
|
118
|
+
status: "ready",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Delete an index (namespace) and all its documents.
|
|
123
|
+
*/
|
|
124
|
+
async deleteIndex(id) {
|
|
125
|
+
const ns = this.client.namespace(id);
|
|
126
|
+
await ns.deleteAll();
|
|
127
|
+
}
|
|
128
|
+
/* ---- Index handle ---- */
|
|
129
|
+
/**
|
|
130
|
+
* Get a handle for operating on a specific index.
|
|
131
|
+
*/
|
|
132
|
+
index(id) {
|
|
133
|
+
return new TurbopufferIndexHandle(this.client, id);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Bind an existing resource as an index.
|
|
137
|
+
*
|
|
138
|
+
* Not supported by Turbopuffer - indexes are created implicitly.
|
|
139
|
+
*/
|
|
140
|
+
async bindIndex(_id, _config) {
|
|
141
|
+
throw new Error("bindIndex not supported by Turbopuffer");
|
|
142
|
+
}
|
|
143
|
+
/* ---- Utility ---- */
|
|
144
|
+
/**
|
|
145
|
+
* Warm/preload an index for faster queries.
|
|
146
|
+
*/
|
|
147
|
+
async warm(id) {
|
|
148
|
+
const ns = this.client.namespace(id);
|
|
149
|
+
await ns.hintCacheWarm();
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Turbopuffer capabilities.
|
|
153
|
+
*
|
|
154
|
+
* Note: Turbopuffer supports text (BM25) and vector (ANN) separately,
|
|
155
|
+
* but not hybrid fusion (text + vector in the same query).
|
|
156
|
+
*/
|
|
157
|
+
capabilities() {
|
|
158
|
+
return {
|
|
159
|
+
modes: new Set(["vector", "text"]),
|
|
160
|
+
multiSignal: false, // Can't mix text + vector signals
|
|
161
|
+
multiVector: false, // Only one ANN query per request
|
|
162
|
+
multiText: true, // Can fuse multiple BM25 queries
|
|
163
|
+
filters: true,
|
|
164
|
+
orderBy: true,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for the Turbopuffer search index.
|
|
3
|
+
*/
|
|
4
|
+
export interface TurbopufferConfig {
|
|
5
|
+
/**
|
|
6
|
+
* Turbopuffer API key.
|
|
7
|
+
*/
|
|
8
|
+
apiKey: string;
|
|
9
|
+
/**
|
|
10
|
+
* Turbopuffer region (e.g. "us-east-1", "eu-west-1").
|
|
11
|
+
*/
|
|
12
|
+
region: string;
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;IAEf;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;CAChB"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kernl-sdk/turbopuffer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Turbopuffer search index adapter for kernl",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"kernl",
|
|
7
|
+
"turbopuffer",
|
|
8
|
+
"vector",
|
|
9
|
+
"search"
|
|
10
|
+
],
|
|
11
|
+
"author": "dremnik",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/kernl-sdk/kernl.git",
|
|
16
|
+
"directory": "packages/storage/turbopuffer"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/kernl-sdk/kernl#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/kernl-sdk/kernl/issues"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"import": "./dist/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@turbopuffer/turbopuffer": "^0.10.0",
|
|
34
|
+
"@kernl-sdk/retrieval": "0.1.0",
|
|
35
|
+
"@kernl-sdk/shared": "0.1.6"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^24.10.0",
|
|
39
|
+
"@types/pg": "^8.15.2",
|
|
40
|
+
"pg": "^8.16.0",
|
|
41
|
+
"tsc-alias": "^1.8.10",
|
|
42
|
+
"typescript": "5.9.2",
|
|
43
|
+
"vitest": "^4.0.8",
|
|
44
|
+
"@kernl-sdk/pg": "0.1.12",
|
|
45
|
+
"@kernl-sdk/protocol": "0.2.5",
|
|
46
|
+
"@kernl-sdk/ai": "0.2.7",
|
|
47
|
+
"kernl": "0.6.3"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsc && tsc-alias --resolve-full-paths",
|
|
51
|
+
"dev": "tsc --watch",
|
|
52
|
+
"check-types": "tsc --noEmit",
|
|
53
|
+
"test": "vitest",
|
|
54
|
+
"test:watch": "vitest --watch",
|
|
55
|
+
"test:run": "vitest run"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { DOCUMENT, PATCH } from "../convert/document";
|
|
4
|
+
import { FILTER } from "../convert/filter";
|
|
5
|
+
import { QUERY, SEARCH_HIT } from "../convert/query";
|
|
6
|
+
import { SCALAR_TYPE, SIMILARITY, INDEX_SCHEMA } from "../convert/schema";
|
|
7
|
+
|
|
8
|
+
describe("convert", () => {
|
|
9
|
+
describe("SCALAR_TYPE", () => {
|
|
10
|
+
it("encodes kernl types to turbopuffer types", () => {
|
|
11
|
+
expect(SCALAR_TYPE.encode("string")).toBe("string");
|
|
12
|
+
expect(SCALAR_TYPE.encode("int")).toBe("int");
|
|
13
|
+
expect(SCALAR_TYPE.encode("float")).toBe("int"); // tpuf doesn't have float
|
|
14
|
+
expect(SCALAR_TYPE.encode("boolean")).toBe("bool");
|
|
15
|
+
expect(SCALAR_TYPE.encode("date")).toBe("datetime");
|
|
16
|
+
expect(SCALAR_TYPE.encode("string[]")).toBe("[]string");
|
|
17
|
+
expect(SCALAR_TYPE.encode("int[]")).toBe("[]int");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("decodes turbopuffer types to kernl types", () => {
|
|
21
|
+
expect(SCALAR_TYPE.decode("string")).toBe("string");
|
|
22
|
+
expect(SCALAR_TYPE.decode("int")).toBe("int");
|
|
23
|
+
expect(SCALAR_TYPE.decode("bool")).toBe("boolean");
|
|
24
|
+
expect(SCALAR_TYPE.decode("datetime")).toBe("date");
|
|
25
|
+
expect(SCALAR_TYPE.decode("[]string")).toBe("string[]");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("SIMILARITY", () => {
|
|
30
|
+
it("encodes similarity to distance metric", () => {
|
|
31
|
+
expect(SIMILARITY.encode("cosine")).toBe("cosine_distance");
|
|
32
|
+
expect(SIMILARITY.encode("euclidean")).toBe("euclidean_squared");
|
|
33
|
+
expect(SIMILARITY.encode("dot_product")).toBe("cosine_distance");
|
|
34
|
+
expect(SIMILARITY.encode(undefined)).toBe("cosine_distance");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("decodes distance metric to similarity", () => {
|
|
38
|
+
expect(SIMILARITY.decode("cosine_distance")).toBe("cosine");
|
|
39
|
+
expect(SIMILARITY.decode("euclidean_squared")).toBe("euclidean");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("INDEX_SCHEMA", () => {
|
|
44
|
+
it("encodes a schema with scalar fields", () => {
|
|
45
|
+
const result = INDEX_SCHEMA.encode({
|
|
46
|
+
title: { type: "string", filterable: true },
|
|
47
|
+
count: { type: "int" },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(result.title).toEqual({ type: "string", filterable: true });
|
|
51
|
+
expect(result.count).toEqual({ type: "int" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("encodes a schema with vector field", () => {
|
|
55
|
+
const result = INDEX_SCHEMA.encode({
|
|
56
|
+
vector: { type: "vector", dimensions: 384 },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(result.vector).toEqual({ type: "[384]f32", ann: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("throws if vector field is not named vector", () => {
|
|
63
|
+
expect(() =>
|
|
64
|
+
INDEX_SCHEMA.encode({
|
|
65
|
+
embedding: { type: "vector", dimensions: 384 },
|
|
66
|
+
}),
|
|
67
|
+
).toThrow(/requires vector fields to be named "vector"/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("encodes fts fields", () => {
|
|
71
|
+
const result = INDEX_SCHEMA.encode({
|
|
72
|
+
content: { type: "string", fts: true },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(result.content).toEqual({
|
|
76
|
+
type: "string",
|
|
77
|
+
full_text_search: true,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("DOCUMENT", () => {
|
|
83
|
+
it("encodes flat document with scalar fields", () => {
|
|
84
|
+
const result = DOCUMENT.encode({
|
|
85
|
+
id: "doc-1",
|
|
86
|
+
title: "Hello",
|
|
87
|
+
count: 42,
|
|
88
|
+
active: true,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(result).toEqual({
|
|
92
|
+
id: "doc-1",
|
|
93
|
+
title: "Hello",
|
|
94
|
+
count: 42,
|
|
95
|
+
active: true,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("encodes flat document with vector field", () => {
|
|
100
|
+
const result = DOCUMENT.encode({
|
|
101
|
+
id: "doc-2",
|
|
102
|
+
vector: [0.1, 0.2, 0.3],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result).toEqual({
|
|
106
|
+
id: "doc-2",
|
|
107
|
+
vector: [0.1, 0.2, 0.3],
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("throws if id is missing", () => {
|
|
112
|
+
expect(() => DOCUMENT.encode({ title: "No ID" })).toThrow(
|
|
113
|
+
'Document must have a string "id" field',
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("PATCH", () => {
|
|
119
|
+
it("encodes flat patch with field updates", () => {
|
|
120
|
+
const result = PATCH.encode({
|
|
121
|
+
id: "doc-1",
|
|
122
|
+
title: "Updated",
|
|
123
|
+
count: 100,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(result).toEqual({
|
|
127
|
+
id: "doc-1",
|
|
128
|
+
title: "Updated",
|
|
129
|
+
count: 100,
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("encodes null values to unset fields", () => {
|
|
134
|
+
const result = PATCH.encode({
|
|
135
|
+
id: "doc-1",
|
|
136
|
+
title: "Keep this",
|
|
137
|
+
description: null,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(result).toEqual({
|
|
141
|
+
id: "doc-1",
|
|
142
|
+
title: "Keep this",
|
|
143
|
+
description: null,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("encodes vector updates", () => {
|
|
148
|
+
const result = PATCH.encode({
|
|
149
|
+
id: "doc-1",
|
|
150
|
+
vector: [0.5, 0.6],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(result).toEqual({
|
|
154
|
+
id: "doc-1",
|
|
155
|
+
vector: [0.5, 0.6],
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("throws if id is missing", () => {
|
|
160
|
+
expect(() => PATCH.encode({ title: "No ID" })).toThrow(
|
|
161
|
+
'Patch must have a string "id" field',
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("FILTER", () => {
|
|
167
|
+
// Simple equality (shorthand)
|
|
168
|
+
it("encodes simple equality filter", () => {
|
|
169
|
+
expect(FILTER.encode({ f: "x" })).toEqual(["f", "Eq", "x"]);
|
|
170
|
+
expect(FILTER.encode({ f: 42 })).toEqual(["f", "Eq", 42]);
|
|
171
|
+
expect(FILTER.encode({ f: true })).toEqual(["f", "Eq", true]);
|
|
172
|
+
expect(FILTER.encode({ f: null })).toEqual(["f", "Eq", null]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Field operators
|
|
176
|
+
it("encodes $eq operator", () => {
|
|
177
|
+
expect(FILTER.encode({ f: { $eq: "x" } })).toEqual(["f", "Eq", "x"]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("encodes $neq operator", () => {
|
|
181
|
+
expect(FILTER.encode({ f: { $neq: "x" } })).toEqual(["f", "NotEq", "x"]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("encodes comparison operators", () => {
|
|
185
|
+
expect(FILTER.encode({ f: { $gt: 10 } })).toEqual(["f", "Gt", 10]);
|
|
186
|
+
expect(FILTER.encode({ f: { $gte: 10 } })).toEqual(["f", "Gte", 10]);
|
|
187
|
+
expect(FILTER.encode({ f: { $lt: 10 } })).toEqual(["f", "Lt", 10]);
|
|
188
|
+
expect(FILTER.encode({ f: { $lte: 10 } })).toEqual(["f", "Lte", 10]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("encodes set membership operators", () => {
|
|
192
|
+
expect(FILTER.encode({ f: { $in: ["a", "b"] } })).toEqual(["f", "In", ["a", "b"]]);
|
|
193
|
+
expect(FILTER.encode({ f: { $nin: ["a", "b"] } })).toEqual(["f", "NotIn", ["a", "b"]]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("encodes $contains operator", () => {
|
|
197
|
+
expect(FILTER.encode({ f: { $contains: "x" } })).toEqual(["f", "Contains", "x"]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("encodes string pattern operators as Glob", () => {
|
|
201
|
+
expect(FILTER.encode({ f: { $startsWith: "pre" } })).toEqual(["f", "Glob", "pre*"]);
|
|
202
|
+
expect(FILTER.encode({ f: { $endsWith: "suf" } })).toEqual(["f", "Glob", "*suf"]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("encodes $exists operator", () => {
|
|
206
|
+
expect(FILTER.encode({ f: { $exists: true } })).toEqual(["f", "NotEq", null]);
|
|
207
|
+
expect(FILTER.encode({ f: { $exists: false } })).toEqual(["f", "Eq", null]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Logical operators
|
|
211
|
+
it("encodes $and filter", () => {
|
|
212
|
+
const result = FILTER.encode({
|
|
213
|
+
$and: [{ a: 1 }, { b: 2 }],
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
expect(result).toEqual([
|
|
217
|
+
"And",
|
|
218
|
+
[
|
|
219
|
+
["a", "Eq", 1],
|
|
220
|
+
["b", "Eq", 2],
|
|
221
|
+
],
|
|
222
|
+
]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("encodes $or filter", () => {
|
|
226
|
+
const result = FILTER.encode({
|
|
227
|
+
$or: [{ status: "active" }, { status: "pending" }],
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(result).toEqual([
|
|
231
|
+
"Or",
|
|
232
|
+
[
|
|
233
|
+
["status", "Eq", "active"],
|
|
234
|
+
["status", "Eq", "pending"],
|
|
235
|
+
],
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("encodes $not filter", () => {
|
|
240
|
+
const result = FILTER.encode({
|
|
241
|
+
$not: { deleted: true },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(result).toEqual(["Not", ["deleted", "Eq", true]]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("encodes deeply nested filters", () => {
|
|
248
|
+
const result = FILTER.encode({
|
|
249
|
+
$and: [
|
|
250
|
+
{ active: true },
|
|
251
|
+
{
|
|
252
|
+
$or: [
|
|
253
|
+
{ role: "admin" },
|
|
254
|
+
{
|
|
255
|
+
$and: [{ role: "user" }, { verified: true }],
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(result).toEqual([
|
|
263
|
+
"And",
|
|
264
|
+
[
|
|
265
|
+
["active", "Eq", true],
|
|
266
|
+
[
|
|
267
|
+
"Or",
|
|
268
|
+
[
|
|
269
|
+
["role", "Eq", "admin"],
|
|
270
|
+
[
|
|
271
|
+
"And",
|
|
272
|
+
[
|
|
273
|
+
["role", "Eq", "user"],
|
|
274
|
+
["verified", "Eq", true],
|
|
275
|
+
],
|
|
276
|
+
],
|
|
277
|
+
],
|
|
278
|
+
],
|
|
279
|
+
],
|
|
280
|
+
]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("encodes multiple fields as implicit AND", () => {
|
|
284
|
+
const result = FILTER.encode({ a: 1, b: 2 });
|
|
285
|
+
expect(result).toEqual([
|
|
286
|
+
"And",
|
|
287
|
+
[
|
|
288
|
+
["a", "Eq", 1],
|
|
289
|
+
["b", "Eq", 2],
|
|
290
|
+
],
|
|
291
|
+
]);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("QUERY", () => {
|
|
296
|
+
it("encodes vector search query", () => {
|
|
297
|
+
const result = QUERY.encode({
|
|
298
|
+
query: [{ vector: [0.1, 0.2, 0.3] }],
|
|
299
|
+
topK: 10,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
expect(result.rank_by).toEqual(["vector", "ANN", [0.1, 0.2, 0.3]]);
|
|
303
|
+
expect(result.top_k).toBe(10);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("encodes text search query", () => {
|
|
307
|
+
const result = QUERY.encode({
|
|
308
|
+
query: [{ content: "hello world" }],
|
|
309
|
+
topK: 5,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(result.rank_by).toEqual(["content", "BM25", "hello world"]);
|
|
313
|
+
expect(result.top_k).toBe(5);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("encodes multi-field text search as Sum", () => {
|
|
317
|
+
const result = QUERY.encode({
|
|
318
|
+
query: [{ title: "search term" }, { body: "search term" }],
|
|
319
|
+
topK: 10,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
expect(result.rank_by).toEqual([
|
|
323
|
+
"Sum",
|
|
324
|
+
[
|
|
325
|
+
["title", "BM25", "search term"],
|
|
326
|
+
["body", "BM25", "search term"],
|
|
327
|
+
],
|
|
328
|
+
]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("encodes filter", () => {
|
|
332
|
+
const result = QUERY.encode({
|
|
333
|
+
query: [{ vector: [0.1] }],
|
|
334
|
+
topK: 5,
|
|
335
|
+
filter: { category: { $eq: "news" } },
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
expect(result.filters).toEqual(["category", "Eq", "news"]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("encodes include fields array", () => {
|
|
342
|
+
const result = QUERY.encode({
|
|
343
|
+
query: [{ vector: [0.1] }],
|
|
344
|
+
topK: 5,
|
|
345
|
+
include: ["title", "content"],
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
expect(result.include_attributes).toEqual(["title", "content"]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("encodes include as boolean", () => {
|
|
352
|
+
const result = QUERY.encode({
|
|
353
|
+
query: [{ vector: [0.1] }],
|
|
354
|
+
topK: 5,
|
|
355
|
+
include: true,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
expect(result.include_attributes).toBe(true);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe("SEARCH_HIT", () => {
|
|
363
|
+
it("decodes row to search hit", () => {
|
|
364
|
+
const result = SEARCH_HIT.decode(
|
|
365
|
+
{
|
|
366
|
+
id: "doc-1",
|
|
367
|
+
$dist: 0.5,
|
|
368
|
+
title: "Test",
|
|
369
|
+
category: "news",
|
|
370
|
+
},
|
|
371
|
+
"my-index",
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
expect(result).toEqual({
|
|
375
|
+
id: "doc-1",
|
|
376
|
+
index: "my-index",
|
|
377
|
+
score: -0.5, // negated: distance → similarity
|
|
378
|
+
document: {
|
|
379
|
+
id: "doc-1",
|
|
380
|
+
title: "Test",
|
|
381
|
+
category: "news",
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("decodes row with vector", () => {
|
|
387
|
+
const result = SEARCH_HIT.decode(
|
|
388
|
+
{
|
|
389
|
+
id: "doc-2",
|
|
390
|
+
$dist: 0.1,
|
|
391
|
+
vector: [0.1, 0.2, 0.3],
|
|
392
|
+
},
|
|
393
|
+
"my-index",
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
expect(result.document?.id).toBe("doc-2");
|
|
397
|
+
expect(result.document?.vector).toEqual([0.1, 0.2, 0.3]);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("handles missing score", () => {
|
|
401
|
+
const result = SEARCH_HIT.decode(
|
|
402
|
+
{
|
|
403
|
+
id: "doc-3",
|
|
404
|
+
},
|
|
405
|
+
"my-index",
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
expect(result.score).toBe(0);
|
|
409
|
+
expect(result.document).toEqual({ id: "doc-3" });
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("converts numeric id to string", () => {
|
|
413
|
+
const result = SEARCH_HIT.decode(
|
|
414
|
+
{
|
|
415
|
+
id: 123,
|
|
416
|
+
$dist: 0.1,
|
|
417
|
+
},
|
|
418
|
+
"my-index",
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
expect(result.id).toBe("123");
|
|
422
|
+
expect(result.document?.id).toBe(123); // document keeps original type
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
});
|