@objy/server 0.0.0-stage → 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.
- package/API.md +1469 -0
- package/LICENSE +21 -0
- package/NOTICE.md +9 -0
- package/README.md +21 -2
- package/objydb.config.json +48 -0
- package/package.json +42 -4
- package/public/index.html +4134 -0
- package/public/objy-db-logo.png +0 -0
- package/src/aggregate.js +438 -0
- package/src/api.js +15 -0
- package/src/auth.js +472 -0
- package/src/clock.js +18 -0
- package/src/cluster-secret.js +25 -0
- package/src/cluster-status.js +562 -0
- package/src/compression-stats.js +32 -0
- package/src/config.js +360 -0
- package/src/crdt.js +1076 -0
- package/src/derived-state-scheduler.js +128 -0
- package/src/files.js +324 -0
- package/src/hnsw.js +269 -0
- package/src/http-cors.js +31 -0
- package/src/http-router.js +162 -0
- package/src/index.js +137 -0
- package/src/interfaces/auth.js +123 -0
- package/src/interfaces/cluster.js +296 -0
- package/src/interfaces/public-object.js +2452 -0
- package/src/interfaces/ui.js +38 -0
- package/src/lib/crypto.cjs +54 -0
- package/src/lib/jwt.cjs +231 -0
- package/src/local-wal.js +122 -0
- package/src/memory-guard.js +152 -0
- package/src/ndjson.js +117 -0
- package/src/node-runtime.js +29 -0
- package/src/object-hooks.js +282 -0
- package/src/object-id.js +7 -0
- package/src/object-storage.js +263 -0
- package/src/object-store.js +288 -0
- package/src/patch.js +87 -0
- package/src/permissions.js +178 -0
- package/src/persistence.js +1918 -0
- package/src/plugin-loader.js +21 -0
- package/src/query-planner.js +266 -0
- package/src/query.js +416 -0
- package/src/references.js +349 -0
- package/src/replication-scheduler.js +303 -0
- package/src/restore-status.js +77 -0
- package/src/runtime.js +47 -0
- package/src/server.js +242 -0
- package/src/shape.js +286 -0
- package/src/shard-map-store.js +85 -0
- package/src/shard-map.js +333 -0
- package/src/shard-router.js +737 -0
- package/src/store.js +3410 -0
- package/src/system-secrets.js +107 -0
- package/src/vector.js +83 -0
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
import { DEFAULT_SHARD } from "./shard-map.js";
|
|
2
|
+
import { aggregateObjects, aggregatePartialObjects, isObjectDbAggregateSpec, mergeAggregatePartials } from "./aggregate.js";
|
|
3
|
+
import { accessTokenFromRequest } from "./auth.js";
|
|
4
|
+
import { CLUSTER_SECRET_HEADER, clusterSecretFromRequest, clusterSecretMatches, normalizeClusterSecret } from "./cluster-secret.js";
|
|
5
|
+
import { NDJSON_CONTENT_TYPE, ndjsonReadable } from "./ndjson.js";
|
|
6
|
+
import { compareSortValues, getPathValue } from "./query.js";
|
|
7
|
+
import { combineQueryPaths, publicAggregatePath, publicReadPath, QUERY_PATH_HEADER } from "./query-planner.js";
|
|
8
|
+
|
|
9
|
+
export const FORWARDED_HEADER = "x-objydb-forwarded";
|
|
10
|
+
export const TARGET_SHARD_HEADER = "x-objydb-target-shard";
|
|
11
|
+
export const DEFAULT_FORWARD_TIMEOUT_MS = 5_000;
|
|
12
|
+
|
|
13
|
+
export class ShardRequestRouter {
|
|
14
|
+
constructor({ node, forwardTimeoutMs = DEFAULT_FORWARD_TIMEOUT_MS, clusterSecret = null }) {
|
|
15
|
+
if (!node) throw new Error("node is required");
|
|
16
|
+
this.node = node;
|
|
17
|
+
this.forwardTimeoutMs = positiveInt(forwardTimeoutMs, DEFAULT_FORWARD_TIMEOUT_MS);
|
|
18
|
+
this.clusterSecret = normalizeClusterSecret(clusterSecret);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
targetShard(req) {
|
|
22
|
+
const value = req.headers[TARGET_SHARD_HEADER];
|
|
23
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
isForwarded(req) {
|
|
27
|
+
if (req.headers[FORWARDED_HEADER] !== "1") return false;
|
|
28
|
+
if (!this.clusterSecret) return true;
|
|
29
|
+
return clusterSecretMatches(clusterSecretFromRequest(req), this.clusterSecret);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
shardForObject(database, collection, id) {
|
|
33
|
+
return this.node.shardForObject(database, collection, id);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
ownsShard(shard = DEFAULT_SHARD) {
|
|
37
|
+
return this.node.ownsShard(shard);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
shouldHandleObjectLocally(req, database, collection, id) {
|
|
41
|
+
const shard = this.shardForObject(database, collection, id);
|
|
42
|
+
this.assertTargetShardMatches(req, shard);
|
|
43
|
+
if (this.ownsShard(shard)) return true;
|
|
44
|
+
if (this.isForwarded(req)) {
|
|
45
|
+
throw httpError(409, `forward target does not own shard ${shard}`);
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
groupByShard(req, database, collection, items, idOf) {
|
|
51
|
+
const groups = new Map();
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
for (const item of items || []) {
|
|
54
|
+
const id = String(idOf(item));
|
|
55
|
+
if (seen.has(id)) throw httpError(400, `duplicate _id in bulk request: ${id}`);
|
|
56
|
+
seen.add(id);
|
|
57
|
+
const shard = this.shardForObject(database, collection, id);
|
|
58
|
+
this.assertTargetShardMatches(req, shard);
|
|
59
|
+
let group = groups.get(shard);
|
|
60
|
+
if (!group) {
|
|
61
|
+
group = {
|
|
62
|
+
shard,
|
|
63
|
+
local: this.ownsShard(shard),
|
|
64
|
+
items: [],
|
|
65
|
+
};
|
|
66
|
+
if (!group.local && this.isForwarded(req)) {
|
|
67
|
+
throw httpError(409, `forward target does not own shard ${shard}`);
|
|
68
|
+
}
|
|
69
|
+
groups.set(shard, group);
|
|
70
|
+
}
|
|
71
|
+
group.items.push(item);
|
|
72
|
+
}
|
|
73
|
+
return [...groups.values()];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async find(req, database, collection, criteria = {}, options = {}) {
|
|
77
|
+
const targetShard = this.targetShard(req);
|
|
78
|
+
if (targetShard) {
|
|
79
|
+
this.assertOwnsTargetShard(targetShard);
|
|
80
|
+
return this.findLocalShard(database, collection, criteria, options, targetShard);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const targets = this.queryTargets(database, collection);
|
|
84
|
+
if (targets.length === 1 && targets[0].local && targets[0].shard === null) {
|
|
85
|
+
return this.findLocalShard(database, collection, criteria, options, null);
|
|
86
|
+
}
|
|
87
|
+
if (targets.every((target) => target.local)) {
|
|
88
|
+
return this.findLocalShards(database, collection, criteria, options, targets.map((target) => target.shard).filter(Boolean));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const all = [];
|
|
92
|
+
const paths = [];
|
|
93
|
+
let count = options.includeCount ? 0 : undefined;
|
|
94
|
+
const shardOptions = shardFetchOptions(options);
|
|
95
|
+
const localShards = targets.filter((target) => target.local).map((target) => target.shard).filter(Boolean);
|
|
96
|
+
const remoteResults = await remoteAfterLocalWork(this.forwardRemoteTargets(targets, (target) => (
|
|
97
|
+
this.forwardToShard(req, target.shard, objectPath(database, collection), {
|
|
98
|
+
method: "GET",
|
|
99
|
+
query: queryForFind(criteria, shardOptions),
|
|
100
|
+
retryStatuses: [409, 503, 504],
|
|
101
|
+
})
|
|
102
|
+
)), () => {
|
|
103
|
+
if (localShards.length) {
|
|
104
|
+
const local = this.findLocalShards(database, collection, criteria, shardOptions, localShards);
|
|
105
|
+
if (options.includeCount) count += local.count;
|
|
106
|
+
all.push(...local.objects);
|
|
107
|
+
if (local.path) paths.push(local.path);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
for (const { remote } of remoteResults) {
|
|
111
|
+
if (!ok(remote.status)) throw httpError(remote.status, remote.body?.error || "remote query failed", remote.body);
|
|
112
|
+
const objects = responseObjects(remote.body);
|
|
113
|
+
if (options.includeCount) count += responseCount(remote.body, objects.length);
|
|
114
|
+
all.push(...objects);
|
|
115
|
+
const remotePath = responseQueryPath(remote);
|
|
116
|
+
if (remotePath) paths.push(remotePath);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
count,
|
|
121
|
+
objects: paginate(sortObjects(all, options), options),
|
|
122
|
+
path: combineQueryPaths(paths),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async aggregate(req, database, collection, criteria = {}, spec = {}) {
|
|
127
|
+
const targetShard = this.targetShard(req);
|
|
128
|
+
if (targetShard) {
|
|
129
|
+
this.assertOwnsTargetShard(targetShard);
|
|
130
|
+
if (isObjectDbAggregateSpec(spec)) {
|
|
131
|
+
return {
|
|
132
|
+
rows: this.node.aggregateShards(database, collection, criteria, spec, [targetShard]),
|
|
133
|
+
path: publicAggregatePath(this.node.aggregatePathShards(database, collection, criteria, spec, [targetShard])),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const rows = this.findLocalShard(database, collection, criteria, {}, targetShard).objects;
|
|
137
|
+
return {
|
|
138
|
+
rows: isPartialAggregateSpec(spec) ? aggregatePartialObjects(rows, spec) : aggregateObjects(rows, spec),
|
|
139
|
+
path: "aggregate-scan",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const targets = this.queryTargets(database, collection);
|
|
144
|
+
if (targets.length === 1 && targets[0].local && targets[0].shard === null) {
|
|
145
|
+
return {
|
|
146
|
+
rows: this.node.aggregate(database, collection, criteria, spec),
|
|
147
|
+
path: publicAggregatePath(this.node.aggregatePath(database, collection, criteria, spec)),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (targets.every((target) => target.local)) {
|
|
151
|
+
return {
|
|
152
|
+
rows: this.node.aggregate(database, collection, criteria, spec),
|
|
153
|
+
path: publicAggregatePath(this.node.aggregatePath(database, collection, criteria, spec)),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (isObjectDbAggregateSpec(spec)) {
|
|
158
|
+
const partials = [];
|
|
159
|
+
const paths = [];
|
|
160
|
+
const localShards = targets.filter((target) => target.local).map((target) => target.shard).filter(Boolean);
|
|
161
|
+
const partialSpec = partialAggregateSpec(spec);
|
|
162
|
+
const remoteResults = await remoteAfterLocalWork(this.forwardRemoteTargets(targets, (target) => (
|
|
163
|
+
this.forwardToShard(req, target.shard, objectPath(database, collection), {
|
|
164
|
+
method: "GET",
|
|
165
|
+
query: queryForAggregate(criteria, partialSpec),
|
|
166
|
+
retryStatuses: [409, 503, 504],
|
|
167
|
+
})
|
|
168
|
+
)), () => {
|
|
169
|
+
if (localShards.length) {
|
|
170
|
+
partials.push(...this.node.aggregateShards(database, collection, criteria, partialSpec, localShards));
|
|
171
|
+
paths.push(publicAggregatePath(this.node.aggregatePathShards(database, collection, criteria, partialSpec, localShards)));
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
for (const { remote } of remoteResults) {
|
|
175
|
+
if (!ok(remote.status)) throw httpError(remote.status, remote.body?.error || "remote aggregate failed", remote.body);
|
|
176
|
+
partials.push(...responseObjects(remote.body));
|
|
177
|
+
const remotePath = responseQueryPath(remote);
|
|
178
|
+
if (remotePath) paths.push(remotePath);
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
rows: mergeAggregatePartials(partials, spec),
|
|
182
|
+
path: combineQueryPaths(paths),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const rows = [];
|
|
187
|
+
const paths = [];
|
|
188
|
+
const localShards = targets.filter((target) => target.local).map((target) => target.shard).filter(Boolean);
|
|
189
|
+
const remoteResults = await remoteAfterLocalWork(this.forwardRemoteTargets(targets, (target) => (
|
|
190
|
+
this.forwardToShard(req, target.shard, objectPath(database, collection), {
|
|
191
|
+
method: "GET",
|
|
192
|
+
query: queryForCriteria(criteria),
|
|
193
|
+
retryStatuses: [409, 503, 504],
|
|
194
|
+
})
|
|
195
|
+
)), () => {
|
|
196
|
+
if (localShards.length) {
|
|
197
|
+
const local = this.findLocalShards(database, collection, criteria, {}, localShards);
|
|
198
|
+
rows.push(...local.objects);
|
|
199
|
+
if (local.path) paths.push(`aggregate-${local.path}`);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
for (const { remote } of remoteResults) {
|
|
203
|
+
if (!ok(remote.status)) throw httpError(remote.status, remote.body?.error || "remote aggregate scan failed", remote.body);
|
|
204
|
+
rows.push(...responseObjects(remote.body));
|
|
205
|
+
const remotePath = responseQueryPath(remote);
|
|
206
|
+
if (remotePath) paths.push(`aggregate-${remotePath}`);
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
rows: aggregateObjects(rows, spec),
|
|
210
|
+
path: combineQueryPaths(paths) || "aggregate-scan",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async vectorSearch(req, database, collection, criteria = {}, vector = {}, options = {}) {
|
|
215
|
+
const targetShard = this.targetShard(req);
|
|
216
|
+
if (targetShard) {
|
|
217
|
+
this.assertOwnsTargetShard(targetShard);
|
|
218
|
+
return finalizeVectorRows(
|
|
219
|
+
this.vectorSearchLocalShard(database, collection, criteria, vector, targetShard, options),
|
|
220
|
+
vector,
|
|
221
|
+
options,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const targets = this.queryTargets(database, collection);
|
|
226
|
+
if (targets.length === 1 && targets[0].local && targets[0].shard === null) {
|
|
227
|
+
return finalizeVectorRows(
|
|
228
|
+
this.vectorSearchLocalShard(database, collection, criteria, vector, null, options),
|
|
229
|
+
vector,
|
|
230
|
+
options,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (targets.every((target) => target.local)) {
|
|
234
|
+
const shards = targets.map((target) => target.shard).filter(Boolean);
|
|
235
|
+
return finalizeVectorRows(
|
|
236
|
+
this.vectorSearchLocalShards(database, collection, criteria, vector, shards, options),
|
|
237
|
+
vector,
|
|
238
|
+
options,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const all = [];
|
|
243
|
+
const localShards = targets.filter((target) => target.local).map((target) => target.shard).filter(Boolean);
|
|
244
|
+
const remoteResults = await remoteAfterLocalWork(this.forwardRemoteTargets(targets, (target) => (
|
|
245
|
+
this.forwardToShard(req, target.shard, searchPath(database, collection), {
|
|
246
|
+
method: "POST",
|
|
247
|
+
body: queryForVectorSearch(criteria, vector, options),
|
|
248
|
+
retryStatuses: [409, 503, 504],
|
|
249
|
+
})
|
|
250
|
+
)), () => {
|
|
251
|
+
if (localShards.length) {
|
|
252
|
+
all.push(...this.vectorSearchLocalShards(database, collection, criteria, vector, localShards, options));
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
for (const { remote } of remoteResults) {
|
|
256
|
+
if (!ok(remote.status)) throw httpError(remote.status, remote.body?.error || "remote vector search failed", remote.body);
|
|
257
|
+
all.push(...responseObjects(remote.body));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return finalizeVectorRows(all, vector, options);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
findLocalShard(database, collection, criteria = {}, options = {}, shard) {
|
|
264
|
+
if (!shard) {
|
|
265
|
+
return {
|
|
266
|
+
count: countIfNeeded(options, () => this.node.count(database, collection, criteria)),
|
|
267
|
+
objects: this.node.find(database, collection, criteria, options),
|
|
268
|
+
path: publicReadPath(this.node.queryPath(database, collection, criteria, options)),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
return this.findLocalShards(database, collection, criteria, options, [shard]);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
findLocalShards(database, collection, criteria = {}, options = {}, shards = []) {
|
|
275
|
+
const shardSet = new Set((shards || []).filter(Boolean));
|
|
276
|
+
if (!shardSet.size) {
|
|
277
|
+
return {
|
|
278
|
+
count: countIfNeeded(options, () => this.node.count(database, collection, criteria)),
|
|
279
|
+
objects: this.node.find(database, collection, criteria, options),
|
|
280
|
+
path: publicReadPath(this.node.queryPath(database, collection, criteria, options)),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
count: countIfNeeded(options, () => this.node.countShards(database, collection, criteria, [...shardSet])),
|
|
285
|
+
objects: this.node.findShards(database, collection, criteria, options, [...shardSet]),
|
|
286
|
+
path: publicReadPath(this.node.queryPathShards(database, collection, criteria, options, [...shardSet])),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
vectorSearchLocalShard(database, collection, criteria = {}, vector = {}, shard = null, options = {}) {
|
|
291
|
+
if (!shard) return this.vectorSearchLocalShards(database, collection, criteria, vector, [], options);
|
|
292
|
+
return this.vectorSearchLocalShards(database, collection, criteria, vector, [shard], options);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
vectorSearchLocalShards(database, collection, criteria = {}, vector = {}, shards = [], options = {}) {
|
|
296
|
+
const queryVec = vector.queryVec || vector.vector;
|
|
297
|
+
const limit = vector.limit || 20;
|
|
298
|
+
const shardSet = new Set((shards || []).filter(Boolean));
|
|
299
|
+
const includeId = shardSet.size
|
|
300
|
+
? (id) => shardSet.has(this.shardForObject(database, collection, id))
|
|
301
|
+
: null;
|
|
302
|
+
if (criteria && Object.keys(criteria).length > 0) {
|
|
303
|
+
const rows = this.node.find(database, collection, criteria, includeId ? { includeId } : {});
|
|
304
|
+
return this.node.rankVectorRows(database, collection, rows, queryVec, limit);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const hits = includeId
|
|
308
|
+
? this.node.vectorSearchShards(database, collection, queryVec, limit, [...shardSet], { ef: vector.ef })
|
|
309
|
+
: this.node.vectorSearch(database, collection, queryVec, limit, { ef: vector.ef });
|
|
310
|
+
return hits
|
|
311
|
+
.map((hit) => {
|
|
312
|
+
const row = this.node.get(database, collection, hit._id, { refDepth: 0 });
|
|
313
|
+
return row ? { ...row, _score: hit._score } : null;
|
|
314
|
+
})
|
|
315
|
+
.filter(Boolean);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
queryTargets(database, collection) {
|
|
319
|
+
const shards = this.node.shardMap.shardsForCollection(database, collection);
|
|
320
|
+
if (!shards.length) return [{ shard: null, local: true }];
|
|
321
|
+
return shards.map((shard) => ({
|
|
322
|
+
shard,
|
|
323
|
+
local: this.ownsShard(shard),
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async forwardToObject(req, database, collection, id, path, options = {}) {
|
|
328
|
+
const shard = this.shardForObject(database, collection, id);
|
|
329
|
+
return this.forwardToShard(req, shard, path, options);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async forwardToObjectRaw(req, database, collection, id, path, options = {}) {
|
|
333
|
+
const shard = this.shardForObject(database, collection, id);
|
|
334
|
+
return this.forwardToShardRaw(req, shard, path, options);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async forwardRemoteTargets(targets, forward) {
|
|
338
|
+
return Promise.all(targets
|
|
339
|
+
.filter((target) => !target.local)
|
|
340
|
+
.map(async (target) => ({ target, remote: await forward(target) })));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async forwardToShard(req, shard, path, {
|
|
344
|
+
method = "GET",
|
|
345
|
+
body,
|
|
346
|
+
query,
|
|
347
|
+
retryStatuses = [409, 502, 503, 504],
|
|
348
|
+
timeoutMs,
|
|
349
|
+
} = {}) {
|
|
350
|
+
const peers = this.node.routingPeersForShard(shard);
|
|
351
|
+
if (!peers.length) throw httpError(503, `no remote peer available for shard ${shard}`);
|
|
352
|
+
|
|
353
|
+
let last = null;
|
|
354
|
+
for (const peer of peers) {
|
|
355
|
+
try {
|
|
356
|
+
const response = await this.forwardJson(req, peer, shard, path, { method, body, query, timeoutMs });
|
|
357
|
+
last = response;
|
|
358
|
+
if (!retryStatuses.includes(response.status)) return response;
|
|
359
|
+
} catch (err) {
|
|
360
|
+
last = {
|
|
361
|
+
status: err.status || 503,
|
|
362
|
+
body: { error: err.message },
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return last;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async forwardToShardNdjson(req, shard, path, {
|
|
370
|
+
method = "POST",
|
|
371
|
+
items = [],
|
|
372
|
+
query,
|
|
373
|
+
retryStatuses = [409, 502, 503, 504],
|
|
374
|
+
timeoutMs,
|
|
375
|
+
} = {}) {
|
|
376
|
+
const peers = this.node.routingPeersForShard(shard);
|
|
377
|
+
if (!peers.length) throw httpError(503, `no remote peer available for shard ${shard}`);
|
|
378
|
+
|
|
379
|
+
let last = null;
|
|
380
|
+
for (const peer of peers) {
|
|
381
|
+
try {
|
|
382
|
+
const response = await this.forwardNdjson(req, peer, shard, path, { method, items, query, timeoutMs });
|
|
383
|
+
last = response;
|
|
384
|
+
if (!retryStatuses.includes(response.status)) return response;
|
|
385
|
+
} catch (err) {
|
|
386
|
+
last = {
|
|
387
|
+
status: err.status || 503,
|
|
388
|
+
body: { error: err.message },
|
|
389
|
+
headers: {},
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return last;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async forwardToShardRaw(req, shard, path, {
|
|
397
|
+
method = "GET",
|
|
398
|
+
body,
|
|
399
|
+
contentType,
|
|
400
|
+
query,
|
|
401
|
+
retryStatuses = [409, 502, 503, 504],
|
|
402
|
+
timeoutMs,
|
|
403
|
+
} = {}) {
|
|
404
|
+
const peers = this.node.routingPeersForShard(shard);
|
|
405
|
+
if (!peers.length) throw httpError(503, `no remote peer available for shard ${shard}`);
|
|
406
|
+
|
|
407
|
+
let last = null;
|
|
408
|
+
for (const peer of peers) {
|
|
409
|
+
try {
|
|
410
|
+
const response = await this.forwardRaw(req, peer, shard, path, { method, body, contentType, query, timeoutMs });
|
|
411
|
+
last = response;
|
|
412
|
+
if (!retryStatuses.includes(response.status)) return response;
|
|
413
|
+
} catch (err) {
|
|
414
|
+
last = {
|
|
415
|
+
status: err.status || 503,
|
|
416
|
+
body: { error: err.message },
|
|
417
|
+
headers: {},
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return last;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async forwardToAllPeers(req, path, {
|
|
425
|
+
method = "GET",
|
|
426
|
+
body,
|
|
427
|
+
retryStatuses = [502, 503, 504],
|
|
428
|
+
allowUnavailable = false,
|
|
429
|
+
timeoutMs,
|
|
430
|
+
} = {}) {
|
|
431
|
+
const peers = this.node.shardMap.allPeers().filter((peer) => peer.url && !this.node._isSelfPeer(peer));
|
|
432
|
+
const unique = new Map(peers.map((peer) => [peer.url, peer]));
|
|
433
|
+
const responses = await Promise.all([...unique.values()].map((peer) => (
|
|
434
|
+
this.forwardJson(req, peer, null, path, {
|
|
435
|
+
method,
|
|
436
|
+
body,
|
|
437
|
+
forwardedOnly: true,
|
|
438
|
+
timeoutMs,
|
|
439
|
+
}).catch((err) => ({
|
|
440
|
+
status: err.status || 503,
|
|
441
|
+
body: err.body || { error: err.message },
|
|
442
|
+
peer,
|
|
443
|
+
unavailable: true,
|
|
444
|
+
}))
|
|
445
|
+
)));
|
|
446
|
+
const failed = responses.find((response) => (
|
|
447
|
+
(retryStatuses.includes(response.status) || !ok(response.status)) &&
|
|
448
|
+
!(allowUnavailable && response.unavailable)
|
|
449
|
+
));
|
|
450
|
+
if (failed) return failed;
|
|
451
|
+
const unavailable = responses.filter((response) => response.unavailable).length;
|
|
452
|
+
return {
|
|
453
|
+
status: 200,
|
|
454
|
+
body: {
|
|
455
|
+
ok: true,
|
|
456
|
+
peers: responses.length - unavailable,
|
|
457
|
+
...(allowUnavailable && unavailable ? { unavailablePeers: unavailable } : {}),
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async forwardJson(req, peer, shard, path, { method = "GET", body, query, forwardedOnly = false, timeoutMs } = {}) {
|
|
463
|
+
const suffix = query ? `?${query}` : "";
|
|
464
|
+
const headers = forwardedOnly
|
|
465
|
+
? forwardedControlHeaders(req, body !== undefined, this.clusterSecret)
|
|
466
|
+
: forwardedHeaders(req, shard, body !== undefined, this.clusterSecret);
|
|
467
|
+
const forwardTimeoutMs = positiveInt(timeoutMs, this.forwardTimeoutMs);
|
|
468
|
+
const controller = new AbortController();
|
|
469
|
+
const timer = setTimeout(() => controller.abort(), forwardTimeoutMs);
|
|
470
|
+
timer.unref?.();
|
|
471
|
+
try {
|
|
472
|
+
const response = await fetch(`${peer.url}${path}${suffix}`, {
|
|
473
|
+
method,
|
|
474
|
+
headers,
|
|
475
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
476
|
+
signal: controller.signal,
|
|
477
|
+
});
|
|
478
|
+
const text = await response.text();
|
|
479
|
+
return {
|
|
480
|
+
status: response.status,
|
|
481
|
+
body: text ? JSON.parse(text) : null,
|
|
482
|
+
headers: Object.fromEntries(response.headers),
|
|
483
|
+
};
|
|
484
|
+
} catch (err) {
|
|
485
|
+
if (err?.name === "AbortError") {
|
|
486
|
+
throw httpError(504, `forward timeout after ${forwardTimeoutMs}ms`);
|
|
487
|
+
}
|
|
488
|
+
throw err;
|
|
489
|
+
} finally {
|
|
490
|
+
clearTimeout(timer);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async forwardNdjson(req, peer, shard, path, { method = "POST", items = [], query, timeoutMs } = {}) {
|
|
495
|
+
const suffix = query ? `?${query}` : "";
|
|
496
|
+
const headers = forwardedHeaders(req, shard, true, this.clusterSecret);
|
|
497
|
+
headers["content-type"] = NDJSON_CONTENT_TYPE;
|
|
498
|
+
headers.prefer = preferMinimal(headers.prefer);
|
|
499
|
+
const forwardTimeoutMs = positiveInt(timeoutMs, this.forwardTimeoutMs);
|
|
500
|
+
const controller = new AbortController();
|
|
501
|
+
const timer = setTimeout(() => controller.abort(), forwardTimeoutMs);
|
|
502
|
+
timer.unref?.();
|
|
503
|
+
try {
|
|
504
|
+
const response = await fetch(`${peer.url}${path}${suffix}`, {
|
|
505
|
+
method,
|
|
506
|
+
headers,
|
|
507
|
+
body: ndjsonReadable(items),
|
|
508
|
+
duplex: "half",
|
|
509
|
+
signal: controller.signal,
|
|
510
|
+
});
|
|
511
|
+
const text = await response.text();
|
|
512
|
+
return {
|
|
513
|
+
status: response.status,
|
|
514
|
+
body: text ? JSON.parse(text) : null,
|
|
515
|
+
headers: Object.fromEntries(response.headers),
|
|
516
|
+
};
|
|
517
|
+
} catch (err) {
|
|
518
|
+
if (err?.name === "AbortError") {
|
|
519
|
+
throw httpError(504, `forward timeout after ${forwardTimeoutMs}ms`);
|
|
520
|
+
}
|
|
521
|
+
throw err;
|
|
522
|
+
} finally {
|
|
523
|
+
clearTimeout(timer);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async forwardRaw(req, peer, shard, path, { method = "GET", body, contentType, query, timeoutMs } = {}) {
|
|
528
|
+
const suffix = query ? `?${query}` : "";
|
|
529
|
+
const headers = forwardedHeaders(req, shard, body !== undefined, this.clusterSecret);
|
|
530
|
+
if (contentType) headers["content-type"] = contentType;
|
|
531
|
+
const forwardTimeoutMs = positiveInt(timeoutMs, this.forwardTimeoutMs);
|
|
532
|
+
const controller = new AbortController();
|
|
533
|
+
const timer = setTimeout(() => controller.abort(), forwardTimeoutMs);
|
|
534
|
+
timer.unref?.();
|
|
535
|
+
try {
|
|
536
|
+
const response = await fetch(`${peer.url}${path}${suffix}`, {
|
|
537
|
+
method,
|
|
538
|
+
headers,
|
|
539
|
+
body,
|
|
540
|
+
signal: controller.signal,
|
|
541
|
+
});
|
|
542
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
543
|
+
const contentResponseType = response.headers.get("content-type") || "";
|
|
544
|
+
const isJson = contentResponseType.toLowerCase().includes("application/json");
|
|
545
|
+
return {
|
|
546
|
+
status: response.status,
|
|
547
|
+
body: isJson && buffer.length ? JSON.parse(buffer.toString("utf8")) : buffer,
|
|
548
|
+
headers: Object.fromEntries(response.headers),
|
|
549
|
+
};
|
|
550
|
+
} catch (err) {
|
|
551
|
+
if (err?.name === "AbortError") {
|
|
552
|
+
throw httpError(504, `forward timeout after ${forwardTimeoutMs}ms`);
|
|
553
|
+
}
|
|
554
|
+
throw err;
|
|
555
|
+
} finally {
|
|
556
|
+
clearTimeout(timer);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
assertTargetShardMatches(req, shard) {
|
|
561
|
+
const targetShard = this.targetShard(req);
|
|
562
|
+
if (targetShard && targetShard !== shard) {
|
|
563
|
+
throw httpError(409, `object belongs to shard ${shard}, not forwarded shard ${targetShard}`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
assertOwnsTargetShard(shard) {
|
|
568
|
+
if (!this.ownsShard(shard)) throw httpError(409, `forward target does not own shard ${shard}`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export function httpError(status, message, body) {
|
|
573
|
+
const err = new Error(message);
|
|
574
|
+
err.status = status;
|
|
575
|
+
err.body = body;
|
|
576
|
+
return err;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function ok(status) {
|
|
580
|
+
return status >= 200 && status < 300;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export function objectPath(database, collection, id = null) {
|
|
584
|
+
const base = `/api/${encodeURIComponent(database)}/${encodeURIComponent(collection)}/object`;
|
|
585
|
+
return id === null ? base : `${base}/${encodeURIComponent(id)}`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function searchPath(database, collection) {
|
|
589
|
+
return `/api/${encodeURIComponent(database)}/${encodeURIComponent(collection)}/search`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function queryForCriteria(criteria) {
|
|
593
|
+
const query = new URLSearchParams();
|
|
594
|
+
if (criteria && Object.keys(criteria).length > 0) query.set("q", JSON.stringify(criteria));
|
|
595
|
+
return query.toString();
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function queryForFind(criteria, options = {}) {
|
|
599
|
+
const query = new URLSearchParams(queryForCriteria(criteria));
|
|
600
|
+
if (options.sortBy) query.set("sortBy", options.sortBy);
|
|
601
|
+
if (options.sort) query.set("sort", options.sort);
|
|
602
|
+
if (options.limit !== undefined && Number.isFinite(options.limit)) query.set("limit", String(options.limit));
|
|
603
|
+
if (options.offset !== undefined && Number.isFinite(options.offset)) query.set("offset", String(options.offset));
|
|
604
|
+
if (options.refDepth !== undefined) query.set("$refDepth", String(options.refDepth));
|
|
605
|
+
return query.toString();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function queryForAggregate(criteria, spec = {}) {
|
|
609
|
+
const query = new URLSearchParams();
|
|
610
|
+
query.set("q", JSON.stringify({
|
|
611
|
+
...(criteria || {}),
|
|
612
|
+
$aggregate: spec,
|
|
613
|
+
$refDepth: 0,
|
|
614
|
+
}));
|
|
615
|
+
return query.toString();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function queryForVectorSearch(criteria, vector = {}, options = {}) {
|
|
619
|
+
return {
|
|
620
|
+
...(criteria || {}),
|
|
621
|
+
$vector: {
|
|
622
|
+
vector: vector.queryVec || vector.vector,
|
|
623
|
+
limit: vector.limit,
|
|
624
|
+
...(vector.ef ? { ef: vector.ef } : {}),
|
|
625
|
+
},
|
|
626
|
+
$refDepth: 0,
|
|
627
|
+
...(options.sortBy ? { $sort: { [options.sortBy]: options.sort === "desc" ? "desc" : "asc" } } : {}),
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function remoteAfterLocalWork(remoteResultsPromise, localWork) {
|
|
632
|
+
try {
|
|
633
|
+
await localWork();
|
|
634
|
+
return await remoteResultsPromise;
|
|
635
|
+
} catch (err) {
|
|
636
|
+
await remoteResultsPromise.catch(() => {});
|
|
637
|
+
throw err;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function partialAggregateSpec(spec = {}) {
|
|
642
|
+
return { ...(spec || {}), $partial: true };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function isPartialAggregateSpec(spec = {}) {
|
|
646
|
+
return spec?.$partial === true;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function shardFetchOptions(options = {}) {
|
|
650
|
+
const limit = Number.isFinite(options.limit) ? options.limit : undefined;
|
|
651
|
+
const offset = Number.isFinite(options.offset) ? options.offset : 0;
|
|
652
|
+
return {
|
|
653
|
+
...(options.sortBy ? { sortBy: options.sortBy } : {}),
|
|
654
|
+
...(options.sort ? { sort: options.sort } : {}),
|
|
655
|
+
...(limit !== undefined ? { limit: offset + limit } : {}),
|
|
656
|
+
offset: 0,
|
|
657
|
+
...(options.includeCount ? { includeCount: true } : {}),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function countIfNeeded(options, count) {
|
|
662
|
+
return options.includeCount ? count() : undefined;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function forwardedHeaders(req, shard, hasBody, clusterSecret) {
|
|
666
|
+
const headers = {
|
|
667
|
+
[FORWARDED_HEADER]: "1",
|
|
668
|
+
[TARGET_SHARD_HEADER]: shard,
|
|
669
|
+
...(clusterSecret ? { [CLUSTER_SECRET_HEADER]: clusterSecret } : {}),
|
|
670
|
+
};
|
|
671
|
+
const token = accessTokenFromRequest(req);
|
|
672
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
673
|
+
if (req.headers.prefer) headers.prefer = req.headers.prefer;
|
|
674
|
+
if (hasBody) headers["content-type"] = "application/json";
|
|
675
|
+
return headers;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function forwardedControlHeaders(req, hasBody, clusterSecret) {
|
|
679
|
+
const headers = {
|
|
680
|
+
[FORWARDED_HEADER]: "1",
|
|
681
|
+
...(clusterSecret ? { [CLUSTER_SECRET_HEADER]: clusterSecret } : {}),
|
|
682
|
+
};
|
|
683
|
+
const token = accessTokenFromRequest(req);
|
|
684
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
685
|
+
if (req.headers.prefer) headers.prefer = req.headers.prefer;
|
|
686
|
+
if (hasBody) headers["content-type"] = "application/json";
|
|
687
|
+
return headers;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function preferMinimal(value) {
|
|
691
|
+
const current = String(value || "");
|
|
692
|
+
if (/\breturn=minimal\b/i.test(current)) return current;
|
|
693
|
+
return current ? `${current}, return=minimal` : "return=minimal";
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function sortObjects(objects, options = {}) {
|
|
697
|
+
if (!options.sortBy) return [...objects];
|
|
698
|
+
const direction = options.sort === "desc" ? -1 : 1;
|
|
699
|
+
return [...objects].sort((left, right) => {
|
|
700
|
+
return compareSortValues(getPathValue(left, options.sortBy), getPathValue(right, options.sortBy)) * direction;
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function paginate(objects, options = {}) {
|
|
705
|
+
const offset = options.offset || 0;
|
|
706
|
+
const limit = options.limit ?? Infinity;
|
|
707
|
+
if (limit <= 0) return [];
|
|
708
|
+
return objects.slice(offset, offset + limit);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function finalizeVectorRows(rows, vector = {}, options = {}) {
|
|
712
|
+
const limit = Number.isFinite(vector.limit) ? vector.limit : 20;
|
|
713
|
+
const ranked = [...(rows || [])]
|
|
714
|
+
.filter((row) => typeof row?._score === "number")
|
|
715
|
+
.sort((left, right) => right._score - left._score)
|
|
716
|
+
.slice(0, limit);
|
|
717
|
+
return options.sortBy ? sortObjects(ranked, options) : ranked;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function responseObjects(body) {
|
|
721
|
+
if (Array.isArray(body)) return body;
|
|
722
|
+
if (Array.isArray(body?.objects)) return body.objects;
|
|
723
|
+
return [];
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function responseQueryPath(response = {}) {
|
|
727
|
+
return response.headers?.[QUERY_PATH_HEADER] || response.headers?.[QUERY_PATH_HEADER.toLowerCase()] || null;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function responseCount(body, fallback) {
|
|
731
|
+
return Number.isFinite(body?.count) ? body.count : fallback;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function positiveInt(value, fallback) {
|
|
735
|
+
const parsed = Number(value);
|
|
736
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
737
|
+
}
|