@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,349 @@
|
|
|
1
|
+
import { compareSortValues, getPathValue } from "./query.js";
|
|
2
|
+
|
|
3
|
+
const MAX_REF_DEPTH = 8;
|
|
4
|
+
|
|
5
|
+
export function isReference(value) {
|
|
6
|
+
return Boolean(
|
|
7
|
+
isPlainObject(value) &&
|
|
8
|
+
value.type === "reference" &&
|
|
9
|
+
typeof value.collection === "string" &&
|
|
10
|
+
value.collection.length > 0 &&
|
|
11
|
+
value.value !== undefined &&
|
|
12
|
+
value.value !== null &&
|
|
13
|
+
String(value.value).length > 0,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function referenceTarget(ref, fallbackDatabase) {
|
|
18
|
+
if (!isReference(ref)) return null;
|
|
19
|
+
return {
|
|
20
|
+
database: ref.database !== undefined && ref.database !== null ? String(ref.database) : fallbackDatabase,
|
|
21
|
+
collection: String(ref.collection),
|
|
22
|
+
id: String(ref.value),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeRefDepth(value, fallback = 1) {
|
|
27
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
28
|
+
const n = Number(value);
|
|
29
|
+
if (!Number.isFinite(n) || n < 0) return fallback;
|
|
30
|
+
return Math.min(MAX_REF_DEPTH, Math.floor(n));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function stripReferenceObjects(value) {
|
|
34
|
+
return stripReferenceObjectsInner(value).value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function prepareReferenceValue(value) {
|
|
38
|
+
const prepared = stripReferenceObjectsInner(value);
|
|
39
|
+
return {
|
|
40
|
+
value: prepared.value,
|
|
41
|
+
hasReference: prepared.hasReference,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function expandReferencesSync(value, {
|
|
46
|
+
database,
|
|
47
|
+
depth = 1,
|
|
48
|
+
getObject,
|
|
49
|
+
seen = new Set(),
|
|
50
|
+
} = {}) {
|
|
51
|
+
const refDepth = normalizeRefDepth(depth, 1);
|
|
52
|
+
const prepared = prepareReferenceValue(value);
|
|
53
|
+
if (refDepth <= 0 || !prepared.hasReference) return prepared.value;
|
|
54
|
+
return expandReferencesSyncInner(prepared.value, {
|
|
55
|
+
database,
|
|
56
|
+
depth: refDepth,
|
|
57
|
+
getObject,
|
|
58
|
+
seen,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function expandReferences(value, {
|
|
63
|
+
database,
|
|
64
|
+
depth = 1,
|
|
65
|
+
getObject,
|
|
66
|
+
seen = new Set(),
|
|
67
|
+
} = {}) {
|
|
68
|
+
const refDepth = normalizeRefDepth(depth, 1);
|
|
69
|
+
const prepared = prepareReferenceValue(value);
|
|
70
|
+
if (refDepth <= 0 || !prepared.hasReference) return prepared.value;
|
|
71
|
+
return expandReferencesInner(prepared.value, {
|
|
72
|
+
database,
|
|
73
|
+
depth: refDepth,
|
|
74
|
+
getObject,
|
|
75
|
+
seen,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function referenceDepthForQuery(query = {}) {
|
|
80
|
+
return referenceDepthForCriteria(query);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function referenceDepthForAggregateSpec(spec = {}) {
|
|
84
|
+
if (!isPlainObject(spec)) return 0;
|
|
85
|
+
let depth = 0;
|
|
86
|
+
for (const path of asArray(spec.groupBy)) depth = Math.max(depth, referenceDepthForPath(path));
|
|
87
|
+
for (const metric of ["sum", "avg", "min", "max"]) {
|
|
88
|
+
for (const path of asArray(spec[metric])) depth = Math.max(depth, referenceDepthForPath(path));
|
|
89
|
+
}
|
|
90
|
+
if (isPlainObject(spec.fields)) {
|
|
91
|
+
for (const definition of Object.values(spec.fields)) {
|
|
92
|
+
if (!isPlainObject(definition)) continue;
|
|
93
|
+
for (const [operator, operand] of Object.entries(definition)) {
|
|
94
|
+
if (operator === "$count" && operand === true) continue;
|
|
95
|
+
depth = Math.max(depth, referenceDepthForPath(operand));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return depth;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function pathHasReferenceObject(path) {
|
|
103
|
+
return referenceDepthForPath(path) > 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function referenceReadPlan(criteria = {}, options = {}) {
|
|
107
|
+
const queryDepth = referenceDepthForQuery(criteria);
|
|
108
|
+
const sortDepth = pathHasReferenceObject(options.sortBy) ? 1 : 0;
|
|
109
|
+
const matchDepth = Math.max(queryDepth, sortDepth);
|
|
110
|
+
const responseDepth = normalizeRefDepth(options.refDepth, matchDepth > 0 ? 1 : 0);
|
|
111
|
+
return {
|
|
112
|
+
queryDepth,
|
|
113
|
+
sortDepth,
|
|
114
|
+
matchDepth,
|
|
115
|
+
responseDepth,
|
|
116
|
+
expansionDepth: Math.max(matchDepth, normalizeRefDepth(options.refDepth, 0)),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function sortReferencePairs(pairs, options = {}) {
|
|
121
|
+
if (!options.sortBy) return pairs;
|
|
122
|
+
const direction = options.sort === "desc" ? -1 : 1;
|
|
123
|
+
return [...pairs].sort((left, right) => {
|
|
124
|
+
return compareSortValues(
|
|
125
|
+
getPathValue(left.matchObject, options.sortBy),
|
|
126
|
+
getPathValue(right.matchObject, options.sortBy),
|
|
127
|
+
) * direction;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function paginateReferencePairs(pairs, options = {}) {
|
|
132
|
+
const offset = options.offset || 0;
|
|
133
|
+
const limit = options.limit ?? Infinity;
|
|
134
|
+
if (limit <= 0) return [];
|
|
135
|
+
return pairs.slice(offset, offset + limit);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function baseCriteriaForReferenceQuery(query = {}) {
|
|
139
|
+
if (!isPlainObject(query)) return {};
|
|
140
|
+
const out = {};
|
|
141
|
+
|
|
142
|
+
for (const [path, expected] of Object.entries(query)) {
|
|
143
|
+
if (path === "$and" && Array.isArray(expected)) {
|
|
144
|
+
const children = expected.map(baseCriteriaForReferenceQuery).filter((child) => Object.keys(child).length > 0);
|
|
145
|
+
if (children.length) out.$and = children;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (path.startsWith("$")) continue;
|
|
149
|
+
if (pathHasReferenceObject(path) || valueHasReferencePath(expected)) continue;
|
|
150
|
+
out[path] = expected;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function stripReferenceObjectsInner(value) {
|
|
157
|
+
if (Array.isArray(value)) {
|
|
158
|
+
let changed = false;
|
|
159
|
+
let hasReference = false;
|
|
160
|
+
const out = value.map((item) => {
|
|
161
|
+
const stripped = stripReferenceObjectsInner(item);
|
|
162
|
+
changed = changed || stripped.changed;
|
|
163
|
+
hasReference = hasReference || stripped.hasReference;
|
|
164
|
+
return stripped.value;
|
|
165
|
+
});
|
|
166
|
+
return changed
|
|
167
|
+
? { value: out, changed: true, hasReference }
|
|
168
|
+
: { value, changed: false, hasReference };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!isPlainObject(value)) return { value, changed: false, hasReference: false };
|
|
172
|
+
|
|
173
|
+
let changed = false;
|
|
174
|
+
let hasReference = false;
|
|
175
|
+
const out = {};
|
|
176
|
+
const reference = isReference(value);
|
|
177
|
+
if (reference) hasReference = true;
|
|
178
|
+
for (const [key, item] of Object.entries(value)) {
|
|
179
|
+
if (reference && key === "object") {
|
|
180
|
+
changed = true;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const stripped = stripReferenceObjectsInner(item);
|
|
184
|
+
changed = changed || stripped.changed;
|
|
185
|
+
hasReference = hasReference || stripped.hasReference;
|
|
186
|
+
out[key] = stripped.value;
|
|
187
|
+
}
|
|
188
|
+
return changed
|
|
189
|
+
? { value: out, changed: true, hasReference }
|
|
190
|
+
: { value, changed: false, hasReference };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function expandReferencesSyncInner(value, context) {
|
|
194
|
+
if (Array.isArray(value)) return value.map((item) => expandReferencesSyncInner(item, context));
|
|
195
|
+
|
|
196
|
+
if (isReference(value)) return expandReferenceSync(value, context);
|
|
197
|
+
|
|
198
|
+
if (isPlainObject(value)) {
|
|
199
|
+
return Object.fromEntries(
|
|
200
|
+
Object.entries(value).map(([key, item]) => [key, expandReferencesSyncInner(item, context)]),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function expandReferenceSync(ref, context) {
|
|
208
|
+
const clean = cloneReferenceDescriptor(ref);
|
|
209
|
+
if (context.depth <= 0 || typeof context.getObject !== "function") return clean;
|
|
210
|
+
|
|
211
|
+
const target = referenceTarget(ref, context.database);
|
|
212
|
+
if (!target?.database) {
|
|
213
|
+
clean.object = null;
|
|
214
|
+
return clean;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const key = referenceKey(target);
|
|
218
|
+
if (context.seen.has(key)) {
|
|
219
|
+
clean.object = null;
|
|
220
|
+
return clean;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const nextSeen = new Set(context.seen);
|
|
224
|
+
nextSeen.add(key);
|
|
225
|
+
const object = context.getObject(target.database, target.collection, target.id);
|
|
226
|
+
clean.object = object
|
|
227
|
+
? expandReferencesSyncInner(object, {
|
|
228
|
+
...context,
|
|
229
|
+
database: target.database,
|
|
230
|
+
depth: context.depth - 1,
|
|
231
|
+
seen: nextSeen,
|
|
232
|
+
})
|
|
233
|
+
: null;
|
|
234
|
+
return clean;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function expandReferencesInner(value, context) {
|
|
238
|
+
if (Array.isArray(value)) {
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const item of value) out.push(await expandReferencesInner(item, context));
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (isReference(value)) return expandReference(value, context);
|
|
245
|
+
|
|
246
|
+
if (isPlainObject(value)) {
|
|
247
|
+
const out = {};
|
|
248
|
+
for (const [key, item] of Object.entries(value)) out[key] = await expandReferencesInner(item, context);
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return value;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function expandReference(ref, context) {
|
|
256
|
+
const clean = cloneReferenceDescriptor(ref);
|
|
257
|
+
if (context.depth <= 0 || typeof context.getObject !== "function") return clean;
|
|
258
|
+
|
|
259
|
+
const target = referenceTarget(ref, context.database);
|
|
260
|
+
if (!target?.database) {
|
|
261
|
+
clean.object = null;
|
|
262
|
+
return clean;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const key = referenceKey(target);
|
|
266
|
+
if (context.seen.has(key)) {
|
|
267
|
+
clean.object = null;
|
|
268
|
+
return clean;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const nextSeen = new Set(context.seen);
|
|
272
|
+
nextSeen.add(key);
|
|
273
|
+
const object = await context.getObject(target.database, target.collection, target.id);
|
|
274
|
+
clean.object = object
|
|
275
|
+
? await expandReferencesInner(object, {
|
|
276
|
+
...context,
|
|
277
|
+
database: target.database,
|
|
278
|
+
depth: context.depth - 1,
|
|
279
|
+
seen: nextSeen,
|
|
280
|
+
})
|
|
281
|
+
: null;
|
|
282
|
+
return clean;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function cloneReferenceDescriptor(ref) {
|
|
286
|
+
const out = {};
|
|
287
|
+
for (const [key, value] of Object.entries(ref || {})) {
|
|
288
|
+
if (key === "object") continue;
|
|
289
|
+
out[key] = stripReferenceObjects(value);
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function referenceDepthForCriteria(criteria) {
|
|
295
|
+
if (!isPlainObject(criteria)) return 0;
|
|
296
|
+
let depth = 0;
|
|
297
|
+
|
|
298
|
+
for (const [path, expected] of Object.entries(criteria)) {
|
|
299
|
+
if ((path === "$and" || path === "$or" || path === "$nor") && Array.isArray(expected)) {
|
|
300
|
+
for (const child of expected) depth = Math.max(depth, referenceDepthForCriteria(child));
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (path === "$not") {
|
|
304
|
+
depth = Math.max(depth, referenceDepthForCriteria(expected));
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (path === "$count" && isPlainObject(expected) && Array.isArray(expected.$constraints)) {
|
|
308
|
+
for (const child of expected.$constraints) depth = Math.max(depth, referenceDepthForCriteria(child));
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (!path.startsWith("$")) depth = Math.max(depth, referenceDepthForPath(path));
|
|
312
|
+
depth = Math.max(depth, referenceDepthForOperatorValue(expected));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return depth;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function referenceDepthForOperatorValue(value) {
|
|
319
|
+
if (Array.isArray(value)) return Math.max(0, ...value.map(referenceDepthForOperatorValue));
|
|
320
|
+
if (!isPlainObject(value)) return 0;
|
|
321
|
+
let depth = 0;
|
|
322
|
+
for (const [key, item] of Object.entries(value)) {
|
|
323
|
+
if (key === "$elemMatch") depth = Math.max(depth, referenceDepthForCriteria(item));
|
|
324
|
+
else depth = Math.max(depth, referenceDepthForOperatorValue(item));
|
|
325
|
+
}
|
|
326
|
+
return depth;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function valueHasReferencePath(value) {
|
|
330
|
+
return referenceDepthForOperatorValue(value) > 0;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function referenceDepthForPath(path) {
|
|
334
|
+
if (typeof path !== "string" || path.length === 0) return 0;
|
|
335
|
+
return path.split(".").filter((part) => part === "object").length;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function referenceKey({ database, collection, id }) {
|
|
339
|
+
return `${database}\0${collection}\0${id}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function asArray(value) {
|
|
343
|
+
if (value === undefined || value === null) return [];
|
|
344
|
+
return Array.isArray(value) ? value : [value];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function isPlainObject(value) {
|
|
348
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
349
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { GLOBAL_COLLECTION, GLOBAL_DATABASE, peerKey } from "./shard-map.js";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_DEBOUNCE_MS = 25;
|
|
4
|
+
const DEFAULT_MAX_SCOPES_PER_HINT = 128;
|
|
5
|
+
|
|
6
|
+
export function createReplicationScheduler(options = {}) {
|
|
7
|
+
return new ReplicationScheduler(options);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class ReplicationScheduler {
|
|
11
|
+
constructor({
|
|
12
|
+
node,
|
|
13
|
+
enabled = true,
|
|
14
|
+
hintsEnabled = true,
|
|
15
|
+
antiEntropyMs = 0,
|
|
16
|
+
debounceMs = DEFAULT_DEBOUNCE_MS,
|
|
17
|
+
maxScopesPerHint = DEFAULT_MAX_SCOPES_PER_HINT,
|
|
18
|
+
} = {}) {
|
|
19
|
+
if (!node) throw new Error("node is required");
|
|
20
|
+
this.node = node;
|
|
21
|
+
this.enabled = enabled !== false;
|
|
22
|
+
this.hintsEnabled = hintsEnabled !== false;
|
|
23
|
+
this.antiEntropyMs = positiveInt(antiEntropyMs, 0);
|
|
24
|
+
this.debounceMs = positiveInt(debounceMs, DEFAULT_DEBOUNCE_MS);
|
|
25
|
+
this.maxScopesPerHint = positiveInt(maxScopesPerHint, DEFAULT_MAX_SCOPES_PER_HINT);
|
|
26
|
+
this.pendingHintScopes = new Map();
|
|
27
|
+
this.pullQueue = new Map();
|
|
28
|
+
this.inflightPulls = new Set();
|
|
29
|
+
this.hintTimer = null;
|
|
30
|
+
this.antiEntropyTimer = null;
|
|
31
|
+
this.syncingAll = false;
|
|
32
|
+
this.stats = {
|
|
33
|
+
hintsSent: 0,
|
|
34
|
+
hintsReceived: 0,
|
|
35
|
+
hintScopesSent: 0,
|
|
36
|
+
hintScopesReceived: 0,
|
|
37
|
+
pullSyncs: 0,
|
|
38
|
+
antiEntropySyncs: 0,
|
|
39
|
+
lastHintSentAt: null,
|
|
40
|
+
lastHintReceivedAt: null,
|
|
41
|
+
lastSyncAt: null,
|
|
42
|
+
lastPartialSyncAt: null,
|
|
43
|
+
lastError: null,
|
|
44
|
+
lastSyncFailures: [],
|
|
45
|
+
};
|
|
46
|
+
this.unsubscribe = this.node.onChange((change) => this._recordLocalChange(change));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
start() {
|
|
50
|
+
if (!this.enabled || this.antiEntropyTimer || !this.antiEntropyMs) return;
|
|
51
|
+
this.antiEntropyTimer = setInterval(() => {
|
|
52
|
+
this.syncAll("anti-entropy");
|
|
53
|
+
}, this.antiEntropyMs);
|
|
54
|
+
this.antiEntropyTimer.unref?.();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async stop() {
|
|
58
|
+
if (this.hintTimer) clearTimeout(this.hintTimer);
|
|
59
|
+
if (this.antiEntropyTimer) clearInterval(this.antiEntropyTimer);
|
|
60
|
+
this.hintTimer = null;
|
|
61
|
+
this.antiEntropyTimer = null;
|
|
62
|
+
this.unsubscribe?.();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
afterLocalWrite() {
|
|
66
|
+
if (!this.enabled || !this.hintsEnabled || !this.pendingHintScopes.size) return;
|
|
67
|
+
if (this.hintTimer) return;
|
|
68
|
+
this.hintTimer = setTimeout(() => {
|
|
69
|
+
this.hintTimer = null;
|
|
70
|
+
this.flushHints();
|
|
71
|
+
}, this.debounceMs);
|
|
72
|
+
this.hintTimer.unref?.();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async flushHints() {
|
|
76
|
+
if (!this.enabled || !this.hintsEnabled || !this.pendingHintScopes.size) return { peers: 0, scopes: 0 };
|
|
77
|
+
const scopes = [...this.pendingHintScopes.values()];
|
|
78
|
+
this.pendingHintScopes.clear();
|
|
79
|
+
const byPeer = this._hintTargets(scopes);
|
|
80
|
+
const sends = [];
|
|
81
|
+
let sentPeers = 0;
|
|
82
|
+
let sentScopes = 0;
|
|
83
|
+
|
|
84
|
+
for (const [key, { peer, scopes: peerScopes }] of byPeer) {
|
|
85
|
+
for (const chunk of chunks(peerScopes, this.maxScopesPerHint)) {
|
|
86
|
+
sentPeers++;
|
|
87
|
+
sentScopes += chunk.length;
|
|
88
|
+
sends.push(this._sendHint(peer, chunk, key));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
await Promise.all(sends);
|
|
92
|
+
return { peers: sentPeers, scopes: sentScopes };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
receiveHint(hint = {}) {
|
|
96
|
+
if (!this.enabled || !this.hintsEnabled) return { accepted: 0, matchedPeer: false };
|
|
97
|
+
const scopes = normalizeHintScopes(hint.scopes);
|
|
98
|
+
let accepted = 0;
|
|
99
|
+
let matchedPeer = false;
|
|
100
|
+
|
|
101
|
+
for (const scope of scopes) {
|
|
102
|
+
const peer = this._peerForHint(scope.shard, hint);
|
|
103
|
+
if (!peer) continue;
|
|
104
|
+
matchedPeer = true;
|
|
105
|
+
if (!this.node.ownsShard(scope.shard)) continue;
|
|
106
|
+
this.enqueuePull(peer, this.node.syncScopeForShard(scope.shard));
|
|
107
|
+
accepted++;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.stats.hintsReceived++;
|
|
111
|
+
this.stats.hintScopesReceived += accepted;
|
|
112
|
+
this.stats.lastHintReceivedAt = new Date().toISOString();
|
|
113
|
+
return { accepted, matchedPeer };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
enqueuePull(peer, scope) {
|
|
117
|
+
const key = pullKey(peer, scope);
|
|
118
|
+
this.pullQueue.set(key, { peer, scope });
|
|
119
|
+
this._drainPullQueue();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async syncAll(reason = "manual") {
|
|
123
|
+
if (!this.enabled || this.syncingAll) return 0;
|
|
124
|
+
this.syncingAll = true;
|
|
125
|
+
try {
|
|
126
|
+
const applied = await this.node.syncAll();
|
|
127
|
+
if (reason === "anti-entropy") this.stats.antiEntropySyncs++;
|
|
128
|
+
const failures = Array.isArray(this.node.lastSyncFailures) ? this.node.lastSyncFailures : [];
|
|
129
|
+
this.stats.lastSyncFailures = failures;
|
|
130
|
+
if (failures.length) {
|
|
131
|
+
this.stats.lastPartialSyncAt = new Date().toISOString();
|
|
132
|
+
this._rememberError(new Error(`${failures.length} peer sync(s) failed`), "sync");
|
|
133
|
+
} else {
|
|
134
|
+
this.stats.lastSyncAt = new Date().toISOString();
|
|
135
|
+
this._rememberSuccess();
|
|
136
|
+
}
|
|
137
|
+
return applied;
|
|
138
|
+
} catch (err) {
|
|
139
|
+
this._rememberError(err);
|
|
140
|
+
return 0;
|
|
141
|
+
} finally {
|
|
142
|
+
this.syncingAll = false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
status() {
|
|
147
|
+
return {
|
|
148
|
+
mode: "hinted-pull",
|
|
149
|
+
enabled: this.enabled,
|
|
150
|
+
hintsEnabled: this.hintsEnabled,
|
|
151
|
+
antiEntropyMs: this.antiEntropyMs,
|
|
152
|
+
debounceMs: this.debounceMs,
|
|
153
|
+
maxScopesPerHint: this.maxScopesPerHint,
|
|
154
|
+
pendingHintScopes: this.pendingHintScopes.size,
|
|
155
|
+
queuedPulls: this.pullQueue.size,
|
|
156
|
+
inflightPulls: this.inflightPulls.size,
|
|
157
|
+
syncingAll: this.syncingAll,
|
|
158
|
+
...this.stats,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_recordLocalChange(change) {
|
|
163
|
+
if (!this.enabled || !this.hintsEnabled || change?.kind !== "ops") return;
|
|
164
|
+
for (const op of change.ops || []) {
|
|
165
|
+
if (op?.from !== this.node.id || !op.shard) continue;
|
|
166
|
+
const scope = explicitShardScope(op.shard, op.seq);
|
|
167
|
+
const key = scopeKey(scope);
|
|
168
|
+
const pending = this.pendingHintScopes.get(key);
|
|
169
|
+
if (!pending || scope.lastSeq > pending.lastSeq) this.pendingHintScopes.set(key, scope);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
_hintTargets(scopes) {
|
|
174
|
+
const byPeer = new Map();
|
|
175
|
+
for (const scope of scopes) {
|
|
176
|
+
for (const peer of this.node.peersForShard(scope.shard)) {
|
|
177
|
+
if (!peer.url || isSelfPeer(this.node, peer)) continue;
|
|
178
|
+
const key = peerKey(peer);
|
|
179
|
+
const entry = byPeer.get(key) || { peer, scopes: [] };
|
|
180
|
+
entry.scopes.push(scope);
|
|
181
|
+
byPeer.set(key, entry);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return byPeer;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async _sendHint(peer, scopes, key = peerKey(peer)) {
|
|
188
|
+
if (!peer.hint) return;
|
|
189
|
+
try {
|
|
190
|
+
await peer.hint({
|
|
191
|
+
node: this.node.id,
|
|
192
|
+
url: this.node.url || undefined,
|
|
193
|
+
scopes,
|
|
194
|
+
});
|
|
195
|
+
this.stats.hintsSent++;
|
|
196
|
+
this.stats.hintScopesSent += scopes.length;
|
|
197
|
+
this.stats.lastHintSentAt = new Date().toISOString();
|
|
198
|
+
this._rememberSuccess(`hint ${key}`);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
this._rememberError(err, `hint ${key}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
_peerForHint(shard, hint) {
|
|
205
|
+
return this.node.peersForShard(shard).find((peer) => (
|
|
206
|
+
!isSelfPeer(this.node, peer) &&
|
|
207
|
+
(
|
|
208
|
+
(hint.url && normalizeUrl(peer.url) === normalizeUrl(hint.url)) ||
|
|
209
|
+
(hint.node && (peer.nodeId === hint.node || peer.key === hint.node))
|
|
210
|
+
)
|
|
211
|
+
));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
_drainPullQueue() {
|
|
215
|
+
for (const [key, item] of this.pullQueue) {
|
|
216
|
+
if (this.inflightPulls.has(key)) continue;
|
|
217
|
+
this.pullQueue.delete(key);
|
|
218
|
+
this._runPull(key, item.peer, item.scope);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async _runPull(key, peer, scope) {
|
|
223
|
+
this.inflightPulls.add(key);
|
|
224
|
+
try {
|
|
225
|
+
await this.node.syncPeer(peer, scope);
|
|
226
|
+
this.stats.pullSyncs++;
|
|
227
|
+
this.stats.lastSyncAt = new Date().toISOString();
|
|
228
|
+
this._rememberSuccess(`pull ${key}`);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
this._rememberError(err, `pull ${key}`);
|
|
231
|
+
} finally {
|
|
232
|
+
this.inflightPulls.delete(key);
|
|
233
|
+
if (this.pullQueue.has(key)) setImmediate(() => this._drainPullQueue());
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
_rememberError(err, prefix = "") {
|
|
238
|
+
const message = err?.message || String(err);
|
|
239
|
+
this.stats.lastError = prefix ? `${prefix}: ${message}` : message;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
_rememberSuccess(prefix = "") {
|
|
243
|
+
if (!this.stats.lastError) return;
|
|
244
|
+
if (!prefix || this.stats.lastError.startsWith(`${prefix}:`)) this.stats.lastError = null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function explicitShardScope(shard, lastSeq = 0) {
|
|
249
|
+
return {
|
|
250
|
+
database: GLOBAL_DATABASE,
|
|
251
|
+
collection: GLOBAL_COLLECTION,
|
|
252
|
+
shard,
|
|
253
|
+
lastSeq: Number(lastSeq) || 0,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function normalizeHintScopes(scopes) {
|
|
258
|
+
if (!Array.isArray(scopes) || !scopes.length) {
|
|
259
|
+
const err = new Error("body.scopes must be a non-empty array");
|
|
260
|
+
err.status = 400;
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
return scopes.map((scope, index) => {
|
|
264
|
+
if (!scope || typeof scope !== "object" || typeof scope.shard !== "string" || !scope.shard) {
|
|
265
|
+
const err = new Error(`scope at index ${index} must include shard`);
|
|
266
|
+
err.status = 400;
|
|
267
|
+
throw err;
|
|
268
|
+
}
|
|
269
|
+
return explicitShardScope(scope.shard, scope.lastSeq);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function chunks(values, size) {
|
|
274
|
+
const n = Math.max(1, size);
|
|
275
|
+
const out = [];
|
|
276
|
+
for (let i = 0; i < values.length; i += n) out.push(values.slice(i, i + n));
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function pullKey(peer, scope) {
|
|
281
|
+
return `${peerKey(peer)}|${scopeKey(scope)}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function scopeKey(scope) {
|
|
285
|
+
return scope ? `${scope.database}/${scope.collection}/${scope.shard}` : "*";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function normalizeUrl(url) {
|
|
289
|
+
return url ? String(url).replace(/\/+$/, "") : null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isSelfPeer(node, peer) {
|
|
293
|
+
return (
|
|
294
|
+
(peer.url && normalizeUrl(peer.url) === node.url) ||
|
|
295
|
+
peer.nodeId === node.id ||
|
|
296
|
+
peer.key === node.id
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function positiveInt(value, fallback) {
|
|
301
|
+
const n = Number(value);
|
|
302
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
|
|
303
|
+
}
|