@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
|
@@ -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,
|
|
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: (
|
|
42
|
-
|
|
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
|
|
45
|
-
if (
|
|
94
|
+
const p = this.pending.get(msg.id)
|
|
95
|
+
if (p) {
|
|
46
96
|
this.pending.delete(msg.id)
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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)
|