@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
package/src/core/client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ODBLiteConfig, ODBLiteConnection, QueryResult, Transaction, TransactionCallback, TransactionMode, SQLFragment } from '../types.ts';
|
|
2
|
-
import { HTTPClient } from './http-client.ts';
|
|
2
|
+
import { HTTPClient, type QueryRequestOptions } from './http-client.ts';
|
|
3
3
|
import { SQLParser } from './sql-parser.ts';
|
|
4
4
|
import { createBatchTransaction, createSimpleTransaction, SimpleTransaction } from './transaction.ts';
|
|
5
5
|
import { ConnectionError } from '../types.ts';
|
|
@@ -22,10 +22,10 @@ interface SQLFunction {
|
|
|
22
22
|
identifier(name: string): string;
|
|
23
23
|
|
|
24
24
|
// Additional postgres.js-like methods
|
|
25
|
-
query<T = any>(sql: string, params?: any[]): Promise<QueryResult<T>>;
|
|
25
|
+
query<T = any>(sql: string, params?: any[], opts?: QueryRequestOptions): Promise<QueryResult<T>>;
|
|
26
26
|
|
|
27
27
|
// libsql-compatible execute method (for backward compatibility)
|
|
28
|
-
execute<T = any>(sql: string | { sql: string; args?: any[] }, args?: any[]): Promise<QueryResult<T>>;
|
|
28
|
+
execute<T = any>(sql: string | { sql: string; args?: any[] }, args?: any[], opts?: QueryRequestOptions): Promise<QueryResult<T>>;
|
|
29
29
|
|
|
30
30
|
// Batch execution - runs multiple statements on same connection (for transactions)
|
|
31
31
|
batch<T = any>(statements: Array<{ sql: string; params?: any[] }>): Promise<BatchResult<T>>;
|
|
@@ -59,6 +59,12 @@ export class ODBLiteClient implements ODBLiteConnection {
|
|
|
59
59
|
// Handle template string queries (returns Promise)
|
|
60
60
|
if (Array.isArray(sql) && (('raw' in sql) || (typeof sql[0] === 'string' && values.length >= 0))) {
|
|
61
61
|
const parsed = SQLParser.parse(sql, values);
|
|
62
|
+
// NOTE: this raw template path sends NO transaction token. The router pins
|
|
63
|
+
// a transaction's statements by txId, so do NOT use `client.sql`...`` (or
|
|
64
|
+
// ODBLiteClient.query) for statements inside a router-pinned transaction —
|
|
65
|
+
// they'd be sent tokenless and treated as foreign work (deadlock-until-
|
|
66
|
+
// watchdog). The DB adapter (ODBLiteConnection) goes through sql.execute()
|
|
67
|
+
// with opts, which carries the token; use the connection, not the raw client.
|
|
62
68
|
return this.httpClient.query<T>(parsed.sql, parsed.params);
|
|
63
69
|
}
|
|
64
70
|
|
|
@@ -75,16 +81,16 @@ export class ODBLiteClient implements ODBLiteConnection {
|
|
|
75
81
|
sqlFunction.identifier = (name: string): string => SQLParser.identifier(name);
|
|
76
82
|
|
|
77
83
|
// Attach client methods to the function
|
|
78
|
-
sqlFunction.query = async <T = any>(sql: string, params: any[] = []): Promise<QueryResult<T>> => {
|
|
79
|
-
return await this.httpClient.query<T>(sql, params);
|
|
84
|
+
sqlFunction.query = async <T = any>(sql: string, params: any[] = [], opts?: QueryRequestOptions): Promise<QueryResult<T>> => {
|
|
85
|
+
return await this.httpClient.query<T>(sql, params, opts);
|
|
80
86
|
};
|
|
81
87
|
|
|
82
88
|
// libsql-compatible execute method (for backward compatibility)
|
|
83
|
-
sqlFunction.execute = async <T = any>(sql: string | { sql: string; args?: any[] }, args?: any[]): Promise<QueryResult<T>> => {
|
|
89
|
+
sqlFunction.execute = async <T = any>(sql: string | { sql: string; args?: any[] }, args?: any[], opts?: QueryRequestOptions): Promise<QueryResult<T>> => {
|
|
84
90
|
if (typeof sql === 'string') {
|
|
85
|
-
return await this.httpClient.query<T>(sql, args || []);
|
|
91
|
+
return await this.httpClient.query<T>(sql, args || [], opts);
|
|
86
92
|
} else {
|
|
87
|
-
return await this.httpClient.query<T>(sql.sql, sql.args || []);
|
|
93
|
+
return await this.httpClient.query<T>(sql.sql, sql.args || [], opts);
|
|
88
94
|
}
|
|
89
95
|
};
|
|
90
96
|
|
package/src/core/http-client.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { ODBLiteConfig, ODBLiteResponse, QueryResult } from '../types.ts'
|
|
2
2
|
import { ConnectionError, QueryError } from '../types.ts'
|
|
3
3
|
|
|
4
|
+
/** Per-request options for a single query. */
|
|
5
|
+
export interface QueryRequestOptions {
|
|
6
|
+
/** Transaction/session token tying this statement to a server-pinned connection. */
|
|
7
|
+
txId?: string
|
|
8
|
+
/** Override the client's retry count (transaction statements pass 1 = no retry). */
|
|
9
|
+
maxRetries?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
4
12
|
/**
|
|
5
13
|
* HTTP client for communicating with ODBLite service
|
|
6
14
|
*/
|
|
@@ -23,7 +31,7 @@ export class HTTPClient {
|
|
|
23
31
|
/**
|
|
24
32
|
* Execute a query against the ODBLite service
|
|
25
33
|
*/
|
|
26
|
-
async query<T = any>(sql: string, params: any[] = []): Promise<QueryResult<T>> {
|
|
34
|
+
async query<T = any>(sql: string, params: any[] = [], opts?: QueryRequestOptions): Promise<QueryResult<T>> {
|
|
27
35
|
if (!this.config.databaseId) {
|
|
28
36
|
throw new ConnectionError('No database ID configured. Use setDatabase() first.')
|
|
29
37
|
}
|
|
@@ -31,7 +39,10 @@ export class HTTPClient {
|
|
|
31
39
|
const url = `${this.config.baseUrl}/query/${this.config.databaseId}`
|
|
32
40
|
const body = {
|
|
33
41
|
sql,
|
|
34
|
-
params
|
|
42
|
+
params,
|
|
43
|
+
// Transaction/session token — present for every statement inside a
|
|
44
|
+
// connection.transaction() so the router pins them to one connection.
|
|
45
|
+
...(opts?.txId ? { txId: opts.txId } : {})
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
try {
|
|
@@ -42,7 +53,7 @@ export class HTTPClient {
|
|
|
42
53
|
Authorization: `Bearer ${this.config.apiKey}`
|
|
43
54
|
},
|
|
44
55
|
body: JSON.stringify(body)
|
|
45
|
-
})
|
|
56
|
+
}, opts?.maxRetries)
|
|
46
57
|
|
|
47
58
|
const data: ODBLiteResponse<T> = await response.json()
|
|
48
59
|
|
|
@@ -186,10 +197,14 @@ export class HTTPClient {
|
|
|
186
197
|
/**
|
|
187
198
|
* Make HTTP request with retry logic
|
|
188
199
|
*/
|
|
189
|
-
private async makeRequest(url: string, options: RequestInit): Promise<Response> {
|
|
200
|
+
private async makeRequest(url: string, options: RequestInit, maxRetries?: number): Promise<Response> {
|
|
190
201
|
let lastError: Error | undefined
|
|
202
|
+
// Transaction-control and in-transaction statements pass maxRetries=1: they
|
|
203
|
+
// are NOT idempotent (a retried COMMIT/INSERT could double-apply or hit a
|
|
204
|
+
// closed transaction), so they must never be auto-retried.
|
|
205
|
+
const retries = maxRetries ?? this.config.retries ?? 3
|
|
191
206
|
|
|
192
|
-
for (let attempt = 1; attempt <=
|
|
207
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
193
208
|
try {
|
|
194
209
|
const controller = new AbortController()
|
|
195
210
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout)
|
|
@@ -224,14 +239,14 @@ export class HTTPClient {
|
|
|
224
239
|
}
|
|
225
240
|
|
|
226
241
|
// Wait before retry (exponential backoff)
|
|
227
|
-
if (attempt <
|
|
242
|
+
if (attempt < retries) {
|
|
228
243
|
const delay = Math.min(1000 * 2 ** (attempt - 1), 10000)
|
|
229
244
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
230
245
|
}
|
|
231
246
|
}
|
|
232
247
|
}
|
|
233
248
|
|
|
234
|
-
throw new ConnectionError(`Failed after ${
|
|
249
|
+
throw new ConnectionError(`Failed after ${retries} attempts: ${lastError?.message}`, lastError)
|
|
235
250
|
}
|
|
236
251
|
|
|
237
252
|
/**
|
|
@@ -0,0 +1,255 @@
|
|
|
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
|
+
type AtomicStep,
|
|
9
|
+
type BatchResult,
|
|
10
|
+
type StmtResult,
|
|
11
|
+
type StreamRequest,
|
|
12
|
+
type StreamResult,
|
|
13
|
+
} from './socket/protocol'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* odblite-socket — the FAST cross-process backend: raw socket + JSON framing to
|
|
17
|
+
* router_v2 (bun:sqlite engine). REPLACES the HTTP `odblite` backend (one hop, no
|
|
18
|
+
* HTTP parse) — it does NOT layer on it. Same `Connection` interface, so it's a
|
|
19
|
+
* drop-in: app-router flips `backend: 'odblite'` → `'odblite-socket'`.
|
|
20
|
+
*
|
|
21
|
+
* Write discipline (the separate-process model forces it): a unit of write work
|
|
22
|
+
* is ONE batch in ONE round-trip — the server runs BEGIN…COMMIT on its side, the
|
|
23
|
+
* client never holds the write slot across the wire. So `atomic(steps)` is the
|
|
24
|
+
* write primitive; `transaction(fn)` BUFFERS writes and flushes them as one
|
|
25
|
+
* atomic batch on commit (no read-your-own-uncommitted-writes mid-tx — that would
|
|
26
|
+
* re-create the slot-starvation the design avoids). Read-compute-write → read
|
|
27
|
+
* first, compute, write one atomic().
|
|
28
|
+
*/
|
|
29
|
+
function toQueryResult(res: StreamResult): QueryResult {
|
|
30
|
+
if (res.type === 'error') throw new Error(res.error.message)
|
|
31
|
+
const r = (res as any).response.result as StmtResult
|
|
32
|
+
const rows = r.rows.map((row) => {
|
|
33
|
+
const obj: Record<string, any> = {}
|
|
34
|
+
r.cols.forEach((c, i) => {
|
|
35
|
+
obj[c.name ?? `c${i}`] = fromHrana(row[i] ?? { type: 'null' })
|
|
36
|
+
})
|
|
37
|
+
return obj
|
|
38
|
+
})
|
|
39
|
+
return { rows, rowsAffected: r.affected_row_count, lastInsertRowid: r.last_insert_rowid ? Number(r.last_insert_rowid) : undefined }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class OdbliteSocketConnection implements Connection {
|
|
43
|
+
databaseName?: string
|
|
44
|
+
databaseHash?: string
|
|
45
|
+
public sql: any
|
|
46
|
+
private readonly buffer?: { sql: string; params: any[] }[]
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly client: SocketClient,
|
|
50
|
+
private readonly tenant: string,
|
|
51
|
+
buffer?: { sql: string; params: any[] }[],
|
|
52
|
+
) {
|
|
53
|
+
this.databaseName = tenant
|
|
54
|
+
this.databaseHash = tenant
|
|
55
|
+
this.buffer = buffer
|
|
56
|
+
const self = this
|
|
57
|
+
this.sql = function (strings: any, ...values: any[]): any {
|
|
58
|
+
if (strings && typeof strings === 'object' && 'raw' in strings) {
|
|
59
|
+
const text = (strings as string[]).reduce((a, s, i) => a + s + (i < values.length ? '?' : ''), '')
|
|
60
|
+
const returns = /^\s*SELECT/i.test(text) || /\bRETURNING\b/i.test(text)
|
|
61
|
+
return (returns ? self.query(text, values) : self.execute(text, values)).then((r: any) => r.rows)
|
|
62
|
+
}
|
|
63
|
+
throw new Error('sql() fragment helper not implemented in odblite-socket')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One request → its single result, failing loud on an empty response. */
|
|
68
|
+
private async one(requests: StreamRequest[]): Promise<StreamResult> {
|
|
69
|
+
const [res] = await this.client.pipeline(this.tenant, requests)
|
|
70
|
+
if (!res) throw new Error('odblite-socket: empty pipeline response')
|
|
71
|
+
return res
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async query<T = any>(sql: string, params: any[] = []): Promise<QueryResult<T>> {
|
|
75
|
+
return toQueryResult(await this.one([{ type: 'execute', stmt: { sql, args: params.map(toHrana) } }])) as QueryResult<T>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async execute(sql: string | { sql: string; args?: any[] }, params: any[] = []): Promise<QueryResult> {
|
|
79
|
+
const s = typeof sql === 'string' ? sql : sql.sql
|
|
80
|
+
const p = typeof sql === 'string' ? params : (sql.args ?? [])
|
|
81
|
+
if (this.buffer) {
|
|
82
|
+
this.buffer.push({ sql: s, params: p }) // deferred → flushed as one batch on commit
|
|
83
|
+
return { rows: [], rowsAffected: 0 }
|
|
84
|
+
}
|
|
85
|
+
return toQueryResult(await this.one([{ type: 'execute', stmt: { sql: s, args: p.map(toHrana) } }]))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Guarded atomic batch — the primary remote write primitive (one round-trip). */
|
|
89
|
+
async atomic(steps: AtomicStep[]): Promise<BatchResult> {
|
|
90
|
+
const res = await this.one([{ type: 'batch', batch: toBatch(steps) }])
|
|
91
|
+
if (res.type === 'error') throw new Error(res.error.message)
|
|
92
|
+
return (res as any).response.result as BatchResult
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
prepare(sql: string): PreparedStatement {
|
|
96
|
+
return {
|
|
97
|
+
execute: async (params = []) => this.execute(sql, params),
|
|
98
|
+
all: async (params = []) => (await this.query(sql, params)).rows,
|
|
99
|
+
get: async (params = []) => (await this.query(sql, params)).rows[0] ?? null,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async transaction<T>(fn: (tx: Connection) => Promise<T>): Promise<T> {
|
|
104
|
+
if (this.buffer) return fn(this) // nested → same buffer
|
|
105
|
+
const buffer: { sql: string; params: any[] }[] = []
|
|
106
|
+
const out = await fn(new OdbliteSocketConnection(this.client, this.tenant, buffer))
|
|
107
|
+
if (buffer.length) {
|
|
108
|
+
const res = await this.atomic(buffer.map((b) => ({ sql: b.sql, params: b.params })))
|
|
109
|
+
const err = res.step_errors?.find((e) => e)
|
|
110
|
+
if (err) throw new Error(`transaction batch failed: ${err.message}`)
|
|
111
|
+
}
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
begin<T>(fn: (tx: Connection) => Promise<T>): Promise<T> {
|
|
115
|
+
return this.transaction(fn)
|
|
116
|
+
}
|
|
117
|
+
savepoint<T>(fn: (tx: Connection) => Promise<T>): Promise<T> {
|
|
118
|
+
return this.transaction(fn)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// router_v2 auto-ATTACHes a tenant's namespaces on open → these are no-ops.
|
|
122
|
+
async ensureNamespaces(namespaces: string[]) {
|
|
123
|
+
return { success: true, databaseName: this.tenant, namespaces }
|
|
124
|
+
}
|
|
125
|
+
async getNamespaces() {
|
|
126
|
+
const ns = (await this.query("SELECT name FROM pragma_database_list WHERE name != 'main'")).rows.map((r: any) => r.name)
|
|
127
|
+
return { success: true, databaseName: this.tenant, exists: true, namespaces: ns, registered: true, registeredNamespaces: ns }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async close(): Promise<void> {
|
|
131
|
+
/* the SocketClient is shared + owned by the adapter; per-connection close is a no-op */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface OdbliteSocketConfig extends SocketTarget {}
|
|
136
|
+
|
|
137
|
+
export class OdbliteSocketAdapter implements DatabaseAdapter {
|
|
138
|
+
readonly type: BackendType = 'odblite-socket'
|
|
139
|
+
private readonly client: SocketClient
|
|
140
|
+
|
|
141
|
+
constructor(config: OdbliteSocketConfig) {
|
|
142
|
+
this.client = new SocketClient(config)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async connect(config: any): Promise<Connection> {
|
|
146
|
+
const tenant = config?.databaseHash ?? config?.databaseName
|
|
147
|
+
if (!tenant) throw new Error('odblite-socket: databaseHash (or databaseName) required')
|
|
148
|
+
return new OdbliteSocketConnection(this.client, String(tenant))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async disconnect(): Promise<void> {
|
|
152
|
+
this.client.close()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async isHealthy(): Promise<boolean> {
|
|
156
|
+
try {
|
|
157
|
+
await Promise.race([
|
|
158
|
+
this.client.pipeline('_health', [{ type: 'get_autocommit' }]),
|
|
159
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error('probe timeout')), 1500)),
|
|
160
|
+
])
|
|
161
|
+
return true
|
|
162
|
+
} catch {
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Default same-host unix socket (free — v1 only uses /tmp/odblite-internal.sock). */
|
|
169
|
+
export const DEFAULT_UNIX_SOCKET = '/tmp/odblite.sock'
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Resolve the odblite-socket target from a string. `ODB_SOCKET` may be:
|
|
173
|
+
* - a UNIX path (`/tmp/odblite.sock`) — same host, or a SHARED VOLUME mount
|
|
174
|
+
* - `host:port` (`odblite:8787`) — TCP, the CROSS-CONTAINER Docker case
|
|
175
|
+
* - `:port` / `port` — TCP on localhost
|
|
176
|
+
* A /tmp unix socket does NOT cross Docker containers, so container-to-container
|
|
177
|
+
* deployments set `ODB_SOCKET=odblite:8787` (TCP over the Docker network).
|
|
178
|
+
*/
|
|
179
|
+
export function parseSocketTarget(s?: string): SocketTarget {
|
|
180
|
+
const v = (s ?? '').trim()
|
|
181
|
+
if (!v) return { unix: DEFAULT_UNIX_SOCKET }
|
|
182
|
+
if (v.includes('/')) return { unix: v } // a path → unix socket
|
|
183
|
+
const m = v.match(/^(?:([^:]+):)?(\d+)$/) // host:port | :port | port → TCP
|
|
184
|
+
if (m) return { hostname: m[1] || '127.0.0.1', port: Number(m[2]) }
|
|
185
|
+
return { unix: v }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* OdbliteAutoAdapter — the default for the `odblite` backend: ZERO config change,
|
|
190
|
+
* autodetected as a cascade unix socket → TCP socket → HTTP. At startup it probes
|
|
191
|
+
* each in order and uses the FIRST that answers:
|
|
192
|
+
* 1. unix socket /tmp/odblite.sock — same host (fastest)
|
|
193
|
+
* 2. TCP socket <ODB_LITE_URL host>:8787 — cross-container Docker; host is
|
|
194
|
+
* DERIVED from the existing ODB_LITE_URL, so Docker needs no new config
|
|
195
|
+
* 3. HTTP ODB_LITE_URL — the universal fallback
|
|
196
|
+
* Explicit `ODB_SOCKET` (unix path or host:port) or an `odbliteSocket` config
|
|
197
|
+
* pins a single socket target instead of the cascade. So same-host dev and
|
|
198
|
+
* cross-container prod both "just work", and a missing/down socket → HTTP.
|
|
199
|
+
* (Browser / non-Bun → straight to HTTP.)
|
|
200
|
+
*/
|
|
201
|
+
export class OdbliteAutoAdapter implements DatabaseAdapter {
|
|
202
|
+
readonly type: BackendType = 'odblite'
|
|
203
|
+
private readonly chosen: Promise<DatabaseAdapter>
|
|
204
|
+
|
|
205
|
+
constructor(opts: { socket?: OdbliteSocketConfig; http: any }) {
|
|
206
|
+
this.chosen = this.pick(opts)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async pick(opts: { socket?: OdbliteSocketConfig; http: any }): Promise<DatabaseAdapter> {
|
|
210
|
+
const hasBun = typeof (globalThis as any).Bun !== 'undefined' && !!(globalThis as any).Bun.connect
|
|
211
|
+
if (hasBun) {
|
|
212
|
+
for (const target of this.candidates(opts)) {
|
|
213
|
+
try {
|
|
214
|
+
const socket = new OdbliteSocketAdapter(target)
|
|
215
|
+
if (await socket.isHealthy()) {
|
|
216
|
+
console.log(`[odb-client] transport → SOCKET (router_v2 @ ${target.unix ?? `${target.hostname}:${target.port}`})`)
|
|
217
|
+
return socket
|
|
218
|
+
}
|
|
219
|
+
await socket.disconnect()
|
|
220
|
+
} catch {
|
|
221
|
+
/* try the next candidate */
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
console.log('[odb-client] transport → HTTP (no router_v2 socket reachable)')
|
|
226
|
+
return new ODBLiteAdapter(opts.http)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Ordered socket targets to probe. Explicit override → a single target; else the
|
|
230
|
+
* zero-config cascade unix (same host) → TCP (host derived from ODB_LITE_URL). */
|
|
231
|
+
private candidates(opts: { socket?: OdbliteSocketConfig; http: any }): SocketTarget[] {
|
|
232
|
+
if (opts.socket?.unix || opts.socket?.port) return [opts.socket]
|
|
233
|
+
const env = (globalThis as any).process?.env?.ODB_SOCKET
|
|
234
|
+
if (env) return [parseSocketTarget(env)]
|
|
235
|
+
const out: SocketTarget[] = [{ unix: DEFAULT_UNIX_SOCKET }]
|
|
236
|
+
try {
|
|
237
|
+
const host = new URL(opts.http?.serviceUrl).hostname // e.g. odb-lite from http://odb-lite:8671
|
|
238
|
+
const port = Number((globalThis as any).process?.env?.ODB_SOCKET_PORT ?? 8787)
|
|
239
|
+
if (host) out.push({ hostname: host, port })
|
|
240
|
+
} catch {
|
|
241
|
+
/* serviceUrl unparseable → no TCP candidate, HTTP will catch it */
|
|
242
|
+
}
|
|
243
|
+
return out
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async connect(config: any): Promise<Connection> {
|
|
247
|
+
return (await this.chosen).connect(config)
|
|
248
|
+
}
|
|
249
|
+
async disconnect(tenantId?: string): Promise<void> {
|
|
250
|
+
return (await this.chosen).disconnect(tenantId)
|
|
251
|
+
}
|
|
252
|
+
async isHealthy(): Promise<boolean> {
|
|
253
|
+
return (await this.chosen).isHealthy()
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -19,6 +19,45 @@ import {
|
|
|
19
19
|
where
|
|
20
20
|
} from '../sql-template'
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* AsyncLocalStorage, loaded lazily so the client stays isomorphic. On the server
|
|
24
|
+
* (Node/Bun) it carries the active transaction token across awaits so every
|
|
25
|
+
* statement inside a transaction is tagged with — and only with — its own token,
|
|
26
|
+
* even when other transactions run concurrently on the same shared connection.
|
|
27
|
+
* In browsers/edge (no async_hooks) it's null and we fall back to a per-connection
|
|
28
|
+
* flag, which is sufficient there because that concurrency pattern doesn't occur.
|
|
29
|
+
*/
|
|
30
|
+
type TxStore = { txId: string }
|
|
31
|
+
let AsyncLocalStorageCtor: (new () => AsyncLocalStorageLike<TxStore>) | null = null
|
|
32
|
+
interface AsyncLocalStorageLike<T> {
|
|
33
|
+
getStore(): T | undefined
|
|
34
|
+
run<R>(store: T, fn: () => R): R
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
38
|
+
AsyncLocalStorageCtor = require('node:async_hooks').AsyncLocalStorage
|
|
39
|
+
} catch {
|
|
40
|
+
try {
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
42
|
+
AsyncLocalStorageCtor = require('async_hooks').AsyncLocalStorage
|
|
43
|
+
} catch {
|
|
44
|
+
AsyncLocalStorageCtor = null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let txCounter = 0
|
|
49
|
+
/** Generate a process-unique transaction token. */
|
|
50
|
+
function newTxId(): string {
|
|
51
|
+
try {
|
|
52
|
+
const c = (globalThis as any).crypto
|
|
53
|
+
if (c?.randomUUID) return `tx_${c.randomUUID()}`
|
|
54
|
+
} catch {
|
|
55
|
+
// fall through
|
|
56
|
+
}
|
|
57
|
+
txCounter = (txCounter + 1) % Number.MAX_SAFE_INTEGER
|
|
58
|
+
return `tx_${Date.now().toString(36)}_${txCounter.toString(36)}`
|
|
59
|
+
}
|
|
60
|
+
|
|
22
61
|
/**
|
|
23
62
|
* Parse JSON columns in query results
|
|
24
63
|
* Only parses if the value is a string (to avoid double-parsing)
|
|
@@ -139,7 +178,14 @@ export class ODBLiteAdapter implements DatabaseAdapter {
|
|
|
139
178
|
class ODBLiteConnection implements Connection {
|
|
140
179
|
private client: ODBLiteClient
|
|
141
180
|
private serviceClient: ServiceClient
|
|
142
|
-
|
|
181
|
+
// Carries the active transaction token across awaits (server only). Distinguishes
|
|
182
|
+
// a genuinely-nested transaction() call (same async context) from a concurrent
|
|
183
|
+
// top-level one on this shared connection — replacing the old shared
|
|
184
|
+
// `inTransaction` boolean, which leaked between concurrent requests.
|
|
185
|
+
private txStorage: AsyncLocalStorageLike<TxStore> | null =
|
|
186
|
+
AsyncLocalStorageCtor ? new AsyncLocalStorageCtor() : null
|
|
187
|
+
// Browser/edge fallback when AsyncLocalStorage is unavailable.
|
|
188
|
+
private fallbackTxId?: string
|
|
143
189
|
|
|
144
190
|
// Public metadata fields
|
|
145
191
|
public databaseName: string
|
|
@@ -209,6 +255,23 @@ class ODBLiteConnection implements Connection {
|
|
|
209
255
|
return this.execute(sql, params, options) as Promise<QueryResult<T>>
|
|
210
256
|
}
|
|
211
257
|
|
|
258
|
+
/** The token of the transaction this async context belongs to, if any. */
|
|
259
|
+
private activeTxId(): string | undefined {
|
|
260
|
+
return this.txStorage ? this.txStorage.getStore()?.txId : this.fallbackTxId
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Per-request options for the underlying client. Inside a transaction every
|
|
265
|
+
* statement carries the transaction token (so the router pins it to the
|
|
266
|
+
* transaction's connection) and disables retries (transaction statements are
|
|
267
|
+
* not idempotent — a retried COMMIT/INSERT could double-apply or hit a closed
|
|
268
|
+
* transaction).
|
|
269
|
+
*/
|
|
270
|
+
private requestOpts(): { txId?: string; maxRetries?: number } | undefined {
|
|
271
|
+
const txId = this.activeTxId()
|
|
272
|
+
return txId ? { txId, maxRetries: 1 } : undefined
|
|
273
|
+
}
|
|
274
|
+
|
|
212
275
|
/**
|
|
213
276
|
* Execute SQL with parameters
|
|
214
277
|
*/
|
|
@@ -217,6 +280,7 @@ class ODBLiteConnection implements Connection {
|
|
|
217
280
|
let rows: any[]
|
|
218
281
|
let rowsAffected: number
|
|
219
282
|
let lastInsertRowid: any
|
|
283
|
+
const reqOpts = this.requestOpts()
|
|
220
284
|
|
|
221
285
|
// Handle object format { sql, args }
|
|
222
286
|
if (typeof sql === 'object') {
|
|
@@ -230,7 +294,7 @@ class ODBLiteConnection implements Connection {
|
|
|
230
294
|
if (options?.stringifyParams) {
|
|
231
295
|
args = stringifyJsonParams(args, options.stringifyParams)
|
|
232
296
|
}
|
|
233
|
-
const result = await this.client.sql.execute(sql.sql, args)
|
|
297
|
+
const result = await this.client.sql.execute(sql.sql, args, reqOpts)
|
|
234
298
|
rows = result.rows
|
|
235
299
|
rowsAffected = result.rowsAffected || 0
|
|
236
300
|
lastInsertRowid = (result as any).lastInsertRowid
|
|
@@ -245,7 +309,7 @@ class ODBLiteConnection implements Connection {
|
|
|
245
309
|
if (options?.stringifyParams) {
|
|
246
310
|
args = stringifyJsonParams(args, options.stringifyParams)
|
|
247
311
|
}
|
|
248
|
-
const result = await this.client.sql.execute(sql, args)
|
|
312
|
+
const result = await this.client.sql.execute(sql, args, reqOpts)
|
|
249
313
|
rows = result.rows
|
|
250
314
|
rowsAffected = result.rowsAffected || 0
|
|
251
315
|
lastInsertRowid = (result as any).lastInsertRowid
|
|
@@ -300,35 +364,42 @@ class ODBLiteConnection implements Connection {
|
|
|
300
364
|
* same connection without interleaving from other requests.
|
|
301
365
|
*/
|
|
302
366
|
async transaction<T>(fn: (tx: Connection) => Promise<T>): Promise<T> {
|
|
303
|
-
if (this.
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
367
|
+
if (this.activeTxId()) {
|
|
368
|
+
// Genuinely nested call: this async context already owns a transaction on
|
|
369
|
+
// this connection — join it (no nested BEGIN), exactly like the previous
|
|
370
|
+
// reentrancy behaviour. Convenience functions that each wrap in
|
|
371
|
+
// db.transaction() compose freely inside an outer transaction.
|
|
307
372
|
return fn(this)
|
|
308
373
|
}
|
|
309
374
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
await this.execute('BEGIN')
|
|
375
|
+
// New top-level transaction: mint a token so the router pins every one of
|
|
376
|
+
// this transaction's statements to a single connection and never lets a
|
|
377
|
+
// concurrent transaction's (or autocommit) statements interleave onto it.
|
|
378
|
+
const txId = newTxId()
|
|
315
379
|
|
|
380
|
+
const run = async (): Promise<T> => {
|
|
381
|
+
const prevFallback = this.fallbackTxId
|
|
382
|
+
if (!this.txStorage) this.fallbackTxId = txId
|
|
316
383
|
try {
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
384
|
+
// Start transaction (tagged with txId + no-retry via requestOpts())
|
|
385
|
+
await this.execute('BEGIN')
|
|
386
|
+
try {
|
|
387
|
+
// Execute user function - each statement executes immediately
|
|
388
|
+
const result = await fn(this)
|
|
389
|
+
// Commit transaction
|
|
390
|
+
await this.execute('COMMIT')
|
|
391
|
+
return result
|
|
392
|
+
} catch (error) {
|
|
393
|
+
// Rollback on error
|
|
394
|
+
await this.execute('ROLLBACK').catch(() => {})
|
|
395
|
+
throw error
|
|
396
|
+
}
|
|
397
|
+
} finally {
|
|
398
|
+
if (!this.txStorage) this.fallbackTxId = prevFallback
|
|
328
399
|
}
|
|
329
|
-
} finally {
|
|
330
|
-
this.inTransaction = false
|
|
331
400
|
}
|
|
401
|
+
|
|
402
|
+
return this.txStorage ? this.txStorage.run({ txId }, run) : run()
|
|
332
403
|
}
|
|
333
404
|
|
|
334
405
|
/**
|
|
@@ -343,7 +414,7 @@ class ODBLiteConnection implements Connection {
|
|
|
343
414
|
* If called outside a transaction, behaves like transaction().
|
|
344
415
|
*/
|
|
345
416
|
async savepoint<T>(fn: (tx: Connection) => Promise<T>): Promise<T> {
|
|
346
|
-
if (!this.
|
|
417
|
+
if (!this.activeTxId()) {
|
|
347
418
|
return this.transaction(fn)
|
|
348
419
|
}
|
|
349
420
|
|
|
@@ -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
|
+
}
|