@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.
@@ -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,32 @@ 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
+ // Auto-reconnect: when the socket is down (router restarting), retry the CONNECT with
28
+ // backoff so a request that lands during the restart window waits it out instead of
29
+ // failing — a redeploy/restart becomes transparent. Bounded so a truly-dead server
30
+ // still fails the request (caller retries / falls back to HTTP) rather than hanging.
31
+ const RECONNECT_ATTEMPTS = Number((globalThis as any).process?.env?.ODB_SOCKET_RECONNECT_ATTEMPTS) || 6
32
+ const RECONNECT_BASE_MS = Number((globalThis as any).process?.env?.ODB_SOCKET_RECONNECT_BASE_MS) || 50
33
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
34
+
35
+ interface Pending {
36
+ resolve: (r: StreamResult[]) => void
37
+ reject: (e: any) => void
38
+ timer: ReturnType<typeof setTimeout>
39
+ }
40
+
17
41
  export class SocketClient {
18
42
  private socket: any
19
43
  private reader = new FrameReader()
20
44
  private writer!: FrameWriter
21
- private readonly pending = new Map<number, (r: StreamResult[]) => void>()
45
+ private readonly pending = new Map<number, Pending>()
22
46
  private nextId = 1
47
+ private nextStreamId = 1
23
48
  private ready: Promise<void>
24
49
 
25
50
  constructor(private readonly target: SocketTarget) {
@@ -29,6 +54,16 @@ export class SocketClient {
29
54
  void this.ready.catch(() => {})
30
55
  }
31
56
 
57
+ /** Reject + clear every in-flight request — called on disconnect/error/close so a
58
+ * dropped connection FAILS its requests (the caller retries) instead of hanging. */
59
+ private failPending(err: Error): void {
60
+ for (const p of this.pending.values()) {
61
+ clearTimeout(p.timer)
62
+ p.reject(err)
63
+ }
64
+ this.pending.clear()
65
+ }
66
+
32
67
  private async connect(): Promise<void> {
33
68
  const Bun = (globalThis as any).Bun
34
69
  if (!Bun?.connect) throw new Error('odblite-socket backend requires Bun (Bun.connect)')
@@ -38,41 +73,104 @@ export class SocketClient {
38
73
  this.socket = await Bun.connect({
39
74
  ...opts,
40
75
  socket: {
41
- data: (_s: any, chunk: Buffer) => {
42
- for (const frame of this.reader.push(chunk)) {
76
+ data: (s: any, chunk: Buffer) => {
77
+ // A framing error (oversized/corrupt length prefix from a buggy/hostile server) makes the byte
78
+ // stream unrecoverable — drop the connection and fail in-flight requests, exactly like a close.
79
+ // Without this the throw escapes the Bun `data` callback uncaught.
80
+ let frames: Buffer[]
81
+ try {
82
+ frames = this.reader.push(chunk)
83
+ } catch (err: any) {
84
+ this.socket = undefined
85
+ this.reader = new FrameReader()
86
+ try {
87
+ s.end?.()
88
+ } catch {}
89
+ this.failPending(err instanceof Error ? err : new Error(`odblite framing error: ${err}`))
90
+ return
91
+ }
92
+ for (const frame of frames) {
43
93
  const msg = JSON.parse(frame.toString('utf8')) as { id: number; results: StreamResult[] }
44
- const resolve = this.pending.get(msg.id)
45
- if (resolve) {
94
+ const p = this.pending.get(msg.id)
95
+ if (p) {
46
96
  this.pending.delete(msg.id)
47
- resolve(msg.results)
97
+ clearTimeout(p.timer)
98
+ p.resolve(msg.results)
48
99
  }
49
100
  }
50
101
  },
51
102
  drain: (_s: any) => this.writer.flush(),
52
103
  close: () => {
53
- // reconnect lazily on next pipeline()
104
+ // Connection gone → reconnect lazily on next pipeline(). A half-read frame
105
+ // in the buffer is invalid now, and EVERY in-flight request must fail (not
106
+ // hang) so its caller's transaction unwinds + releases the write lane.
107
+ this.socket = undefined
108
+ this.reader = new FrameReader()
109
+ this.failPending(new Error('odblite socket disconnected — request not completed'))
110
+ },
111
+ error: (_s: any, err: any) => {
54
112
  this.socket = undefined
113
+ this.reader = new FrameReader()
114
+ this.failPending(err instanceof Error ? err : new Error(`odblite socket error: ${err}`))
55
115
  },
56
116
  },
57
117
  })
58
118
  this.writer = new FrameWriter(this.socket)
59
119
  }
60
120
 
61
- async pipeline(tenant: string, requests: StreamRequest[]): Promise<StreamResult[]> {
121
+ /** Reconnect with bounded backoff bridges a router restart so a request landing in the down-window
122
+ * waits it out instead of failing. After the last attempt the error propagates (caller retries / the
123
+ * auto-adapter can fall back to HTTP). Backoff: 50·100·200·400·800·1600ms ≈ 3s total by default. */
124
+ private async connectWithRetry(): Promise<void> {
125
+ let lastErr: any
126
+ for (let attempt = 0; attempt < RECONNECT_ATTEMPTS; attempt++) {
127
+ try {
128
+ await this.connect()
129
+ return
130
+ } catch (err) {
131
+ lastErr = err
132
+ if (attempt < RECONNECT_ATTEMPTS - 1) await sleep(RECONNECT_BASE_MS * 2 ** attempt)
133
+ }
134
+ }
135
+ throw lastErr instanceof Error ? lastErr : new Error(`odblite socket reconnect failed: ${lastErr}`)
136
+ }
137
+
138
+ /** Allocate a fresh stream id for an interactive transaction (unique per connection). The server
139
+ * pins one SQLite connection per stream id, so all of a tx's statements run on it (read-your-own-
140
+ * writes). The id is carried in every frame of that tx via pipeline()'s `stream` arg. */
141
+ allocStream(): number {
142
+ return this.nextStreamId++
143
+ }
144
+
145
+ async pipeline(tenant: string, requests: StreamRequest[], stream?: number): Promise<StreamResult[]> {
146
+ // Await the in-flight connect FIRST — only reconnect if it's actually gone
147
+ // (closed/failed). Checking `!this.socket` before awaiting would start a
148
+ // second connect while the first is still resolving (a leaked connection).
149
+ await this.ready.catch(() => {}) // a prior failed connect must not poison this await; we re-check below
62
150
  if (!this.socket) {
63
- this.ready = this.connect()
151
+ // Reconnect with backoff (bridges a restart). Shared promise so concurrent pipeline() callers
152
+ // ride ONE reconnect, not N. If it ultimately fails, every caller rejects (then retries / HTTP).
153
+ this.ready = this.connectWithRetry()
64
154
  void this.ready.catch(() => {})
155
+ await this.ready
65
156
  }
66
- await this.ready
67
157
  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'))
158
+ return new Promise<StreamResult[]>((resolve, reject) => {
159
+ const timer = setTimeout(() => {
160
+ // No reply AND no disconnect (server wedged) → reject so we never hang forever.
161
+ if (this.pending.delete(id)) reject(new Error(`odblite socket request ${id} timed out after ${REQUEST_TIMEOUT_MS}ms`))
162
+ }, REQUEST_TIMEOUT_MS)
163
+ ;(timer as any).unref?.()
164
+ this.pending.set(id, { resolve, reject, timer })
165
+ // `stream` present → the server routes this frame to that interactive tx's pinned connection.
166
+ const payload = stream != null ? { id, tenant, stream, requests } : { id, tenant, requests }
167
+ this.writer.send(Buffer.from(JSON.stringify(payload), 'utf8'))
71
168
  })
72
169
  }
73
170
 
74
171
  close(): void {
75
172
  this.socket?.end?.()
76
173
  this.socket = undefined
174
+ this.failPending(new Error('odblite socket closed'))
77
175
  }
78
176
  }
@@ -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
@@ -131,6 +141,10 @@ export class FrameWriter {
131
141
  }
132
142
  }
133
143
  }
144
+ // Reject an insane length prefix instead of buffering gigabytes for bytes that never arrive — a buggy or
145
+ // hostile server shouldn't be able to OOM the client inside the Bun `data` callback. Matches the router's
146
+ // frame cap; tune via ODB_MAX_FRAME_MB.
147
+ const MAX_FRAME = (Number((globalThis as any).process?.env?.ODB_MAX_FRAME_MB) || 64) * 1024 * 1024
134
148
  export class FrameReader {
135
149
  private buf: Buffer = Buffer.alloc(0)
136
150
  push(chunk: Buffer): Buffer[] {
@@ -138,6 +152,7 @@ export class FrameReader {
138
152
  const out: Buffer[] = []
139
153
  while (this.buf.length >= 4) {
140
154
  const len = this.buf.readUInt32LE(0)
155
+ if (len > MAX_FRAME) throw new Error(`odblite reply frame too large: ${len} bytes (max ${MAX_FRAME})`)
141
156
  if (this.buf.length < 4 + len) break
142
157
  out.push(this.buf.subarray(4, 4 + len))
143
158
  this.buf = this.buf.subarray(4 + len)