@pineliner/odb-client 1.2.0 → 1.4.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.
@@ -10,6 +10,10 @@ export declare class OdbliteSocketAdapter implements DatabaseAdapter {
10
10
  disconnect(): Promise<void>;
11
11
  isHealthy(): Promise<boolean>;
12
12
  }
13
+ /** Socket wire-protocol version this client speaks. The connect-time handshake (isHealthy → get_version)
14
+ * requires the server to match; on mismatch the auto-adapter falls back to HTTP. Keep in lockstep with
15
+ * router_v2's SOCKET_PROTOCOL. */
16
+ export declare const CLIENT_SOCKET_PROTOCOL = 2;
13
17
  /** Default same-host unix socket (free — v1 only uses /tmp/odblite-internal.sock). */
14
18
  export declare const DEFAULT_UNIX_SOCKET = "/tmp/odblite.sock";
15
19
  /**
@@ -36,12 +40,21 @@ export declare function parseSocketTarget(s?: string): SocketTarget;
36
40
  */
37
41
  export declare class OdbliteAutoAdapter implements DatabaseAdapter {
38
42
  readonly type: BackendType;
39
- private readonly chosen;
43
+ private readonly opts;
44
+ private chosen;
45
+ private onHttp;
46
+ private nextRetryAt;
40
47
  constructor(opts: {
41
48
  socket?: OdbliteSocketConfig;
42
49
  http: any;
43
50
  });
44
51
  private pick;
52
+ /** Probe the cascade once; return a live socket adapter or null. */
53
+ private trySocket;
54
+ /** When we fell back to HTTP, re-probe the socket on a cooldown so a socket that
55
+ * comes up LATER (server started after the app) is picked up without a restart —
56
+ * subsequent connections then use it. */
57
+ private maybeUpgrade;
45
58
  /** Ordered socket targets to probe. Explicit override → a single target; else the
46
59
  * zero-config cascade unix (same host) → TCP (host derived from ODB_LITE_URL). */
47
60
  private candidates;
@@ -1 +1 @@
1
- {"version":3,"file":"odblite-socket.d.ts","sourceRoot":"","sources":["../../../src/database/adapters/odblite-socket.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,eAAe,EAAkC,MAAM,UAAU,CAAA;AAExG,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAoIjE,MAAM,WAAW,mBAAoB,SAAQ,YAAY;CAAG;AAE5D,qBAAa,oBAAqB,YAAW,eAAe;IAC1D,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAmB;IAC7C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,MAAM,EAAE,mBAAmB;IAIjC,OAAO,CAAC,MAAM,EAAE,GAAG,GAAG,OAAO,CAAC,UAAU,CAAC;IAMzC,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAI3B,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC;CAWpC;AAED,sFAAsF;AACtF,eAAO,MAAM,mBAAmB,sBAAsB,CAAA;AAEtD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,CAAC,EAAE,MAAM,GAAG,YAAY,CAO1D;AAED;;;;;;;;;;;;GAYG;AACH,qBAAa,kBAAmB,YAAW,eAAe;IACxD,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA0B;gBAErC,IAAI,EAAE;QAAE,MAAM,CAAC,EAAE,mBAAmB,CAAC;QAAC,IAAI,EAAE,GAAG,CAAA;KAAE;YAI/C,IAAI;IAoBlB;uFACmF;IACnF,OAAO,CAAC,UAAU;IAeZ,OAAO,CAAC,MAAM,EAAE,GAAG,GAAG,OAAO,CAAC,UAAU,CAAC;IAGzC,UAAU,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAG5C,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC;CAGpC"}
1
+ {"version":3,"file":"odblite-socket.d.ts","sourceRoot":"","sources":["../../../src/database/adapters/odblite-socket.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,eAAe,EAAkC,MAAM,UAAU,CAAA;AAExG,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAA;AA0KjE,MAAM,WAAW,mBAAoB,SAAQ,YAAY;CAAG;AAE5D,qBAAa,oBAAqB,YAAW,eAAe;IAC1D,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAmB;IAC7C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,MAAM,EAAE,mBAAmB;IAIjC,OAAO,CAAC,MAAM,EAAE,GAAG,GAAG,OAAO,CAAC,UAAU,CAAC;IAMzC,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAI3B,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC;CAmBpC;AAED;;mCAEmC;AACnC,eAAO,MAAM,sBAAsB,IAAI,CAAA;AAEvC,sFAAsF;AACtF,eAAO,MAAM,mBAAmB,sBAAsB,CAAA;AAEtD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,CAAC,EAAE,MAAM,GAAG,YAAY,CAO1D;AAED;;;;;;;;;;;;GAYG;AACH,qBAAa,kBAAmB,YAAW,eAAe;IACxD,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA6C;IAClE,OAAO,CAAC,MAAM,CAA0B;IACxC,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,WAAW,CAAI;gBAEX,IAAI,EAAE;QAAE,MAAM,CAAC,EAAE,mBAAmB,CAAC;QAAC,IAAI,EAAE,GAAG,CAAA;KAAE;YAK/C,IAAI;IAWlB,oEAAoE;YACtD,SAAS;IAgBvB;;8CAE0C;YAC5B,YAAY;IAY1B;uFACmF;IACnF,OAAO,CAAC,UAAU;IAgBZ,OAAO,CAAC,MAAM,EAAE,GAAG,GAAG,OAAO,CAAC,UAAU,CAAC;IAIzC,UAAU,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAG5C,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC;CAGpC"}
@@ -6,6 +6,11 @@ import { type StreamRequest, type StreamResult } from './protocol';
6
6
  *
7
7
  * Bun-only (uses `Bun.connect`). app-router runs on Bun; the HTTP `odblite`
8
8
  * backend remains the universal fallback for non-Bun environments.
9
+ *
10
+ * Liveness invariant: an in-flight request must ALWAYS terminate. If the
11
+ * connection drops (close/error) or a reply never arrives, the pending promise
12
+ * is REJECTED — never left hanging. A hung pending request would wedge the
13
+ * caller's transaction, hold its write lane, and cascade into a 30s timeout.
9
14
  */
10
15
  export interface SocketTarget {
11
16
  unix?: string;
@@ -19,10 +24,18 @@ export declare class SocketClient {
19
24
  private writer;
20
25
  private readonly pending;
21
26
  private nextId;
27
+ private nextStreamId;
22
28
  private ready;
23
29
  constructor(target: SocketTarget);
30
+ /** Reject + clear every in-flight request — called on disconnect/error/close so a
31
+ * dropped connection FAILS its requests (the caller retries) instead of hanging. */
32
+ private failPending;
24
33
  private connect;
25
- pipeline(tenant: string, requests: StreamRequest[]): Promise<StreamResult[]>;
34
+ /** Allocate a fresh stream id for an interactive transaction (unique per connection). The server
35
+ * pins one SQLite connection per stream id, so all of a tx's statements run on it (read-your-own-
36
+ * writes). The id is carried in every frame of that tx via pipeline()'s `stream` arg. */
37
+ allocStream(): number;
38
+ pipeline(tenant: string, requests: StreamRequest[], stream?: number): Promise<StreamResult[]>;
26
39
  close(): void;
27
40
  }
28
41
  //# sourceMappingURL=client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../src/database/adapters/socket/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAA4B,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY,CAAA;AAE5F;;;;;;;GAOG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,qBAAa,YAAY;IAQX,OAAO,CAAC,QAAQ,CAAC,MAAM;IAPnC,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,MAAM,CAAoB;IAClC,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAiD;IACzE,OAAO,CAAC,MAAM,CAAI;IAClB,OAAO,CAAC,KAAK,CAAe;gBAEC,MAAM,EAAE,YAAY;YAOnC,OAAO;IA6Bf,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,EAAE,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IAalF,KAAK,IAAI,IAAI;CAId"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../src/database/adapters/socket/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAA4B,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY,CAAA;AAE5F;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAaD,qBAAa,YAAY;IASX,OAAO,CAAC,QAAQ,CAAC,MAAM;IARnC,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,MAAM,CAAoB;IAClC,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA6B;IACrD,OAAO,CAAC,MAAM,CAAI;IAClB,OAAO,CAAC,YAAY,CAAI;IACxB,OAAO,CAAC,KAAK,CAAe;gBAEC,MAAM,EAAE,YAAY;IAOjD;yFACqF;IACrF,OAAO,CAAC,WAAW;YAQL,OAAO;IAuCrB;;8FAE0F;IAC1F,WAAW,IAAI,MAAM;IAIf,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,EAAE,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IAwBnG,KAAK,IAAI,IAAI;CAKd"}
@@ -95,6 +95,8 @@ export type StreamRequest = {
95
95
  sql: string;
96
96
  } | {
97
97
  type: 'get_autocommit';
98
+ } | {
99
+ type: 'get_version';
98
100
  } | {
99
101
  type: 'close';
100
102
  };
@@ -111,6 +113,7 @@ export interface AtomicStep {
111
113
  expect?: 'one' | 'atLeastOne' | number;
112
114
  }
113
115
  export declare function toBatch(steps: AtomicStep[]): Batch;
116
+ export declare function mightReturnRows(sql: string): boolean;
114
117
  interface Writable {
115
118
  write(data: Uint8Array): number;
116
119
  }
@@ -1 +1 @@
1
- {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../../../../src/database/adapters/socket/protocol.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../../../../src/database/adapters/socket/protocol.ts"],"names":[],"mappings":"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"}
package/dist/index.cjs CHANGED
@@ -2204,6 +2204,10 @@ var __webpack_exports__ = {};
2204
2204
  }))
2205
2205
  };
2206
2206
  }
