@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/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
+ }