@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.
Files changed (91) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-check-types.log +60 -0
  3. package/CHANGELOG.md +33 -0
  4. package/LICENSE +201 -0
  5. package/README.md +60 -0
  6. package/dist/__tests__/convert.test.d.ts +2 -0
  7. package/dist/__tests__/convert.test.d.ts.map +1 -0
  8. package/dist/__tests__/convert.test.js +346 -0
  9. package/dist/__tests__/filter.test.d.ts +8 -0
  10. package/dist/__tests__/filter.test.d.ts.map +1 -0
  11. package/dist/__tests__/filter.test.js +649 -0
  12. package/dist/__tests__/filters.integration.test.d.ts +8 -0
  13. package/dist/__tests__/filters.integration.test.d.ts.map +1 -0
  14. package/dist/__tests__/filters.integration.test.js +502 -0
  15. package/dist/__tests__/integration/filters.integration.test.d.ts +8 -0
  16. package/dist/__tests__/integration/filters.integration.test.d.ts.map +1 -0
  17. package/dist/__tests__/integration/filters.integration.test.js +475 -0
  18. package/dist/__tests__/integration/integration.test.d.ts +2 -0
  19. package/dist/__tests__/integration/integration.test.d.ts.map +1 -0
  20. package/dist/__tests__/integration/integration.test.js +329 -0
  21. package/dist/__tests__/integration/lifecycle.integration.test.d.ts +8 -0
  22. package/dist/__tests__/integration/lifecycle.integration.test.d.ts.map +1 -0
  23. package/dist/__tests__/integration/lifecycle.integration.test.js +370 -0
  24. package/dist/__tests__/integration/memory.integration.test.d.ts +2 -0
  25. package/dist/__tests__/integration/memory.integration.test.d.ts.map +1 -0
  26. package/dist/__tests__/integration/memory.integration.test.js +287 -0
  27. package/dist/__tests__/integration/query.integration.test.d.ts +8 -0
  28. package/dist/__tests__/integration/query.integration.test.d.ts.map +1 -0
  29. package/dist/__tests__/integration/query.integration.test.js +385 -0
  30. package/dist/__tests__/integration.test.d.ts +2 -0
  31. package/dist/__tests__/integration.test.d.ts.map +1 -0
  32. package/dist/__tests__/integration.test.js +343 -0
  33. package/dist/__tests__/lifecycle.integration.test.d.ts +8 -0
  34. package/dist/__tests__/lifecycle.integration.test.d.ts.map +1 -0
  35. package/dist/__tests__/lifecycle.integration.test.js +385 -0
  36. package/dist/__tests__/query.integration.test.d.ts +8 -0
  37. package/dist/__tests__/query.integration.test.d.ts.map +1 -0
  38. package/dist/__tests__/query.integration.test.js +423 -0
  39. package/dist/__tests__/query.test.d.ts +8 -0
  40. package/dist/__tests__/query.test.d.ts.map +1 -0
  41. package/dist/__tests__/query.test.js +472 -0
  42. package/dist/convert/document.d.ts +20 -0
  43. package/dist/convert/document.d.ts.map +1 -0
  44. package/dist/convert/document.js +72 -0
  45. package/dist/convert/filter.d.ts +15 -0
  46. package/dist/convert/filter.d.ts.map +1 -0
  47. package/dist/convert/filter.js +109 -0
  48. package/dist/convert/index.d.ts +8 -0
  49. package/dist/convert/index.d.ts.map +1 -0
  50. package/dist/convert/index.js +7 -0
  51. package/dist/convert/query.d.ts +22 -0
  52. package/dist/convert/query.d.ts.map +1 -0
  53. package/dist/convert/query.js +111 -0
  54. package/dist/convert/schema.d.ts +39 -0
  55. package/dist/convert/schema.d.ts.map +1 -0
  56. package/dist/convert/schema.js +124 -0
  57. package/dist/convert.d.ts +68 -0
  58. package/dist/convert.d.ts.map +1 -0
  59. package/dist/convert.js +333 -0
  60. package/dist/handle.d.ts +34 -0
  61. package/dist/handle.d.ts.map +1 -0
  62. package/dist/handle.js +72 -0
  63. package/dist/index.d.ts +27 -0
  64. package/dist/index.d.ts.map +1 -0
  65. package/dist/index.js +26 -0
  66. package/dist/search.d.ts +85 -0
  67. package/dist/search.d.ts.map +1 -0
  68. package/dist/search.js +167 -0
  69. package/dist/types.d.ts +14 -0
  70. package/dist/types.d.ts.map +1 -0
  71. package/dist/types.js +1 -0
  72. package/package.json +57 -0
  73. package/src/__tests__/convert.test.ts +425 -0
  74. package/src/__tests__/filter.test.ts +730 -0
  75. package/src/__tests__/integration/filters.integration.test.ts +558 -0
  76. package/src/__tests__/integration/integration.test.ts +399 -0
  77. package/src/__tests__/integration/lifecycle.integration.test.ts +464 -0
  78. package/src/__tests__/integration/memory.integration.test.ts +353 -0
  79. package/src/__tests__/integration/query.integration.test.ts +471 -0
  80. package/src/__tests__/query.test.ts +636 -0
  81. package/src/convert/document.ts +95 -0
  82. package/src/convert/filter.ts +123 -0
  83. package/src/convert/index.ts +8 -0
  84. package/src/convert/query.ts +151 -0
  85. package/src/convert/schema.ts +163 -0
  86. package/src/handle.ts +104 -0
  87. package/src/index.ts +31 -0
  88. package/src/search.ts +207 -0
  89. package/src/types.ts +14 -0
  90. package/tsconfig.json +13 -0
  91. package/vitest.config.ts +15 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Document conversion codecs.
