@cocreate/qdrant 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/FUNDING.yml +3 -0
- package/.github/workflows/automated.yml +56 -0
- package/CHANGELOG.md +18 -0
- package/CONTRIBUTING.md +97 -0
- package/CoCreate.config.js +23 -0
- package/LICENSE +661 -0
- package/README.md +88 -0
- package/demo/index.html +19 -0
- package/docs/index.html +228 -0
- package/package.json +45 -0
- package/prettier.config.js +16 -0
- package/release.config.js +30 -0
- package/src/index.js +936 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (C) 2026 CoCreate and Contributors.
|
|
3
|
+
*
|
|
4
|
+
* This program is free software: you can redistribute it and/or modify
|
|
5
|
+
* it under the terms of the GNU Affero General Public License as published
|
|
6
|
+
* by the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
* (at your option) any later version.
|
|
8
|
+
*
|
|
9
|
+
* This program is distributed in the hope that it will be useful,
|
|
10
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
* GNU Affero General Public License for more details.
|
|
13
|
+
*
|
|
14
|
+
* You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
********************************************************************************/
|
|
17
|
+
|
|
18
|
+
import { QdrantClient } from "@qdrant/js-client-rest";
|
|
19
|
+
import {
|
|
20
|
+
dotNotationToObject,
|
|
21
|
+
queryData,
|
|
22
|
+
searchData,
|
|
23
|
+
sortData,
|
|
24
|
+
isValidDate
|
|
25
|
+
} from "@cocreate/utils";
|
|
26
|
+
|
|
27
|
+
const organizations = {};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Dynamically resolves or caches a long-lived Qdrant Vector Client connection.
|
|
31
|
+
* Caches the connection promise to prevent concurrent, duplicate handshakes.
|
|
32
|
+
* Supports direct connection URLs as well as credential objects containing API keys.
|
|
33
|
+
*/
|
|
34
|
+
async function dbClient(data) {
|
|
35
|
+
const storageUrl = data.storageUrl || data.url;
|
|
36
|
+
|
|
37
|
+
if (storageUrl) {
|
|
38
|
+
if (!organizations[data.organization_id]) {
|
|
39
|
+
organizations[data.organization_id] = {};
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
if (!organizations[data.organization_id][storageUrl]) {
|
|
43
|
+
const connectionPromise = (async () => {
|
|
44
|
+
let config = {};
|
|
45
|
+
|
|
46
|
+
// Case A: Structured credentials object containing apiKey and endpoint
|
|
47
|
+
if (typeof storageUrl === "object" && storageUrl !== null) {
|
|
48
|
+
config = {
|
|
49
|
+
url: storageUrl.url || storageUrl.endpoint || "http://localhost:6333",
|
|
50
|
+
apiKey: storageUrl.apiKey || storageUrl.api_key || undefined
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// Case B: Standard connection string URL
|
|
54
|
+
else if (typeof storageUrl === "string") {
|
|
55
|
+
config = { url: storageUrl };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const client = new QdrantClient(config);
|
|
59
|
+
|
|
60
|
+
// Test the client connection viability
|
|
61
|
+
await client.api("collections").getCollections();
|
|
62
|
+
return client;
|
|
63
|
+
})();
|
|
64
|
+
|
|
65
|
+
organizations[data.organization_id][storageUrl] = connectionPromise;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return await organizations[data.organization_id][storageUrl];
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error(`${data.organization_id}: Qdrant failed to connect`);
|
|
71
|
+
errorHandler(data, error);
|
|
72
|
+
return { status: false };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
errorHandler(data, "missing Qdrant Connection URL/Credentials");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Global Event Listener: orgDeleted
|
|
82
|
+
* Cleans up active Qdrant instances and destroys connection configurations gracefully.
|
|
83
|
+
*/
|
|
84
|
+
process.on("orgDeleted", (organization_id) => {
|
|
85
|
+
const orgPools = organizations[organization_id];
|
|
86
|
+
if (orgPools) {
|
|
87
|
+
console.log(`[Qdrant Utilities] Cleanup Scheduled: Initiating 5-second grace period for Org: ${organization_id}`);
|
|
88
|
+
setTimeout(async () => {
|
|
89
|
+
const currentPools = organizations[organization_id];
|
|
90
|
+
if (!currentPools) return;
|
|
91
|
+
|
|
92
|
+
console.log(`[Qdrant Utilities] Cleanup Executing: Closing clients for Org: ${organization_id}`);
|
|
93
|
+
// Qdrant client uses standard http/https fetch underneath, so we simply delete the cached connections
|
|
94
|
+
delete organizations[organization_id];
|
|
95
|
+
console.log(`[Qdrant Utilities] Memory Cleared: Registry freed for Org: ${organization_id}`);
|
|
96
|
+
}, 5000);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Universal router matching the standard CoCreate pipeline signature.
|
|
102
|
+
*/
|
|
103
|
+
export function send(data) {
|
|
104
|
+
let [type, method] = data.method.split(".");
|
|
105
|
+
if (type === "database") return database(method, data);
|
|
106
|
+
if (type === "array") return array(method, data);
|
|
107
|
+
if (type === "object") return object(method, data);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* "database" operations. Since Qdrant is catalog-less and runs globally,
|
|
112
|
+
* we emulate isolated databases by appending a prefix or treating the
|
|
113
|
+
* active tenant name as a partition namespace.
|
|
114
|
+
*/
|
|
115
|
+
function database(method, data) {
|
|
116
|
+
const storageName = data.storageName || data.provider || "qdrant";
|
|
117
|
+
return new Promise(async (resolve) => {
|
|
118
|
+
let type = "database";
|
|
119
|
+
let databaseArray = [];
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const db = await dbClient(data);
|
|
123
|
+
if (!db || db.status === false) return resolve(data);
|
|
124
|
+
|
|
125
|
+
if (method === "read") {
|
|
126
|
+
const dbName = data.database || "default";
|
|
127
|
+
const dbObj = { name: dbName };
|
|
128
|
+
|
|
129
|
+
process.emit("usage", {
|
|
130
|
+
type: "egress",
|
|
131
|
+
data: { api: "listDatabases", context: dbName },
|
|
132
|
+
organization_id: data.organization_id
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (data.$filter && data.$filter.query) {
|
|
136
|
+
let isFilter = queryData(dbObj, data.$filter.query);
|
|
137
|
+
if (isFilter) {
|
|
138
|
+
databaseArray.push({ database: dbObj, storage: storageName });
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
databaseArray.push({ database: dbObj, storage: storageName });
|
|
142
|
+
}
|
|
143
|
+
resolve(createData(data, databaseArray, type));
|
|
144
|
+
}
|
|
145
|
+
if (method === "delete") {
|
|
146
|
+
// Emulate database partition teardown by dropping all collections with our organization prefix
|
|
147
|
+
const listRes = await db.api("collections").getCollections();
|
|
148
|
+
const prefix = `${data.organization_id}_`;
|
|
149
|
+
|
|
150
|
+
for (let col of listRes.result.collections || []) {
|
|
151
|
+
if (col.name.startsWith(prefix)) {
|
|
152
|
+
process.emit("usage", {
|
|
153
|
+
type: "egress",
|
|
154
|
+
data: { api: "deleteCollection", collection: col.name },
|
|
155
|
+
organization_id: data.organization_id
|
|
156
|
+
});
|
|
157
|
+
await db.deleteCollection(col.name);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
resolve({ status: true });
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
errorHandler(data, error);
|
|
164
|
+
console.log(method, "error", error);
|
|
165
|
+
resolve(data);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* "array" operations mapped directly to Qdrant Collections.
|
|
172
|
+
* We isolate multi-tenant structures cleanly by prefixing collections: "orgId_collectionName"
|
|
173
|
+
*/
|
|
174
|
+
function array(method, data) {
|
|
175
|
+
const storageName = data.storageName || data.provider || "qdrant";
|
|
176
|
+
return new Promise(async (resolve) => {
|
|
177
|
+
let type = "array";
|
|
178
|
+
let arrayArray = [];
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const db = await dbClient(data);
|
|
182
|
+
if (!db || db.status === false) return resolve(data);
|
|
183
|
+
|
|
184
|
+
if (data.request) data.array = data.request;
|
|
185
|
+
|
|
186
|
+
let databases = data.database;
|
|
187
|
+
if (!Array.isArray(databases)) databases = [databases];
|
|
188
|
+
|
|
189
|
+
let databasesLength = databases.length;
|
|
190
|
+
for (let database of databases) {
|
|
191
|
+
const prefix = `${data.organization_id}_`;
|
|
192
|
+
|
|
193
|
+
if (method === "read") {
|
|
194
|
+
process.emit("usage", {
|
|
195
|
+
type: "egress",
|
|
196
|
+
data: { api: "getCollections" },
|
|
197
|
+
organization_id: data.organization_id
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const listRes = await db.api("collections").getCollections();
|
|
201
|
+
for (let col of listRes.result.collections || []) {
|
|
202
|
+
if (col.name.startsWith(prefix)) {
|
|
203
|
+
const cleanName = col.name.substring(prefix.length);
|
|
204
|
+
const tableObj = { name: cleanName };
|
|
205
|
+
|
|
206
|
+
if (data.$filter && data.$filter.query) {
|
|
207
|
+
let isFilter = queryData(tableObj, data.$filter.query);
|
|
208
|
+
if (isFilter) {
|
|
209
|
+
arrayArray.push({ name: cleanName, database, storage: storageName });
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
arrayArray.push({ name: cleanName, database, storage: storageName });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
databasesLength -= 1;
|
|
218
|
+
if (!databasesLength) {
|
|
219
|
+
data = createData(data, arrayArray, type);
|
|
220
|
+
resolve(data);
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
let arrays = data.array;
|
|
224
|
+
if (method === "update") arrays = Object.entries(data.array);
|
|
225
|
+
if (!Array.isArray(arrays)) arrays = [arrays];
|
|
226
|
+
|
|
227
|
+
let arraysLength = arrays.length;
|
|
228
|
+
for (let array of arrays) {
|
|
229
|
+
if (method === "create") {
|
|
230
|
+
const realTableName = `${prefix}${array}`;
|
|
231
|
+
|
|
232
|
+
process.emit("usage", {
|
|
233
|
+
type: "egress",
|
|
234
|
+
data: { api: "createCollection", collection: realTableName },
|
|
235
|
+
organization_id: data.organization_id
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Create a vector index collection. Qdrant requires vector configurations.
|
|
239
|
+
// Defaulting to 1536 dimensions (standard OpenAI text-embedding-3-small size) and Cosine metric.
|
|
240
|
+
const vectorSize = data.vectorSize || data.vector_size || 1536;
|
|
241
|
+
const distanceMetric = data.distanceMetric || data.distance_metric || "Cosine";
|
|
242
|
+
|
|
243
|
+
await db.createCollection(realTableName, {
|
|
244
|
+
vectors: {
|
|
245
|
+
size: vectorSize,
|
|
246
|
+
distance: distanceMetric
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
arrayArray.push({ name: array, database, storage: storageName });
|
|
251
|
+
|
|
252
|
+
arraysLength -= 1;
|
|
253
|
+
if (!arraysLength) databasesLength -= 1;
|
|
254
|
+
if (!databasesLength && !arraysLength) {
|
|
255
|
+
data = createData(data, arrayArray, type);
|
|
256
|
+
resolve(data);
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
if (method === "update") {
|
|
260
|
+
let [oldName, newName] = array;
|
|
261
|
+
const oldRealName = `${prefix}${oldName}`;
|
|
262
|
+
const newRealName = `${prefix}${newName}`;
|
|
263
|
+
|
|
264
|
+
process.emit("usage", {
|
|
265
|
+
type: "egress",
|
|
266
|
+
data: { api: "renameCollectionEmulation", from: oldRealName, to: newRealName },
|
|
267
|
+
organization_id: data.organization_id
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Qdrant does not natively support renaming collections. We emulate by creating
|
|
271
|
+
// the target collection and migrating all points.
|
|
272
|
+
const colInfo = await db.getCollection(oldRealName);
|
|
273
|
+
const vectorParams = colInfo.config.params.vectors;
|
|
274
|
+
|
|
275
|
+
await db.createCollection(newRealName, {
|
|
276
|
+
vectors: vectorParams
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Scroll and migrate points in batches
|
|
280
|
+
let scrollRes = await db.scroll(oldRealName, { limit: 100, with_payload: true, with_vector: true });
|
|
281
|
+
while (scrollRes.points && scrollRes.points.length > 0) {
|
|
282
|
+
await db.upsert(newRealName, {
|
|
283
|
+
wait: true,
|
|
284
|
+
points: scrollRes.points
|
|
285
|
+
});
|
|
286
|
+
if (!scrollRes.next_page_offset) break;
|
|
287
|
+
scrollRes = await db.scroll(oldRealName, {
|
|
288
|
+
limit: 100,
|
|
289
|
+
offset: scrollRes.next_page_offset,
|
|
290
|
+
with_payload: true,
|
|
291
|
+
with_vector: true
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Drop old collection
|
|
296
|
+
await db.deleteCollection(oldRealName);
|
|
297
|
+
|
|
298
|
+
arrayArray.push({ name: newName, oldName: oldName, database, storage: storageName });
|
|
299
|
+
|
|
300
|
+
arraysLength -= 1;
|
|
301
|
+
if (!arraysLength) databasesLength -= 1;
|
|
302
|
+
if (!databasesLength && !arraysLength) {
|
|
303
|
+
data = createData(data, arrayArray, type);
|
|
304
|
+
resolve(data);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (method === "delete") {
|
|
309
|
+
const realTableName = `${prefix}${array}`;
|
|
310
|
+
process.emit("usage", {
|
|
311
|
+
type: "egress",
|
|
312
|
+
data: { api: "deleteCollection", collection: realTableName },
|
|
313
|
+
organization_id: data.organization_id
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
await db.deleteCollection(realTableName);
|
|
317
|
+
|
|
318
|
+
arrayArray.push({ name: array, database, storage: storageName });
|
|
319
|
+
|
|
320
|
+
arraysLength -= 1;
|
|
321
|
+
if (!arraysLength) databasesLength -= 1;
|
|
322
|
+
if (!databasesLength && !arraysLength) {
|
|
323
|
+
data = createData(data, arrayArray, type);
|
|
324
|
+
resolve(data);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} catch (error) {
|
|
332
|
+
errorHandler(data, error);
|
|
333
|
+
console.log(method, "error", error);
|
|
334
|
+
resolve(data);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* "object" operations mapped to Qdrant Collection Points (Vector + Metadata Payload).
|
|
341
|
+
* Documents in Qdrant must contain a "vector" key (an array of floats representing embeddings).
|
|
342
|
+
*/
|
|
343
|
+
function object(method, data) {
|
|
344
|
+
const storageName = data.storageName || data.provider || "qdrant";
|
|
345
|
+
return new Promise(async (resolve) => {
|
|
346
|
+
try {
|
|
347
|
+
const db = await dbClient(data);
|
|
348
|
+
if (!db || db.status === false) return resolve(data);
|
|
349
|
+
|
|
350
|
+
let type = "object";
|
|
351
|
+
let documents = [];
|
|
352
|
+
|
|
353
|
+
if (!data["timeStamp"]) {
|
|
354
|
+
data["timeStamp"] = new Date().toISOString();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let databases = data.database;
|
|
358
|
+
if (!Array.isArray(databases)) databases = [databases];
|
|
359
|
+
|
|
360
|
+
for (let database of databases) {
|
|
361
|
+
let arrays = data.array;
|
|
362
|
+
if (!Array.isArray(arrays)) arrays = [arrays];
|
|
363
|
+
|
|
364
|
+
for (let array of arrays) {
|
|
365
|
+
const realTableName = `${data.organization_id}_${array}`;
|
|
366
|
+
const reference = {
|
|
367
|
+
$storage: storageName,
|
|
368
|
+
$database: database,
|
|
369
|
+
$array: array
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
if (!data[type]) data[type] = [];
|
|
373
|
+
else if (typeof data[type] === "string")
|
|
374
|
+
data[type] = [{ _id: data[type] }];
|
|
375
|
+
else if (!Array.isArray(data[type]))
|
|
376
|
+
data[type] = [data[type]];
|
|
377
|
+
|
|
378
|
+
let isFilter = !!data.$filter;
|
|
379
|
+
if ((isFilter && !data[type].length) || data.isFilter) {
|
|
380
|
+
data[type].splice(0, 0, { isFilter: "isEmptyObjectFilter" });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
for (let i = 0; i < data[type].length; i++) {
|
|
384
|
+
let $storage = data[type][i].$storage || [];
|
|
385
|
+
let $database = data[type][i].$database || [];
|
|
386
|
+
let $array = data[type][i].$array || [];
|
|
387
|
+
|
|
388
|
+
if (!Array.isArray($storage)) $storage = [data[type][i].$storage];
|
|
389
|
+
if (!Array.isArray($database)) $database = [data[type][i].$database];
|
|
390
|
+
if (!Array.isArray($array)) $array = [data[type][i].$array];
|
|
391
|
+
|
|
392
|
+
if (!$storage.includes(storageName)) $storage.push(storageName);
|
|
393
|
+
if (!$database.includes(database)) $database.push(database);
|
|
394
|
+
if (!$array.includes(array)) $array.push(array);
|
|
395
|
+
|
|
396
|
+
delete data[type][i].$storage;
|
|
397
|
+
delete data[type][i].$database;
|
|
398
|
+
delete data[type][i].$array;
|
|
399
|
+
|
|
400
|
+
let queryFilter = isFilter ? data.$filter : (data[type][i].$filter || null);
|
|
401
|
+
|
|
402
|
+
if (method === "create") {
|
|
403
|
+
data[type][i] = replaceArray(data[type][i]);
|
|
404
|
+
data[type][i] = dotNotationToObject(data[type][i]);
|
|
405
|
+
|
|
406
|
+
// Qdrant point IDs must be UUIDs or 64-bit integers.
|
|
407
|
+
const id = data[type][i]._id || generateUUID();
|
|
408
|
+
const orgId = data.organization_id;
|
|
409
|
+
const created = {
|
|
410
|
+
on: new Date(data.timeStamp).toISOString(),
|
|
411
|
+
by: data.user_id || data.clientId
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
// Extract the vector data. If none is passed, we default to a mock zero-vector.
|
|
415
|
+
const vector = data[type][i].vector || data[type][i].embeddings || new Array(1536).fill(0.0);
|
|
416
|
+
|
|
417
|
+
const recordPayload = { ...data[type][i] };
|
|
418
|
+
delete recordPayload._id;
|
|
419
|
+
delete recordPayload.organization_id;
|
|
420
|
+
delete recordPayload.vector;
|
|
421
|
+
delete recordPayload.embeddings;
|
|
422
|
+
|
|
423
|
+
const finalPayload = {
|
|
424
|
+
_id: id,
|
|
425
|
+
organization_id: orgId,
|
|
426
|
+
created,
|
|
427
|
+
modified: null,
|
|
428
|
+
...recordPayload
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
process.emit("usage", {
|
|
432
|
+
type: "egress",
|
|
433
|
+
data: { api: "upsertPoint", collection: realTableName, id },
|
|
434
|
+
organization_id: data.organization_id
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
await db.upsert(realTableName, {
|
|
438
|
+
wait: true,
|
|
439
|
+
points: [
|
|
440
|
+
{
|
|
441
|
+
id: id,
|
|
442
|
+
vector: vector,
|
|
443
|
+
payload: finalPayload
|
|
444
|
+
}
|
|
445
|
+
]
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
data[type][i] = { ...finalPayload, vector, $storage, $database, $array };
|
|
449
|
+
}
|
|
450
|
+
else if (method === "read") {
|
|
451
|
+
if (data[type][i]._id && !isFilter) {
|
|
452
|
+
process.emit("usage", {
|
|
453
|
+
type: "egress",
|
|
454
|
+
data: { api: "retrievePoint", collection: realTableName, id: data[type][i]._id },
|
|
455
|
+
organization_id: data.organization_id
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const retrieveRes = await db.retrieve(realTableName, {
|
|
459
|
+
ids: [data[type][i]._id],
|
|
460
|
+
with_payload: true,
|
|
461
|
+
with_vector: true
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
if (retrieveRes.length > 0) {
|
|
465
|
+
let dbRecord = retrieveRes[0].payload;
|
|
466
|
+
let vector = retrieveRes[0].vector;
|
|
467
|
+
|
|
468
|
+
if ($storage.length && data[type][i].modified && data[type][i].modified.on) {
|
|
469
|
+
let clientTime = new Date(data[type][i].modified.on);
|
|
470
|
+
let dbTime = new Date(dbRecord.modified ? dbRecord.modified.on : 0);
|
|
471
|
+
|
|
472
|
+
if (clientTime > dbTime) {
|
|
473
|
+
const updatedRecord = { ...dbRecord, ...data[type][i] };
|
|
474
|
+
const savedRecord = await executeUpdatePoint(db, realTableName, data[type][i]._id, updatedRecord, data);
|
|
475
|
+
data[type][i] = { ...savedRecord, $storage, $database, $array };
|
|
476
|
+
} else {
|
|
477
|
+
data[type][i] = { ...data[type][i], ...dbRecord, vector, $storage, $database, $array };
|
|
478
|
+
}
|
|
479
|
+
} else {
|
|
480
|
+
data[type][i] = { ...data[type][i], ...dbRecord, vector, $storage, $database, $array };
|
|
481
|
+
}
|
|
482
|
+
} else {
|
|
483
|
+
data[type].splice(i, 1);
|
|
484
|
+
i--;
|
|
485
|
+
}
|
|
486
|
+
} else {
|
|
487
|
+
process.emit("usage", {
|
|
488
|
+
type: "egress",
|
|
489
|
+
data: { api: "scrollPoints", collection: realTableName },
|
|
490
|
+
organization_id: data.organization_id
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
let queryResults = [];
|
|
494
|
+
|
|
495
|
+
if (data.$filter && (data.$filter.vector || data.$filter.embeddings)) {
|
|
496
|
+
const queryVector = data.$filter.vector || data.$filter.embeddings;
|
|
497
|
+
const filterParams = compileFilterToQdrant(queryFilter, data.organization_id);
|
|
498
|
+
|
|
499
|
+
process.emit("usage", {
|
|
500
|
+
type: "egress",
|
|
501
|
+
data: { api: "searchPoints", collection: realTableName },
|
|
502
|
+
organization_id: data.organization_id
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// Execute high-performance cosine similarity nearest-neighbor semantic search
|
|
506
|
+
const searchRes = await db.search(realTableName, {
|
|
507
|
+
vector: queryVector,
|
|
508
|
+
filter: filterParams,
|
|
509
|
+
limit: data.$filter.limit || 10,
|
|
510
|
+
with_payload: true,
|
|
511
|
+
with_vector: true
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
for (let match of searchRes) {
|
|
515
|
+
queryResults.push({
|
|
516
|
+
...match.payload,
|
|
517
|
+
_score: match.score,
|
|
518
|
+
vector: match.vector,
|
|
519
|
+
...reference
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
} else {
|
|
523
|
+
const filterParams = compileFilterToQdrant(queryFilter, data.organization_id);
|
|
524
|
+
|
|
525
|
+
process.emit("usage", {
|
|
526
|
+
type: "egress",
|
|
527
|
+
data: { api: "scrollPoints", collection: realTableName },
|
|
528
|
+
organization_id: data.organization_id
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const scrollRes = await db.scroll(realTableName, {
|
|
532
|
+
filter: filterParams,
|
|
533
|
+
limit: data.$filter?.limit || 100,
|
|
534
|
+
offset: data.$filter?.index || undefined,
|
|
535
|
+
with_payload: true,
|
|
536
|
+
with_vector: true
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
for (let pt of scrollRes.points || []) {
|
|
540
|
+
queryResults.push({
|
|
541
|
+
...pt.payload,
|
|
542
|
+
vector: pt.vector,
|
|
543
|
+
...reference
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// In-memory sort fallback
|
|
549
|
+
if (data.$filter && data.$filter.sort) {
|
|
550
|
+
queryResults = sortData(queryResults, data.$filter.sort);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
for (let record of queryResults) {
|
|
554
|
+
if (data.$filter && data.$filter.search) {
|
|
555
|
+
let isMatch = searchData(record, data.$filter.search);
|
|
556
|
+
if (!isMatch) continue;
|
|
557
|
+
}
|
|
558
|
+
documents.push(record);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
else if (method === "update") {
|
|
563
|
+
const updatePayload = { ...data[type][i] };
|
|
564
|
+
delete updatePayload._id;
|
|
565
|
+
delete updatePayload.$filter;
|
|
566
|
+
|
|
567
|
+
if (data[type][i]._id) {
|
|
568
|
+
const updatedDoc = await executeUpdatePoint(db, realTableName, data[type][i]._id, updatePayload, data);
|
|
569
|
+
if (updatedDoc) {
|
|
570
|
+
data[type][i] = { ...updatedDoc, $storage, $database, $array };
|
|
571
|
+
}
|
|
572
|
+
} else if (isFilter) {
|
|
573
|
+
const filterParams = compileFilterToQdrant(queryFilter, data.organization_id);
|
|
574
|
+
const scrollRes = await db.scroll(realTableName, {
|
|
575
|
+
filter: filterParams,
|
|
576
|
+
with_payload: true
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
for (let pt of scrollRes.points || []) {
|
|
580
|
+
const updatedDoc = await executeUpdatePoint(db, realTableName, pt.id, updatePayload, data);
|
|
581
|
+
if (updatedDoc) {
|
|
582
|
+
documents.push({ ...updatedDoc, ...reference });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
else if (method === "delete") {
|
|
588
|
+
if (data[type][i]._id) {
|
|
589
|
+
process.emit("usage", {
|
|
590
|
+
type: "egress",
|
|
591
|
+
data: { api: "deletePoints", collection: realTableName, id: data[type][i]._id },
|
|
592
|
+
organization_id: data.organization_id
|
|
593
|
+
});
|
|
594
|
+
await db.delete(realTableName, {
|
|
595
|
+
points: [data[type][i]._id]
|
|
596
|
+
});
|
|
597
|
+
data[type][i] = { _id: data[type][i]._id, $storage, $database, $array };
|
|
598
|
+
} else if (isFilter) {
|
|
599
|
+
const filterParams = compileFilterToQdrant(queryFilter, data.organization_id);
|
|
600
|
+
process.emit("usage", {
|
|
601
|
+
type: "egress",
|
|
602
|
+
data: { api: "deletePointsByFilter", collection: realTableName },
|
|
603
|
+
organization_id: data.organization_id
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// Get the point IDs to delete
|
|
607
|
+
const scrollRes = await db.scroll(realTableName, {
|
|
608
|
+
filter: filterParams,
|
|
609
|
+
with_payload: false
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
if (scrollRes.points && scrollRes.points.length > 0) {
|
|
613
|
+
const idsToDelete = scrollRes.points.map(pt => pt.id);
|
|
614
|
+
await db.delete(realTableName, {
|
|
615
|
+
points: idsToDelete
|
|
616
|
+
});
|
|
617
|
+
for (let id of idsToDelete) {
|
|
618
|
+
documents.push({ _id: id, ...reference });
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
data = createData(data, documents, type);
|
|
628
|
+
resolve(data);
|
|
629
|
+
} catch (error) {
|
|
630
|
+
errorHandler(data, error);
|
|
631
|
+
console.log(method, "error", error);
|
|
632
|
+
resolve(data);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Simulates transactional updates inside Qdrant by performing a Retrieve-Modify-Upsert lock pattern.
|
|
639
|
+
*/
|
|
640
|
+
async function executeUpdatePoint(db, realTableName, pointId, rawUpdateInput, requestData) {
|
|
641
|
+
try {
|
|
642
|
+
const retrieveRes = await db.retrieve(realTableName, {
|
|
643
|
+
ids: [pointId],
|
|
644
|
+
with_payload: true,
|
|
645
|
+
with_vector: true
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
let currentPoint = retrieveRes.length > 0 ? retrieveRes[0] : null;
|
|
649
|
+
let currentRecord = currentPoint ? currentPoint.payload : null;
|
|
650
|
+
let currentVector = currentPoint ? currentPoint.vector : (rawUpdateInput.vector || rawUpdateInput.embeddings || new Array(1536).fill(0.0));
|
|
651
|
+
|
|
652
|
+
if (!currentRecord) {
|
|
653
|
+
if (requestData.upsert || rawUpdateInput.upsert || rawUpdateInput.$upsert) {
|
|
654
|
+
currentRecord = { _id: pointId, organization_id: requestData.organization_id };
|
|
655
|
+
} else {
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const modified = {
|
|
661
|
+
on: new Date(requestData.timeStamp).toISOString(),
|
|
662
|
+
by: requestData.user_id || requestData.clientId
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
// Run deep nested JSON modifier aligning with Mongo semantics
|
|
666
|
+
const updatedPayload = applyMongoUpdate(currentRecord, rawUpdateInput);
|
|
667
|
+
|
|
668
|
+
// If a new vector was passed in the update payload, update the vectors coordinates
|
|
669
|
+
if (rawUpdateInput.vector || rawUpdateInput.embeddings) {
|
|
670
|
+
currentVector = rawUpdateInput.vector || rawUpdateInput.embeddings;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const savePayload = { ...updatedPayload };
|
|
674
|
+
delete savePayload._id;
|
|
675
|
+
delete savePayload.organization_id;
|
|
676
|
+
delete savePayload.created;
|
|
677
|
+
delete savePayload.modified;
|
|
678
|
+
delete savePayload.vector;
|
|
679
|
+
delete savePayload.embeddings;
|
|
680
|
+
|
|
681
|
+
const recordToWrite = {
|
|
682
|
+
_id: pointId,
|
|
683
|
+
organization_id: requestData.organization_id,
|
|
684
|
+
created: currentRecord.created || modified,
|
|
685
|
+
modified,
|
|
686
|
+
...savePayload
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
process.emit("usage", {
|
|
690
|
+
type: "egress",
|
|
691
|
+
data: { api: "upsertPoint", collection: realTableName, id: pointId },
|
|
692
|
+
organization_id: requestData.organization_id
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
await db.upsert(realTableName, {
|
|
696
|
+
wait: true,
|
|
697
|
+
points: [
|
|
698
|
+
{
|
|
699
|
+
id: pointId,
|
|
700
|
+
vector: currentVector,
|
|
701
|
+
payload: recordToWrite
|
|
702
|
+
}
|
|
703
|
+
]
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
return { ...recordToWrite, vector: currentVector };
|
|
707
|
+
} catch (error) {
|
|
708
|
+
throw error;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Applies MongoDB update operators recursively on payloads before index storage.
|
|
714
|
+
*/
|
|
715
|
+
function applyMongoUpdate(record, updatePayload) {
|
|
716
|
+
let target = { ...record };
|
|
717
|
+
|
|
718
|
+
Object.keys(updatePayload).forEach((rawKey) => {
|
|
719
|
+
if (rawKey.startsWith("$")) return;
|
|
720
|
+
let cleanKey = rawKey.replace(/\[(\d+)\]/g, ".$1");
|
|
721
|
+
setNestedValue(target, cleanKey, updatePayload[rawKey]);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
if (updatePayload.$set) {
|
|
725
|
+
Object.keys(updatePayload.$set).forEach((key) => {
|
|
726
|
+
let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
|
|
727
|
+
setNestedValue(target, cleanKey, updatePayload.$set[key]);
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (updatePayload.$unset) {
|
|
732
|
+
Object.keys(updatePayload.$unset).forEach((key) => {
|
|
733
|
+
let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
|
|
734
|
+
deleteNestedValue(target, cleanKey);
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (updatePayload.$inc) {
|
|
739
|
+
Object.keys(updatePayload.$inc).forEach((key) => {
|
|
740
|
+
let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
|
|
741
|
+
let currentVal = getNestedValue(target, cleanKey) || 0;
|
|
742
|
+
setNestedValue(target, cleanKey, Number(currentVal) + Number(updatePayload.$inc[key]));
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (updatePayload.$push) {
|
|
747
|
+
Object.keys(updatePayload.$push).forEach((key) => {
|
|
748
|
+
let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
|
|
749
|
+
let arr = getNestedValue(target, cleanKey);
|
|
750
|
+
if (!Array.isArray(arr)) arr = [];
|
|
751
|
+
|
|
752
|
+
const pushValue = updatePayload.$push[key];
|
|
753
|
+
if (pushValue && pushValue.$each) {
|
|
754
|
+
let valuesToPush = pushValue.$each;
|
|
755
|
+
let position = typeof pushValue.$position === "number" ? pushValue.$position : arr.length;
|
|
756
|
+
arr.splice(position, 0, ...valuesToPush);
|
|
757
|
+
} else {
|
|
758
|
+
arr.push(pushValue);
|
|
759
|
+
}
|
|
760
|
+
setNestedValue(target, cleanKey, arr);
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (updatePayload.$pull) {
|
|
765
|
+
Object.keys(updatePayload.$pull).forEach((key) => {
|
|
766
|
+
let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
|
|
767
|
+
let arr = getNestedValue(target, cleanKey);
|
|
768
|
+
if (Array.isArray(arr)) {
|
|
769
|
+
const filterVal = updatePayload.$pull[key];
|
|
770
|
+
arr = arr.filter(item => {
|
|
771
|
+
if (typeof filterVal === "object" && filterVal !== null) {
|
|
772
|
+
return !queryData(item, filterVal);
|
|
773
|
+
}
|
|
774
|
+
return item !== filterVal;
|
|
775
|
+
});
|
|
776
|
+
setNestedValue(target, cleanKey, arr);
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (updatePayload.$addToSet) {
|
|
782
|
+
Object.keys(updatePayload.$addToSet).forEach((key) => {
|
|
783
|
+
let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
|
|
784
|
+
let arr = getNestedValue(target, cleanKey);
|
|
785
|
+
if (!Array.isArray(arr)) arr = [];
|
|
786
|
+
|
|
787
|
+
const addValue = updatePayload.$addToSet[key];
|
|
788
|
+
if (addValue && addValue.$each) {
|
|
789
|
+
addValue.$each.forEach(item => {
|
|
790
|
+
if (!arr.includes(item)) arr.push(item);
|
|
791
|
+
});
|
|
792
|
+
} else {
|
|
793
|
+
if (!arr.includes(addValue)) arr.push(addValue);
|
|
794
|
+
}
|
|
795
|
+
setNestedValue(target, cleanKey, arr);
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return target;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Normalizes and translates Mongo-style schema filters dynamically to Qdrant Payload Filter syntax.
|
|
804
|
+
*/
|
|
805
|
+
function compileFilterToQdrant(filter, organizationId) {
|
|
806
|
+
let qdrantFilter = {
|
|
807
|
+
must: [
|
|
808
|
+
{
|
|
809
|
+
key: "organization_id",
|
|
810
|
+
match: { value: organizationId }
|
|
811
|
+
}
|
|
812
|
+
]
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
if (filter && filter.query) {
|
|
816
|
+
for (let key in filter.query) {
|
|
817
|
+
if (["$or", "$and", "organization_id"].includes(key)) continue;
|
|
818
|
+
|
|
819
|
+
let value = filter.query[key];
|
|
820
|
+
|
|
821
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
822
|
+
for (let op in value) {
|
|
823
|
+
let operand = value[op];
|
|
824
|
+
if (op === "$gt") qdrantFilter.must.push({ key, range: { gt: operand } });
|
|
825
|
+
else if (op === "$gte") qdrantFilter.must.push({ key, range: { gte: operand } });
|
|
826
|
+
else if (op === "$lt") qdrantFilter.must.push({ key, range: { lt: operand } });
|
|
827
|
+
else if (op === "$lte") qdrantFilter.must.push({ key, range: { lte: operand } });
|
|
828
|
+
else if (op === "$ne") {
|
|
829
|
+
if (!qdrantFilter.must_not) qdrantFilter.must_not = [];
|
|
830
|
+
qdrantFilter.must_not.push({ key, match: { value: operand } });
|
|
831
|
+
}
|
|
832
|
+
else if (op === "$in") {
|
|
833
|
+
qdrantFilter.must.push({ key, match: { any: operand } });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
} else {
|
|
837
|
+
qdrantFilter.must.push({
|
|
838
|
+
key: key,
|
|
839
|
+
match: { value: value }
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
return qdrantFilter;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* UUID generator utility used to produce fully compliant UUID-v4 point IDs for Qdrant.
|
|
850
|
+
*/
|
|
851
|
+
function generateUUID() {
|
|
852
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
|
853
|
+
const r = (Math.random() * 16) | 0,
|
|
854
|
+
v = c == "x" ? r : (r & 0x3) | 0x8;
|
|
855
|
+
return v.toString(16);
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function replaceArray(data = {}) {
|
|
860
|
+
let object = {};
|
|
861
|
+
Object.keys(data).forEach((key) => {
|
|
862
|
+
object[key.replace(/\[(\d+)\]/g, ".$1")] = data[key];
|
|
863
|
+
});
|
|
864
|
+
return object;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function getNestedValue(obj, path) {
|
|
868
|
+
return path.split(".").reduce((acc, part) => acc && acc[part], obj);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function setNestedValue(obj, path, value) {
|
|
872
|
+
const parts = path.split(".");
|
|
873
|
+
const last = parts.pop();
|
|
874
|
+
const target = parts.reduce((acc, part) => acc[part] = acc[part] || {}, obj);
|
|
875
|
+
target[last] = value;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function deleteNestedValue(obj, path) {
|
|
879
|
+
const parts = path.split(".");
|
|
880
|
+
const last = parts.pop();
|
|
881
|
+
const target = parts.reduce((acc, part) => acc && acc[part], obj);
|
|
882
|
+
if (target) delete target[last];
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function createData(data, array, type) {
|
|
886
|
+
if (data[type] && data[type][0] && data[type][0].isFilter === "isEmptyObjectFilter") {
|
|
887
|
+
data[type].shift();
|
|
888
|
+
data.isFilter = true;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
let key = type !== "object" ? "name" : "_id";
|
|
892
|
+
|
|
893
|
+
if (!Array.isArray(data[type])) {
|
|
894
|
+
console.log("data[type] is not an array", type);
|
|
895
|
+
} else {
|
|
896
|
+
for (let i = 0; i < array.length; i++) {
|
|
897
|
+
const matchIndex = data[type].findIndex((item) => item[key] === array[i][key]);
|
|
898
|
+
if (matchIndex !== -1) {
|
|
899
|
+
for (let $type of ["$storage", "$database", "$array"]) {
|
|
900
|
+
if (!data[type][matchIndex][$type])
|
|
901
|
+
data[type][matchIndex][$type] = [];
|
|
902
|
+
if (!Array.isArray(data[type][matchIndex][$type])) {
|
|
903
|
+
data[type][matchIndex][$type] = [data[type][matchIndex][$type]];
|
|
904
|
+
}
|
|
905
|
+
if (!data[type][matchIndex][$type].includes(array[i][$type])) {
|
|
906
|
+
data[type][matchIndex][$type].push(array[i][$type]);
|
|
907
|
+
}
|
|
908
|
+
delete array[i][$type];
|
|
909
|
+
}
|
|
910
|
+
data[type][matchIndex] = { ...data[type][matchIndex], ...array[i] };
|
|
911
|
+
} else {
|
|
912
|
+
data[type].push(array[i]);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return data;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function errorHandler(data, error, database, array) {
|
|
920
|
+
let errorMessage = typeof error === "object" && error.message ? error.message : error;
|
|
921
|
+
let errorObject = {
|
|
922
|
+
message: errorMessage,
|
|
923
|
+
storage: "qdrant"
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
if (database) errorObject.database = database;
|
|
927
|
+
if (array) errorObject.array = array;
|
|
928
|
+
|
|
929
|
+
if (Array.isArray(data.error)) {
|
|
930
|
+
data.error.push(errorObject);
|
|
931
|
+
} else {
|
|
932
|
+
data.error = [errorObject];
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
export default { send };
|