@kernl-sdk/pg 0.1.10 → 0.1.12

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 (153) hide show
  1. package/.turbo/turbo-build.log +4 -5
  2. package/.turbo/turbo-check-types.log +36 -0
  3. package/CHANGELOG.md +41 -0
  4. package/README.md +124 -0
  5. package/dist/__tests__/integration.test.js +81 -1
  6. package/dist/__tests__/memory-integration.test.d.ts +2 -0
  7. package/dist/__tests__/memory-integration.test.d.ts.map +1 -0
  8. package/dist/__tests__/memory-integration.test.js +287 -0
  9. package/dist/__tests__/memory.test.d.ts +2 -0
  10. package/dist/__tests__/memory.test.d.ts.map +1 -0
  11. package/dist/__tests__/memory.test.js +357 -0
  12. package/dist/index.d.ts +5 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +5 -3
  15. package/dist/memory/sql.d.ts +30 -0
  16. package/dist/memory/sql.d.ts.map +1 -0
  17. package/dist/memory/sql.js +100 -0
  18. package/dist/memory/store.d.ts +41 -0
  19. package/dist/memory/store.d.ts.map +1 -0
  20. package/dist/memory/store.js +114 -0
  21. package/dist/migrations.d.ts +1 -1
  22. package/dist/migrations.d.ts.map +1 -1
  23. package/dist/migrations.js +9 -3
  24. package/dist/pgvector/__tests__/handle.test.d.ts +2 -0
  25. package/dist/pgvector/__tests__/handle.test.d.ts.map +1 -0
  26. package/dist/pgvector/__tests__/handle.test.js +277 -0
  27. package/dist/pgvector/__tests__/hit.test.d.ts +2 -0
  28. package/dist/pgvector/__tests__/hit.test.d.ts.map +1 -0
  29. package/dist/pgvector/__tests__/hit.test.js +134 -0
  30. package/dist/pgvector/__tests__/integration/document.integration.test.d.ts +7 -0
  31. package/dist/pgvector/__tests__/integration/document.integration.test.d.ts.map +1 -0
  32. package/dist/pgvector/__tests__/integration/document.integration.test.js +587 -0
  33. package/dist/pgvector/__tests__/integration/edge.integration.test.d.ts +8 -0
  34. package/dist/pgvector/__tests__/integration/edge.integration.test.d.ts.map +1 -0
  35. package/dist/pgvector/__tests__/integration/edge.integration.test.js +663 -0
  36. package/dist/pgvector/__tests__/integration/filters.integration.test.d.ts +8 -0
  37. package/dist/pgvector/__tests__/integration/filters.integration.test.d.ts.map +1 -0
  38. package/dist/pgvector/__tests__/integration/filters.integration.test.js +609 -0
  39. package/dist/pgvector/__tests__/integration/lifecycle.integration.test.d.ts +8 -0
  40. package/dist/pgvector/__tests__/integration/lifecycle.integration.test.d.ts.map +1 -0
  41. package/dist/pgvector/__tests__/integration/lifecycle.integration.test.js +449 -0
  42. package/dist/pgvector/__tests__/integration/query.integration.test.d.ts +8 -0
  43. package/dist/pgvector/__tests__/integration/query.integration.test.d.ts.map +1 -0
  44. package/dist/pgvector/__tests__/integration/query.integration.test.js +544 -0
  45. package/dist/pgvector/__tests__/search.test.d.ts +2 -0
  46. package/dist/pgvector/__tests__/search.test.d.ts.map +1 -0
  47. package/dist/pgvector/__tests__/search.test.js +279 -0
  48. package/dist/pgvector/handle.d.ts +60 -0
  49. package/dist/pgvector/handle.d.ts.map +1 -0
  50. package/dist/pgvector/handle.js +213 -0
  51. package/dist/pgvector/hit.d.ts +10 -0
  52. package/dist/pgvector/hit.d.ts.map +1 -0
  53. package/dist/pgvector/hit.js +44 -0
  54. package/dist/pgvector/index.d.ts +7 -0
  55. package/dist/pgvector/index.d.ts.map +1 -0
  56. package/dist/pgvector/index.js +5 -0
  57. package/dist/pgvector/search.d.ts +60 -0
  58. package/dist/pgvector/search.d.ts.map +1 -0
  59. package/dist/pgvector/search.js +227 -0
  60. package/dist/pgvector/sql/__tests__/limit.test.d.ts +2 -0
  61. package/dist/pgvector/sql/__tests__/limit.test.d.ts.map +1 -0
  62. package/dist/pgvector/sql/__tests__/limit.test.js +161 -0
  63. package/dist/pgvector/sql/__tests__/order.test.d.ts +2 -0
  64. package/dist/pgvector/sql/__tests__/order.test.d.ts.map +1 -0
  65. package/dist/pgvector/sql/__tests__/order.test.js +218 -0
  66. package/dist/pgvector/sql/__tests__/query.test.d.ts +2 -0
  67. package/dist/pgvector/sql/__tests__/query.test.d.ts.map +1 -0
  68. package/dist/pgvector/sql/__tests__/query.test.js +392 -0
  69. package/dist/pgvector/sql/__tests__/select.test.d.ts +2 -0
  70. package/dist/pgvector/sql/__tests__/select.test.d.ts.map +1 -0
  71. package/dist/pgvector/sql/__tests__/select.test.js +293 -0
  72. package/dist/pgvector/sql/__tests__/where.test.d.ts +2 -0
  73. package/dist/pgvector/sql/__tests__/where.test.d.ts.map +1 -0
  74. package/dist/pgvector/sql/__tests__/where.test.js +488 -0
  75. package/dist/pgvector/sql/index.d.ts +7 -0
  76. package/dist/pgvector/sql/index.d.ts.map +1 -0
  77. package/dist/pgvector/sql/index.js +6 -0
  78. package/dist/pgvector/sql/limit.d.ts +8 -0
  79. package/dist/pgvector/sql/limit.d.ts.map +1 -0
  80. package/dist/pgvector/sql/limit.js +20 -0
  81. package/dist/pgvector/sql/order.d.ts +9 -0
  82. package/dist/pgvector/sql/order.d.ts.map +1 -0
  83. package/dist/pgvector/sql/order.js +47 -0
  84. package/dist/pgvector/sql/query.d.ts +46 -0
  85. package/dist/pgvector/sql/query.d.ts.map +1 -0
  86. package/dist/pgvector/sql/query.js +54 -0
  87. package/dist/pgvector/sql/schema.d.ts +16 -0
  88. package/dist/pgvector/sql/schema.d.ts.map +1 -0
  89. package/dist/pgvector/sql/schema.js +47 -0
  90. package/dist/pgvector/sql/select.d.ts +11 -0
  91. package/dist/pgvector/sql/select.d.ts.map +1 -0
  92. package/dist/pgvector/sql/select.js +87 -0
  93. package/dist/pgvector/sql/where.d.ts +8 -0
  94. package/dist/pgvector/sql/where.d.ts.map +1 -0
  95. package/dist/pgvector/sql/where.js +137 -0
  96. package/dist/pgvector/types.d.ts +20 -0
  97. package/dist/pgvector/types.d.ts.map +1 -0
  98. package/dist/pgvector/types.js +1 -0
  99. package/dist/pgvector/utils.d.ts +18 -0
  100. package/dist/pgvector/utils.d.ts.map +1 -0
  101. package/dist/pgvector/utils.js +22 -0
  102. package/dist/postgres.d.ts +19 -26
  103. package/dist/postgres.d.ts.map +1 -1
  104. package/dist/postgres.js +15 -27
  105. package/dist/storage.d.ts +62 -0
  106. package/dist/storage.d.ts.map +1 -1
  107. package/dist/storage.js +55 -10
  108. package/dist/thread/sql.d.ts +38 -0
  109. package/dist/thread/sql.d.ts.map +1 -0
  110. package/dist/thread/sql.js +112 -0
  111. package/dist/thread/store.d.ts +7 -3
  112. package/dist/thread/store.d.ts.map +1 -1
  113. package/dist/thread/store.js +46 -105
  114. package/package.json +8 -5
  115. package/src/__tests__/integration.test.ts +114 -15
  116. package/src/__tests__/memory-integration.test.ts +355 -0
  117. package/src/__tests__/memory.test.ts +428 -0
  118. package/src/index.ts +19 -3
  119. package/src/memory/sql.ts +141 -0
  120. package/src/memory/store.ts +166 -0
  121. package/src/migrations.ts +13 -3
  122. package/src/pgvector/README.md +50 -0
  123. package/src/pgvector/__tests__/handle.test.ts +335 -0
  124. package/src/pgvector/__tests__/hit.test.ts +165 -0
  125. package/src/pgvector/__tests__/integration/document.integration.test.ts +717 -0
  126. package/src/pgvector/__tests__/integration/edge.integration.test.ts +835 -0
  127. package/src/pgvector/__tests__/integration/filters.integration.test.ts +721 -0
  128. package/src/pgvector/__tests__/integration/lifecycle.integration.test.ts +570 -0
  129. package/src/pgvector/__tests__/integration/query.integration.test.ts +667 -0
  130. package/src/pgvector/__tests__/search.test.ts +366 -0
  131. package/src/pgvector/handle.ts +285 -0
  132. package/src/pgvector/hit.ts +56 -0
  133. package/src/pgvector/index.ts +7 -0
  134. package/src/pgvector/search.ts +330 -0
  135. package/src/pgvector/sql/__tests__/limit.test.ts +180 -0
  136. package/src/pgvector/sql/__tests__/order.test.ts +248 -0
  137. package/src/pgvector/sql/__tests__/query.test.ts +548 -0
  138. package/src/pgvector/sql/__tests__/select.test.ts +367 -0
  139. package/src/pgvector/sql/__tests__/where.test.ts +554 -0
  140. package/src/pgvector/sql/index.ts +14 -0
  141. package/src/pgvector/sql/limit.ts +29 -0
  142. package/src/pgvector/sql/order.ts +55 -0
  143. package/src/pgvector/sql/query.ts +112 -0
  144. package/src/pgvector/sql/schema.ts +61 -0
  145. package/src/pgvector/sql/select.ts +100 -0
  146. package/src/pgvector/sql/where.ts +152 -0
  147. package/src/pgvector/types.ts +21 -0
  148. package/src/pgvector/utils.ts +24 -0
  149. package/src/postgres.ts +31 -33
  150. package/src/storage.ts +102 -11
  151. package/src/thread/sql.ts +159 -0
  152. package/src/thread/store.ts +58 -127
  153. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,835 @@