3
+ */
4
+
5
+ import type { Codec } from "@kernl-sdk/shared/lib";
6
+ import type {
7
+ FieldValue,
8
+ DenseVector,
9
+ UnknownDocument,
10
+ } from "@kernl-sdk/retrieval";
11
+ import type { Row } from "@turbopuffer/turbopuffer/resources/namespaces";
12
+
13
+ /**
14
+ * Codec for converting documents to Turbopuffer Row.
15
+ *
16
+ * Documents must have an `id` field. All other fields become attributes.
17
+ * Undefined values are omitted.
18
+ */
19
+ export const DOCUMENT: Codec<UnknownDocument, Row> = {
20
+ encode: (doc) => {
21
+ const { id, ...fields } = doc;
22
+
23
+ if (typeof id !== "string") {
24
+ throw new Error('Document must have a string "id" field');
25
+ }
26
+
27
+ const row: Row = { id };
28
+
29
+ for (const [key, val] of Object.entries(fields)) {
30
+ if (val !== undefined) {
31
+ row[key] = encodeFieldValue(val);
32
+ }
33
+ }
34
+
35
+ return row;
36
+ },
37
+
38
+ decode: (_row) => {
39
+ throw new Error("DOCUMENT.decode: not implemented");
40
+ },
41
+ };
42
+
43
+ /**
44
+ * Codec for converting document patches to Turbopuffer Row.
45
+ *
46
+ * Similar to DOCUMENT but passes through null values to unset fields.
47
+ */
48
+ export const PATCH: Codec<UnknownDocument, Row> = {
49
+ encode: (patch) => {
50
+ const { id, ...fields } = patch;
51
+
52
+ if (typeof id !== "string") {
53
+ throw new Error('Patch must have a string "id" field');
54
+ }
55
+
56
+ const row: Row = { id };
57
+
58
+ for (const [key, val] of Object.entries(fields)) {
59
+ if (val === null) {
60
+ row[key] = null; // null unsets the field in Turbopuffer
61
+ } else if (val !== undefined) {
62
+ row[key] = encodeFieldValue(val);
63
+ }
64
+ }
65
+
66
+ return row;
67
+ },
68
+
69
+ decode: (_row) => {
70
+ throw new Error("PATCH.decode: not implemented");
71
+ },
72
+ };
73
+
74
+ /**
75
+ * Check if a value is a DenseVector.
76
+ */
77
+ function isDenseVector(val: FieldValue): val is DenseVector {
78
+ return (
79
+ typeof val === "object" &&
80
+ val !== null &&
81
+ "kind" in val &&
82
+ val.kind === "vector"
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Convert a FieldValue to Turbopuffer attribute value.
88
+ * Extracts vector values from DenseVector wrapper.
89
+ */
90
+ function encodeFieldValue(val: FieldValue): unknown {
91
+ if (isDenseVector(val)) {
92
+ return val.values;
93
+ }
94
+ return val;
95
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Filter conversion codecs.
3
+ *
4
+ * Converts MongoDB-style filters to Turbopuffer filter format.
5
+ */
6
+
7
+ import type { Filter, FieldOps, ScalarValue } from "@kernl-sdk/retrieval";
8
+ import type { Filter as TpufFilter } from "@turbopuffer/turbopuffer/resources/custom";
9
+
10
+ /**
11
+ * Codec for converting Filter to Turbopuffer Filter.
12
+ */
13
+ export const FILTER = {
14
+ encode: (filter: Filter): TpufFilter => {
15
+ const conditions: TpufFilter[] = [];
16
+
17
+ for (const [key, value] of Object.entries(filter)) {
18
+ if (value === undefined) continue;
19
+
20
+ // Logical operators
21
+ if (key === "$and" && Array.isArray(value)) {
22
+ const sub = (value as Filter[]).map(FILTER.encode);
23
+ if (sub.length === 1) {
24
+ conditions.push(sub[0]);
25
+ } else {
26
+ conditions.push(["And", sub]);
27
+ }
28
+ continue;
29
+ }
30
+
31
+ if (key === "$or" && Array.isArray(value)) {
32
+ const sub = (value as Filter[]).map(FILTER.encode);
33
+ conditions.push(["Or", sub]);
34
+ continue;
35
+ }
36
+
37
+ if (key === "$not") {
38
+ conditions.push(["Not", FILTER.encode(value as Filter)]);
39
+ continue;
40
+ }
41
+
42
+ // Field-level filter
43
+ if (isFieldOps(value)) {
44
+ conditions.push(...encodeFieldOps(key, value));
45
+ } else {
46
+ // Simple equality: { field: value }
47
+ conditions.push([key, "Eq", value as ScalarValue]);
48
+ }
49
+ }
50
+
51
+ if (conditions.length === 0) {
52
+ throw new Error("Empty filter");
53
+ }
54
+
55
+ if (conditions.length === 1) {
56
+ return conditions[0];
57
+ }
58
+
59
+ return ["And", conditions];
60
+ },
61
+
62
+ decode: (_filter: TpufFilter): Filter => {
63
+ throw new Error("FILTER.decode: not implemented");
64
+ },
65
+ };
66
+
67
+ /**
68
+ * Check if a value is a FieldOps object (has operator keys like $eq, $gt, etc.)
69
+ */
70
+ function isFieldOps(value: unknown): value is FieldOps {
71
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
72
+ return false;
73
+ }
74
+ const keys = Object.keys(value);
75
+ return keys.some((k) => k.startsWith("$"));
76
+ }
77
+
78
+ /**
79
+ * Encode field-level operators to Turbopuffer filters.
80
+ */
81
+ function encodeFieldOps(field: string, ops: FieldOps): TpufFilter[] {
82
+ const conditions: TpufFilter[] = [];
83
+
84
+ if (ops.$eq !== undefined) {
85
+ conditions.push([field, "Eq", ops.$eq]);
86
+ }
87
+ if (ops.$neq !== undefined) {
88
+ conditions.push([field, "NotEq", ops.$neq]);
89
+ }
90
+ if (ops.$gt !== undefined) {
91
+ conditions.push([field, "Gt", ops.$gt]);
92
+ }
93
+ if (ops.$gte !== undefined) {
94
+ conditions.push([field, "Gte", ops.$gte]);
95
+ }
96
+ if (ops.$lt !== undefined) {
97
+ conditions.push([field, "Lt", ops.$lt]);
98
+ }
99
+ if (ops.$lte !== undefined) {
100
+ conditions.push([field, "Lte", ops.$lte]);
101
+ }
102
+ if (ops.$in !== undefined) {
103
+ conditions.push([field, "In", ops.$in]);
104
+ }
105
+ if (ops.$nin !== undefined) {
106
+ conditions.push([field, "NotIn", ops.$nin]);
107
+ }
108
+ if (ops.$contains !== undefined) {
109
+ conditions.push([field, "Contains", ops.$contains]);
110
+ }
111
+ if (ops.$startsWith !== undefined) {
112
+ conditions.push([field, "Glob", `${ops.$startsWith}*`]);
113
+ }
114
+ if (ops.$endsWith !== undefined) {
115
+ conditions.push([field, "Glob", `*${ops.$endsWith}`]);
116
+ }
117
+ if (ops.$exists !== undefined) {
118
+ // exists: true → NotEq null, exists: false → Eq null
119
+ conditions.push([field, ops.$exists ? "NotEq" : "Eq", null]);
120
+ }
121
+
122
+ return conditions;
123
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Type conversion codecs for Turbopuffer.
3
+ */
4
+
5
+ export { SCALAR_TYPE, SIMILARITY, FIELD_SCHEMA, INDEX_SCHEMA } from "./schema";
6
+ export { DOCUMENT, PATCH } from "./document";
7
+ export { FILTER } from "./filter";
8
+ export { QUERY, SEARCH_HIT } from "./query";
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Query conversion codecs.
3
+ *
4
+ * Converts the new RankingSignal-based query format to Turbopuffer params.
5
+ */
6
+
7
+ import type {
8
+ SearchQuery,
9
+ SearchHit,
10
+ RankingSignal,
11
+ UnknownDocument,
12
+ } from "@kernl-sdk/retrieval";
13
+ import type {
14
+ Row,
15
+ NamespaceQueryParams,
16
+ } from "@turbopuffer/turbopuffer/resources/namespaces";
17
+ import type { RankBy } from "@turbopuffer/turbopuffer/resources/custom";
18
+
19
+ import { FILTER } from "./filter";
20
+
21
+ /**
22
+ * Codec for converting SearchQuery to Turbopuffer NamespaceQueryParams.
23
+ */
24
+ export const QUERY = {
25
+ encode: (query: SearchQuery): NamespaceQueryParams => {
26
+ const params: NamespaceQueryParams = {};
27
+
28
+ // Build rank_by from query signals
29
+ const signals = query.query ?? query.max;
30
+ if (signals && signals.length > 0) {
31
+ params.rank_by = buildRankBy(signals, query.max !== undefined);
32
+ }
33
+
34
+ // top K
35
+ if (query.topK !== undefined) {
36
+ params.top_k = query.topK;
37
+ }
38
+
39
+ // filters
40
+ if (query.filter) {
41
+ params.filters = FILTER.encode(query.filter);
42
+ }
43
+
44
+ // include attributes
45
+ if (query.include !== undefined) {
46
+ if (typeof query.include === "boolean") {
47
+ params.include_attributes = query.include;
48
+ } else {
49
+ params.include_attributes = [...query.include];
50
+ }
51
+ }
52
+
53
+ return params;
54
+ },
55
+
56
+ decode: (_params: NamespaceQueryParams): SearchQuery => {
57
+ throw new Error("QUERY.decode: not implemented");
58
+ },
59
+ };
60
+
61
+ /**
62
+ * Codec for converting Turbopuffer Row to SearchHit.
63
+ */
64
+ export const SEARCH_HIT = {
65
+ encode: <TDocument = UnknownDocument>(_hit: SearchHit<TDocument>): Row => {
66
+ throw new Error("SEARCH_HIT.encode: not implemented");
67
+ },
68
+
69
+ decode: <TDocument = UnknownDocument>(
70
+ row: Row,
71
+ index: string,
72
+ ): SearchHit<TDocument> => {
73
+ const { id, $dist, ...rest } = row;
74
+
75
+ const dist = typeof $dist === "number" ? $dist : 0;
76
+
77
+ const hit: SearchHit<TDocument> = {
78
+ id: String(id),
79
+ index,
80
+ score: dist === 0 ? 0 : -dist, // convert distance to similarity (negate so higher = better)
81
+ };
82
+
83
+ // include document fields with id
84
+ hit.document = { id, ...rest } as unknown as Partial<TDocument>;
85
+
86
+ return hit;
87
+ },
88
+ };
89
+
90
+ /**
91
+ * Build rank_by from ranking signals.
92
+ *
93
+ * Turbopuffer constraints:
94
+ * - Sum/Max fusion only works with BM25 (text) signals
95
+ * - Vector search must be a single ANN query
96
+ * - Hybrid (text + vector) fusion is not supported in a single query
97
+ */
98
+ function buildRankBy(signals: RankingSignal[], useMax: boolean): RankBy {
99
+ const textRankBys: RankBy[] = [];
100
+ const vectorRankBys: RankBy[] = [];
101
+
102
+ for (const signal of signals) {
103
+ const { weight, ...fields } = signal;
104
+ for (const [field, value] of Object.entries(fields)) {
105
+ if (value === undefined) continue;
106
+
107
+ if (Array.isArray(value)) {
108
+ vectorRankBys.push(["vector", "ANN", value as number[]]);
109
+ } else if (typeof value === "string") {
110
+ textRankBys.push([field, "BM25", value]);
111
+ }
112
+ }
113
+ }
114
+
115
+ const hasVector = vectorRankBys.length > 0;
116
+ const hasText = textRankBys.length > 0;
117
+
118
+ if (!hasVector && !hasText) {
119
+ throw new Error("No ranking signals provided");
120
+ }
121
+
122
+ // hybrid fusion not supported
123
+ if (hasVector && hasText) {
124
+ throw new Error(
125
+ "Turbopuffer does not support hybrid (vector + text) fusion in a single query. " +
126
+ "Use separate queries and merge results client-side.",
127
+ );
128
+ }
129
+
130
+ // multi-vector fusion not supported
131
+ if (vectorRankBys.length > 1) {
132
+ throw new Error(
133
+ "Turbopuffer does not support multi-vector fusion. " +
134
+ "Use separate queries and merge results client-side.",
135
+ );
136
+ }
137
+
138
+ // single vector query
139
+ if (hasVector) {
140
+ return vectorRankBys[0];
141
+ }
142
+
143
+ // single text query
144
+ if (textRankBys.length === 1) {
145
+ return textRankBys[0];
146
+ }
147
+
148
+ // multiple text signals: use Sum or Max fusion
149
+ const fusion = useMax ? "Max" : "Sum";
150
+ return [fusion, textRankBys] as RankBy;
151
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Schema conversion codecs.
3
+ */
4
+
5
+ import type { Codec } from "@kernl-sdk/shared/lib";
6
+ import type {
7
+ FieldSchema,
8
+ VectorFieldSchema,
9
+ ScalarFieldSchema,
10
+ } from "@kernl-sdk/retrieval";
11
+ import type {
12
+ AttributeSchema,
13
+ AttributeSchemaConfig,
14
+ DistanceMetric,
15
+ } from "@turbopuffer/turbopuffer/resources/namespaces";
16
+
17
+ type Similarity = VectorFieldSchema["similarity"];
18
+ type ScalarType = ScalarFieldSchema["type"];
19
+ type TpufType = string;
20
+
21
+ /**
22
+ * Mapping from kernl scalar types to Turbopuffer attribute types.
23
+ */
24
+ const SCALAR_TO_TPUF: Record<string, TpufType> = {
25
+ string: "string",
26
+ int: "int",
27
+ bigint: "uint", // Unix epoch timestamps in ms
28
+ float: "int", // tpuf doesn't have float
29
+ boolean: "bool",
30
+ date: "datetime",
31
+ "string[]": "[]string",
32
+ "int[]": "[]int",
33
+ "date[]": "[]datetime",
34
+ };
35
+
36
+ /**
37
+ * Mapping from Turbopuffer attribute types to kernl scalar types.
38
+ */
39
+ const TPUF_TO_SCALAR: Record<string, ScalarType> = {
40
+ string: "string",
41
+ int: "int",
42
+ uint: "bigint",
43
+ bool: "boolean",
44
+ datetime: "date",
45
+ "[]string": "string[]",
46
+ "[]int": "int[]",
47
+ "[]datetime": "date[]",
48
+ };
49
+
50
+ /**
51
+ * Codec for converting kernl scalar types to Turbopuffer attribute types.
52
+ */
53
+ export const SCALAR_TYPE: Codec<ScalarType, TpufType> = {
54
+ encode: (type) => SCALAR_TO_TPUF[type] ?? "string",
55
+ decode: (type) => TPUF_TO_SCALAR[type] ?? "string",
56
+ };
57
+
58
+ /**
59
+ * Codec for converting similarity metric to Turbopuffer distance metric.
60
+ *
61
+ * Turbopuffer supports: cosine_distance, euclidean_squared
62
+ * We support: cosine, euclidean, dot_product
63
+ */
64
+ export const SIMILARITY: Codec<Similarity, DistanceMetric> = {
65
+ encode: (similarity) => {
66
+ switch (similarity) {
67
+ case "euclidean":
68
+ return "euclidean_squared";
69
+ case "cosine":
70
+ case "dot_product":
71
+ default:
72
+ return "cosine_distance";
73
+ }
74
+ },
75
+
76
+ decode: (metric) => {
77
+ switch (metric) {
78
+ case "euclidean_squared":
79
+ return "euclidean";
80
+ case "cosine_distance":
81
+ default:
82
+ return "cosine";
83
+ }
84
+ },
85
+ };
86
+
87
+ /**
88
+ * Codec-like converter for FieldSchema to Turbopuffer AttributeSchema.
89
+ *
90
+ * Takes the field name as context since Turbopuffer requires `ann: true`
91
+ * only on the special `vector` attribute.
92
+ */
93
+ export const FIELD_SCHEMA = {
94
+ encode: (field: FieldSchema, name: string): AttributeSchema => {
95
+ // Vector fields
96
+ if (field.type === "vector" || field.type === "sparse-vector") {
97
+ const vf = field as VectorFieldSchema;
98
+ const precision = vf.quantization === "f16" ? "f16" : "f32";
99
+ return {
100
+ type: `[${vf.dimensions}]${precision}`,
101
+ ann: name === "vector",
102
+ };
103
+ }
104
+
105
+ // Scalar fields
106
+ const config: AttributeSchemaConfig = {
107
+ type: SCALAR_TYPE.encode(field.type),
108
+ };
109
+
110
+ if (field.filterable) {
111
+ config.filterable = true;
112
+ }
113
+
114
+ if (field.fts) {
115
+ config.full_text_search =
116
+ typeof field.fts === "object"
117
+ ? { language: field.fts.language as never }
118
+ : true;
119
+ }
120
+
121
+ return config;
122
+ },
123
+
124
+ decode: () => {
125
+ throw new Error("FIELD_SCHEMA.decode: not implemented");
126
+ },
127
+ };
128
+
129
+ /**
130
+ * Codec for converting a full schema record.
131
+ *
132
+ * Validates that vector fields are named `vector` since Turbopuffer only
133
+ * supports ANN indexing on that specific attribute name.
134
+ */
135
+ export const INDEX_SCHEMA: Codec<
136
+ Record<string, FieldSchema>,
137
+ Record<string, AttributeSchema>
138
+ > = {
139
+ encode: (schema) => {
140
+ const result: Record<string, AttributeSchema> = {};
141
+
142
+ for (const [name, field] of Object.entries(schema)) {
143
+ const isVector =
144
+ field.type === "vector" || field.type === "sparse-vector";
145
+
146
+ // Enforce vector field naming
147
+ if (isVector && name !== "vector") {
148
+ throw new Error(
149
+ `Turbopuffer requires vector fields to be named "vector", got "${name}". ` +
150
+ `Rename your field or use a different search provider.`,
151
+ );
152
+ }
153
+
154
+ result[name] = FIELD_SCHEMA.encode(field, name);
155
+ }
156
+
157
+ return result;
158
+ },
159
+
160
+ decode: () => {
161
+ throw new Error("INDEX_SCHEMA.decode: not implemented");
162
+ },
163
+ };
package/src/handle.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Turbopuffer index handle implementation.
3
+ */
4
+
5
+ import type Turbopuffer from "@turbopuffer/turbopuffer";
6
+ import type {
7
+ IndexHandle,
8
+ DocumentPatch,
9
+ UpsertResult,
10
+ PatchResult,
11
+ DeleteResult,
12
+ QueryInput,
13
+ SearchHit,
14
+ FieldSchema,
15
+ UnknownDocument,
16
+ } from "@kernl-sdk/retrieval";
17
+ import { normalizeQuery } from "@kernl-sdk/retrieval";
18
+
19
+ import { DOCUMENT, PATCH, QUERY, SEARCH_HIT } from "./convert";
20
+
21
+ /**
22
+ * Handle for operating on a specific Turbopuffer namespace (index).
23
+ */
24
+ export class TurbopufferIndexHandle<TDocument = UnknownDocument>
25
+ implements IndexHandle<TDocument>
26
+ {
27
+ readonly id: string;
28
+ private client: Turbopuffer;
29
+
30
+ constructor(client: Turbopuffer, id: string) {
31
+ this.client = client;
32
+ this.id = id;
33
+ }
34
+
35
+ /**
36
+ * Query the index.
37
+ */
38
+ async query(query: QueryInput): Promise<SearchHit<TDocument>[]> {
39
+ const q = normalizeQuery(query);
40
+ const ns = this.client.namespace(this.id);
41
+
42
+ const res = await ns.query(QUERY.encode(q));
43
+ if (!res.rows) {
44
+ return [];
45
+ }
46
+
47
+ return res.rows.map((row) => SEARCH_HIT.decode<TDocument>(row, this.id));
48
+ }
49
+
50
+ /**
51
+ * Upsert one or more documents.
52
+ */
53
+ async upsert(docs: TDocument | TDocument[]): Promise<UpsertResult> {
54
+ const arr = Array.isArray(docs) ? docs : [docs];
55
+ if (arr.length === 0) {
56
+ return { count: 0 };
57
+ }
58
+
59
+ const ns = this.client.namespace(this.id);
60
+ const rows = arr.map((doc) => DOCUMENT.encode(doc as UnknownDocument));
61
+
62
+ await ns.write({ upsert_rows: rows });
63
+ return { count: arr.length };
64
+ }
65
+
66
+ /**
67
+ * Patch one or more documents.
68
+ */
69
+ async patch(
70
+ patches: DocumentPatch<TDocument> | DocumentPatch<TDocument>[],
71
+ ): Promise<PatchResult> {
72
+ const arr = Array.isArray(patches) ? patches : [patches];
73
+ if (arr.length === 0) {
74
+ return { count: 0 };
75
+ }
76
+
77
+ const ns = this.client.namespace(this.id);
78
+ const rows = arr.map((p) => PATCH.encode(p as UnknownDocument));
79
+
80
+ await ns.write({ patch_rows: rows });
81
+ return { count: arr.length };
82
+ }
83
+
84
+ /**
85
+ * Delete one or more documents by ID.
86
+ */
87
+ async delete(ids: string | string[]): Promise<DeleteResult> {
88
+ const arr = Array.isArray(ids) ? ids : [ids];
89
+ if (arr.length === 0) {
90
+ return { count: 0 };
91
+ }
92
+
93
+ const ns = this.client.namespace(this.id);
94
+ await ns.write({ deletes: arr });
95
+ return { count: arr.length };
96
+ }
97
+
98
+ /**
99
+ * Add a field to the index schema.
100
+ */
101
+ async addField(_field: string, _schema: FieldSchema): Promise<void> {
102
+ throw new Error("addField not supported by Turbopuffer");
103
+ }
104
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Turbopuffer search index adapter.
3
+ */
4
+
5
+ import { TurbopufferSearchIndex } from "./search";
6
+ import type { TurbopufferConfig } from "./types";
7
+
8
+ export { TurbopufferSearchIndex } from "./search";
9
+ export { TurbopufferIndexHandle } from "./handle";
10
+ export type { TurbopufferConfig } from "./types";
11
+
12
+ /**
13
+ * Create a Turbopuffer search index.
14
+ *
15
+ * @param config - Turbopuffer API key and region
16
+ * @returns SearchIndex instance backed by Turbopuffer
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * const tpuf = turbopuffer({
21
+ * apiKey: "your-api-key",
22
+ * region: "us-east-1",
23
+ * });
24
+ *
25
+ * const docs = tpuf.index("my-index");
26
+ * await docs.upsert({ id: "doc-1", fields: { text: "Hello" } });
27
+ * ```
28
+ */
29
+ export function turbopuffer(config: TurbopufferConfig) {
30
+ return new TurbopufferSearchIndex(config);
31
+ }