@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,636 @@
1
+ /**
2
+ * Comprehensive unit tests for query conversion.
3
+ *
4
+ * Tests vector, text, hybrid queries, fusion modes, query options,
5
+ * and SearchHit decoding.
6
+ */
7
+
8
+ import { describe, it, expect } from "vitest";
9
+
10
+ import { QUERY, SEARCH_HIT } from "../convert/query";
11
+
12
+ describe("QUERY", () => {
13
+ // ============================================================
14
+ // VECTOR-ONLY QUERIES
15
+ // ============================================================
16
+
17
+ describe("vector-only queries", () => {
18
+ it("encodes single vector signal", () => {
19
+ const result = QUERY.encode({
20
+ query: [{ vector: [0.1, 0.2, 0.3] }],
21
+ });
22
+
23
+ expect(result.rank_by).toEqual(["vector", "ANN", [0.1, 0.2, 0.3]]);
24
+ });
25
+
26
+ it("encodes vector with many dimensions", () => {
27
+ const vec = new Array(384).fill(0.5);
28
+ const result = QUERY.encode({
29
+ query: [{ vector: vec }],
30
+ });
31
+
32
+ expect(result.rank_by).toEqual(["vector", "ANN", vec]);
33
+ });
34
+
35
+ it("encodes vector with zeros", () => {
36
+ const result = QUERY.encode({
37
+ query: [{ vector: [0, 0, 0] }],
38
+ });
39
+
40
+ expect(result.rank_by).toEqual(["vector", "ANN", [0, 0, 0]]);
41
+ });
42
+
43
+ it("encodes vector with negative values", () => {
44
+ const result = QUERY.encode({
45
+ query: [{ vector: [-0.5, 0.5, -1.0] }],
46
+ });
47
+
48
+ expect(result.rank_by).toEqual(["vector", "ANN", [-0.5, 0.5, -1.0]]);
49
+ });
50
+
51
+ it("throws error for multiple vector signals", () => {
52
+ expect(() =>
53
+ QUERY.encode({
54
+ query: [{ vector: [0.1, 0.2] }, { vector: [0.3, 0.4] }],
55
+ }),
56
+ ).toThrow(/does not support multi-vector/);
57
+ });
58
+
59
+ it("encodes vector query with max fusion", () => {
60
+ const result = QUERY.encode({
61
+ max: [{ vector: [0.1, 0.2] }],
62
+ });
63
+
64
+ // Single signal, no fusion wrapper needed
65
+ expect(result.rank_by).toEqual(["vector", "ANN", [0.1, 0.2]]);
66
+ });
67
+
68
+ it("throws error for multiple vector signals with max fusion", () => {
69
+ expect(() =>
70
+ QUERY.encode({
71
+ max: [{ vector: [0.1, 0.2] }, { vector: [0.3, 0.4] }],
72
+ }),
73
+ ).toThrow(/does not support multi-vector/);
74
+ });
75
+ });
76
+
77
+ // ============================================================
78
+ // TEXT-ONLY QUERIES (BM25)
79
+ // ============================================================
80
+
81
+ describe("text-only queries (BM25)", () => {
82
+ it("encodes single text field query", () => {
83
+ const result = QUERY.encode({
84
+ query: [{ content: "hello world" }],
85
+ });
86
+
87
+ expect(result.rank_by).toEqual(["content", "BM25", "hello world"]);
88
+ });
89
+
90
+ it("encodes text query on different field", () => {
91
+ const result = QUERY.encode({
92
+ query: [{ title: "search terms" }],
93
+ });
94
+
95
+ expect(result.rank_by).toEqual(["title", "BM25", "search terms"]);
96
+ });
97
+
98
+ it("encodes empty string text query", () => {
99
+ const result = QUERY.encode({
100
+ query: [{ content: "" }],
101
+ });
102
+
103
+ expect(result.rank_by).toEqual(["content", "BM25", ""]);
104
+ });
105
+
106
+ it("encodes text query with special characters", () => {
107
+ const result = QUERY.encode({
108
+ query: [{ content: "hello \"world\" & foo | bar" }],
109
+ });
110
+
111
+ expect(result.rank_by).toEqual([
112
+ "content",
113
+ "BM25",
114
+ "hello \"world\" & foo | bar",
115
+ ]);
116
+ });
117
+
118
+ it("encodes multi-field text query as Sum fusion", () => {
119
+ const result = QUERY.encode({
120
+ query: [{ title: "search" }, { body: "search" }],
121
+ });
122
+
123
+ expect(result.rank_by).toEqual([
124
+ "Sum",
125
+ [
126
+ ["title", "BM25", "search"],
127
+ ["body", "BM25", "search"],
128
+ ],
129
+ ]);
130
+ });
131
+
132
+ it("encodes three-field text query as Sum fusion", () => {
133
+ const result = QUERY.encode({
134
+ query: [
135
+ { title: "query" },
136
+ { description: "query" },
137
+ { content: "query" },
138
+ ],
139
+ });
140
+
141
+ expect(result.rank_by).toEqual([
142
+ "Sum",
143
+ [
144
+ ["title", "BM25", "query"],
145
+ ["description", "BM25", "query"],
146
+ ["content", "BM25", "query"],
147
+ ],
148
+ ]);
149
+ });
150
+
151
+ it("encodes multi-field text query with max fusion", () => {
152
+ const result = QUERY.encode({
153
+ max: [{ title: "search" }, { body: "search" }],
154
+ });
155
+
156
+ expect(result.rank_by).toEqual([
157
+ "Max",
158
+ [
159
+ ["title", "BM25", "search"],
160
+ ["body", "BM25", "search"],
161
+ ],
162
+ ]);
163
+ });
164
+ });
165
+
166
+ // ============================================================
167
+ // HYBRID QUERIES (VECTOR + TEXT) - NOT SUPPORTED
168
+ // ============================================================
169
+
170
+ describe("hybrid queries (vector + text)", () => {
171
+ it("throws error for vector + text fusion", () => {
172
+ expect(() =>
173
+ QUERY.encode({
174
+ query: [{ vector: [0.1, 0.2, 0.3] }, { content: "search terms" }],
175
+ }),
176
+ ).toThrow(/does not support hybrid/);
177
+ });
178
+
179
+ it("throws error for vector + text max fusion", () => {
180
+ expect(() =>
181
+ QUERY.encode({
182
+ max: [{ vector: [0.1, 0.2, 0.3] }, { content: "search terms" }],
183
+ }),
184
+ ).toThrow(/does not support hybrid/);
185
+ });
186
+
187
+ it("throws error for vector + multi-field text", () => {
188
+ expect(() =>
189
+ QUERY.encode({
190
+ query: [
191
+ { vector: [0.1, 0.2] },
192
+ { title: "query" },
193
+ { body: "query" },
194
+ ],
195
+ }),
196
+ ).toThrow(/does not support hybrid/);
197
+ });
198
+
199
+ it("throws error for multi-vector fusion", () => {
200
+ expect(() =>
201
+ QUERY.encode({
202
+ query: [{ vector: [0.1, 0.2] }, { vector: [0.3, 0.4] }],
203
+ }),
204
+ ).toThrow(/does not support multi-vector/);
205
+ });
206
+ });
207
+
208
+ // ============================================================
209
+ // QUERY OPTIONS
210
+ // ============================================================
211
+
212
+ describe("query options", () => {
213
+ describe("topK", () => {
214
+ it("encodes topK as top_k", () => {
215
+ const result = QUERY.encode({
216
+ query: [{ vector: [0.1] }],
217
+ topK: 10,
218
+ });
219
+
220
+ expect(result.top_k).toBe(10);
221
+ });
222
+
223
+ it("encodes topK of 1", () => {
224
+ const result = QUERY.encode({
225
+ query: [{ vector: [0.1] }],
226
+ topK: 1,
227
+ });
228
+
229
+ expect(result.top_k).toBe(1);
230
+ });
231
+
232
+ it("encodes large topK", () => {
233
+ const result = QUERY.encode({
234
+ query: [{ vector: [0.1] }],
235
+ topK: 10000,
236
+ });
237
+
238
+ expect(result.top_k).toBe(10000);
239
+ });
240
+
241
+ it("does not include top_k when undefined", () => {
242
+ const result = QUERY.encode({
243
+ query: [{ vector: [0.1] }],
244
+ });
245
+
246
+ expect(result.top_k).toBeUndefined();
247
+ });
248
+ });
249
+
250
+ describe("filter", () => {
251
+ it("encodes simple filter", () => {
252
+ const result = QUERY.encode({
253
+ query: [{ vector: [0.1] }],
254
+ filter: { status: "active" },
255
+ });
256
+
257
+ expect(result.filters).toEqual(["status", "Eq", "active"]);
258
+ });
259
+
260
+ it("encodes complex filter", () => {
261
+ const result = QUERY.encode({
262
+ query: [{ vector: [0.1] }],
263
+ filter: {
264
+ $and: [{ status: "active" }, { views: { $gte: 100 } }],
265
+ },
266
+ });
267
+
268
+ expect(result.filters).toEqual([
269
+ "And",
270
+ [
271
+ ["status", "Eq", "active"],
272
+ ["views", "Gte", 100],
273
+ ],
274
+ ]);
275
+ });
276
+
277
+ it("does not include filters when undefined", () => {
278
+ const result = QUERY.encode({
279
+ query: [{ vector: [0.1] }],
280
+ });
281
+
282
+ expect(result.filters).toBeUndefined();
283
+ });
284
+ });
285
+
286
+ describe("include", () => {
287
+ it("encodes include as boolean true", () => {
288
+ const result = QUERY.encode({
289
+ query: [{ vector: [0.1] }],
290
+ include: true,
291
+ });
292
+
293
+ expect(result.include_attributes).toBe(true);
294
+ });
295
+
296
+ it("encodes include as boolean false", () => {
297
+ const result = QUERY.encode({
298
+ query: [{ vector: [0.1] }],
299
+ include: false,
300
+ });
301
+
302
+ expect(result.include_attributes).toBe(false);
303
+ });
304
+
305
+ it("encodes include as string array", () => {
306
+ const result = QUERY.encode({
307
+ query: [{ vector: [0.1] }],
308
+ include: ["title", "content"],
309
+ });
310
+
311
+ expect(result.include_attributes).toEqual(["title", "content"]);
312
+ });
313
+
314
+ it("encodes include as single-element array", () => {
315
+ const result = QUERY.encode({
316
+ query: [{ vector: [0.1] }],
317
+ include: ["id"],
318
+ });
319
+
320
+ expect(result.include_attributes).toEqual(["id"]);
321
+ });
322
+
323
+ it("encodes include as empty array", () => {
324
+ const result = QUERY.encode({
325
+ query: [{ vector: [0.1] }],
326
+ include: [],
327
+ });
328
+
329
+ expect(result.include_attributes).toEqual([]);
330
+ });
331
+
332
+ it("does not include include_attributes when undefined", () => {
333
+ const result = QUERY.encode({
334
+ query: [{ vector: [0.1] }],
335
+ });
336
+
337
+ expect(result.include_attributes).toBeUndefined();
338
+ });
339
+ });
340
+
341
+ describe("combined options", () => {
342
+ it("encodes all options together", () => {
343
+ const result = QUERY.encode({
344
+ query: [{ vector: [0.1, 0.2] }],
345
+ topK: 20,
346
+ filter: { category: "news" },
347
+ include: ["title", "summary"],
348
+ });
349
+
350
+ expect(result.rank_by).toEqual(["vector", "ANN", [0.1, 0.2]]);
351
+ expect(result.top_k).toBe(20);
352
+ expect(result.filters).toEqual(["category", "Eq", "news"]);
353
+ expect(result.include_attributes).toEqual(["title", "summary"]);
354
+ });
355
+ });
356
+ });
357
+
358
+ // ============================================================
359
+ // FUSION MODE SELECTION
360
+ // ============================================================
361
+
362
+ describe("fusion mode selection", () => {
363
+ it("throws error when query has both vector and text", () => {
364
+ expect(() =>
365
+ QUERY.encode({
366
+ query: [{ vector: [0.1] }, { content: "search" }],
367
+ }),
368
+ ).toThrow(/does not support hybrid/);
369
+ });
370
+
371
+ it("throws error when max has both vector and text", () => {
372
+ expect(() =>
373
+ QUERY.encode({
374
+ max: [{ vector: [0.1] }, { content: "search" }],
375
+ }),
376
+ ).toThrow(/does not support hybrid/);
377
+ });
378
+
379
+ it("prefers query over max when both present", () => {
380
+ const result = QUERY.encode({
381
+ query: [{ vector: [0.1] }],
382
+ max: [{ content: "search" }],
383
+ });
384
+
385
+ // query takes precedence
386
+ expect(result.rank_by).toEqual(["vector", "ANN", [0.1]]);
387
+ });
388
+ });
389
+
390
+ // ============================================================
391
+ // ERROR CASES
392
+ // ============================================================
393
+
394
+ describe("error cases", () => {
395
+ it("throws when query array has only undefined values", () => {
396
+ expect(() =>
397
+ QUERY.encode({
398
+ query: [{ field: undefined }],
399
+ }),
400
+ ).toThrow("No ranking signals provided");
401
+ });
402
+
403
+ it("returns params without rank_by when query is empty array", () => {
404
+ // Note: In practice, empty arrays are caught during normalization.
405
+ // Direct codec call with empty array returns params without rank_by.
406
+ const result = QUERY.encode({
407
+ query: [],
408
+ topK: 10,
409
+ });
410
+
411
+ expect(result.rank_by).toBeUndefined();
412
+ expect(result.top_k).toBe(10);
413
+ });
414
+
415
+ it("returns params without rank_by when no query or max", () => {
416
+ const result = QUERY.encode({
417
+ topK: 10,
418
+ filter: { status: "active" },
419
+ });
420
+
421
+ expect(result.rank_by).toBeUndefined();
422
+ expect(result.top_k).toBe(10);
423
+ expect(result.filters).toEqual(["status", "Eq", "active"]);
424
+ });
425
+ });
426
+
427
+ // ============================================================
428
+ // EDGE CASES
429
+ // ============================================================
430
+
431
+ describe("edge cases", () => {
432
+ it("handles signal with multiple fields in one object", () => {
433
+ // A signal object could technically have multiple fields
434
+ const result = QUERY.encode({
435
+ query: [{ title: "hello", body: "world" }],
436
+ });
437
+
438
+ // Both should be extracted as separate BM25 signals
439
+ expect(result.rank_by).toEqual([
440
+ "Sum",
441
+ [
442
+ ["title", "BM25", "hello"],
443
+ ["body", "BM25", "world"],
444
+ ],
445
+ ]);
446
+ });
447
+
448
+ it("throws error for mixed signal types in one object", () => {
449
+ // Vector and text in same signal object
450
+ expect(() =>
451
+ QUERY.encode({
452
+ query: [{ vector: [0.1], content: "search" }],
453
+ }),
454
+ ).toThrow(/does not support hybrid/);
455
+ });
456
+ });
457
+ });
458
+
459
+ // ============================================================
460
+ // SEARCH_HIT CODEC
461
+ // ============================================================
462
+
463
+ describe("SEARCH_HIT", () => {
464
+ describe("decode", () => {
465
+ it("decodes basic row with id and score", () => {
466
+ const result = SEARCH_HIT.decode(
467
+ { id: "doc-1", $dist: 0.5 },
468
+ "my-index",
469
+ );
470
+
471
+ expect(result).toEqual({
472
+ id: "doc-1",
473
+ index: "my-index",
474
+ score: -0.5, // negated: distance → similarity
475
+ document: { id: "doc-1" },
476
+ });
477
+ });
478
+
479
+ it("decodes row with additional attributes", () => {
480
+ const result = SEARCH_HIT.decode(
481
+ {
482
+ id: "doc-1",
483
+ $dist: 0.25,
484
+ title: "Hello World",
485
+ category: "greeting",
486
+ },
487
+ "my-index",
488
+ );
489
+
490
+ expect(result).toEqual({
491
+ id: "doc-1",
492
+ index: "my-index",
493
+ score: -0.25, // negated: distance → similarity
494
+ document: {
495
+ id: "doc-1",
496
+ title: "Hello World",
497
+ category: "greeting",
498
+ },
499
+ });
500
+ });
501
+
502
+ it("decodes row with vector attribute", () => {
503
+ const result = SEARCH_HIT.decode(
504
+ {
505
+ id: "doc-1",
506
+ $dist: 0.1,
507
+ vector: [0.1, 0.2, 0.3],
508
+ },
509
+ "my-index",
510
+ );
511
+
512
+ expect(result.document?.id).toBe("doc-1");
513
+ expect(result.document?.vector).toEqual([0.1, 0.2, 0.3]);
514
+ });
515
+
516
+ it("converts numeric id to string", () => {
517
+ const result = SEARCH_HIT.decode(
518
+ { id: 12345, $dist: 0.5 },
519
+ "my-index",
520
+ );
521
+
522
+ expect(result.id).toBe("12345");
523
+ expect(typeof result.id).toBe("string");
524
+ expect(result.document?.id).toBe(12345); // document keeps original type
525
+ });
526
+
527
+ it("handles missing $dist (defaults to 0)", () => {
528
+ const result = SEARCH_HIT.decode({ id: "doc-1" }, "my-index");
529
+
530
+ expect(result.score).toBe(0);
531
+ expect(result.document).toEqual({ id: "doc-1" });
532
+ });
533
+
534
+ it("handles $dist of 0", () => {
535
+ const result = SEARCH_HIT.decode(
536
+ { id: "doc-1", $dist: 0 },
537
+ "my-index",
538
+ );
539
+
540
+ expect(result.score).toBe(0);
541
+ });
542
+
543
+ it("handles negative $dist", () => {
544
+ const result = SEARCH_HIT.decode(
545
+ { id: "doc-1", $dist: -0.5 },
546
+ "my-index",
547
+ );
548
+
549
+ expect(result.score).toBe(0.5); // negated: distance → similarity
550
+ });
551
+
552
+ it("handles large $dist values", () => {
553
+ const result = SEARCH_HIT.decode(
554
+ { id: "doc-1", $dist: 999999.99 },
555
+ "my-index",
556
+ );
557
+
558
+ expect(result.score).toBe(-999999.99); // negated: distance → similarity
559
+ });
560
+
561
+ it("includes id in document even when no extra attributes", () => {
562
+ const result = SEARCH_HIT.decode(
563
+ { id: "doc-1", $dist: 0.5 },
564
+ "my-index",
565
+ );
566
+
567
+ expect(result.document).toEqual({ id: "doc-1" });
568
+ });
569
+
570
+ it("preserves attribute types", () => {
571
+ const result = SEARCH_HIT.decode(
572
+ {
573
+ id: "doc-1",
574
+ $dist: 0.5,
575
+ count: 42,
576
+ enabled: true,
577
+ rating: 4.5,
578
+ tags: ["a", "b"],
579
+ meta: null,
580
+ },
581
+ "my-index",
582
+ );
583
+
584
+ expect(result.document).toEqual({
585
+ id: "doc-1",
586
+ count: 42,
587
+ enabled: true,
588
+ rating: 4.5,
589
+ tags: ["a", "b"],
590
+ meta: null,
591
+ });
592
+ });
593
+
594
+ it("handles empty string id", () => {
595
+ const result = SEARCH_HIT.decode({ id: "", $dist: 0.5 }, "my-index");
596
+
597
+ expect(result.id).toBe("");
598
+ });
599
+
600
+ it("handles special characters in attribute values", () => {
601
+ const result = SEARCH_HIT.decode(
602
+ {
603
+ id: "doc-1",
604
+ $dist: 0.5,
605
+ content: "Hello \"world\" & <script>",
606
+ },
607
+ "my-index",
608
+ );
609
+
610
+ expect(result.document?.content).toBe("Hello \"world\" & <script>");
611
+ });
612
+ });
613
+
614
+ describe("decode with typed document", () => {
615
+ interface MyDoc {
616
+ title: string;
617
+ views: number;
618
+ }
619
+
620
+ it("returns typed document", () => {
621
+ const result = SEARCH_HIT.decode<MyDoc>(
622
+ {
623
+ id: "doc-1",
624
+ $dist: 0.5,
625
+ title: "Test",
626
+ views: 100,
627
+ },
628
+ "my-index",
629
+ );
630
+
631
+ // TypeScript should recognize these as MyDoc fields
632
+ expect(result.document?.title).toBe("Test");
633
+ expect(result.document?.views).toBe(100);
634
+ });
635
+ });
636
+ });