@pineliner/odb-client 1.2.0 → 1.5.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/dist/database/adapters/odblite-socket.d.ts +14 -1
- package/dist/database/adapters/odblite-socket.d.ts.map +1 -1
- package/dist/database/adapters/socket/client.d.ts +18 -1
- package/dist/database/adapters/socket/client.d.ts.map +1 -1
- package/dist/database/adapters/socket/protocol.d.ts +3 -0
- package/dist/database/adapters/socket/protocol.d.ts.map +1 -1
- package/dist/index.cjs +176 -51
- package/dist/index.js +176 -51
- package/package.json +1 -1
- package/src/database/adapters/odblite-socket.ts +118 -38
- package/src/database/adapters/socket/client.ts +111 -13
- package/src/database/adapters/socket/protocol.ts +15 -0
package/dist/index.js
CHANGED
|
@@ -2114,6 +2114,10 @@ function toBatch(steps) {
|
|
|
2114
2114
|
}))
|
|
2115
2115
|
};
|
|
2116
2116
|
}
|
|
2117
|
+
const READ_ONLY_RE = /^\s*(?:SELECT|EXPLAIN|VALUES)\b/i;
|
|
2118
|
+
function mightReturnRows(sql) {
|
|
2119
|
+
return READ_ONLY_RE.test(sql) || /\bRETURNING\b/i.test(sql);
|
|
2120
|
+
}
|
|
2117
2121
|
class FrameWriter {
|
|
2118
2122
|
socket;
|
|
2119
2123
|
pending = Buffer.alloc(0);
|
|
@@ -2141,6 +2145,7 @@ class FrameWriter {
|
|
|
2141
2145
|
}
|
|
2142
2146
|
}
|
|
2143
2147
|
}
|
|
2148
|
+
const MAX_FRAME = 1024 * (Number(globalThis.process?.env?.ODB_MAX_FRAME_MB) || 64) * 1024;
|
|
2144
2149
|
class FrameReader {
|
|
2145
2150
|
buf = Buffer.alloc(0);
|
|
2146
2151
|
push(chunk) {
|
|
@@ -2151,6 +2156,7 @@ class FrameReader {
|
|
|
2151
2156
|
const out = [];
|
|
2152
2157
|
while(this.buf.length >= 4){
|
|
2153
2158
|
const len = this.buf.readUInt32LE(0);
|
|
2159
|
+
if (len > MAX_FRAME) throw new Error(`odblite reply frame too large: ${len} bytes (max ${MAX_FRAME})`);
|
|
2154
2160
|
if (this.buf.length < 4 + len) break;
|
|
2155
2161
|
out.push(this.buf.subarray(4, 4 + len));
|
|
2156
2162
|
this.buf = this.buf.subarray(4 + len);
|
|
@@ -2158,6 +2164,10 @@ class FrameReader {
|
|
|
2158
2164
|
return out;
|
|
2159
2165
|
}
|
|
2160
2166
|
}
|
|
2167
|
+
const REQUEST_TIMEOUT_MS = Number(globalThis.process?.env?.ODB_SOCKET_REQUEST_TIMEOUT_MS) || 30000;
|
|
2168
|
+
const RECONNECT_ATTEMPTS = Number(globalThis.process?.env?.ODB_SOCKET_RECONNECT_ATTEMPTS) || 6;
|
|
2169
|
+
const RECONNECT_BASE_MS = Number(globalThis.process?.env?.ODB_SOCKET_RECONNECT_BASE_MS) || 50;
|
|
2170
|
+
const sleep = (ms)=>new Promise((r)=>setTimeout(r, ms));
|
|
2161
2171
|
class SocketClient {
|
|
2162
2172
|
target;
|
|
2163
2173
|
socket;
|
|
@@ -2165,12 +2175,20 @@ class SocketClient {
|
|
|
2165
2175
|
writer;
|
|
2166
2176
|
pending = new Map();
|
|
2167
2177
|
nextId = 1;
|
|
2178
|
+
nextStreamId = 1;
|
|
2168
2179
|
ready;
|
|
2169
2180
|
constructor(target){
|
|
2170
2181
|
this.target = target;
|
|
2171
2182
|
this.ready = this.connect();
|
|
2172
2183
|
this.ready.catch(()=>{});
|
|
2173
2184
|
}
|
|
2185
|
+
failPending(err) {
|
|
2186
|
+
for (const p of this.pending.values()){
|
|
2187
|
+
clearTimeout(p.timer);
|
|
2188
|
+
p.reject(err);
|
|
2189
|
+
}
|
|
2190
|
+
this.pending.clear();
|
|
2191
|
+
}
|
|
2174
2192
|
async connect() {
|
|
2175
2193
|
const Bun = globalThis.Bun;
|
|
2176
2194
|
if (!Bun?.connect) throw new Error('odblite-socket backend requires Bun (Bun.connect)');
|
|
@@ -2183,43 +2201,93 @@ class SocketClient {
|
|
|
2183
2201
|
this.socket = await Bun.connect({
|
|
2184
2202
|
...opts,
|
|
2185
2203
|
socket: {
|
|
2186
|
-
data: (
|
|
2187
|
-
|
|
2204
|
+
data: (s, chunk)=>{
|
|
2205
|
+
let frames;
|
|
2206
|
+
try {
|
|
2207
|
+
frames = this.reader.push(chunk);
|
|
2208
|
+
} catch (err) {
|
|
2209
|
+
this.socket = void 0;
|
|
2210
|
+
this.reader = new FrameReader();
|
|
2211
|
+
try {
|
|
2212
|
+
s.end?.();
|
|
2213
|
+
} catch {}
|
|
2214
|
+
this.failPending(err instanceof Error ? err : new Error(`odblite framing error: ${err}`));
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
for (const frame of frames){
|
|
2188
2218
|
const msg = JSON.parse(frame.toString('utf8'));
|
|
2189
|
-
const
|
|
2190
|
-
if (
|
|
2219
|
+
const p = this.pending.get(msg.id);
|
|
2220
|
+
if (p) {
|
|
2191
2221
|
this.pending.delete(msg.id);
|
|
2192
|
-
|
|
2222
|
+
clearTimeout(p.timer);
|
|
2223
|
+
p.resolve(msg.results);
|
|
2193
2224
|
}
|
|
2194
2225
|
}
|
|
2195
2226
|
},
|
|
2196
2227
|
drain: (_s)=>this.writer.flush(),
|
|
2197
2228
|
close: ()=>{
|
|
2198
2229
|
this.socket = void 0;
|
|
2230
|
+
this.reader = new FrameReader();
|
|
2231
|
+
this.failPending(new Error('odblite socket disconnected — request not completed'));
|
|
2232
|
+
},
|
|
2233
|
+
error: (_s, err)=>{
|
|
2234
|
+
this.socket = void 0;
|
|
2235
|
+
this.reader = new FrameReader();
|
|
2236
|
+
this.failPending(err instanceof Error ? err : new Error(`odblite socket error: ${err}`));
|
|
2199
2237
|
}
|
|
2200
2238
|
}
|
|
2201
2239
|
});
|
|
2202
2240
|
this.writer = new FrameWriter(this.socket);
|
|
2203
2241
|
}
|
|
2204
|
-
async
|
|
2242
|
+
async connectWithRetry() {
|
|
2243
|
+
let lastErr;
|
|
2244
|
+
for(let attempt = 0; attempt < RECONNECT_ATTEMPTS; attempt++)try {
|
|
2245
|
+
await this.connect();
|
|
2246
|
+
return;
|
|
2247
|
+
} catch (err) {
|
|
2248
|
+
lastErr = err;
|
|
2249
|
+
if (attempt < RECONNECT_ATTEMPTS - 1) await sleep(RECONNECT_BASE_MS * 2 ** attempt);
|
|
2250
|
+
}
|
|
2251
|
+
throw lastErr instanceof Error ? lastErr : new Error(`odblite socket reconnect failed: ${lastErr}`);
|
|
2252
|
+
}
|
|
2253
|
+
allocStream() {
|
|
2254
|
+
return this.nextStreamId++;
|
|
2255
|
+
}
|
|
2256
|
+
async pipeline(tenant, requests, stream) {
|
|
2257
|
+
await this.ready.catch(()=>{});
|
|
2205
2258
|
if (!this.socket) {
|
|
2206
|
-
this.ready = this.
|
|
2259
|
+
this.ready = this.connectWithRetry();
|
|
2207
2260
|
this.ready.catch(()=>{});
|
|
2261
|
+
await this.ready;
|
|
2208
2262
|
}
|
|
2209
|
-
await this.ready;
|
|
2210
2263
|
const id = this.nextId++;
|
|
2211
|
-
return new Promise((resolve)=>{
|
|
2212
|
-
|
|
2213
|
-
|
|
2264
|
+
return new Promise((resolve, reject)=>{
|
|
2265
|
+
const timer = setTimeout(()=>{
|
|
2266
|
+
if (this.pending.delete(id)) reject(new Error(`odblite socket request ${id} timed out after ${REQUEST_TIMEOUT_MS}ms`));
|
|
2267
|
+
}, REQUEST_TIMEOUT_MS);
|
|
2268
|
+
timer.unref?.();
|
|
2269
|
+
this.pending.set(id, {
|
|
2270
|
+
resolve,
|
|
2271
|
+
reject,
|
|
2272
|
+
timer
|
|
2273
|
+
});
|
|
2274
|
+
const payload = null != stream ? {
|
|
2275
|
+
id,
|
|
2276
|
+
tenant,
|
|
2277
|
+
stream,
|
|
2278
|
+
requests
|
|
2279
|
+
} : {
|
|
2214
2280
|
id,
|
|
2215
2281
|
tenant,
|
|
2216
2282
|
requests
|
|
2217
|
-
}
|
|
2283
|
+
};
|
|
2284
|
+
this.writer.send(Buffer.from(JSON.stringify(payload), 'utf8'));
|
|
2218
2285
|
});
|
|
2219
2286
|
}
|
|
2220
2287
|
close() {
|
|
2221
2288
|
this.socket?.end?.();
|
|
2222
2289
|
this.socket = void 0;
|
|
2290
|
+
this.failPending(new Error('odblite socket closed'));
|
|
2223
2291
|
}
|
|
2224
2292
|
}
|
|
2225
2293
|
function toQueryResult(res) {
|
|
@@ -2243,21 +2311,22 @@ function toQueryResult(res) {
|
|
|
2243
2311
|
class OdbliteSocketConnection {
|
|
2244
2312
|
client;
|
|
2245
2313
|
tenant;
|
|
2314
|
+
streamId;
|
|
2246
2315
|
databaseName;
|
|
2247
2316
|
databaseHash;
|
|
2248
2317
|
sql;
|
|
2249
|
-
|
|
2250
|
-
constructor(client, tenant,
|
|
2318
|
+
beginPending = false;
|
|
2319
|
+
constructor(client, tenant, streamId){
|
|
2251
2320
|
this.client = client;
|
|
2252
2321
|
this.tenant = tenant;
|
|
2322
|
+
this.streamId = streamId;
|
|
2253
2323
|
this.databaseName = tenant;
|
|
2254
2324
|
this.databaseHash = tenant;
|
|
2255
|
-
this.buffer = buffer;
|
|
2256
2325
|
const self = this;
|
|
2257
2326
|
this.sql = function(strings, ...values) {
|
|
2258
2327
|
if (strings && 'object' == typeof strings && 'raw' in strings) {
|
|
2259
2328
|
const text = strings.reduce((a, s, i)=>a + s + (i < values.length ? '?' : ''), '');
|
|
2260
|
-
const returns =
|
|
2329
|
+
const returns = mightReturnRows(text);
|
|
2261
2330
|
return (returns ? self.query(text, values) : self.execute(text, values)).then((r)=>r.rows);
|
|
2262
2331
|
}
|
|
2263
2332
|
throw new Error('sql() fragment helper not implemented in odblite-socket');
|
|
@@ -2268,7 +2337,32 @@ class OdbliteSocketConnection {
|
|
|
2268
2337
|
if (!res) throw new Error('odblite-socket: empty pipeline response');
|
|
2269
2338
|
return res;
|
|
2270
2339
|
}
|
|
2340
|
+
async streamExec(sql, params = []) {
|
|
2341
|
+
const reqs = [];
|
|
2342
|
+
if (this.beginPending) {
|
|
2343
|
+
reqs.push({
|
|
2344
|
+
type: 'execute',
|
|
2345
|
+
stmt: {
|
|
2346
|
+
sql: 'BEGIN'
|
|
2347
|
+
}
|
|
2348
|
+
});
|
|
2349
|
+
this.beginPending = false;
|
|
2350
|
+
}
|
|
2351
|
+
reqs.push({
|
|
2352
|
+
type: 'execute',
|
|
2353
|
+
stmt: {
|
|
2354
|
+
sql,
|
|
2355
|
+
args: params.map(toHrana)
|
|
2356
|
+
}
|
|
2357
|
+
});
|
|
2358
|
+
const results = await this.client.pipeline(this.tenant, reqs, this.streamId);
|
|
2359
|
+
if (reqs.length > 1 && results[0]?.type === 'error') throw new Error(results[0].error.message);
|
|
2360
|
+
const res = results[results.length - 1];
|
|
2361
|
+
if (!res) throw new Error('odblite-socket: empty stream response');
|
|
2362
|
+
return toQueryResult(res);
|
|
2363
|
+
}
|
|
2271
2364
|
async query(sql, params = []) {
|
|
2365
|
+
if (null != this.streamId) return await this.streamExec(sql, params);
|
|
2272
2366
|
return toQueryResult(await this.one([
|
|
2273
2367
|
{
|
|
2274
2368
|
type: 'execute',
|
|
@@ -2282,16 +2376,7 @@ class OdbliteSocketConnection {
|
|
|
2282
2376
|
async execute(sql, params = []) {
|
|
2283
2377
|
const s = 'string' == typeof sql ? sql : sql.sql;
|
|
2284
2378
|
const p = 'string' == typeof sql ? params : sql.args ?? [];
|
|
2285
|
-
if (this.
|
|
2286
|
-
this.buffer.push({
|
|
2287
|
-
sql: s,
|
|
2288
|
-
params: p
|
|
2289
|
-
});
|
|
2290
|
-
return {
|
|
2291
|
-
rows: [],
|
|
2292
|
-
rowsAffected: 0
|
|
2293
|
-
};
|
|
2294
|
-
}
|
|
2379
|
+
if (null != this.streamId) return this.streamExec(s, p);
|
|
2295
2380
|
return toQueryResult(await this.one([
|
|
2296
2381
|
{
|
|
2297
2382
|
type: 'execute',
|
|
@@ -2320,24 +2405,33 @@ class OdbliteSocketConnection {
|
|
|
2320
2405
|
};
|
|
2321
2406
|
}
|
|
2322
2407
|
async transaction(fn) {
|
|
2323
|
-
if (this.
|
|
2324
|
-
const
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
const
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2408
|
+
if (null != this.streamId) return fn(this);
|
|
2409
|
+
const tx = new OdbliteSocketConnection(this.client, this.tenant, this.client.allocStream());
|
|
2410
|
+
tx.beginPending = true;
|
|
2411
|
+
try {
|
|
2412
|
+
const out = await fn(tx);
|
|
2413
|
+
if (!tx.beginPending) await tx.streamExec('COMMIT');
|
|
2414
|
+
return out;
|
|
2415
|
+
} catch (e) {
|
|
2416
|
+
if (!tx.beginPending) await tx.streamExec('ROLLBACK').catch(()=>{});
|
|
2417
|
+
throw e;
|
|
2333
2418
|
}
|
|
2334
|
-
return out;
|
|
2335
2419
|
}
|
|
2336
2420
|
begin(fn) {
|
|
2337
2421
|
return this.transaction(fn);
|
|
2338
2422
|
}
|
|
2339
|
-
savepoint(fn) {
|
|
2340
|
-
return this.transaction(fn);
|
|
2423
|
+
async savepoint(fn) {
|
|
2424
|
+
if (null == this.streamId) return this.transaction(fn);
|
|
2425
|
+
const name = `sp_${this.client.allocStream()}`;
|
|
2426
|
+
await this.streamExec(`SAVEPOINT ${name}`);
|
|
2427
|
+
try {
|
|
2428
|
+
const out = await fn(this);
|
|
2429
|
+
await this.streamExec(`RELEASE SAVEPOINT ${name}`);
|
|
2430
|
+
return out;
|
|
2431
|
+
} catch (e) {
|
|
2432
|
+
await this.streamExec(`ROLLBACK TO SAVEPOINT ${name}`).catch(()=>{});
|
|
2433
|
+
throw e;
|
|
2434
|
+
}
|
|
2341
2435
|
}
|
|
2342
2436
|
async ensureNamespaces(namespaces) {
|
|
2343
2437
|
return {
|
|
@@ -2375,20 +2469,26 @@ class OdbliteSocketAdapter {
|
|
|
2375
2469
|
}
|
|
2376
2470
|
async isHealthy() {
|
|
2377
2471
|
try {
|
|
2378
|
-
await Promise.race([
|
|
2472
|
+
const res = await Promise.race([
|
|
2379
2473
|
this.client.pipeline('_health', [
|
|
2380
2474
|
{
|
|
2381
|
-
type: '
|
|
2475
|
+
type: 'get_version'
|
|
2382
2476
|
}
|
|
2383
2477
|
]),
|
|
2384
2478
|
new Promise((_, rej)=>setTimeout(()=>rej(new Error('probe timeout')), 1500))
|
|
2385
2479
|
]);
|
|
2480
|
+
const proto = res?.[0]?.response?.protocol;
|
|
2481
|
+
if (proto !== CLIENT_SOCKET_PROTOCOL) {
|
|
2482
|
+
console.warn(`[odb-client] socket protocol mismatch (server=${proto ?? 'none'}, client=${CLIENT_SOCKET_PROTOCOL}) → using HTTP`);
|
|
2483
|
+
return false;
|
|
2484
|
+
}
|
|
2386
2485
|
return true;
|
|
2387
2486
|
} catch {
|
|
2388
2487
|
return false;
|
|
2389
2488
|
}
|
|
2390
2489
|
}
|
|
2391
2490
|
}
|
|
2491
|
+
const CLIENT_SOCKET_PROTOCOL = 2;
|
|
2392
2492
|
const DEFAULT_UNIX_SOCKET = '/tmp/odblite.sock';
|
|
2393
2493
|
function parseSocketTarget(s) {
|
|
2394
2494
|
const v = (s ?? '').trim();
|
|
@@ -2409,13 +2509,26 @@ function parseSocketTarget(s) {
|
|
|
2409
2509
|
}
|
|
2410
2510
|
class OdbliteAutoAdapter {
|
|
2411
2511
|
type = 'odblite';
|
|
2512
|
+
opts;
|
|
2412
2513
|
chosen;
|
|
2514
|
+
onHttp = false;
|
|
2515
|
+
nextRetryAt = 0;
|
|
2413
2516
|
constructor(opts){
|
|
2414
|
-
this.
|
|
2517
|
+
this.opts = opts;
|
|
2518
|
+
this.chosen = this.pick();
|
|
2415
2519
|
}
|
|
2416
|
-
async pick(
|
|
2520
|
+
async pick() {
|
|
2417
2521
|
const hasBun = void 0 !== globalThis.Bun && !!globalThis.Bun.connect;
|
|
2418
|
-
if (hasBun)
|
|
2522
|
+
if (hasBun) {
|
|
2523
|
+
const socket = await this.trySocket();
|
|
2524
|
+
if (socket) return socket;
|
|
2525
|
+
}
|
|
2526
|
+
this.onHttp = true;
|
|
2527
|
+
console.log('[odb-client] transport → HTTP (no router_v2 socket reachable)');
|
|
2528
|
+
return new ODBLiteAdapter(this.opts.http);
|
|
2529
|
+
}
|
|
2530
|
+
async trySocket() {
|
|
2531
|
+
for (const target of this.candidates())try {
|
|
2419
2532
|
const socket = new OdbliteSocketAdapter(target);
|
|
2420
2533
|
if (await socket.isHealthy()) {
|
|
2421
2534
|
console.log(`[odb-client] transport → SOCKET (router_v2 @ ${target.unix ?? `${target.hostname}:${target.port}`})`);
|
|
@@ -2423,12 +2536,23 @@ class OdbliteAutoAdapter {
|
|
|
2423
2536
|
}
|
|
2424
2537
|
await socket.disconnect();
|
|
2425
2538
|
} catch {}
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2539
|
+
return null;
|
|
2540
|
+
}
|
|
2541
|
+
async maybeUpgrade() {
|
|
2542
|
+
if (!this.onHttp) return;
|
|
2543
|
+
const now = Date.now();
|
|
2544
|
+
if (now < this.nextRetryAt) return;
|
|
2545
|
+
this.nextRetryAt = now + 15000;
|
|
2546
|
+
const socket = await this.trySocket();
|
|
2547
|
+
if (socket) {
|
|
2548
|
+
this.chosen = Promise.resolve(socket);
|
|
2549
|
+
this.onHttp = false;
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
candidates() {
|
|
2553
|
+
const { socket, http } = this.opts;
|
|
2554
|
+
if (socket?.unix || socket?.port) return [
|
|
2555
|
+
socket
|
|
2432
2556
|
];
|
|
2433
2557
|
const env = globalThis.process?.env?.ODB_SOCKET;
|
|
2434
2558
|
if (env) return [
|
|
@@ -2440,7 +2564,7 @@ class OdbliteAutoAdapter {
|
|
|
2440
2564
|
}
|
|
2441
2565
|
];
|
|
2442
2566
|
try {
|
|
2443
|
-
const host = new URL(
|
|
2567
|
+
const host = new URL(http?.serviceUrl).hostname;
|
|
2444
2568
|
const port = Number(globalThis.process?.env?.ODB_SOCKET_PORT ?? 8787);
|
|
2445
2569
|
if (host) out.push({
|
|
2446
2570
|
hostname: host,
|
|
@@ -2450,6 +2574,7 @@ class OdbliteAutoAdapter {
|
|
|
2450
2574
|
return out;
|
|
2451
2575
|
}
|
|
2452
2576
|
async connect(config) {
|
|
2577
|
+
await this.maybeUpgrade();
|
|
2453
2578
|
return (await this.chosen).connect(config);
|
|
2454
2579
|
}
|
|
2455
2580
|
async disconnect(tenantId) {
|
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
toHrana,
|
|
6
6
|
fromHrana,
|
|
7
7
|
toBatch,
|
|
8
|
+
mightReturnRows,
|
|
8
9
|
type AtomicStep,
|
|
9
10
|
type BatchResult,
|
|
10
11
|
type StmtResult,
|
|
@@ -43,21 +44,24 @@ class OdbliteSocketConnection implements Connection {
|
|
|
43
44
|
databaseName?: string
|
|
44
45
|
databaseHash?: string
|
|
45
46
|
public sql: any
|
|
46
|
-
|
|
47
|
+
/** A fresh stream tx whose BEGIN hasn't been flushed yet — it's folded into the first statement's
|
|
48
|
+
* frame to save a round-trip (and an empty tx that issues no statements sends nothing at all). */
|
|
49
|
+
private beginPending = false
|
|
47
50
|
|
|
48
51
|
constructor(
|
|
49
52
|
private readonly client: SocketClient,
|
|
50
53
|
private readonly tenant: string,
|
|
51
|
-
|
|
54
|
+
/** Set → this connection is scoped to an interactive transaction STREAM: every query/execute runs
|
|
55
|
+
* on the server's PINNED connection (read-your-own-writes), not autocommit. */
|
|
56
|
+
private readonly streamId?: number,
|
|
52
57
|
) {
|
|
53
58
|
this.databaseName = tenant
|
|
54
59
|
this.databaseHash = tenant
|
|
55
|
-
this.buffer = buffer
|
|
56
60
|
const self = this
|
|
57
61
|
this.sql = function (strings: any, ...values: any[]): any {
|
|
58
62
|
if (strings && typeof strings === 'object' && 'raw' in strings) {
|
|
59
63
|
const text = (strings as string[]).reduce((a, s, i) => a + s + (i < values.length ? '?' : ''), '')
|
|
60
|
-
const returns =
|
|
64
|
+
const returns = mightReturnRows(text)
|
|
61
65
|
return (returns ? self.query(text, values) : self.execute(text, values)).then((r: any) => r.rows)
|
|
62
66
|
}
|
|
63
67
|
throw new Error('sql() fragment helper not implemented in odblite-socket')
|
|
@@ -71,17 +75,31 @@ class OdbliteSocketConnection implements Connection {
|
|
|
71
75
|
return res
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
/** Run one statement on this connection's interactive STREAM — the server's pinned connection, so a
|
|
79
|
+
* read sees this tx's own uncommitted writes. Throws on error (incl. a poisoned COMMIT). */
|
|
80
|
+
private async streamExec(sql: string, params: any[] = []): Promise<QueryResult> {
|
|
81
|
+
const reqs: StreamRequest[] = []
|
|
82
|
+
if (this.beginPending) {
|
|
83
|
+
reqs.push({ type: 'execute', stmt: { sql: 'BEGIN' } }) // fold BEGIN into this first statement's frame
|
|
84
|
+
this.beginPending = false
|
|
85
|
+
}
|
|
86
|
+
reqs.push({ type: 'execute', stmt: { sql, args: params.map(toHrana) } })
|
|
87
|
+
const results = await this.client.pipeline(this.tenant, reqs, this.streamId)
|
|
88
|
+
if (reqs.length > 1 && results[0]?.type === 'error') throw new Error(results[0].error.message) // the folded BEGIN failed
|
|
89
|
+
const res = results[results.length - 1]
|
|
90
|
+
if (!res) throw new Error('odblite-socket: empty stream response')
|
|
91
|
+
return toQueryResult(res)
|
|
92
|
+
}
|
|
93
|
+
|
|
74
94
|
async query<T = any>(sql: string, params: any[] = []): Promise<QueryResult<T>> {
|
|
95
|
+
if (this.streamId != null) return (await this.streamExec(sql, params)) as QueryResult<T>
|
|
75
96
|
return toQueryResult(await this.one([{ type: 'execute', stmt: { sql, args: params.map(toHrana) } }])) as QueryResult<T>
|
|
76
97
|
}
|
|
77
98
|
|
|
78
99
|
async execute(sql: string | { sql: string; args?: any[] }, params: any[] = []): Promise<QueryResult> {
|
|
79
100
|
const s = typeof sql === 'string' ? sql : sql.sql
|
|
80
101
|
const p = typeof sql === 'string' ? params : (sql.args ?? [])
|
|
81
|
-
if (this.
|
|
82
|
-
this.buffer.push({ sql: s, params: p }) // deferred → flushed as one batch on commit
|
|
83
|
-
return { rows: [], rowsAffected: 0 }
|
|
84
|
-
}
|
|
102
|
+
if (this.streamId != null) return this.streamExec(s, p) // on the pinned stream — runs immediately, NOT buffered
|
|
85
103
|
return toQueryResult(await this.one([{ type: 'execute', stmt: { sql: s, args: p.map(toHrana) } }]))
|
|
86
104
|
}
|
|
87
105
|
|
|
@@ -100,22 +118,42 @@ class OdbliteSocketConnection implements Connection {
|
|
|
100
118
|
}
|
|
101
119
|
}
|
|
102
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Interactive transaction over a STREAM (one pinned server connection): BEGIN → fn's reads+writes →
|
|
123
|
+
* COMMIT, all on the same connection. Reads see their own writes, and the engine enforces atomicity
|
|
124
|
+
* (a failed statement poisons the tx → a later COMMIT is forced to ROLLBACK). Replaces the old
|
|
125
|
+
* buffered model, which couldn't read its own writes and only flushed at commit.
|
|
126
|
+
*/
|
|
103
127
|
async transaction<T>(fn: (tx: Connection) => Promise<T>): Promise<T> {
|
|
104
|
-
if (this.
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
128
|
+
if (this.streamId != null) return fn(this) // nested → join the already-open stream
|
|
129
|
+
const tx = new OdbliteSocketConnection(this.client, this.tenant, this.client.allocStream())
|
|
130
|
+
tx.beginPending = true // BEGIN folds into the first statement's frame (one fewer round-trip)
|
|
131
|
+
try {
|
|
132
|
+
const out = await fn(tx)
|
|
133
|
+
if (!tx.beginPending) await tx.streamExec('COMMIT') // a statement ran → BEGIN was sent → COMMIT it
|
|
134
|
+
return out // else fn issued no statements → empty tx, BEGIN never sent, zero round-trips
|
|
135
|
+
} catch (e) {
|
|
136
|
+
if (!tx.beginPending) await tx.streamExec('ROLLBACK').catch(() => {}) // best-effort; server also reaps
|
|
137
|
+
throw e
|
|
111
138
|
}
|
|
112
|
-
return out
|
|
113
139
|
}
|
|
114
140
|
begin<T>(fn: (tx: Connection) => Promise<T>): Promise<T> {
|
|
115
141
|
return this.transaction(fn)
|
|
116
142
|
}
|
|
117
|
-
|
|
118
|
-
|
|
143
|
+
/** Nested partial-rollback scope. Inside a stream → a real SAVEPOINT (a ROLLBACK TO SAVEPOINT clears
|
|
144
|
+
* the engine's poison); at top level → a full transaction. */
|
|
145
|
+
async savepoint<T>(fn: (tx: Connection) => Promise<T>): Promise<T> {
|
|
146
|
+
if (this.streamId == null) return this.transaction(fn)
|
|
147
|
+
const name = `sp_${this.client.allocStream()}`
|
|
148
|
+
await this.streamExec(`SAVEPOINT ${name}`)
|
|
149
|
+
try {
|
|
150
|
+
const out = await fn(this)
|
|
151
|
+
await this.streamExec(`RELEASE SAVEPOINT ${name}`)
|
|
152
|
+
return out
|
|
153
|
+
} catch (e) {
|
|
154
|
+
await this.streamExec(`ROLLBACK TO SAVEPOINT ${name}`).catch(() => {})
|
|
155
|
+
throw e
|
|
156
|
+
}
|
|
119
157
|
}
|
|
120
158
|
|
|
121
159
|
// router_v2 auto-ATTACHes a tenant's namespaces on open → these are no-ops.
|
|
@@ -154,10 +192,18 @@ export class OdbliteSocketAdapter implements DatabaseAdapter {
|
|
|
154
192
|
|
|
155
193
|
async isHealthy(): Promise<boolean> {
|
|
156
194
|
try {
|
|
157
|
-
await Promise.race([
|
|
158
|
-
this.client.pipeline('_health', [{ type: '
|
|
195
|
+
const res: any = await Promise.race([
|
|
196
|
+
this.client.pipeline('_health', [{ type: 'get_version' }]),
|
|
159
197
|
new Promise((_, rej) => setTimeout(() => rej(new Error('probe timeout')), 1500)),
|
|
160
198
|
])
|
|
199
|
+
// Connect-time HANDSHAKE: adopt the socket only if the server speaks our wire protocol. A different
|
|
200
|
+
// generation — or a server too old to answer get_version — falls back to HTTP instead of letting a
|
|
201
|
+
// mismatched client mis-speak the binary port (the 1.2.0 ↔ rebuilt-server skew).
|
|
202
|
+
const proto = res?.[0]?.response?.protocol
|
|
203
|
+
if (proto !== CLIENT_SOCKET_PROTOCOL) {
|
|
204
|
+
console.warn(`[odb-client] socket protocol mismatch (server=${proto ?? 'none'}, client=${CLIENT_SOCKET_PROTOCOL}) → using HTTP`)
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
161
207
|
return true
|
|
162
208
|
} catch {
|
|
163
209
|
return false
|
|
@@ -165,6 +211,11 @@ export class OdbliteSocketAdapter implements DatabaseAdapter {
|
|
|
165
211
|
}
|
|
166
212
|
}
|
|
167
213
|
|
|
214
|
+
/** Socket wire-protocol version this client speaks. The connect-time handshake (isHealthy → get_version)
|
|
215
|
+
* requires the server to match; on mismatch the auto-adapter falls back to HTTP. Keep in lockstep with
|
|
216
|
+
* router_v2's SOCKET_PROTOCOL. */
|
|
217
|
+
export const CLIENT_SOCKET_PROTOCOL = 2
|
|
218
|
+
|
|
168
219
|
/** Default same-host unix socket (free — v1 only uses /tmp/odblite-internal.sock). */
|
|
169
220
|
export const DEFAULT_UNIX_SOCKET = '/tmp/odblite.sock'
|
|
170
221
|
|
|
@@ -200,41 +251,69 @@ export function parseSocketTarget(s?: string): SocketTarget {
|
|
|
200
251
|
*/
|
|
201
252
|
export class OdbliteAutoAdapter implements DatabaseAdapter {
|
|
202
253
|
readonly type: BackendType = 'odblite'
|
|
203
|
-
private readonly
|
|
254
|
+
private readonly opts: { socket?: OdbliteSocketConfig; http: any }
|
|
255
|
+
private chosen: Promise<DatabaseAdapter>
|
|
256
|
+
private onHttp = false
|
|
257
|
+
private nextRetryAt = 0
|
|
204
258
|
|
|
205
259
|
constructor(opts: { socket?: OdbliteSocketConfig; http: any }) {
|
|
206
|
-
this.
|
|
260
|
+
this.opts = opts
|
|
261
|
+
this.chosen = this.pick()
|
|
207
262
|
}
|
|
208
263
|
|
|
209
|
-
private async pick(
|
|
264
|
+
private async pick(): Promise<DatabaseAdapter> {
|
|
210
265
|
const hasBun = typeof (globalThis as any).Bun !== 'undefined' && !!(globalThis as any).Bun.connect
|
|
211
266
|
if (hasBun) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
267
|
+
const socket = await this.trySocket()
|
|
268
|
+
if (socket) return socket
|
|
269
|
+
}
|
|
270
|
+
this.onHttp = true
|
|
271
|
+
console.log('[odb-client] transport → HTTP (no router_v2 socket reachable)')
|
|
272
|
+
return new ODBLiteAdapter(this.opts.http)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Probe the cascade once; return a live socket adapter or null. */
|
|
276
|
+
private async trySocket(): Promise<DatabaseAdapter | null> {
|
|
277
|
+
for (const target of this.candidates()) {
|
|
278
|
+
try {
|
|
279
|
+
const socket = new OdbliteSocketAdapter(target)
|
|
280
|
+
if (await socket.isHealthy()) {
|
|
281
|
+
console.log(`[odb-client] transport → SOCKET (router_v2 @ ${target.unix ?? `${target.hostname}:${target.port}`})`)
|
|
282
|
+
return socket
|
|
222
283
|
}
|
|
284
|
+
await socket.disconnect()
|
|
285
|
+
} catch {
|
|
286
|
+
/* try the next candidate */
|
|
223
287
|
}
|
|
224
288
|
}
|
|
225
|
-
|
|
226
|
-
|
|
289
|
+
return null
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** When we fell back to HTTP, re-probe the socket on a cooldown so a socket that
|
|
293
|
+
* comes up LATER (server started after the app) is picked up without a restart —
|
|
294
|
+
* subsequent connections then use it. */
|
|
295
|
+
private async maybeUpgrade(): Promise<void> {
|
|
296
|
+
if (!this.onHttp) return
|
|
297
|
+
const now = Date.now()
|
|
298
|
+
if (now < this.nextRetryAt) return
|
|
299
|
+
this.nextRetryAt = now + 15_000
|
|
300
|
+
const socket = await this.trySocket()
|
|
301
|
+
if (socket) {
|
|
302
|
+
this.chosen = Promise.resolve(socket)
|
|
303
|
+
this.onHttp = false
|
|
304
|
+
}
|
|
227
305
|
}
|
|
228
306
|
|
|
229
307
|
/** Ordered socket targets to probe. Explicit override → a single target; else the
|
|
230
308
|
* zero-config cascade unix (same host) → TCP (host derived from ODB_LITE_URL). */
|
|
231
|
-
private candidates(
|
|
232
|
-
|
|
309
|
+
private candidates(): SocketTarget[] {
|
|
310
|
+
const { socket, http } = this.opts
|
|
311
|
+
if (socket?.unix || socket?.port) return [socket]
|
|
233
312
|
const env = (globalThis as any).process?.env?.ODB_SOCKET
|
|
234
313
|
if (env) return [parseSocketTarget(env)]
|
|
235
314
|
const out: SocketTarget[] = [{ unix: DEFAULT_UNIX_SOCKET }]
|
|
236
315
|
try {
|
|
237
|
-
const host = new URL(
|
|
316
|
+
const host = new URL(http?.serviceUrl).hostname // e.g. odb-lite from http://odb-lite:8671
|
|
238
317
|
const port = Number((globalThis as any).process?.env?.ODB_SOCKET_PORT ?? 8787)
|
|
239
318
|
if (host) out.push({ hostname: host, port })
|
|
240
319
|
} catch {
|
|
@@ -244,6 +323,7 @@ export class OdbliteAutoAdapter implements DatabaseAdapter {
|
|
|
244
323
|
}
|
|
245
324
|
|
|
246
325
|
async connect(config: any): Promise<Connection> {
|
|
326
|
+
await this.maybeUpgrade()
|
|
247
327
|
return (await this.chosen).connect(config)
|
|
248
328
|
}
|
|
249
329
|
async disconnect(tenantId?: string): Promise<void> {
|