1
+ /**
2
+ * Edge case integration tests for pgvector.
3
+ *
4
+ * Tests boundary conditions, error handling, special values,
5
+ * and security considerations.
6
+ */
7
+
8
+ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
9
+ import { Pool } from "pg";
10
+
11
+ import { PGSearchIndex } from "../../search";
12
+ import type { IndexHandle } from "@kernl-sdk/retrieval";
13
+
14
+ const TEST_DB_URL = process.env.KERNL_PG_TEST_URL;
15
+ const SCHEMA = "kernl_edge_integration_test";
16
+
17
+ describe.sequential("pgvector edge cases integration tests", () => {
18
+ if (!TEST_DB_URL) {
19
+ it.skip("requires KERNL_PG_TEST_URL environment variable", () => {});
20
+ return;
21
+ }
22
+
23
+ let pool: Pool;
24
+ let pgvec: PGSearchIndex;
25
+
26
+ beforeAll(async () => {
27
+ pool = new Pool({ connectionString: TEST_DB_URL });
28
+
29
+ await pool.query(`CREATE EXTENSION IF NOT EXISTS vector`);
30
+ await pool.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
31
+ await pool.query(`CREATE SCHEMA "${SCHEMA}"`);
32
+
33
+ pgvec = new PGSearchIndex({ pool });
34
+ }, 30000);
35
+
36
+ afterAll(async () => {
37
+ await pool.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
38
+ await pool.end();
39
+ });
40
+
41
+ // ============================================================
42
+ // SPECIAL STRING VALUES
43
+ // ============================================================
44
+
45
+ describe("special string values", () => {
46
+ let handle: IndexHandle<any>;
47
+
48
+ beforeAll(async () => {
49
+ await pgvec.createIndex({
50
+ id: "string_edge",
51
+ schema: {
52
+ id: { type: "string", pk: true },
53
+ title: { type: "string" },
54
+ content: { type: "string" },
55
+ embedding: { type: "vector", dimensions: 4 },
56
+ },
57
+ providerOptions: { schema: SCHEMA },
58
+ });
59
+ handle = pgvec.index("string_edge");
60
+ });
61
+
62
+ beforeEach(async () => {
63
+ await pool.query(`DELETE FROM "${SCHEMA}"."string_edge"`);
64
+ });
65
+
66
+ it("handles SQL injection attempt in value", async () => {
67
+ await handle.upsert({
68
+ id: "injection-1",
69
+ title: "'; DROP TABLE string_edge; --",
70
+ content: "Content",
71
+ embedding: [0.1, 0.1, 0.1, 0.1],
72
+ });
73
+
74
+ // Table should still exist and be queryable
75
+ const hits = await handle.query({
76
+ filter: { id: "injection-1" },
77
+ topK: 1,
78
+ });
79
+
80
+ expect(hits).toHaveLength(1);
81
+ expect(hits[0].document?.title).toBe("'; DROP TABLE string_edge; --");
82
+ });
83
+
84
+ it("handles SQL injection attempt in filter", async () => {
85
+ await handle.upsert({
86
+ id: "safe-doc",
87
+ title: "Safe Title",
88
+ content: "Content",
89
+ embedding: [0.1, 0.1, 0.1, 0.1],
90
+ });
91
+
92
+ const hits = await handle.query({
93
+ filter: { title: "'; DROP TABLE string_edge; --" },
94
+ topK: 10,
95
+ });
96
+
97
+ // Should return no results, not crash
98
+ expect(hits).toHaveLength(0);
99
+ });
100
+
101
+ it("handles quotes in strings", async () => {
102
+ await handle.upsert({
103
+ id: "quotes",
104
+ title: 'He said "Hello"',
105
+ content: "It's a test",
106
+ embedding: [0.1, 0.1, 0.1, 0.1],
107
+ });
108
+
109
+ const hits = await handle.query({
110
+ filter: { id: "quotes" },
111
+ topK: 1,
112
+ });
113
+
114
+ expect(hits[0].document?.title).toBe('He said "Hello"');
115
+ expect(hits[0].document?.content).toBe("It's a test");
116
+ });
117
+
118
+ it("handles backslashes", async () => {
119
+ await handle.upsert({
120
+ id: "backslash",
121
+ title: "Path\\to\\file",
122
+ content: "C:\\Windows\\System32",
123
+ embedding: [0.1, 0.1, 0.1, 0.1],
124
+ });
125
+
126
+ const hits = await handle.query({
127
+ filter: { id: "backslash" },
128
+ topK: 1,
129
+ });
130
+
131
+ expect(hits[0].document?.title).toBe("Path\\to\\file");
132
+ });
133
+
134
+ it("handles newlines and tabs", async () => {
135
+ await handle.upsert({
136
+ id: "whitespace",
137
+ title: "Line1\nLine2",
138
+ content: "Col1\tCol2\tCol3",
139
+ embedding: [0.1, 0.1, 0.1, 0.1],
140
+ });
141
+
142
+ const hits = await handle.query({
143
+ filter: { id: "whitespace" },
144
+ topK: 1,
145
+ });
146
+
147
+ expect(hits[0].document?.title).toBe("Line1\nLine2");
148
+ expect(hits[0].document?.content).toBe("Col1\tCol2\tCol3");
149
+ });
150
+
151
+ it("handles null byte attempt", async () => {
152
+ // Postgres doesn't allow null bytes in text
153
+ await expect(
154
+ handle.upsert({
155
+ id: "nullbyte",
156
+ title: "Has\x00null",
157
+ content: "Content",
158
+ embedding: [0.1, 0.1, 0.1, 0.1],
159
+ }),
160
+ ).rejects.toThrow();
161
+ });
162
+
163
+ it("handles unicode edge cases", async () => {
164
+ await handle.upsert({
165
+ id: "unicode-edge",
166
+ title: "\u0000\u0001\uFFFF", // Postgres will reject null byte
167
+ content: "Normal",
168
+ embedding: [0.1, 0.1, 0.1, 0.1],
169
+ }).catch(() => {
170
+ // Expected to fail with null byte
171
+ });
172
+
173
+ // Test without null byte
174
+ await handle.upsert({
175
+ id: "unicode-edge-2",
176
+ title: "\u0001\uFFFE\uFFFF",
177
+ content: "Unicode test",
178
+ embedding: [0.1, 0.1, 0.1, 0.1],
179
+ });
180
+
181
+ const hits = await handle.query({
182
+ filter: { id: "unicode-edge-2" },
183
+ topK: 1,
184
+ });
185
+
186
+ expect(hits).toHaveLength(1);
187
+ });
188
+
189
+ it("handles very long strings", async () => {
190
+ const longString = "x".repeat(100000);
191
+
192
+ await handle.upsert({
193
+ id: "long",
194
+ title: longString,
195
+ content: "Short",
196
+ embedding: [0.1, 0.1, 0.1, 0.1],
197
+ });
198
+
199
+ const hits = await handle.query({
200
+ filter: { id: "long" },
201
+ topK: 1,
202
+ });
203
+
204
+ expect(hits[0].document?.title).toHaveLength(100000);
205
+ });
206
+
207
+ it("handles LIKE pattern characters in $contains", async () => {
208
+ await handle.upsert({
209
+ id: "pattern-chars",
210
+ title: "100% match_test",
211
+ content: "Content",
212
+ embedding: [0.1, 0.1, 0.1, 0.1],
213
+ });
214
+
215
+ // Should not interpret % and _ as wildcards
216
+ const hits = await handle.query({
217
+ filter: { title: { $contains: "100%" } },
218
+ topK: 10,
219
+ });
220
+
221
+ expect(hits).toHaveLength(1);
222
+ });
223
+ });
224
+
225
+ // ============================================================
226
+ // NUMERIC EDGE CASES
227
+ // ============================================================
228
+
229
+ describe("numeric edge cases", () => {
230
+ let handle: IndexHandle<any>;
231
+
232
+ beforeAll(async () => {
233
+ await pgvec.createIndex({
234
+ id: "numeric_edge",
235
+ schema: {
236
+ id: { type: "string", pk: true },
237
+ int_val: { type: "int" },
238
+ float_val: { type: "float" },
239
+ embedding: { type: "vector", dimensions: 4 },
240
+ },
241
+ providerOptions: { schema: SCHEMA },
242
+ });
243
+ handle = pgvec.index("numeric_edge");
244
+ });
245
+
246
+ beforeEach(async () => {
247
+ await pool.query(`DELETE FROM "${SCHEMA}"."numeric_edge"`);
248
+ });
249
+
250
+ it("handles integer zero", async () => {
251
+ await handle.upsert({
252
+ id: "zero",
253
+ int_val: 0,
254
+ float_val: 0.0,
255
+ embedding: [0.1, 0.1, 0.1, 0.1],
256
+ });
257
+
258
+ const hits = await handle.query({
259
+ filter: { int_val: 0 },
260
+ topK: 1,
261
+ });
262
+
263
+ expect(hits).toHaveLength(1);
264
+ expect(hits[0].document?.int_val).toBe(0);
265
+ });
266
+
267
+ it("handles negative numbers", async () => {
268
+ await handle.upsert({
269
+ id: "negative",
270
+ int_val: -999,
271
+ float_val: -123.456,
272
+ embedding: [0.1, 0.1, 0.1, 0.1],
273
+ });
274
+
275
+ const hits = await handle.query({
276
+ filter: { int_val: { $lt: 0 } },
277
+ topK: 1,
278
+ });
279
+
280
+ expect(hits).toHaveLength(1);
281
+ expect(hits[0].document?.int_val).toBe(-999);
282
+ });
283
+
284
+ it("handles large integers", async () => {
285
+ // PostgreSQL integer max is 2147483647
286
+ await handle.upsert({
287
+ id: "large-int",
288
+ int_val: 2147483647,
289
+ float_val: 0,
290
+ embedding: [0.1, 0.1, 0.1, 0.1],
291
+ });
292
+
293
+ const hits = await handle.query({
294
+ filter: { int_val: 2147483647 },
295
+ topK: 1,
296
+ });
297
+
298
+ expect(hits).toHaveLength(1);
299
+ expect(hits[0].document?.int_val).toBe(2147483647);
300
+ });
301
+
302
+ it("handles float precision", async () => {
303
+ await handle.upsert({
304
+ id: "precise",
305
+ int_val: 0,
306
+ float_val: 0.123456789012345,
307
+ embedding: [0.1, 0.1, 0.1, 0.1],
308
+ });
309
+
310
+ const hits = await handle.query({
311
+ filter: { id: "precise" },
312
+ topK: 1,
313
+ });
314
+
315
+ // Double precision maintains about 15 significant digits
316
+ expect(hits[0].document?.float_val).toBeCloseTo(0.123456789012345, 10);
317
+ });
318
+
319
+ it("handles special float values", async () => {
320
+ // Infinity and NaN are not valid JSON, so they shouldn't be used
321
+ // But very small and very large floats should work
322
+ await handle.upsert({
323
+ id: "extreme-float",
324
+ int_val: 0,
325
+ float_val: 1e308, // Near max double
326
+ embedding: [0.1, 0.1, 0.1, 0.1],
327
+ });
328
+
329
+ const hits = await handle.query({
330
+ filter: { id: "extreme-float" },
331
+ topK: 1,
332
+ });
333
+
334
+ expect(hits[0].document?.float_val).toBe(1e308);
335
+ });
336
+ });
337
+
338
+ // ============================================================
339
+ // VECTOR EDGE CASES
340
+ // ============================================================
341
+
342
+ describe("vector edge cases", () => {
343
+ let handle: IndexHandle<any>;
344
+
345
+ beforeAll(async () => {
346
+ await pgvec.createIndex({
347
+ id: "vector_edge",
348
+ schema: {
349
+ id: { type: "string", pk: true },
350
+ embedding: { type: "vector", dimensions: 4, similarity: "cosine" },
351
+ },
352
+ providerOptions: { schema: SCHEMA },
353
+ });
354
+ handle = pgvec.index("vector_edge");
355
+ });
356
+
357
+ beforeEach(async () => {
358
+ await pool.query(`DELETE FROM "${SCHEMA}"."vector_edge"`);
359
+ });
360
+
361
+ it("handles zero vector", async () => {
362
+ await handle.upsert({
363
+ id: "zero-vec",
364
+ embedding: [0, 0, 0, 0],
365
+ });
366
+
367
+ // Querying with zero vector - cosine similarity undefined
368
+ // pgvector returns NaN for cosine of zero vectors
369
+ const hits = await handle.query({
370
+ filter: { id: "zero-vec" },
371
+ topK: 1,
372
+ });
373
+
374
+ expect(hits).toHaveLength(1);
375
+ });
376
+
377
+ it("handles negative vector components", async () => {
378
+ await handle.upsert({
379
+ id: "negative-vec",
380
+ embedding: [-1, -0.5, 0.5, 1],
381
+ });
382
+
383
+ const hits = await handle.query({
384
+ query: [{ embedding: [-1, -0.5, 0.5, 1] }],
385
+ topK: 1,
386
+ });
387
+
388
+ expect(hits[0].id).toBe("negative-vec");
389
+ expect(hits[0].score).toBeGreaterThan(0.99);
390
+ });
391
+
392
+ it("handles very small vector components", async () => {
393
+ await handle.upsert({
394
+ id: "small-vec",
395
+ embedding: [1e-10, 1e-10, 1e-10, 1e-10],
396
+ });
397
+
398
+ const hits = await handle.query({
399
+ filter: { id: "small-vec" },
400
+ topK: 1,
401
+ });
402
+
403
+ expect(hits).toHaveLength(1);
404
+ });
405
+
406
+ it("handles very large vector components", async () => {
407
+ await handle.upsert({
408
+ id: "large-vec",
409
+ embedding: [1e10, 1e10, 1e10, 1e10],
410
+ });
411
+
412
+ const hits = await handle.query({
413
+ query: [{ embedding: [1e10, 1e10, 1e10, 1e10] }],
414
+ topK: 1,
415
+ });
416
+
417
+ expect(hits[0].id).toBe("large-vec");
418
+ });
419
+
420
+ it("rejects wrong dimension vector", async () => {
421
+ await expect(
422
+ handle.upsert({
423
+ id: "wrong-dim",
424
+ embedding: [1, 2, 3], // 3 dimensions instead of 4
425
+ }),
426
+ ).rejects.toThrow();
427
+ });
428
+
429
+ it("handles high-dimensional vectors (1536)", async () => {
430
+ await pgvec.createIndex({
431
+ id: "high_dim_edge",
432
+ schema: {
433
+ id: { type: "string", pk: true },
434
+ embedding: { type: "vector", dimensions: 1536 },
435
+ },
436
+ providerOptions: { schema: SCHEMA },
437
+ });
438
+
439
+ const highHandle = pgvec.index("high_dim_edge");
440
+ const vec = new Array(1536).fill(0).map((_, i) => Math.sin(i));
441
+
442
+ await highHandle.upsert({ id: "high-1", embedding: vec });
443
+
444
+ const hits = await highHandle.query({
445
+ query: [{ embedding: vec }],
446
+ topK: 1,
447
+ });
448
+
449
+ expect(hits[0].id).toBe("high-1");
450
+ expect(hits[0].score).toBeGreaterThan(0.99);
451
+ });
452
+ });
453
+
454
+ // ============================================================
455
+ // NULL HANDLING
456
+ // ============================================================
457
+
458
+ describe("null handling", () => {
459
+ let handle: IndexHandle<any>;
460
+
461
+ beforeAll(async () => {
462
+ await pgvec.createIndex({
463
+ id: "null_edge",
464
+ schema: {
465
+ id: { type: "string", pk: true },
466
+ required_field: { type: "string" },
467
+ optional_field: { type: "string" },
468
+ embedding: { type: "vector", dimensions: 4 },
469
+ },
470
+ providerOptions: { schema: SCHEMA },
471
+ });
472
+ handle = pgvec.index("null_edge");
473
+ });
474
+
475
+ beforeEach(async () => {
476
+ await pool.query(`DELETE FROM "${SCHEMA}"."null_edge"`);
477
+ });
478
+
479
+ it("stores null in optional field", async () => {
480
+ await handle.upsert({
481
+ id: "null-test",
482
+ required_field: "present",
483
+ optional_field: null,
484
+ embedding: [0.1, 0.1, 0.1, 0.1],
485
+ });
486
+
487
+ const hits = await handle.query({
488
+ filter: { optional_field: null },
489
+ topK: 1,
490
+ });
491
+
492
+ expect(hits).toHaveLength(1);
493
+ expect(hits[0].document?.optional_field).toBeNull();
494
+ });
495
+
496
+ it("$exists works with null values", async () => {
497
+ await handle.upsert([
498
+ {
499
+ id: "has-value",
500
+ required_field: "yes",
501
+ optional_field: "present",
502
+ embedding: [0.1, 0.1, 0.1, 0.1],
503
+ },
504
+ {
505
+ id: "has-null",
506
+ required_field: "yes",
507
+ optional_field: null,
508
+ embedding: [0.2, 0.2, 0.2, 0.2],
509
+ },
510
+ ]);
511
+
512
+ const existsTrue = await handle.query({
513
+ filter: { optional_field: { $exists: true } },
514
+ topK: 10,
515
+ });
516
+
517
+ const existsFalse = await handle.query({
518
+ filter: { optional_field: { $exists: false } },
519
+ topK: 10,
520
+ });
521
+
522
+ expect(existsTrue).toHaveLength(1);
523
+ expect(existsTrue[0].id).toBe("has-value");
524
+
525
+ expect(existsFalse).toHaveLength(1);
526
+ expect(existsFalse[0].id).toBe("has-null");
527
+ });
528
+
529
+ it("patches field to null", async () => {
530
+ await handle.upsert({
531
+ id: "patch-null",
532
+ required_field: "yes",
533
+ optional_field: "will be nulled",
534
+ embedding: [0.1, 0.1, 0.1, 0.1],
535
+ });
536
+
537
+ await handle.patch({
538
+ id: "patch-null",
539
+ optional_field: null,
540
+ });
541
+
542
+ const hits = await handle.query({
543
+ filter: { id: "patch-null" },
544
+ topK: 1,
545
+ });
546
+
547
+ expect(hits[0].document?.optional_field).toBeNull();
548
+ });
549
+ });
550
+
551
+ // ============================================================
552
+ // ID EDGE CASES
553
+ // ============================================================
554
+
555
+ describe("id edge cases", () => {
556
+ let handle: IndexHandle<any>;
557
+
558
+ beforeAll(async () => {
559
+ await pgvec.createIndex({
560
+ id: "id_edge",
561
+ schema: {
562
+ id: { type: "string", pk: true },
563
+ name: { type: "string" },
564
+ embedding: { type: "vector", dimensions: 4 },
565
+ },
566
+ providerOptions: { schema: SCHEMA },
567
+ });
568
+ handle = pgvec.index("id_edge");
569
+ });
570
+
571
+ beforeEach(async () => {
572
+ await pool.query(`DELETE FROM "${SCHEMA}"."id_edge"`);
573
+ });
574
+
575
+ it("handles UUID-style ids", async () => {
576
+ const uuid = "550e8400-e29b-41d4-a716-446655440000";
577
+
578
+ await handle.upsert({
579
+ id: uuid,
580
+ name: "UUID Doc",
581
+ embedding: [0.1, 0.1, 0.1, 0.1],
582
+ });
583
+
584
+ const hits = await handle.query({
585
+ filter: { id: uuid },
586
+ topK: 1,
587
+ });
588
+
589
+ expect(hits[0].id).toBe(uuid);
590
+ });
591
+
592
+ it("handles very long ids", async () => {
593
+ const longId = "x".repeat(255);
594
+
595
+ await handle.upsert({
596
+ id: longId,
597
+ name: "Long ID Doc",
598
+ embedding: [0.1, 0.1, 0.1, 0.1],
599
+ });
600
+
601
+ const hits = await handle.query({
602
+ filter: { id: longId },
603
+ topK: 1,
604
+ });
605
+
606
+ expect(hits[0].id).toBe(longId);
607
+ });
608
+
609
+ it("handles ids with special characters", async () => {
610
+ const specialId = "doc/with:special@chars#and?query=params";
611
+
612
+ await handle.upsert({
613
+ id: specialId,
614
+ name: "Special ID Doc",
615
+ embedding: [0.1, 0.1, 0.1, 0.1],
616
+ });
617
+
618
+ const hits = await handle.query({
619
+ filter: { id: specialId },
620
+ topK: 1,
621
+ });
622
+
623
+ expect(hits[0].id).toBe(specialId);
624
+ });
625
+
626
+ it("handles numeric string ids", async () => {
627
+ await handle.upsert({
628
+ id: "12345",
629
+ name: "Numeric ID",
630
+ embedding: [0.1, 0.1, 0.1, 0.1],
631
+ });
632
+
633
+ const hits = await handle.query({
634
+ filter: { id: "12345" },
635
+ topK: 1,
636
+ });
637
+
638
+ expect(hits[0].id).toBe("12345");
639
+ });
640
+ });
641
+
642
+ // ============================================================
643
+ // ERROR HANDLING
644
+ // ============================================================
645
+
646
+ describe("error handling", () => {
647
+ it("throws on non-existent table query", async () => {
648
+ const badHandle = pgvec.index("nonexistent_table");
649
+
650
+ await expect(
651
+ badHandle.query({
652
+ query: [{ embedding: [0.1, 0.1, 0.1, 0.1] }],
653
+ topK: 10,
654
+ }),
655
+ ).rejects.toThrow();
656
+ });
657
+
658
+ it("throws on non-existent column in filter", async () => {
659
+ await pgvec.createIndex({
660
+ id: "error_test",
661
+ schema: {
662
+ id: { type: "string", pk: true },
663
+ name: { type: "string" },
664
+ embedding: { type: "vector", dimensions: 4 },
665
+ },
666
+ providerOptions: { schema: SCHEMA },
667
+ });
668
+
669
+ const handle = pgvec.index("error_test");
670
+
671
+ await expect(
672
+ handle.query({
673
+ filter: { nonexistent_column: "value" } as any,
674
+ topK: 10,
675
+ }),
676
+ ).rejects.toThrow();
677
+ });
678
+
679
+ it("throws on deleteIndex for non-bound index", async () => {
680
+ await expect(pgvec.deleteIndex("not_bound")).rejects.toThrow(
681
+ 'Index "not_bound" not bound',
682
+ );
683
+ });
684
+
685
+ it("throws on describeIndex for non-bound index", async () => {
686
+ await expect(pgvec.describeIndex("not_bound")).rejects.toThrow(
687
+ 'Index "not_bound" not bound',
688
+ );
689
+ });
690
+
691
+ it("throws on createIndex without primary key", async () => {
692
+ await expect(
693
+ pgvec.createIndex({
694
+ id: "no_pk",
695
+ schema: {
696
+ name: { type: "string" },
697
+ embedding: { type: "vector", dimensions: 4 },
698
+ },
699
+ providerOptions: { schema: SCHEMA },
700
+ }),
701
+ ).rejects.toThrow("schema must have a field with pk: true");
702
+ });
703
+ });
704
+
705
+ // ============================================================
706
+ // CONCURRENT OPERATIONS
707
+ // ============================================================
708
+
709
+ describe("concurrent operations", () => {
710
+ let handle: IndexHandle<any>;
711
+
712
+ beforeAll(async () => {
713
+ await pgvec.createIndex({
714
+ id: "concurrent_test",
715
+ schema: {
716
+ id: { type: "string", pk: true },
717
+ counter: { type: "int" },
718
+ embedding: { type: "vector", dimensions: 4 },
719
+ },
720
+ providerOptions: { schema: SCHEMA },
721
+ });
722
+ handle = pgvec.index("concurrent_test");
723
+ });
724
+
725
+ beforeEach(async () => {
726
+ await pool.query(`DELETE FROM "${SCHEMA}"."concurrent_test"`);
727
+ });
728
+
729
+ it("handles concurrent upserts to same document", async () => {
730
+ // Insert initial doc
731
+ await handle.upsert({
732
+ id: "concurrent-1",
733
+ counter: 0,
734
+ embedding: [0.1, 0.1, 0.1, 0.1],
735
+ });
736
+
737
+ // Concurrent updates
738
+ const updates = Array.from({ length: 10 }, (_, i) =>
739
+ handle.upsert({
740
+ id: "concurrent-1",
741
+ counter: i + 1,
742
+ embedding: [0.1, 0.1, 0.1, 0.1],
743
+ }),
744
+ );
745
+
746
+ await Promise.all(updates);
747
+
748
+ // Should have completed without errors
749
+ const hits = await handle.query({
750
+ filter: { id: "concurrent-1" },
751
+ topK: 1,
752
+ });
753
+
754
+ expect(hits).toHaveLength(1);
755
+ // Counter should be one of the values
756
+ expect(hits[0].document?.counter).toBeGreaterThanOrEqual(1);
757
+ expect(hits[0].document?.counter).toBeLessThanOrEqual(10);
758
+ });
759
+
760
+ it("handles concurrent upserts to different documents", async () => {
761
+ const upserts = Array.from({ length: 50 }, (_, i) =>
762
+ handle.upsert({
763
+ id: `parallel-${i}`,
764
+ counter: i,
765
+ embedding: [i / 50, (50 - i) / 50, 0.5, 0.5],
766
+ }),
767
+ );
768
+
769
+ await Promise.all(upserts);
770
+
771
+ // Increase ef_search to allow HNSW to explore more candidates
772
+ // Default is 40, which limits results to ~40 even with topK=100
773
+ await pool.query("SET hnsw.ef_search = 200");
774
+
775
+ const hits = await handle.query({
776
+ query: [{ embedding: [0.5, 0.5, 0.5, 0.5] }],
777
+ topK: 100,
778
+ });
779
+
780
+ expect(hits).toHaveLength(50);
781
+ });
782
+
783
+ it("handles query during upsert", async () => {
784
+ // Start upserting
785
+ const upsertPromise = handle.upsert(
786
+ Array.from({ length: 100 }, (_, i) => ({
787
+ id: `during-${i}`,
788
+ counter: i,
789
+ embedding: [0.1, 0.1, 0.1, 0.1],
790
+ })),
791
+ );
792
+
793
+ // Query while upserting
794
+ const queryPromise = handle.query({
795
+ query: [{ embedding: [0.1, 0.1, 0.1, 0.1] }],
796
+ topK: 1000,
797
+ });
798
+
799
+ const [, hits] = await Promise.all([upsertPromise, queryPromise]);
800
+
801
+ // Query should succeed (may see partial or full results)
802
+ expect(hits).toBeDefined();
803
+ });
804
+ });
805
+
806
+ // ============================================================
807
+ // SCHEMA NAME EDGE CASES
808
+ // ============================================================
809
+
810
+ describe("schema name handling", () => {
811
+ it("creates index in custom schema", async () => {
812
+ await pool.query(`CREATE SCHEMA IF NOT EXISTS "custom_schema"`);
813
+
814
+ await pgvec.createIndex({
815
+ id: "custom_schema_test",
816
+ schema: {
817
+ id: { type: "string", pk: true },
818
+ embedding: { type: "vector", dimensions: 4 },
819
+ },
820
+ providerOptions: { schema: "custom_schema" },
821
+ });
822
+
823
+ // Verify table is in correct schema
824
+ const result = await pool.query(
825
+ `SELECT 1 FROM information_schema.tables
826
+ WHERE table_schema = 'custom_schema' AND table_name = 'custom_schema_test'`,
827
+ );
828
+
829
+ expect(result.rows).toHaveLength(1);
830
+
831
+ await pgvec.deleteIndex("custom_schema_test");
832
+ await pool.query(`DROP SCHEMA IF EXISTS "custom_schema" CASCADE`);
833
+ });
834
+ });
835
+ });