@nativedesktop/data 0.1.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/package.json +35 -0
- package/src/adapters.test.ts +207 -0
- package/src/client.ts +140 -0
- package/src/index.ts +10 -0
- package/src/protocol.ts +48 -0
- package/src/react.ts +50 -0
- package/src/sqlite.test.ts +91 -0
- package/src/sqlite.worker.ts +86 -0
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nativedesktop/data",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/FormalSnake/NativeDesktop#readme",
|
|
7
|
+
"bugs": "https://github.com/FormalSnake/NativeDesktop/issues",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/FormalSnake/NativeDesktop.git",
|
|
11
|
+
"directory": "packages/data"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"files": ["src"],
|
|
17
|
+
"main": "./src/index.ts",
|
|
18
|
+
"types": "./src/index.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./src/index.ts",
|
|
21
|
+
"./react": "./src/react.ts"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@nativedesktop/react": "^0.1.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependenciesMeta": {
|
|
27
|
+
"@nativedesktop/react": {
|
|
28
|
+
"optional": true
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"drizzle-orm": "0.45.2",
|
|
33
|
+
"kysely": "0.29.2"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Worker-backed Drizzle and Kysely, proven end-to-end.
|
|
2
|
+
//
|
|
3
|
+
// Neither ORM ships in @nativedesktop/data — both are devDependencies of this
|
|
4
|
+
// package and appear ONLY as the userland adapter examples below. Each adapter
|
|
5
|
+
// is a small function written against the package's public `SqliteExecutor`
|
|
6
|
+
// contract (openDatabase() returns one that implements it); copy either into an
|
|
7
|
+
// app, install the ORM as the app's own dependency, and you get a worker-backed
|
|
8
|
+
// query builder whose heavy queries never touch React's commit loop.
|
|
9
|
+
//
|
|
10
|
+
// Run with: bun test packages/data/src/adapters.test.ts
|
|
11
|
+
|
|
12
|
+
import { afterAll, expect, test } from "bun:test";
|
|
13
|
+
import { eq, sql as dsql } from "drizzle-orm";
|
|
14
|
+
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
15
|
+
import { drizzle } from "drizzle-orm/sqlite-proxy";
|
|
16
|
+
import {
|
|
17
|
+
CompiledQuery,
|
|
18
|
+
type DatabaseConnection,
|
|
19
|
+
type Dialect,
|
|
20
|
+
type Driver,
|
|
21
|
+
type Generated,
|
|
22
|
+
Kysely,
|
|
23
|
+
type QueryResult,
|
|
24
|
+
SqliteAdapter,
|
|
25
|
+
SqliteIntrospector,
|
|
26
|
+
SqliteQueryCompiler,
|
|
27
|
+
} from "kysely";
|
|
28
|
+
import { openDatabase, type SqliteDatabase, type SqliteExecutor, type SqlParams } from "./index.ts";
|
|
29
|
+
|
|
30
|
+
// ── Example adapter: Drizzle via drizzle-orm/sqlite-proxy ────────────────────
|
|
31
|
+
// sqlite-proxy is Drizzle's official *async remote* driver: you hand it a
|
|
32
|
+
// callback and it awaits your rows, so the whole builder returns Promises even
|
|
33
|
+
// though Drizzle's bun-sqlite dialect is synchronous. That is what makes the
|
|
34
|
+
// transparent async path feasible without forking Drizzle — the sync dialect
|
|
35
|
+
// stays in the worker, only this proxy runs on the main thread. sqlite-proxy
|
|
36
|
+
// reconstructs each result from a POSITIONAL value array, so we re-key each
|
|
37
|
+
// object row bun:sqlite gives us into `Object.values` in projected-column order.
|
|
38
|
+
// Caveat: a join selecting two same-named columns collapses in an object — such
|
|
39
|
+
// selects need column aliases (or a `.values()`-shaped executor).
|
|
40
|
+
function drizzleOverWorker<TSchema extends Record<string, unknown>>(exec: SqliteExecutor, schema: TSchema) {
|
|
41
|
+
return drizzle(
|
|
42
|
+
async (sql, params, method) => {
|
|
43
|
+
if (method === "run") {
|
|
44
|
+
await exec.mutate(sql, params);
|
|
45
|
+
return { rows: [] };
|
|
46
|
+
}
|
|
47
|
+
const rows = (await exec.query(sql, params)).map((row) => Object.values(row));
|
|
48
|
+
return { rows: method === "get" ? (rows[0] as unknown[]) : rows };
|
|
49
|
+
},
|
|
50
|
+
{ schema },
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Example adapter: Kysely via a custom async Dialect ───────────────────────
|
|
55
|
+
// Kysely's driver model is async-first: DatabaseConnection.executeQuery returns
|
|
56
|
+
// a Promise of `{ rows }`, so it maps onto the worker with no shimming and no
|
|
57
|
+
// row-shape conversion (Kysely keys rows by column name, which is exactly what
|
|
58
|
+
// our `query` returns). We reuse Kysely's own SQLite compiler/adapter/
|
|
59
|
+
// introspector and supply only the driver.
|
|
60
|
+
class WorkerConnection implements DatabaseConnection {
|
|
61
|
+
constructor(private readonly exec: SqliteExecutor) {}
|
|
62
|
+
|
|
63
|
+
async executeQuery<R>(compiled: CompiledQuery): Promise<QueryResult<R>> {
|
|
64
|
+
const node = compiled.query as { kind: string; returning?: unknown };
|
|
65
|
+
const write =
|
|
66
|
+
node.kind === "InsertQueryNode" ||
|
|
67
|
+
node.kind === "UpdateQueryNode" ||
|
|
68
|
+
node.kind === "DeleteQueryNode" ||
|
|
69
|
+
node.kind === "MergeQueryNode";
|
|
70
|
+
if (write && node.returning == null) {
|
|
71
|
+
const r = await this.exec.mutate(compiled.sql, compiled.parameters as SqlParams);
|
|
72
|
+
return { rows: [], numAffectedRows: BigInt(r.changes), insertId: BigInt(r.lastInsertRowid) };
|
|
73
|
+
}
|
|
74
|
+
return { rows: await this.exec.query<R>(compiled.sql, compiled.parameters as SqlParams) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async *streamQuery<R>(): AsyncIterableIterator<QueryResult<R>> {
|
|
78
|
+
throw new Error("streaming is not supported by the worker-backed SQLite driver");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function kyselyOverWorker<DB>(exec: SqliteExecutor): Kysely<DB> {
|
|
83
|
+
const connection = new WorkerConnection(exec);
|
|
84
|
+
const driver: Driver = {
|
|
85
|
+
async init() {},
|
|
86
|
+
async acquireConnection() {
|
|
87
|
+
return connection;
|
|
88
|
+
},
|
|
89
|
+
async beginTransaction(conn) {
|
|
90
|
+
await conn.executeQuery(CompiledQuery.raw("begin"));
|
|
91
|
+
},
|
|
92
|
+
async commitTransaction(conn) {
|
|
93
|
+
await conn.executeQuery(CompiledQuery.raw("commit"));
|
|
94
|
+
},
|
|
95
|
+
async rollbackTransaction(conn) {
|
|
96
|
+
await conn.executeQuery(CompiledQuery.raw("rollback"));
|
|
97
|
+
},
|
|
98
|
+
async releaseConnection() {},
|
|
99
|
+
async destroy() {},
|
|
100
|
+
};
|
|
101
|
+
const dialect: Dialect = {
|
|
102
|
+
createDriver: () => driver,
|
|
103
|
+
createQueryCompiler: () => new SqliteQueryCompiler(),
|
|
104
|
+
createAdapter: () => new SqliteAdapter(),
|
|
105
|
+
createIntrospector: (db) => new SqliteIntrospector(db),
|
|
106
|
+
};
|
|
107
|
+
return new Kysely<DB>({ dialect });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Verification ─────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
const notes = sqliteTable("notes", {
|
|
113
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
114
|
+
title: text("title").notNull(),
|
|
115
|
+
pinned: integer("pinned", { mode: "boolean" }).notNull(),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
let db: SqliteDatabase;
|
|
119
|
+
|
|
120
|
+
afterAll(async () => {
|
|
121
|
+
await db?.close();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("Drizzle over the worker returns correctly-mapped rows via sqlite-proxy", async () => {
|
|
125
|
+
db = await openDatabase(":memory:");
|
|
126
|
+
await db.mutate(
|
|
127
|
+
"CREATE TABLE notes (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, pinned INTEGER NOT NULL)",
|
|
128
|
+
);
|
|
129
|
+
const d = drizzleOverWorker(db, { notes });
|
|
130
|
+
|
|
131
|
+
await d.insert(notes).values([
|
|
132
|
+
{ title: "first", pinned: true },
|
|
133
|
+
{ title: "second", pinned: false },
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
// Full-row select: proves positional mapping AND type decoding — `pinned` comes
|
|
137
|
+
// back as a real boolean (integer 1/0 in SQLite), which only works if the value
|
|
138
|
+
// array reached Drizzle's mapResultRow in the right column order.
|
|
139
|
+
const rows = await d.select().from(notes).orderBy(notes.id);
|
|
140
|
+
expect(rows).toEqual([
|
|
141
|
+
{ id: 1, title: "first", pinned: true },
|
|
142
|
+
{ id: 2, title: "second", pinned: false },
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
// Projected single-row select: exercises the `get` path and bound params.
|
|
146
|
+
const one = await d.select({ title: notes.title }).from(notes).where(eq(notes.id, 2)).get();
|
|
147
|
+
expect(one).toEqual({ title: "second" });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("Kysely over the worker returns correctly-mapped rows via a custom async dialect", async () => {
|
|
151
|
+
interface KDB {
|
|
152
|
+
notes: { id: Generated<number>; title: string; pinned: number };
|
|
153
|
+
}
|
|
154
|
+
const k = kyselyOverWorker<KDB>(db);
|
|
155
|
+
|
|
156
|
+
const inserted = await k
|
|
157
|
+
.insertInto("notes")
|
|
158
|
+
.values({ title: "third", pinned: 1 })
|
|
159
|
+
.executeTakeFirstOrThrow();
|
|
160
|
+
expect(inserted.numInsertedOrUpdatedRows).toBe(1n); // sourced from our numAffectedRows
|
|
161
|
+
expect(inserted.insertId).toBe(3n); // sourced from lastInsertRowid
|
|
162
|
+
|
|
163
|
+
const rows = await k.selectFrom("notes").select(["title", "pinned"]).orderBy("id").execute();
|
|
164
|
+
expect(rows).toEqual([
|
|
165
|
+
{ title: "first", pinned: 1 },
|
|
166
|
+
{ title: "second", pinned: 0 },
|
|
167
|
+
{ title: "third", pinned: 1 },
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a heavy Drizzle query does not block the main thread", async () => {
|
|
172
|
+
const d = drizzleOverWorker(db, {});
|
|
173
|
+
|
|
174
|
+
let ticks = 0;
|
|
175
|
+
const heartbeat = setInterval(() => ticks++, 10);
|
|
176
|
+
const order: string[] = [];
|
|
177
|
+
|
|
178
|
+
const started = performance.now();
|
|
179
|
+
// Runs synchronously inside the worker (Drizzle's dialect is sync); the main
|
|
180
|
+
// thread only awaits the postMessage reply, so it must stay responsive.
|
|
181
|
+
const heavy = d
|
|
182
|
+
.all<[number]>(
|
|
183
|
+
dsql`WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM c WHERE n < 3000000) SELECT count(*) AS c FROM c`,
|
|
184
|
+
)
|
|
185
|
+
.then((result) => {
|
|
186
|
+
order.push("drizzle-query");
|
|
187
|
+
return Number(result[0]?.[0]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const independent = new Promise<void>((resolve) => setTimeout(resolve, 50)).then(() => {
|
|
191
|
+
order.push("independent-timer");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const [count] = await Promise.all([heavy, independent]);
|
|
195
|
+
clearInterval(heartbeat);
|
|
196
|
+
const elapsed = performance.now() - started;
|
|
197
|
+
|
|
198
|
+
console.log(
|
|
199
|
+
`heavy Drizzle query returned count=${count} after ${elapsed.toFixed(0)}ms; ` +
|
|
200
|
+
`main thread ticked ${ticks} times and the 50ms timer resolved first (order: ${order.join(" -> ")})`,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
expect(count).toBe(3000000);
|
|
204
|
+
expect(elapsed).toBeGreaterThan(100); // the query really was slow
|
|
205
|
+
expect(order[0]).toBe("independent-timer"); // timer beat the ORM query -> not blocked
|
|
206
|
+
expect(ticks).toBeGreaterThan(3); // heartbeat kept advancing during the query
|
|
207
|
+
});
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Main-thread client for the worker-backed SQLite connection. Nothing here
|
|
2
|
+
// touches `bun:sqlite`; every call posts a message to sqlite.worker.ts and
|
|
3
|
+
// returns a Promise resolved when the worker replies, so the Bun main thread
|
|
4
|
+
// (which also runs React's commit loop) never blocks on a query.
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
OpenOptions,
|
|
8
|
+
RunResult,
|
|
9
|
+
SerializedError,
|
|
10
|
+
SqlParams,
|
|
11
|
+
TxStep,
|
|
12
|
+
WorkerRequest,
|
|
13
|
+
WorkerResponse,
|
|
14
|
+
} from "./protocol.ts";
|
|
15
|
+
|
|
16
|
+
interface PendingCall {
|
|
17
|
+
resolve: (value: unknown) => void;
|
|
18
|
+
reject: (reason: Error) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A plain `Omit<WorkerRequest, "id">` collapses the discriminated union to its
|
|
22
|
+
// shared `kind` key; distributing over the union keeps each variant's fields.
|
|
23
|
+
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
|
|
24
|
+
type RequestBody = DistributiveOmit<WorkerRequest, "id">;
|
|
25
|
+
|
|
26
|
+
function rebuildError(error: SerializedError): Error {
|
|
27
|
+
const err = new Error(error.message);
|
|
28
|
+
err.name = error.name;
|
|
29
|
+
if (error.code !== undefined) (err as { code?: string }).code = error.code;
|
|
30
|
+
return err;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The async execution contract every call in this package ultimately speaks:
|
|
35
|
+
* `sql` + structured-clone-safe `params` in, a Promise of results out, with the
|
|
36
|
+
* synchronous `bun:sqlite` work happening in the worker. `SqliteDatabase`
|
|
37
|
+
* implements it; `openDatabase()` hands you one.
|
|
38
|
+
*
|
|
39
|
+
* This is the stable extension seam for query builders / ORMs. An adapter is a
|
|
40
|
+
* userland function that takes a `SqliteExecutor` and drives its own tool
|
|
41
|
+
* against these three methods — so the framework never depends on any ORM, and
|
|
42
|
+
* the app owns the ORM (its dependency, its version). Two example adapters,
|
|
43
|
+
* `drizzle-orm/sqlite-proxy` and a custom Kysely `Dialect`, are proven in
|
|
44
|
+
* `src/adapters.test.ts`; each is <20 lines against this interface.
|
|
45
|
+
*/
|
|
46
|
+
export interface SqliteExecutor {
|
|
47
|
+
/** Run a read query. Returns every row as a plain object keyed by column name. */
|
|
48
|
+
query<Row = Record<string, unknown>>(sql: string, params?: SqlParams): Promise<Row[]>;
|
|
49
|
+
/** Run an INSERT/UPDATE/DELETE (or any exec). Returns the affected-row count and last insert rowid. */
|
|
50
|
+
mutate(sql: string, params?: SqlParams): Promise<RunResult>;
|
|
51
|
+
/** Run a batch of statements atomically (BEGIN/COMMIT, rolled back if any step throws). */
|
|
52
|
+
transaction(steps: readonly TxStep[]): Promise<RunResult[]>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class SqliteDatabase implements SqliteExecutor {
|
|
56
|
+
readonly #worker: Worker;
|
|
57
|
+
#seq = 0;
|
|
58
|
+
#closed = false;
|
|
59
|
+
readonly #pending = new Map<number, PendingCall>();
|
|
60
|
+
|
|
61
|
+
private constructor(worker: Worker) {
|
|
62
|
+
this.#worker = worker;
|
|
63
|
+
worker.addEventListener("message", (event: MessageEvent<WorkerResponse>) => this.#onMessage(event.data));
|
|
64
|
+
// A worker-level error (e.g. the worker module failing to load) can never
|
|
65
|
+
// be tied to one request, so fail every in-flight call rather than hang.
|
|
66
|
+
worker.addEventListener("error", (event: ErrorEvent) =>
|
|
67
|
+
this.#failAll(new Error(`sqlite worker error: ${event.message}`)),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static async open(filename: string, options?: OpenOptions): Promise<SqliteDatabase> {
|
|
72
|
+
const worker = new Worker(new URL("./sqlite.worker.ts", import.meta.url).href, { type: "module" });
|
|
73
|
+
const db = new SqliteDatabase(worker);
|
|
74
|
+
try {
|
|
75
|
+
await db.#send({ kind: "open", filename, options });
|
|
76
|
+
} catch (err) {
|
|
77
|
+
worker.terminate();
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
return db;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Run a read query. Returns every row as a plain object. */
|
|
84
|
+
query<Row = Record<string, unknown>>(sql: string, params?: SqlParams): Promise<Row[]> {
|
|
85
|
+
return this.#send<Row[]>({ kind: "query", sql, params });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Run an INSERT/UPDATE/DELETE (or any exec). Returns the affected-row count and last insert rowid. */
|
|
89
|
+
mutate(sql: string, params?: SqlParams): Promise<RunResult> {
|
|
90
|
+
return this.#send<RunResult>({ kind: "mutate", sql, params });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Run a batch of statements atomically (BEGIN/COMMIT, rolled back if any step throws). */
|
|
94
|
+
transaction(steps: readonly TxStep[]): Promise<RunResult[]> {
|
|
95
|
+
return this.#send<RunResult[]>({ kind: "transaction", steps });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Close the underlying database and terminate the worker. Idempotent. */
|
|
99
|
+
async close(): Promise<void> {
|
|
100
|
+
if (this.#closed) return;
|
|
101
|
+
const closed = this.#send({ kind: "close" }); // send while still open
|
|
102
|
+
this.#closed = true; // then reject any further calls
|
|
103
|
+
try {
|
|
104
|
+
await closed;
|
|
105
|
+
} finally {
|
|
106
|
+
this.#worker.terminate();
|
|
107
|
+
this.#failAll(new Error("database is closed"));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#send<T>(req: RequestBody): Promise<T> {
|
|
112
|
+
if (this.#closed) return Promise.reject(new Error("database is closed"));
|
|
113
|
+
const id = ++this.#seq;
|
|
114
|
+
return new Promise<T>((resolve, reject) => {
|
|
115
|
+
this.#pending.set(id, { resolve: resolve as (value: unknown) => void, reject });
|
|
116
|
+
this.#worker.postMessage({ ...req, id } as WorkerRequest);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#onMessage(res: WorkerResponse): void {
|
|
121
|
+
const call = this.#pending.get(res.id);
|
|
122
|
+
if (!call) return;
|
|
123
|
+
this.#pending.delete(res.id);
|
|
124
|
+
if (res.ok) call.resolve(res.result);
|
|
125
|
+
else call.reject(rebuildError(res.error));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#failAll(err: Error): void {
|
|
129
|
+
for (const call of this.#pending.values()) call.reject(err);
|
|
130
|
+
this.#pending.clear();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Open a worker-backed SQLite database. `filename` is caller-provided — pass
|
|
136
|
+
* `":memory:"`, an absolute path, or e.g. `` `${appDataDir()}/app.sqlite` ``.
|
|
137
|
+
*/
|
|
138
|
+
export function openDatabase(filename: string, options?: OpenOptions): Promise<SqliteDatabase> {
|
|
139
|
+
return SqliteDatabase.open(filename, options);
|
|
140
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// @nativedesktop/data — a worker-backed, Promise-based SQLite data layer.
|
|
2
|
+
// The connection lives in a Bun Worker (sqlite.worker.ts) so heavy queries run
|
|
3
|
+
// off the main thread and never stall React's commit loop.
|
|
4
|
+
//
|
|
5
|
+
// The optional `useQuery` React hook lives at "@nativedesktop/data/react" so
|
|
6
|
+
// this core entry stays free of any React dependency.
|
|
7
|
+
|
|
8
|
+
export { openDatabase, SqliteDatabase } from "./client.ts";
|
|
9
|
+
export type { SqliteExecutor } from "./client.ts";
|
|
10
|
+
export type { OpenOptions, RunResult, SqlParams, SqlValue, TxStep } from "./protocol.ts";
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Wire types shared by the main-thread client (client.ts) and the SQLite
|
|
2
|
+
// worker (sqlite.worker.ts). Every value here crosses a `postMessage`
|
|
3
|
+
// boundary, so it must be structured-clone-safe: only SQLite-representable
|
|
4
|
+
// scalars, no functions, no class instances. That constraint is why a
|
|
5
|
+
// transaction is a *list of statements* (TxStep[]) and not a callback — a
|
|
6
|
+
// closure can't be cloned to the worker.
|
|
7
|
+
|
|
8
|
+
/** A single bindable SQLite value. `bigint`/`Uint8Array` clone fine over postMessage. */
|
|
9
|
+
export type SqlValue = string | number | bigint | boolean | null | Uint8Array;
|
|
10
|
+
|
|
11
|
+
/** Positional bindings (`?`, `?1`) or named bindings (`$id`, `:id`, `@id`). */
|
|
12
|
+
export type SqlParams = readonly SqlValue[] | Readonly<Record<string, SqlValue>>;
|
|
13
|
+
|
|
14
|
+
/** Result of a mutation, mirroring bun:sqlite's `Statement.run()` return. */
|
|
15
|
+
export interface RunResult {
|
|
16
|
+
changes: number;
|
|
17
|
+
lastInsertRowid: number | bigint;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One statement in an atomic transaction batch. */
|
|
21
|
+
export interface TxStep {
|
|
22
|
+
sql: string;
|
|
23
|
+
params?: SqlParams;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Passed straight through to `new Database(filename, options)` in the worker. */
|
|
27
|
+
export interface OpenOptions {
|
|
28
|
+
readonly?: boolean;
|
|
29
|
+
create?: boolean;
|
|
30
|
+
readwrite?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type WorkerRequest =
|
|
34
|
+
| { id: number; kind: "open"; filename: string; options?: OpenOptions }
|
|
35
|
+
| { id: number; kind: "query"; sql: string; params?: SqlParams }
|
|
36
|
+
| { id: number; kind: "mutate"; sql: string; params?: SqlParams }
|
|
37
|
+
| { id: number; kind: "transaction"; steps: readonly TxStep[] }
|
|
38
|
+
| { id: number; kind: "close" };
|
|
39
|
+
|
|
40
|
+
export interface SerializedError {
|
|
41
|
+
message: string;
|
|
42
|
+
name: string;
|
|
43
|
+
code?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type WorkerResponse =
|
|
47
|
+
| { id: number; ok: true; result: unknown }
|
|
48
|
+
| { id: number; ok: false; error: SerializedError };
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Optional React binding for @nativedesktop/data. Import from
|
|
2
|
+
// "@nativedesktop/data/react". Kept separate from the core so apps that only
|
|
3
|
+
// want the async client never pull in React.
|
|
4
|
+
|
|
5
|
+
import { useEffect, useState } from "@nativedesktop/react";
|
|
6
|
+
import type { SqliteDatabase } from "./client.ts";
|
|
7
|
+
import type { SqlParams } from "./protocol.ts";
|
|
8
|
+
|
|
9
|
+
export interface QueryState<Row> {
|
|
10
|
+
data: Row[] | undefined;
|
|
11
|
+
error: Error | undefined;
|
|
12
|
+
loading: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Run a read query and track its result as it resolves. Re-runs when `db`,
|
|
17
|
+
* `sql`, or `params` change; a superseded or unmounted query is ignored so
|
|
18
|
+
* late replies can't overwrite fresh state. Pass a nullish `db` (e.g. while it
|
|
19
|
+
* is still opening) to stay in the loading state without querying.
|
|
20
|
+
*/
|
|
21
|
+
export function useQuery<Row = Record<string, unknown>>(
|
|
22
|
+
db: SqliteDatabase | null | undefined,
|
|
23
|
+
sql: string,
|
|
24
|
+
params?: SqlParams,
|
|
25
|
+
): QueryState<Row> {
|
|
26
|
+
const [state, setState] = useState<QueryState<Row>>({ data: undefined, error: undefined, loading: true });
|
|
27
|
+
const key = JSON.stringify([sql, params]);
|
|
28
|
+
|
|
29
|
+
// effect:audited — subscribes to an out-of-tree async source (the worker);
|
|
30
|
+
// re-keyed on sql/params so a changed query refetches with fresh bindings.
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!db) return;
|
|
33
|
+
let cancelled = false;
|
|
34
|
+
setState((prev) => (prev.loading ? prev : { ...prev, loading: true }));
|
|
35
|
+
db.query<Row>(sql, params).then(
|
|
36
|
+
(rows) => {
|
|
37
|
+
if (!cancelled) setState({ data: rows, error: undefined, loading: false });
|
|
38
|
+
},
|
|
39
|
+
(err: unknown) => {
|
|
40
|
+
if (!cancelled) setState({ data: undefined, error: err as Error, loading: false });
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
return () => {
|
|
44
|
+
cancelled = true;
|
|
45
|
+
};
|
|
46
|
+
// params is captured through `key`; listing it directly would re-run on every render.
|
|
47
|
+
}, [db, key]);
|
|
48
|
+
|
|
49
|
+
return state;
|
|
50
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Runtime verification for the worker-backed SQLite client. Run with:
|
|
2
|
+
// bun test packages/data/src/sqlite.test.ts
|
|
3
|
+
//
|
|
4
|
+
// Proves both halves of the promise: queries work end-to-end across the worker
|
|
5
|
+
// boundary (round-trip, named params, transactions, error propagation), and a
|
|
6
|
+
// deliberately slow query never blocks the Bun main thread that drives React.
|
|
7
|
+
|
|
8
|
+
import { afterAll, expect, test } from "bun:test";
|
|
9
|
+
import { openDatabase, type SqliteDatabase } from "./index.ts";
|
|
10
|
+
|
|
11
|
+
let db: SqliteDatabase;
|
|
12
|
+
|
|
13
|
+
afterAll(async () => {
|
|
14
|
+
await db?.close();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("query + mutate round-trip through the worker", async () => {
|
|
18
|
+
db = await openDatabase(":memory:");
|
|
19
|
+
await db.mutate("CREATE TABLE notes (id INTEGER PRIMARY KEY, title TEXT, pinned INTEGER)");
|
|
20
|
+
|
|
21
|
+
const inserted = await db.mutate("INSERT INTO notes (title, pinned) VALUES (?, ?)", ["first", true]);
|
|
22
|
+
expect(inserted.changes).toBe(1);
|
|
23
|
+
expect(inserted.lastInsertRowid).toBe(1);
|
|
24
|
+
|
|
25
|
+
const rows = await db.query<{ id: number; title: string; pinned: number }>("SELECT * FROM notes ORDER BY id");
|
|
26
|
+
expect(rows).toEqual([{ id: 1, title: "first", pinned: 1 }]);
|
|
27
|
+
|
|
28
|
+
const named = await db.query("SELECT title FROM notes WHERE id = $id", { $id: 1 });
|
|
29
|
+
expect(named).toEqual([{ title: "first" }]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("transaction commits a batch atomically", async () => {
|
|
33
|
+
await db.mutate("DELETE FROM notes");
|
|
34
|
+
const results = await db.transaction([
|
|
35
|
+
{ sql: "INSERT INTO notes (title, pinned) VALUES (?, 0)", params: ["a"] },
|
|
36
|
+
{ sql: "INSERT INTO notes (title, pinned) VALUES (?, 1)", params: ["b"] },
|
|
37
|
+
]);
|
|
38
|
+
expect(results.map((r) => r.changes)).toEqual([1, 1]);
|
|
39
|
+
const rows = await db.query<{ c: number }>("SELECT count(*) AS c FROM notes");
|
|
40
|
+
expect(rows[0]?.c).toBe(2);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a failing transaction rolls back and rejects", async () => {
|
|
44
|
+
await db.mutate("DELETE FROM notes");
|
|
45
|
+
await expect(
|
|
46
|
+
db.transaction([
|
|
47
|
+
{ sql: "INSERT INTO notes (title, pinned) VALUES (?, 0)", params: ["kept?"] },
|
|
48
|
+
{ sql: "INSERT INTO notes (nonexistent) VALUES (1)" }, // throws in the worker
|
|
49
|
+
]),
|
|
50
|
+
).rejects.toThrow(/nonexistent/);
|
|
51
|
+
const rows = await db.query<{ c: number }>("SELECT count(*) AS c FROM notes");
|
|
52
|
+
expect(rows[0]?.c).toBe(0); // the first insert was rolled back
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a query error rejects with a usable message", async () => {
|
|
56
|
+
await expect(db.query("SELECT * FROM does_not_exist")).rejects.toThrow(/no such table: does_not_exist/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a slow query does not block the main thread", async () => {
|
|
60
|
+
let ticks = 0;
|
|
61
|
+
const heartbeat = setInterval(() => ticks++, 10);
|
|
62
|
+
const order: string[] = [];
|
|
63
|
+
|
|
64
|
+
const started = performance.now();
|
|
65
|
+
const slowSql =
|
|
66
|
+
"WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM c WHERE n < 3000000) SELECT count(*) AS c FROM c";
|
|
67
|
+
const slow = db.query<{ c: number }>(slowSql).then((rows) => {
|
|
68
|
+
order.push("slow-query");
|
|
69
|
+
return rows[0]?.c;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// An independent main-thread timer far shorter than the query. If the main
|
|
73
|
+
// thread were blocked by the query, this 50ms timer could not fire first.
|
|
74
|
+
const independent = new Promise<void>((resolve) => setTimeout(resolve, 50)).then(() => {
|
|
75
|
+
order.push("independent-timer");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const [count] = await Promise.all([slow, independent]);
|
|
79
|
+
clearInterval(heartbeat);
|
|
80
|
+
const elapsed = performance.now() - started;
|
|
81
|
+
|
|
82
|
+
console.log(
|
|
83
|
+
`slow query returned count=${count} after ${elapsed.toFixed(0)}ms; ` +
|
|
84
|
+
`main thread ticked ${ticks} times and the 50ms timer resolved first (order: ${order.join(" -> ")})`,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(count).toBe(3000000);
|
|
88
|
+
expect(elapsed).toBeGreaterThan(100); // the query really was slow
|
|
89
|
+
expect(order[0]).toBe("independent-timer"); // timer beat the query -> not blocked
|
|
90
|
+
expect(ticks).toBeGreaterThan(3); // heartbeat kept advancing during the query
|
|
91
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The SQLite worker: the ONLY place `bun:sqlite` is opened. It runs on its own
|
|
2
|
+
// thread, so every query here is off the Bun main thread that drives React's
|
|
3
|
+
// commit loop — a slow `SELECT` blocks this worker, never the UI.
|
|
4
|
+
//
|
|
5
|
+
// Requests arrive in order on the worker's event loop and are answered by `id`,
|
|
6
|
+
// so the client can have many in flight at once. The first request is always
|
|
7
|
+
// `open`; queries the client sends afterwards are guaranteed to see an open DB
|
|
8
|
+
// because openDatabase() awaits the open reply before handing back the client.
|
|
9
|
+
|
|
10
|
+
import { Database, type Statement } from "bun:sqlite";
|
|
11
|
+
import type { RunResult, SerializedError, SqlParams, TxStep, WorkerRequest, WorkerResponse } from "./protocol.ts";
|
|
12
|
+
|
|
13
|
+
declare const self: Worker;
|
|
14
|
+
|
|
15
|
+
let db: Database | null = null;
|
|
16
|
+
|
|
17
|
+
function reply(res: WorkerResponse): void {
|
|
18
|
+
self.postMessage(res);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function serializeError(err: unknown): SerializedError {
|
|
22
|
+
if (err instanceof Error) {
|
|
23
|
+
const code = (err as { code?: unknown }).code;
|
|
24
|
+
return { message: err.message, name: err.name, code: typeof code === "string" ? code : undefined };
|
|
25
|
+
}
|
|
26
|
+
return { message: String(err), name: "Error" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// bun:sqlite accepts positional bindings as a spread and named bindings as a
|
|
30
|
+
// single object argument; normalize both shapes (and the no-params case) here.
|
|
31
|
+
function all(stmt: Statement, params?: SqlParams): unknown[] {
|
|
32
|
+
if (params === undefined) return stmt.all() as unknown[];
|
|
33
|
+
return Array.isArray(params) ? (stmt.all(...params) as unknown[]) : (stmt.all(params) as unknown[]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function run(stmt: Statement, params?: SqlParams): RunResult {
|
|
37
|
+
if (params === undefined) return stmt.run();
|
|
38
|
+
return Array.isArray(params) ? stmt.run(...params) : stmt.run(params);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function requireDb(): Database {
|
|
42
|
+
if (!db) throw new Error("database is not open");
|
|
43
|
+
return db;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
self.onmessage = (event: MessageEvent<WorkerRequest>): void => {
|
|
47
|
+
const req = event.data;
|
|
48
|
+
try {
|
|
49
|
+
switch (req.kind) {
|
|
50
|
+
case "open": {
|
|
51
|
+
if (db) throw new Error("database is already open");
|
|
52
|
+
db = new Database(req.filename, req.options);
|
|
53
|
+
reply({ id: req.id, ok: true, result: null });
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
case "query": {
|
|
57
|
+
const rows = all(requireDb().query(req.sql), req.params);
|
|
58
|
+
reply({ id: req.id, ok: true, result: rows });
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case "mutate": {
|
|
62
|
+
const result = run(requireDb().query(req.sql), req.params);
|
|
63
|
+
reply({ id: req.id, ok: true, result });
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case "transaction": {
|
|
67
|
+
const active = requireDb();
|
|
68
|
+
// db.transaction() wraps the batch in BEGIN/COMMIT and rolls back +
|
|
69
|
+
// rethrows if any step throws, giving us atomicity for free.
|
|
70
|
+
const batch = active.transaction((steps: readonly TxStep[]): RunResult[] =>
|
|
71
|
+
steps.map((step) => run(active.query(step.sql), step.params)),
|
|
72
|
+
);
|
|
73
|
+
reply({ id: req.id, ok: true, result: batch(req.steps) });
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case "close": {
|
|
77
|
+
db?.close();
|
|
78
|
+
db = null;
|
|
79
|
+
reply({ id: req.id, ok: true, result: null });
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
reply({ id: req.id, ok: false, error: serializeError(err) });
|
|
85
|
+
}
|
|
86
|
+
};
|