@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/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: (_s, chunk)=>{
2187
- for (const frame of this.reader.push(chunk)){
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 resolve = this.pending.get(msg.id);
2190
- if (resolve) {
2219
+ const p = this.pending.get(msg.id);
2220
+ if (p) {
2191
2221
  this.pending.delete(msg.id);
2192
- resolve(msg.results);
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 pipeline(tenant, requests) {
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.connect();
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
- this.pending.set(id, resolve);
2213
- this.writer.send(Buffer.from(JSON.stringify({
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
- }), 'utf8'));
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
- buffer;
2250
- constructor(client, tenant, buffer){
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 = /^\s*SELECT/i.test(text) || /\bRETURNING\b/i.test(text);
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.buffer) {
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.buffer) return fn(this);
2324
- const buffer = [];
2325
- const out = await fn(new OdbliteSocketConnection(this.client, this.tenant, buffer));
2326
- if (buffer.length) {
2327
- const res = await this.atomic(buffer.map((b)=>({
2328
- sql: b.sql,
2329
- params: b.params
2330
- })));
2331
- const err = res.step_errors?.find((e)=>e);
2332
- if (err) throw new Error(`transaction batch failed: ${err.message}`);
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: 'get_autocommit'
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.chosen = this.pick(opts);
2517
+ this.opts = opts;
2518
+ this.chosen = this.pick();
2415
2519
  }
2416
- async pick(opts) {
2520
+ async pick() {
2417
2521
  const hasBun = void 0 !== globalThis.Bun && !!globalThis.Bun.connect;
2418
- if (hasBun) for (const target of this.candidates(opts))try {
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
- console.log('[odb-client] transport → HTTP (no router_v2 socket reachable)');
2427
- return new ODBLiteAdapter(opts.http);
2428
- }
2429
- candidates(opts) {
2430
- if (opts.socket?.unix || opts.socket?.port) return [
2431
- opts.socket
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(opts.http?.serviceUrl).hostname;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pineliner/odb-client",
3
- "version": "1.2.0",
3
+ "version": "1.5.0",
4
4
  "description": "Isomorphic client for ODB-Lite with postgres.js-like template string SQL support",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -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
- private readonly buffer?: { sql: string; params: any[] }[]
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
- buffer?: { sql: string; params: any[] }[],
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 = /^\s*SELECT/i.test(text) || /\bRETURNING\b/i.test(text)
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.buffer) {
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.buffer) return fn(this) // nested → same buffer
105
- const buffer: { sql: string; params: any[] }[] = []
106
- const out = await fn(new OdbliteSocketConnection(this.client, this.tenant, buffer))
107
- if (buffer.length) {
108
- const res = await this.atomic(buffer.map((b) => ({ sql: b.sql, params: b.params })))
109
- const err = res.step_errors?.find((e) => e)
110
- if (err) throw new Error(`transaction batch failed: ${err.message}`)
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
- savepoint<T>(fn: (tx: Connection) => Promise<T>): Promise<T> {
118
- return this.transaction(fn)
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: 'get_autocommit' }]),
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 chosen: Promise<DatabaseAdapter>
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.chosen = this.pick(opts)
260
+ this.opts = opts
261
+ this.chosen = this.pick()
207
262
  }
208
263
 
209
- private async pick(opts: { socket?: OdbliteSocketConfig; http: any }): Promise<DatabaseAdapter> {
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
- for (const target of this.candidates(opts)) {
213
- try {
214
- const socket = new OdbliteSocketAdapter(target)
215
- if (await socket.isHealthy()) {
216
- console.log(`[odb-client] transport → SOCKET (router_v2 @ ${target.unix ?? `${target.hostname}:${target.port}`})`)
217
- return socket
218
- }
219
- await socket.disconnect()
220
- } catch {
221
- /* try the next candidate */
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
- console.log('[odb-client] transport → HTTP (no router_v2 socket reachable)')
226
- return new ODBLiteAdapter(opts.http)
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(opts: { socket?: OdbliteSocketConfig; http: any }): SocketTarget[] {
232
- if (opts.socket?.unix || opts.socket?.port) return [opts.socket]
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(opts.http?.serviceUrl).hostname // e.g. odb-lite from http://odb-lite:8671
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> {