@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,464 @@
1
+ /**
2
+ * Index and handle lifecycle edge case integration tests.
3
+ *
4
+ * Tests edge cases for index lifecycle operations and document handling
5
+ * against real Turbopuffer API.
6
+ */
7
+
8
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
9
+
10
+ import { TurbopufferSearchIndex } from "../../search";
11
+
12
+ const TURBOPUFFER_API_KEY = process.env.TURBOPUFFER_API_KEY;
13
+ const TURBOPUFFER_REGION = process.env.TURBOPUFFER_REGION ?? "api";
14
+
15
+ /**
16
+ * Helper to create a vector array.
17
+ */
18
+ function vec(dim: number, fill = 0.1) {
19
+ return new Array(dim).fill(fill);
20
+ }
21
+
22
+ describe("Lifecycle edge cases integration tests", () => {
23
+ if (!TURBOPUFFER_API_KEY) {
24
+ it.skip("requires TURBOPUFFER_API_KEY to be set", () => {});
25
+ return;
26
+ }
27
+
28
+ let tpuf: TurbopufferSearchIndex;
29
+ const testPrefix = `kernl-lifecycle-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
30
+
31
+ beforeAll(() => {
32
+ tpuf = new TurbopufferSearchIndex({
33
+ apiKey: TURBOPUFFER_API_KEY,
34
+ region: TURBOPUFFER_REGION,
35
+ });
36
+ });
37
+
38
+ afterAll(async () => {
39
+ // Clean up any test indexes that might have been created
40
+ try {
41
+ const page = await tpuf.listIndexes({ prefix: testPrefix });
42
+ for (const idx of page.items) {
43
+ try {
44
+ await tpuf.deleteIndex(idx.id);
45
+ } catch {
46
+ // Ignore individual cleanup errors
47
+ }
48
+ }
49
+ } catch {
50
+ // Ignore cleanup errors
51
+ }
52
+ });
53
+
54
+ // ============================================================
55
+ // INDEX LIFECYCLE EDGE CASES
56
+ // ============================================================
57
+
58
+ describe("index lifecycle edge cases", () => {
59
+ it("describes index immediately after creation", async () => {
60
+ const indexId = `${testPrefix}-describe-after-create`;
61
+
62
+ await tpuf.createIndex({
63
+ id: indexId,
64
+ schema: {
65
+ text: { type: "string" },
66
+ vector: { type: "vector", dimensions: 4 },
67
+ },
68
+ });
69
+
70
+ const stats = await tpuf.describeIndex(indexId);
71
+ expect(stats.id).toBe(indexId);
72
+ expect(stats.count).toBe(0);
73
+
74
+ await tpuf.deleteIndex(indexId);
75
+ });
76
+
77
+ it("describe after delete throws", async () => {
78
+ const indexId = `${testPrefix}-describe-after-delete`;
79
+
80
+ await tpuf.createIndex({
81
+ id: indexId,
82
+ schema: { text: { type: "string" } },
83
+ });
84
+
85
+ await tpuf.deleteIndex(indexId);
86
+
87
+ await expect(tpuf.describeIndex(indexId)).rejects.toThrow();
88
+ });
89
+
90
+ it("delete non-existent index throws", async () => {
91
+ const indexId = `${testPrefix}-nonexistent-${Date.now()}`;
92
+
93
+ await expect(tpuf.deleteIndex(indexId)).rejects.toThrow();
94
+ });
95
+
96
+ it("create index with same id twice is idempotent", async () => {
97
+ const indexId = `${testPrefix}-duplicate`;
98
+
99
+ await tpuf.createIndex({
100
+ id: indexId,
101
+ schema: { text: { type: "string" } },
102
+ });
103
+
104
+ // Second create should succeed (idempotent) - Turbopuffer creates indexes implicitly
105
+ await tpuf.createIndex({
106
+ id: indexId,
107
+ schema: { text: { type: "string" } },
108
+ });
109
+
110
+ // Verify index still exists and is accessible
111
+ const stats = await tpuf.describeIndex(indexId);
112
+ expect(stats.id).toBe(indexId);
113
+
114
+ await tpuf.deleteIndex(indexId);
115
+ });
116
+
117
+ it("listIndexes returns empty page for non-matching prefix", async () => {
118
+ const page = await tpuf.listIndexes({
119
+ prefix: `nonexistent-prefix-${Date.now()}`,
120
+ });
121
+
122
+ expect(page.items).toEqual([]);
123
+ });
124
+ });
125
+
126
+ // ============================================================
127
+ // DOCUMENT UPSERT EDGE CASES
128
+ // ============================================================
129
+
130
+ describe("document upsert edge cases", () => {
131
+ const indexId = `${testPrefix}-upsert-edge`;
132
+
133
+ beforeAll(async () => {
134
+ await tpuf.createIndex({
135
+ id: indexId,
136
+ schema: {
137
+ content: { type: "string" },
138
+ count: { type: "int" },
139
+ vector: { type: "vector", dimensions: 4 },
140
+ },
141
+ });
142
+ });
143
+
144
+ afterAll(async () => {
145
+ try {
146
+ await tpuf.deleteIndex(indexId);
147
+ } catch {
148
+ // Ignore
149
+ }
150
+ });
151
+
152
+ it("multiple upserts same id keeps last write", async () => {
153
+ const index = tpuf.index(indexId);
154
+
155
+ // First write
156
+ await index.upsert({
157
+ id: "overwrite-test",
158
+ content: "First version",
159
+ count: 1,
160
+ vector: vec(4, 0.1),
161
+ });
162
+
163
+ // Second write
164
+ await index.upsert({
165
+ id: "overwrite-test",
166
+ content: "Second version",
167
+ count: 2,
168
+ vector: vec(4, 0.2),
169
+ });
170
+
171
+ // Third write
172
+ await index.upsert({
173
+ id: "overwrite-test",
174
+ content: "Final version",
175
+ count: 3,
176
+ vector: vec(4, 0.3),
177
+ });
178
+
179
+ // Wait for indexing
180
+ await new Promise((r) => setTimeout(r, 1000));
181
+
182
+ // Query and verify final state
183
+ const hits = await index.query({
184
+ query: [{ vector: [0.3, 0.3, 0.3, 0.3] }],
185
+ topK: 10,
186
+ filter: { id: "overwrite-test" },
187
+ include: ["content", "count"],
188
+ });
189
+
190
+ expect(hits.length).toBe(1);
191
+ expect(hits[0].document?.content).toBe("Final version");
192
+ expect(hits[0].document?.count).toBe(3);
193
+ });
194
+
195
+ it("upsert with empty fields object", async () => {
196
+ const index = tpuf.index(indexId);
197
+
198
+ // This should work - document with just id and vector
199
+ await index.upsert({
200
+ id: "minimal-doc",
201
+ vector: vec(4),
202
+ });
203
+
204
+ await new Promise((r) => setTimeout(r, 500));
205
+
206
+ const hits = await index.query({
207
+ query: [{ vector: [0.1, 0.1, 0.1, 0.1] }],
208
+ topK: 10,
209
+ filter: { id: "minimal-doc" },
210
+ });
211
+
212
+ expect(hits.some((h) => h.id === "minimal-doc")).toBe(true);
213
+ });
214
+
215
+ it("batch upsert with same id in batch throws", async () => {
216
+ const index = tpuf.index(indexId);
217
+
218
+ // Turbopuffer rejects batches with duplicate IDs
219
+ await expect(
220
+ index.upsert([
221
+ {
222
+ id: "batch-dup",
223
+ content: "First",
224
+ count: 1,
225
+ vector: vec(4, 0.1),
226
+ },
227
+ {
228
+ id: "batch-dup",
229
+ content: "Second",
230
+ count: 2,
231
+ vector: vec(4, 0.2),
232
+ },
233
+ {
234
+ id: "batch-dup",
235
+ content: "Third",
236
+ count: 3,
237
+ vector: vec(4, 0.3),
238
+ },
239
+ ]),
240
+ ).rejects.toThrow(/duplicate document IDs/i);
241
+ });
242
+ });
243
+
244
+ // ============================================================
245
+ // DOCUMENT DELETE EDGE CASES
246
+ // ============================================================
247
+
248
+ describe("document delete edge cases", () => {
249
+ const indexId = `${testPrefix}-delete-edge`;
250
+
251
+ beforeAll(async () => {
252
+ await tpuf.createIndex({
253
+ id: indexId,
254
+ schema: {
255
+ content: { type: "string" },
256
+ vector: { type: "vector", dimensions: 4 },
257
+ },
258
+ });
259
+ });
260
+
261
+ afterAll(async () => {
262
+ try {
263
+ await tpuf.deleteIndex(indexId);
264
+ } catch {
265
+ // Ignore
266
+ }
267
+ });
268
+
269
+ it("delete non-existent id does not throw", async () => {
270
+ const index = tpuf.index(indexId);
271
+
272
+ // Should not throw - just a no-op
273
+ const result = await index.delete("nonexistent-doc-id-12345");
274
+
275
+ // count reflects input, not actual deletions
276
+ expect(result.count).toBe(1);
277
+ });
278
+
279
+ it("batch delete with non-existent ids does not throw", async () => {
280
+ const index = tpuf.index(indexId);
281
+
282
+ const result = await index.delete([
283
+ "nonexistent-1",
284
+ "nonexistent-2",
285
+ "nonexistent-3",
286
+ ]);
287
+
288
+ expect(result.count).toBe(3);
289
+ });
290
+
291
+ it("delete empty array returns count 0", async () => {
292
+ const index = tpuf.index(indexId);
293
+
294
+ const result = await index.delete([]);
295
+
296
+ expect(result.count).toBe(0);
297
+ });
298
+
299
+ it("delete then query returns empty", async () => {
300
+ const index = tpuf.index(indexId);
301
+
302
+ // Insert
303
+ await index.upsert({
304
+ id: "to-delete",
305
+ content: "Will be deleted",
306
+ vector: vec(4, 0.9),
307
+ });
308
+
309
+ await new Promise((r) => setTimeout(r, 500));
310
+
311
+ // Verify exists
312
+ let hits = await index.query({
313
+ query: [{ vector: [0.9, 0.9, 0.9, 0.9] }],
314
+ topK: 10,
315
+ filter: { id: "to-delete" },
316
+ });
317
+ expect(hits.length).toBe(1);
318
+
319
+ // Delete
320
+ await index.delete("to-delete");
321
+
322
+ await new Promise((r) => setTimeout(r, 500));
323
+
324
+ // Verify gone
325
+ hits = await index.query({
326
+ query: [{ vector: [0.9, 0.9, 0.9, 0.9] }],
327
+ topK: 10,
328
+ filter: { id: "to-delete" },
329
+ });
330
+ expect(hits.length).toBe(0);
331
+ });
332
+ });
333
+
334
+ // ============================================================
335
+ // QUERY EDGE CASES
336
+ // ============================================================
337
+
338
+ describe("query edge cases", () => {
339
+ const indexId = `${testPrefix}-query-edge`;
340
+
341
+ beforeAll(async () => {
342
+ await tpuf.createIndex({
343
+ id: indexId,
344
+ schema: {
345
+ content: { type: "string", fts: true },
346
+ vector: { type: "vector", dimensions: 4 },
347
+ },
348
+ });
349
+ });
350
+
351
+ afterAll(async () => {
352
+ try {
353
+ await tpuf.deleteIndex(indexId);
354
+ } catch {
355
+ // Ignore
356
+ }
357
+ });
358
+
359
+ it("query on empty index returns empty array", async () => {
360
+ const index = tpuf.index(indexId);
361
+
362
+ const hits = await index.query({
363
+ query: [{ vector: [0.1, 0.2, 0.3, 0.4] }],
364
+ topK: 10,
365
+ });
366
+
367
+ expect(hits).toEqual([]);
368
+ });
369
+
370
+ it("query with filter matching nothing returns empty", async () => {
371
+ const index = tpuf.index(indexId);
372
+
373
+ // Add a doc first
374
+ await index.upsert({
375
+ id: "query-edge-doc",
376
+ content: "Test content",
377
+ vector: vec(4),
378
+ });
379
+
380
+ await new Promise((r) => setTimeout(r, 500));
381
+
382
+ // Query with filter that matches nothing
383
+ const hits = await index.query({
384
+ query: [{ vector: [0.1, 0.1, 0.1, 0.1] }],
385
+ topK: 10,
386
+ filter: { id: "nonexistent-id" },
387
+ });
388
+
389
+ expect(hits).toEqual([]);
390
+ });
391
+
392
+ it("topK of 0 throws", async () => {
393
+ const index = tpuf.index(indexId);
394
+
395
+ // Turbopuffer requires topK between 1 and 1200
396
+ await expect(
397
+ index.query({
398
+ query: [{ vector: [0.1, 0.1, 0.1, 0.1] }],
399
+ topK: 0,
400
+ }),
401
+ ).rejects.toThrow(/top_k must be between 1 and 1200/i);
402
+ });
403
+ });
404
+
405
+ // ============================================================
406
+ // VECTOR DIMENSION EDGE CASES
407
+ // ============================================================
408
+
409
+ describe("vector dimension edge cases", () => {
410
+ it("upsert with wrong vector dimension throws", async () => {
411
+ const indexId = `${testPrefix}-wrong-dim`;
412
+
413
+ await tpuf.createIndex({
414
+ id: indexId,
415
+ schema: {
416
+ vector: { type: "vector", dimensions: 4 },
417
+ },
418
+ });
419
+
420
+ const index = tpuf.index(indexId);
421
+
422
+ // Try to upsert with wrong dimension (8 instead of 4)
423
+ await expect(
424
+ index.upsert({
425
+ id: "wrong-dim-doc",
426
+ vector: vec(8), // Wrong dimension!
427
+ }),
428
+ ).rejects.toThrow();
429
+
430
+ await tpuf.deleteIndex(indexId);
431
+ });
432
+
433
+ it("query with wrong vector dimension throws", async () => {
434
+ const indexId = `${testPrefix}-wrong-query-dim`;
435
+
436
+ await tpuf.createIndex({
437
+ id: indexId,
438
+ schema: {
439
+ vector: { type: "vector", dimensions: 4 },
440
+ },
441
+ });
442
+
443
+ const index = tpuf.index(indexId);
444
+
445
+ // Insert valid doc
446
+ await index.upsert({
447
+ id: "valid-doc",
448
+ vector: vec(4),
449
+ });
450
+
451
+ await new Promise((r) => setTimeout(r, 500));
452
+
453
+ // Query with wrong dimension
454
+ await expect(
455
+ index.query({
456
+ query: [{ vector: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8] }], // 8 dims
457
+ topK: 10,
458
+ }),
459
+ ).rejects.toThrow();
460
+
461
+ await tpuf.deleteIndex(indexId);
462
+ });
463
+ });
464
+ });