@substrio/sdk 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/LICENSE +177 -0
- package/README.md +39 -2
- package/package.json +52 -5
- package/substrio.d.ts +1087 -0
- package/substrio.mjs +3928 -0
- package/substrio_errors.mjs +265 -0
- package/substrio_kv_tcp.mjs +559 -0
- package/substrio_operation_client.d.mts +28 -0
- package/substrio_operation_client.mjs +196 -0
- package/substrio_operations.d.mts +10911 -0
- package/substrio_operations.mjs +1617 -0
package/substrio.mjs
ADDED
|
@@ -0,0 +1,3928 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio JavaScript SDK: v0.1.0
|
|
3
|
+
*
|
|
4
|
+
* Zero dependencies. Single file. AI-friendly API.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import Substrio from '@substrio/sdk'
|
|
8
|
+
* const db = new Substrio({ url: 'http://localhost:9000', key: 'master-key' })
|
|
9
|
+
* const docs = await db.col('users').find({ age: { $gt: 25 } })
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { QueryBuilder, bytesToBase64 } from './substrio-qx.mjs';
|
|
13
|
+
import { KvTcp as KvWireTcp } from './substrio_kv_tcp.mjs';
|
|
14
|
+
import { bindOperations, attachOperations } from './substrio_operation_client.mjs';
|
|
15
|
+
|
|
16
|
+
// Error codes: generated from src/substrio/api/substrio_error.h and ERROR_TABLE in
|
|
17
|
+
// src/substrio/api/substrio_error_strings.cpp (scripts/gen_errors.mjs); never edit by hand.
|
|
18
|
+
export { SUBSTRIO_ERRORS } from './substrio_errors.mjs';
|
|
19
|
+
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
// SubstrioError: structured error from server responses
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const _RETRYABLE = new Set(['RETRY', 'TRANSACTION_CONFLICT', 'RATE_LIMIT_EXCEEDED', 'CONCURRENT_LIMIT', 'TIMEOUT', 'QUERY_TIMEOUT']);
|
|
25
|
+
const _AUTH = new Set(['TOKEN_EXPIRED', 'TOKEN_INVALID', 'TOKEN_REVOKED', 'SESSION_EXPIRED', 'CREDENTIAL_REQUIRED', 'OAUTH_INVALID', 'INSUFFICIENT_PERMISSIONS']);
|
|
26
|
+
const _QUOTA = new Set(['STORAGE_QUOTA_EXCEEDED', 'DOCUMENT_QUOTA_EXCEEDED', 'BANDWIDTH_QUOTA_EXCEEDED', 'RATE_LIMIT_EXCEEDED', 'REQUEST_TOO_LARGE', 'CONCURRENT_LIMIT', 'COLLECTION_LIMIT', 'INDEX_LIMIT', 'FUNCTION_LIMIT']);
|
|
27
|
+
const _NOT_FOUND = new Set(['KEY_NOT_FOUND', 'FILE_NOT_FOUND', 'TENANT_NOT_FOUND', 'COLLECTION_NOT_FOUND', 'FUNCTION_NOT_FOUND', 'THREAD_NOT_FOUND', 'MESSAGE_NOT_FOUND', 'ENDPOINT_NOT_FOUND']);
|
|
28
|
+
|
|
29
|
+
export class SubstrioError extends Error {
|
|
30
|
+
constructor(status, code, message, body) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'SubstrioError';
|
|
33
|
+
this.status = status;
|
|
34
|
+
this.code = code;
|
|
35
|
+
this.body = body;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The error for a failed response: `parsed` is the body (the substrio error
|
|
40
|
+
* envelope when it parses as one). The message is the error followed by
|
|
41
|
+
* its detail, the specific text the server gave ("Invalid argument:
|
|
42
|
+
* Invalid mode. Use: ...").
|
|
43
|
+
*/
|
|
44
|
+
static fromResponse(status, parsed) {
|
|
45
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
46
|
+
return new SubstrioError(status, '', parsed || `HTTP ${status}`, parsed);
|
|
47
|
+
}
|
|
48
|
+
const head = parsed.error || parsed.message || '';
|
|
49
|
+
const msg = head && parsed.detail && parsed.detail !== head
|
|
50
|
+
? `${head}: ${parsed.detail}`
|
|
51
|
+
: (head || parsed.detail || JSON.stringify(parsed));
|
|
52
|
+
return new SubstrioError(status, parsed.code || '', msg, parsed);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True if this error is transient and the operation should be retried */
|
|
56
|
+
get isRetryable() { return _RETRYABLE.has(this.code); }
|
|
57
|
+
|
|
58
|
+
/** True if this is an authentication or authorization error */
|
|
59
|
+
get isAuth() { return _AUTH.has(this.code); }
|
|
60
|
+
|
|
61
|
+
/** True if this is a quota or rate-limiting error */
|
|
62
|
+
get isQuota() { return _QUOTA.has(this.code); }
|
|
63
|
+
|
|
64
|
+
/** True if the requested resource was not found */
|
|
65
|
+
get isNotFound() { return _NOT_FOUND.has(this.code); }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
// Internal helpers
|
|
70
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
const JSON_TYPE = 'application/json';
|
|
73
|
+
|
|
74
|
+
function qs(params) {
|
|
75
|
+
if (!params) return '';
|
|
76
|
+
const parts = [];
|
|
77
|
+
for (const [k, v] of Object.entries(params)) {
|
|
78
|
+
if (v !== undefined && v !== null) parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
|
|
79
|
+
}
|
|
80
|
+
return parts.length ? '?' + parts.join('&') : '';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
// Detect runtime: Node.js gets low-level HTTP, browsers get fetch()
|
|
85
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const _isNode = typeof globalThis.process !== 'undefined'
|
|
88
|
+
&& globalThis.process.versions?.node != null;
|
|
89
|
+
|
|
90
|
+
// Lazy-loaded Node.js modules (only imported once, on first use)
|
|
91
|
+
let _http = null, _https = null;
|
|
92
|
+
if (_isNode) {
|
|
93
|
+
// Top-level await not needed: createRequire works synchronously in ESM
|
|
94
|
+
const { createRequire } = await import('node:module');
|
|
95
|
+
const _require = createRequire(import.meta.url);
|
|
96
|
+
_https = _require('node:https');
|
|
97
|
+
_http = _require('node:http');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
101
|
+
// HTTP engine: shared by all classes
|
|
102
|
+
//
|
|
103
|
+
// Node.js: raw https.request() with persistent keep-alive agent.
|
|
104
|
+
// Zero abstraction overhead: no fetch(), no Response object, no Headers.
|
|
105
|
+
// Measured: 0.12ms p50 per request vs ~0.5ms with fetch() under burst.
|
|
106
|
+
//
|
|
107
|
+
// Browser: falls back to fetch() (browsers already optimize connections).
|
|
108
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
class Http {
|
|
111
|
+
constructor(baseUrl, headers, url, tenant, key) {
|
|
112
|
+
// Strip trailing slash
|
|
113
|
+
this.base = baseUrl.replace(/\/+$/, '');
|
|
114
|
+
this.headers = headers;
|
|
115
|
+
// WS connection info for onSnapshot()
|
|
116
|
+
this._url = url || baseUrl;
|
|
117
|
+
this._tenant = tenant || 'default';
|
|
118
|
+
this._key = key || null;
|
|
119
|
+
// RYOW: session consistency, tracks highest write timestamp
|
|
120
|
+
this._sessionTs = '0';
|
|
121
|
+
// Lifecycle
|
|
122
|
+
this._closed = false;
|
|
123
|
+
|
|
124
|
+
// ── Parse base URL once ──────────────────────────────────────────
|
|
125
|
+
const parsed = new URL(this.base);
|
|
126
|
+
this._hostname = parsed.hostname;
|
|
127
|
+
this._port = parseInt(parsed.port || (parsed.protocol === 'https:' ? '443' : '80'), 10);
|
|
128
|
+
this._isHttps = parsed.protocol === 'https:';
|
|
129
|
+
this._basePath = parsed.pathname === '/' ? '' : parsed.pathname;
|
|
130
|
+
|
|
131
|
+
// ── Persistent keep-alive agent (Node.js only) ───────────────────
|
|
132
|
+
// One TLS session, reused across all requests. No handshake per request.
|
|
133
|
+
if (_isNode) {
|
|
134
|
+
const Mod = this._isHttps ? _https : _http;
|
|
135
|
+
this._agent = new Mod.Agent({
|
|
136
|
+
keepAlive: true, // Reuse TCP+TLS connections
|
|
137
|
+
keepAliveMsecs: 30_000, // 30s idle before close
|
|
138
|
+
maxSockets: 64, // Max concurrent connections
|
|
139
|
+
maxFreeSockets: 16, // Idle connections ready for reuse
|
|
140
|
+
scheduling: 'fifo', // Fair distribution
|
|
141
|
+
...(this._isHttps ? { rejectUnauthorized: false } : {}),
|
|
142
|
+
});
|
|
143
|
+
} else {
|
|
144
|
+
this._agent = null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Abort all in-flight requests and mark this instance as closed. */
|
|
149
|
+
close() {
|
|
150
|
+
if (!this._closed) {
|
|
151
|
+
this._closed = true;
|
|
152
|
+
if (this._agent) {
|
|
153
|
+
this._agent.destroy();
|
|
154
|
+
this._agent = null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Node.js low-level request ────────────────────────────────────────
|
|
160
|
+
// Absolute minimum: send bytes → receive bytes → resolve.
|
|
161
|
+
// No abstraction layers. No parsing. No header scanning.
|
|
162
|
+
|
|
163
|
+
/** @internal Node.js raw request: returns { status, headers, body } */
|
|
164
|
+
_nodeReq(method, path, body, extraHeaders, _retry) {
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
const headers = extraHeaders
|
|
167
|
+
? { ...this.headers, ...extraHeaders }
|
|
168
|
+
: { ...this.headers };
|
|
169
|
+
|
|
170
|
+
// RYOW: session consistency
|
|
171
|
+
if (this._sessionTs !== '0') {
|
|
172
|
+
headers['X-Substrio-After'] = this._sessionTs;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let payload;
|
|
176
|
+
if (body !== undefined) {
|
|
177
|
+
if (typeof body === 'string') {
|
|
178
|
+
payload = body;
|
|
179
|
+
} else if (body instanceof Uint8Array) {
|
|
180
|
+
// A view over the same memory: no copy.
|
|
181
|
+
payload = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
|
182
|
+
} else if (body instanceof ArrayBuffer) {
|
|
183
|
+
payload = Buffer.from(body);
|
|
184
|
+
} else {
|
|
185
|
+
payload = JSON.stringify(body);
|
|
186
|
+
}
|
|
187
|
+
headers['Content-Length'] = Buffer.byteLength(payload);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let settled = false;
|
|
191
|
+
const Mod = this._isHttps ? _https : _http;
|
|
192
|
+
const req = Mod.request({
|
|
193
|
+
hostname: this._hostname,
|
|
194
|
+
port: this._port,
|
|
195
|
+
path: this._basePath + path,
|
|
196
|
+
method,
|
|
197
|
+
headers,
|
|
198
|
+
agent: this._agent,
|
|
199
|
+
}, (res) => {
|
|
200
|
+
// Fast body accumulation
|
|
201
|
+
const chunks = [];
|
|
202
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
203
|
+
res.on('end', () => {
|
|
204
|
+
if (settled) return;
|
|
205
|
+
settled = true;
|
|
206
|
+
const raw = chunks.length === 1
|
|
207
|
+
? chunks[0].toString()
|
|
208
|
+
: Buffer.concat(chunks).toString();
|
|
209
|
+
|
|
210
|
+
// RYOW: direct header access (no iteration)
|
|
211
|
+
const ts = res.headers['x-substrio-ts'];
|
|
212
|
+
if (ts && ts > this._sessionTs) {
|
|
213
|
+
this._sessionTs = ts;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Store raw headers ref for lazy profiling access
|
|
217
|
+
this._rawHeaders = res.headers;
|
|
218
|
+
|
|
219
|
+
resolve({ status: res.statusCode, body: raw });
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
req.on('error', (err) => {
|
|
224
|
+
if (settled) return;
|
|
225
|
+
settled = true;
|
|
226
|
+
// Destroy the socket so the keep-alive agent doesn't reuse a dead connection
|
|
227
|
+
if (req.socket) req.socket.destroy();
|
|
228
|
+
// Auto-retry once on stale keep-alive socket (EPIPE/ECONNRESET/parse errors)
|
|
229
|
+
if (!_retry && (err.code === 'EPIPE' || err.code === 'ECONNRESET' || err.code === 'HPE_INVALID_CONSTANT')) {
|
|
230
|
+
resolve(this._nodeReq(method, path, body, extraHeaders, true));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (err.code === 'ECONNREFUSED') {
|
|
234
|
+
reject(new SubstrioError(0, 'ECONNREFUSED',
|
|
235
|
+
`Cannot connect to ${this.base}: is the server running?`));
|
|
236
|
+
} else {
|
|
237
|
+
reject(err);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
if (payload !== undefined) {
|
|
242
|
+
try { req.write(payload); } catch (_) { /* write-after-end */ }
|
|
243
|
+
}
|
|
244
|
+
req.end();
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Profiling headers: lazy getter, only computed when accessed (never on hot path) */
|
|
249
|
+
get _lastHeaders() {
|
|
250
|
+
if (!this._rawHeaders) return {};
|
|
251
|
+
const prof = {};
|
|
252
|
+
for (const key in this._rawHeaders) {
|
|
253
|
+
if (key.startsWith('x-substrio-')) {
|
|
254
|
+
prof[key] = this._rawHeaders[key];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return prof;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async req(method, path, body, extra) {
|
|
261
|
+
// ── Node.js fast path ────────────────────────────────────────────
|
|
262
|
+
// TOTAL HOT PATH: _nodeReq → JSON.parse → return. Nothing else.
|
|
263
|
+
if (_isNode) {
|
|
264
|
+
const r = await this._nodeReq(method, path, body, extra);
|
|
265
|
+
if (r.status >= 200 && r.status < 300) {
|
|
266
|
+
try { return JSON.parse(r.body); } catch { return r.body; }
|
|
267
|
+
}
|
|
268
|
+
// Error path (cold: doesn't need to be fast)
|
|
269
|
+
let parsed;
|
|
270
|
+
try { parsed = JSON.parse(r.body); } catch { parsed = r.body; }
|
|
271
|
+
throw SubstrioError.fromResponse(r.status, parsed);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Browser fallback (fetch) ─────────────────────────────────────
|
|
275
|
+
const url = this.base + path;
|
|
276
|
+
const headers = extra ? { ...this.headers, ...extra } : { ...this.headers };
|
|
277
|
+
if (this._sessionTs !== '0') headers['X-Substrio-After'] = this._sessionTs;
|
|
278
|
+
const opts = { method, headers };
|
|
279
|
+
if (body !== undefined) {
|
|
280
|
+
opts.body = (typeof body === 'string' || body instanceof Uint8Array || body instanceof ArrayBuffer)
|
|
281
|
+
? body : JSON.stringify(body);
|
|
282
|
+
}
|
|
283
|
+
const res = await fetch(url, opts);
|
|
284
|
+
const text = await res.text();
|
|
285
|
+
const writeTs = res.headers.get('x-substrio-ts');
|
|
286
|
+
if (writeTs && writeTs > this._sessionTs) this._sessionTs = writeTs;
|
|
287
|
+
|
|
288
|
+
if (res.ok || res.status === 202) {
|
|
289
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
290
|
+
}
|
|
291
|
+
let parsed;
|
|
292
|
+
try { parsed = JSON.parse(text); } catch { parsed = null; }
|
|
293
|
+
throw SubstrioError.fromResponse(res.status, parsed);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Raw request: returns the response object (for streaming)
|
|
297
|
+
async raw(method, path, body, extra) {
|
|
298
|
+
if (_isNode) {
|
|
299
|
+
const r = await this._nodeReq(method, path, body, extra);
|
|
300
|
+
if (r.status >= 200 && r.status < 300) return r;
|
|
301
|
+
let parsed;
|
|
302
|
+
try { parsed = JSON.parse(r.body); } catch { parsed = r.body; }
|
|
303
|
+
throw SubstrioError.fromResponse(r.status, parsed);
|
|
304
|
+
}
|
|
305
|
+
// Browser fallback
|
|
306
|
+
const url = this.base + path;
|
|
307
|
+
const headers = extra ? { ...this.headers, ...extra } : { ...this.headers };
|
|
308
|
+
const opts = { method, headers };
|
|
309
|
+
if (body !== undefined) {
|
|
310
|
+
opts.body = (typeof body === 'string' || body instanceof Uint8Array || body instanceof ArrayBuffer)
|
|
311
|
+
? body : JSON.stringify(body);
|
|
312
|
+
}
|
|
313
|
+
const res = await fetch(url, opts);
|
|
314
|
+
if (!res.ok) {
|
|
315
|
+
const text = await res.text();
|
|
316
|
+
let parsed;
|
|
317
|
+
try { parsed = JSON.parse(text); } catch { parsed = null; }
|
|
318
|
+
throw SubstrioError.fromResponse(res.status, parsed);
|
|
319
|
+
}
|
|
320
|
+
return res;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
// Query: fluent chainable query builder
|
|
328
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
export class Query {
|
|
331
|
+
constructor(http, col) {
|
|
332
|
+
this.http = http;
|
|
333
|
+
this.col = col;
|
|
334
|
+
this._where = null;
|
|
335
|
+
this._limit = null;
|
|
336
|
+
this._skip = null;
|
|
337
|
+
this._sort = null;
|
|
338
|
+
this._select = null;
|
|
339
|
+
this._count = false;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Set the where clause. */
|
|
343
|
+
where(filter) { this._where = filter; return this; }
|
|
344
|
+
|
|
345
|
+
/** Limit results. */
|
|
346
|
+
limit(n) { this._limit = n; return this; }
|
|
347
|
+
|
|
348
|
+
/** Skip results. */
|
|
349
|
+
skip(n) { this._skip = n; return this; }
|
|
350
|
+
|
|
351
|
+
/** Sort results. e.g. sort({ age: 1 }) or sort({ age: -1 }) */
|
|
352
|
+
sort(spec) { this._sort = spec; return this; }
|
|
353
|
+
|
|
354
|
+
/** Select fields. e.g. select(['name', 'age']) */
|
|
355
|
+
select(fields) { this._select = fields; return this; }
|
|
356
|
+
|
|
357
|
+
/** Count-only mode: returns just the count, no data. */
|
|
358
|
+
count(val = true) { this._count = val; return this; }
|
|
359
|
+
|
|
360
|
+
/** Execute the query via bytecode: compiles to QX and sends via /_qx. */
|
|
361
|
+
async run() {
|
|
362
|
+
const qb = new BoundQueryBuilder(this.http, this.col);
|
|
363
|
+
QueryBuilder.compileFilter(qb, this._where || {}, {
|
|
364
|
+
sort: this._sort,
|
|
365
|
+
limit: this._limit,
|
|
366
|
+
skip: this._skip,
|
|
367
|
+
select: this._select,
|
|
368
|
+
count: this._count
|
|
369
|
+
});
|
|
370
|
+
return qb.run();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
375
|
+
// BoundQueryBuilder: QueryBuilder bound to a collection + HTTP client
|
|
376
|
+
// Uses /_qx endpoint for zero-parse bytecode queries
|
|
377
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
export class BoundQueryBuilder extends QueryBuilder {
|
|
380
|
+
constructor(http, col, extraHeaders) {
|
|
381
|
+
super();
|
|
382
|
+
this._http = http;
|
|
383
|
+
this._col = col;
|
|
384
|
+
this._extraHeaders = extraHeaders || undefined;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Execute the bytecode query via /_qx endpoint. */
|
|
388
|
+
async run() {
|
|
389
|
+
const bytes = this.toUint8Array();
|
|
390
|
+
return this._http.req('POST', `/v1/${this._col}/_qx`, bytes,
|
|
391
|
+
{ ...this._extraHeaders, 'Content-Type': 'application/octet-stream' });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Re-export QueryBuilder for direct use (e.g. in CF SDK or manual construction)
|
|
396
|
+
export { QueryBuilder };
|
|
397
|
+
|
|
398
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
399
|
+
// Collection: the core data primitive
|
|
400
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
export class Collection {
|
|
403
|
+
constructor(http, name) {
|
|
404
|
+
this.http = http;
|
|
405
|
+
this.name = name;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Subscribe to ALL changes on this collection (Firestore-style).
|
|
410
|
+
* Returns an unsubscribe function.
|
|
411
|
+
* @param {function} callback - receives { type, collection, operation, doc }
|
|
412
|
+
* @returns {function} unsubscribe: call to stop listening and close connection
|
|
413
|
+
*/
|
|
414
|
+
onSnapshot(callback) {
|
|
415
|
+
const rt = new Realtime(this.http._url, this.http._tenant, this.http._key);
|
|
416
|
+
rt.ready.then(() => {
|
|
417
|
+
rt.subscribeCollection(this.name);
|
|
418
|
+
rt.on('change', callback);
|
|
419
|
+
});
|
|
420
|
+
return () => {
|
|
421
|
+
rt.unsubscribeCollection(this.name);
|
|
422
|
+
rt.close();
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* CDC / Resumable Change Stream.
|
|
428
|
+
* Returns a ChangeStream that tracks _seq for resume-on-disconnect.
|
|
429
|
+
* _seq (a change history position) comes only on an app that keeps its
|
|
430
|
+
* change history; without one a change has no _seq and nothing resumes.
|
|
431
|
+
* @param {function} callback - receives { type, collection, operation, _seq?, _ts, doc, ... }
|
|
432
|
+
* @param {object} [options] - { resumeAfter: string }: resume after this _seq
|
|
433
|
+
* @returns {ChangeStream}
|
|
434
|
+
*/
|
|
435
|
+
watch(callback, options = {}) {
|
|
436
|
+
return new ChangeStream(this.http._url, this.http._tenant, this.http._key, this.name, callback, options);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── CRUD ──────────────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
/** Insert one document or an array of documents. */
|
|
442
|
+
async add(doc) {
|
|
443
|
+
if (Array.isArray(doc)) {
|
|
444
|
+
const results = [];
|
|
445
|
+
for (const d of doc) {
|
|
446
|
+
results.push(await this.add(d));
|
|
447
|
+
}
|
|
448
|
+
return results;
|
|
449
|
+
}
|
|
450
|
+
// The server ingests JSON (substrio_parse_insert_body); req sends the
|
|
451
|
+
// document as JSON.
|
|
452
|
+
return this.http.req('POST', `/v1/${this.name}`, doc);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Get a document by ID. */
|
|
456
|
+
async get(id) {
|
|
457
|
+
return this.http.req('GET', `/v1/${this.name}/${encodeURIComponent(id)}`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Replace all fields on a document by ID (PATCH + $set). */
|
|
461
|
+
async set(id, doc) {
|
|
462
|
+
return this.http.req('PATCH', `/v1/${this.name}/${encodeURIComponent(id)}`, { $set: doc });
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Partial update a document by ID. Accepts $set, $unset, $inc, etc. */
|
|
466
|
+
async update(id, ops) {
|
|
467
|
+
return this.http.req('PATCH', `/v1/${this.name}/${encodeURIComponent(id)}`, ops);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Delete a document by ID. */
|
|
471
|
+
async delete(id) {
|
|
472
|
+
return this.http.req('DELETE', `/v1/${this.name}/${encodeURIComponent(id)}`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Drop the entire collection. */
|
|
476
|
+
async drop() {
|
|
477
|
+
return this.http.req('DELETE', `/v1/_meta/collections/${encodeURIComponent(this.name)}`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ── Query builder ────────────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
/** Start a fluent query chain. e.g. collection.query().where({age:{$gt:25}}).limit(5).run() */
|
|
483
|
+
query() { return new Query(this.http, this.name); }
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Start a bytecode query builder (zero-parse path).
|
|
487
|
+
* Returns a BoundQueryBuilder with .eq(), .gt(), .sort(), etc.
|
|
488
|
+
* Call .run() to execute via /_qx endpoint.
|
|
489
|
+
* @returns {BoundQueryBuilder}
|
|
490
|
+
*/
|
|
491
|
+
qx() { return new BoundQueryBuilder(this.http, this.name); }
|
|
492
|
+
|
|
493
|
+
// ── Shorthand query methods ──────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Find documents matching a filter.
|
|
497
|
+
* @param {object} filter - Where clause (e.g. { age: { $gt: 25 } })
|
|
498
|
+
* @param {object} [opts] - { limit, skip, select, sort, cursor }
|
|
499
|
+
* @returns {Promise<{ data: object[], count: number }>}
|
|
500
|
+
*/
|
|
501
|
+
async find(filter = {}, opts = {}) {
|
|
502
|
+
const qb = this.qx();
|
|
503
|
+
QueryBuilder.compileFilter(qb, filter, opts);
|
|
504
|
+
return qb.run();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Find a single document matching a filter.
|
|
509
|
+
* @param {object} filter - Where clause (e.g. { name: { $eq: 'Alice' } })
|
|
510
|
+
* @param {object} [opts] - { select, sort }
|
|
511
|
+
* @returns {Promise<object|null>} First matching document or null
|
|
512
|
+
*/
|
|
513
|
+
async findOne(filter = {}, opts = {}) {
|
|
514
|
+
const r = await this.find(filter, { ...opts, limit: 1 });
|
|
515
|
+
return (r && r.data && r.data.length > 0) ? r.data[0] : null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Get a single document by its OID.
|
|
520
|
+
* @param {string} id - Document OID
|
|
521
|
+
* @returns {Promise<object>}
|
|
522
|
+
*/
|
|
523
|
+
async findById(id) {
|
|
524
|
+
return this.get(id);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Check if any document matches the filter.
|
|
529
|
+
* @param {object} filter - Where clause
|
|
530
|
+
* @returns {Promise<boolean>}
|
|
531
|
+
*/
|
|
532
|
+
async exists(filter = {}) {
|
|
533
|
+
const r = await this.find(filter, { limit: 1, select: ['_id'] });
|
|
534
|
+
return r && r.data && r.data.length > 0;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Insert or update a document. If filter matches an existing doc, update it;
|
|
539
|
+
* otherwise insert the doc.
|
|
540
|
+
* @param {object} filter - Where clause to find existing doc
|
|
541
|
+
* @param {object} doc - Document to insert or fields to $set on update
|
|
542
|
+
* @returns {Promise<object>} The inserted or updated result
|
|
543
|
+
*/
|
|
544
|
+
async upsert(filter, doc) {
|
|
545
|
+
const existing = await this.findOne(filter);
|
|
546
|
+
if (existing) {
|
|
547
|
+
return this.set(existing._id, doc);
|
|
548
|
+
}
|
|
549
|
+
return this.add(doc);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Delete all documents matching a filter.
|
|
554
|
+
* @param {object} filter - Where clause
|
|
555
|
+
* @returns {Promise<object>} Deletion result with count
|
|
556
|
+
*/
|
|
557
|
+
async deleteWhere(filter) {
|
|
558
|
+
return this.rawQuery({ where: filter, $delete: true });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Update all documents matching a filter with the given updates.
|
|
563
|
+
* @param {object} filter - Where clause
|
|
564
|
+
* @param {object} updates - Fields to $set on each matched document
|
|
565
|
+
* @returns {Promise<object>} Update result
|
|
566
|
+
*/
|
|
567
|
+
async updateWhere(filter, updates) {
|
|
568
|
+
const matched = await this.find(filter);
|
|
569
|
+
if (!matched || !matched.data) return { updated: 0 };
|
|
570
|
+
let count = 0;
|
|
571
|
+
for (const doc of matched.data) {
|
|
572
|
+
await this.set(doc._id, updates);
|
|
573
|
+
count++;
|
|
574
|
+
}
|
|
575
|
+
return { updated: count };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Send a raw query body via bytecode. Compiles filter from the body.
|
|
580
|
+
* Falls back to _query for features not yet in QX bytecode (explain, timeout).
|
|
581
|
+
* @param {object} body - Query body (compiled to bytecode)
|
|
582
|
+
* @returns {Promise<object>}
|
|
583
|
+
*/
|
|
584
|
+
async rawQuery(body) {
|
|
585
|
+
// ── Input Validation (mirrors the old _query endpoint) ──────────
|
|
586
|
+
const KNOWN_KEYS = new Set([
|
|
587
|
+
'where', 'sort', 'limit', 'skip', 'select', 'count',
|
|
588
|
+
'$delete', '$exec', 'explain', 'timeout', '$whereJS',
|
|
589
|
+
// Aggregation keys
|
|
590
|
+
'sum', 'avg', 'min', 'max', 'variance', 'stddev', 'percentile',
|
|
591
|
+
'top', 'bottom', 'histogram', 'range_count',
|
|
592
|
+
// Pipeline
|
|
593
|
+
'$pipeline',
|
|
594
|
+
// Other
|
|
595
|
+
'rank', 'k', 'buckets', 'gte', 'lte', 'noCache',
|
|
596
|
+
]);
|
|
597
|
+
const KNOWN_OPS = new Set([
|
|
598
|
+
'$eq', '$ne', '$gt', '$gte', '$lt', '$lte',
|
|
599
|
+
'$in', '$nin', '$regex', '$exists', '$type', '$size',
|
|
600
|
+
'$all', '$elemMatch', '$not',
|
|
601
|
+
'$knn', '$box', '$geoWithin', '$geoIntersects',
|
|
602
|
+
]);
|
|
603
|
+
const KNOWN_WHERE_OPS = new Set(['$and', '$or', '$nor', '$not', '$whereJS']);
|
|
604
|
+
|
|
605
|
+
// Reject $where: suggest $whereJS
|
|
606
|
+
if ('$where' in body || (body.where && '$where' in body.where)) {
|
|
607
|
+
throw new SubstrioError(400, 'INVALID_QUERY',
|
|
608
|
+
'$where is not supported in Substrio. Use $whereJS for JavaScript expression evaluation.');
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Reject unknown top-level $ keys (e.g. $sort, $foobar)
|
|
612
|
+
for (const k of Object.keys(body)) {
|
|
613
|
+
if (k.startsWith('$') && !KNOWN_KEYS.has(k)) {
|
|
614
|
+
const suggestion = k === '$sort' ? ' Did you mean "sort" (without $)?' :
|
|
615
|
+
k === '$limit' ? ' Did you mean "limit" (without $)?' :
|
|
616
|
+
k === '$skip' ? ' Did you mean "skip" (without $)?' : '';
|
|
617
|
+
throw new SubstrioError(400, 'INVALID_QUERY',
|
|
618
|
+
`Unknown query operator "${k}".${suggestion} Supported: where, sort, limit, skip, select, count, $delete, $exec, explain.`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Validate limit type
|
|
623
|
+
if ('limit' in body && (typeof body.limit !== 'number' || !Number.isFinite(body.limit))) {
|
|
624
|
+
throw new SubstrioError(400, 'INVALID_QUERY',
|
|
625
|
+
'limit must be an integer');
|
|
626
|
+
}
|
|
627
|
+
// Validate skip type
|
|
628
|
+
if ('skip' in body && (typeof body.skip !== 'number' || !Number.isFinite(body.skip))) {
|
|
629
|
+
throw new SubstrioError(400, 'INVALID_QUERY',
|
|
630
|
+
'skip must be an integer');
|
|
631
|
+
}
|
|
632
|
+
// Validate sort type
|
|
633
|
+
if ('sort' in body && (typeof body.sort !== 'object' || body.sort === null || Array.isArray(body.sort))) {
|
|
634
|
+
throw new SubstrioError(400, 'INVALID_QUERY',
|
|
635
|
+
'sort must be an object (e.g. { age: 1 })');
|
|
636
|
+
}
|
|
637
|
+
// Validate sort direction values
|
|
638
|
+
if (body.sort && typeof body.sort === 'object') {
|
|
639
|
+
for (const [field, dir] of Object.entries(body.sort)) {
|
|
640
|
+
if (dir !== 1 && dir !== -1) {
|
|
641
|
+
throw new SubstrioError(400, 'INVALID_QUERY',
|
|
642
|
+
`sort direction for "${field}" must be 1 (ascending) or -1 (descending), got ${dir}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
// Validate select type
|
|
647
|
+
if ('select' in body && !Array.isArray(body.select)) {
|
|
648
|
+
throw new SubstrioError(400, 'INVALID_QUERY',
|
|
649
|
+
'select must be an array of field names');
|
|
650
|
+
}
|
|
651
|
+
// Validate count type
|
|
652
|
+
if ('count' in body && typeof body.count !== 'boolean') {
|
|
653
|
+
throw new SubstrioError(400, 'INVALID_QUERY',
|
|
654
|
+
'count must be true or false');
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Validate where clause operators
|
|
658
|
+
const where = body.where;
|
|
659
|
+
if (where && typeof where === 'object') {
|
|
660
|
+
for (const [k, v] of Object.entries(where)) {
|
|
661
|
+
if (k.startsWith('$') && !KNOWN_WHERE_OPS.has(k)) {
|
|
662
|
+
throw new SubstrioError(400, 'INVALID_QUERY',
|
|
663
|
+
`Unknown query operator "${k}". Supported: $and, $or, $nor, $not, $whereJS, or use field-level operators.`);
|
|
664
|
+
}
|
|
665
|
+
// Validate field-level operators
|
|
666
|
+
if (!k.startsWith('$') && v && typeof v === 'object' && !Array.isArray(v)) {
|
|
667
|
+
for (const op of Object.keys(v)) {
|
|
668
|
+
if (op.startsWith('$') && !KNOWN_OPS.has(op)) {
|
|
669
|
+
throw new SubstrioError(400, 'INVALID_QUERY',
|
|
670
|
+
`Unknown field operator "${op}" on field "${k}". Supported: ${[...KNOWN_OPS].join(', ')}`);
|
|
671
|
+
}
|
|
672
|
+
// Validate $in/$nin must be arrays
|
|
673
|
+
if ((op === '$in' || op === '$nin') && !Array.isArray(v[op])) {
|
|
674
|
+
throw new SubstrioError(400, 'INVALID_QUERY',
|
|
675
|
+
`${op} value for field "${k}" must be an array`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ── Aggregation keys → delegate to _agg() ───────────────────────
|
|
683
|
+
const AGG_KEYS = ['sum','avg','min','max','variance','stddev','percentile',
|
|
684
|
+
'top','bottom','histogram','range_count'];
|
|
685
|
+
for (const k of AGG_KEYS) {
|
|
686
|
+
if (k in body) return this._agg(body);
|
|
687
|
+
}
|
|
688
|
+
const qb = this.qx();
|
|
689
|
+
// Extract known keys and compile to bytecode
|
|
690
|
+
const filter = body.where || {};
|
|
691
|
+
const opts = {};
|
|
692
|
+
if (body.sort) opts.sort = body.sort;
|
|
693
|
+
if (body.limit !== undefined) opts.limit = body.limit;
|
|
694
|
+
if (body.skip !== undefined) opts.skip = body.skip;
|
|
695
|
+
if (body.select) opts.select = body.select;
|
|
696
|
+
if (body.count) opts.count = body.count;
|
|
697
|
+
QueryBuilder.compileFilter(qb, filter, opts);
|
|
698
|
+
if (body.$delete) qb.deleteMatching();
|
|
699
|
+
if (body.$exec) qb.execOp(body.$exec);
|
|
700
|
+
if (body.explain) qb.explainOp();
|
|
701
|
+
if (body.timeout) qb.timeoutOp(body.timeout);
|
|
702
|
+
return qb.run();
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Count documents matching a filter. */
|
|
706
|
+
async count(filter = {}) {
|
|
707
|
+
const r = await this.query().where(filter).count().run();
|
|
708
|
+
return (typeof r === 'object' && 'count' in r) ? r.count : r;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Run an aggregation pipeline. @param {Array} stages @param {object} [opts] @param {boolean} [opts.noCache] */
|
|
712
|
+
async pipeline(stages, opts = {}) {
|
|
713
|
+
// Route through _query endpoint (JSON): the QX bytecode pipeline encoder
|
|
714
|
+
// doesn't fully support all stages ($count, $join, $rerank, $bucket output,
|
|
715
|
+
// $unionWith sub-pipeline, $facet with $count, etc.)
|
|
716
|
+
return this.http.req('POST', `/v1/${this.name}/_query`, { $pipeline: stages });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ── RAG sub-object ────────────────────────────────────────────────────
|
|
720
|
+
|
|
721
|
+
/** Lazy RAG sub-object: collection.rag.set(config), collection.rag.get(), collection.rag.delete(), collection.rag.drift(), collection.rag.reembed(), collection.rag.embed(text) */
|
|
722
|
+
get rag() {
|
|
723
|
+
if (!this._rag) {
|
|
724
|
+
const http = this.http, name = this.name;
|
|
725
|
+
this._rag = {
|
|
726
|
+
set: (config) => http.req('POST', `/v1/${name}/_rag`, config),
|
|
727
|
+
get: () => http.req('GET', `/v1/${name}/_rag`),
|
|
728
|
+
delete: () => http.req('DELETE', `/v1/${name}/_rag`),
|
|
729
|
+
drift: () => http.req('GET', `/v1/${name}/_rag/drift`),
|
|
730
|
+
reembed: (opts) => http.req('POST', `/v1/${name}/_rag/re-embed`, opts),
|
|
731
|
+
embed: (text) => http.req('POST', `/v1/${name}/_rag/embed`, { text }),
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
return this._rag;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Lazy Schema sub-object: collection.schema.set(rules), collection.schema.get(), collection.schema.delete() */
|
|
738
|
+
get schema() {
|
|
739
|
+
if (!this._schema) {
|
|
740
|
+
const http = this.http, name = this.name;
|
|
741
|
+
this._schema = {
|
|
742
|
+
set: (config) => http.req('POST', `/v1/${name}/_schema`, config),
|
|
743
|
+
get: () => http.req('GET', `/v1/${name}/_schema`),
|
|
744
|
+
delete: () => http.req('DELETE', `/v1/${name}/_schema`),
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
return this._schema;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// ── Vector / semantic search ──────────────────────────────────────────
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Vector/semantic search.
|
|
754
|
+
* @param {string} query - Search query text
|
|
755
|
+
* @param {object} [opts] - { k, mode, filter, select, decay, boost_feedback }
|
|
756
|
+
*/
|
|
757
|
+
async search(query, opts = {}) {
|
|
758
|
+
const config = {
|
|
759
|
+
query: typeof query === 'string' ? query : (query.query || query),
|
|
760
|
+
k: opts.k || 10,
|
|
761
|
+
};
|
|
762
|
+
if (opts.mode) config.mode = opts.mode;
|
|
763
|
+
if (opts.filter) config.filter = opts.filter;
|
|
764
|
+
if (opts.select) config.select = opts.select;
|
|
765
|
+
if (opts.decay) config.decay = opts.decay;
|
|
766
|
+
if (opts.boost_feedback !== undefined) config.boost_feedback = opts.boost_feedback;
|
|
767
|
+
if (opts.limit) config.limit = opts.limit;
|
|
768
|
+
const qb = this.qx();
|
|
769
|
+
qb.searchOp(config);
|
|
770
|
+
return qb.run();
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* RAG context retrieval: search + return context, citations.
|
|
775
|
+
* @param {string} question
|
|
776
|
+
* @param {object} [opts] - { k, filter }
|
|
777
|
+
* @returns {Promise<object>} - { context, citations, search_hits, chunks_used, collection }
|
|
778
|
+
*/
|
|
779
|
+
async ask(question, opts = {}) {
|
|
780
|
+
const body = { question };
|
|
781
|
+
if (opts.k !== undefined) body.search_k = opts.k;
|
|
782
|
+
if (opts.filter) body.filter = opts.filter;
|
|
783
|
+
return this.http.req('POST', `/v1/${this.name}/_ask`, body);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Cross-vector search: search across multiple named vector fields with RRF fusion.
|
|
788
|
+
* @param {string} query - Text to search for
|
|
789
|
+
* @param {object} [opts] - { fields, k, limit }
|
|
790
|
+
* @returns {Promise<object>} - { data, count, fields_searched, fields_skipped }
|
|
791
|
+
*/
|
|
792
|
+
async crossSearch(query, opts = {}) {
|
|
793
|
+
const body = { query };
|
|
794
|
+
if (opts.fields) body.fields = opts.fields;
|
|
795
|
+
if (opts.k !== undefined) body.k = opts.k;
|
|
796
|
+
if (opts.limit !== undefined) body.limit = opts.limit;
|
|
797
|
+
return this.http.req('POST', `/v1/${this.name}/_cross_search`, body);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** Expand context window around a chunk document. */
|
|
801
|
+
async expand(opts) {
|
|
802
|
+
const body = {};
|
|
803
|
+
if (opts.doc_id) body.doc_id = opts.doc_id;
|
|
804
|
+
if (opts.before !== undefined) body.before = opts.before;
|
|
805
|
+
if (opts.after !== undefined) body.after = opts.after;
|
|
806
|
+
return this.http.req('POST', `/v1/${this.name}/_expand`, body);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ── Feedback sub-object ───────────────────────────────────────────────
|
|
810
|
+
|
|
811
|
+
/** Lazy feedback sub-object: collection.feedback.record(signal), .list(opts), .stats() */
|
|
812
|
+
get feedback() {
|
|
813
|
+
if (!this._feedback) {
|
|
814
|
+
const http = this.http, name = this.name;
|
|
815
|
+
this._feedback = {
|
|
816
|
+
record: (signal) => http.req('POST', `/v1/${name}/_feedback`, signal),
|
|
817
|
+
list: (opts) => http.req('GET', `/v1/${name}/_feedback${qs(opts)}`),
|
|
818
|
+
stats: () => http.req('GET', `/v1/${name}/_feedback/analytics`),
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
return this._feedback;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ── Analytics & Stats ─────────────────────────────────────────────────
|
|
825
|
+
|
|
826
|
+
/** Get search analytics. */
|
|
827
|
+
async analytics(opts) {
|
|
828
|
+
return this.http.req('GET', `/v1/${this.name}/_analytics/search${qs(opts)}`);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Get collection stats. */
|
|
832
|
+
async stats() {
|
|
833
|
+
return this.http.req('GET', `/v1/${this.name}/_stats`);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ── Namespaces ────────────────────────────────────────────────────────
|
|
837
|
+
|
|
838
|
+
/** Get a namespace-scoped collection ref. */
|
|
839
|
+
namespace(name) {
|
|
840
|
+
return new NamespacedCollection(this.http, this.name, name);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** List all namespaces. */
|
|
844
|
+
async namespaces() {
|
|
845
|
+
return this.http.req('GET', `/v1/${this.name}/_namespaces`);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** Delete a namespace. */
|
|
849
|
+
async deleteNamespace(name) {
|
|
850
|
+
return this.http.req('DELETE', `/v1/${this.name}/_namespaces/${encodeURIComponent(name)}`);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Get namespace stats. */
|
|
854
|
+
async namespaceStats(name) {
|
|
855
|
+
return this.http.req('GET', `/v1/${this.name}/_namespaces/${encodeURIComponent(name)}/stats`);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ── Join ──────────────────────────────────────────────────────────────
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Cross-collection $join query.
|
|
862
|
+
* @param {object|object[]} join - { from, localField, foreignField, as } or array of them
|
|
863
|
+
* @param {object} [opts] - { where, limit, select }
|
|
864
|
+
*/
|
|
865
|
+
async join(join, opts = {}) {
|
|
866
|
+
// Route through _query endpoint (JSON): QX bytecode join encoder
|
|
867
|
+
// doesn't populate foreign fields correctly in all cases.
|
|
868
|
+
const body = { $join: join };
|
|
869
|
+
if (opts.where) body.where = opts.where;
|
|
870
|
+
if (opts.limit !== undefined) body.limit = opts.limit;
|
|
871
|
+
if (opts.select) body.select = opts.select;
|
|
872
|
+
return this.http.req('POST', `/v1/${this.name}/_query`, body);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ── Import/Export ─────────────────────────────────────────────────────
|
|
876
|
+
|
|
877
|
+
/** Bulk import documents. */
|
|
878
|
+
async import(data, opts = {}) {
|
|
879
|
+
const format = opts.format || 'ndjson';
|
|
880
|
+
const body = typeof data === 'string' ? data : JSON.stringify(data);
|
|
881
|
+
const ct = format === 'csv' ? 'text/csv' : 'application/x-ndjson';
|
|
882
|
+
return this.http.req('POST', `/v1/${this.name}/_import${qs({ format })}`, body, { 'Content-Type': ct });
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** Export documents. */
|
|
886
|
+
async export(opts = {}) {
|
|
887
|
+
return this.http.req('GET', `/v1/${this.name}/_export${qs(opts)}`);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ── Index-Only Analytics ──────────────────────────────────────────────
|
|
891
|
+
// Zero-hydration columnar aggregation: pure B-tree cursor walk.
|
|
892
|
+
|
|
893
|
+
/** @internal Shared analytics helper: bytecode aggregation via /_qx
|
|
894
|
+
* Supports both calling conventions:
|
|
895
|
+
* _agg('sum', 'field') : new string-based (from sugar methods)
|
|
896
|
+
* _agg({ sum: 'field' }) : legacy object-based (backward compat)
|
|
897
|
+
*/
|
|
898
|
+
_agg(subType, field, params) {
|
|
899
|
+
// Backward compat: _agg({ sum: 'revenue', ...opts })
|
|
900
|
+
if (typeof subType === 'object' && subType !== null) {
|
|
901
|
+
const body = subType;
|
|
902
|
+
const ops = ['sum','avg','min','max','variance','stddev','percentile',
|
|
903
|
+
'top','bottom','histogram','range_count'];
|
|
904
|
+
for (const op of ops) {
|
|
905
|
+
if (op in body) {
|
|
906
|
+
return this._agg(op, body[op],
|
|
907
|
+
{ rank: body.rank, k: body.k, buckets: body.buckets,
|
|
908
|
+
gte: body.gte, lte: body.lte });
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
throw new Error(`_agg: no recognized aggregation op in ${JSON.stringify(body)}`);
|
|
912
|
+
}
|
|
913
|
+
const qb = this.qx();
|
|
914
|
+
switch (subType) {
|
|
915
|
+
case 'sum': qb.aggSum(field); break;
|
|
916
|
+
case 'avg': qb.aggAvg(field); break;
|
|
917
|
+
case 'min': qb.aggMin(field); break;
|
|
918
|
+
case 'max': qb.aggMax(field); break;
|
|
919
|
+
case 'variance': qb.aggVariance(field); break;
|
|
920
|
+
case 'stddev': qb.aggStddev(field); break;
|
|
921
|
+
case 'percentile': qb.aggPercentile(field, params.rank); break;
|
|
922
|
+
case 'top': qb.aggTop(field, params.k); break;
|
|
923
|
+
case 'bottom': qb.aggBottom(field, params.k); break;
|
|
924
|
+
case 'histogram': qb.aggHistogram(field, params.buckets); break;
|
|
925
|
+
case 'range_count': qb.aggRangeCount(field, params.gte, params.lte); break;
|
|
926
|
+
default: throw new Error(`_agg: unknown aggregation type '${subType}'`);
|
|
927
|
+
}
|
|
928
|
+
return qb.run();
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** Index-only SUM of a numeric field. */
|
|
932
|
+
async sum(field) { return this._agg('sum', field); }
|
|
933
|
+
|
|
934
|
+
/** Index-only AVG of a numeric field. */
|
|
935
|
+
async avg(field) { return this._agg('avg', field); }
|
|
936
|
+
|
|
937
|
+
/** Index-only MIN of a field. */
|
|
938
|
+
async min(field) { return this._agg('min', field); }
|
|
939
|
+
|
|
940
|
+
/** Index-only MAX of a field. */
|
|
941
|
+
async max(field) { return this._agg('max', field); }
|
|
942
|
+
|
|
943
|
+
/** Index-only variance (Welford's algorithm). */
|
|
944
|
+
async variance(field) { return this._agg('variance', field); }
|
|
945
|
+
|
|
946
|
+
/** Index-only standard deviation. */
|
|
947
|
+
async stddev(field) { return this._agg('stddev', field); }
|
|
948
|
+
|
|
949
|
+
/** Index-only percentile (0.0–1.0 rank). */
|
|
950
|
+
async percentile(field, rank) { return this._agg('percentile', field, { rank }); }
|
|
951
|
+
|
|
952
|
+
/** Index-only top-K largest values. */
|
|
953
|
+
async top(field, k) { return this._agg('top', field, { k }); }
|
|
954
|
+
|
|
955
|
+
/** Index-only bottom-K smallest values. */
|
|
956
|
+
async bottom(field, k) { return this._agg('bottom', field, { k }); }
|
|
957
|
+
|
|
958
|
+
/** Index-only histogram with N equal-width buckets. */
|
|
959
|
+
async histogram(field, buckets) { return this._agg('histogram', field, { buckets }); }
|
|
960
|
+
|
|
961
|
+
/** Index-only range count (gte..lte). */
|
|
962
|
+
async rangeCount(field, gte, lte) { return this._agg('range_count', field, { gte, lte }); }
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
966
|
+
// NamespacedCollection: namespace-scoped operations
|
|
967
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
968
|
+
|
|
969
|
+
class NamespacedCollection {
|
|
970
|
+
constructor(http, collection, namespace) {
|
|
971
|
+
this.http = http;
|
|
972
|
+
this.collection = collection;
|
|
973
|
+
this.namespace = namespace;
|
|
974
|
+
this._nsHeader = { 'X-Substrio-Namespace': namespace };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async add(doc) {
|
|
978
|
+
// JSON, as Collection.add sends it.
|
|
979
|
+
return this.http.req('POST', `/v1/${this.collection}`, doc, this._nsHeader);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
async find(query = {}, opts = {}) {
|
|
983
|
+
const qb = new BoundQueryBuilder(this.http, this.collection, this._nsHeader);
|
|
984
|
+
QueryBuilder.compileFilter(qb, query, opts);
|
|
985
|
+
return qb.run();
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
async search(query, opts = {}) {
|
|
989
|
+
const config = {
|
|
990
|
+
query: typeof query === 'string' ? query : (query.query || query),
|
|
991
|
+
k: opts.k || 10,
|
|
992
|
+
};
|
|
993
|
+
if (opts.mode) config.mode = opts.mode;
|
|
994
|
+
if (opts.filter) config.filter = opts.filter;
|
|
995
|
+
if (opts.select) config.select = opts.select;
|
|
996
|
+
const qb = new BoundQueryBuilder(this.http, this.collection, this._nsHeader);
|
|
997
|
+
qb.searchOp(config);
|
|
998
|
+
return qb.run();
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
async delete(id) {
|
|
1002
|
+
return this.http.req('DELETE', `/v1/${this.collection}/${encodeURIComponent(id)}`, undefined, this._nsHeader);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async deleteBy(query) {
|
|
1006
|
+
const qb = new BoundQueryBuilder(this.http, this.collection, this._nsHeader);
|
|
1007
|
+
QueryBuilder.compileFilter(qb, query);
|
|
1008
|
+
qb.deleteMatching();
|
|
1009
|
+
return qb.run();
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
async count(query = {}) {
|
|
1013
|
+
const qb = new BoundQueryBuilder(this.http, this.collection, this._nsHeader);
|
|
1014
|
+
QueryBuilder.compileFilter(qb, query);
|
|
1015
|
+
qb.count();
|
|
1016
|
+
const r = await qb.run();
|
|
1017
|
+
return (typeof r === 'object' && 'count' in r) ? r.count : r;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1022
|
+
// Thread: conversation thread primitive
|
|
1023
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1024
|
+
|
|
1025
|
+
export class Thread {
|
|
1026
|
+
constructor(http, id, data) {
|
|
1027
|
+
this.http = http;
|
|
1028
|
+
this.id = id;
|
|
1029
|
+
if (data) Object.assign(this, data);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/** Fetch thread metadata. */
|
|
1033
|
+
async get() {
|
|
1034
|
+
const d = await this.http.req('GET', `/v1/_threads/${encodeURIComponent(this.id)}`);
|
|
1035
|
+
Object.assign(this, d);
|
|
1036
|
+
return d;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/** Update thread config/metadata. */
|
|
1040
|
+
async update(patch) {
|
|
1041
|
+
return this.http.req('PATCH', `/v1/_threads/${encodeURIComponent(this.id)}`, patch);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** Delete this thread and all its messages. */
|
|
1045
|
+
async delete() {
|
|
1046
|
+
return this.http.req('DELETE', `/v1/_threads/${encodeURIComponent(this.id)}`);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** Add a message to the thread. */
|
|
1050
|
+
async addMessage(message) {
|
|
1051
|
+
if (typeof message === 'string') message = { role: 'user', content: message };
|
|
1052
|
+
return this.http.req('POST', `/v1/_threads/${encodeURIComponent(this.id)}/messages`, message);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** List messages in this thread. */
|
|
1056
|
+
async messages(opts) {
|
|
1057
|
+
return this.http.req('GET', `/v1/_threads/${encodeURIComponent(this.id)}/messages${qs(opts)}`);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** Delete a specific message by ID. */
|
|
1061
|
+
async deleteMessage(msgId) {
|
|
1062
|
+
return this.http.req('DELETE', `/v1/_threads/${encodeURIComponent(this.id)}/messages/${encodeURIComponent(msgId)}`);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* RAG context retrieval within thread context.
|
|
1067
|
+
* @param {string} question
|
|
1068
|
+
* @param {object} [opts] - { rag_collections, k }
|
|
1069
|
+
* @returns {Promise<object>} - { context, citations, search_hits, chunks_used, conversation, thread_id }
|
|
1070
|
+
*/
|
|
1071
|
+
async ask(question, opts = {}) {
|
|
1072
|
+
const body = { question };
|
|
1073
|
+
if (opts.rag_collections) body.rag_collections = opts.rag_collections;
|
|
1074
|
+
if (opts.k !== undefined) body.search_k = opts.k;
|
|
1075
|
+
return this.http.req('POST', `/v1/_threads/${encodeURIComponent(this.id)}/_ask`, body);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** Compress old messages into a summary. */
|
|
1079
|
+
async summarize(opts) {
|
|
1080
|
+
return this.http.req('POST', `/v1/_threads/${encodeURIComponent(this.id)}/_summarize`, opts);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** Prune messages by criteria. */
|
|
1084
|
+
async prune(opts) {
|
|
1085
|
+
const body = {};
|
|
1086
|
+
if (opts.keep_last !== undefined) body.keep_last = opts.keep_last;
|
|
1087
|
+
if (opts.msg_ids) body.msg_ids = opts.msg_ids;
|
|
1088
|
+
if (opts.role) body.role = opts.role;
|
|
1089
|
+
return this.http.req('POST', `/v1/_threads/${encodeURIComponent(this.id)}/_prune`, body);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1094
|
+
// Auth: authentication methods
|
|
1095
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1096
|
+
|
|
1097
|
+
class Auth {
|
|
1098
|
+
constructor(http, tenant) {
|
|
1099
|
+
this.http = http;
|
|
1100
|
+
this.tenant = tenant || 'default';
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
_hostHeader() {
|
|
1104
|
+
return { 'Host': `${this.tenant}.localhost` };
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
async signup(creds) {
|
|
1108
|
+
return this.http.req('POST', '/v1/_auth/register', creds, this._hostHeader());
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
async login(creds) {
|
|
1112
|
+
return this.http.req('POST', '/v1/_auth/login', creds, this._hostHeader());
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
async refresh(token) {
|
|
1116
|
+
return this.http.req('POST', '/v1/_auth/refresh', {}, {
|
|
1117
|
+
...this._hostHeader(),
|
|
1118
|
+
'Authorization': `Bearer ${token}`,
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
async oauth(opts) {
|
|
1123
|
+
return this.http.req('POST', '/v1/_auth/oauth', opts, this._hostHeader());
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
async google(opts) {
|
|
1127
|
+
return this.http.req('POST', '/v1/_auth/google', opts, this._hostHeader());
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
async github(opts) {
|
|
1131
|
+
return this.http.req('POST', '/v1/_auth/oauth', { provider: 'github', ...opts }, this._hostHeader());
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
async discord(opts) {
|
|
1135
|
+
return this.http.req('POST', '/v1/_auth/oauth', { provider: 'discord', ...opts }, this._hostHeader());
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
async apple(opts) {
|
|
1139
|
+
return this.http.req('POST', '/v1/_auth/oauth', { provider: 'apple', ...opts }, this._hostHeader());
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
async microsoft(opts) {
|
|
1143
|
+
return this.http.req('POST', '/v1/_auth/oauth', { provider: 'microsoft', ...opts }, this._hostHeader());
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
async twitter(opts) {
|
|
1147
|
+
return this.http.req('POST', '/v1/_auth/oauth', { provider: 'twitter', ...opts }, this._hostHeader());
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** Lazy providers sub-object for OAuth provider config CRUD. */
|
|
1151
|
+
get providers() {
|
|
1152
|
+
if (!this._providers) {
|
|
1153
|
+
const http = this.http, hh = this._hostHeader();
|
|
1154
|
+
this._providers = {
|
|
1155
|
+
list: () => http.req('GET', '/v1/_auth/providers', undefined, hh),
|
|
1156
|
+
set: (config) => http.req('POST', '/v1/_auth/providers', config, hh),
|
|
1157
|
+
/** Delete a provider config (auth.provider.delete). */
|
|
1158
|
+
delete: (provider) => http.req('DELETE', `/v1/_auth/providers/${encodeURIComponent(provider)}`, undefined, hh),
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
return this._providers;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1166
|
+
// Users: admin user management
|
|
1167
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1168
|
+
|
|
1169
|
+
class Users {
|
|
1170
|
+
constructor(http) {
|
|
1171
|
+
this.http = http;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** List users with pagination. Options: { limit, skip } */
|
|
1175
|
+
async list(opts = {}) {
|
|
1176
|
+
const params = new URLSearchParams();
|
|
1177
|
+
if (opts.limit) params.set('limit', opts.limit);
|
|
1178
|
+
if (opts.skip) params.set('skip', opts.skip);
|
|
1179
|
+
const qs = params.toString();
|
|
1180
|
+
return this.http.req('GET', qs ? `/v1/_users?${qs}` : '/v1/_users');
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/** Get a single user by ID */
|
|
1184
|
+
async get(userId) {
|
|
1185
|
+
return this.http.req('GET', `/v1/_users/${encodeURIComponent(userId)}`);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/** Admin-create a user. Fields: email, password, role?, keyring?, verified?, metadata? */
|
|
1189
|
+
async create(data) {
|
|
1190
|
+
return this.http.req('POST', '/v1/_users', data);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/** Update a user by ID. Fields: locked?, disabled?, admin?, email_verified?, keyring?, password?, metadata?, role? */
|
|
1194
|
+
async update(userId, patch) {
|
|
1195
|
+
return this.http.req('PATCH', `/v1/_users/${encodeURIComponent(userId)}`, patch);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/** Delete a user. Options: { hard: true } for permanent removal */
|
|
1199
|
+
async delete(userId, opts = {}) {
|
|
1200
|
+
const qs = opts.hard ? '?hard=true' : '';
|
|
1201
|
+
return this.http.req('DELETE', `/v1/_users/${encodeURIComponent(userId)}${qs}`);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/** Get total user count */
|
|
1205
|
+
async count() {
|
|
1206
|
+
return this.http.req('GET', '/v1/_users/_count');
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/** Impersonate a user (get a token as that user) */
|
|
1210
|
+
async impersonate(userId) {
|
|
1211
|
+
return this.http.req('POST', `/v1/_users/${encodeURIComponent(userId)}/impersonate`, {});
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** Assign a role to a user by ID. */
|
|
1215
|
+
async setRole(userId, role) {
|
|
1216
|
+
return this.http.req('PUT', `/v1/_users/${encodeURIComponent(userId)}/role`, { role });
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** Check if a user has permission for a collection + operation. */
|
|
1220
|
+
async can(userId, collection, operation) {
|
|
1221
|
+
return this.http.req('GET', `/v1/_users/${encodeURIComponent(userId)}/can?collection=${encodeURIComponent(collection)}&operation=${encodeURIComponent(operation)}`);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1226
|
+
// KvHttp: HTTP-based Key-Value store operations
|
|
1227
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1228
|
+
|
|
1229
|
+
class KvHttp {
|
|
1230
|
+
constructor(http, store) {
|
|
1231
|
+
this.http = http;
|
|
1232
|
+
this.store = store;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** Set one or more key-value pairs: [[key, value], ...] */
|
|
1236
|
+
async set(pairs) {
|
|
1237
|
+
return this.http.req('POST', `/v1/kv/${encodeURIComponent(this.store)}`, pairs);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** Get values for an array of keys: [key1, key2, ...] */
|
|
1241
|
+
async get(keys) {
|
|
1242
|
+
return this.http.req('POST', `/v1/kv/${encodeURIComponent(this.store)}/_get`, keys);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/** Delete an array of keys: [key1, key2, ...] */
|
|
1246
|
+
async delete(keys) {
|
|
1247
|
+
return this.http.req('POST', `/v1/kv/${encodeURIComponent(this.store)}/_del`, keys);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1252
|
+
// Graph: labeled directed edge operations (TurboGraph)
|
|
1253
|
+
//
|
|
1254
|
+
// Usage:
|
|
1255
|
+
// const g = db.graph('follows');
|
|
1256
|
+
// await g.addEdge(1, 2); // src=1 → dst=2
|
|
1257
|
+
// await g.hasEdge(1, 2); // { exists: true, ... }
|
|
1258
|
+
// await g.neighbors(1); // { neighbors: [2], count: 1 }
|
|
1259
|
+
// await g.neighbors(1, 'in'); // incoming neighbors
|
|
1260
|
+
// await g.degree(1); // { out_degree: 1, in_degree: 0 }
|
|
1261
|
+
// await g.removeEdge(1, 2);
|
|
1262
|
+
// await g.stats(); // { edge_count: ... }
|
|
1263
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1264
|
+
|
|
1265
|
+
class Graph {
|
|
1266
|
+
constructor(http, label) {
|
|
1267
|
+
this.http = http;
|
|
1268
|
+
this._base = `/v1/_graph/${encodeURIComponent(label)}`;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/** Add a directed edge: src → dst. Returns { added: true/false }. */
|
|
1272
|
+
async addEdge(src, dst) {
|
|
1273
|
+
return this.http.req('POST', `${this._base}/edges`, { src, dst });
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/** Remove a directed edge: src → dst. Returns { removed: true/false }. */
|
|
1277
|
+
async removeEdge(src, dst) {
|
|
1278
|
+
return this.http.req('POST', `${this._base}/edges/_remove`, { src, dst });
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/** Check if an edge exists: src → dst. Returns { exists: true/false }. */
|
|
1282
|
+
async hasEdge(src, dst) {
|
|
1283
|
+
return this.http.req('POST', `${this._base}/edges/_check`, { src, dst });
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* Get neighbors of a node.
|
|
1288
|
+
* @param {number} nodeId - Node ID
|
|
1289
|
+
* @param {string} [dir='out'] - Direction: 'out' or 'in'
|
|
1290
|
+
* @returns {Promise<{neighbors: number[], count: number}>}
|
|
1291
|
+
*/
|
|
1292
|
+
async neighbors(nodeId, dir = 'out') {
|
|
1293
|
+
return this.http.req('POST', `${this._base}/neighbors/${nodeId}`, { dir });
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Get degree (in/out) of a node.
|
|
1298
|
+
* @param {number} nodeId - Node ID
|
|
1299
|
+
* @returns {Promise<{out_degree: number, in_degree: number}>}
|
|
1300
|
+
*/
|
|
1301
|
+
async degree(nodeId) {
|
|
1302
|
+
return this.http.req('GET', `${this._base}/degree/${nodeId}`);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/** Get edge count stats for this label. */
|
|
1306
|
+
async stats() {
|
|
1307
|
+
return this.http.req('GET', `${this._base}/stats`);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// ── Bitmap-Native Analytics (Batch 1) ────────────────────────────────
|
|
1311
|
+
|
|
1312
|
+
/** Internal: dispatch an analytics operation to POST /analytics. */
|
|
1313
|
+
async _analytics(body) {
|
|
1314
|
+
return this.http.req('POST', `${this._base}/analytics`, body);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/** Common neighbors of nodes A and B. Returns { neighbors: [...], count }. */
|
|
1318
|
+
async commonNeighbors(a, b, dir = 'out') {
|
|
1319
|
+
return this._analytics({ op: 'common_neighbors', a, b, dir });
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** Count of common neighbors (no materialization). Returns { count }. */
|
|
1323
|
+
async commonNeighborCount(a, b, dir = 'out') {
|
|
1324
|
+
return this._analytics({ op: 'common_neighbor_count', a, b, dir });
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Jaccard similarity: |A∩B| / |A∪B|. Returns { score }. */
|
|
1328
|
+
async jaccard(a, b, dir = 'out') {
|
|
1329
|
+
return this._analytics({ op: 'jaccard', a, b, dir });
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/** Adamic-Adar index: Σ 1/log(deg(c)) for common neighbors c. Returns { score }. */
|
|
1333
|
+
async adamicAdar(a, b, dir = 'out') {
|
|
1334
|
+
return this._analytics({ op: 'adamic_adar', a, b, dir });
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/** Preferential attachment: degree(A) × degree(B). Returns { score }. */
|
|
1338
|
+
async preferentialAttachment(a, b, dir = 'out') {
|
|
1339
|
+
return this._analytics({ op: 'preferential_attachment', a, b, dir });
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/** Neighbors of A that are NOT neighbors of B. Returns { neighbors: [...], count }. */
|
|
1343
|
+
async exclusiveNeighbors(a, b, dir = 'out') {
|
|
1344
|
+
return this._analytics({ op: 'exclusive_neighbors', a, b, dir });
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/** Combined neighbors of A and B (deduplicated). Returns { neighbors: [...], count }. */
|
|
1348
|
+
async neighborUnion(a, b, dir = 'out') {
|
|
1349
|
+
return this._analytics({ op: 'neighbor_union', a, b, dir });
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/** Intersection of neighbors across multiple nodes. Returns { neighbors: [...], count }. */
|
|
1353
|
+
async commonNeighborsMulti(nodes, dir = 'out') {
|
|
1354
|
+
return this._analytics({ op: 'common_neighbors_multi', nodes, dir });
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/** Union of neighbors across multiple nodes. Returns { neighbors: [...], count }. */
|
|
1358
|
+
async neighborUnionMulti(nodes, dir = 'out') {
|
|
1359
|
+
return this._analytics({ op: 'neighbor_union_multi', nodes, dir });
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** Overlap coefficient: |A∩B| / min(|A|, |B|). Returns { score }. */
|
|
1363
|
+
async overlapCoefficient(a, b, dir = 'out') {
|
|
1364
|
+
return this._analytics({ op: 'overlap_coefficient', a, b, dir });
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// ── Bitmap-Native Analytics (Batch 2) ────────────────────────────────
|
|
1368
|
+
|
|
1369
|
+
// ── Tier 1: Link Prediction ──────────────────────────────────────────
|
|
1370
|
+
|
|
1371
|
+
/** Cosine similarity: |A∩B| / sqrt(|A|×|B|). Returns { score }. */
|
|
1372
|
+
async cosineSimilarity(a, b, dir = 'out') {
|
|
1373
|
+
return this._analytics({ op: 'cosine_similarity', a, b, dir });
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/** Resource allocation: Σ 1/degree(c) for common neighbors c. Returns { score }. */
|
|
1377
|
+
async resourceAllocation(a, b, dir = 'out') {
|
|
1378
|
+
return this._analytics({ op: 'resource_allocation', a, b, dir });
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// ── Tier 1: Range-Pruned Queries ─────────────────────────────────────
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* Neighbors with IDs in [lo, hi]. Returns { neighbors: [...], count }.
|
|
1385
|
+
* @param {number} node - Node ID
|
|
1386
|
+
* @param {number} lo - Range lower bound (inclusive)
|
|
1387
|
+
* @param {number} hi - Range upper bound (inclusive)
|
|
1388
|
+
* @param {string} [dir='out'] - Direction: 'out' or 'in'
|
|
1389
|
+
*/
|
|
1390
|
+
async neighborsInRange(node, lo, hi, dir = 'out') {
|
|
1391
|
+
return this._analytics({ op: 'neighbors_in_range', node, lo, hi, dir });
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* Count neighbors with IDs in [lo, hi]. Zero allocation. Returns { count }.
|
|
1396
|
+
* @param {number} node - Node ID
|
|
1397
|
+
* @param {number} lo - Range lower bound (inclusive)
|
|
1398
|
+
* @param {number} hi - Range upper bound (inclusive)
|
|
1399
|
+
* @param {string} [dir='out'] - Direction: 'out' or 'in'
|
|
1400
|
+
*/
|
|
1401
|
+
async neighborsInRangeCount(node, lo, hi, dir = 'out') {
|
|
1402
|
+
return this._analytics({ op: 'neighbors_in_range_count', node, lo, hi, dir });
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// ── Tier 2: Triangle Counting ────────────────────────────────────────
|
|
1406
|
+
|
|
1407
|
+
/** Per-node triangle count. Returns { count }. */
|
|
1408
|
+
async triangleCountNode(node, dir = 'out') {
|
|
1409
|
+
return this._analytics({ op: 'triangle_count_node', node, dir });
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/** Clustering coefficient: triangles / (deg×(deg-1)/2). Returns { score }. */
|
|
1413
|
+
async clusteringCoefficient(node, dir = 'out') {
|
|
1414
|
+
return this._analytics({ op: 'clustering_coefficient', node, dir });
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/** Global clustering coefficient across all nodes. Returns { score }. */
|
|
1418
|
+
async globalClusteringCoefficient(dir = 'out') {
|
|
1419
|
+
return this._analytics({ op: 'global_clustering_coefficient', dir });
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// ── Tier 3: Multi-Hop Traversals ─────────────────────────────────────
|
|
1423
|
+
|
|
1424
|
+
/** Friends-of-friends (2-hop). Returns { neighbors: [...], count }. */
|
|
1425
|
+
async twoHopNeighbors(node, dir = 'out') {
|
|
1426
|
+
return this._analytics({ op: 'two_hop_neighbors', node, dir });
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/** K-hop BFS neighbors at exactly hop k. Returns { neighbors: [...], count }. */
|
|
1430
|
+
async kHopNeighbors(node, k, dir = 'out') {
|
|
1431
|
+
return this._analytics({ op: 'k_hop_neighbors', node, k, dir });
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/** K-hop neighbor count (no materialization). Returns { count }. */
|
|
1435
|
+
async kHopCount(node, k, dir = 'out') {
|
|
1436
|
+
return this._analytics({ op: 'k_hop_count', node, k, dir });
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/** Shortest path length (unweighted BFS). Returns { length } (-1 if unreachable). */
|
|
1440
|
+
async shortestPathLength(src, dst) {
|
|
1441
|
+
return this._analytics({ op: 'shortest_path_length', src, dst });
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/** Reachability check. Returns { reachable: true/false }. */
|
|
1445
|
+
async reachable(src, dst) {
|
|
1446
|
+
return this._analytics({ op: 'reachable', src, dst });
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// ── Tier 10: Tree-Structural ─────────────────────────────────────────
|
|
1450
|
+
|
|
1451
|
+
/** Minimum neighbor ID. Returns { value }. */
|
|
1452
|
+
async minNeighbor(node, dir = 'out') {
|
|
1453
|
+
return this._analytics({ op: 'min_neighbor', node, dir });
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/** Maximum neighbor ID. Returns { value }. */
|
|
1457
|
+
async maxNeighbor(node, dir = 'out') {
|
|
1458
|
+
return this._analytics({ op: 'max_neighbor', node, dir });
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// ── Bitmap-Native Analytics (Batch 3) ────────────────────────────────
|
|
1462
|
+
|
|
1463
|
+
// ── Tier 4: Degree Analytics ─────────────────────────────────────────
|
|
1464
|
+
|
|
1465
|
+
/** Degree centrality: degree(node) / (N-1). Returns { score }. */
|
|
1466
|
+
async degreeCentrality(node, dir = 'out') {
|
|
1467
|
+
return this._analytics({ op: 'degree_centrality', node, dir });
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* Top-K nodes by degree (min-heap, no full sort). Returns { nodes: [{node, degree}, ...], count }.
|
|
1472
|
+
* @param {number} k - Number of top nodes
|
|
1473
|
+
* @param {string} [dir='out'] - Direction
|
|
1474
|
+
*/
|
|
1475
|
+
async topKByDegree(k, dir = 'out') {
|
|
1476
|
+
return this._analytics({ op: 'top_k_by_degree', k, dir });
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/** Degree distribution: map of degree → count. Returns { distribution: [{degree, count}, ...] }. */
|
|
1480
|
+
async degreeDistribution(dir = 'out') {
|
|
1481
|
+
return this._analytics({ op: 'degree_distribution', dir });
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Degree histogram: bucketed degree counts. Returns { buckets: [{start, count}, ...] }.
|
|
1486
|
+
* @param {number} bucketSize - Width of each bucket
|
|
1487
|
+
* @param {string} [dir='out'] - Direction
|
|
1488
|
+
*/
|
|
1489
|
+
async degreeHistogram(bucketSize, dir = 'out') {
|
|
1490
|
+
return this._analytics({ op: 'degree_histogram', bucket_size: bucketSize, dir });
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// ── Tier 5: Label-Aware Operations ───────────────────────────────────
|
|
1494
|
+
|
|
1495
|
+
/** Label stats: {node_count, edge_count, avg_degree, density}. */
|
|
1496
|
+
async labelStats() {
|
|
1497
|
+
return this._analytics({ op: 'label_stats' });
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/**
|
|
1501
|
+
* Neighbors across multiple labels (OR/union). Returns { neighbors: [...], count }.
|
|
1502
|
+
* @param {string[]} labels - Label names to union across
|
|
1503
|
+
* @param {number} node - Node ID
|
|
1504
|
+
* @param {string} [dir='out'] - Direction
|
|
1505
|
+
*/
|
|
1506
|
+
async neighborsMultiLabel(labels, node, dir = 'out') {
|
|
1507
|
+
return this._analytics({ op: 'neighbors_multi_label', labels, node, dir });
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/**
|
|
1511
|
+
* Neighbors present in ALL labels (AND/intersection). Returns { neighbors: [...], count }.
|
|
1512
|
+
* @param {string[]} labels - Label names to intersect
|
|
1513
|
+
* @param {number} node - Node ID
|
|
1514
|
+
* @param {string} [dir='out'] - Direction
|
|
1515
|
+
*/
|
|
1516
|
+
async neighborsAllLabels(labels, node, dir = 'out') {
|
|
1517
|
+
return this._analytics({ op: 'neighbors_all_labels', labels, node, dir });
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// ── Tier 2: Global Triangle Counting ─────────────────────────────────
|
|
1521
|
+
|
|
1522
|
+
/** Global triangle count (all triangles in graph). Returns { count }. */
|
|
1523
|
+
async triangleCountGlobal(dir = 'out') {
|
|
1524
|
+
return this._analytics({ op: 'triangle_count_global', dir });
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// ── Tier 10: Tree-Structural (Batch 3) ───────────────────────────────
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Neighbor rank: count of neighbors with ID < target. Returns { rank }.
|
|
1531
|
+
* @param {number} node - Node ID
|
|
1532
|
+
* @param {number} target - Target value to rank against
|
|
1533
|
+
* @param {string} [dir='out'] - Direction
|
|
1534
|
+
*/
|
|
1535
|
+
async neighborRank(node, target, dir = 'out') {
|
|
1536
|
+
return this._analytics({ op: 'neighbor_rank', node, target, dir });
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// ── Tier 9: Advanced Graph Analytics ─────────────────────────────────
|
|
1540
|
+
|
|
1541
|
+
/** Topological sort (Kahn's algorithm). Returns { order: [...], count } or empty if cycle. */
|
|
1542
|
+
async topologicalSort() {
|
|
1543
|
+
return this._analytics({ op: 'topological_sort' });
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/** Cycle detection. Returns { has_cycle: true/false }. */
|
|
1547
|
+
async hasCycle() {
|
|
1548
|
+
return this._analytics({ op: 'has_cycle' });
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/** Connected components (undirected BFS). Returns { components: [[...], ...], count }. */
|
|
1552
|
+
async connectedComponents() {
|
|
1553
|
+
return this._analytics({ op: 'connected_components' });
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/**
|
|
1557
|
+
* Comprehensive graph summary in one call. Returns node/edge counts,
|
|
1558
|
+
* density, avg degree, max degree nodes, component count, cycle status.
|
|
1559
|
+
*/
|
|
1560
|
+
async graphSummary() {
|
|
1561
|
+
return this._analytics({ op: 'graph_summary' });
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// ── Bitmap-Native Analytics (Batch 4): Hard Algorithms ──────────────
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* PageRank: iterative authority propagation. Returns { nodes: [{node, score}, ...], count }.
|
|
1568
|
+
* @param {object} [opts={}] - Options
|
|
1569
|
+
* @param {number} [opts.iterations=20] - Number of power iterations
|
|
1570
|
+
* @param {number} [opts.damping=0.85] - Damping factor (teleport probability = 1 - damping)
|
|
1571
|
+
*/
|
|
1572
|
+
async pagerank(opts = {}) {
|
|
1573
|
+
const body = { op: 'pagerank' };
|
|
1574
|
+
if (opts.iterations !== undefined) body.iterations = opts.iterations;
|
|
1575
|
+
if (opts.damping !== undefined) body.damping = opts.damping;
|
|
1576
|
+
return this._analytics(body);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* Betweenness centrality (Brandes' algorithm).
|
|
1581
|
+
* Returns { nodes: [{node, score}, ...], count }.
|
|
1582
|
+
*/
|
|
1583
|
+
async betweennessCentrality() {
|
|
1584
|
+
return this._analytics({ op: 'betweenness_centrality' });
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* Closeness centrality for a single node: (reachable-1) / Σ distances.
|
|
1589
|
+
* @param {number} node - Node ID
|
|
1590
|
+
* @returns {Promise<{score: number}>}
|
|
1591
|
+
*/
|
|
1592
|
+
async closenessCentrality(node) {
|
|
1593
|
+
return this._analytics({ op: 'closeness_centrality', node });
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* HITS: Hyperlink-Induced Topic Search.
|
|
1598
|
+
* Returns { nodes: [{node, hub, authority}, ...], count }.
|
|
1599
|
+
* @param {object} [opts={}] - Options
|
|
1600
|
+
* @param {number} [opts.iterations=20] - Number of iterations
|
|
1601
|
+
*/
|
|
1602
|
+
async hits(opts = {}) {
|
|
1603
|
+
const body = { op: 'hits' };
|
|
1604
|
+
if (opts.iterations !== undefined) body.iterations = opts.iterations;
|
|
1605
|
+
return this._analytics(body);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Strongly connected components (Kosaraju's).
|
|
1610
|
+
* Returns { components: [[...], ...], count }.
|
|
1611
|
+
*/
|
|
1612
|
+
async scc() {
|
|
1613
|
+
return this._analytics({ op: 'scc' });
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// ── Graph Intrinsics (Batch 5): Paths, Community, Bulk ──────────────
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* Shortest path (BFS) from src to dst. Returns { path: [...], length }.
|
|
1620
|
+
* @param {number} src - Source node ID
|
|
1621
|
+
* @param {number} dst - Destination node ID
|
|
1622
|
+
* @param {string} [dir='out'] - Direction: 'out' or 'in'
|
|
1623
|
+
*/
|
|
1624
|
+
async shortestPath(src, dst, dir = 'out') {
|
|
1625
|
+
return this._analytics({ op: 'shortest_path', src, dst, dir });
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
/**
|
|
1629
|
+
* All shortest paths from src to dst (same length). Returns { paths: [[...], ...], length, count }.
|
|
1630
|
+
* @param {number} src - Source node ID
|
|
1631
|
+
* @param {number} dst - Destination node ID
|
|
1632
|
+
* @param {string} [dir='out'] - Direction
|
|
1633
|
+
*/
|
|
1634
|
+
async allShortestPaths(src, dst, dir = 'out') {
|
|
1635
|
+
return this._analytics({ op: 'all_shortest_paths', src, dst, dir });
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/**
|
|
1639
|
+
* Eccentricity: max shortest-path distance from node to any reachable node.
|
|
1640
|
+
* @param {number} node - Node ID
|
|
1641
|
+
* @param {string} [dir='out'] - Direction
|
|
1642
|
+
* @returns {Promise<{eccentricity: number}>}
|
|
1643
|
+
*/
|
|
1644
|
+
async eccentricity(node, dir = 'out') {
|
|
1645
|
+
return this._analytics({ op: 'eccentricity', node, dir });
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* Diameter: max eccentricity across all nodes (longest shortest path).
|
|
1650
|
+
* @param {string} [dir='out'] - Direction
|
|
1651
|
+
* @returns {Promise<{diameter: number}>}
|
|
1652
|
+
*/
|
|
1653
|
+
async diameter(dir = 'out') {
|
|
1654
|
+
return this._analytics({ op: 'diameter', dir });
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/**
|
|
1658
|
+
* Find a cycle in the graph. Returns { cycle: [...] } or { cycle: [] } if acyclic.
|
|
1659
|
+
* @param {string} [dir='out'] - Direction
|
|
1660
|
+
*/
|
|
1661
|
+
async findCycle(dir = 'out') {
|
|
1662
|
+
return this._analytics({ op: 'find_cycle', dir });
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/**
|
|
1666
|
+
* Count 4-cycles (squares) incident to a node.
|
|
1667
|
+
* @param {number} node - Node ID
|
|
1668
|
+
* @param {string} [dir='out'] - Direction
|
|
1669
|
+
* @returns {Promise<{count: number}>}
|
|
1670
|
+
*/
|
|
1671
|
+
async squareCount(node, dir = 'out') {
|
|
1672
|
+
return this._analytics({ op: 'square_count', node, dir });
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/**
|
|
1676
|
+
* SimRank: structural similarity between two nodes.
|
|
1677
|
+
* @param {number} a - First node ID
|
|
1678
|
+
* @param {number} b - Second node ID
|
|
1679
|
+
* @param {number} [c=0.8] - Decay factor (0 < c < 1)
|
|
1680
|
+
* @param {number} [iterations=5] - Number of iterations
|
|
1681
|
+
* @returns {Promise<{score: number}>}
|
|
1682
|
+
*/
|
|
1683
|
+
async simrank(a, b, c = 0.8, iterations = 5) {
|
|
1684
|
+
return this._analytics({ op: 'simrank', a, b, c, iterations });
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// ── Community Detection ──────────────────────────────────────────────
|
|
1688
|
+
|
|
1689
|
+
/**
|
|
1690
|
+
* Label Propagation community detection.
|
|
1691
|
+
* @param {number} [iterations=10] - Max iterations
|
|
1692
|
+
* @returns {Promise<{assignments: [{node, community}], count}>}
|
|
1693
|
+
*/
|
|
1694
|
+
async labelPropagation(iterations = 10) {
|
|
1695
|
+
return this._analytics({ op: 'label_propagation', iterations });
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* Louvain modularity-based community detection.
|
|
1700
|
+
* @returns {Promise<{assignments: [{node, community}], modularity, count}>}
|
|
1701
|
+
*/
|
|
1702
|
+
async louvain() {
|
|
1703
|
+
return this._analytics({ op: 'louvain' });
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
/**
|
|
1707
|
+
* Graph coloring (greedy). Returns { coloring: [{node, color}], count }.
|
|
1708
|
+
*/
|
|
1709
|
+
async graphColoring() {
|
|
1710
|
+
return this._analytics({ op: 'graph_coloring' });
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/**
|
|
1714
|
+
* Minimum vertex cover approximation (2-approx).
|
|
1715
|
+
* @returns {Promise<{nodes: number[], count}>}
|
|
1716
|
+
*/
|
|
1717
|
+
async minVertexCover() {
|
|
1718
|
+
return this._analytics({ op: 'min_vertex_cover' });
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/**
|
|
1722
|
+
* Maximum independent set approximation (greedy).
|
|
1723
|
+
* @returns {Promise<{nodes: number[], count}>}
|
|
1724
|
+
*/
|
|
1725
|
+
async maxIndependentSet() {
|
|
1726
|
+
return this._analytics({ op: 'max_independent_set' });
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
/**
|
|
1730
|
+
* Bipartite projection: produce co-occurrence edges.
|
|
1731
|
+
* @param {string} [dir='out'] - Direction
|
|
1732
|
+
* @returns {Promise<{edges: [{a, b, weight}], count}>}
|
|
1733
|
+
*/
|
|
1734
|
+
async bipartiteProject(dir = 'out') {
|
|
1735
|
+
return this._analytics({ op: 'bipartite_project', dir });
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
// ── Degree Analytics ─────────────────────────────────────────────────
|
|
1739
|
+
|
|
1740
|
+
/**
|
|
1741
|
+
* Degree at a given percentile across all nodes.
|
|
1742
|
+
* @param {number} percentile - Percentile (0.0 to 1.0)
|
|
1743
|
+
* @param {string} [dir='out'] - Direction
|
|
1744
|
+
* @returns {Promise<{value: number}>}
|
|
1745
|
+
*/
|
|
1746
|
+
async degreePercentile(percentile, dir = 'out') {
|
|
1747
|
+
return this._analytics({ op: 'degree_percentile', percentile, dir });
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* Median degree across all nodes.
|
|
1752
|
+
* @param {string} [dir='out'] - Direction
|
|
1753
|
+
* @returns {Promise<{value: number}>}
|
|
1754
|
+
*/
|
|
1755
|
+
async degreeMedian(dir = 'out') {
|
|
1756
|
+
return this._analytics({ op: 'degree_median', dir });
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* Count nodes with degree strictly greater than threshold.
|
|
1761
|
+
* @param {number} threshold
|
|
1762
|
+
* @param {string} [dir='out'] - Direction
|
|
1763
|
+
* @returns {Promise<{count: number}>}
|
|
1764
|
+
*/
|
|
1765
|
+
async nodesWithDegreeGt(threshold, dir = 'out') {
|
|
1766
|
+
return this._analytics({ op: 'nodes_with_degree_gt', threshold, dir });
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/**
|
|
1770
|
+
* Count nodes with degree >= threshold.
|
|
1771
|
+
* @param {number} threshold
|
|
1772
|
+
* @param {string} [dir='out'] - Direction
|
|
1773
|
+
* @returns {Promise<{count: number}>}
|
|
1774
|
+
*/
|
|
1775
|
+
async nodesWithDegreeGte(threshold, dir = 'out') {
|
|
1776
|
+
return this._analytics({ op: 'nodes_with_degree_gte', threshold, dir });
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
/**
|
|
1780
|
+
* Count nodes with degree < threshold.
|
|
1781
|
+
* @param {number} threshold
|
|
1782
|
+
* @param {string} [dir='out'] - Direction
|
|
1783
|
+
* @returns {Promise<{count: number}>}
|
|
1784
|
+
*/
|
|
1785
|
+
async nodesWithDegreeLt(threshold, dir = 'out') {
|
|
1786
|
+
return this._analytics({ op: 'nodes_with_degree_lt', threshold, dir });
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* Count nodes with degree in [lo, hi] inclusive.
|
|
1791
|
+
* @param {number} lo - Lower bound
|
|
1792
|
+
* @param {number} hi - Upper bound
|
|
1793
|
+
* @param {string} [dir='out'] - Direction
|
|
1794
|
+
* @returns {Promise<{count: number}>}
|
|
1795
|
+
*/
|
|
1796
|
+
async nodesWithDegreeInRange(lo, hi, dir = 'out') {
|
|
1797
|
+
return this._analytics({ op: 'nodes_with_degree_in_range', lo, hi, dir });
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// ── Node Neighborhood Analytics ──────────────────────────────────────
|
|
1801
|
+
|
|
1802
|
+
/**
|
|
1803
|
+
* Neighbor at a given percentile of a node's sorted neighbor list.
|
|
1804
|
+
* @param {number} node - Node ID
|
|
1805
|
+
* @param {number} [percentile=0.5] - Percentile (0.0 to 1.0)
|
|
1806
|
+
* @param {string} [dir='out'] - Direction
|
|
1807
|
+
* @returns {Promise<{value: number}>}
|
|
1808
|
+
*/
|
|
1809
|
+
async neighborAtPercentile(node, percentile = 0.5, dir = 'out') {
|
|
1810
|
+
return this._analytics({ op: 'neighbor_at_percentile', node, percentile, dir });
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* Degree facets for each neighbor of a node.
|
|
1815
|
+
* @param {number} node - Node ID
|
|
1816
|
+
* @param {string} [dir='out'] - Direction
|
|
1817
|
+
* @returns {Promise<{facets: [{neighbor, degree}], count}>}
|
|
1818
|
+
*/
|
|
1819
|
+
async neighborDegreeFacets(node, dir = 'out') {
|
|
1820
|
+
return this._analytics({ op: 'neighbor_degree_facets', node, dir });
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
/**
|
|
1824
|
+
* Histogram of neighbor IDs bucketed by value.
|
|
1825
|
+
* @param {number} node - Node ID
|
|
1826
|
+
* @param {number} [bucketSize=10] - Width of each bucket
|
|
1827
|
+
* @param {string} [dir='out'] - Direction
|
|
1828
|
+
* @returns {Promise<{buckets: [{start, count}], bucket_count}>}
|
|
1829
|
+
*/
|
|
1830
|
+
async neighborHistogram(node, bucketSize = 10, dir = 'out') {
|
|
1831
|
+
return this._analytics({ op: 'neighbor_histogram', node, bucket_size: bucketSize, dir });
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// ── Multi-Label Traversal ────────────────────────────────────────────
|
|
1835
|
+
|
|
1836
|
+
/**
|
|
1837
|
+
* K-hop traversal across different labels at each hop.
|
|
1838
|
+
* @param {number} node - Starting node ID
|
|
1839
|
+
* @param {string[]} labels - Label sequence for each hop
|
|
1840
|
+
* @returns {Promise<{neighbors: number[], count}>}
|
|
1841
|
+
*/
|
|
1842
|
+
async kHopLabeled(node, labels) {
|
|
1843
|
+
return this._analytics({ op: 'k_hop_labeled', node, labels });
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
// ── Influence & Simulation ───────────────────────────────────────────
|
|
1847
|
+
|
|
1848
|
+
/**
|
|
1849
|
+
* Influence spread estimation (Independent Cascade model, Monte Carlo).
|
|
1850
|
+
* @param {number[]} seeds - Seed node IDs
|
|
1851
|
+
* @param {number} [probability=0.1] - Propagation probability per edge
|
|
1852
|
+
* @param {number} [simulations=100] - Number of Monte Carlo simulations
|
|
1853
|
+
* @returns {Promise<{spread: number}>}
|
|
1854
|
+
*/
|
|
1855
|
+
async influenceSpread(seeds, probability = 0.1, simulations = 100) {
|
|
1856
|
+
return this._analytics({ op: 'influence_spread', seeds, probability, simulations });
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// ── Bulk Operations ──────────────────────────────────────────────────
|
|
1860
|
+
|
|
1861
|
+
/**
|
|
1862
|
+
* Check multiple edges at once. Returns { results: [true/false, ...] }.
|
|
1863
|
+
* @param {Array<[number, number]>} pairs - Array of [src, dst] pairs
|
|
1864
|
+
*/
|
|
1865
|
+
async hasEdges(pairs) {
|
|
1866
|
+
return this._analytics({ op: 'has_edges', pairs });
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
/**
|
|
1870
|
+
* Batch-add multiple edges at once. Returns { added: count }.
|
|
1871
|
+
* @param {Array<[number, number]>} pairs - Array of [src, dst] pairs
|
|
1872
|
+
*/
|
|
1873
|
+
async addEdges(pairs) {
|
|
1874
|
+
return this._analytics({ op: 'add_edges', pairs });
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* Batch-remove multiple edges at once. Returns { removed: count }.
|
|
1879
|
+
* @param {Array<[number, number]>} pairs - Array of [src, dst] pairs
|
|
1880
|
+
*/
|
|
1881
|
+
async removeEdges(pairs) {
|
|
1882
|
+
return this._analytics({ op: 'remove_edges', pairs });
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/**
|
|
1886
|
+
* Get degrees of multiple nodes at once. Returns { degrees: [N, ...] }.
|
|
1887
|
+
* @param {number[]} nodes - Array of node IDs
|
|
1888
|
+
* @param {string} [dir='out'] - Direction
|
|
1889
|
+
*/
|
|
1890
|
+
async degrees(nodes, dir = 'out') {
|
|
1891
|
+
return this._analytics({ op: 'degrees', nodes, dir });
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/**
|
|
1895
|
+
* Pairwise Jaccard similarity matrix for a set of nodes.
|
|
1896
|
+
* Returns { matrix: [[...], ...] }: NxN symmetric matrix.
|
|
1897
|
+
* @param {number[]} nodes - Array of node IDs
|
|
1898
|
+
* @param {string} [dir='out'] - Direction
|
|
1899
|
+
*/
|
|
1900
|
+
async pairwiseJaccard(nodes, dir = 'out') {
|
|
1901
|
+
return this._analytics({ op: 'pairwise_jaccard', nodes, dir });
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1906
|
+
// Tenants: multi-tenant shard management
|
|
1907
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1908
|
+
|
|
1909
|
+
export class Tenants {
|
|
1910
|
+
constructor(http) {
|
|
1911
|
+
this.http = http;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
/**
|
|
1915
|
+
* Create a new tenant (shard).
|
|
1916
|
+
* Auto-registers a SyncManager for the new tenant.
|
|
1917
|
+
* @param {string} slug - Tenant slug (lowercase alphanumeric + hyphens, 1-32 chars)
|
|
1918
|
+
* @returns {Promise<{_id: number, slug: string, subdomain: string, created: boolean, sync?: {node_id: string}}>}
|
|
1919
|
+
*/
|
|
1920
|
+
async create(slug) {
|
|
1921
|
+
return this.http.req('POST', '/v1/tenants', { subdomain: slug });
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
/**
|
|
1925
|
+
* List all tenants with their sync engine status (tenant.list).
|
|
1926
|
+
* @returns {Promise<{data: Array<{_id: number, slug: string, sync: {node_id: string, gossip_mode: number, peers: number}|null}>, count: number, tenant_domains: string[]}>}
|
|
1927
|
+
*/
|
|
1928
|
+
async list() {
|
|
1929
|
+
return this.http.req('GET', '/v1/_tenants');
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1934
|
+
// ScatterHttp: Transport wrapper that routes queries through scatter endpoint
|
|
1935
|
+
//
|
|
1936
|
+
// This wraps the real Http client and intercepts query calls:
|
|
1937
|
+
// POST /v1/{collection}/_query → POST /v1/_mesh/scatter
|
|
1938
|
+
//
|
|
1939
|
+
// The Collection class is reused AS-IS: scatter is only a transport layer.
|
|
1940
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1941
|
+
|
|
1942
|
+
class ScatterHttp {
|
|
1943
|
+
/**
|
|
1944
|
+
* @param {Http} realHttp - The real HTTP client
|
|
1945
|
+
* @param {object} [opts] - Scatter options
|
|
1946
|
+
* @param {string} [opts.tenant='*'] - Tenant/namespace filter ('*' = all)
|
|
1947
|
+
* @param {number} [opts.timeout=5000] - Per-node timeout in ms
|
|
1948
|
+
* @param {number} [opts.limit] - Global result limit
|
|
1949
|
+
*/
|
|
1950
|
+
constructor(realHttp, opts = {}) {
|
|
1951
|
+
this._real = realHttp;
|
|
1952
|
+
this._opts = opts;
|
|
1953
|
+
// Forward properties needed by Collection/Query
|
|
1954
|
+
this._url = realHttp._url;
|
|
1955
|
+
this._tenant = realHttp._tenant;
|
|
1956
|
+
this._key = realHttp._key;
|
|
1957
|
+
this._timing = realHttp._timing;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
async req(method, path, body) {
|
|
1961
|
+
// Intercept POST /v1/{collection}/_query → scatter
|
|
1962
|
+
const m = path.match(/^\/v1\/([^/]+)\/_query$/);
|
|
1963
|
+
if (method === 'POST' && m) {
|
|
1964
|
+
const collection = m[1];
|
|
1965
|
+
const scatterBody = { collection };
|
|
1966
|
+
// Pass the query body through as-is
|
|
1967
|
+
if (body && Object.keys(body).length > 0) scatterBody.query = body;
|
|
1968
|
+
if (this._opts.tenant) scatterBody.tenant = this._opts.tenant;
|
|
1969
|
+
if (this._opts.timeout) scatterBody.timeout = this._opts.timeout;
|
|
1970
|
+
if (this._opts.limit) scatterBody.limit = this._opts.limit;
|
|
1971
|
+
return this._real.req('POST', '/v1/_mesh/scatter', scatterBody);
|
|
1972
|
+
}
|
|
1973
|
+
// Intercept POST /v1/{collection}/_qx → scatter with bytecode
|
|
1974
|
+
const mQx = path.match(/^\/v1\/([^/]+)\/_qx$/);
|
|
1975
|
+
if (method === 'POST' && mQx) {
|
|
1976
|
+
const collection = mQx[1];
|
|
1977
|
+
// JSON carries no bytes: raw bytecode travels as base64 here.
|
|
1978
|
+
const bytecode = body instanceof Uint8Array ? bytesToBase64(body) : body;
|
|
1979
|
+
const scatterBody = { collection, bytecode };
|
|
1980
|
+
if (this._opts.tenant) scatterBody.tenant = this._opts.tenant;
|
|
1981
|
+
if (this._opts.timeout) scatterBody.timeout = this._opts.timeout;
|
|
1982
|
+
if (this._opts.limit) scatterBody.limit = this._opts.limit;
|
|
1983
|
+
return this._real.req('POST', '/v1/_mesh/scatter', scatterBody);
|
|
1984
|
+
}
|
|
1985
|
+
// Everything else passes through to the real transport
|
|
1986
|
+
return this._real.req(method, path, body);
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1991
|
+
// ScatterScope: Returns Collection objects backed by ScatterHttp transport
|
|
1992
|
+
//
|
|
1993
|
+
// Usage (identical API surface as normal collections):
|
|
1994
|
+
// db.mesh.scatter.collection('users').find({ age: { $gte: 18 } })
|
|
1995
|
+
// db.mesh.scatter.collection('orders').pipeline([{ $group: ... }])
|
|
1996
|
+
// db.mesh.scatter.collection('logs').query().where({level:'error'}).limit(100).run()
|
|
1997
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1998
|
+
|
|
1999
|
+
class ScatterScope {
|
|
2000
|
+
/**
|
|
2001
|
+
* @param {Http} realHttp - The real HTTP client
|
|
2002
|
+
* @param {object} [opts] - Default scatter options
|
|
2003
|
+
*/
|
|
2004
|
+
constructor(realHttp, opts = {}) {
|
|
2005
|
+
this._http = new ScatterHttp(realHttp, { tenant: '*', ...opts });
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* Get a collection backed by scatter transport.
|
|
2010
|
+
* Same API as db.collection(): find, pipeline, query, count, search all work identically.
|
|
2011
|
+
*
|
|
2012
|
+
* @param {string} name - Collection name
|
|
2013
|
+
* @returns {Collection}
|
|
2014
|
+
*
|
|
2015
|
+
* @example
|
|
2016
|
+
* db.mesh.scatter.collection('users').find({ age: { $gte: 18 } })
|
|
2017
|
+
*/
|
|
2018
|
+
collection(name) {
|
|
2019
|
+
return new Collection(this._http, name);
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
/**
|
|
2023
|
+
* Create a new ScatterScope with custom options.
|
|
2024
|
+
* @param {object} opts - { tenant, timeout, limit }
|
|
2025
|
+
* @returns {ScatterScope}
|
|
2026
|
+
*
|
|
2027
|
+
* @example
|
|
2028
|
+
* db.mesh.scatter.with({ tenant: 'us-east', timeout: 10000 }).collection('users').find()
|
|
2029
|
+
*/
|
|
2030
|
+
with(opts) {
|
|
2031
|
+
return new ScatterScope(this._http._real, opts);
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2036
|
+
// Mesh: SubstrioMesh inter-node orchestration
|
|
2037
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2038
|
+
|
|
2039
|
+
|
|
2040
|
+
export class Mesh {
|
|
2041
|
+
constructor(http) {
|
|
2042
|
+
this.http = http;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// ── Scatter-Gather Query Proxy ──────────────────────────────────────
|
|
2046
|
+
|
|
2047
|
+
/**
|
|
2048
|
+
* Scatter-gather scope: returns a scope where collection() creates
|
|
2049
|
+
* scatter-backed collections with identical API to normal collections.
|
|
2050
|
+
*
|
|
2051
|
+
* @type {ScatterScope}
|
|
2052
|
+
*
|
|
2053
|
+
* @example
|
|
2054
|
+
* // Same API as db.collection(), but queries fan out across all mesh nodes:
|
|
2055
|
+
* const r = await db.mesh.scatter.collection('users').find({ age: { $gte: 18 } })
|
|
2056
|
+
* const r = await db.mesh.scatter.collection('orders').pipeline([{ $group: ... }])
|
|
2057
|
+
* const r = await db.mesh.scatter.collection('logs').query().where({level:'error'}).limit(100).run()
|
|
2058
|
+
*
|
|
2059
|
+
* // With custom options:
|
|
2060
|
+
* const r = await db.mesh.scatter.with({ tenant: 'us-east' }).collection('users').find()
|
|
2061
|
+
*/
|
|
2062
|
+
get scatter() {
|
|
2063
|
+
if (!this._scatter) this._scatter = new ScatterScope(this.http);
|
|
2064
|
+
return this._scatter;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
// ── Distributed Cloud Function Invocation ────────────────────────────
|
|
2068
|
+
|
|
2069
|
+
/**
|
|
2070
|
+
* Execute a Cloud Function on any mesh node: computation shipping.
|
|
2071
|
+
* The coordinator routes to whichever node holds the target tenant.
|
|
2072
|
+
*
|
|
2073
|
+
* @param {string} name - Function name to invoke
|
|
2074
|
+
* @param {object} [args={}] - Arguments passed as event.body to the CF
|
|
2075
|
+
* @param {object} [opts={}] - Options
|
|
2076
|
+
* @param {string} [opts.tenant] - Target tenant slug (default: SDK tenant)
|
|
2077
|
+
* @param {number} [opts.timeout] - Timeout in ms (default: 5000)
|
|
2078
|
+
* @returns {Promise<{result: any, tenant: string, node_id: string, timing: {dispatch_us: number, total_us: number}}>}
|
|
2079
|
+
*
|
|
2080
|
+
* @example
|
|
2081
|
+
* // Invoke on default tenant
|
|
2082
|
+
* const r = await db.mesh.invoke('processData', { ids: [1,2,3] })
|
|
2083
|
+
* console.log(r.result)
|
|
2084
|
+
*
|
|
2085
|
+
* // Invoke on specific tenant (runs where that tenant lives)
|
|
2086
|
+
* const r = await db.mesh.invoke('aggregate', { field: 'revenue' }, { tenant: 'us-east' })
|
|
2087
|
+
*
|
|
2088
|
+
* // With custom timeout
|
|
2089
|
+
* const r = await db.mesh.invoke('heavyCompute', data, { timeout: 30000 })
|
|
2090
|
+
*/
|
|
2091
|
+
async invoke(name, args = {}, opts = {}) {
|
|
2092
|
+
const body = { function: name, args };
|
|
2093
|
+
if (opts.tenant) body.tenant = opts.tenant;
|
|
2094
|
+
if (opts.timeout) body.timeout = opts.timeout;
|
|
2095
|
+
if (opts.nodes) body.nodes = opts.nodes;
|
|
2096
|
+
return this.http.req('POST', '/v1/_mesh/invoke', body);
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
// ── Core Operations ─────────────────────────────────────────────────
|
|
2100
|
+
|
|
2101
|
+
/**
|
|
2102
|
+
* Push (provision) a tenant onto remote nodes.
|
|
2103
|
+
* The tenant must exist locally first (create via db.tenants.create()).
|
|
2104
|
+
*
|
|
2105
|
+
* @param {string} slug - Tenant slug to push
|
|
2106
|
+
* @param {object} [opts] - Options
|
|
2107
|
+
* @param {string[]} [opts.nodes=['*']] - Target node addresses or ['*'] for all peers
|
|
2108
|
+
* @returns {Promise<{status: string, slug: string, target: string|string[]}>}
|
|
2109
|
+
*
|
|
2110
|
+
* @example
|
|
2111
|
+
* // Push to ALL connected peers
|
|
2112
|
+
* await db.mesh.push('acme-corp')
|
|
2113
|
+
*
|
|
2114
|
+
* // Push to specific nodes by address
|
|
2115
|
+
* await db.mesh.push('acme-corp', { nodes: ['10.0.1.1:9000', '10.0.2.1:9000'] })
|
|
2116
|
+
*/
|
|
2117
|
+
async push(slug, opts = {}) {
|
|
2118
|
+
const body = { slug };
|
|
2119
|
+
body.nodes = opts.nodes || ['*'];
|
|
2120
|
+
return this.http.req('POST', '/v1/_mesh/push', body);
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
/**
|
|
2124
|
+
* Drop (remove) a tenant from remote nodes.
|
|
2125
|
+
*
|
|
2126
|
+
* @param {string} slug - Tenant slug to drop (cannot be 'default')
|
|
2127
|
+
* @param {object} [opts] - Options
|
|
2128
|
+
* @param {string[]} [opts.nodes=['*']] - Target node addresses or ['*'] for all peers
|
|
2129
|
+
* @returns {Promise<{status: string, slug: string}>}
|
|
2130
|
+
*
|
|
2131
|
+
* @example
|
|
2132
|
+
* await db.mesh.drop('acme-corp')
|
|
2133
|
+
*/
|
|
2134
|
+
async drop(slug, opts = {}) {
|
|
2135
|
+
const body = { slug };
|
|
2136
|
+
body.nodes = opts.nodes || ['*'];
|
|
2137
|
+
return this.http.req('POST', '/v1/_mesh/drop', body);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
/**
|
|
2141
|
+
* List all tenants on this node with sync metadata.
|
|
2142
|
+
*
|
|
2143
|
+
* @returns {Promise<{node_id: string, port: number, tenants: Array<{slug: string, node_id?: string, oplog_seq?: string, peer_count?: number}>}>}
|
|
2144
|
+
*
|
|
2145
|
+
* @example
|
|
2146
|
+
* const { tenants } = await db.mesh.tenants()
|
|
2147
|
+
* tenants.forEach(t => console.log(t.slug, t.peer_count))
|
|
2148
|
+
*/
|
|
2149
|
+
async tenants() {
|
|
2150
|
+
return this.http.req('GET', '/v1/_mesh/tenants');
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/**
|
|
2154
|
+
* Get this node's mesh status (sync stats, peer count, RPC stats).
|
|
2155
|
+
*
|
|
2156
|
+
* @returns {Promise<{node_id: string, port: number, tenant_count: number, sync: object, peer_count: number, rpc: {handlers: number, pending: number}}>}
|
|
2157
|
+
*
|
|
2158
|
+
* @example
|
|
2159
|
+
* const s = await db.mesh.status()
|
|
2160
|
+
* console.log(`Node ${s.node_id}: ${s.tenant_count} tenants, ${s.peer_count} peers`)
|
|
2161
|
+
*/
|
|
2162
|
+
async status() {
|
|
2163
|
+
return this.http.req('GET', '/v1/_mesh/status');
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
// ── Convenience Methods ─────────────────────────────────────────────
|
|
2167
|
+
|
|
2168
|
+
/**
|
|
2169
|
+
* Create a tenant and immediately push it to remote nodes.
|
|
2170
|
+
* This is the most common workflow: one call to replicate a tenant
|
|
2171
|
+
* across your entire mesh or to specific nodes.
|
|
2172
|
+
*
|
|
2173
|
+
* @param {string} slug - Tenant slug to create and replicate
|
|
2174
|
+
* @param {object} [opts] - Options
|
|
2175
|
+
* @param {string[]} [opts.nodes=['*']] - Target nodes to push to (default: all peers)
|
|
2176
|
+
* @returns {Promise<{created: boolean, slug: string, pushed: object}>}
|
|
2177
|
+
*
|
|
2178
|
+
* @example
|
|
2179
|
+
* // Create 'acme-corp' and push to all peers: one line
|
|
2180
|
+
* await db.mesh.replicate('acme-corp')
|
|
2181
|
+
*
|
|
2182
|
+
* // Create and push to specific regional nodes
|
|
2183
|
+
* await db.mesh.replicate('acme-corp', { nodes: ['us-east:9000', 'eu-west:9000'] })
|
|
2184
|
+
*/
|
|
2185
|
+
async replicate(slug, opts = {}) {
|
|
2186
|
+
let createResult;
|
|
2187
|
+
try {
|
|
2188
|
+
createResult = await this.http.req('POST', '/v1/projects', { subdomain: slug });
|
|
2189
|
+
} catch (e) {
|
|
2190
|
+
// Tenant may already exist: that's OK, proceed with push
|
|
2191
|
+
createResult = e.body || { error: e.message };
|
|
2192
|
+
}
|
|
2193
|
+
let pushResult;
|
|
2194
|
+
try {
|
|
2195
|
+
pushResult = await this.push(slug, opts);
|
|
2196
|
+
} catch (e) {
|
|
2197
|
+
// Push may fail if SyncManager hasn't initialized yet for new tenant
|
|
2198
|
+
pushResult = e.body || { error: e.message };
|
|
2199
|
+
}
|
|
2200
|
+
const alreadyExists = createResult?.error?.includes('already exists') ||
|
|
2201
|
+
createResult?.error?.includes('Tenant already exists');
|
|
2202
|
+
return {
|
|
2203
|
+
created: !!(createResult?.created || createResult?.slug === slug || alreadyExists),
|
|
2204
|
+
slug,
|
|
2205
|
+
pushed: pushResult,
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
/**
|
|
2210
|
+
* Quick health check: returns true if this node is responsive and has
|
|
2211
|
+
* at least one tenant. Use for load balancer health probes or monitoring.
|
|
2212
|
+
*
|
|
2213
|
+
* @returns {Promise<boolean>}
|
|
2214
|
+
*
|
|
2215
|
+
* @example
|
|
2216
|
+
* if (await db.mesh.health()) console.log('Node is healthy')
|
|
2217
|
+
*/
|
|
2218
|
+
async health() {
|
|
2219
|
+
try {
|
|
2220
|
+
const s = await this.status();
|
|
2221
|
+
return !!(s && typeof s.tenant_count === 'number' && s.tenant_count > 0);
|
|
2222
|
+
} catch { return false; }
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
/**
|
|
2226
|
+
* Get the number of connected peer nodes.
|
|
2227
|
+
*
|
|
2228
|
+
* @returns {Promise<number>}
|
|
2229
|
+
*
|
|
2230
|
+
* @example
|
|
2231
|
+
* const count = await db.mesh.peerCount()
|
|
2232
|
+
* console.log(`Connected to ${count} peers`)
|
|
2233
|
+
*/
|
|
2234
|
+
async peerCount() {
|
|
2235
|
+
const s = await this.status();
|
|
2236
|
+
return s?.peer_count ?? 0;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2243
|
+
// Security: IP allowlists, rate limiting, and audit logging
|
|
2244
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2245
|
+
|
|
2246
|
+
export class Security {
|
|
2247
|
+
constructor(http) {
|
|
2248
|
+
this.http = http;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
// ── IP Allowlist ─────────────────────────────────────────────────────
|
|
2252
|
+
|
|
2253
|
+
/** IP allowlist management for api, mesh, and kv targets. */
|
|
2254
|
+
get allowlist() {
|
|
2255
|
+
const http = this.http;
|
|
2256
|
+
return {
|
|
2257
|
+
/** GET /v1/_security/allowlist: view all allowlist entries */
|
|
2258
|
+
list: () => http.req('GET', '/v1/_security/allowlist'),
|
|
2259
|
+
/** POST: add an IP or CIDR range to a target (api|mesh|kv) */
|
|
2260
|
+
add: (target, ip) => http.req('POST', '/v1/_security/allowlist', { target, ip }),
|
|
2261
|
+
/** DELETE: remove a specific IP from a target */
|
|
2262
|
+
remove: (target, ip) => http.req('DELETE', '/v1/_security/allowlist', { target, ip }),
|
|
2263
|
+
/** DELETE with clear:true: clear ALL entries for a target */
|
|
2264
|
+
clear: (target) => http.req('DELETE', '/v1/_security/allowlist', { target, clear: true }),
|
|
2265
|
+
};
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
// ── Rate Limiting ────────────────────────────────────────────────────
|
|
2269
|
+
|
|
2270
|
+
/** Rate limit configuration (per-IP token bucket). */
|
|
2271
|
+
get rateLimit() {
|
|
2272
|
+
const http = this.http;
|
|
2273
|
+
return {
|
|
2274
|
+
/** GET /v1/_security/rate_limit: view current config */
|
|
2275
|
+
get: () => http.req('GET', '/v1/_security/rate_limit'),
|
|
2276
|
+
/** POST: set default rate limit { max_tokens, tokens_per_second } */
|
|
2277
|
+
set: (config) => http.req('POST', '/v1/_security/rate_limit', config),
|
|
2278
|
+
/** POST: set per-IP override { ip, max_tokens, tokens_per_second } */
|
|
2279
|
+
setOverride: (ip, config) => http.req('POST', '/v1/_security/rate_limit', { ip, ...config }),
|
|
2280
|
+
/** DELETE: remove per-IP override */
|
|
2281
|
+
removeOverride: (ip) => http.req('DELETE', '/v1/_security/rate_limit', { ip }),
|
|
2282
|
+
/** DELETE with clear:true: reset all rate limits */
|
|
2283
|
+
clear: () => http.req('DELETE', '/v1/_security/rate_limit', { clear: true }),
|
|
2284
|
+
};
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
// ── Audit Log ────────────────────────────────────────────────────────
|
|
2288
|
+
|
|
2289
|
+
/** Authentication failures: a view of the audit trail (auth.failure records). Append-only. */
|
|
2290
|
+
get audit() {
|
|
2291
|
+
const http = this.http;
|
|
2292
|
+
return {
|
|
2293
|
+
/** GET /v1/_security/audit: list auth failures, newest first, with cursor paging */
|
|
2294
|
+
list: (opts = {}) => {
|
|
2295
|
+
const params = new URLSearchParams();
|
|
2296
|
+
for (const k of ['actor_kind', 'actor', 'since', 'until', 'order', 'limit', 'cursor']) {
|
|
2297
|
+
if (opts[k] !== undefined) params.set(k, String(opts[k]));
|
|
2298
|
+
}
|
|
2299
|
+
const q = params.toString();
|
|
2300
|
+
return http.req('GET', '/v1/_security/audit' + (q ? '?' + q : ''));
|
|
2301
|
+
},
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2307
|
+
// Substrio: the root client
|
|
2308
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2309
|
+
|
|
2310
|
+
export default class Substrio {
|
|
2311
|
+
/**
|
|
2312
|
+
* @param {object} config
|
|
2313
|
+
* @param {string} config.url - Server URL (e.g. 'http://localhost:9000')
|
|
2314
|
+
* @param {string} [config.key] - Master key (sets X-Substrio-Master-Key header)
|
|
2315
|
+
* @param {string} [config.token] - Bearer token from signup/login (sets Authorization header)
|
|
2316
|
+
* @param {string} [config.tenant] - Tenant slug (default: 'default')
|
|
2317
|
+
*/
|
|
2318
|
+
constructor(config) {
|
|
2319
|
+
if (!config?.url) throw new Error('Substrio: url is required');
|
|
2320
|
+
const tenant = config.tenant || 'default';
|
|
2321
|
+
const headers = { 'Content-Type': JSON_TYPE };
|
|
2322
|
+
headers['X-Substrio-Tenant'] = tenant;
|
|
2323
|
+
if (config.key) headers['X-Substrio-Master-Key'] = config.key;
|
|
2324
|
+
if (config.token) headers['Authorization'] = `Bearer ${config.token}`;
|
|
2325
|
+
this.http = new Http(config.url, headers, config.url, tenant, config.key);
|
|
2326
|
+
this.auth = new Auth(this.http, tenant);
|
|
2327
|
+
this.users = new Users(this.http);
|
|
2328
|
+
this.tenants = new Tenants(this.http);
|
|
2329
|
+
this.mesh = new Mesh(this.http);
|
|
2330
|
+
// Every registry operation, generated (substrio_operations.mjs):
|
|
2331
|
+
// client.ops.kv.get(input), and client.kv.get(input) where the
|
|
2332
|
+
// client has no member of the group's name.
|
|
2333
|
+
this.ops = bindOperations(this.http);
|
|
2334
|
+
attachOperations(this, this.ops);
|
|
2335
|
+
// Store config for WS connections
|
|
2336
|
+
this._url = config.url;
|
|
2337
|
+
this._tenant = tenant;
|
|
2338
|
+
this._key = config.key || null;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// ── Collection ref ────────────────────────────────────────────────────
|
|
2342
|
+
|
|
2343
|
+
/** Get a collection reference (lazy: no HTTP call). */
|
|
2344
|
+
collection(name) {
|
|
2345
|
+
return new Collection(this.http, name);
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
// ── Thread management ─────────────────────────────────────────────────
|
|
2349
|
+
|
|
2350
|
+
/**
|
|
2351
|
+
* Create a new thread or get a ref to an existing one.
|
|
2352
|
+
* - If called with a string: returns a lazy Thread ref (no HTTP).
|
|
2353
|
+
* - If called with an object: creates a new thread (HTTP POST).
|
|
2354
|
+
*
|
|
2355
|
+
* @param {string|Object} opts - Thread ID (string) or creation options (object)
|
|
2356
|
+
* @param {string} [opts.system_prompt] - System prompt for AI conversations
|
|
2357
|
+
* @param {string} [opts.model] - LLM model override
|
|
2358
|
+
* @param {string[]} [opts.rag_collections] - Collections for RAG context
|
|
2359
|
+
* @param {string} [opts.tool_preset] - AI tool access level: 'read_only' (default), 'analyst', 'curator', 'operator'
|
|
2360
|
+
* @param {string[]} [opts.write_collections] - Collections AI can write to (curator/operator only)
|
|
2361
|
+
* @param {number} [opts.tool_budget_max_calls] - Max tool calls per _ask (default 10)
|
|
2362
|
+
* @param {number} [opts.tool_budget_max_writes] - Max write operations per _ask (default 3)
|
|
2363
|
+
* @param {string} [opts.memory_collection] - Collection for AI agent persistent memory
|
|
2364
|
+
* @returns {Promise<Thread>}
|
|
2365
|
+
*/
|
|
2366
|
+
async thread(opts) {
|
|
2367
|
+
if (typeof opts === 'string') return new Thread(this.http, opts);
|
|
2368
|
+
const data = await this.ops.thread.create(opts || {});
|
|
2369
|
+
return new Thread(this.http, data.id, data);
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
/** List threads. */
|
|
2373
|
+
async threads(opts) {
|
|
2374
|
+
return this.ops.thread.list(opts);
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
// ── Multi-collection search ───────────────────────────────────────────
|
|
2378
|
+
|
|
2379
|
+
/** Search across multiple collections with RRF fusion. */
|
|
2380
|
+
async search(collections, query, opts = {}) {
|
|
2381
|
+
const body = { collections, query };
|
|
2382
|
+
if (opts.limit !== undefined) body.limit = opts.limit;
|
|
2383
|
+
if (opts.k !== undefined) body.search_k = opts.k;
|
|
2384
|
+
if (opts.mode) body.mode = opts.mode;
|
|
2385
|
+
return this.http.req('POST', '/v1/_multi_search', body);
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
// ── Batch / Transactions ──────────────────────────────────────────────
|
|
2389
|
+
|
|
2390
|
+
/**
|
|
2391
|
+
* Execute multiple operations atomically (or non-atomically).
|
|
2392
|
+
* @param {Array<{op: 'insert'|'update'|'delete', collection: string, doc?: object, id?: string|number, data?: object}>} ops
|
|
2393
|
+
* @param {object} [opts]
|
|
2394
|
+
* @param {boolean} [opts.atomic=true] - If true, all-or-nothing with rollback on failure
|
|
2395
|
+
* @returns {Promise<{ok: boolean, atomic: boolean, count: number, results: Array}>}
|
|
2396
|
+
*/
|
|
2397
|
+
async batch(ops, opts = {}) {
|
|
2398
|
+
const atomic = opts.atomic !== undefined ? opts.atomic : true;
|
|
2399
|
+
return this.ops.batch.execute({ atomic, ops });
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
// ── Inline JS Execution ($execJS) ─────────────────────────────────────
|
|
2403
|
+
|
|
2404
|
+
/**
|
|
2405
|
+
* Execute inline JavaScript with full platform access.
|
|
2406
|
+
* The JS code runs in a sandboxed JSC context with a `substrio` object
|
|
2407
|
+
* providing synchronous access to db operations and auth.
|
|
2408
|
+
*
|
|
2409
|
+
* @param {string} code - JavaScript code to execute (use `return` to return values)
|
|
2410
|
+
* @returns {Promise<{result: any, exec_us: number}>}
|
|
2411
|
+
*
|
|
2412
|
+
* @example
|
|
2413
|
+
* // Simple computation
|
|
2414
|
+
* await db.exec('return 1 + 2'); // { result: 3, exec_us: ... }
|
|
2415
|
+
*
|
|
2416
|
+
* // Insert + query in single request
|
|
2417
|
+
* await db.exec(`
|
|
2418
|
+
* substrio.db.insert("users", { name: "Alice", age: 30 });
|
|
2419
|
+
* return substrio.db.query("users");
|
|
2420
|
+
* `);
|
|
2421
|
+
*/
|
|
2422
|
+
async exec(code) {
|
|
2423
|
+
return this.ops.function.execute_inline({ code });
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
// ── Lua Scripting Engine ──────────────────────────────────────────────
|
|
2427
|
+
|
|
2428
|
+
/**
|
|
2429
|
+
* Execute a Lua script on the server via the LuaJIT scripting engine.
|
|
2430
|
+
* @param {string} script - Lua source code to execute
|
|
2431
|
+
* @param {string[]} [keys=[]] - KEYS array passed to the script
|
|
2432
|
+
* @param {string[]} [args=[]] - ARGV array passed to the script
|
|
2433
|
+
* @returns {Promise<{result: any}>}
|
|
2434
|
+
*/
|
|
2435
|
+
async luaEval(script, keys = [], args = []) {
|
|
2436
|
+
return this.http.req('POST', '/v1/eval', { script, keys, args });
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
/**
|
|
2440
|
+
* Execute a previously-loaded Lua script by SHA1 digest.
|
|
2441
|
+
* @param {string} sha - SHA1 hex digest of the script
|
|
2442
|
+
* @param {string[]} [keys=[]] - KEYS array passed to the script
|
|
2443
|
+
* @param {string[]} [args=[]] - ARGV array passed to the script
|
|
2444
|
+
* @returns {Promise<{result: any}>}
|
|
2445
|
+
*/
|
|
2446
|
+
async luaEvalSha(sha, keys = [], args = []) {
|
|
2447
|
+
return this.http.req('POST', '/v1/eval', { sha, keys, args });
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
/**
|
|
2451
|
+
* Load a Lua script into the server's script cache.
|
|
2452
|
+
* @param {string} script - Lua source code
|
|
2453
|
+
* @returns {Promise<{sha: string}>}
|
|
2454
|
+
*/
|
|
2455
|
+
async luaLoad(script) {
|
|
2456
|
+
return this.http.req('POST', '/v1/eval', { script, load: true });
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
// ── Health ────────────────────────────────────────────────────────────
|
|
2460
|
+
|
|
2461
|
+
async health() {
|
|
2462
|
+
return this.ops.system.health();
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
// ── Collections metadata ──────────────────────────────────────────────
|
|
2466
|
+
|
|
2467
|
+
async collections() {
|
|
2468
|
+
const r = await this.http.req('GET', '/v1/_meta/collections');
|
|
2469
|
+
return r.collections || r;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
// ── OpenAPI / Swagger ─────────────────────────────────────────────────
|
|
2473
|
+
|
|
2474
|
+
async openapi() {
|
|
2475
|
+
return this.ops.documentation.openapi();
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
// ── Gossip Stats ─────────────────────────────────────────────────────
|
|
2479
|
+
|
|
2480
|
+
async gossipStats() {
|
|
2481
|
+
const r = await this.http.req('GET', '/v1/_sync');
|
|
2482
|
+
return r.gossip || {};
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
// ── Secrets ───────────────────────────────────────────────────────────
|
|
2486
|
+
|
|
2487
|
+
get secrets() {
|
|
2488
|
+
if (!this._secrets) this._secrets = new Secrets(this.http, this.ops);
|
|
2489
|
+
return this._secrets;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
|
|
2493
|
+
|
|
2494
|
+
// ── Triggers ──────────────────────────────────────────────────────────
|
|
2495
|
+
|
|
2496
|
+
get triggers() {
|
|
2497
|
+
if (!this._triggers) this._triggers = new Resource(this.http, '/v1/_triggers', {
|
|
2498
|
+
list: this.ops.trigger.list, create: this.ops.trigger.create,
|
|
2499
|
+
delete: this.ops.trigger.delete, patch: this.ops.trigger.update,
|
|
2500
|
+
});
|
|
2501
|
+
return this._triggers;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
// ── Webhooks ──────────────────────────────────────────────────────────
|
|
2505
|
+
|
|
2506
|
+
get webhooks() {
|
|
2507
|
+
if (!this._webhooks) this._webhooks = new Webhooks(this.http, this.ops);
|
|
2508
|
+
return this._webhooks;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
// ── Cron Jobs ─────────────────────────────────────────────────────────
|
|
2512
|
+
|
|
2513
|
+
get crons() {
|
|
2514
|
+
if (!this._crons) this._crons = new Resource(this.http, '/v1/_cron', {
|
|
2515
|
+
list: this.ops.schedule.list, create: this.ops.schedule.create,
|
|
2516
|
+
delete: this.ops.schedule.delete, patch: this.ops.schedule.update,
|
|
2517
|
+
});
|
|
2518
|
+
return this._crons;
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
// ── Views (scheduled) ─────────────────────────────────────────────────
|
|
2522
|
+
|
|
2523
|
+
get views() {
|
|
2524
|
+
if (!this._views) this._views = new Views(this.http, '/v1/_views', this.ops.view, false);
|
|
2525
|
+
return this._views;
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
// ── Live Views ────────────────────────────────────────────────────────
|
|
2529
|
+
|
|
2530
|
+
get live() {
|
|
2531
|
+
if (!this._live) this._live = new Views(this.http, '/v1/_live_views', this.ops.live_view, true);
|
|
2532
|
+
return this._live;
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
// ── Backups ───────────────────────────────────────────────────────────
|
|
2536
|
+
|
|
2537
|
+
get backups() {
|
|
2538
|
+
if (!this._backups) this._backups = new Backups(this.http, this.ops);
|
|
2539
|
+
return this._backups;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
// ── Jobs ──────────────────────────────────────────────────────────────
|
|
2543
|
+
|
|
2544
|
+
async job(id) {
|
|
2545
|
+
return this.ops.job.get({ id });
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
// ── Rate Limits ───────────────────────────────────────────────────────
|
|
2549
|
+
|
|
2550
|
+
get limits() {
|
|
2551
|
+
if (!this._limits) this._limits = new Resource(this.http, '/v1/_ratelimit', {
|
|
2552
|
+
list: this.ops.rate_limit.list, create: this.ops.rate_limit.set, delete: this.ops.rate_limit.delete,
|
|
2553
|
+
});
|
|
2554
|
+
return this._limits;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
// ── Security Rules ───────────────────────────────────────────────────
|
|
2558
|
+
|
|
2559
|
+
get rules() {
|
|
2560
|
+
if (!this._rules) {
|
|
2561
|
+
const rules = this.ops.security.rules;
|
|
2562
|
+
this._rules = {
|
|
2563
|
+
set: (config) => rules.set(config),
|
|
2564
|
+
get: () => rules.get(),
|
|
2565
|
+
/** Delete security rules. */
|
|
2566
|
+
delete: () => rules.delete(),
|
|
2567
|
+
};
|
|
2568
|
+
}
|
|
2569
|
+
return this._rules;
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
// ── Security Admin ───────────────────────────────────────────────────
|
|
2573
|
+
|
|
2574
|
+
get security() {
|
|
2575
|
+
if (!this._security) this._security = new Security(this.http);
|
|
2576
|
+
return this._security;
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
// ── Logs ──────────────────────────────────────────────────────────────
|
|
2580
|
+
|
|
2581
|
+
get logs() {
|
|
2582
|
+
if (!this._logs) this._logs = new Logs(this.http, this.ops);
|
|
2583
|
+
return this._logs;
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
// ── Files ─────────────────────────────────────────────────────────────
|
|
2587
|
+
|
|
2588
|
+
get files() {
|
|
2589
|
+
if (!this._files) this._files = new Files(this.http, this.ops);
|
|
2590
|
+
return this._files;
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// ── Cloud Functions ───────────────────────────────────────────────────
|
|
2594
|
+
|
|
2595
|
+
/** Cloud functions sub-object: db.functions.list(), .invoke(name, body), etc. */
|
|
2596
|
+
get functions() {
|
|
2597
|
+
if (!this._functions) this._functions = new Functions(this.http, this.ops);
|
|
2598
|
+
return this._functions;
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
// ── Deploy ────────────────────────────────────────────────────────────
|
|
2602
|
+
|
|
2603
|
+
async deploy(payload) {
|
|
2604
|
+
return this.ops.function.deploy(payload);
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
// ── Roles ─────────────────────────────────────────────────────────────
|
|
2608
|
+
|
|
2609
|
+
get roles() {
|
|
2610
|
+
if (!this._roles) this._roles = new Resource(this.http, '/v1/_roles', {
|
|
2611
|
+
list: this.ops.role.list, create: this.ops.role.create,
|
|
2612
|
+
delete: this.ops.role.delete, patch: this.ops.role.update,
|
|
2613
|
+
});
|
|
2614
|
+
return this._roles;
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
// ── Projects/Tenants ──────────────────────────────────────────────────
|
|
2618
|
+
|
|
2619
|
+
get projects() {
|
|
2620
|
+
if (!this._projects) this._projects = new Resource(this.http, '/v1/projects', {
|
|
2621
|
+
list: this.ops.tenant.list, create: this.ops.tenant.create, get: this.ops.tenant.get,
|
|
2622
|
+
delete: this.ops.tenant.delete, patch: this.ops.tenant.update,
|
|
2623
|
+
});
|
|
2624
|
+
return this._projects;
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
/** Convenience: create a project/tenant. Alias for db.projects.create(cfg). */
|
|
2628
|
+
async createProject(cfg) { return this.projects.create(cfg); }
|
|
2629
|
+
|
|
2630
|
+
/** Convenience: list all tenants/projects. Alias for db.projects.list(). */
|
|
2631
|
+
async listTenants(opts) { return this.projects.list(opts); }
|
|
2632
|
+
|
|
2633
|
+
/** Convenience: create a collection with options (e.g. acl_flags). */
|
|
2634
|
+
async createCollection(cfg) {
|
|
2635
|
+
const name = cfg.name;
|
|
2636
|
+
if (!name) throw new Error('createCollection: name is required');
|
|
2637
|
+
return this.http.req('POST', `/v1/_meta/collections`, cfg);
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
// ── SubstrioLink Sync ─────────────────────────────────────────────────────
|
|
2641
|
+
|
|
2642
|
+
get sync() {
|
|
2643
|
+
if (!this._sync) this._sync = new Sync(this.http);
|
|
2644
|
+
return this._sync;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
// ── Tools manifest (AI agent discovery) ───────────────────────────────
|
|
2648
|
+
|
|
2649
|
+
|
|
2650
|
+
// ── AI: grouped sub-object for AI config, audit, tools ─────────────
|
|
2651
|
+
|
|
2652
|
+
/**
|
|
2653
|
+
* AI sub-object: db.ai.config, db.ai.audit(filters), db.ai.tools()
|
|
2654
|
+
*
|
|
2655
|
+
* @example
|
|
2656
|
+
* const cfg = await db.ai.config.get();
|
|
2657
|
+
* await db.ai.config.patch({ defaults: { tool_budget_max_calls: 30 } });
|
|
2658
|
+
* const events = await db.ai.audit({ limit: 10 });
|
|
2659
|
+
* const tools = await db.ai.tools();
|
|
2660
|
+
*/
|
|
2661
|
+
get ai() {
|
|
2662
|
+
if (!this._ai) {
|
|
2663
|
+
const http = this.http;
|
|
2664
|
+
this._ai = {
|
|
2665
|
+
/** AI configuration sub-object: .get(), .set(cfg), .patch(cfg), .delete() */
|
|
2666
|
+
config: {
|
|
2667
|
+
/** Get resolved AI config (global + tenant merged) */
|
|
2668
|
+
get: () => http.req('GET', '/v1/_ai/config'),
|
|
2669
|
+
/** Set full tenant AI config (replaces existing) */
|
|
2670
|
+
set: (cfg) => http.req('POST', '/v1/_ai/config', cfg),
|
|
2671
|
+
/** Merge partial update into tenant config */
|
|
2672
|
+
patch: (cfg) => http.req('PATCH', '/v1/_ai/config', cfg),
|
|
2673
|
+
/** Remove tenant override (revert to global) */
|
|
2674
|
+
delete: () => http.req('DELETE', '/v1/_ai/config'),
|
|
2675
|
+
},
|
|
2676
|
+
/** Query the AI agent audit trail. */
|
|
2677
|
+
audit: async (filters = {}) => {
|
|
2678
|
+
const params = new URLSearchParams();
|
|
2679
|
+
if (filters.context_id) params.set('context_id', filters.context_id);
|
|
2680
|
+
if (filters.tool_name) params.set('tool_name', filters.tool_name);
|
|
2681
|
+
if (filters.since) params.set('since', String(filters.since));
|
|
2682
|
+
if (filters.limit) params.set('limit', String(filters.limit));
|
|
2683
|
+
const qs = params.toString();
|
|
2684
|
+
return http.req('GET', '/v1/_ai/audit' + (qs ? '?' + qs : ''));
|
|
2685
|
+
},
|
|
2686
|
+
/** Get available AI tools manifest. */
|
|
2687
|
+
tools: () => http.req('GET', '/v1/_tools'),
|
|
2688
|
+
};
|
|
2689
|
+
}
|
|
2690
|
+
return this._ai;
|
|
2691
|
+
}
|
|
2692
|
+
// ── Models ────────────────────────────────────────────────────────────
|
|
2693
|
+
|
|
2694
|
+
async models() {
|
|
2695
|
+
return this.ops.ai.models();
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
// ── Stats ─────────────────────────────────────────────────────────────
|
|
2699
|
+
|
|
2700
|
+
async stats() {
|
|
2701
|
+
return this.http.req('GET', '/v1/realtime/stats');
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// ── WebSocket: Realtime Subscriptions ──────────────────────────────────
|
|
2705
|
+
|
|
2706
|
+
/** Create a Realtime pub/sub connection (WebSocket). */
|
|
2707
|
+
realtime() {
|
|
2708
|
+
return new Realtime(this._url, this._tenant, this._key);
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
// ── WebSocket: Query Streaming ─────────────────────────────────────────
|
|
2712
|
+
|
|
2713
|
+
/** Create a WS query connection (lower-latency alternative to HTTP). */
|
|
2714
|
+
wsQuery() {
|
|
2715
|
+
return new WsQuery(this._url, this._tenant, this._key);
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
/**
|
|
2719
|
+
* Open a binary KV WebSocket connection for ultra-low-latency key-value ops.
|
|
2720
|
+
* @param {string} store - Collection/store name
|
|
2721
|
+
* @returns {Promise<KvWs>} Ready-to-use KvWs client with registered collection
|
|
2722
|
+
*/
|
|
2723
|
+
async kvWs(store) {
|
|
2724
|
+
const kv = new KvWs(this._url, this._tenant, this._key);
|
|
2725
|
+
await kv.ready;
|
|
2726
|
+
await kv.register(store);
|
|
2727
|
+
return kv;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
/**
|
|
2731
|
+
* Open a raw TCP KV connection (text protocol, Memcached-compatible).
|
|
2732
|
+
* Zero WebSocket overhead: direct TCP with string-based parsing.
|
|
2733
|
+
* Ideal for high-throughput KV workloads on localhost.
|
|
2734
|
+
*
|
|
2735
|
+
* @param {string} [store] - KV store/collection name (default: uses server default '_kv_default')
|
|
2736
|
+
* @param {number} [port=9001] - SubstrioWire TCP port
|
|
2737
|
+
* @param {string} [host] - Server host (extracted from url by default)
|
|
2738
|
+
* @returns {Promise<KvTcp>} Ready-to-use KvTcp client
|
|
2739
|
+
*/
|
|
2740
|
+
async kvTcp(store, port = 9001, host) {
|
|
2741
|
+
const h = host || new URL(this._url).hostname;
|
|
2742
|
+
const kv = new KvTcp(h, port, this._tenant, this._key);
|
|
2743
|
+
await kv.ready;
|
|
2744
|
+
if (store) await kv.use(store);
|
|
2745
|
+
return kv;
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
/**
|
|
2749
|
+
* Open a binary SubstrioWire TCP KV connection with configurable in-flight depth.
|
|
2750
|
+
* Depth is a client-side sliding window, not server-side batching.
|
|
2751
|
+
*
|
|
2752
|
+
* @param {string} [store] - KV store/collection name
|
|
2753
|
+
* @param {number} [port=9001] - SubstrioWire TCP port
|
|
2754
|
+
* @param {string} [host] - Server host (extracted from url by default)
|
|
2755
|
+
* @param {object} [options] - Binary TCP client options
|
|
2756
|
+
* @param {number} [options.maxInFlight=16] - Max outstanding requests per socket
|
|
2757
|
+
* @returns {Promise<KvWireTcp>} Ready-to-use binary SubstrioWire client
|
|
2758
|
+
*/
|
|
2759
|
+
async kvWire(store, port = 9001, host, options = {}) {
|
|
2760
|
+
const h = host || new URL(this._url).hostname;
|
|
2761
|
+
const kv = new KvWireTcp(h, port, this._tenant, this._key, options);
|
|
2762
|
+
await kv.ready;
|
|
2763
|
+
if (store) await kv.register(store);
|
|
2764
|
+
return kv;
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
// ── HTTP KV ───────────────────────────────────────────────────────────
|
|
2768
|
+
|
|
2769
|
+
/**
|
|
2770
|
+
* Get a scoped HTTP KV client for a store.
|
|
2771
|
+
* @param {string} store - KV store/collection name
|
|
2772
|
+
* @returns {KvHttp} Scoped client with set/get/delete methods
|
|
2773
|
+
*
|
|
2774
|
+
* @example
|
|
2775
|
+
* const kv = db.kv('session');
|
|
2776
|
+
* await kv.set([['user:1', 'alice'], ['user:2', 'bob']]);
|
|
2777
|
+
* const r = await kv.get(['user:1', 'user:2']);
|
|
2778
|
+
* await kv.delete(['user:1']);
|
|
2779
|
+
*/
|
|
2780
|
+
kv(store) {
|
|
2781
|
+
return new KvHttp(this.http, store);
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
// ── Graph API ─────────────────────────────────────────────────────────
|
|
2785
|
+
|
|
2786
|
+
/**
|
|
2787
|
+
* Get a scoped Graph client for a labeled edge set.
|
|
2788
|
+
* @param {string} label - Graph edge label (e.g. 'follows', 'likes')
|
|
2789
|
+
* @returns {Graph} Scoped client with addEdge/removeEdge/hasEdge/neighbors/degree/stats
|
|
2790
|
+
*
|
|
2791
|
+
* @example
|
|
2792
|
+
* const g = db.graph('follows');
|
|
2793
|
+
* await g.addEdge(1, 2); // 1 → 2
|
|
2794
|
+
* await g.neighbors(1); // [2]
|
|
2795
|
+
* await g.hasEdge(1, 2); // true
|
|
2796
|
+
* await g.degree(1); // { out_degree: 1, in_degree: 0 }
|
|
2797
|
+
* await g.removeEdge(1, 2);
|
|
2798
|
+
*/
|
|
2799
|
+
graph(label) {
|
|
2800
|
+
return new Graph(this.http, label);
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
/**
|
|
2804
|
+
* List all graph labels across the tenant.
|
|
2805
|
+
* @returns {Promise<{labels: string[], count: number}>}
|
|
2806
|
+
*/
|
|
2807
|
+
async graphLabels() {
|
|
2808
|
+
return this.http.req('GET', '/v1/_graph');
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
|
|
2812
|
+
// ── GC / Meta ─────────────────────────────────────────────────────────
|
|
2813
|
+
|
|
2814
|
+
/** Get garbage collection status. */
|
|
2815
|
+
async gc() {
|
|
2816
|
+
return this.http.req('GET', '/v1/_meta/gc');
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
/** Trigger garbage collection. */
|
|
2820
|
+
async runGc() {
|
|
2821
|
+
return this.http.req('POST', '/v1/_meta/gc');
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
|
|
2825
|
+
|
|
2826
|
+
// ── Transactions ──────────────────────────────────────────────────────
|
|
2827
|
+
|
|
2828
|
+
/**
|
|
2829
|
+
* Run an atomic transaction. All operations succeed or all are rolled back.
|
|
2830
|
+
* @param {Array} ops - Array of operation objects:
|
|
2831
|
+
* { op: 'insert', collection: 'users', doc: { ... } }
|
|
2832
|
+
* { op: 'update', collection: 'users', id: 'xxx', data: { $set: { ... } } }
|
|
2833
|
+
* { op: 'delete', collection: 'users', id: 'xxx' }
|
|
2834
|
+
* @returns {{ ok: boolean, count: number, results: Array }}
|
|
2835
|
+
*/
|
|
2836
|
+
async transaction(ops) {
|
|
2837
|
+
if (!Array.isArray(ops) || ops.length === 0) {
|
|
2838
|
+
throw new Error('transaction requires a non-empty array of operations');
|
|
2839
|
+
}
|
|
2840
|
+
return this.http.req('POST', '/v1/_batch', { atomic: true, ops });
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
/**
|
|
2844
|
+
* Close this Substrio instance and release all resources.
|
|
2845
|
+
* Aborts in-flight HTTP requests (tears down keep-alive sockets),
|
|
2846
|
+
* closes any cached KV TCP/WS connections and Realtime subscriptions.
|
|
2847
|
+
* The instance should not be used after calling close().
|
|
2848
|
+
*/
|
|
2849
|
+
close() {
|
|
2850
|
+
this.http.close();
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
// =========================================================================
|
|
2854
|
+
// Enterprise Type Factories
|
|
2855
|
+
// Static methods for creating the typed JSON wrappers the server stores
|
|
2856
|
+
// typed on insert (src/substrio/core/substrio_typed_value.h).
|
|
2857
|
+
// =========================================================================
|
|
2858
|
+
|
|
2859
|
+
/** Create a DATE typed value. Accepts Date instance, ISO string, or ms-epoch number; sends ms-epoch. */
|
|
2860
|
+
static date(val) {
|
|
2861
|
+
const ms = val instanceof Date ? val.getTime() : typeof val === 'string' ? Date.parse(val) : val;
|
|
2862
|
+
if (!Number.isInteger(ms)) throw new TypeError(`Substrio.date: not a valid date: ${val}`);
|
|
2863
|
+
return { $date: ms };
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
/** Create an INTEGER typed value. For exact int64 values (including > 2^53). */
|
|
2867
|
+
static int(val) { return { $int: val }; }
|
|
2868
|
+
|
|
2869
|
+
/** Create a DECIMAL typed value. For financial/monetary exact decimal. */
|
|
2870
|
+
static decimal(val) { return { $decimal: String(val) }; }
|
|
2871
|
+
|
|
2872
|
+
/** Create a UUID typed value. Auto-generates if no value provided. */
|
|
2873
|
+
static uuid(val) { return { $uuid: val ?? globalThis.crypto?.randomUUID?.() ?? _generateUUID() }; }
|
|
2874
|
+
|
|
2875
|
+
/** Create a BINARY typed value from a base64 string. */
|
|
2876
|
+
static binary(val) { return { $binary: val }; }
|
|
2877
|
+
|
|
2878
|
+
/** Create a REGEX typed value with pattern and optional flags. */
|
|
2879
|
+
static regex(pattern, options) { return { $regex: { pattern, options: options ?? '' } }; }
|
|
2880
|
+
|
|
2881
|
+
/**
|
|
2882
|
+
* Create a BITMAP typed value. Accepts:
|
|
2883
|
+
* - An integer bitmask (e.g. 0b1010, 255, 0xFF)
|
|
2884
|
+
* - An array of booleans (packed into integer, index 0 = bit 0)
|
|
2885
|
+
*/
|
|
2886
|
+
static bitmap(val) {
|
|
2887
|
+
if (Array.isArray(val)) {
|
|
2888
|
+
let bits = 0;
|
|
2889
|
+
for (let i = 0; i < val.length; i++) {
|
|
2890
|
+
if (val[i]) bits |= (1 << i);
|
|
2891
|
+
}
|
|
2892
|
+
return { $bitmap: bits };
|
|
2893
|
+
}
|
|
2894
|
+
return { $bitmap: val };
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2899
|
+
// Sync: SubstrioLink sync engine control (KCP/UDP transport)
|
|
2900
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2901
|
+
|
|
2902
|
+
class Sync {
|
|
2903
|
+
constructor(http) { this.http = http; }
|
|
2904
|
+
|
|
2905
|
+
/** Get sync status (gossip mode, peers, collections, oplog). */
|
|
2906
|
+
async status() { return this.http.req('GET', '/v1/_sync'); }
|
|
2907
|
+
|
|
2908
|
+
/** Generic action dispatch. */
|
|
2909
|
+
async action(payload) { return this.http.req('POST', '/v1/_sync', payload); }
|
|
2910
|
+
|
|
2911
|
+
/** Set gossip mode: full_mesh, write_only, receive_only, paused. */
|
|
2912
|
+
async gossipMode(mode) { return this.action({ action: 'set_gossip_mode', mode }); }
|
|
2913
|
+
|
|
2914
|
+
/** Connect to a peer by address (host:port). Optional token for auth. */
|
|
2915
|
+
async connectPeer(address, token) {
|
|
2916
|
+
const payload = { action: 'connect_peer', address };
|
|
2917
|
+
if (token) payload.token = token;
|
|
2918
|
+
return this.action(payload);
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
/** Disconnect a peer by node ID. */
|
|
2922
|
+
async disconnectPeer(peerId) { return this.action({ action: 'disconnect_peer', peer_id: peerId }); }
|
|
2923
|
+
|
|
2924
|
+
/** Remove a peer permanently. */
|
|
2925
|
+
async removePeer(peerId) { return this.action({ action: 'remove_peer', peer_id: peerId }); }
|
|
2926
|
+
|
|
2927
|
+
/** Set per-peer state: active, send_only, receive_only, paused, disconnected. */
|
|
2928
|
+
async peerState(peerId, state) { return this.action({ action: 'set_peer_state', peer_id: peerId, state }); }
|
|
2929
|
+
|
|
2930
|
+
/** Enable sync for a collection. opts: { conflict: 'lww'|'counter_merge'|'max_wins' } */
|
|
2931
|
+
async collectionSync(collection, opts = {}) {
|
|
2932
|
+
return this.action({ action: 'set_collection_sync', collection, ...opts });
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
/** Disable sync for a collection. */
|
|
2936
|
+
async removeCollectionSync(collection) {
|
|
2937
|
+
return this.action({ action: 'remove_collection_sync', collection });
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
/** Create a link token for peer discovery. */
|
|
2941
|
+
async linkToken() { return this.action({ action: 'create_link_token' }); }
|
|
2942
|
+
|
|
2943
|
+
/** Join a cluster using a link token. */
|
|
2944
|
+
async joinToken(token) { return this.action({ action: 'join_with_token', token }); }
|
|
2945
|
+
|
|
2946
|
+
/** Update node config: sync_port, max_peers, etc. */
|
|
2947
|
+
async nodeConfig(cfg) { return this.action({ action: 'set_node_config', ...cfg }); }
|
|
2948
|
+
|
|
2949
|
+
/** Get current node config. */
|
|
2950
|
+
async getNodeConfig() { return this.action({ action: 'get_node_config' }); }
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2954
|
+
// Resource: reusable base for all REST resources
|
|
2955
|
+
// Every resource follows the same predictable API:
|
|
2956
|
+
// resource.list() → GET
|
|
2957
|
+
// resource.create(config) → POST
|
|
2958
|
+
// resource.get(id) → GET /:id
|
|
2959
|
+
// resource.delete(id) → DELETE /:id
|
|
2960
|
+
// resource.patch(id, data) → PATCH /:id
|
|
2961
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2962
|
+
|
|
2963
|
+
// The HTTP method of each resource verb: the route a registry operation
|
|
2964
|
+
// with several (secret.write: POST, PUT, DELETE) is called on.
|
|
2965
|
+
const VIA = Object.freeze({
|
|
2966
|
+
list: Object.freeze({ method: 'GET' }), create: Object.freeze({ method: 'POST' }),
|
|
2967
|
+
get: Object.freeze({ method: 'GET' }), delete: Object.freeze({ method: 'DELETE' }),
|
|
2968
|
+
patch: Object.freeze({ method: 'PATCH' }),
|
|
2969
|
+
});
|
|
2970
|
+
|
|
2971
|
+
// Calls `operation` on the record `id` names: the path parameter (id,
|
|
2972
|
+
// name, slug, key) of its route for `via`'s method takes it.
|
|
2973
|
+
function onRecord(operation, id, data, via) {
|
|
2974
|
+
const routes = operation.operation.routes;
|
|
2975
|
+
const route = routes.find((r) => r.method === via.method && r.params.length) ?? routes.find((r) => r.params.length);
|
|
2976
|
+
return operation({ ...data, [route.params[0]]: id }, via);
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
/**
|
|
2980
|
+
* A resource's verbs, each its registry operation (`ops`: verb -> a
|
|
2981
|
+
* generated operation of client.ops) where the registry has one, else the
|
|
2982
|
+
* resource's own route under `path`.
|
|
2983
|
+
*/
|
|
2984
|
+
class Resource {
|
|
2985
|
+
constructor(http, path, ops = {}) { this.http = http; this.path = path; this.ops = ops; }
|
|
2986
|
+
|
|
2987
|
+
async list(opts) {
|
|
2988
|
+
return this.ops.list ? this.ops.list(opts, VIA.list) : this.http.req('GET', `${this.path}${qs(opts)}`);
|
|
2989
|
+
}
|
|
2990
|
+
async create(config) {
|
|
2991
|
+
return this.ops.create ? this.ops.create(config, VIA.create) : this.http.req('POST', this.path, config);
|
|
2992
|
+
}
|
|
2993
|
+
async get(id) {
|
|
2994
|
+
return this.ops.get ? onRecord(this.ops.get, id, {}, VIA.get)
|
|
2995
|
+
: this.http.req('GET', `${this.path}/${encodeURIComponent(id)}`);
|
|
2996
|
+
}
|
|
2997
|
+
/** Delete a resource by ID. */
|
|
2998
|
+
async delete(id) {
|
|
2999
|
+
return this.ops.delete ? onRecord(this.ops.delete, id, {}, VIA.delete)
|
|
3000
|
+
: this.http.req('DELETE', `${this.path}/${encodeURIComponent(id)}`);
|
|
3001
|
+
}
|
|
3002
|
+
async patch(id, data) {
|
|
3003
|
+
return this.ops.patch ? onRecord(this.ops.patch, id, data, VIA.patch)
|
|
3004
|
+
: this.http.req('PATCH', `${this.path}/${encodeURIComponent(id)}`, data);
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
// ── Secrets (custom: .set(name, value) instead of .create({...})) ─────────
|
|
3009
|
+
|
|
3010
|
+
class Secrets extends Resource {
|
|
3011
|
+
constructor(http, ops) {
|
|
3012
|
+
super(http, '/v1/_secrets', { list: ops.secret.list, create: ops.secret.write, delete: ops.secret.write });
|
|
3013
|
+
}
|
|
3014
|
+
async set(name, value) { return this.ops.create({ key: name, value }, VIA.create); }
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
// ── Webhooks (custom: .logs(id)) ──────────────────────────────────────────
|
|
3018
|
+
|
|
3019
|
+
class Webhooks extends Resource {
|
|
3020
|
+
constructor(http, ops) {
|
|
3021
|
+
const w = ops.webhook;
|
|
3022
|
+
super(http, '/v1/_webhooks', { list: w.list, create: w.create, get: w.get, delete: w.delete, patch: w.update });
|
|
3023
|
+
this.logsOf = w.logs;
|
|
3024
|
+
}
|
|
3025
|
+
async logs(id) { return onRecord(this.logsOf, id, {}, VIA.get); }
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
// ── Views (auto-normalizes 'collection' → 'source_collection') ───────────
|
|
3029
|
+
|
|
3030
|
+
class Views extends Resource {
|
|
3031
|
+
/** `group` is ops.view (scheduled views) or ops.live_view (live views). */
|
|
3032
|
+
constructor(http, path, group, live) {
|
|
3033
|
+
super(http, path, { list: group.list, create: group.create, delete: group.delete, patch: group.update });
|
|
3034
|
+
this.dataOf = group.get;
|
|
3035
|
+
this.live = live;
|
|
3036
|
+
}
|
|
3037
|
+
async create(config) {
|
|
3038
|
+
const body = { ...config };
|
|
3039
|
+
// Live views use 'source_collection', scheduled views use 'collection'
|
|
3040
|
+
if (this.live && body.collection && !body.source_collection) {
|
|
3041
|
+
body.source_collection = body.collection;
|
|
3042
|
+
delete body.collection;
|
|
3043
|
+
}
|
|
3044
|
+
return super.create(body);
|
|
3045
|
+
}
|
|
3046
|
+
async data(name) { return onRecord(this.dataOf, name, {}, VIA.get); }
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
// ── Backups (custom: .restore(id)) ────────────────────────────────────────
|
|
3050
|
+
|
|
3051
|
+
class Backups extends Resource {
|
|
3052
|
+
constructor(http, ops) {
|
|
3053
|
+
const b = ops.backup;
|
|
3054
|
+
super(http, '/v1/_backups', { list: b.list, create: b.create, get: b.get, delete: b.delete });
|
|
3055
|
+
this.restoreOf = b.restore;
|
|
3056
|
+
}
|
|
3057
|
+
async restore(id) { return onRecord(this.restoreOf, id, {}, VIA.create); }
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
// ── Logs ───────────────────────────────────────────────────────────────────
|
|
3061
|
+
|
|
3062
|
+
class Logs extends Resource {
|
|
3063
|
+
constructor(http, ops) { super(http, '/v1/_logs', { list: ops.function.logs }); }
|
|
3064
|
+
async clear() { return this.ops.list.clear(); }
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
// ── Files (custom upload with content-type headers) ───────────────────────
|
|
3068
|
+
|
|
3069
|
+
class Files extends Resource {
|
|
3070
|
+
constructor(http, ops) {
|
|
3071
|
+
const f = ops.file;
|
|
3072
|
+
super(http, '/v1/files', { list: f.list, get: f.download, delete: f.delete });
|
|
3073
|
+
}
|
|
3074
|
+
async upload(data, filename, contentType) {
|
|
3075
|
+
const extra = {};
|
|
3076
|
+
if (contentType) extra['Content-Type'] = contentType;
|
|
3077
|
+
if (filename) extra['X-Filename'] = filename;
|
|
3078
|
+
return this.http.req('POST', this.path, data, extra);
|
|
3079
|
+
}
|
|
3080
|
+
async download(id) { return this.get(id); }
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
// ── Cloud Functions ───────────────────────────────────────────────────────
|
|
3084
|
+
|
|
3085
|
+
class Functions extends Resource {
|
|
3086
|
+
constructor(http, ops) {
|
|
3087
|
+
const f = ops.function;
|
|
3088
|
+
super(http, '/v1/functions', { list: f.list, create: f.deploy, get: f.get, delete: f.delete, patch: f.update });
|
|
3089
|
+
}
|
|
3090
|
+
/** Deploy a cloud function. @param {{ name: string, script: string }} config */
|
|
3091
|
+
async deploy(config) { return this.create(config); }
|
|
3092
|
+
async invoke(name, body) {
|
|
3093
|
+
return this.http.req('POST', `/fn/${encodeURIComponent(name)}`, body);
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3098
|
+
// Realtime: WebSocket pub/sub subscriptions (/v1/realtime)
|
|
3099
|
+
//
|
|
3100
|
+
// Usage:
|
|
3101
|
+
// const rt = db.realtime();
|
|
3102
|
+
// await rt.ready;
|
|
3103
|
+
// rt.subscribe({ collection: 'posts', field: 'city', value: 'NYC' });
|
|
3104
|
+
// rt.on('notification', ev => console.log(ev));
|
|
3105
|
+
// rt.close();
|
|
3106
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3107
|
+
|
|
3108
|
+
class Realtime {
|
|
3109
|
+
constructor(url, tenant, key) {
|
|
3110
|
+
const wsUrl = url.replace(/^http/, 'ws');
|
|
3111
|
+
const headers = { 'X-Substrio-Tenant': tenant };
|
|
3112
|
+
if (key) headers['X-Substrio-Master-Key'] = key;
|
|
3113
|
+
this._handlers = {};
|
|
3114
|
+
this._ws = new WebSocket(`${wsUrl}/v1/realtime`, { headers });
|
|
3115
|
+
this._ws.onmessage = (ev) => {
|
|
3116
|
+
try {
|
|
3117
|
+
const msg = JSON.parse(ev.data);
|
|
3118
|
+
const type = msg.type || (msg.error ? 'error' : 'message');
|
|
3119
|
+
if (this._handlers[type]) this._handlers[type].forEach(fn => fn(msg));
|
|
3120
|
+
if (this._handlers['*']) this._handlers['*'].forEach(fn => fn(msg));
|
|
3121
|
+
} catch { }
|
|
3122
|
+
};
|
|
3123
|
+
let readyReject;
|
|
3124
|
+
this._ws.onerror = (ev) => {
|
|
3125
|
+
if (readyReject) { readyReject(ev); readyReject = null; }
|
|
3126
|
+
if (this._handlers.error) this._handlers.error.forEach(fn => fn(ev));
|
|
3127
|
+
};
|
|
3128
|
+
this.ready = new Promise((resolve, reject) => {
|
|
3129
|
+
readyReject = reject;
|
|
3130
|
+
this._ws.onopen = () => { readyReject = null; resolve(this); };
|
|
3131
|
+
});
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
/** Register handler: type = 'connected'|'pong'|'subscribed'|'notification'|'error'|'*' */
|
|
3135
|
+
on(type, fn) {
|
|
3136
|
+
(this._handlers[type] ||= []).push(fn);
|
|
3137
|
+
return this;
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
/** Send a raw message. */
|
|
3141
|
+
send(msg) {
|
|
3142
|
+
this._ws.send(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
/** Subscribe to a field-value pair on a collection. */
|
|
3146
|
+
subscribe(opts) {
|
|
3147
|
+
this.send({ type: 'subscribe', ...opts });
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
/** Subscribe to ALL changes (insert/update/delete) on a collection. */
|
|
3151
|
+
subscribeCollection(collection) {
|
|
3152
|
+
this.send({ type: 'subscribe_collection', collection });
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
/** Unsubscribe from collection-level change stream. */
|
|
3156
|
+
unsubscribeCollection(collection) {
|
|
3157
|
+
this.send({ type: 'unsubscribe_collection', collection });
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
/**
|
|
3161
|
+
* Batch subscribe to multiple field-value pairs. The 'subscribed_batch'
|
|
3162
|
+
* reply answers every entry in request order: results[i] is { topic } or
|
|
3163
|
+
* { code, error } ('invalid_subscription', 'subscription_limit'); count is
|
|
3164
|
+
* how many were taken.
|
|
3165
|
+
*/
|
|
3166
|
+
subscribeBatch(subscriptions) {
|
|
3167
|
+
this.send({ type: 'subscribe_batch', subscriptions });
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
/** Send ping. */
|
|
3171
|
+
ping() { this.send({ type: 'ping' }); }
|
|
3172
|
+
|
|
3173
|
+
/** Close the connection. */
|
|
3174
|
+
close() {
|
|
3175
|
+
if (this._ws.readyState <= 1) this._ws.close();
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3180
|
+
// ChangeStream: CDC / Resumable Change Stream
|
|
3181
|
+
//
|
|
3182
|
+
// Usage:
|
|
3183
|
+
// const stream = db.col('orders').watch((event) => {
|
|
3184
|
+
// console.log(event.operation, event._seq);
|
|
3185
|
+
// }, { resumeAfter: '12345' });
|
|
3186
|
+
// stream.resumeToken; // latest _seq seen, or null: none sent (no change history)
|
|
3187
|
+
// stream.close();
|
|
3188
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3189
|
+
|
|
3190
|
+
class ChangeStream {
|
|
3191
|
+
/**
|
|
3192
|
+
* @param {string} url - Substrio server URL
|
|
3193
|
+
* @param {string} tenant - Tenant slug
|
|
3194
|
+
* @param {string} key - Master key
|
|
3195
|
+
* @param {string} collection - Collection name
|
|
3196
|
+
* @param {function} callback - Event handler: receives {type, collection, operation, _seq, _ts, ...}
|
|
3197
|
+
* @param {object} [options] - { resumeAfter: string }
|
|
3198
|
+
*/
|
|
3199
|
+
constructor(url, tenant, key, collection, callback, options = {}) {
|
|
3200
|
+
this._url = url;
|
|
3201
|
+
this._tenant = tenant;
|
|
3202
|
+
this._key = key;
|
|
3203
|
+
this._collection = collection;
|
|
3204
|
+
this._callback = callback;
|
|
3205
|
+
this._resumeAfter = options.resumeAfter || null;
|
|
3206
|
+
// The newest _seq received: a position in the app's change history,
|
|
3207
|
+
// the only thing a stream resumes after. null while none came: an
|
|
3208
|
+
// app without change history sends no _seq, and such a stream
|
|
3209
|
+
// reconnects as a new subscription, never resumes (B109ag).
|
|
3210
|
+
this._resumeToken = this._resumeAfter;
|
|
3211
|
+
this._closed = false;
|
|
3212
|
+
this._reconnectAttempts = 0;
|
|
3213
|
+
this._maxReconnectDelay = 30000;
|
|
3214
|
+
this._ws = null;
|
|
3215
|
+
this._subscribeAck = null;
|
|
3216
|
+
|
|
3217
|
+
// Ready promise resolves when first subscribe_collection ack received
|
|
3218
|
+
this.ready = new Promise((resolve, reject) => {
|
|
3219
|
+
this._readyResolve = resolve;
|
|
3220
|
+
this._readyReject = reject;
|
|
3221
|
+
});
|
|
3222
|
+
|
|
3223
|
+
this._connect();
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
/** Latest _seq received (a history position), or null when none came. Use as resumeAfter on reconnect. */
|
|
3227
|
+
get resumeToken() { return this._resumeToken; }
|
|
3228
|
+
|
|
3229
|
+
/** @private Keeps the newest _seq of a message; one without _seq changes nothing. */
|
|
3230
|
+
_advanceResumeToken(msg) {
|
|
3231
|
+
if (msg._seq === undefined) return;
|
|
3232
|
+
if (this._resumeToken === null || BigInt(msg._seq) > BigInt(this._resumeToken)) {
|
|
3233
|
+
this._resumeToken = msg._seq;
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
/** Number of events replayed on subscribe (from resumeAfter). */
|
|
3238
|
+
get replayedCount() { return this._replayedCount || 0; }
|
|
3239
|
+
|
|
3240
|
+
/** Close the change stream permanently. */
|
|
3241
|
+
close() {
|
|
3242
|
+
this._closed = true;
|
|
3243
|
+
if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
|
|
3244
|
+
if (this._ws) {
|
|
3245
|
+
// Remove event handlers before closing to prevent onclose triggering reconnect
|
|
3246
|
+
this._ws.onclose = null;
|
|
3247
|
+
this._ws.onmessage = null;
|
|
3248
|
+
this._ws.onerror = null;
|
|
3249
|
+
if (this._ws.readyState <= 1) this._ws.close();
|
|
3250
|
+
this._ws = null;
|
|
3251
|
+
}
|
|
3252
|
+
// Null out references to prevent structured clone issues in test runners
|
|
3253
|
+
this._callback = null;
|
|
3254
|
+
this._readyResolve = null;
|
|
3255
|
+
this._readyReject = null;
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
/** @private */
|
|
3259
|
+
_connect() {
|
|
3260
|
+
if (this._closed) return;
|
|
3261
|
+
|
|
3262
|
+
const wsUrl = this._url.replace(/^http/, 'ws');
|
|
3263
|
+
const headers = { 'X-Substrio-Tenant': this._tenant };
|
|
3264
|
+
if (this._key) headers['X-Substrio-Master-Key'] = this._key;
|
|
3265
|
+
|
|
3266
|
+
this._ws = new WebSocket(`${wsUrl}/v1/realtime`, { headers });
|
|
3267
|
+
|
|
3268
|
+
this._ws.onopen = () => {
|
|
3269
|
+
this._reconnectAttempts = 0;
|
|
3270
|
+
// Subscribe with optional resumeAfter
|
|
3271
|
+
const msg = { type: 'subscribe_collection', collection: this._collection };
|
|
3272
|
+
if (this._resumeAfter) {
|
|
3273
|
+
msg.resumeAfter = String(this._resumeAfter);
|
|
3274
|
+
}
|
|
3275
|
+
this._ws.send(JSON.stringify(msg));
|
|
3276
|
+
};
|
|
3277
|
+
|
|
3278
|
+
this._ws.onmessage = (ev) => {
|
|
3279
|
+
try {
|
|
3280
|
+
const msg = JSON.parse(ev.data);
|
|
3281
|
+
|
|
3282
|
+
if (msg.type === 'subscribed_collection') {
|
|
3283
|
+
// Ack: the replay count, and _seq on a history stream
|
|
3284
|
+
this._subscribeAck = msg;
|
|
3285
|
+
this._replayedCount = Number(msg._replayed || 0);
|
|
3286
|
+
this._advanceResumeToken(msg);
|
|
3287
|
+
if (this._readyResolve) {
|
|
3288
|
+
this._readyResolve(this);
|
|
3289
|
+
this._readyResolve = null;
|
|
3290
|
+
}
|
|
3291
|
+
return;
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
if (msg.type === 'change') {
|
|
3295
|
+
this._advanceResumeToken(msg);
|
|
3296
|
+
this._callback(msg);
|
|
3297
|
+
return;
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
if (msg.type === 'error') {
|
|
3301
|
+
// A typed refusal, e.g. history_not_kept: the resume point
|
|
3302
|
+
// is not in the kept history, so the changes since it
|
|
3303
|
+
// cannot be replayed. The token is dropped (a reconnect
|
|
3304
|
+
// never resumes from it again) and ready rejects: the
|
|
3305
|
+
// caller reads the collection again.
|
|
3306
|
+
this._resumeToken = null;
|
|
3307
|
+
this._resumeAfter = null;
|
|
3308
|
+
if (this._readyReject) {
|
|
3309
|
+
this._readyReject(new SubstrioError(0, msg.code || '', msg.error || 'change stream refused', msg));
|
|
3310
|
+
this._readyReject = null;
|
|
3311
|
+
this._readyResolve = null;
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
} catch { }
|
|
3315
|
+
};
|
|
3316
|
+
|
|
3317
|
+
this._ws.onerror = (ev) => {
|
|
3318
|
+
if (this._readyReject) {
|
|
3319
|
+
this._readyReject(ev);
|
|
3320
|
+
this._readyReject = null;
|
|
3321
|
+
}
|
|
3322
|
+
};
|
|
3323
|
+
|
|
3324
|
+
this._ws.onclose = () => {
|
|
3325
|
+
if (this._closed) return;
|
|
3326
|
+
// Auto-reconnect with exponential backoff: resumes after the last
|
|
3327
|
+
// _seq, or subscribes anew when the stream sent none.
|
|
3328
|
+
this._resumeAfter = this._resumeToken;
|
|
3329
|
+
const delay = Math.min(1000 * Math.pow(2, this._reconnectAttempts), this._maxReconnectDelay);
|
|
3330
|
+
this._reconnectAttempts++;
|
|
3331
|
+
this._reconnectTimer = setTimeout(() => this._connect(), delay);
|
|
3332
|
+
};
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3337
|
+
// WsQuery: WebSocket query streaming (/v1/query/ws)
|
|
3338
|
+
//
|
|
3339
|
+
// Usage:
|
|
3340
|
+
// const ws = db.wsQuery();
|
|
3341
|
+
// await ws.ready;
|
|
3342
|
+
// const result = await ws.query({ where: {} });
|
|
3343
|
+
// ws.close();
|
|
3344
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3345
|
+
|
|
3346
|
+
class WsQuery {
|
|
3347
|
+
constructor(url, tenant, key) {
|
|
3348
|
+
const wsUrl = url.replace(/^http/, 'ws');
|
|
3349
|
+
const headers = { 'X-Substrio-Tenant': tenant };
|
|
3350
|
+
if (key) headers['X-Substrio-Master-Key'] = key;
|
|
3351
|
+
this._ws = new WebSocket(`${wsUrl}/v1/query/ws`, { headers });
|
|
3352
|
+
this._pending = null;
|
|
3353
|
+
this._ws.onmessage = (ev) => {
|
|
3354
|
+
if (this._pending) {
|
|
3355
|
+
try {
|
|
3356
|
+
this._pending.resolve(JSON.parse(ev.data));
|
|
3357
|
+
} catch (e) {
|
|
3358
|
+
this._pending.resolve(ev.data);
|
|
3359
|
+
}
|
|
3360
|
+
this._pending = null;
|
|
3361
|
+
}
|
|
3362
|
+
};
|
|
3363
|
+
this._ws.onerror = (ev) => {
|
|
3364
|
+
if (this._pending) { this._pending.reject(ev); this._pending = null; }
|
|
3365
|
+
};
|
|
3366
|
+
this.ready = new Promise((resolve, reject) => {
|
|
3367
|
+
this._ws.onopen = () => resolve(this);
|
|
3368
|
+
this._ws.onerror = (e) => reject(e);
|
|
3369
|
+
});
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
/** Send a query and await the response. */
|
|
3373
|
+
query(body) {
|
|
3374
|
+
return new Promise((resolve, reject) => {
|
|
3375
|
+
this._pending = { resolve, reject };
|
|
3376
|
+
this._ws.send(JSON.stringify(body));
|
|
3377
|
+
});
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
/** Close the connection. */
|
|
3381
|
+
close() {
|
|
3382
|
+
if (this._ws.readyState <= 1) this._ws.close();
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3387
|
+
// KvWs: Binary KV WebSocket client (/v1/kv/ws)
|
|
3388
|
+
//
|
|
3389
|
+
// Zero-overhead binary protocol. Uses ArrayBuffer/DataView for zero-copy.
|
|
3390
|
+
// No JSON, no Buffer, no string intermediaries in the hot path.
|
|
3391
|
+
//
|
|
3392
|
+
// Usage:
|
|
3393
|
+
// const kv = await db.kvWs('my_store');
|
|
3394
|
+
// await kv.put([['k1','v1'], ['k2','v2']]);
|
|
3395
|
+
// const vals = await kv.get(['k1','k2']); // Map { 'k1' => 'v1', ... }
|
|
3396
|
+
// await kv.delete(['k1']);
|
|
3397
|
+
// kv.close();
|
|
3398
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3399
|
+
|
|
3400
|
+
const _kvEnc = new TextEncoder();
|
|
3401
|
+
const _kvDec = new TextDecoder();
|
|
3402
|
+
|
|
3403
|
+
export class KvWs {
|
|
3404
|
+
static OP_PUT = 0x01;
|
|
3405
|
+
static OP_GET = 0x02;
|
|
3406
|
+
static OP_DEL = 0x03;
|
|
3407
|
+
static OP_REG = 0x10;
|
|
3408
|
+
static OP_ERR = 0xFF;
|
|
3409
|
+
|
|
3410
|
+
constructor(url, tenant, key) {
|
|
3411
|
+
const wsUrl = url.replace(/^http/, 'ws');
|
|
3412
|
+
const headers = { 'X-Substrio-Tenant': tenant };
|
|
3413
|
+
if (key) headers['X-Substrio-Master-Key'] = key;
|
|
3414
|
+
this._ws = new WebSocket(`${wsUrl}/v1/kv/ws`, { headers });
|
|
3415
|
+
this._ws.binaryType = 'arraybuffer';
|
|
3416
|
+
this._pending = null;
|
|
3417
|
+
this._colId = 0;
|
|
3418
|
+
// Pre-allocated reusable send buffer (64KB): eliminates per-op ArrayBuffer+DataView alloc
|
|
3419
|
+
this._reuseBuf = new ArrayBuffer(65536);
|
|
3420
|
+
this._reuseDv = new DataView(this._reuseBuf);
|
|
3421
|
+
this._reuseU8 = new Uint8Array(this._reuseBuf);
|
|
3422
|
+
this._ws.onmessage = (ev) => {
|
|
3423
|
+
if (this._pending) {
|
|
3424
|
+
const p = this._pending;
|
|
3425
|
+
this._pending = null;
|
|
3426
|
+
// Zero-copy: use ArrayBuffer directly from WebSocket frame
|
|
3427
|
+
p.resolve(new Uint8Array(ev.data instanceof ArrayBuffer ? ev.data : ev.data.buffer || ev.data));
|
|
3428
|
+
}
|
|
3429
|
+
};
|
|
3430
|
+
this._ws.onerror = (ev) => {
|
|
3431
|
+
if (this._pending) { const p = this._pending; this._pending = null; p.reject(ev); }
|
|
3432
|
+
};
|
|
3433
|
+
this.ready = new Promise((resolve, reject) => {
|
|
3434
|
+
this._ws.onopen = () => resolve(this);
|
|
3435
|
+
this._ws.onerror = (e) => reject(e);
|
|
3436
|
+
});
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
_send(buf, len) {
|
|
3440
|
+
return new Promise((resolve, reject) => {
|
|
3441
|
+
this._pending = { resolve, reject };
|
|
3442
|
+
// If len provided, send a subarray view (reusable buffer path)
|
|
3443
|
+
if (len !== undefined) {
|
|
3444
|
+
this._ws.send(new Uint8Array(buf, 0, len));
|
|
3445
|
+
} else {
|
|
3446
|
+
this._ws.send(buf);
|
|
3447
|
+
}
|
|
3448
|
+
});
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
/** Register a collection → get 1-byte handle. */
|
|
3452
|
+
async register(name) {
|
|
3453
|
+
const nb = _kvEnc.encode(name);
|
|
3454
|
+
const buf = new ArrayBuffer(4 + nb.length);
|
|
3455
|
+
const dv = new DataView(buf);
|
|
3456
|
+
dv.setUint8(0, 0x10); dv.setUint8(1, 0);
|
|
3457
|
+
dv.setUint16(2, nb.length, true);
|
|
3458
|
+
new Uint8Array(buf).set(nb, 4);
|
|
3459
|
+
const r = await this._send(buf);
|
|
3460
|
+
if (r[0] !== 0x10 || r[2] !== 1) throw new Error('KvWs: register failed');
|
|
3461
|
+
this._colId = r[1];
|
|
3462
|
+
return this._colId;
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
/** PUT [[key,val],...] → number of successes. */
|
|
3466
|
+
async put(pairs) {
|
|
3467
|
+
const enc = new Array(pairs.length);
|
|
3468
|
+
let sz = 4;
|
|
3469
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
3470
|
+
const k = _kvEnc.encode(pairs[i][0]), v = _kvEnc.encode(pairs[i][1]);
|
|
3471
|
+
enc[i] = { k, v }; sz += 6 + k.length + v.length;
|
|
3472
|
+
}
|
|
3473
|
+
// Reuse pre-allocated buffer when payload fits (hot path for single-key ops)
|
|
3474
|
+
const reuse = sz <= 65536;
|
|
3475
|
+
const dv = reuse ? this._reuseDv : new DataView(new ArrayBuffer(sz));
|
|
3476
|
+
const u = reuse ? this._reuseU8 : new Uint8Array(dv.buffer);
|
|
3477
|
+
dv.setUint8(0, 0x01); dv.setUint8(1, this._colId);
|
|
3478
|
+
dv.setUint16(2, pairs.length, true);
|
|
3479
|
+
let o = 4;
|
|
3480
|
+
for (const { k, v } of enc) {
|
|
3481
|
+
dv.setUint16(o, k.length, true); o += 2;
|
|
3482
|
+
dv.setUint32(o, v.length, true); o += 4;
|
|
3483
|
+
u.set(k, o); o += k.length;
|
|
3484
|
+
u.set(v, o); o += v.length;
|
|
3485
|
+
}
|
|
3486
|
+
const r = await this._send(dv.buffer, reuse ? sz : undefined);
|
|
3487
|
+
if (r[0] === 0xFF) throw new Error('KvWs PUT: ' + _kvDec.decode(r.subarray(3)));
|
|
3488
|
+
const cnt = r[1] | (r[2] << 8);
|
|
3489
|
+
let ok = 0;
|
|
3490
|
+
for (let i = 0; i < cnt; i++) if (r[3 + (i >> 3)] & (1 << (i & 7))) ok++;
|
|
3491
|
+
return ok;
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
/** GET [key,...] → Map<string, string|null>. */
|
|
3495
|
+
async get(keys) {
|
|
3496
|
+
const kb = new Array(keys.length);
|
|
3497
|
+
let sz = 4;
|
|
3498
|
+
for (let i = 0; i < keys.length; i++) { kb[i] = _kvEnc.encode(keys[i]); sz += 2 + kb[i].length; }
|
|
3499
|
+
// Reuse pre-allocated buffer when payload fits
|
|
3500
|
+
const reuse = sz <= 65536;
|
|
3501
|
+
const dv = reuse ? this._reuseDv : new DataView(new ArrayBuffer(sz));
|
|
3502
|
+
const u = reuse ? this._reuseU8 : new Uint8Array(dv.buffer);
|
|
3503
|
+
dv.setUint8(0, 0x02); dv.setUint8(1, this._colId);
|
|
3504
|
+
dv.setUint16(2, keys.length, true);
|
|
3505
|
+
let o = 4;
|
|
3506
|
+
for (const b of kb) { dv.setUint16(o, b.length, true); o += 2; u.set(b, o); o += b.length; }
|
|
3507
|
+
const r = await this._send(dv.buffer, reuse ? sz : undefined);
|
|
3508
|
+
if (r[0] === 0xFF) throw new Error('KvWs GET: ' + _kvDec.decode(r.subarray(3)));
|
|
3509
|
+
const cnt = r[1] | (r[2] << 8);
|
|
3510
|
+
const m = new Map();
|
|
3511
|
+
let p = 3;
|
|
3512
|
+
for (let i = 0; i < cnt && p < r.length; i++) {
|
|
3513
|
+
if (r[p++] === 1 && p + 4 <= r.length) {
|
|
3514
|
+
const vl = r[p] | (r[p + 1] << 8) | (r[p + 2] << 16) | ((r[p + 3] << 24) >>> 0); p += 4;
|
|
3515
|
+
m.set(keys[i], _kvDec.decode(r.subarray(p, p + vl))); p += vl;
|
|
3516
|
+
} else { m.set(keys[i], null); }
|
|
3517
|
+
}
|
|
3518
|
+
return m;
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
/** DEL [key,...] → number of deleted. */
|
|
3522
|
+
async del(keys) {
|
|
3523
|
+
const kb = new Array(keys.length);
|
|
3524
|
+
let sz = 4;
|
|
3525
|
+
for (let i = 0; i < keys.length; i++) { kb[i] = _kvEnc.encode(keys[i]); sz += 2 + kb[i].length; }
|
|
3526
|
+
// Reuse pre-allocated buffer when payload fits
|
|
3527
|
+
const reuse = sz <= 65536;
|
|
3528
|
+
const dv = reuse ? this._reuseDv : new DataView(new ArrayBuffer(sz));
|
|
3529
|
+
const u = reuse ? this._reuseU8 : new Uint8Array(dv.buffer);
|
|
3530
|
+
dv.setUint8(0, 0x03); dv.setUint8(1, this._colId);
|
|
3531
|
+
dv.setUint16(2, keys.length, true);
|
|
3532
|
+
let o = 4;
|
|
3533
|
+
for (const b of kb) { dv.setUint16(o, b.length, true); o += 2; u.set(b, o); o += b.length; }
|
|
3534
|
+
const r = await this._send(dv.buffer, reuse ? sz : undefined);
|
|
3535
|
+
if (r[0] === 0xFF) throw new Error('KvWs DEL: ' + _kvDec.decode(r.subarray(3)));
|
|
3536
|
+
const cnt = r[1] | (r[2] << 8);
|
|
3537
|
+
let ok = 0;
|
|
3538
|
+
for (let i = 0; i < cnt; i++) if (r[3 + (i >> 3)] & (1 << (i & 7))) ok++;
|
|
3539
|
+
return ok;
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
/** Alias for del(): matches idiomatic JS naming. */
|
|
3543
|
+
async delete(keys) { return this.del(keys); }
|
|
3544
|
+
|
|
3545
|
+
/** Send raw frame, get raw Uint8Array. For benchmarks. */
|
|
3546
|
+
async raw(frame) { return this._send(frame); }
|
|
3547
|
+
|
|
3548
|
+
close() { if (this._ws.readyState <= 1) this._ws.close(); }
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3552
|
+
// KvTcp: Raw TCP text-protocol KV client (SubstrioWire)
|
|
3553
|
+
//
|
|
3554
|
+
// Memcached-compatible text protocol over raw TCP.
|
|
3555
|
+
// Uses setEncoding('utf8') + indexOf('\r\n') for maximum V8 string perf.
|
|
3556
|
+
// Zero Buffer allocations in the hot path.
|
|
3557
|
+
//
|
|
3558
|
+
// Usage:
|
|
3559
|
+
// const kv = await db.kvTcp(9001);
|
|
3560
|
+
// await kv.set('key', 'value');
|
|
3561
|
+
// const val = await kv.get('key'); // 'value' or null
|
|
3562
|
+
// await kv.delete('key');
|
|
3563
|
+
// kv.close();
|
|
3564
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3565
|
+
|
|
3566
|
+
export class KvTcp {
|
|
3567
|
+
constructor(host, port, tenant, key) {
|
|
3568
|
+
this._queue = [];
|
|
3569
|
+
this._buf = '';
|
|
3570
|
+
|
|
3571
|
+
this.ready = (async () => {
|
|
3572
|
+
const net = (await import('net')).default || await import('net');
|
|
3573
|
+
this._sock = net.connect(port, host);
|
|
3574
|
+
this._sock.setEncoding('utf8');
|
|
3575
|
+
this._sock.setNoDelay(true);
|
|
3576
|
+
this._sock.on('data', (c) => this._onData(c));
|
|
3577
|
+
this._sock.on('error', (err) => {
|
|
3578
|
+
if (this._queue.length) this._queue.shift().reject(err);
|
|
3579
|
+
});
|
|
3580
|
+
|
|
3581
|
+
await new Promise((resolve, reject) => {
|
|
3582
|
+
this._sock.once('connect', resolve);
|
|
3583
|
+
this._sock.once('error', reject);
|
|
3584
|
+
});
|
|
3585
|
+
|
|
3586
|
+
// AUTH on connect
|
|
3587
|
+
const authResp = await this._send(
|
|
3588
|
+
`AUTH ${tenant} ${key}\r\n`,
|
|
3589
|
+
this._lineParser
|
|
3590
|
+
);
|
|
3591
|
+
if (authResp !== 'OK') throw new Error('KvTcp: auth failed: ' + authResp);
|
|
3592
|
+
|
|
3593
|
+
return this;
|
|
3594
|
+
})();
|
|
3595
|
+
}
|
|
3596
|
+
|
|
3597
|
+
_onData(chunk) {
|
|
3598
|
+
this._buf += chunk;
|
|
3599
|
+
while (this._queue.length) {
|
|
3600
|
+
const head = this._queue[0];
|
|
3601
|
+
const result = head.parser(this._buf);
|
|
3602
|
+
if (result === null) break;
|
|
3603
|
+
this._queue.shift();
|
|
3604
|
+
this._buf = result.rest;
|
|
3605
|
+
head.resolve(result.value);
|
|
3606
|
+
}
|
|
3607
|
+
}
|
|
3608
|
+
|
|
3609
|
+
_send(cmd, parser) {
|
|
3610
|
+
return new Promise((resolve, reject) => {
|
|
3611
|
+
this._queue.push({ resolve, reject, parser });
|
|
3612
|
+
this._sock.write(cmd);
|
|
3613
|
+
});
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
_lineParser(buf) {
|
|
3617
|
+
const i = buf.indexOf('\r\n');
|
|
3618
|
+
if (i < 0) return null;
|
|
3619
|
+
return { value: buf.substring(0, i), rest: buf.substring(i + 2) };
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
_getParser(buf) {
|
|
3623
|
+
const endIdx = buf.indexOf('END\r\n');
|
|
3624
|
+
if (endIdx === -1) return null;
|
|
3625
|
+
const block = buf.substring(0, endIdx);
|
|
3626
|
+
const rest = buf.substring(endIdx + 5);
|
|
3627
|
+
if (block.startsWith('VALUE')) {
|
|
3628
|
+
const firstLine = block.indexOf('\r\n');
|
|
3629
|
+
const data = block.substring(firstLine + 2).replace(/\r\n$/, '');
|
|
3630
|
+
return { value: data, rest };
|
|
3631
|
+
}
|
|
3632
|
+
return { value: null, rest };
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
/** Switch to a different KV collection. Tree handle is cached: zero overhead after first call. */
|
|
3636
|
+
async use(collection) {
|
|
3637
|
+
const r = await this._send(`USE ${collection}\r\n`, this._lineParser);
|
|
3638
|
+
if (r !== 'OK') throw new Error('KvTcp: USE failed: ' + r);
|
|
3639
|
+
return true;
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
/** SET key=value. Returns true on success. */
|
|
3643
|
+
async set(key, value) {
|
|
3644
|
+
const cmd = `set ${key} 0 0 ${Buffer.byteLength(value)}\r\n${value}\r\n`;
|
|
3645
|
+
const r = await this._send(cmd, this._lineParser);
|
|
3646
|
+
return r === 'STORED';
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
/** GET key → value string or null. */
|
|
3650
|
+
async get(key) {
|
|
3651
|
+
return this._send(`get ${key}\r\n`, this._getParser);
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
/** DEL key → true if deleted, false if not found. */
|
|
3655
|
+
async del(key) {
|
|
3656
|
+
const r = await this._send(`delete ${key}\r\n`, this._lineParser);
|
|
3657
|
+
return r === 'DELETED';
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
/** Alias for del(): matches idiomatic JS naming. */
|
|
3661
|
+
async delete(key) { return this.del(key); }
|
|
3662
|
+
|
|
3663
|
+
/** PUT [[key,val],...] → number of successes (sequential, one await per key). */
|
|
3664
|
+
async put(pairs) {
|
|
3665
|
+
let ok = 0;
|
|
3666
|
+
for (const [k, v] of pairs) {
|
|
3667
|
+
if (await this.set(k, v)) ok++;
|
|
3668
|
+
}
|
|
3669
|
+
return ok;
|
|
3670
|
+
}
|
|
3671
|
+
|
|
3672
|
+
/**
|
|
3673
|
+
* PIPELINE SET [[key,val],...] → number of successes.
|
|
3674
|
+
* Sends ALL commands in a single socket.write() (one syscall), then drains
|
|
3675
|
+
* all responses in one pass. Eliminates per-op roundtrip latency entirely.
|
|
3676
|
+
*/
|
|
3677
|
+
async pipeSet(pairs) {
|
|
3678
|
+
if (!pairs.length) return 0;
|
|
3679
|
+
const total = pairs.length;
|
|
3680
|
+
// Build all commands into a single buffer
|
|
3681
|
+
let cmd = '';
|
|
3682
|
+
for (const [k, v] of pairs) {
|
|
3683
|
+
cmd += `set ${k} 0 0 ${Buffer.byteLength(v)}\r\n${v}\r\n`;
|
|
3684
|
+
}
|
|
3685
|
+
// Custom parser: counts STORED/error lines until all N responses received.
|
|
3686
|
+
// Tracks consumed offset so re-reads never happen when _onData passes
|
|
3687
|
+
// the full buffer on subsequent calls after a partial return.
|
|
3688
|
+
let ok = 0, remaining = total, consumed = 0;
|
|
3689
|
+
const pipeParser = (buf) => {
|
|
3690
|
+
let pos = consumed;
|
|
3691
|
+
while (remaining > 0) {
|
|
3692
|
+
const nl = buf.indexOf('\r\n', pos);
|
|
3693
|
+
if (nl < 0) { consumed = pos; return null; } // need more data
|
|
3694
|
+
const line = buf.substring(pos, nl);
|
|
3695
|
+
pos = nl + 2;
|
|
3696
|
+
if (line === 'STORED') ok++;
|
|
3697
|
+
remaining--;
|
|
3698
|
+
}
|
|
3699
|
+
consumed = pos;
|
|
3700
|
+
return { value: ok, rest: buf.substring(pos) };
|
|
3701
|
+
};
|
|
3702
|
+
return this._send(cmd, pipeParser);
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
/**
|
|
3706
|
+
* PIPELINE GET [key,...] → [value|null, ...].
|
|
3707
|
+
* Sends ALL get commands in a single write, drains all VALUE/END blocks.
|
|
3708
|
+
*/
|
|
3709
|
+
async pipeGet(keys) {
|
|
3710
|
+
if (!keys.length) return [];
|
|
3711
|
+
const total = keys.length;
|
|
3712
|
+
let cmd = '';
|
|
3713
|
+
for (const k of keys) cmd += `get ${k}\r\n`;
|
|
3714
|
+
// Custom parser: collects N get responses (VALUE...END or just END).
|
|
3715
|
+
// Tracks consumed offset to avoid re-reading on partial returns.
|
|
3716
|
+
const results = [];
|
|
3717
|
+
let remaining = total, consumed = 0;
|
|
3718
|
+
const pipeParser = (buf) => {
|
|
3719
|
+
let pos = consumed;
|
|
3720
|
+
while (remaining > 0) {
|
|
3721
|
+
const endIdx = buf.indexOf('END\r\n', pos);
|
|
3722
|
+
if (endIdx === -1) { consumed = pos; return null; } // need more data
|
|
3723
|
+
const block = buf.substring(pos, endIdx);
|
|
3724
|
+
pos = endIdx + 5;
|
|
3725
|
+
if (block.startsWith('VALUE')) {
|
|
3726
|
+
const firstLine = block.indexOf('\r\n');
|
|
3727
|
+
results.push(block.substring(firstLine + 2).replace(/\r\n$/, ''));
|
|
3728
|
+
} else {
|
|
3729
|
+
results.push(null);
|
|
3730
|
+
}
|
|
3731
|
+
remaining--;
|
|
3732
|
+
}
|
|
3733
|
+
consumed = pos;
|
|
3734
|
+
return { value: results, rest: buf.substring(pos) };
|
|
3735
|
+
};
|
|
3736
|
+
return this._send(cmd, pipeParser);
|
|
3737
|
+
}
|
|
3738
|
+
|
|
3739
|
+
/** Flush all keys. */
|
|
3740
|
+
async flush() {
|
|
3741
|
+
return this._send('flush_all\r\n', this._lineParser);
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3744
|
+
/** INCR key delta → new numeric value. Returns null if key not found. */
|
|
3745
|
+
async incr(key, delta = 1) {
|
|
3746
|
+
const r = await this._send(`incr ${key} ${delta}\r\n`, this._lineParser);
|
|
3747
|
+
if (r === 'NOT_FOUND') return null;
|
|
3748
|
+
if (r.startsWith('CLIENT_ERROR')) throw new Error(r);
|
|
3749
|
+
return parseInt(r, 10);
|
|
3750
|
+
}
|
|
3751
|
+
|
|
3752
|
+
/** DECR key delta → new numeric value. Returns null if key not found. Floors at 0. */
|
|
3753
|
+
async decr(key, delta = 1) {
|
|
3754
|
+
const r = await this._send(`decr ${key} ${delta}\r\n`, this._lineParser);
|
|
3755
|
+
if (r === 'NOT_FOUND') return null;
|
|
3756
|
+
if (r.startsWith('CLIENT_ERROR')) throw new Error(r);
|
|
3757
|
+
return parseInt(r, 10);
|
|
3758
|
+
}
|
|
3759
|
+
|
|
3760
|
+
/** ADD key value: set only if key does NOT exist. Returns true if stored. */
|
|
3761
|
+
async add(key, value) {
|
|
3762
|
+
const cmd = `add ${key} 0 0 ${Buffer.byteLength(value)}\r\n${value}\r\n`;
|
|
3763
|
+
const r = await this._send(cmd, this._lineParser);
|
|
3764
|
+
return r === 'STORED';
|
|
3765
|
+
}
|
|
3766
|
+
|
|
3767
|
+
/** REPLACE key value: set only if key DOES exist. Returns true if stored. */
|
|
3768
|
+
async replace(key, value) {
|
|
3769
|
+
const cmd = `replace ${key} 0 0 ${Buffer.byteLength(value)}\r\n${value}\r\n`;
|
|
3770
|
+
const r = await this._send(cmd, this._lineParser);
|
|
3771
|
+
return r === 'STORED';
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3774
|
+
/** APPEND suffix to existing value. Returns true if stored. Key must exist. */
|
|
3775
|
+
async append(key, suffix) {
|
|
3776
|
+
const bytes = Buffer.byteLength(suffix);
|
|
3777
|
+
const cmd = `append ${key} 0 0 ${bytes}\r\n${suffix}\r\n`;
|
|
3778
|
+
const r = await this._send(cmd, this._lineParser);
|
|
3779
|
+
return r === 'STORED';
|
|
3780
|
+
}
|
|
3781
|
+
|
|
3782
|
+
/** PREPEND prefix to existing value. Returns true if stored. Key must exist. */
|
|
3783
|
+
async prepend(key, prefix) {
|
|
3784
|
+
const bytes = Buffer.byteLength(prefix);
|
|
3785
|
+
const cmd = `prepend ${key} 0 0 ${bytes}\r\n${prefix}\r\n`;
|
|
3786
|
+
const r = await this._send(cmd, this._lineParser);
|
|
3787
|
+
return r === 'STORED';
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
/** EXISTS key → boolean. Checks existence without fetching value. */
|
|
3791
|
+
async exists(key) {
|
|
3792
|
+
const r = await this._send(`exists ${key}\r\n`, this._lineParser);
|
|
3793
|
+
return r === 'EXISTS';
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
/** LEN key → byte length of value, or null if not found. */
|
|
3797
|
+
async len(key) {
|
|
3798
|
+
const r = await this._send(`len ${key}\r\n`, this._lineParser);
|
|
3799
|
+
if (r === 'NOT_FOUND') return null;
|
|
3800
|
+
return parseInt(r, 10);
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
/** GETDEL key → value string (atomically deletes), or null if not found. */
|
|
3804
|
+
async getdel(key) {
|
|
3805
|
+
return this._send(`getdel ${key}\r\n`, this._getParser);
|
|
3806
|
+
}
|
|
3807
|
+
|
|
3808
|
+
/** VERSION → server version string. */
|
|
3809
|
+
async version() {
|
|
3810
|
+
const r = await this._send('version\r\n', this._lineParser);
|
|
3811
|
+
return r.startsWith('VERSION ') ? r.substring(8) : r;
|
|
3812
|
+
}
|
|
3813
|
+
|
|
3814
|
+
/** STATS → Map<string, string> of server statistics. */
|
|
3815
|
+
async stats() {
|
|
3816
|
+
const statsParser = (buf) => {
|
|
3817
|
+
const endIdx = buf.indexOf('END\r\n');
|
|
3818
|
+
if (endIdx === -1) return null;
|
|
3819
|
+
const block = buf.substring(0, endIdx);
|
|
3820
|
+
const rest = buf.substring(endIdx + 5);
|
|
3821
|
+
const m = new Map();
|
|
3822
|
+
for (const line of block.split('\r\n')) {
|
|
3823
|
+
if (line.startsWith('STAT ')) {
|
|
3824
|
+
const sp = line.indexOf(' ', 5);
|
|
3825
|
+
if (sp > 0) m.set(line.substring(5, sp), line.substring(sp + 1));
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
return { value: m, rest };
|
|
3829
|
+
};
|
|
3830
|
+
return this._send('stats\r\n', statsParser);
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3833
|
+
/** SCAN prefix [limit] → [[key, value], ...]. Prefix scan over keys. */
|
|
3834
|
+
async scan(prefix, limit = 100) {
|
|
3835
|
+
const scanParser = (buf) => {
|
|
3836
|
+
const endIdx = buf.indexOf('END\r\n');
|
|
3837
|
+
if (endIdx === -1) return null;
|
|
3838
|
+
const block = buf.substring(0, endIdx);
|
|
3839
|
+
const rest = buf.substring(endIdx + 5);
|
|
3840
|
+
const results = [];
|
|
3841
|
+
const lines = block.split('\r\n');
|
|
3842
|
+
let i = 0;
|
|
3843
|
+
while (i < lines.length) {
|
|
3844
|
+
if (lines[i].startsWith('VALUE ')) {
|
|
3845
|
+
const parts = lines[i].split(' ');
|
|
3846
|
+
const key = parts[1];
|
|
3847
|
+
const bytes = parseInt(parts[3], 10);
|
|
3848
|
+
// Value is on next line
|
|
3849
|
+
i++;
|
|
3850
|
+
if (i < lines.length) {
|
|
3851
|
+
results.push([key, lines[i]]);
|
|
3852
|
+
}
|
|
3853
|
+
}
|
|
3854
|
+
i++;
|
|
3855
|
+
}
|
|
3856
|
+
return { value: results, rest };
|
|
3857
|
+
};
|
|
3858
|
+
return this._send(`scan ${prefix} ${limit}\r\n`, scanParser);
|
|
3859
|
+
}
|
|
3860
|
+
|
|
3861
|
+
/** MGET [key,...] → Map<key, value|null>. Multi-key get in a single call. */
|
|
3862
|
+
async mget(keys) {
|
|
3863
|
+
if (!keys.length) return new Map();
|
|
3864
|
+
// Build multi-key get: "get key1 key2 key3\r\n"
|
|
3865
|
+
// Each key returns its own VALUE...END block, so we pipeline individual gets
|
|
3866
|
+
const total = keys.length;
|
|
3867
|
+
let cmd = '';
|
|
3868
|
+
for (const k of keys) cmd += `get ${k}\r\n`;
|
|
3869
|
+
const results = new Map();
|
|
3870
|
+
let remaining = total, consumed = 0, idx = 0;
|
|
3871
|
+
const mgetParser = (buf) => {
|
|
3872
|
+
let pos = consumed;
|
|
3873
|
+
while (remaining > 0) {
|
|
3874
|
+
const endIdx = buf.indexOf('END\r\n', pos);
|
|
3875
|
+
if (endIdx === -1) { consumed = pos; return null; }
|
|
3876
|
+
const block = buf.substring(pos, endIdx);
|
|
3877
|
+
pos = endIdx + 5;
|
|
3878
|
+
if (block.startsWith('VALUE')) {
|
|
3879
|
+
const firstLine = block.indexOf('\r\n');
|
|
3880
|
+
results.set(keys[idx], block.substring(firstLine + 2).replace(/\r\n$/, ''));
|
|
3881
|
+
} else {
|
|
3882
|
+
results.set(keys[idx], null);
|
|
3883
|
+
}
|
|
3884
|
+
idx++;
|
|
3885
|
+
remaining--;
|
|
3886
|
+
}
|
|
3887
|
+
consumed = pos;
|
|
3888
|
+
return { value: results, rest: buf.substring(pos) };
|
|
3889
|
+
};
|
|
3890
|
+
return this._send(cmd, mgetParser);
|
|
3891
|
+
}
|
|
3892
|
+
|
|
3893
|
+
/** GETS key → {value, cas} or null. Returns value with CAS version token. */
|
|
3894
|
+
async gets(key) {
|
|
3895
|
+
const getsParser = (buf) => {
|
|
3896
|
+
const endIdx = buf.indexOf('END\r\n');
|
|
3897
|
+
if (endIdx === -1) return null;
|
|
3898
|
+
const block = buf.substring(0, endIdx);
|
|
3899
|
+
const rest = buf.substring(endIdx + 5);
|
|
3900
|
+
if (block.startsWith('VALUE')) {
|
|
3901
|
+
// VALUE key flags bytes cas_token\r\ndata
|
|
3902
|
+
const firstLine = block.indexOf('\r\n');
|
|
3903
|
+
const header = block.substring(0, firstLine);
|
|
3904
|
+
const parts = header.split(' ');
|
|
3905
|
+
const cas = parts[4]; // CAS token (version_timestamp as decimal string)
|
|
3906
|
+
const data = block.substring(firstLine + 2).replace(/\r\n$/, '');
|
|
3907
|
+
return { value: { value: data, cas }, rest };
|
|
3908
|
+
}
|
|
3909
|
+
return { value: null, rest };
|
|
3910
|
+
};
|
|
3911
|
+
return this._send(`gets ${key}\r\n`, getsParser);
|
|
3912
|
+
}
|
|
3913
|
+
|
|
3914
|
+
/** CAS key value cas_token → 'STORED'|'EXISTS'|'NOT_FOUND'. Compare-and-swap. */
|
|
3915
|
+
async cas(key, value, casToken) {
|
|
3916
|
+
const bytes = Buffer.byteLength(value);
|
|
3917
|
+
const cmd = `cas ${key} 0 0 ${bytes} ${casToken}\r\n${value}\r\n`;
|
|
3918
|
+
const r = await this._send(cmd, this._lineParser);
|
|
3919
|
+
return r; // 'STORED', 'EXISTS', or 'NOT_FOUND'
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3922
|
+
close() {
|
|
3923
|
+
if (this._sock) {
|
|
3924
|
+
this._sock.destroy();
|
|
3925
|
+
this._sock = null;
|
|
3926
|
+
}
|
|
3927
|
+
}
|
|
3928
|
+
}
|