@pineliner/odb-client 1.1.4 → 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/core/client.d.ts +3 -3
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/http-client.d.ts +8 -1
- package/dist/core/http-client.d.ts.map +1 -1
- 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/odblite.d.ts.map +1 -1
- 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/sql-parser.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 +513 -30
- package/dist/index.js +515 -30
- package/package.json +2 -2
- package/src/core/client.ts +14 -8
- package/src/core/http-client.ts +22 -7
- package/src/database/adapters/odblite-socket.ts +255 -0
- package/src/database/adapters/odblite.ts +97 -26
- 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/sql-parser.ts +23 -3
- package/src/database/types.ts +8 -1
|
@@ -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
|
}
|
|
@@ -20,7 +20,7 @@ export function parseSQL(sqlContent: string, options: SQLParserOptions = {}): Pa
|
|
|
20
20
|
if (!separatePragma) {
|
|
21
21
|
return {
|
|
22
22
|
pragmaStatements: [],
|
|
23
|
-
regularStatements: statements
|
|
23
|
+
regularStatements: statements
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -49,6 +49,8 @@ function splitStatements(sqlContent: string): string[] {
|
|
|
49
49
|
let inQuote = false
|
|
50
50
|
let quoteChar = ''
|
|
51
51
|
let beginEndDepth = 0
|
|
52
|
+
let inCte = false
|
|
53
|
+
let cteParenDepth = 0
|
|
52
54
|
|
|
53
55
|
const lines = sqlContent.split('\n')
|
|
54
56
|
|
|
@@ -72,6 +74,24 @@ function splitStatements(sqlContent: string): string[] {
|
|
|
72
74
|
}
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
// Track CTE (WITH ... AS) parenthesis depth
|
|
78
|
+
if (!inQuote && lineWithoutComments.trim()) {
|
|
79
|
+
const withMatch = lineWithoutComments.match(/\bWITH\b.*\bAS\b/i)
|
|
80
|
+
if (withMatch) {
|
|
81
|
+
inCte = true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (inCte) {
|
|
85
|
+
for (const char of lineWithoutComments) {
|
|
86
|
+
if (char === '(') cteParenDepth++
|
|
87
|
+
if (char === ')') cteParenDepth--
|
|
88
|
+
if (cteParenDepth === 0 && char === ')') {
|
|
89
|
+
inCte = false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
75
95
|
for (let i = 0; i < line.length; i++) {
|
|
76
96
|
const char = line[i]
|
|
77
97
|
const prevChar = i > 0 ? line[i - 1] : ''
|
|
@@ -95,8 +115,8 @@ function splitStatements(sqlContent: string): string[] {
|
|
|
95
115
|
|
|
96
116
|
processedLine += char
|
|
97
117
|
|
|
98
|
-
// Split on semicolon when not in quotes AND not inside BEGIN...END
|
|
99
|
-
if (char === ';' && !inQuote && beginEndDepth === 0) {
|
|
118
|
+
// Split on semicolon when not in quotes AND not inside BEGIN...END AND not inside CTE
|
|
119
|
+
if (char === ';' && !inQuote && beginEndDepth === 0 && !inCte) {
|
|
100
120
|
currentStatement += processedLine
|
|
101
121
|
const stmt = currentStatement.trim()
|
|
102
122
|
if (stmt && !stmt.startsWith('--')) {
|
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
|
}
|