2207
+ const READ_ONLY_RE = /^\s*(?:SELECT|EXPLAIN|VALUES)\b/i;
2208
+ function mightReturnRows(sql) {
2209
+ return READ_ONLY_RE.test(sql) || /\bRETURNING\b/i.test(sql);
2210
+ }
2207
2211
  class FrameWriter {
2208
2212
  socket;
2209
2213
  pending = Buffer.alloc(0);
@@ -2248,6 +2252,7 @@ var __webpack_exports__ = {};
2248
2252
  return out;
2249
2253
  }
2250
2254
  }
2255
+ const REQUEST_TIMEOUT_MS = Number(globalThis.process?.env?.ODB_SOCKET_REQUEST_TIMEOUT_MS) || 30000;
2251
2256
  class SocketClient {
2252
2257
  target;
2253
2258
  socket;
@@ -2255,12 +2260,20 @@ var __webpack_exports__ = {};
2255
2260
  writer;
2256
2261
  pending = new Map();
2257
2262
  nextId = 1;
2263
+ nextStreamId = 1;
2258
2264
  ready;
2259
2265
  constructor(target){
2260
2266
  this.target = target;
2261
2267
  this.ready = this.connect();
2262
2268
  this.ready.catch(()=>{});
2263
2269
  }
2270
+ failPending(err) {
2271
+ for (const p of this.pending.values()){
2272
+ clearTimeout(p.timer);
2273
+ p.reject(err);
2274
+ }
2275
+ this.pending.clear();
2276
+ }
2264
2277
  async connect() {
2265
2278
  const Bun = globalThis.Bun;
2266
2279
  if (!Bun?.connect) throw new Error('odblite-socket backend requires Bun (Bun.connect)');
@@ -2276,40 +2289,67 @@ var __webpack_exports__ = {};
2276
2289
  data: (_s, chunk)=>{
2277
2290
  for (const frame of this.reader.push(chunk)){
2278
2291
  const msg = JSON.parse(frame.toString('utf8'));
2279
- const resolve = this.pending.get(msg.id);
2280
- if (resolve) {
2292
+ const p = this.pending.get(msg.id);
2293
+ if (p) {
2281
2294
  this.pending.delete(msg.id);
2282
- resolve(msg.results);
2295
+ clearTimeout(p.timer);
2296
+ p.resolve(msg.results);
2283
2297
  }
2284
2298
  }
2285
2299
  },
2286
2300
  drain: (_s)=>this.writer.flush(),
2287
2301
  close: ()=>{
2288
2302
  this.socket = void 0;
2303
+ this.reader = new FrameReader();
2304
+ this.failPending(new Error('odblite socket disconnected — request not completed'));
2305
+ },
2306
+ error: (_s, err)=>{
2307
+ this.socket = void 0;
2308
+ this.reader = new FrameReader();
2309
+ this.failPending(err instanceof Error ? err : new Error(`odblite socket error: ${err}`));
2289
2310
  }
2290
2311
  }
2291
2312
  });
