@pineliner/odb-client 1.1.5 → 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.
- package/dist/database/adapters/odblite-socket.d.ts +65 -0
- package/dist/database/adapters/odblite-socket.d.ts.map +1 -0
- package/dist/database/adapters/socket/client.d.ts +41 -0
- package/dist/database/adapters/socket/client.d.ts.map +1 -0
- package/dist/database/adapters/socket/protocol.d.ts +132 -0
- package/dist/database/adapters/socket/protocol.d.ts.map +1 -0
- package/dist/database/manager.d.ts.map +1 -1
- package/dist/database/types.d.ts +6 -1
- package/dist/database/types.d.ts.map +1 -1
- package/dist/index.cjs +522 -1
- package/dist/index.js +522 -1
- package/package.json +1 -1
- package/src/database/adapters/odblite-socket.ts +335 -0
- package/src/database/adapters/socket/client.ts +134 -0
- package/src/database/adapters/socket/protocol.ts +157 -0
- package/src/database/manager.ts +19 -1
- package/src/database/types.ts +8 -1
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import type { BackendType, Connection, DatabaseAdapter, PreparedStatement, QueryResult } from '../types'
|
|
2
|
+
import { ODBLiteAdapter } from './odblite'
|
|
3
|
+
import { SocketClient, type SocketTarget } from './socket/client'
|
|
4
|
+
import {
|
|
5
|
+
toHrana,
|
|
6
|
+
fromHrana,
|
|
7
|
+
toBatch,
|
|
8
|
+
mightReturnRows,
|
|
9
|
+
type AtomicStep,
|
|
10
|
+
type BatchResult,
|
|
11
|
+
type StmtResult,
|
|
12
|
+
type StreamRequest,
|
|
13
|
+
type StreamResult,
|
|
14
|
+
} from './socket/protocol'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* odblite-socket — the FAST cross-process backend: raw socket + JSON framing to
|
|
18
|
+
* router_v2 (bun:sqlite engine). REPLACES the HTTP `odblite` backend (one hop, no
|
|
19
|
+
* HTTP parse) — it does NOT layer on it. Same `Connection` interface, so it's a
|
|
20
|
+
* drop-in: app-router flips `backend: 'odblite'` → `'odblite-socket'`.
|
|
21
|
+
*
|
|
22
|
+
* Write discipline (the separate-process model forces it): a unit of write work
|
|
23
|
+
* is ONE batch in ONE round-trip — the server runs BEGIN…COMMIT on its side, the
|
|
24
|
+
* client never holds the write slot across the wire. So `atomic(steps)` is the
|
|
25
|
+
* write primitive; `transaction(fn)` BUFFERS writes and flushes them as one
|
|
26
|
+
* atomic batch on commit (no read-your-own-uncommitted-writes mid-tx — that would
|
|
27
|
+
* re-create the slot-starvation the design avoids). Read-compute-write → read
|
|
28
|
+
* first, compute, write one atomic().
|
|
29
|
+
*/
|
|
30
|
+
function toQueryResult(res: StreamResult): QueryResult {
|
|
31
|
+
if (res.type === 'error') throw new Error(res.error.message)
|
|
32
|
+
const r = (res as any).response.result as StmtResult
|
|
33
|
+
const rows = r.rows.map((row) => {
|
|
34
|
+
const obj: Record<string, any> = {}
|
|
35
|
+
r.cols.forEach((c, i) => {
|
|
36
|
+
obj[c.name ?? `c${i}`] = fromHrana(row[i] ?? { type: 'null' })
|
|
37
|
+
})
|
|
38
|
+
return obj
|
|
39
|
+
})
|
|
40
|
+
return { rows, rowsAffected: r.affected_row_count, lastInsertRowid: r.last_insert_rowid ? Number(r.last_insert_rowid) : undefined }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class OdbliteSocketConnection implements Connection {
|
|
44
|
+
databaseName?: string
|
|
45
|
+
databaseHash?: string
|
|
46
|
+
public sql: 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
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly client: SocketClient,
|
|
53
|
+
private readonly tenant: string,
|
|
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,
|
|
57
|
+
) {
|
|
58
|
+
this.databaseName = tenant
|
|
59
|
+
this.databaseHash = tenant
|
|
60
|
+
const self = this
|
|
61
|
+
this.sql = function (strings: any, ...values: any[]): any {
|
|
62
|
+
if (strings && typeof strings === 'object' && 'raw' in strings) {
|
|
63
|
+
const text = (strings as string[]).reduce((a, s, i) => a + s + (i < values.length ? '?' : ''), '')
|
|
64
|
+
const returns = mightReturnRows(text)
|
|
65
|
+
return (returns ? self.query(text, values) : self.execute(text, values)).then((r: any) => r.rows)
|
|
66
|
+
}
|
|
67
|
+
throw new Error('sql() fragment helper not implemented in odblite-socket')
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One request → its single result, failing loud on an empty response. */
|
|
72
|
+
private async one(requests: StreamRequest[]): Promise<StreamResult> {
|
|
73
|
+
const [res] = await this.client.pipeline(this.tenant, requests)
|
|
74
|
+
if (!res) throw new Error('odblite-socket: empty pipeline response')
|
|
75
|
+
return res
|
|
76
|
+
}
|
|
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
|
+
|
|
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>
|
|
96
|
+
return toQueryResult(await this.one([{ type: 'execute', stmt: { sql, args: params.map(toHrana) } }])) as QueryResult<T>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async execute(sql: string | { sql: string; args?: any[] }, params: any[] = []): Promise<QueryResult> {
|
|
100
|
+
const s = typeof sql === 'string' ? sql : sql.sql
|
|
101
|
+
const p = typeof sql === 'string' ? params : (sql.args ?? [])
|
|
102
|
+
if (this.streamId != null) return this.streamExec(s, p) // on the pinned stream — runs immediately, NOT buffered
|
|
103
|
+
return toQueryResult(await this.one([{ type: 'execute', stmt: { sql: s, args: p.map(toHrana) } }]))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Guarded atomic batch — the primary remote write primitive (one round-trip). */
|
|
107
|
+
async atomic(steps: AtomicStep[]): Promise<BatchResult> {
|
|
108
|
+
const res = await this.one([{ type: 'batch', batch: toBatch(steps) }])
|
|
109
|
+
if (res.type === 'error') throw new Error(res.error.message)
|
|
110
|
+
return (res as any).response.result as BatchResult
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
prepare(sql: string): PreparedStatement {
|
|
114
|
+
return {
|
|
115
|
+
execute: async (params = []) => this.execute(sql, params),
|
|
116
|
+
all: async (params = []) => (await this.query(sql, params)).rows,
|
|
117
|
+
get: async (params = []) => (await this.query(sql, params)).rows[0] ?? null,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
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
|
+
*/
|
|
127
|
+
async transaction<T>(fn: (tx: Connection) => Promise<T>): Promise<T> {
|
|
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
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
begin<T>(fn: (tx: Connection) => Promise<T>): Promise<T> {
|
|
141
|
+
return this.transaction(fn)
|
|
142
|
+
}
|
|
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
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// router_v2 auto-ATTACHes a tenant's namespaces on open → these are no-ops.
|
|
160
|
+
async ensureNamespaces(namespaces: string[]) {
|
|
161
|
+
return { success: true, databaseName: this.tenant, namespaces }
|
|
162
|
+
}
|
|
163
|
+
async getNamespaces() {
|
|
164
|
+
const ns = (await this.query("SELECT name FROM pragma_database_list WHERE name != 'main'")).rows.map((r: any) => r.name)
|
|
165
|
+
return { success: true, databaseName: this.tenant, exists: true, namespaces: ns, registered: true, registeredNamespaces: ns }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async close(): Promise<void> {
|
|
169
|
+
/* the SocketClient is shared + owned by the adapter; per-connection close is a no-op */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface OdbliteSocketConfig extends SocketTarget {}
|
|
174
|
+
|
|
175
|
+
export class OdbliteSocketAdapter implements DatabaseAdapter {
|
|
176
|
+
readonly type: BackendType = 'odblite-socket'
|
|
177
|
+
private readonly client: SocketClient
|
|
178
|
+
|
|
179
|
+
constructor(config: OdbliteSocketConfig) {
|
|
180
|
+
this.client = new SocketClient(config)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async connect(config: any): Promise<Connection> {
|
|
184
|
+
const tenant = config?.databaseHash ?? config?.databaseName
|
|
185
|
+
if (!tenant) throw new Error('odblite-socket: databaseHash (or databaseName) required')
|
|
186
|
+
return new OdbliteSocketConnection(this.client, String(tenant))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async disconnect(): Promise<void> {
|
|
190
|
+
this.client.close()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async isHealthy(): Promise<boolean> {
|
|
194
|
+
try {
|
|
195
|
+
const res: any = await Promise.race([
|
|
196
|
+
this.client.pipeline('_health', [{ type: 'get_version' }]),
|
|
197
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error('probe timeout')), 1500)),
|
|
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
|
+
}
|
|
207
|
+
return true
|
|
208
|
+
} catch {
|
|
209
|
+
return false
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
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
|
+
|
|
219
|
+
/** Default same-host unix socket (free — v1 only uses /tmp/odblite-internal.sock). */
|
|
220
|
+
export const DEFAULT_UNIX_SOCKET = '/tmp/odblite.sock'
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Resolve the odblite-socket target from a string. `ODB_SOCKET` may be:
|
|
224
|
+
* - a UNIX path (`/tmp/odblite.sock`) — same host, or a SHARED VOLUME mount
|
|
225
|
+
* - `host:port` (`odblite:8787`) — TCP, the CROSS-CONTAINER Docker case
|
|
226
|
+
* - `:port` / `port` — TCP on localhost
|
|
227
|
+
* A /tmp unix socket does NOT cross Docker containers, so container-to-container
|
|
228
|
+
* deployments set `ODB_SOCKET=odblite:8787` (TCP over the Docker network).
|
|
229
|
+
*/
|
|
230
|
+
export function parseSocketTarget(s?: string): SocketTarget {
|
|
231
|
+
const v = (s ?? '').trim()
|
|
232
|
+
if (!v) return { unix: DEFAULT_UNIX_SOCKET }
|
|
233
|
+
if (v.includes('/')) return { unix: v } // a path → unix socket
|
|
234
|
+
const m = v.match(/^(?:([^:]+):)?(\d+)$/) // host:port | :port | port → TCP
|
|
235
|
+
if (m) return { hostname: m[1] || '127.0.0.1', port: Number(m[2]) }
|
|
236
|
+
return { unix: v }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* OdbliteAutoAdapter — the default for the `odblite` backend: ZERO config change,
|
|
241
|
+
* autodetected as a cascade unix socket → TCP socket → HTTP. At startup it probes
|
|
242
|
+
* each in order and uses the FIRST that answers:
|
|
243
|
+
* 1. unix socket /tmp/odblite.sock — same host (fastest)
|
|
244
|
+
* 2. TCP socket <ODB_LITE_URL host>:8787 — cross-container Docker; host is
|
|
245
|
+
* DERIVED from the existing ODB_LITE_URL, so Docker needs no new config
|
|
246
|
+
* 3. HTTP ODB_LITE_URL — the universal fallback
|
|
247
|
+
* Explicit `ODB_SOCKET` (unix path or host:port) or an `odbliteSocket` config
|
|
248
|
+
* pins a single socket target instead of the cascade. So same-host dev and
|
|
249
|
+
* cross-container prod both "just work", and a missing/down socket → HTTP.
|
|
250
|
+
* (Browser / non-Bun → straight to HTTP.)
|
|
251
|
+
*/
|
|
252
|
+
export class OdbliteAutoAdapter implements DatabaseAdapter {
|
|
253
|
+
readonly type: BackendType = 'odblite'
|
|
254
|
+
private readonly opts: { socket?: OdbliteSocketConfig; http: any }
|
|
255
|
+
private chosen: Promise<DatabaseAdapter>
|
|
256
|
+
private onHttp = false
|
|
257
|
+
private nextRetryAt = 0
|
|
258
|
+
|
|
259
|
+
constructor(opts: { socket?: OdbliteSocketConfig; http: any }) {
|
|
260
|
+
this.opts = opts
|
|
261
|
+
this.chosen = this.pick()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private async pick(): Promise<DatabaseAdapter> {
|
|
265
|
+
const hasBun = typeof (globalThis as any).Bun !== 'undefined' && !!(globalThis as any).Bun.connect
|
|
266
|
+
if (hasBun) {
|
|
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
|
|
283
|
+
}
|
|
284
|
+
await socket.disconnect()
|
|
285
|
+
} catch {
|
|
286
|
+
/* try the next candidate */
|
|
287
|
+
}
|
|
288
|
+
}
|
|
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
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Ordered socket targets to probe. Explicit override → a single target; else the
|
|
308
|
+
* zero-config cascade unix (same host) → TCP (host derived from ODB_LITE_URL). */
|
|
309
|
+
private candidates(): SocketTarget[] {
|
|
310
|
+
const { socket, http } = this.opts
|
|
311
|
+
if (socket?.unix || socket?.port) return [socket]
|
|
312
|
+
const env = (globalThis as any).process?.env?.ODB_SOCKET
|
|
313
|
+
if (env) return [parseSocketTarget(env)]
|
|
314
|
+
const out: SocketTarget[] = [{ unix: DEFAULT_UNIX_SOCKET }]
|
|
315
|
+
try {
|
|
316
|
+
const host = new URL(http?.serviceUrl).hostname // e.g. odb-lite from http://odb-lite:8671
|
|
317
|
+
const port = Number((globalThis as any).process?.env?.ODB_SOCKET_PORT ?? 8787)
|
|
318
|
+
if (host) out.push({ hostname: host, port })
|
|
319
|
+
} catch {
|
|
320
|
+
/* serviceUrl unparseable → no TCP candidate, HTTP will catch it */
|
|
321
|
+
}
|
|
322
|
+
return out
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async connect(config: any): Promise<Connection> {
|
|
326
|
+
await this.maybeUpgrade()
|
|
327
|
+
return (await this.chosen).connect(config)
|
|
328
|
+
}
|
|
329
|
+
async disconnect(tenantId?: string): Promise<void> {
|
|
330
|
+
return (await this.chosen).disconnect(tenantId)
|
|
331
|
+
}
|
|
332
|
+
async isHealthy(): Promise<boolean> {
|
|
333
|
+
return (await this.chosen).isHealthy()
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { FrameReader, FrameWriter, type StreamRequest, type StreamResult } from './protocol'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Persistent socket client for the odblite-socket transport. One connection,
|
|
5
|
+
* length-prefixed frames, replies correlated by id. Batch many statements into
|
|
6
|
+
* one `requests[]` so N statements cost one round-trip.
|
|
7
|
+
*
|
|
8
|
+
* Bun-only (uses `Bun.connect`). app-router runs on Bun; the HTTP `odblite`
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
export interface SocketTarget {
|
|
17
|
+
unix?: string
|
|
18
|
+
hostname?: string
|
|
19
|
+
port?: number
|
|
20
|
+
}
|
|
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
|
+
|
|
33
|
+
export class SocketClient {
|
|
34
|
+
private socket: any
|
|
35
|
+
private reader = new FrameReader()
|
|
36
|
+
private writer!: FrameWriter
|
|
37
|
+
private readonly pending = new Map<number, Pending>()
|
|
38
|
+
private nextId = 1
|
|
39
|
+
private nextStreamId = 1
|
|
40
|
+
private ready: Promise<void>
|
|
41
|
+
|
|
42
|
+
constructor(private readonly target: SocketTarget) {
|
|
43
|
+
this.ready = this.connect()
|
|
44
|
+
// A failed initial connect (e.g. socket not up) surfaces via pipeline()'s
|
|
45
|
+
// await — don't let the orphaned promise become an unhandled rejection.
|
|
46
|
+
void this.ready.catch(() => {})
|
|
47
|
+
}
|
|
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
|
+
|
|
59
|
+
private async connect(): Promise<void> {
|
|
60
|
+
const Bun = (globalThis as any).Bun
|
|
61
|
+
if (!Bun?.connect) throw new Error('odblite-socket backend requires Bun (Bun.connect)')
|
|
62
|
+
const opts: any = this.target.unix
|
|
63
|
+
? { unix: this.target.unix }
|
|
64
|
+
: { hostname: this.target.hostname ?? '127.0.0.1', port: this.target.port ?? 8787 }
|
|
65
|
+
this.socket = await Bun.connect({
|
|
66
|
+
...opts,
|
|
67
|
+
socket: {
|
|
68
|
+
data: (_s: any, chunk: Buffer) => {
|
|
69
|
+
for (const frame of this.reader.push(chunk)) {
|
|
70
|
+
const msg = JSON.parse(frame.toString('utf8')) as { id: number; results: StreamResult[] }
|
|
71
|
+
const p = this.pending.get(msg.id)
|
|
72
|
+
if (p) {
|
|
73
|
+
this.pending.delete(msg.id)
|
|
74
|
+
clearTimeout(p.timer)
|
|
75
|
+
p.resolve(msg.results)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
drain: (_s: any) => this.writer.flush(),
|
|
80
|
+
close: () => {
|
|
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.
|
|
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}`))
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
this.writer = new FrameWriter(this.socket)
|
|
96
|
+
}
|
|
97
|
+
|
|
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
|
|
110
|
+
if (!this.socket) {
|
|
111
|
+
this.ready = this.connect()
|
|
112
|
+
void this.ready.catch(() => {})
|
|
113
|
+
await this.ready
|
|
114
|
+
}
|
|
115
|
+
const id = this.nextId++
|
|
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'))
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
close(): void {
|
|
130
|
+
this.socket?.end?.()
|
|
131
|
+
this.socket = undefined
|
|
132
|
+
this.failPending(new Error('odblite socket closed'))
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* odblite-socket wire protocol (CLIENT side).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ COUPLING: this must stay byte-compatible with the SERVER in
|
|
5
|
+
* `packages/odb-lite/router_v2/protocol/{hrana,framing}.ts`. They are deliberately
|
|
6
|
+
* separate copies (the client lib must not import the server engine) — same rule
|
|
7
|
+
* as the /ext test-service mirror: change one wire shape, change the other.
|
|
8
|
+
*
|
|
9
|
+
* Transport: `[u32 LE length][utf8 JSON]` frames over a unix socket / TCP. Each
|
|
10
|
+
* request is `{ id, tenant, requests }`; the reply is `{ id, results }`. The
|
|
11
|
+
* payload stays JSON on purpose (native JSON.parse beats a hand-rolled JS codec;
|
|
12
|
+
* a binary payload only pays off remote, via a native codec).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ── value encoding (64-bit-safe: integers travel as strings) ──────────────────
|
|
16
|
+
export type HranaValue =
|
|
17
|
+
| { type: 'null' }
|
|
18
|
+
| { type: 'integer'; value: string }
|
|
19
|
+
| { type: 'float'; value: number }
|
|
20
|
+
| { type: 'text'; value: string }
|
|
21
|
+
| { type: 'blob'; base64: string }
|
|
22
|
+
|
|
23
|
+
export function toHrana(v: any): HranaValue {
|
|
24
|
+
if (v === null || v === undefined) return { type: 'null' }
|
|
25
|
+
if (typeof v === 'bigint') return { type: 'integer', value: v.toString() }
|
|
26
|
+
if (typeof v === 'number') return Number.isInteger(v) ? { type: 'integer', value: String(v) } : { type: 'float', value: v }
|
|
27
|
+
if (typeof v === 'string') return { type: 'text', value: v }
|
|
28
|
+
if (v instanceof Uint8Array) return { type: 'blob', base64: Buffer.from(v).toString('base64') }
|
|
29
|
+
if (typeof v === 'boolean') return { type: 'integer', value: v ? '1' : '0' }
|
|
30
|
+
return { type: 'text', value: String(v) }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function fromHrana(v: HranaValue): any {
|
|
34
|
+
switch (v.type) {
|
|
35
|
+
case 'null':
|
|
36
|
+
return null
|
|
37
|
+
case 'integer': {
|
|
38
|
+
const n = Number(v.value)
|
|
39
|
+
return Number.isSafeInteger(n) ? n : BigInt(v.value)
|
|
40
|
+
}
|
|
41
|
+
case 'float':
|
|
42
|
+
return v.value
|
|
43
|
+
case 'text':
|
|
44
|
+
return v.value
|
|
45
|
+
case 'blob':
|
|
46
|
+
return new Uint8Array(Buffer.from(v.base64, 'base64'))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── message types ─────────────────────────────────────────────────────────────
|
|
51
|
+
export interface Stmt {
|
|
52
|
+
sql?: string
|
|
53
|
+
args?: HranaValue[]
|
|
54
|
+
named_args?: { name: string; value: HranaValue }[]
|
|
55
|
+
want_rows?: boolean
|
|
56
|
+
}
|
|
57
|
+
export type BatchCond =
|
|
58
|
+
| { type: 'ok'; step: number }
|
|
59
|
+
| { type: 'error'; step: number }
|
|
60
|
+
| { type: 'not'; cond: BatchCond }
|
|
61
|
+
| { type: 'and'; conds: BatchCond[] }
|
|
62
|
+
| { type: 'or'; conds: BatchCond[] }
|
|
63
|
+
| { type: 'is_autocommit' }
|
|
64
|
+
export interface BatchStep {
|
|
65
|
+
condition?: BatchCond | null
|
|
66
|
+
stmt: Stmt
|
|
67
|
+
expect?: { affected: 'one' | 'atLeastOne' | number }
|
|
68
|
+
}
|
|
69
|
+
export interface Batch {
|
|
70
|
+
steps: BatchStep[]
|
|
71
|
+
}
|
|
72
|
+
export interface StmtResult {
|
|
73
|
+
cols: { name: string | null }[]
|
|
74
|
+
rows: HranaValue[][]
|
|
75
|
+
affected_row_count: number
|
|
76
|
+
last_insert_rowid: string | null
|
|
77
|
+
}
|
|
78
|
+
export interface BatchResult {
|
|
79
|
+
step_results: (StmtResult | null)[]
|
|
80
|
+
step_errors: ({ message: string; code?: string } | null)[]
|
|
81
|
+
}
|
|
82
|
+
export interface HranaError {
|
|
83
|
+
message: string
|
|
84
|
+
code?: string
|
|
85
|
+
}
|
|
86
|
+
export type StreamRequest =
|
|
87
|
+
| { type: 'execute'; stmt: Stmt }
|
|
88
|
+
| { type: 'batch'; batch: Batch }
|
|
89
|
+
| { type: 'sequence'; sql: string }
|
|
90
|
+
| { type: 'get_autocommit' }
|
|
91
|
+
| { type: 'get_version' } // connect-time wire-protocol handshake
|
|
92
|
+
| { type: 'close' }
|
|
93
|
+
export type StreamResult =
|
|
94
|
+
| { type: 'ok'; response: any }
|
|
95
|
+
| { type: 'error'; error: HranaError }
|
|
96
|
+
|
|
97
|
+
// ── guarded-batch helper (the §8g atomic primitive) ───────────────────────────
|
|
98
|
+
export interface AtomicStep {
|
|
99
|
+
sql: string
|
|
100
|
+
params?: any[]
|
|
101
|
+
expect?: 'one' | 'atLeastOne' | number
|
|
102
|
+
}
|
|
103
|
+
export function toBatch(steps: AtomicStep[]): Batch {
|
|
104
|
+
return {
|
|
105
|
+
steps: steps.map((s, i) => ({
|
|
106
|
+
condition: i === 0 ? undefined : { type: 'ok', step: i - 1 },
|
|
107
|
+
stmt: { sql: s.sql, args: (s.params ?? []).map(toHrana) },
|
|
108
|
+
expect: s.expect ? { affected: s.expect } : undefined,
|
|
109
|
+
})),
|
|
110
|
+
}
|
|
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
|
+
|
|
122
|
+
// ── length-prefixed framing with backpressure-safe writes ─────────────────────
|
|
123
|
+
interface Writable {
|
|
124
|
+
write(data: Uint8Array): number
|
|
125
|
+
}
|
|
126
|
+
export class FrameWriter {
|
|
127
|
+
private pending: Buffer = Buffer.alloc(0)
|
|
128
|
+
constructor(private readonly socket: Writable) {}
|
|
129
|
+
send(payload: Buffer): void {
|
|
130
|
+
const header = Buffer.allocUnsafe(4)
|
|
131
|
+
header.writeUInt32LE(payload.length, 0)
|
|
132
|
+
const frame = Buffer.concat([header, payload])
|
|
133
|
+
this.pending = this.pending.length ? Buffer.concat([this.pending, frame]) : frame
|
|
134
|
+
this.flush()
|
|
135
|
+
}
|
|
136
|
+
flush(): void {
|
|
137
|
+
while (this.pending.length > 0) {
|
|
138
|
+
const n = this.socket.write(this.pending)
|
|
139
|
+
if (n <= 0) return // backpressured — drain() re-calls flush()
|
|
140
|
+
this.pending = n < this.pending.length ? this.pending.subarray(n) : Buffer.alloc(0)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export class FrameReader {
|
|
145
|
+
private buf: Buffer = Buffer.alloc(0)
|
|
146
|
+
push(chunk: Buffer): Buffer[] {
|
|
147
|
+
this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk
|
|
148
|
+
const out: Buffer[] = []
|
|
149
|
+
while (this.buf.length >= 4) {
|
|
150
|
+
const len = this.buf.readUInt32LE(0)
|
|
151
|
+
if (this.buf.length < 4 + len) break
|
|
152
|
+
out.push(this.buf.subarray(4, 4 + len))
|
|
153
|
+
this.buf = this.buf.subarray(4 + len)
|
|
154
|
+
}
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/database/manager.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
import { BunSQLiteAdapter } from './adapters/bun-sqlite'
|
|
10
10
|
import { LibSQLAdapter } from './adapters/libsql'
|
|
11
11
|
import { ODBLiteAdapter } from './adapters/odblite'
|
|
12
|
+
import { OdbliteSocketAdapter, OdbliteAutoAdapter } from './adapters/odblite-socket'
|
|
12
13
|
import { parseSQL } from './sql-parser'
|
|
13
14
|
import fs from 'node:fs'
|
|
14
15
|
|
|
@@ -67,7 +68,17 @@ export class DatabaseManager {
|
|
|
67
68
|
if (!config.odblite) {
|
|
68
69
|
throw new Error('odblite config required for odblite backend')
|
|
69
70
|
}
|
|
70
|
-
|
|
71
|
+
// Autodetect, zero-config: prefer the router_v2 socket if it's reachable
|
|
72
|
+
// (convention path / ODB_SOCKET, or config.odbliteSocket override), else
|
|
73
|
+
// HTTP. App config is unchanged — start the socket server and it upgrades.
|
|
74
|
+
this.adapter = new OdbliteAutoAdapter({ socket: config.odbliteSocket, http: config.odblite })
|
|
75
|
+
break
|
|
76
|
+
|
|
77
|
+
case 'odblite-socket':
|
|
78
|
+
if (!config.odbliteSocket) {
|
|
79
|
+
throw new Error('odbliteSocket config required for odblite-socket backend')
|
|
80
|
+
}
|
|
81
|
+
this.adapter = new OdbliteSocketAdapter(config.odbliteSocket)
|
|
71
82
|
break
|
|
72
83
|
|
|
73
84
|
default:
|
|
@@ -308,6 +319,13 @@ export class DatabaseManager {
|
|
|
308
319
|
...this.config.odblite
|
|
309
320
|
}
|
|
310
321
|
|
|
322
|
+
case 'odblite-socket':
|
|
323
|
+
return {
|
|
324
|
+
databaseName: name,
|
|
325
|
+
databaseHash: name,
|
|
326
|
+
...this.config.odbliteSocket
|
|
327
|
+
}
|
|
328
|
+
|
|
311
329
|
default:
|
|
312
330
|
throw new Error(`Unknown backend: ${this.config.backend}`)
|
|
313
331
|
}
|
package/src/database/types.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type TenancyMode =
|
|
|
14
14
|
/**
|
|
15
15
|
* Backend database types
|
|
16
16
|
*/
|
|
17
|
-
export type BackendType = 'bun-sqlite' | 'libsql' | 'odblite'
|
|
17
|
+
export type BackendType = 'bun-sqlite' | 'libsql' | 'odblite' | 'odblite-socket'
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Query result from any backend
|
|
@@ -161,6 +161,13 @@ export interface DatabaseManagerConfig {
|
|
|
161
161
|
nodeId?: string // Optional: specific node to create databases on (server selects if not provided)
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
// router_v2 raw-socket transport (the fast path; Bun-only). One of unix OR host:port.
|
|
165
|
+
odbliteSocket?: {
|
|
166
|
+
unix?: string // unix socket path, e.g. /tmp/odblite-v2.sock
|
|
167
|
+
hostname?: string // or TCP host (cross-container Docker)
|
|
168
|
+
port?: number // and TCP port
|
|
169
|
+
}
|
|
170
|
+
|
|
164
171
|
// Connection pooling
|
|
165
172
|
connectionPoolSize?: number // Max connections per database (default: 10)
|
|
166
173
|
}
|