@pikku/kysely-sqlite 0.12.3 → 0.12.5
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/CHANGELOG.md +16 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +1 -0
- package/dist/src/libsql-web-dialect.d.ts +33 -0
- package/dist/src/libsql-web-dialect.js +175 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -5
- package/src/index.ts +2 -0
- package/src/libsql-web-dialect.ts +250 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @pikku/kysely-sqlite
|
|
2
2
|
|
|
3
|
+
## 0.12.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 9060165: New `@pikku/kysely-node-sqlite` provides a Kysely dialect and migrator for Node's built-in `node:sqlite`. `@pikku/kysely-sqlite` adds a `LibsqlWebDialect` for running Kysely against Cloudflare Workers and Turso databases over HTTP.
|
|
8
|
+
- Updated dependencies [9060165]
|
|
9
|
+
- Updated dependencies [9060165]
|
|
10
|
+
- Updated dependencies [9060165]
|
|
11
|
+
- @pikku/core@0.12.21
|
|
12
|
+
|
|
13
|
+
## 0.12.4
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- Fix workspace protocol references in published dependencies
|
|
18
|
+
|
|
3
19
|
## 0.12.3
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/dist/src/index.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export { SQLiteKyselyWorkflowRunService } from './sqlite-kysely-workflow-run-ser
|
|
|
7
7
|
export { SQLiteKyselyChannelStore } from './sqlite-kysely-channel-store.js';
|
|
8
8
|
export { SQLiteKyselyEventHubStore } from './sqlite-kysely-eventhub-store.js';
|
|
9
9
|
export { SQLiteKyselySecretService } from './sqlite-kysely-secret-service.js';
|
|
10
|
+
export { LibsqlWebDialect } from './libsql-web-dialect.js';
|
|
11
|
+
export type { LibsqlWebDialectConfig } from './libsql-web-dialect.js';
|
|
10
12
|
export type { KyselyPikkuDB } from '@pikku/kysely';
|
|
11
13
|
export type { KyselySecretServiceConfig } from '@pikku/kysely';
|
|
12
14
|
export type { WorkflowRunService } from '@pikku/core/workflow';
|
package/dist/src/index.js
CHANGED
|
@@ -7,3 +7,4 @@ export { SQLiteKyselyWorkflowRunService } from './sqlite-kysely-workflow-run-ser
|
|
|
7
7
|
export { SQLiteKyselyChannelStore } from './sqlite-kysely-channel-store.js';
|
|
8
8
|
export { SQLiteKyselyEventHubStore } from './sqlite-kysely-eventhub-store.js';
|
|
9
9
|
export { SQLiteKyselySecretService } from './sqlite-kysely-secret-service.js';
|
|
10
|
+
export { LibsqlWebDialect } from './libsql-web-dialect.js';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Dialect, type Driver, type Kysely, type DatabaseIntrospector, type DialectAdapter, type QueryCompiler } from 'kysely';
|
|
2
|
+
/**
|
|
3
|
+
* Kysely dialect for libsql over the *HTTP pipeline* transport (v2/pipeline).
|
|
4
|
+
*
|
|
5
|
+
* Why this file exists at all:
|
|
6
|
+
* - @libsql/kysely-libsql imports @libsql/client (Node), which uses node:http.
|
|
7
|
+
* CF Workers nodejs_compat_v2 doesn't ship node:http -> upload error 10021.
|
|
8
|
+
* - @libsql/client/web imports both ./http.js and ./ws.js at module top, and
|
|
9
|
+
* ./ws.js pulls in `ws` which does `require('node:events')` at load time.
|
|
10
|
+
* CF Workers also doesn't ship node:events -> upload error 10021.
|
|
11
|
+
* - @libsql/client/http re-uses @libsql/hrana-client's index which still
|
|
12
|
+
* imports ws transitively for the same reason.
|
|
13
|
+
*
|
|
14
|
+
* So we talk to the libsql/Turso HTTP pipeline directly with fetch. Single
|
|
15
|
+
* endpoint, single round-trip per execute. Transactions use the `baton`
|
|
16
|
+
* mechanism: every response returns a baton, and resending it on the next
|
|
17
|
+
* request keeps the same server-side stream (== same SQL connection ==
|
|
18
|
+
* same transaction).
|
|
19
|
+
*
|
|
20
|
+
* Pipeline reference: https://github.com/tursodatabase/libsql/blob/main/docs/HTTP_V2_SPEC.md
|
|
21
|
+
*/
|
|
22
|
+
export interface LibsqlWebDialectConfig {
|
|
23
|
+
url: string;
|
|
24
|
+
authToken?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare class LibsqlWebDialect implements Dialect {
|
|
27
|
+
private readonly config;
|
|
28
|
+
constructor(config: LibsqlWebDialectConfig);
|
|
29
|
+
createAdapter(): DialectAdapter;
|
|
30
|
+
createDriver(): Driver;
|
|
31
|
+
createQueryCompiler(): QueryCompiler;
|
|
32
|
+
createIntrospector(db: Kysely<unknown>): DatabaseIntrospector;
|
|
33
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, } from 'kysely';
|
|
2
|
+
export class LibsqlWebDialect {
|
|
3
|
+
config;
|
|
4
|
+
constructor(config) {
|
|
5
|
+
this.config = config;
|
|
6
|
+
}
|
|
7
|
+
createAdapter() {
|
|
8
|
+
return new SqliteAdapter();
|
|
9
|
+
}
|
|
10
|
+
createDriver() {
|
|
11
|
+
return new LibsqlWebDriver(this.config);
|
|
12
|
+
}
|
|
13
|
+
createQueryCompiler() {
|
|
14
|
+
return new SqliteQueryCompiler();
|
|
15
|
+
}
|
|
16
|
+
createIntrospector(db) {
|
|
17
|
+
return new SqliteIntrospector(db);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
class LibsqlWebDriver {
|
|
21
|
+
endpoint;
|
|
22
|
+
authHeader;
|
|
23
|
+
constructor(config) {
|
|
24
|
+
// Accept libsql://, https://, http://. The pipeline is HTTP only.
|
|
25
|
+
// Turso embeds the JWT in the URL as ?authToken=... — honor that, but let
|
|
26
|
+
// an explicit config.authToken win.
|
|
27
|
+
const u = new URL(config.url);
|
|
28
|
+
const scheme = u.protocol === 'libsql:' ? 'https:' : u.protocol;
|
|
29
|
+
const tokenFromUrl = u.searchParams.get('authToken') ?? undefined;
|
|
30
|
+
this.endpoint = `${scheme}//${u.host}/v2/pipeline`;
|
|
31
|
+
const token = config.authToken ?? tokenFromUrl;
|
|
32
|
+
this.authHeader = token ? `Bearer ${token}` : undefined;
|
|
33
|
+
}
|
|
34
|
+
async init() { }
|
|
35
|
+
async acquireConnection() {
|
|
36
|
+
return new LibsqlWebConnection(this.endpoint, this.authHeader);
|
|
37
|
+
}
|
|
38
|
+
async beginTransaction(conn) {
|
|
39
|
+
await conn.executeRaw('BEGIN');
|
|
40
|
+
}
|
|
41
|
+
async commitTransaction(conn) {
|
|
42
|
+
await conn.executeRaw('COMMIT');
|
|
43
|
+
}
|
|
44
|
+
async rollbackTransaction(conn) {
|
|
45
|
+
await conn.executeRaw('ROLLBACK');
|
|
46
|
+
}
|
|
47
|
+
async releaseConnection(conn) {
|
|
48
|
+
await conn.close();
|
|
49
|
+
}
|
|
50
|
+
async destroy() { }
|
|
51
|
+
}
|
|
52
|
+
class LibsqlWebConnection {
|
|
53
|
+
authHeader;
|
|
54
|
+
baton = null;
|
|
55
|
+
endpoint;
|
|
56
|
+
constructor(initialEndpoint, authHeader) {
|
|
57
|
+
this.authHeader = authHeader;
|
|
58
|
+
this.endpoint = initialEndpoint;
|
|
59
|
+
}
|
|
60
|
+
async executeQuery(query) {
|
|
61
|
+
const result = await this.runStmt(query.sql, query.parameters);
|
|
62
|
+
return decodeResult(result);
|
|
63
|
+
}
|
|
64
|
+
async *streamQuery(query) {
|
|
65
|
+
yield this.executeQuery(query);
|
|
66
|
+
}
|
|
67
|
+
async executeRaw(sql) {
|
|
68
|
+
await this.runStmt(sql, []);
|
|
69
|
+
}
|
|
70
|
+
async close() {
|
|
71
|
+
if (this.baton === null)
|
|
72
|
+
return;
|
|
73
|
+
await this.send([{ type: 'close' }]);
|
|
74
|
+
this.baton = null;
|
|
75
|
+
}
|
|
76
|
+
async runStmt(sql, args) {
|
|
77
|
+
const resp = await this.send([
|
|
78
|
+
{
|
|
79
|
+
type: 'execute',
|
|
80
|
+
stmt: { sql, args: args.map(encodeArg) },
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
const r = resp.results[0];
|
|
84
|
+
if (!r)
|
|
85
|
+
throw new Error('libsql: empty pipeline response');
|
|
86
|
+
if (r.type === 'error')
|
|
87
|
+
throw new Error(`libsql: ${r.error.message}${r.error.code ? ` (${r.error.code})` : ''}`);
|
|
88
|
+
if (r.response.type !== 'execute')
|
|
89
|
+
throw new Error(`libsql: unexpected response type ${r.response.type}`);
|
|
90
|
+
return r.response.result;
|
|
91
|
+
}
|
|
92
|
+
async send(requests) {
|
|
93
|
+
const headers = {
|
|
94
|
+
'content-type': 'application/json',
|
|
95
|
+
};
|
|
96
|
+
if (this.authHeader)
|
|
97
|
+
headers.authorization = this.authHeader;
|
|
98
|
+
const res = await fetch(this.endpoint, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers,
|
|
101
|
+
body: JSON.stringify({ baton: this.baton, requests }),
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
const body = await res.text().catch(() => '');
|
|
105
|
+
throw new Error(`libsql HTTP ${res.status}: ${body}`);
|
|
106
|
+
}
|
|
107
|
+
const json = (await res.json());
|
|
108
|
+
this.baton = json.baton;
|
|
109
|
+
if (json.base_url)
|
|
110
|
+
this.endpoint = `${json.base_url.replace(/\/$/, '')}/v2/pipeline`;
|
|
111
|
+
return json;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function encodeArg(v) {
|
|
115
|
+
if (v === null || v === undefined)
|
|
116
|
+
return { type: 'null' };
|
|
117
|
+
if (typeof v === 'string')
|
|
118
|
+
return { type: 'text', value: v };
|
|
119
|
+
if (typeof v === 'number')
|
|
120
|
+
return Number.isInteger(v)
|
|
121
|
+
? { type: 'integer', value: String(v) }
|
|
122
|
+
: { type: 'float', value: v };
|
|
123
|
+
if (typeof v === 'bigint')
|
|
124
|
+
return { type: 'integer', value: v.toString() };
|
|
125
|
+
if (typeof v === 'boolean')
|
|
126
|
+
return { type: 'integer', value: v ? '1' : '0' };
|
|
127
|
+
if (v instanceof ArrayBuffer)
|
|
128
|
+
return { type: 'blob', base64: bytesToBase64(new Uint8Array(v)) };
|
|
129
|
+
if (v instanceof Uint8Array)
|
|
130
|
+
return { type: 'blob', base64: bytesToBase64(v) };
|
|
131
|
+
throw new Error(`libsql: unsupported argument type ${typeof v}`);
|
|
132
|
+
}
|
|
133
|
+
function decodeValue(v) {
|
|
134
|
+
switch (v.type) {
|
|
135
|
+
case 'null':
|
|
136
|
+
return null;
|
|
137
|
+
case 'integer': {
|
|
138
|
+
const n = Number(v.value);
|
|
139
|
+
return Number.isSafeInteger(n) ? n : BigInt(v.value);
|
|
140
|
+
}
|
|
141
|
+
case 'float':
|
|
142
|
+
return v.value;
|
|
143
|
+
case 'text':
|
|
144
|
+
return v.value;
|
|
145
|
+
case 'blob':
|
|
146
|
+
return base64ToBytes(v.base64);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function decodeResult(r) {
|
|
150
|
+
const colNames = r.cols.map((c, i) => c.name ?? `c${i}`);
|
|
151
|
+
const rows = r.rows.map((row) => {
|
|
152
|
+
const o = {};
|
|
153
|
+
for (let i = 0; i < colNames.length; i++)
|
|
154
|
+
o[colNames[i]] = decodeValue(row[i]);
|
|
155
|
+
return o;
|
|
156
|
+
});
|
|
157
|
+
return {
|
|
158
|
+
rows,
|
|
159
|
+
numAffectedRows: BigInt(r.affected_row_count),
|
|
160
|
+
insertId: r.last_insert_rowid !== null ? BigInt(r.last_insert_rowid) : undefined,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function bytesToBase64(b) {
|
|
164
|
+
let s = '';
|
|
165
|
+
for (let i = 0; i < b.length; i++)
|
|
166
|
+
s += String.fromCharCode(b[i]);
|
|
167
|
+
return btoa(s);
|
|
168
|
+
}
|
|
169
|
+
function base64ToBytes(s) {
|
|
170
|
+
const bin = atob(s);
|
|
171
|
+
const out = new Uint8Array(bin.length);
|
|
172
|
+
for (let i = 0; i < bin.length; i++)
|
|
173
|
+
out[i] = bin.charCodeAt(i);
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["../src/create-sqlite-kysely.ts","../src/index.ts","../src/sqlite-kysely-agent-run-service.ts","../src/sqlite-kysely-ai-storage-service.ts","../src/sqlite-kysely-channel-store.ts","../src/sqlite-kysely-deployment-service.ts","../src/sqlite-kysely-eventhub-store.ts","../src/sqlite-kysely-secret-service.ts","../src/sqlite-kysely-workflow-run-service.ts","../src/sqlite-kysely-workflow-service.ts"],"version":"5.9.3"}
|
|
1
|
+
{"root":["../src/create-sqlite-kysely.ts","../src/index.ts","../src/libsql-web-dialect.ts","../src/sqlite-kysely-agent-run-service.ts","../src/sqlite-kysely-ai-storage-service.ts","../src/sqlite-kysely-channel-store.ts","../src/sqlite-kysely-deployment-service.ts","../src/sqlite-kysely-eventhub-store.ts","../src/sqlite-kysely-secret-service.ts","../src/sqlite-kysely-workflow-run-service.ts","../src/sqlite-kysely-workflow-service.ts"],"version":"5.9.3"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/kysely-sqlite",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.5",
|
|
4
4
|
"author": "yasser.fadl@gmail.com",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"module": "dist/src/index.js",
|
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"tsc": "tsc",
|
|
11
11
|
"ncu": "npx npm-check-updates",
|
|
12
|
-
"build": "tsc -b"
|
|
12
|
+
"build": "tsc -b",
|
|
13
|
+
"prepublishOnly": "yarn build"
|
|
13
14
|
},
|
|
14
15
|
"dependencies": {
|
|
15
|
-
"@pikku/core": "
|
|
16
|
-
"@pikku/kysely": "
|
|
17
|
-
"kysely": "^0.28.
|
|
16
|
+
"@pikku/core": "^0.12.21",
|
|
17
|
+
"@pikku/kysely": "^0.12.0",
|
|
18
|
+
"kysely": "^0.28.12",
|
|
18
19
|
"kysely-plugin-serialize": "^0.8.2"
|
|
19
20
|
},
|
|
20
21
|
"devDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ export { SQLiteKyselyWorkflowRunService } from './sqlite-kysely-workflow-run-ser
|
|
|
7
7
|
export { SQLiteKyselyChannelStore } from './sqlite-kysely-channel-store.js'
|
|
8
8
|
export { SQLiteKyselyEventHubStore } from './sqlite-kysely-eventhub-store.js'
|
|
9
9
|
export { SQLiteKyselySecretService } from './sqlite-kysely-secret-service.js'
|
|
10
|
+
export { LibsqlWebDialect } from './libsql-web-dialect.js'
|
|
11
|
+
export type { LibsqlWebDialectConfig } from './libsql-web-dialect.js'
|
|
10
12
|
|
|
11
13
|
export type { KyselyPikkuDB } from '@pikku/kysely'
|
|
12
14
|
export type { KyselySecretServiceConfig } from '@pikku/kysely'
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SqliteAdapter,
|
|
3
|
+
SqliteIntrospector,
|
|
4
|
+
SqliteQueryCompiler,
|
|
5
|
+
type DatabaseConnection,
|
|
6
|
+
type Dialect,
|
|
7
|
+
type Driver,
|
|
8
|
+
type CompiledQuery,
|
|
9
|
+
type QueryResult,
|
|
10
|
+
type Kysely,
|
|
11
|
+
type DatabaseIntrospector,
|
|
12
|
+
type DialectAdapter,
|
|
13
|
+
type QueryCompiler,
|
|
14
|
+
} from 'kysely'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Kysely dialect for libsql over the *HTTP pipeline* transport (v2/pipeline).
|
|
18
|
+
*
|
|
19
|
+
* Why this file exists at all:
|
|
20
|
+
* - @libsql/kysely-libsql imports @libsql/client (Node), which uses node:http.
|
|
21
|
+
* CF Workers nodejs_compat_v2 doesn't ship node:http -> upload error 10021.
|
|
22
|
+
* - @libsql/client/web imports both ./http.js and ./ws.js at module top, and
|
|
23
|
+
* ./ws.js pulls in `ws` which does `require('node:events')` at load time.
|
|
24
|
+
* CF Workers also doesn't ship node:events -> upload error 10021.
|
|
25
|
+
* - @libsql/client/http re-uses @libsql/hrana-client's index which still
|
|
26
|
+
* imports ws transitively for the same reason.
|
|
27
|
+
*
|
|
28
|
+
* So we talk to the libsql/Turso HTTP pipeline directly with fetch. Single
|
|
29
|
+
* endpoint, single round-trip per execute. Transactions use the `baton`
|
|
30
|
+
* mechanism: every response returns a baton, and resending it on the next
|
|
31
|
+
* request keeps the same server-side stream (== same SQL connection ==
|
|
32
|
+
* same transaction).
|
|
33
|
+
*
|
|
34
|
+
* Pipeline reference: https://github.com/tursodatabase/libsql/blob/main/docs/HTTP_V2_SPEC.md
|
|
35
|
+
*/
|
|
36
|
+
export interface LibsqlWebDialectConfig {
|
|
37
|
+
url: string
|
|
38
|
+
authToken?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class LibsqlWebDialect implements Dialect {
|
|
42
|
+
constructor(private readonly config: LibsqlWebDialectConfig) {}
|
|
43
|
+
createAdapter(): DialectAdapter {
|
|
44
|
+
return new SqliteAdapter()
|
|
45
|
+
}
|
|
46
|
+
createDriver(): Driver {
|
|
47
|
+
return new LibsqlWebDriver(this.config)
|
|
48
|
+
}
|
|
49
|
+
createQueryCompiler(): QueryCompiler {
|
|
50
|
+
return new SqliteQueryCompiler()
|
|
51
|
+
}
|
|
52
|
+
createIntrospector(db: Kysely<unknown>): DatabaseIntrospector {
|
|
53
|
+
return new SqliteIntrospector(db)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type HranaValue =
|
|
58
|
+
| { type: 'null' }
|
|
59
|
+
| { type: 'integer'; value: string }
|
|
60
|
+
| { type: 'float'; value: number }
|
|
61
|
+
| { type: 'text'; value: string }
|
|
62
|
+
| { type: 'blob'; base64: string }
|
|
63
|
+
|
|
64
|
+
interface HranaStmtResult {
|
|
65
|
+
cols: Array<{ name: string | null; decltype: string | null }>
|
|
66
|
+
rows: HranaValue[][]
|
|
67
|
+
affected_row_count: number
|
|
68
|
+
last_insert_rowid: string | null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface PipelineResponse {
|
|
72
|
+
baton: string | null
|
|
73
|
+
base_url: string | null
|
|
74
|
+
results: Array<
|
|
75
|
+
| {
|
|
76
|
+
type: 'ok'
|
|
77
|
+
response:
|
|
78
|
+
| { type: 'execute'; result: HranaStmtResult }
|
|
79
|
+
| { type: 'close' }
|
|
80
|
+
}
|
|
81
|
+
| { type: 'error'; error: { message: string; code?: string } }
|
|
82
|
+
>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class LibsqlWebDriver implements Driver {
|
|
86
|
+
private readonly endpoint: string
|
|
87
|
+
private readonly authHeader: string | undefined
|
|
88
|
+
constructor(config: LibsqlWebDialectConfig) {
|
|
89
|
+
// Accept libsql://, https://, http://. The pipeline is HTTP only.
|
|
90
|
+
// Turso embeds the JWT in the URL as ?authToken=... — honor that, but let
|
|
91
|
+
// an explicit config.authToken win.
|
|
92
|
+
const u = new URL(config.url)
|
|
93
|
+
const scheme = u.protocol === 'libsql:' ? 'https:' : u.protocol
|
|
94
|
+
const tokenFromUrl = u.searchParams.get('authToken') ?? undefined
|
|
95
|
+
this.endpoint = `${scheme}//${u.host}/v2/pipeline`
|
|
96
|
+
const token = config.authToken ?? tokenFromUrl
|
|
97
|
+
this.authHeader = token ? `Bearer ${token}` : undefined
|
|
98
|
+
}
|
|
99
|
+
async init(): Promise<void> {}
|
|
100
|
+
async acquireConnection(): Promise<DatabaseConnection> {
|
|
101
|
+
return new LibsqlWebConnection(this.endpoint, this.authHeader)
|
|
102
|
+
}
|
|
103
|
+
async beginTransaction(conn: DatabaseConnection): Promise<void> {
|
|
104
|
+
await (conn as LibsqlWebConnection).executeRaw('BEGIN')
|
|
105
|
+
}
|
|
106
|
+
async commitTransaction(conn: DatabaseConnection): Promise<void> {
|
|
107
|
+
await (conn as LibsqlWebConnection).executeRaw('COMMIT')
|
|
108
|
+
}
|
|
109
|
+
async rollbackTransaction(conn: DatabaseConnection): Promise<void> {
|
|
110
|
+
await (conn as LibsqlWebConnection).executeRaw('ROLLBACK')
|
|
111
|
+
}
|
|
112
|
+
async releaseConnection(conn: DatabaseConnection): Promise<void> {
|
|
113
|
+
await (conn as LibsqlWebConnection).close()
|
|
114
|
+
}
|
|
115
|
+
async destroy(): Promise<void> {}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
class LibsqlWebConnection implements DatabaseConnection {
|
|
119
|
+
private baton: string | null = null
|
|
120
|
+
private endpoint: string
|
|
121
|
+
constructor(
|
|
122
|
+
initialEndpoint: string,
|
|
123
|
+
private readonly authHeader: string | undefined
|
|
124
|
+
) {
|
|
125
|
+
this.endpoint = initialEndpoint
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async executeQuery<R>(query: CompiledQuery): Promise<QueryResult<R>> {
|
|
129
|
+
const result = await this.runStmt(query.sql, query.parameters)
|
|
130
|
+
return decodeResult<R>(result)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async *streamQuery<R>(
|
|
134
|
+
query: CompiledQuery
|
|
135
|
+
): AsyncIterableIterator<QueryResult<R>> {
|
|
136
|
+
yield this.executeQuery<R>(query)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async executeRaw(sql: string): Promise<void> {
|
|
140
|
+
await this.runStmt(sql, [])
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async close(): Promise<void> {
|
|
144
|
+
if (this.baton === null) return
|
|
145
|
+
await this.send([{ type: 'close' }])
|
|
146
|
+
this.baton = null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async runStmt(
|
|
150
|
+
sql: string,
|
|
151
|
+
args: readonly unknown[]
|
|
152
|
+
): Promise<HranaStmtResult> {
|
|
153
|
+
const resp = await this.send([
|
|
154
|
+
{
|
|
155
|
+
type: 'execute',
|
|
156
|
+
stmt: { sql, args: args.map(encodeArg) },
|
|
157
|
+
},
|
|
158
|
+
])
|
|
159
|
+
const r = resp.results[0]
|
|
160
|
+
if (!r) throw new Error('libsql: empty pipeline response')
|
|
161
|
+
if (r.type === 'error')
|
|
162
|
+
throw new Error(
|
|
163
|
+
`libsql: ${r.error.message}${r.error.code ? ` (${r.error.code})` : ''}`
|
|
164
|
+
)
|
|
165
|
+
if (r.response.type !== 'execute')
|
|
166
|
+
throw new Error(`libsql: unexpected response type ${r.response.type}`)
|
|
167
|
+
return r.response.result
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async send(requests: unknown[]): Promise<PipelineResponse> {
|
|
171
|
+
const headers: Record<string, string> = {
|
|
172
|
+
'content-type': 'application/json',
|
|
173
|
+
}
|
|
174
|
+
if (this.authHeader) headers.authorization = this.authHeader
|
|
175
|
+
const res = await fetch(this.endpoint, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers,
|
|
178
|
+
body: JSON.stringify({ baton: this.baton, requests }),
|
|
179
|
+
})
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
const body = await res.text().catch(() => '')
|
|
182
|
+
throw new Error(`libsql HTTP ${res.status}: ${body}`)
|
|
183
|
+
}
|
|
184
|
+
const json = (await res.json()) as PipelineResponse
|
|
185
|
+
this.baton = json.baton
|
|
186
|
+
if (json.base_url)
|
|
187
|
+
this.endpoint = `${json.base_url.replace(/\/$/, '')}/v2/pipeline`
|
|
188
|
+
return json
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function encodeArg(v: unknown): HranaValue {
|
|
193
|
+
if (v === null || v === undefined) return { type: 'null' }
|
|
194
|
+
if (typeof v === 'string') return { type: 'text', value: v }
|
|
195
|
+
if (typeof v === 'number')
|
|
196
|
+
return Number.isInteger(v)
|
|
197
|
+
? { type: 'integer', value: String(v) }
|
|
198
|
+
: { type: 'float', value: v }
|
|
199
|
+
if (typeof v === 'bigint') return { type: 'integer', value: v.toString() }
|
|
200
|
+
if (typeof v === 'boolean') return { type: 'integer', value: v ? '1' : '0' }
|
|
201
|
+
if (v instanceof ArrayBuffer)
|
|
202
|
+
return { type: 'blob', base64: bytesToBase64(new Uint8Array(v)) }
|
|
203
|
+
if (v instanceof Uint8Array) return { type: 'blob', base64: bytesToBase64(v) }
|
|
204
|
+
throw new Error(`libsql: unsupported argument type ${typeof v}`)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function decodeValue(v: HranaValue): unknown {
|
|
208
|
+
switch (v.type) {
|
|
209
|
+
case 'null':
|
|
210
|
+
return null
|
|
211
|
+
case 'integer': {
|
|
212
|
+
const n = Number(v.value)
|
|
213
|
+
return Number.isSafeInteger(n) ? n : BigInt(v.value)
|
|
214
|
+
}
|
|
215
|
+
case 'float':
|
|
216
|
+
return v.value
|
|
217
|
+
case 'text':
|
|
218
|
+
return v.value
|
|
219
|
+
case 'blob':
|
|
220
|
+
return base64ToBytes(v.base64)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function decodeResult<R>(r: HranaStmtResult): QueryResult<R> {
|
|
225
|
+
const colNames = r.cols.map((c, i) => c.name ?? `c${i}`)
|
|
226
|
+
const rows = r.rows.map((row) => {
|
|
227
|
+
const o: Record<string, unknown> = {}
|
|
228
|
+
for (let i = 0; i < colNames.length; i++)
|
|
229
|
+
o[colNames[i]!] = decodeValue(row[i]!)
|
|
230
|
+
return o as R
|
|
231
|
+
})
|
|
232
|
+
return {
|
|
233
|
+
rows,
|
|
234
|
+
numAffectedRows: BigInt(r.affected_row_count),
|
|
235
|
+
insertId:
|
|
236
|
+
r.last_insert_rowid !== null ? BigInt(r.last_insert_rowid) : undefined,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function bytesToBase64(b: Uint8Array): string {
|
|
241
|
+
let s = ''
|
|
242
|
+
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]!)
|
|
243
|
+
return btoa(s)
|
|
244
|
+
}
|
|
245
|
+
function base64ToBytes(s: string): Uint8Array {
|
|
246
|
+
const bin = atob(s)
|
|
247
|
+
const out = new Uint8Array(bin.length)
|
|
248
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
249
|
+
return out
|
|
250
|
+
}
|