@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
package/dist/index.js
CHANGED
|
@@ -2048,6 +2048,417 @@ class ODBLiteConnection {
|
|
|
2048
2048
|
return this.client.httpClient.getNamespaces();
|
|
2049
2049
|
}
|
|
2050
2050
|
}
|
|
2051
|
+
function toHrana(v) {
|
|
2052
|
+
if (null == v) return {
|
|
2053
|
+
type: 'null'
|
|
2054
|
+
};
|
|
2055
|
+
if ('bigint' == typeof v) return {
|
|
2056
|
+
type: 'integer',
|
|
2057
|
+
value: v.toString()
|
|
2058
|
+
};
|
|
2059
|
+
if ('number' == typeof v) return Number.isInteger(v) ? {
|
|
2060
|
+
type: 'integer',
|
|
2061
|
+
value: String(v)
|
|
2062
|
+
} : {
|
|
2063
|
+
type: 'float',
|
|
2064
|
+
value: v
|
|
2065
|
+
};
|
|
2066
|
+
if ('string' == typeof v) return {
|
|
2067
|
+
type: 'text',
|
|
2068
|
+
value: v
|
|
2069
|
+
};
|
|
2070
|
+
if (v instanceof Uint8Array) return {
|
|
2071
|
+
type: 'blob',
|
|
2072
|
+
base64: Buffer.from(v).toString('base64')
|
|
2073
|
+
};
|
|
2074
|
+
if ('boolean' == typeof v) return {
|
|
2075
|
+
type: 'integer',
|
|
2076
|
+
value: v ? '1' : '0'
|
|
2077
|
+
};
|
|
2078
|
+
return {
|
|
2079
|
+
type: 'text',
|
|
2080
|
+
value: String(v)
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
function fromHrana(v) {
|
|
2084
|
+
switch(v.type){
|
|
2085
|
+
case 'null':
|
|
2086
|
+
return null;
|
|
2087
|
+
case 'integer':
|
|
2088
|
+
{
|
|
2089
|
+
const n = Number(v.value);
|
|
2090
|
+
return Number.isSafeInteger(n) ? n : BigInt(v.value);
|
|
2091
|
+
}
|
|
2092
|
+
case 'float':
|
|
2093
|
+
return v.value;
|
|
2094
|
+
case 'text':
|
|
2095
|
+
return v.value;
|
|
2096
|
+
case 'blob':
|
|
2097
|
+
return new Uint8Array(Buffer.from(v.base64, 'base64'));
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
function toBatch(steps) {
|
|
2101
|
+
return {
|
|
2102
|
+
steps: steps.map((s, i)=>({
|
|
2103
|
+
condition: 0 === i ? void 0 : {
|
|
2104
|
+
type: 'ok',
|
|
2105
|
+
step: i - 1
|
|
2106
|
+
},
|
|
2107
|
+
stmt: {
|
|
2108
|
+
sql: s.sql,
|
|
2109
|
+
args: (s.params ?? []).map(toHrana)
|
|
2110
|
+
},
|
|
2111
|
+
expect: s.expect ? {
|
|
2112
|
+
affected: s.expect
|
|
2113
|
+
} : void 0
|
|
2114
|
+
}))
|
|
2115
|
+
};
|
|
2116
|
+
}
|
|
2117
|
+
class FrameWriter {
|
|
2118
|
+
socket;
|
|
2119
|
+
pending = Buffer.alloc(0);
|
|
2120
|
+
constructor(socket){
|
|
2121
|
+
this.socket = socket;
|
|
2122
|
+
}
|
|
2123
|
+
send(payload) {
|
|
2124
|
+
const header = Buffer.allocUnsafe(4);
|
|
2125
|
+
header.writeUInt32LE(payload.length, 0);
|
|
2126
|
+
const frame = Buffer.concat([
|
|
2127
|
+
header,
|
|
2128
|
+
payload
|
|
2129
|
+
]);
|
|
2130
|
+
this.pending = this.pending.length ? Buffer.concat([
|
|
2131
|
+
this.pending,
|
|
2132
|
+
frame
|
|
2133
|
+
]) : frame;
|
|
2134
|
+
this.flush();
|
|
2135
|
+
}
|
|
2136
|
+
flush() {
|
|
2137
|
+
while(this.pending.length > 0){
|
|
2138
|
+
const n = this.socket.write(this.pending);
|
|
2139
|
+
if (n <= 0) return;
|
|
2140
|
+
this.pending = n < this.pending.length ? this.pending.subarray(n) : Buffer.alloc(0);
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
class FrameReader {
|
|
2145
|
+
buf = Buffer.alloc(0);
|
|
2146
|
+
push(chunk) {
|
|
2147
|
+
this.buf = this.buf.length ? Buffer.concat([
|
|
2148
|
+
this.buf,
|
|
2149
|
+
chunk
|
|
2150
|
+
]) : chunk;
|
|
2151
|
+
const out = [];
|
|
2152
|
+
while(this.buf.length >= 4){
|
|
2153
|
+
const len = this.buf.readUInt32LE(0);
|
|
2154
|
+
if (this.buf.length < 4 + len) break;
|
|
2155
|
+
out.push(this.buf.subarray(4, 4 + len));
|
|
2156
|
+
this.buf = this.buf.subarray(4 + len);
|
|
2157
|
+
}
|
|
2158
|
+
return out;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
class SocketClient {
|
|
2162
|
+
target;
|
|
2163
|
+
socket;
|
|
2164
|
+
reader = new FrameReader();
|
|
2165
|
+
writer;
|
|
2166
|
+
pending = new Map();
|
|
2167
|
+
nextId = 1;
|
|
2168
|
+
ready;
|
|
2169
|
+
constructor(target){
|
|
2170
|
+
this.target = target;
|
|
2171
|
+
this.ready = this.connect();
|
|
2172
|
+
this.ready.catch(()=>{});
|
|
2173
|
+
}
|
|
2174
|
+
async connect() {
|
|
2175
|
+
const Bun = globalThis.Bun;
|
|
2176
|
+
if (!Bun?.connect) throw new Error('odblite-socket backend requires Bun (Bun.connect)');
|
|
2177
|
+
const opts = this.target.unix ? {
|
|
2178
|
+
unix: this.target.unix
|
|
2179
|
+
} : {
|
|
2180
|
+
hostname: this.target.hostname ?? '127.0.0.1',
|
|
2181
|
+
port: this.target.port ?? 8787
|
|
2182
|
+
};
|
|
2183
|
+
this.socket = await Bun.connect({
|
|
2184
|
+
...opts,
|
|
2185
|
+
socket: {
|
|
2186
|
+
data: (_s, chunk)=>{
|
|
2187
|
+
for (const frame of this.reader.push(chunk)){
|
|
2188
|
+
const msg = JSON.parse(frame.toString('utf8'));
|
|
2189
|
+
const resolve = this.pending.get(msg.id);
|
|
2190
|
+
if (resolve) {
|
|
2191
|
+
this.pending.delete(msg.id);
|
|
2192
|
+
resolve(msg.results);
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
},
|
|
2196
|
+
drain: (_s)=>this.writer.flush(),
|
|
2197
|
+
close: ()=>{
|
|
2198
|
+
this.socket = void 0;
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
});
|
|
2202
|
+
this.writer = new FrameWriter(this.socket);
|
|
2203
|
+
}
|
|
2204
|
+
async pipeline(tenant, requests) {
|
|
2205
|
+
if (!this.socket) {
|
|
2206
|
+
this.ready = this.connect();
|
|
2207
|
+
this.ready.catch(()=>{});
|
|
2208
|
+
}
|
|
2209
|
+
await this.ready;
|
|
2210
|
+
const id = this.nextId++;
|
|
2211
|
+
return new Promise((resolve)=>{
|
|
2212
|
+
this.pending.set(id, resolve);
|
|
2213
|
+
this.writer.send(Buffer.from(JSON.stringify({
|
|
2214
|
+
id,
|
|
2215
|
+
tenant,
|
|
2216
|
+
requests
|
|
2217
|
+
}), 'utf8'));
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
2220
|
+
close() {
|
|
2221
|
+
this.socket?.end?.();
|
|
2222
|
+
this.socket = void 0;
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
function toQueryResult(res) {
|
|
2226
|
+
if ('error' === res.type) throw new Error(res.error.message);
|
|
2227
|
+
const r = res.response.result;
|
|
2228
|
+
const rows = r.rows.map((row)=>{
|
|
2229
|
+
const obj = {};
|
|
2230
|
+
r.cols.forEach((c, i)=>{
|
|
2231
|
+
obj[c.name ?? `c${i}`] = fromHrana(row[i] ?? {
|
|
2232
|
+
type: 'null'
|
|
2233
|
+
});
|
|
2234
|
+
});
|
|
2235
|
+
return obj;
|
|
2236
|
+
});
|
|
2237
|
+
return {
|
|
2238
|
+
rows,
|
|
2239
|
+
rowsAffected: r.affected_row_count,
|
|
2240
|
+
lastInsertRowid: r.last_insert_rowid ? Number(r.last_insert_rowid) : void 0
|
|
2241
|
+
};
|
|
2242
|
+
}
|
|
2243
|
+
class OdbliteSocketConnection {
|
|
2244
|
+
client;
|
|
2245
|
+
tenant;
|
|
2246
|
+
databaseName;
|
|
2247
|
+
databaseHash;
|
|
2248
|
+
sql;
|
|
2249
|
+
buffer;
|
|
2250
|
+
constructor(client, tenant, buffer){
|
|
2251
|
+
this.client = client;
|
|
2252
|
+
this.tenant = tenant;
|
|
2253
|
+
this.databaseName = tenant;
|
|
2254
|
+
this.databaseHash = tenant;
|
|
2255
|
+
this.buffer = buffer;
|
|
2256
|
+
const self = this;
|
|
2257
|
+
this.sql = function(strings, ...values) {
|
|
2258
|
+
if (strings && 'object' == typeof strings && 'raw' in strings) {
|
|
2259
|
+
const text = strings.reduce((a, s, i)=>a + s + (i < values.length ? '?' : ''), '');
|
|
2260
|
+
const returns = /^\s*SELECT/i.test(text) || /\bRETURNING\b/i.test(text);
|
|
2261
|
+
return (returns ? self.query(text, values) : self.execute(text, values)).then((r)=>r.rows);
|
|
2262
|
+
}
|
|
2263
|
+
throw new Error('sql() fragment helper not implemented in odblite-socket');
|
|
2264
|
+
};
|
|
2265
|
+
}
|
|
2266
|
+
async one(requests) {
|
|
2267
|
+
const [res] = await this.client.pipeline(this.tenant, requests);
|
|
2268
|
+
if (!res) throw new Error('odblite-socket: empty pipeline response');
|
|
2269
|
+
return res;
|
|
2270
|
+
}
|
|
2271
|
+
async query(sql, params = []) {
|
|
2272
|
+
return toQueryResult(await this.one([
|
|
2273
|
+
{
|
|
2274
|
+
type: 'execute',
|
|
2275
|
+
stmt: {
|
|
2276
|
+
sql,
|
|
2277
|
+
args: params.map(toHrana)
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
]));
|
|
2281
|
+
}
|
|
2282
|
+
async execute(sql, params = []) {
|
|
2283
|
+
const s = 'string' == typeof sql ? sql : sql.sql;
|
|
2284
|
+
const p = 'string' == typeof sql ? params : sql.args ?? [];
|
|
2285
|
+
if (this.buffer) {
|
|
2286
|
+
this.buffer.push({
|
|
2287
|
+
sql: s,
|
|
2288
|
+
params: p
|
|
2289
|
+
});
|
|
2290
|
+
return {
|
|
2291
|
+
rows: [],
|
|
2292
|
+
rowsAffected: 0
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
return toQueryResult(await this.one([
|
|
2296
|
+
{
|
|
2297
|
+
type: 'execute',
|
|
2298
|
+
stmt: {
|
|
2299
|
+
sql: s,
|
|
2300
|
+
args: p.map(toHrana)
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
]));
|
|
2304
|
+
}
|
|
2305
|
+
async atomic(steps) {
|
|
2306
|
+
const res = await this.one([
|
|
2307
|
+
{
|
|
2308
|
+
type: 'batch',
|
|
2309
|
+
batch: toBatch(steps)
|
|
2310
|
+
}
|
|
2311
|
+
]);
|
|
2312
|
+
if ('error' === res.type) throw new Error(res.error.message);
|
|
2313
|
+
return res.response.result;
|
|
2314
|
+
}
|
|
2315
|
+
prepare(sql) {
|
|
2316
|
+
return {
|
|
2317
|
+
execute: async (params = [])=>this.execute(sql, params),
|
|
2318
|
+
all: async (params = [])=>(await this.query(sql, params)).rows,
|
|
2319
|
+
get: async (params = [])=>(await this.query(sql, params)).rows[0] ?? null
|
|
2320
|
+
};
|
|
2321
|
+
}
|
|
2322
|
+
async transaction(fn) {
|
|
2323
|
+
if (this.buffer) return fn(this);
|
|
2324
|
+
const buffer = [];
|
|
2325
|
+
const out = await fn(new OdbliteSocketConnection(this.client, this.tenant, buffer));
|
|
2326
|
+
if (buffer.length) {
|
|
2327
|
+
const res = await this.atomic(buffer.map((b)=>({
|
|
2328
|
+
sql: b.sql,
|
|
2329
|
+
params: b.params
|
|
2330
|
+
})));
|
|
2331
|
+
const err = res.step_errors?.find((e)=>e);
|
|
2332
|
+
if (err) throw new Error(`transaction batch failed: ${err.message}`);
|
|
2333
|
+
}
|
|
2334
|
+
return out;
|
|
2335
|
+
}
|
|
2336
|
+
begin(fn) {
|
|
2337
|
+
return this.transaction(fn);
|
|
2338
|
+
}
|
|
2339
|
+
savepoint(fn) {
|
|
2340
|
+
return this.transaction(fn);
|
|
2341
|
+
}
|
|
2342
|
+
async ensureNamespaces(namespaces) {
|
|
2343
|
+
return {
|
|
2344
|
+
success: true,
|
|
2345
|
+
databaseName: this.tenant,
|
|
2346
|
+
namespaces
|
|
2347
|
+
};
|
|
2348
|
+
}
|
|
2349
|
+
async getNamespaces() {
|
|
2350
|
+
const ns = (await this.query("SELECT name FROM pragma_database_list WHERE name != 'main'")).rows.map((r)=>r.name);
|
|
2351
|
+
return {
|
|
2352
|
+
success: true,
|
|
2353
|
+
databaseName: this.tenant,
|
|
2354
|
+
exists: true,
|
|
2355
|
+
namespaces: ns,
|
|
2356
|
+
registered: true,
|
|
2357
|
+
registeredNamespaces: ns
|
|
2358
|
+
};
|
|
2359
|
+
}
|
|
2360
|
+
async close() {}
|
|
2361
|
+
}
|
|
2362
|
+
class OdbliteSocketAdapter {
|
|
2363
|
+
type = 'odblite-socket';
|
|
2364
|
+
client;
|
|
2365
|
+
constructor(config){
|
|
2366
|
+
this.client = new SocketClient(config);
|
|
2367
|
+
}
|
|
2368
|
+
async connect(config) {
|
|
2369
|
+
const tenant = config?.databaseHash ?? config?.databaseName;
|
|
2370
|
+
if (!tenant) throw new Error('odblite-socket: databaseHash (or databaseName) required');
|
|
2371
|
+
return new OdbliteSocketConnection(this.client, String(tenant));
|
|
2372
|
+
}
|
|
2373
|
+
async disconnect() {
|
|
2374
|
+
this.client.close();
|
|
2375
|
+
}
|
|
2376
|
+
async isHealthy() {
|
|
2377
|
+
try {
|
|
2378
|
+
await Promise.race([
|
|
2379
|
+
this.client.pipeline('_health', [
|
|
2380
|
+
{
|
|
2381
|
+
type: 'get_autocommit'
|
|
2382
|
+
}
|
|
2383
|
+
]),
|
|
2384
|
+
new Promise((_, rej)=>setTimeout(()=>rej(new Error('probe timeout')), 1500))
|
|
2385
|
+
]);
|
|
2386
|
+
return true;
|
|
2387
|
+
} catch {
|
|
2388
|
+
return false;
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
const DEFAULT_UNIX_SOCKET = '/tmp/odblite.sock';
|
|
2393
|
+
function parseSocketTarget(s) {
|
|
2394
|
+
const v = (s ?? '').trim();
|
|
2395
|
+
if (!v) return {
|
|
2396
|
+
unix: DEFAULT_UNIX_SOCKET
|
|
2397
|
+
};
|
|
2398
|
+
if (v.includes('/')) return {
|
|
2399
|
+
unix: v
|
|
2400
|
+
};
|
|
2401
|
+
const m = v.match(/^(?:([^:]+):)?(\d+)$/);
|
|
2402
|
+
if (m) return {
|
|
2403
|
+
hostname: m[1] || '127.0.0.1',
|
|
2404
|
+
port: Number(m[2])
|
|
2405
|
+
};
|
|
2406
|
+
return {
|
|
2407
|
+
unix: v
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
class OdbliteAutoAdapter {
|
|
2411
|
+
type = 'odblite';
|
|
2412
|
+
chosen;
|
|
2413
|
+
constructor(opts){
|
|
2414
|
+
this.chosen = this.pick(opts);
|
|
2415
|
+
}
|
|
2416
|
+
async pick(opts) {
|
|
2417
|
+
const hasBun = void 0 !== globalThis.Bun && !!globalThis.Bun.connect;
|
|
2418
|
+
if (hasBun) for (const target of this.candidates(opts))try {
|
|
2419
|
+
const socket = new OdbliteSocketAdapter(target);
|
|
2420
|
+
if (await socket.isHealthy()) {
|
|
2421
|
+
console.log(`[odb-client] transport → SOCKET (router_v2 @ ${target.unix ?? `${target.hostname}:${target.port}`})`);
|
|
2422
|
+
return socket;
|
|
2423
|
+
}
|
|
2424
|
+
await socket.disconnect();
|
|
2425
|
+
} catch {}
|
|
2426
|
+
console.log('[odb-client] transport → HTTP (no router_v2 socket reachable)');
|
|
2427
|
+
return new ODBLiteAdapter(opts.http);
|
|
2428
|
+
}
|
|
2429
|
+
candidates(opts) {
|
|
2430
|
+
if (opts.socket?.unix || opts.socket?.port) return [
|
|
2431
|
+
opts.socket
|
|
2432
|
+
];
|
|
2433
|
+
const env = globalThis.process?.env?.ODB_SOCKET;
|
|
2434
|
+
if (env) return [
|
|
2435
|
+
parseSocketTarget(env)
|
|
2436
|
+
];
|
|
2437
|
+
const out = [
|
|
2438
|
+
{
|
|
2439
|
+
unix: DEFAULT_UNIX_SOCKET
|
|
2440
|
+
}
|
|
2441
|
+
];
|
|
2442
|
+
try {
|
|
2443
|
+
const host = new URL(opts.http?.serviceUrl).hostname;
|
|
2444
|
+
const port = Number(globalThis.process?.env?.ODB_SOCKET_PORT ?? 8787);
|
|
2445
|
+
if (host) out.push({
|
|
2446
|
+
hostname: host,
|
|
2447
|
+
port
|
|
2448
|
+
});
|
|
2449
|
+
} catch {}
|
|
2450
|
+
return out;
|
|
2451
|
+
}
|
|
2452
|
+
async connect(config) {
|
|
2453
|
+
return (await this.chosen).connect(config);
|
|
2454
|
+
}
|
|
2455
|
+
async disconnect(tenantId) {
|
|
2456
|
+
return (await this.chosen).disconnect(tenantId);
|
|
2457
|
+
}
|
|
2458
|
+
async isHealthy() {
|
|
2459
|
+
return (await this.chosen).isHealthy();
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2051
2462
|
function parseSQL(sqlContent, options = {}) {
|
|
2052
2463
|
const separatePragma = options.separatePragma ?? true;
|
|
2053
2464
|
const statements = splitStatements(sqlContent);
|
|
@@ -2157,7 +2568,14 @@ class DatabaseManager {
|
|
|
2157
2568
|
break;
|
|
2158
2569
|
case 'odblite':
|
|
2159
2570
|
if (!config.odblite) throw new Error('odblite config required for odblite backend');
|
|
2160
|
-
this.adapter = new
|
|
2571
|
+
this.adapter = new OdbliteAutoAdapter({
|
|
2572
|
+
socket: config.odbliteSocket,
|
|
2573
|
+
http: config.odblite
|
|
2574
|
+
});
|
|
2575
|
+
break;
|
|
2576
|
+
case 'odblite-socket':
|
|
2577
|
+
if (!config.odbliteSocket) throw new Error('odbliteSocket config required for odblite-socket backend');
|
|
2578
|
+
this.adapter = new OdbliteSocketAdapter(config.odbliteSocket);
|
|
2161
2579
|
break;
|
|
2162
2580
|
default:
|
|
2163
2581
|
throw new Error(`Unknown backend type: ${config.backend}`);
|
|
@@ -2275,6 +2693,12 @@ class DatabaseManager {
|
|
|
2275
2693
|
databaseName: `${this.config.databasePath}_${name}`,
|
|
2276
2694
|
...this.config.odblite
|
|
2277
2695
|
};
|
|
2696
|
+
case 'odblite-socket':
|
|
2697
|
+
return {
|
|
2698
|
+
databaseName: name,
|
|
2699
|
+
databaseHash: name,
|
|
2700
|
+
...this.config.odbliteSocket
|
|
2701
|
+
};
|
|
2278
2702
|
default:
|
|
2279
2703
|
throw new Error(`Unknown backend: ${this.config.backend}`);
|
|
2280
2704
|
}
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|