@pineliner/odb-client 1.1.5 → 1.2.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 +52 -0
- package/dist/database/adapters/odblite-socket.d.ts.map +1 -0
- package/dist/database/adapters/socket/client.d.ts +28 -0
- package/dist/database/adapters/socket/client.d.ts.map +1 -0
- package/dist/database/adapters/socket/protocol.d.ts +129 -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 +425 -1
- package/dist/index.js +425 -1
- package/package.json +1 -1
- package/src/database/adapters/odblite-socket.ts +255 -0
- package/src/database/adapters/socket/client.ts +78 -0
- package/src/database/adapters/socket/protocol.ts +147 -0
- package/src/database/manager.ts +19 -1
- package/src/database/types.ts +8 -1
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
export interface SocketTarget {
|
|
12
|
+
unix?: string
|
|
13
|
+
hostname?: string
|
|
14
|
+
port?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class SocketClient {
|
|
18
|
+
private socket: any
|
|
19
|
+
private reader = new FrameReader()
|
|
20
|
+
private writer!: FrameWriter
|
|
21
|
+
private readonly pending = new Map<number, (r: StreamResult[]) => void>()
|
|
22
|
+
private nextId = 1
|
|
23
|
+
private ready: Promise<void>
|
|
24
|
+
|
|
25
|
+
constructor(private readonly target: SocketTarget) {
|
|
26
|
+
this.ready = this.connect()
|
|
27
|
+
// A failed initial connect (e.g. socket not up) surfaces via pipeline()'s
|
|
28
|
+
// await — don't let the orphaned promise become an unhandled rejection.
|
|
29
|
+
void this.ready.catch(() => {})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private async connect(): Promise<void> {
|
|
33
|
+
const Bun = (globalThis as any).Bun
|
|
34
|
+
if (!Bun?.connect) throw new Error('odblite-socket backend requires Bun (Bun.connect)')
|
|
35
|
+
const opts: any = this.target.unix
|
|
36
|
+
? { unix: this.target.unix }
|
|
37
|
+
: { hostname: this.target.hostname ?? '127.0.0.1', port: this.target.port ?? 8787 }
|
|
38
|
+
this.socket = await Bun.connect({
|
|
39
|
+
...opts,
|
|
40
|
+
socket: {
|
|
41
|
+
data: (_s: any, chunk: Buffer) => {
|
|
42
|
+
for (const frame of this.reader.push(chunk)) {
|
|
43
|
+
const msg = JSON.parse(frame.toString('utf8')) as { id: number; results: StreamResult[] }
|
|
44
|
+
const resolve = this.pending.get(msg.id)
|
|
45
|
+
if (resolve) {
|
|
46
|
+
this.pending.delete(msg.id)
|
|
47
|
+
resolve(msg.results)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
drain: (_s: any) => this.writer.flush(),
|
|
52
|
+
close: () => {
|
|
53
|
+
// reconnect lazily on next pipeline()
|
|
54
|
+
this.socket = undefined
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
this.writer = new FrameWriter(this.socket)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async pipeline(tenant: string, requests: StreamRequest[]): Promise<StreamResult[]> {
|
|
62
|
+
if (!this.socket) {
|
|
63
|
+
this.ready = this.connect()
|
|
64
|
+
void this.ready.catch(() => {})
|
|
65
|
+
}
|
|
66
|
+
await this.ready
|
|
67
|
+
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'))
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
close(): void {
|
|
75
|
+
this.socket?.end?.()
|
|
76
|
+
this.socket = undefined
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
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: 'close' }
|
|
92
|
+
export type StreamResult =
|
|
93
|
+
| { type: 'ok'; response: any }
|
|
94
|
+
| { type: 'error'; error: HranaError }
|
|
95
|
+
|
|
96
|
+
// ── guarded-batch helper (the §8g atomic primitive) ───────────────────────────
|
|
97
|
+
export interface AtomicStep {
|
|
98
|
+
sql: string
|
|
99
|
+
params?: any[]
|
|
100
|
+
expect?: 'one' | 'atLeastOne' | number
|
|
101
|
+
}
|
|
102
|
+
export function toBatch(steps: AtomicStep[]): Batch {
|
|
103
|
+
return {
|
|
104
|
+
steps: steps.map((s, i) => ({
|
|
105
|
+
condition: i === 0 ? undefined : { type: 'ok', step: i - 1 },
|
|
106
|
+
stmt: { sql: s.sql, args: (s.params ?? []).map(toHrana) },
|
|
107
|
+
expect: s.expect ? { affected: s.expect } : undefined,
|
|
108
|
+
})),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── length-prefixed framing with backpressure-safe writes ─────────────────────
|
|
113
|
+
interface Writable {
|
|
114
|
+
write(data: Uint8Array): number
|
|
115
|
+
}
|
|
116
|
+
export class FrameWriter {
|
|
117
|
+
private pending: Buffer = Buffer.alloc(0)
|
|
118
|
+
constructor(private readonly socket: Writable) {}
|
|
119
|
+
send(payload: Buffer): void {
|
|
120
|
+
const header = Buffer.allocUnsafe(4)
|
|
121
|
+
header.writeUInt32LE(payload.length, 0)
|
|
122
|
+
const frame = Buffer.concat([header, payload])
|
|
123
|
+
this.pending = this.pending.length ? Buffer.concat([this.pending, frame]) : frame
|
|
124
|
+
this.flush()
|
|
125
|
+
}
|
|
126
|
+
flush(): void {
|
|
127
|
+
while (this.pending.length > 0) {
|
|
128
|
+
const n = this.socket.write(this.pending)
|
|
129
|
+
if (n <= 0) return // backpressured — drain() re-calls flush()
|
|
130
|
+
this.pending = n < this.pending.length ? this.pending.subarray(n) : Buffer.alloc(0)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export class FrameReader {
|
|
135
|
+
private buf: Buffer = Buffer.alloc(0)
|
|
136
|
+
push(chunk: Buffer): Buffer[] {
|
|
137
|
+
this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk
|
|
138
|
+
const out: Buffer[] = []
|
|
139
|
+
while (this.buf.length >= 4) {
|
|
140
|
+
const len = this.buf.readUInt32LE(0)
|
|
141
|
+
if (this.buf.length < 4 + len) break
|
|
142
|
+
out.push(this.buf.subarray(4, 4 + len))
|
|
143
|
+
this.buf = this.buf.subarray(4 + len)
|
|
144
|
+
}
|
|
145
|
+
return out
|
|
146
|
+
}
|
|
147
|
+
}
|
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
|
}
|