2292
2313
  this.writer = new FrameWriter(this.socket);
2293
2314
  }
2294
- async pipeline(tenant, requests) {
2315
+ allocStream() {
2316
+ return this.nextStreamId++;
2317
+ }
2318
+ async pipeline(tenant, requests, stream) {
2319
+ await this.ready;
2295
2320
  if (!this.socket) {
2296
2321
  this.ready = this.connect();
2297
2322
  this.ready.catch(()=>{});
2323
+ await this.ready;
2298
2324
  }
2299
- await this.ready;
2300
2325
  const id = this.nextId++;
2301
- return new Promise((resolve)=>{
2302
- this.pending.set(id, resolve);
2303
- this.writer.send(Buffer.from(JSON.stringify({
2326
+ return new Promise((resolve, reject)=>{
2327
+ const timer = setTimeout(()=>{
2328
+ if (this.pending.delete(id)) reject(new Error(`odblite socket request ${id} timed out after ${REQUEST_TIMEOUT_MS}ms`));
2329
+ }, REQUEST_TIMEOUT_MS);
2330
+ timer.unref?.();
2331
+ this.pending.set(id, {
2332
+ resolve,
2333
+ reject,
2334
+ timer
2335
+ });
2336
+ const payload = null != stream ? {
2337
+ id,
2338
+ tenant,
2339
+ stream,
2340
+ requests
2341
+ } : {
2304
2342
  id,
2305
2343
  tenant,
2306
2344
  requests
2307
- }), 'utf8'));
2345
+ };
2346
+ this.writer.send(Buffer.from(JSON.stringify(payload), 'utf8'));
2308
2347
  });
2309
2348
  }
2310
2349
  close() {
2311
2350
  this.socket?.end?.();
2312
2351
  this.socket = void 0;
2352
+ this.failPending(new Error('odblite socket closed'));
2313
2353
  }
2314
2354
  }
2315
2355
  function toQueryResult(res) {
@@ -2333,21 +2373,22 @@ var __webpack_exports__ = {};
2333
2373
  class OdbliteSocketConnection {
2334
2374
  client;
2335
2375
  tenant;
2376
+ streamId;
2336
2377
  databaseName;
2337
2378
  databaseHash;
2338
2379
  sql;
2339
- buffer;
2340
- constructor(client, tenant, buffer){
2380
+ beginPending = false;
2381
+ constructor(client, tenant, streamId){
2341
2382
  this.client = client;
2342
2383
  this.tenant = tenant;
2384
+ this.streamId = streamId;
2343
2385
  this.databaseName = tenant;
2344
2386
  this.databaseHash = tenant;
2345
- this.buffer = buffer;
2346
2387
  const self = this;
2347
2388
  this.sql = function(strings, ...values) {
2348
2389
  if (strings && 'object' == typeof strings && 'raw' in strings) {
2349
2390
  const text = strings.reduce((a, s, i)=>a + s + (i < values.length ? '?' : ''), '');
2350
- const returns = /^\s*SELECT/i.test(text) || /\bRETURNING\b/i.test(text);
2391
+ const returns = mightReturnRows(text);
2351
2392
  return (returns ? self.query(text, values) : self.execute(text, values)).then((r)=>r.rows);
2352
2393
  }
2353
2394
  throw new Error('sql() fragment helper not implemented in odblite-socket');
@@ -2358,7 +2399,32 @@ var __webpack_exports__ = {};
2358
2399
  if (!res) throw new Error('odblite-socket: empty pipeline response');
2359
2400
  return res;
2360
2401
  }
2402
+ async streamExec(sql, params = []) {
2403
+ const reqs = [];
2404
+ if (this.beginPending) {
2405
+ reqs.push({
2406
+ type: 'execute',
2407
+ stmt: {
2408
+ sql: 'BEGIN'
2409
+ }
2410
+ });
2411
+ this.beginPending = false;
2412
+ }
2413
+ reqs.push({
2414
+ type: 'execute',
2415
+ stmt: {
2416
+ sql,
2417
+ args: params.map(toHrana)
2418
+ }
2419
+ });
2420
+ const results = await this.client.pipeline(this.tenant, reqs, this.streamId);
2421
+ if (reqs.length > 1 && results[0]?.type === 'error') throw new Error(results[0].error.message);
2422
+ const res = results[results.length - 1];
2423
+ if (!res) throw new Error('odblite-socket: empty stream response');
2424
+ return toQueryResult(res);
2425
+ }
2361
2426
  async query(sql, params = []) {
2427
+ if (null != this.streamId) return await this.streamExec(sql, params);
2362
2428
  return toQueryResult(await this.one([
2363
2429
  {
2364
2430
  type: 'execute',
@@ -2372,16 +2438,7 @@ var __webpack_exports__ = {};
2372
2438
  async execute(sql, params = []) {
2373
2439
  const s = 'string' == typeof sql ? sql : sql.sql;
2374
2440
  const p = 'string' == typeof sql ? params : sql.args ?? [];
2375
- if (this.buffer) {
2376
- this.buffer.push({
2377
- sql: s,
2378
- params: p
2379
- });
2380
- return {
2381
- rows: [],
2382
- rowsAffected: 0
2383
- };
2384
- }
2441
+ if (null != this.streamId) return this.streamExec(s, p);
2385
2442
  return toQueryResult(await this.one([
2386
2443
  {
2387
2444
  type: 'execute',
@@ -2410,24 +2467,33 @@ var __webpack_exports__ = {};
2410
2467
  };
2411
2468
  }
2412
2469
  async transaction(fn) {
2413
- if (this.buffer) return fn(this);
2414
- const buffer = [];
2415
- const out = await fn(new OdbliteSocketConnection(this.client, this.tenant, buffer));
2416
- if (buffer.length) {
2417
- const res = await this.atomic(buffer.map((b)=>({
2418
- sql: b.sql,
2419
- params: b.params
2420
- })));
2421
- const err = res.step_errors?.find((e)=>e);
2422
- if (err) throw new Error(`transaction batch failed: ${err.message}`);
2470
+ if (null != this.streamId) return fn(this);
2471
+ const tx = new OdbliteSocketConnection(this.client, this.tenant, this.client.allocStream());
2472
+ tx.beginPending = true;
2473
+ try {
2474
+ const out = await fn(tx);
2475
+ if (!tx.beginPending) await tx.streamExec('COMMIT');
2476
+ return out;
2477
+ } catch (e) {
2478
+ if (!tx.beginPending) await tx.streamExec('ROLLBACK').catch(()=>{});
2479
+ throw e;
2423
2480
  }
2424
- return out;
2425
2481
  }
2426
2482
  begin(fn) {
2427
2483
  return this.transaction(fn);
2428
2484
  }
2429
- savepoint(fn) {
2430
- return this.transaction(fn);
2485
+ async savepoint(fn) {
2486
+ if (null == this.streamId) return this.transaction(fn);
2487
+ const name = `sp_${this.client.allocStream()}`;
2488
+ await this.streamExec(`SAVEPOINT ${name}`);
2489
+ try {
2490
+ const out = await fn(this);
2491
+ await this.streamExec(`RELEASE SAVEPOINT ${name}`);
2492
+ return out;
2493
+ } catch (e) {
2494
+ await this.streamExec(`ROLLBACK TO SAVEPOINT ${name}`).catch(()=>{});
2495
+ throw e;
2496
+ }
2431
2497
  }
2432
2498
  async ensureNamespaces(namespaces) {
2433
2499
  return {
@@ -2465,20 +2531,26 @@ var __webpack_exports__ = {};
2465
2531
  }
2466
2532
  async isHealthy() {
2467
2533
  try {
2468
- await Promise.race([
2534
+ const res = await Promise.race([
2469
2535
  this.client.pipeline('_health', [
2470
2536
  {
2471
- type: 'get_autocommit'
2537
+ type: 'get_version'
2472
2538
  }
2473
2539
  ]),
2474
2540
  new Promise((_, rej)=>setTimeout(()=>rej(new Error('probe timeout')), 1500))
2475
2541
  ]);
2542
+ const proto = res?.[0]?.response?.protocol;
2543
+ if (proto !== CLIENT_SOCKET_PROTOCOL) {
2544
+ console.warn(`[odb-client] socket protocol mismatch (server=${proto ?? 'none'}, client=${CLIENT_SOCKET_PROTOCOL}) → using HTTP`);
2545
+ return false;
2546
+ }
2476
2547
  return true;
2477
2548
  } catch {
2478
2549
  return false;
2479
2550
  }
2480
2551
  }
2481
2552
  }
2553
+ const CLIENT_SOCKET_PROTOCOL = 2;
2482
2554
  const DEFAULT_UNIX_SOCKET = '/tmp/odblite.sock';
2483
2555
  function parseSocketTarget(s) {
2484
2556
  const v = (s ?? '').trim();
@@ -2499,13 +2571,26 @@ var __webpack_exports__ = {};
2499
2571
  }
2500
2572
  class OdbliteAutoAdapter {
2501
2573
  type = 'odblite';
2574
+ opts;
2502
2575
  chosen;
2576
+ onHttp = false;
2577
+ nextRetryAt = 0;
2503
2578
  constructor(opts){
2504
- this.chosen = this.pick(opts);
2579
+ this.opts = opts;
2580
+ this.chosen = this.pick();
2505
2581
  }
2506
- async pick(opts) {
2582
+ async pick() {
2507
2583
  const hasBun = void 0 !== globalThis.Bun && !!globalThis.Bun.connect;
2508
- if (hasBun) for (const target of this.candidates(opts))try {
2584
+ if (hasBun) {
2585
+ const socket = await this.trySocket();
2586
+ if (socket) return socket;
2587
+ }
2588
+ this.onHttp = true;
2589
+ console.log('[odb-client] transport → HTTP (no router_v2 socket reachable)');
2590
+ return new ODBLiteAdapter(this.opts.http);
2591
+ }
2592
+ async trySocket() {
2593
+ for (const target of this.candidates())try {
2509
2594
  const socket = new OdbliteSocketAdapter(target);
2510
2595
  if (await socket.isHealthy()) {
2511
2596
  console.log(`[odb-client] transport → SOCKET (router_v2 @ ${target.unix ?? `${target.hostname}:${target.port}`})`);
@@ -2513,12 +2598,23 @@ var __webpack_exports__ = {};
2513
2598
  }
2514
2599
  await socket.disconnect();
2515
2600
  } catch {}
2516
- console.log('[odb-client] transport → HTTP (no router_v2 socket reachable)');
2517
- return new ODBLiteAdapter(opts.http);
2518
- }
2519
- candidates(opts) {
2520
- if (opts.socket?.unix || opts.socket?.port) return [
2521
- opts.socket
2601
+ return null;
2602
+ }
2603
+ async maybeUpgrade() {
2604
+ if (!this.onHttp) return;
2605
+ const now = Date.now();
2606
+ if (now < this.nextRetryAt) return;
2607
+ this.nextRetryAt = now + 15000;
2608
+ const socket = await this.trySocket();
2609
+ if (socket) {
2610
+ this.chosen = Promise.resolve(socket);
2611
+ this.onHttp = false;
2612
+ }
2613
+ }
2614
+ candidates() {
2615
+ const { socket, http } = this.opts;
2616
+ if (socket?.unix || socket?.port) return [
2617
+ socket
2522
2618
  ];
2523
2619
  const env = globalThis.process?.env?.ODB_SOCKET;
2524
2620
  if (env) return [
@@ -2530,7 +2626,7 @@ var __webpack_exports__ = {};
2530
2626
  }
2531
2627
  ];
2532
2628
  try {
2533
- const host = new URL(opts.http?.serviceUrl).hostname;
2629
+ const host = new URL(http?.serviceUrl).hostname;
2534
2630
  const port = Number(globalThis.process?.env?.ODB_SOCKET_PORT ?? 8787);
2535
2631
  if (host) out.push({
2536
2632
  hostname: host,
@@ -2540,6 +2636,7 @@ var __webpack_exports__ = {};
2540
2636
  return out;
2541
2637
  }
2542
2638
  async connect(config) {
2639
+ await this.maybeUpgrade();
2543
2640
  return (await this.chosen).connect(config);
2544
2641
  }
2545
2642
  async disconnect(tenantId) {
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);
@@ -2158,6 +2162,7 @@ class FrameReader {
2158
2162
  return out;
2159
2163
  }
2160
2164
  }
2165
+ const REQUEST_TIMEOUT_MS = Number(globalThis.process?.env?.ODB_SOCKET_REQUEST_TIMEOUT_MS) || 30000;
2161
2166
  class SocketClient {
2162
2167
  target;
2163
2168
  socket;
@@ -2165,12 +2170,20 @@ class SocketClient {
2165
2170
  writer;
2166
2171
  pending = new Map();
2167
2172
  nextId = 1;
2173
+ nextStreamId = 1;
2168
2174
  ready;
2169
2175
  constructor(target){
2170
2176
  this.target = target;
2171
2177
  this.ready = this.connect();
2172
2178
  this.ready.catch(()=>{});
2173
2179
  }
2180
+ failPending(err) {
2181
+ for (const p of this.pending.values()){
2182
+ clearTimeout(p.timer);
2183
+ p.reject(err);
2184
+ }
2185
+ this.pending.clear();
2186
+ }
2174
2187
  async connect() {
2175
2188
  const Bun = globalThis.Bun;
2176
2189
  if (!Bun?.connect) throw new Error('odblite-socket backend requires Bun (Bun.connect)');
@@ -2186,40 +2199,67 @@ class SocketClient {
2186
2199
  data: (_s, chunk)=>{
2187
2200
  for (const frame of this.reader.push(chunk)){
2188
2201
  const msg = JSON.parse(frame.toString('utf8'));
2189
- const resolve = this.pending.get(msg.id);
2190
- if (resolve) {
2202
+ const p = this.pending.get(msg.id);
2203
+ if (p) {
2191
2204
  this.pending.delete(msg.id);
2192
- resolve(msg.results);
2205
+ clearTimeout(p.timer);
2206
+ p.resolve(msg.results);
2193
2207
  }
2194
2208
  }
2195
2209
  },
2196
2210
  drain: (_s)=>this.writer.flush(),
2197
2211
  close: ()=>{
2198
2212
  this.socket = void 0;
2213
+ this.reader = new FrameReader();
2214
+ this.failPending(new Error('odblite socket disconnected — request not completed'));
2215
+ },
2216
+ error: (_s, err)=>{
2217
+ this.socket = void 0;
2218
+ this.reader = new FrameReader();
2219
+ this.failPending(err instanceof Error ? err : new Error(`odblite socket error: ${err}`));
2199
2220
  }
2200
2221
  }
2201
2222
  });
2202
2223
  this.writer = new FrameWriter(this.socket);
2203
2224
  }
2204
- async pipeline(tenant, requests) {
2225
+ allocStream() {
2226
+ return this.nextStreamId++;
2227
+ }
2228
+ async pipeline(tenant, requests, stream) {
2229
+ await this.ready;
2205
2230
  if (!this.socket) {
2206
2231
  this.ready = this.connect();
2207
2232
  this.ready.catch(()=>{});
2233
+ await this.ready;
2208
2234
  }
2209
- await this.ready;
2210
2235
  const id = this.nextId++;
2211
- return new Promise((resolve)=>{
2212
- this.pending.set(id, resolve);
2213
- this.writer.send(Buffer.from(JSON.stringify({
2236
+ return new Promise((resolve, reject)=>{
2237
+ const timer = setTimeout(()=>{
2238
+ if (this.pending.delete(id)) reject(new Error(`odblite socket request ${id} timed out after ${REQUEST_TIMEOUT_MS}ms`));
2239
+ }, REQUEST_TIMEOUT_MS);
2240
+ timer.unref?.();
2241
+ this.pending.set(id, {
2242
+ resolve,
2243
+ reject,
2244
+ timer
2245
+ });
2246
+ const payload = null != stream ? {
2247
+ id,
2248
+ tenant,
2249
+ stream,
2250
+ requests
2251
+ } : {
2214
2252
  id,
2215
2253
  tenant,
2216
2254
  requests
2217
- }), 'utf8'));
2255
+ };
2256
+ this.writer.send(Buffer.from(JSON.stringify(payload), 'utf8'));
2218
2257
  });
2219
2258
  }
2220
2259
  close() {
2221
2260
  this.socket?.end?.();
2222
2261
  this.socket = void 0;
2262
+ this.failPending(new Error('odblite socket closed'));
2223
2263
  }
2224
2264
  }
2225
2265
  function toQueryResult(res) {
@@ -2243,21 +2283,22 @@ function toQueryResult(res) {
2243
2283
  class OdbliteSocketConnection {
2244
2284
  client;
2245
2285
  tenant;
2286
+ streamId;
2246
2287
  databaseName;
2247
2288
  databaseHash;
2248
2289
  sql;
2249
- buffer;
2250
- constructor(client, tenant, buffer){
2290
+ beginPending = false;
2291
+ constructor(client, tenant, streamId){
2251
2292
  this.client = client;
2252
2293
  this.tenant = tenant;
2294
+ this.streamId = streamId;
2253
2295
  this.databaseName = tenant;
2254
2296
  this.databaseHash = tenant;
2255
- this.buffer = buffer;
2256
2297
  const self = this;
2257
2298
  this.sql = function(strings, ...values) {
2258
2299
  if (strings && 'object' == typeof strings && 'raw' in strings) {
2259
2300
  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);
2301
+ const returns = mightReturnRows(text);
2261
2302
  return (returns ? self.query(text, values) : self.execute(text, values)).then((r)=>r.rows);
2262
2303
  }
2263
2304
  throw new Error('sql() fragment helper not implemented in odblite-socket');
@@ -2268,7 +2309,32 @@ class OdbliteSocketConnection {
2268
2309
  if (!res) throw new Error('odblite-socket: empty pipeline response');
2269
2310
  return res;
2270
2311
  }
2312
+ async streamExec(sql, params = []) {
2313
+ const reqs = [];
2314
+ if (this.beginPending) {
2315
+ reqs.push({
2316
+ type: 'execute',
2317
+ stmt: {
2318
+ sql: 'BEGIN'
2319
+ }
2320
+ });
2321
+ this.beginPending = false;
2322
+ }
2323
+ reqs.push({
2324
+ type: 'execute',
2325
+ stmt: {
2326
+ sql,
2327
+ args: params.map(toHrana)
2328
+ }
2329
+ });
2330
+ const results = await this.client.pipeline(this.tenant, reqs, this.streamId);
2331
+ if (reqs.length > 1 && results[0]?.type === 'error') throw new Error(results[0].error.message);
2332
+ const res = results[results.length - 1];
2333
+ if (!res) throw new Error('odblite-socket: empty stream response');
2334
+ return toQueryResult(res);
2335
+ }
2271
2336
  async query(sql, params = []) {
2337
+ if (null != this.streamId) return await this.streamExec(sql, params);
2272
2338
  return toQueryResult(await this.one([
2273
2339
  {
2274
2340
  type: 'execute',
@@ -2282,16 +2348,7 @@ class OdbliteSocketConnection {
2282
2348
  async execute(sql, params = []) {
2283
2349
  const s = 'string' == typeof sql ? sql : sql.sql;
2284
2350
  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
- }
2351
+ if (null != this.streamId) return this.streamExec(s, p);
2295
2352
  return toQueryResult(await this.one([
2296
2353
  {
2297
2354
  type: 'execute',
@@ -2320,24 +2377,33 @@ class OdbliteSocketConnection {
2320
2377
  };
2321
2378
  }
2322
2379
  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}`);
2380
+ if (null != this.streamId) return fn(this);
2381
+ const tx = new OdbliteSocketConnection(this.client, this.tenant, this.client.allocStream());
2382
+ tx.beginPending = true;
2383
+ try {
2384
+ const out = await fn(tx);
2385
+ if (!tx.beginPending) await tx.streamExec('COMMIT');
2386
+ return out;
2387
+ } catch (e) {
2388
+ if (!tx.beginPending) await tx.streamExec('ROLLBACK').catch(()=>{});
2389
+ throw e;
2333
2390
  }
2334
- return out;
2335
2391
  }
2336
2392
  begin(fn) {
2337
2393
  return this.transaction(fn);
2338
2394
  }
2339
- savepoint(fn) {
2340
- return this.transaction(fn);
2395
+ async savepoint(fn) {
2396
+ if (null == this.streamId) return this.transaction(fn);
2397
+ const name = `sp_${this.client.allocStream()}`;
2398
+ await this.streamExec(`SAVEPOINT ${name}`);
2399
+ try {
2400
+ const out = await fn(this);
2401
+ await this.streamExec(`RELEASE SAVEPOINT ${name}`);
2402
+ return out;
2403
+ } catch (e) {
2404
+ await this.streamExec(`ROLLBACK TO SAVEPOINT ${name}`).catch(()=>{});
2405
+ throw e;
2406
+ }
2341
2407
  }
2342
2408
  async ensureNamespaces(namespaces) {
2343
2409
  return {
@@ -2375,20 +2441,26 @@ class OdbliteSocketAdapter {
2375
2441
  }
2376
2442
  async isHealthy() {
2377
2443
  try {
2378
- await Promise.race([
2444
+ const res = await Promise.race([
2379
2445
  this.client.pipeline('_health', [
2380
2446
  {
2381
- type: 'get_autocommit'
2447
+ type: 'get_version'
2382
2448
  }
2383
2449
  ]),
2384
2450
  new Promise((_, rej)=>setTimeout(()=>rej(new Error('probe timeout')), 1500))
2385
2451
  ]);
2452
+ const proto = res?.[0]?.response?.protocol;
2453
+ if (proto !== CLIENT_SOCKET_PROTOCOL) {
2454
+ console.warn(`[odb-client] socket protocol mismatch (server=${proto ?? 'none'}, client=${CLIENT_SOCKET_PROTOCOL}) → using HTTP`);
2455
+ return false;
2456
+ }
2386
2457
  return true;
2387
2458
  } catch {
2388
2459
  return false;
2389
2460
  }
2390
2461
  }
2391
2462
  }
2463
+ const CLIENT_SOCKET_PROTOCOL = 2;
2392
2464
  const DEFAULT_UNIX_SOCKET = '/tmp/odblite.sock';
2393
2465
  function parseSocketTarget(s) {
2394
2466
  const v = (s ?? '').trim();
@@ -2409,13 +2481,26 @@ function parseSocketTarget(s) {
2409
2481
  }
2410
2482
  class OdbliteAutoAdapter {
2411
2483
  type = 'odblite';
2484
+ opts;
2412
2485
  chosen;
2486
+ onHttp = false;
2487
+ nextRetryAt = 0;
2413
2488
  constructor(opts){
2414
- this.chosen = this.pick(opts);
2489
+ this.opts = opts;
2490
+ this.chosen = this.pick();
2415
2491
  }
2416
- async pick(opts) {
2492
+ async pick() {
2417
2493
  const hasBun = void 0 !== globalThis.Bun && !!globalThis.Bun.connect;
2418
- if (hasBun) for (const target of this.candidates(opts))try {
2494
+ if (hasBun) {
2495
+ const socket = await this.trySocket();
2496
+ if (socket) return socket;
2497
+ }
2498
+ this.onHttp = true;
2499
+ console.log('[odb-client] transport → HTTP (no router_v2 socket reachable)');
2500
+ return new ODBLiteAdapter(this.opts.http);
2501
+ }
2502
+ async trySocket() {
2503
+ for (const target of this.candidates())try {
2419
2504
  const socket = new OdbliteSocketAdapter(target);
2420
2505
  if (await socket.isHealthy()) {
2421
2506
  console.log(`[odb-client] transport → SOCKET (router_v2 @ ${target.unix ?? `${target.hostname}:${target.port}`})`);
@@ -2423,12 +2508,23 @@ class OdbliteAutoAdapter {
2423
2508
  }
2424
2509
  await socket.disconnect();
2425
2510
  } 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
2511
+ return null;
2512
+ }
2513
+ async maybeUpgrade() {
2514
+ if (!this.onHttp) return;
2515
+ const now = Date.now();
2516
+ if (now < this.nextRetryAt) return;
2517
+ this.nextRetryAt = now + 15000;
2518
+ const socket = await this.trySocket();
2519
+ if (socket) {
2520
+ this.chosen = Promise.resolve(socket);
2521
+ this.onHttp = false;
2522
+ }
2523
+ }
2524
+ candidates() {
2525
+ const { socket, http } = this.opts;
2526
+ if (socket?.unix || socket?.port) return [
2527
+ socket
2432
2528
  ];
2433
2529
  const env = globalThis.process?.env?.ODB_SOCKET;
2434
2530
  if (env) return [
@@ -2440,7 +2536,7 @@ class OdbliteAutoAdapter {
2440
2536
  }
2441
2537
  ];
2442
2538
  try {
2443
- const host = new URL(opts.http?.serviceUrl).hostname;
2539
+ const host = new URL(http?.serviceUrl).hostname;
2444
2540
  const port = Number(globalThis.process?.env?.ODB_SOCKET_PORT ?? 8787);
2445
2541
  if (host) out.push({
2446
2542
  hostname: host,
@@ -2450,6 +2546,7 @@ class OdbliteAutoAdapter {
2450
2546
  return out;
2451
2547
  }
2452
2548
  async connect(config) {
2549
+ await this.maybeUpgrade();
2453
2550
  return (await this.chosen).connect(config);
2454
2551
  }
2455
2552
  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.4.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> {
@@ -7,6 +7,11 @@ import { FrameReader, FrameWriter, type StreamRequest, type StreamResult } from
7
7
  *
8
8
  * Bun-only (uses `Bun.connect`). app-router runs on Bun; the HTTP `odblite`
9
9
  * backend remains the universal fallback for non-Bun environments.
10
+ *
11
+ * Liveness invariant: an in-flight request must ALWAYS terminate. If the
12
+ * connection drops (close/error) or a reply never arrives, the pending promise
13
+ * is REJECTED — never left hanging. A hung pending request would wedge the
14
+ * caller's transaction, hold its write lane, and cascade into a 30s timeout.
10
15
  */
11
16
  export interface SocketTarget {
12
17
  unix?: string
@@ -14,12 +19,24 @@ export interface SocketTarget {
14
19
  port?: number
15
20
  }
16
21
 
22
+ // Backstop: a request that gets neither a reply nor a disconnect (server wedged on
23
+ // it) is rejected after this long, so the caller fails fast + can retry rather than
24
+ // hang forever. Generous by default; tune via ODB_SOCKET_REQUEST_TIMEOUT_MS.
25
+ const REQUEST_TIMEOUT_MS = Number((globalThis as any).process?.env?.ODB_SOCKET_REQUEST_TIMEOUT_MS) || 30000
26
+
27
+ interface Pending {
28
+ resolve: (r: StreamResult[]) => void
29
+ reject: (e: any) => void
30
+ timer: ReturnType<typeof setTimeout>
31
+ }
32
+
17
33
  export class SocketClient {
18
34
  private socket: any
19
35
  private reader = new FrameReader()
20
36
  private writer!: FrameWriter
21
- private readonly pending = new Map<number, (r: StreamResult[]) => void>()
37
+ private readonly pending = new Map<number, Pending>()
22
38
  private nextId = 1
39
+ private nextStreamId = 1
23
40
  private ready: Promise<void>
24
41
 
25
42
  constructor(private readonly target: SocketTarget) {
@@ -29,6 +46,16 @@ export class SocketClient {
29
46
  void this.ready.catch(() => {})
30
47
  }
31
48
 
49
+ /** Reject + clear every in-flight request — called on disconnect/error/close so a
50
+ * dropped connection FAILS its requests (the caller retries) instead of hanging. */
51
+ private failPending(err: Error): void {
52
+ for (const p of this.pending.values()) {
53
+ clearTimeout(p.timer)
54
+ p.reject(err)
55
+ }
56
+ this.pending.clear()
57
+ }
58
+
32
59
  private async connect(): Promise<void> {
33
60
  const Bun = (globalThis as any).Bun
34
61
  if (!Bun?.connect) throw new Error('odblite-socket backend requires Bun (Bun.connect)')
@@ -41,38 +68,67 @@ export class SocketClient {
41
68
  data: (_s: any, chunk: Buffer) => {
42
69
  for (const frame of this.reader.push(chunk)) {
43
70
  const msg = JSON.parse(frame.toString('utf8')) as { id: number; results: StreamResult[] }
44
- const resolve = this.pending.get(msg.id)
45
- if (resolve) {
71
+ const p = this.pending.get(msg.id)
72
+ if (p) {
46
73
  this.pending.delete(msg.id)
47
- resolve(msg.results)
74
+ clearTimeout(p.timer)
75
+ p.resolve(msg.results)
48
76
  }
49
77
  }
50
78
  },
51
79
  drain: (_s: any) => this.writer.flush(),
52
80
  close: () => {
53
- // reconnect lazily on next pipeline()
81
+ // Connection gone → reconnect lazily on next pipeline(). A half-read frame
82
+ // in the buffer is invalid now, and EVERY in-flight request must fail (not
83
+ // hang) so its caller's transaction unwinds + releases the write lane.
54
84
  this.socket = undefined
85
+ this.reader = new FrameReader()
86
+ this.failPending(new Error('odblite socket disconnected — request not completed'))
87
+ },
88
+ error: (_s: any, err: any) => {
89
+ this.socket = undefined
90
+ this.reader = new FrameReader()
91
+ this.failPending(err instanceof Error ? err : new Error(`odblite socket error: ${err}`))
55
92
  },
56
93
  },
57
94
  })
58
95
  this.writer = new FrameWriter(this.socket)
59
96
  }
60
97
 
61
- async pipeline(tenant: string, requests: StreamRequest[]): Promise<StreamResult[]> {
98
+ /** Allocate a fresh stream id for an interactive transaction (unique per connection). The server
99
+ * pins one SQLite connection per stream id, so all of a tx's statements run on it (read-your-own-
100
+ * writes). The id is carried in every frame of that tx via pipeline()'s `stream` arg. */
101
+ allocStream(): number {
102
+ return this.nextStreamId++
103
+ }
104
+
105
+ async pipeline(tenant: string, requests: StreamRequest[], stream?: number): Promise<StreamResult[]> {
106
+ // Await the in-flight connect FIRST — only reconnect if it's actually gone
107
+ // (closed/failed). Checking `!this.socket` before awaiting would start a
108
+ // second connect while the first is still resolving (a leaked connection).
109
+ await this.ready
62
110
  if (!this.socket) {
63
111
  this.ready = this.connect()
64
112
  void this.ready.catch(() => {})
113
+ await this.ready
65
114
  }
66
- await this.ready
67
115
  const id = this.nextId++
68
- return new Promise<StreamResult[]>((resolve) => {
69
- this.pending.set(id, resolve)
70
- this.writer.send(Buffer.from(JSON.stringify({ id, tenant, requests }), 'utf8'))
116
+ return new Promise<StreamResult[]>((resolve, reject) => {
117
+ const timer = setTimeout(() => {
118
+ // No reply AND no disconnect (server wedged) → reject so we never hang forever.
119
+ if (this.pending.delete(id)) reject(new Error(`odblite socket request ${id} timed out after ${REQUEST_TIMEOUT_MS}ms`))
120
+ }, REQUEST_TIMEOUT_MS)
121
+ ;(timer as any).unref?.()
122
+ this.pending.set(id, { resolve, reject, timer })
123
+ // `stream` present → the server routes this frame to that interactive tx's pinned connection.
124
+ const payload = stream != null ? { id, tenant, stream, requests } : { id, tenant, requests }
125
+ this.writer.send(Buffer.from(JSON.stringify(payload), 'utf8'))
71
126
  })
72
127
  }
73
128
 
74
129
  close(): void {
75
130
  this.socket?.end?.()
76
131
  this.socket = undefined
132
+ this.failPending(new Error('odblite socket closed'))
77
133
  }
78
134
  }
@@ -88,6 +88,7 @@ export type StreamRequest =
88
88
  | { type: 'batch'; batch: Batch }
89
89
  | { type: 'sequence'; sql: string }
90
90
  | { type: 'get_autocommit' }
91
+ | { type: 'get_version' } // connect-time wire-protocol handshake
91
92
  | { type: 'close' }
92
93
  export type StreamResult =
93
94
  | { type: 'ok'; response: any }
@@ -109,6 +110,15 @@ export function toBatch(steps: AtomicStep[]): Batch {
109
110
  }
110
111
  }
111
112
 
113
+ // ── SQL classification (client-side heuristic) ────────────────────────────────
114
+ // Mirrors router_v2/engine/sql-kind.ts (kept in lockstep, like the wire types).
115
+ // The SERVER is authoritative (it checks the prepared statement's column count);
116
+ // this only picks the `sql``` convenience tag's shape before the round-trip.
117
+ const READ_ONLY_RE = /^\s*(?:SELECT|EXPLAIN|VALUES)\b/i
118
+ export function mightReturnRows(sql: string): boolean {
119
+ return READ_ONLY_RE.test(sql) || /\bRETURNING\b/i.test(sql)
120
+ }
121
+
112
122
  // ── length-prefixed framing with backpressure-safe writes ─────────────────────
113
123
  interface Writable {
114
124
  write(data: Uint8Array): number