@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.
@@ -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
+ }
@@ -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
- this.adapter = new ODBLiteAdapter(config.odblite)
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('--')) {
@@ -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
  }