cross-sqlite-client 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ueaner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,344 @@
1
+ # cross-sqlite-client
2
+
3
+ Cross-platform (Web + Tauri) SQLite client for JavaScript/TypeScript apps: one
4
+ `DbClient` interface, a versioned migration framework, and React bindings —
5
+ with no business schema baked in. You bring your own schema and pick an
6
+ adapter; the library handles the platform differences underneath.
7
+
8
+ ## Why
9
+
10
+ Web (via `@sqlite.org/sqlite-wasm`) and Tauri (via `@tauri-apps/plugin-sql`)
11
+ talk to SQLite in very different ways — different APIs, different connection
12
+ models, different failure modes. This package hides that behind one small
13
+ interface so the rest of your app can call `select()`/`execute()` without
14
+ caring which platform it's running on, and ships a migration runner and React
15
+ context on top so you don't have to write that plumbing per project.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pnpm add cross-sqlite-client
21
+ ```
22
+
23
+ `@sqlite.org/sqlite-wasm` and `@tauri-apps/plugin-sql` are `optionalDependencies`
24
+ of this package, so installing `cross-sqlite-client` pulls both in automatically
25
+ — you don't need to `pnpm add` either one yourself in your app's own
26
+ `package.json`. "optional" here means a failed install of one won't block the
27
+ rest, not "skipped unless requested." Your app only needs to *use* the one
28
+ matching its target platform; remove the other with `--no-optional` (or your
29
+ package manager's equivalent) if you don't want it in `node_modules` at all.
30
+ `react` is an optional peer dependency, needed only if you use the `./react`
31
+ subpath.
32
+
33
+ ## Quick start
34
+
35
+ **1. Define your schema as versioned migrations** (see [Writing migrations](#writing-migrations)):
36
+
37
+ ```ts
38
+ // appMigrations.ts
39
+ import type { Migration } from "cross-sqlite-client";
40
+
41
+ export const APP_MIGRATIONS: Migration[] = [
42
+ {
43
+ version: 1,
44
+ statements: [`CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY, title TEXT NOT NULL);`],
45
+ },
46
+ ];
47
+ ```
48
+
49
+ **2. Create a client, picking the adapter for the current platform:**
50
+
51
+ ```ts
52
+ // appDb.ts
53
+ import { createDbClient } from "cross-sqlite-client";
54
+ import { createWebAdapter } from "cross-sqlite-client/adapters/web";
55
+ import { createTauriAdapter } from "cross-sqlite-client/adapters/tauri";
56
+ import { isTauri } from "@tauri-apps/api/core";
57
+ import { APP_MIGRATIONS } from "./appMigrations";
58
+
59
+ export const clientPromise = createDbClient({
60
+ name: "my-app", // becomes the OPFS/Tauri database filename
61
+ adapter: isTauri() ? createTauriAdapter() : createWebAdapter(),
62
+ migrations: APP_MIGRATIONS,
63
+ });
64
+ ```
65
+
66
+ **3. Make it available to your React tree:**
67
+
68
+ ```tsx
69
+ import { DatabaseProvider } from "cross-sqlite-client/react";
70
+ import { clientPromise } from "./appDb";
71
+
72
+ function App() {
73
+ return (
74
+ <DatabaseProvider client={clientPromise}>
75
+ <Router />
76
+ </DatabaseProvider>
77
+ );
78
+ }
79
+ ```
80
+
81
+ **4. Read it wherever you need the client:**
82
+
83
+ ```tsx
84
+ import { useDatabase } from "cross-sqlite-client/react";
85
+
86
+ function TodoList() {
87
+ const { dbClient, isDbReady, isLoading, dbError } = useDatabase();
88
+
89
+ if (isLoading) return <Spinner />;
90
+ if (dbError) return <ErrorMessage error={dbError} />;
91
+ if (!isDbReady || !dbClient) return null;
92
+
93
+ // dbClient.select<T>(sql, params?) / dbClient.execute(sql, params?) / dbClient.close()
94
+ // ...
95
+ }
96
+ ```
97
+
98
+ ## API reference
99
+
100
+ ### Core (`cross-sqlite-client`)
101
+
102
+ | Export | What it is |
103
+ |---|---|
104
+ | `createDbClient(options)` | Resolves the adapter, initializes it, runs pending migrations, returns `Promise<DbClient>`. |
105
+ | `runMigrations(db, migrations, options?)` | The migration runner `createDbClient` uses internally — call it directly if you're not going through `createDbClient`. |
106
+ | `defaultExecutor` | The migration executor used when `migrationOptions.executor` isn't set — runs each statement with no transaction. |
107
+ | `DbClient` (type) | `{ select<T>(sql, params?), execute(sql, params?), close() }` — see below. |
108
+ | `DbAdapter` / `DbAdapterConfig` (types) | The interface each `createXAdapter()` factory returns / the `{ name }` config passed to `initialize()`. |
109
+ | `Migration` / `MigrationExecutor` / `MigrationOptions` (types) | See [Writing migrations](#writing-migrations). |
110
+ | `DbError` and subclasses | See [Errors](#errors). |
111
+
112
+ ```ts
113
+ import { createDbClient, runMigrations, defaultExecutor } from "cross-sqlite-client";
114
+ ```
115
+
116
+ `DbClient`, the interface every adapter implements:
117
+
118
+ ```ts
119
+ interface DbClient {
120
+ select<T>(sql: string, params?: unknown[]): Promise<T[]>;
121
+ execute(sql: string, params?: unknown[]): Promise<{ lastInsertId?: number; rowsAffected?: number }>;
122
+ close(): Promise<void>;
123
+ }
124
+ ```
125
+
126
+ ### Adapters
127
+
128
+ Each `createXAdapter()` call returns a fresh, independent `DbAdapter` instance
129
+ (no shared module-level state), so you can safely create more than one in the
130
+ same process — e.g. in tests.
131
+
132
+ | Subpath | Factory | Backing driver | `singleConnection` |
133
+ |---|---|---|---|
134
+ | `cross-sqlite-client/adapters/web` | `createWebAdapter(options?)` | `@sqlite.org/sqlite-wasm` (Worker) | `true` |
135
+ | `cross-sqlite-client/adapters/tauri` | `createTauriAdapter()` | `@tauri-apps/plugin-sql` | `false` |
136
+ | `cross-sqlite-client/adapters/memory` | `createMemoryAdapter()` | `@sqlite.org/sqlite-wasm` (Node/main-thread, in-memory) | `true` |
137
+
138
+ `singleConnection` says whether every `execute()`/`select()` call on that
139
+ adapter is guaranteed to land on the same physical connection — see
140
+ [Custom migration executors](#custom-migration-executors-and-singleconnection)
141
+ for why that matters.
142
+
143
+ **`createWebAdapter(options?)`:**
144
+
145
+ | Option | Default | Meaning |
146
+ |---|---|---|
147
+ | `timeoutMs` | `15000` | How long to wait for the SQLite worker to become ready before `initialize()` rejects. Without this, a failed worker-script load would leave `initialize()` pending forever. |
148
+ | `fallbackToMemory` | `true` | Whether to silently use `:memory:` when OPFS isn't available, instead of throwing. See [COOP/COEP](#coopcoep-required-for-opfs-persistence). |
149
+ | `singleTabLock` | `true` | Whether to coordinate access to the same OPFS file across browser tabs. See [Multi-tab coordination](#multi-tab-coordination). |
150
+
151
+ **`createTauriAdapter()`** and **`createMemoryAdapter()`** take no options.
152
+ `createMemoryAdapter()` is meant for tests — see [Testing](#testing).
153
+
154
+ ### React (`cross-sqlite-client/react`)
155
+
156
+ | Export | What it is |
157
+ |---|---|
158
+ | `<DatabaseProvider client={...}>` | Takes a `DbClient` or `Promise<DbClient>` (typically `createDbClient()`'s return value) and resolves it, exposing the result via context. It does **not** decide which adapter/migrations to use — that's your app's job (see Quick start) — and it does **not** call `client.close()` on unmount (see below). |
159
+ | `useDatabase()` | Reads the context: `{ dbClient, isDbReady, isLoading, dbError }`. Throws if called outside a `DatabaseProvider`. |
160
+
161
+ Two things worth knowing about `DatabaseProvider`:
162
+
163
+ - **No `retry`.** The `client` prop only ever settles once — retrying means
164
+ passing it a *new* promise, which re-triggers initialization because the
165
+ prop reference changed:
166
+
167
+ ```tsx
168
+ function App() {
169
+ const [clientPromise, setClientPromise] = useState(() => createDbClient({ ... }));
170
+
171
+ return (
172
+ <DatabaseProvider client={clientPromise}>
173
+ {/* on a dbError, e.g. from a "Retry" button: */}
174
+ {/* setClientPromise(createDbClient({ ... })) */}
175
+ <Router />
176
+ </DatabaseProvider>
177
+ );
178
+ }
179
+ ```
180
+
181
+ - **No auto-`close()` on unmount.** Whoever creates the client owns closing
182
+ it. If `DatabaseProvider` closed it automatically, a cached/shared client
183
+ (e.g. an app-level singleton) would get closed the moment the provider
184
+ happens to unmount and remount (conditional rendering, a remounting route,
185
+ test setup/teardown) — leaving `isDbReady: true` pointing at a dead
186
+ connection. Close the client yourself, in whatever code created it, if its
187
+ lifetime should be tied to something specific.
188
+
189
+ ## Writing migrations
190
+
191
+ ```ts
192
+ interface Migration {
193
+ version: number;
194
+ statements: string[]; // plain DDL/DML strings, no bound parameters
195
+ }
196
+ ```
197
+
198
+ - **Versions must start at 1.** With no migrations applied yet, `runMigrations`
199
+ treats the current version as `0`; a migration with `version: 0` would never
200
+ satisfy `version > currentVersion` and would be silently skipped forever.
201
+ `runMigrations` throws immediately if any `version <= 0`.
202
+ - **Every statement must be safe to re-run.** There is no cross-platform
203
+ transaction guarantee (see below), so if a migration fails partway, the next
204
+ startup re-runs the *entire* version from scratch. Stick to
205
+ `CREATE TABLE/INDEX IF NOT EXISTS` for new schema. For a future
206
+ non-idempotent change (e.g. renaming a column), check the current schema
207
+ state first — e.g. `SELECT 1 FROM pragma_table_info('t') WHERE name = '...'`
208
+ — and skip the step if it's already done, rather than relying on rollback.
209
+
210
+ ## Custom migration executors and `singleConnection`
211
+
212
+ The default executor (`defaultExecutor`) runs each migration statement
213
+ independently, with no transaction. `adapters/web` also exports a
214
+ `transactionalExecutor` that wraps a migration in `BEGIN`/`COMMIT`/`ROLLBACK` —
215
+ but that's only safe on an adapter whose `singleConnection` is `true` (a real,
216
+ single persistent connection). `@tauri-apps/plugin-sql`'s backend is a
217
+ connection pool (`sqlx::Pool<Sqlite>`); separate `execute()` calls aren't
218
+ guaranteed to land on the same physical connection, so a `BEGIN` and `COMMIT`
219
+ split across calls can be silently torn apart there without any error.
220
+ `createDbClient()` throws up front if you pass an executor marked
221
+ `requiresSingleConnection: true` to an adapter whose `singleConnection` is
222
+ `false`.
223
+
224
+ `transactionalExecutor` carries that marker via
225
+ `Object.assign(fn, { requiresSingleConnection: true })` — not by comparing the
226
+ executor to `defaultExecutor` by reference, which would only catch "some
227
+ non-default executor was passed," not specifically "this executor needs a
228
+ transaction." Write your own executor the same way if it also manages a
229
+ transaction. An executor that doesn't touch transactions at all (e.g. one
230
+ that just adds logging) doesn't need the marker and won't be rejected, even on
231
+ a pooled-connection adapter.
232
+
233
+ ```ts
234
+ import { runMigrations } from "cross-sqlite-client";
235
+ import { transactionalExecutor } from "cross-sqlite-client/adapters/web";
236
+
237
+ await runMigrations(client, APP_MIGRATIONS, { executor: transactionalExecutor });
238
+ ```
239
+
240
+ ## Web adapter details
241
+
242
+ ### COOP/COEP required for OPFS persistence
243
+
244
+ OPFS persistence (used by `createWebAdapter()`) requires the page to be served
245
+ with:
246
+
247
+ ```
248
+ Cross-Origin-Opener-Policy: same-origin
249
+ Cross-Origin-Embedder-Policy: require-corp
250
+ ```
251
+
252
+ Without these headers, the adapter doesn't error — it silently falls back to
253
+ an in-memory database (`fallbackToMemory: true` by default), so data won't
254
+ survive a page reload. Pass `fallbackToMemory: false` if you'd rather fail
255
+ loudly than run in-memory unexpectedly.
256
+
257
+ Note this is a *deployment* constraint, not a browser-version one: even on a
258
+ fully modern browser, you can lose cross-origin isolation by being embedded in
259
+ someone else's iframe, being hosted on a platform that won't let you set
260
+ custom headers, or a team deliberately not enabling `COEP: require-corp`
261
+ because it would block some other third-party script on the same page.
262
+ There's currently no middle tier between full OPFS persistence and no
263
+ persistence at all (e.g. a `localStorage`-backed fallback) — see
264
+ [Known limitations](#known-limitations).
265
+
266
+ ### Multi-tab coordination
267
+
268
+ sqlite-wasm's `opfs` VFS has its own locking protocol, so two tabs writing to
269
+ the same OPFS-backed file won't corrupt data and generally won't hang — the
270
+ losing tab just gets a catchable "database is locked" SQL error. But that
271
+ error only surfaces the moment some query happens to hit contention, with
272
+ nothing telling you *why* it failed.
273
+
274
+ By default (`singleTabLock: true`), `createWebAdapter()` uses the
275
+ [Web Locks API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API)
276
+ to claim a named lock for the database file before opening it. If another tab
277
+ already holds it, `initialize()` rejects immediately with `DbTabLockError`
278
+ instead of letting the app find out later via a random query failure — catch
279
+ it to show something like "this app is already open in another tab." Pass
280
+ `singleTabLock: false` to skip this and rely solely on sqlite-wasm's own
281
+ retry/`SQLITE_BUSY` behavior. This only ever applies to the OPFS-backed path —
282
+ `:memory:` is private per tab, so there's nothing to coordinate there.
283
+
284
+ ## Errors
285
+
286
+ Every adapter throws one of these (all extend `DbError extends Error`, and all
287
+ accept an optional `cause`):
288
+
289
+ | Class | Thrown when |
290
+ |---|---|
291
+ | `DbError` | Generic/usage errors, e.g. calling `select()`/`execute()` before `initialize()` resolves, or after `close()`. |
292
+ | `DbInitializationError` | `adapter.initialize()` failed (worker/OPFS/Tauri-load failure, etc.). |
293
+ | `DbExecutionError` (has `.sql` and `.params`) | A `select()`/`execute()` call failed. |
294
+ | `DbCloseError` | `client.close()` failed. |
295
+ | `DbTabLockError` | (Web adapter only, `singleTabLock: true`) Another tab already holds the database. |
296
+
297
+ ```ts
298
+ import { DbTabLockError } from "cross-sqlite-client";
299
+
300
+ try {
301
+ await clientPromise;
302
+ } catch (error) {
303
+ if (error instanceof DbTabLockError) {
304
+ // show "already open in another tab" instead of a generic error
305
+ }
306
+ }
307
+ ```
308
+
309
+ ## Testing
310
+
311
+ Use `createMemoryAdapter()` in tests instead of mocking `DbClient` — it's a
312
+ real SQLite engine (the same one the web adapter uses, via
313
+ `@sqlite.org/sqlite-wasm`'s Node/main-thread build), so your SQL runs for
314
+ real and behaves the same as it would against the web adapter, just without
315
+ persistence:
316
+
317
+ ```ts
318
+ import { createMemoryAdapter } from "cross-sqlite-client/adapters/memory";
319
+ import { runMigrations } from "cross-sqlite-client";
320
+
321
+ const client = await createMemoryAdapter().initialize({ name: "test" });
322
+ await runMigrations(client, APP_MIGRATIONS);
323
+ // exercise client.select() / client.execute() as usual
324
+ ```
325
+
326
+ Each `createMemoryAdapter()` call is a fresh, independent instance, so
327
+ separate tests (or parallel tests in the same process) don't share state.
328
+
329
+ ## Known limitations
330
+
331
+ - **`lastInsertId` precision.** `DbClient.execute()`'s `lastInsertId` is typed
332
+ as `number`. The web adapter converts SQLite's `sqlite3_last_insert_rowid()`
333
+ (a 64-bit `bigint`, up to 2^63-1) down via `Number()`, so a rowid beyond
334
+ `Number.MAX_SAFE_INTEGER` (2^53-1) loses precision. Not an issue for a
335
+ typical local-first app (that's over 9 quadrillion rows in one table), but
336
+ if you need an exact large rowid, read it back with a dedicated
337
+ `SELECT last_insert_rowid()` query instead.
338
+ - **No OPFS degradation tier.** When OPFS/cross-origin isolation isn't
339
+ available, `createWebAdapter()` only has two states: full OPFS persistence,
340
+ or `:memory:` with none at all. sqlite-wasm ships a `kvvfs` backend
341
+ (`localStorage`/`sessionStorage`-based, no cross-origin isolation required)
342
+ that could serve as a middle tier, but it wasn't wired in as of this
343
+ writing — pursue it if you need persistence in deployments that can't
344
+ achieve cross-origin isolation (see [COOP/COEP](#coopcoep-required-for-opfs-persistence)).
@@ -0,0 +1,246 @@
1
+ # cross-sqlite-client
2
+
3
+ 面向 JavaScript/TypeScript 应用的跨平台(Web + Tauri)SQLite 客户端:一个统一的 `DbClient` 接口、版本化迁移框架、以及 React 绑定——业务 schema 完全由调用方定义。你提供自己的 schema 并选一个适配器,库负责抹平底层平台差异。
4
+
5
+ ## 为什么需要它
6
+
7
+ Web(通过 `@sqlite.org/sqlite-wasm`)和 Tauri(通过 `@tauri-apps/plugin-sql`)访问 SQLite 的方式截然不同:API 不同、连接模型不同、失败模式也不同。这个库把这些差异隐藏在一个很小的接口后面,让应用其余部分直接调用 `select()`/`execute()`,而不用关心自己跑在哪个平台上;同时内置了迁移运行器和 React Context,省得每个项目都重复写一遍。
8
+
9
+ ## 安装
10
+
11
+ ```bash
12
+ pnpm add cross-sqlite-client
13
+ ```
14
+
15
+ `@sqlite.org/sqlite-wasm` 和 `@tauri-apps/plugin-sql` 是本包的 `optionalDependencies`,因此安装 `cross-sqlite-client` 时会自动把两个都带上——不需要在你自己应用的 `package.json` 里单独 `pnpm add` 它们。这里的 optional 意味着其中一个安装失败不会阻塞其余安装,而不是「按需跳过」。你的应用只需要**使用**匹配目标平台的那一个;如果不想让另一个出现在 `node_modules` 里,可用 `--no-optional`(或对应包管理器的等效参数)安装。`react` 是可选 peer dependency,只有使用 `./react` 子路径时才需要。
16
+
17
+ ## 快速开始
18
+
19
+ **1. 用版本化迁移定义 schema**(详见[编写迁移](#编写迁移)):
20
+
21
+ ```ts
22
+ // appMigrations.ts
23
+ import type { Migration } from "cross-sqlite-client";
24
+
25
+ export const APP_MIGRATIONS: Migration[] = [
26
+ {
27
+ version: 1,
28
+ statements: [`CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY, title TEXT NOT NULL);`],
29
+ },
30
+ ];
31
+ ```
32
+
33
+ **2. 创建客户端,按当前平台选择适配器:**
34
+
35
+ ```ts
36
+ // appDb.ts
37
+ import { createDbClient } from "cross-sqlite-client";
38
+ import { createWebAdapter } from "cross-sqlite-client/adapters/web";
39
+ import { createTauriAdapter } from "cross-sqlite-client/adapters/tauri";
40
+ import { isTauri } from "@tauri-apps/api/core";
41
+ import { APP_MIGRATIONS } from "./appMigrations";
42
+
43
+ export const clientPromise = createDbClient({
44
+ name: "my-app", // 会成为 OPFS/Tauri 数据库文件名
45
+ adapter: isTauri() ? createTauriAdapter() : createWebAdapter(),
46
+ migrations: APP_MIGRATIONS,
47
+ });
48
+ ```
49
+
50
+ **3. 通过 React Provider 提供给组件树:**
51
+
52
+ ```tsx
53
+ import { DatabaseProvider } from "cross-sqlite-client/react";
54
+ import { clientPromise } from "./appDb";
55
+
56
+ function App() {
57
+ return (
58
+ <DatabaseProvider client={clientPromise}>
59
+ <Router />
60
+ </DatabaseProvider>
61
+ );
62
+ }
63
+ ```
64
+
65
+ **4. 在任意组件里读取客户端:**
66
+
67
+ ```tsx
68
+ import { useDatabase } from "cross-sqlite-client/react";
69
+
70
+ function TodoList() {
71
+ const { dbClient, isDbReady, isLoading, dbError } = useDatabase();
72
+
73
+ if (isLoading) return <Spinner />;
74
+ if (dbError) return <ErrorMessage error={dbError} />;
75
+ if (!isDbReady || !dbClient) return null;
76
+
77
+ // dbClient.select<T>(sql, params?) / dbClient.execute(sql, params?) / dbClient.close()
78
+ // ...
79
+ }
80
+ ```
81
+
82
+ ## API 参考
83
+
84
+ ### 核心(`cross-sqlite-client`)
85
+
86
+ | 导出 | 说明 |
87
+ |---|---|
88
+ | `createDbClient(options)` | 初始化适配器、执行待应用迁移,返回 `Promise<DbClient>`。 |
89
+ | `runMigrations(db, migrations, options?)` | `createDbClient` 内部使用的迁移运行器;不经过 `createDbClient` 时可直接调用。 |
90
+ | `defaultExecutor` | 未设置 `migrationOptions.executor` 时使用的默认迁移执行器:逐条执行语句,无事务包裹。 |
91
+ | `DbClient`(类型) | `{ select<T>(sql, params?), execute(sql, params?), close() }` —— 详见下文。 |
92
+ | `DbAdapter` / `DbAdapterConfig`(类型) | 各 `createXAdapter()` 工厂返回的接口 / 传给 `initialize()` 的 `{ name }` 配置。 |
93
+ | `Migration` / `MigrationExecutor` / `MigrationOptions`(类型) | 见[编写迁移](#编写迁移)。 |
94
+ | `DbError` 及子类 | 见[错误](#错误)。 |
95
+
96
+ ```ts
97
+ import { createDbClient, runMigrations, defaultExecutor } from "cross-sqlite-client";
98
+ ```
99
+
100
+ 每个适配器都实现的 `DbClient` 接口:
101
+
102
+ ```ts
103
+ interface DbClient {
104
+ select<T>(sql: string, params?: unknown[]): Promise<T[]>;
105
+ execute(sql: string, params?: unknown[]): Promise<{ lastInsertId?: number; rowsAffected?: number }>;
106
+ close(): Promise<void>;
107
+ }
108
+ ```
109
+
110
+ ### 适配器
111
+
112
+ 每次调用 `createXAdapter()` 都会返回一个**全新的、相互独立的** `DbAdapter` 实例(没有模块级单例状态),因此同一进程里可以放心创建多个——例如测试里。
113
+
114
+ | 子路径 | 工厂 | 底层驱动 | `singleConnection` |
115
+ |---|---|---|---|
116
+ | `cross-sqlite-client/adapters/web` | `createWebAdapter(options?)` | `@sqlite.org/sqlite-wasm`(Worker) | `true` |
117
+ | `cross-sqlite-client/adapters/tauri` | `createTauriAdapter()` | `@tauri-apps/plugin-sql` | `false` |
118
+ | `cross-sqlite-client/adapters/memory` | `createMemoryAdapter()` | `@sqlite.org/sqlite-wasm`(Node/主线程,内存) | `true` |
119
+
120
+ `singleConnection` 表示该适配器的每次 `execute()`/`select()` 是否保证落在同一条物理连接上——为什么重要,见[自定义迁移执行器](#自定义迁移执行器与-singleconnection)。
121
+
122
+ **`createWebAdapter(options?)` 选项:**
123
+
124
+ | 选项 | 默认值 | 说明 |
125
+ |---|---|---|
126
+ | `timeoutMs` | `15000` | 等待 SQLite Worker 就绪的超时时间;若 Worker 脚本加载失败,不设超时会让 `initialize()` 永远 pending。 |
127
+ | `fallbackToMemory` | `true` | OPFS 不可用时是否静默回退到 `:memory:`,而不是抛错。详见 [COOP/COEP](#opfs-持久化需要-coopcoep)。 |
128
+ | `singleTabLock` | `true` | 是否跨浏览器标签页协调对同一 OPFS 文件的访问。详见[多标签页协调](#多标签页协调)。 |
129
+
130
+ **`createTauriAdapter()`** 和 **`createMemoryAdapter()`** 不接受选项。`createMemoryAdapter()` 用于测试——见[测试](#测试)。
131
+
132
+ ### React(`cross-sqlite-client/react`)
133
+
134
+ | 导出 | 说明 |
135
+ |---|---|
136
+ | `<DatabaseProvider client={...}>` | 接收 `DbClient` 或 `Promise<DbClient>`(通常是 `createDbClient()` 的返回值),解析后通过 Context 暴露。它**不**决定用哪个适配器或迁移哪些 schema——那是应用层的职责(见快速开始)——也**不**在卸载时调用 `client.close()`(原因见下文)。 |
137
+ | `useDatabase()` | 读取 Context:`{ dbClient, isDbReady, isLoading, dbError }`。在 `DatabaseProvider` 外调用会抛错。 |
138
+
139
+ 关于 `DatabaseProvider` 有两点值得注意:
140
+
141
+ - **没有内置 `retry`。** `client` prop 只会进入最终状态一次——重试意味着传给它一个**新的 Promise**,prop 引用变化会重新触发初始化:
142
+
143
+ ```tsx
144
+ function App() {
145
+ const [clientPromise, setClientPromise] = useState(() => createDbClient({ ... }));
146
+
147
+ return (
148
+ <DatabaseProvider client={clientPromise}>
149
+ {/* 出现 dbError 时,例如点击「重试」按钮:setClientPromise(createDbClient({ ... })) */}
150
+ <Router />
151
+ </DatabaseProvider>
152
+ );
153
+ }
154
+ ```
155
+
156
+ - **卸载时不自动 `close()`。** 谁创建 client,谁负责关闭。如果 `DatabaseProvider` 自动关闭 client,那么当它因为条件渲染、路由重挂载、测试 setup/teardown 而卸载又重新挂载时,被缓存/共享的 client(比如应用级单例)就会被直接关掉——结果 `isDbReady: true`,但连接其实已经死了。如果 client 生命周期需要绑定到某个特定范围,请在你创建它的地方自行关闭。
157
+
158
+ ## 编写迁移
159
+
160
+ ```ts
161
+ interface Migration {
162
+ version: number;
163
+ statements: string[]; // 纯 DDL/DML 字符串,不带绑定参数
164
+ }
165
+ ```
166
+
167
+ - **版本号必须从 1 开始。** 没有应用任何迁移时,`runMigrations` 把当前版本视为 `0`;一个 `version: 0` 的迁移永远满足不了 `version > currentVersion`,会被永久静默跳过。`runMigrations` 会在传入 `version <= 0` 时立即抛错。
168
+ - **每条语句都必须可安全重跑。** 跨平台事务没有统一保证(原因见下文),因此如果某次迁移执行到一半失败,下次启动会**从头重跑整个 version**。新 schema 请使用 `CREATE TABLE/INDEX IF NOT EXISTS`。未来若要写不可重跑的操作(例如重命名列),先查询当前 schema 状态——例如 `SELECT 1 FROM pragma_table_info('t') WHERE name = '...'`——已完成就跳过,而不是依赖回滚。
169
+
170
+ ## 自定义迁移执行器与 `singleConnection`
171
+
172
+ 默认执行器(`defaultExecutor`)逐条独立执行迁移语句,无事务包裹。`adapters/web` 还导出了 `transactionalExecutor`,它把一次迁移包在 `BEGIN`/`COMMIT`/`ROLLBACK` 里——但这只在 `singleConnection: true` 的适配器上安全(真正的一条持久连接)。`@tauri-apps/plugin-sql` 的底层是连接池(`sqlx::Pool<Sqlite>`),多次 `execute()` 调用不保证落在同一条物理连接上,`BEGIN` 和 `COMMIT` 跨调用拆散后甚至可能不报错。`createDbClient()` 会在你把一个标记为 `requiresSingleConnection: true` 的执行器传给 `singleConnection: false` 的适配器时,提前抛错。
173
+
174
+ 这个检查基于显式标记,而不是把执行器和 `defaultExecutor` 做引用相等比较:`transactionalExecutor` 通过 `Object.assign(fn, { requiresSingleConnection: true })` 携带标记。如果你也写了一个管理事务的自定义执行器,请用同样方式标记。完全不碰事务的执行器(例如只加日志)不需要这个标记,即使传给连接池适配器也不会被拒绝。
175
+
176
+ ```ts
177
+ import { runMigrations } from "cross-sqlite-client";
178
+ import { transactionalExecutor } from "cross-sqlite-client/adapters/web";
179
+
180
+ await runMigrations(client, APP_MIGRATIONS, { executor: transactionalExecutor });
181
+ ```
182
+
183
+ ## Web 适配器细节
184
+
185
+ ### OPFS 持久化需要 COOP/COEP
186
+
187
+ `createWebAdapter()` 使用 OPFS 持久化,要求页面必须以下列响应头提供:
188
+
189
+ ```
190
+ Cross-Origin-Opener-Policy: same-origin
191
+ Cross-Origin-Embedder-Policy: require-corp
192
+ ```
193
+
194
+ 没有这些头时,适配器不会报错,而是静默回退到内存数据库(`fallbackToMemory: true` 为默认),因此页面刷新后数据会丢失。如果你宁可失败也不愿在不知情的情况下跑内存模式,可传 `fallbackToMemory: false`。
195
+
196
+ 注意这是**部署/托管层面的约束**,不是浏览器版本问题:即使在很新的浏览器上,也可能因为嵌在别人的 iframe 里、托管平台不允许自定义响应头、或者团队故意不启用 `COEP: require-corp`(以免阻塞页面上的其他第三方脚本)而失去跨源隔离。目前只在「完整 OPFS 持久化」和「完全没有持久化(`:memory:`)」之间二选一——例如 sqlite-wasm 还提供了基于 `localStorage`/`sessionStorage` 的 `kvvfs` 后端,可作为中间层,但当前版本尚未接入;见[已知限制](#已知限制)。
197
+
198
+ ### 多标签页协调
199
+
200
+ sqlite-wasm 的 `opfs` VFS 自带锁协议,因此两个标签页同时写同一个 OPFS 文件时**不会损坏数据,通常也不会卡死**——失败的标签页会收到一个可捕获的 "database is locked" SQL 错误。但这个错误只在某条查询恰好撞上竞争时才暴露,而且不会告诉你**为什么**失败。
201
+
202
+ 默认开启 `singleTabLock: true` 时,`createWebAdapter()` 会在打开数据库文件前用 [Web Locks API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API) 申请一个命名锁。如果另一个标签页已经持有该锁,`initialize()` 会立即以 `DbTabLockError` reject,而不是让应用在之后的某次随机查询里才发现失败——捕获它之后可以展示「该应用已在另一个标签页打开」之类的提示。传 `singleTabLock: false` 可跳过此行为,只依赖 sqlite-wasm 自身的重试/`SQLITE_BUSY` 处理。它只对 OPFS 持久化路径生效——`:memory:` 每个标签页互相独立,无需协调。
203
+
204
+ ## 错误
205
+
206
+ 所有适配器都抛出以下错误类(全部继承 `DbError extends Error`,均可选传入 `cause`):
207
+
208
+ | 类 | 触发时机 |
209
+ |---|---|
210
+ | `DbError` | 通用/用法错误,例如 `initialize()` 还没 resolve 就调用 `select()`/`execute()`,或 `close()` 之后调用。 |
211
+ | `DbInitializationError` | `adapter.initialize()` 失败(Worker/OPFS/Tauri 加载失败等)。 |
212
+ | `DbExecutionError`(带 `.sql` 和 `.params`) | `select()`/`execute()` 调用失败。 |
213
+ | `DbCloseError` | `client.close()` 失败。 |
214
+ | `DbTabLockError` | (仅 Web 适配器,`singleTabLock: true`)另一个标签页已持有数据库锁。 |
215
+
216
+ ```ts
217
+ import { DbTabLockError } from "cross-sqlite-client";
218
+
219
+ try {
220
+ await clientPromise;
221
+ } catch (error) {
222
+ if (error instanceof DbTabLockError) {
223
+ // 展示「已在另一个标签页打开」而不是通用错误
224
+ }
225
+ }
226
+ ```
227
+
228
+ ## 测试
229
+
230
+ 测试时请用 `createMemoryAdapter()`,而不是 mock `DbClient`——它是真正的 SQLite 引擎(与 Web 适配器使用同一个 `@sqlite.org/sqlite-wasm` 的 Node/主线程版本),因此你的 SQL 会真实执行,行为与 Web 适配器一致,只是没有持久化:
231
+
232
+ ```ts
233
+ import { createMemoryAdapter } from "cross-sqlite-client/adapters/memory";
234
+ import { runMigrations } from "cross-sqlite-client";
235
+
236
+ const client = await createMemoryAdapter().initialize({ name: "test" });
237
+ await runMigrations(client, APP_MIGRATIONS);
238
+ // 正常使用 client.select() / client.execute()
239
+ ```
240
+
241
+ 每次 `createMemoryAdapter()` 调用都是一个全新的独立实例,因此不同测试(或同一进程里的并行测试)不会共享状态。
242
+
243
+ ## 已知限制
244
+
245
+ - **`lastInsertId` 精度。** `DbClient.execute()` 返回的 `lastInsertId` 类型是 `number`。Web 适配器会把 SQLite 的 `sqlite3_last_insert_rowid()`(64 位 `bigint`,最大 2^63-1)通过 `Number()` 转换,因此超过 `Number.MAX_SAFE_INTEGER`(2^53-1)时会丢失精度。对典型本地优先应用不是问题(需要单表超过 9 千万亿行才会触发),但如果你需要精确的大整数 rowid,请用专门的 `SELECT last_insert_rowid()` 查询读取。
246
+ - **没有 OPFS 降级层。** OPFS/跨源隔离不可用时,`createWebAdapter()` 只有两种状态:完整 OPFS 持久化,或完全没有持久化的 `:memory:`。sqlite-wasm 提供了基于 `localStorage`/`sessionStorage` 的 `kvvfs` 后端,可作为中间层,但当前版本尚未接入——如果你需要在无法达到跨源隔离的部署环境里获得持久化,可以考虑接入它(见 [COOP/COEP](#opfs-持久化需要-coopcoep))。
@@ -0,0 +1,12 @@
1
+ import { n as DbAdapter } from "../types-XKvAFU82.js";
2
+ //#region src/adapters/memory.d.ts
3
+ /**
4
+ * 内存适配器(测试用)。基于 @sqlite.org/sqlite-wasm 官方支持的 Node 单线程用法
5
+ * (sqlite3InitModule() + oo1.DB(':memory:')),跟 web 适配器共享同一个 sqlite3 引擎,
6
+ * 保证同一组 SQL 在两个适配器上的行为一致,而不是用另一套假实现模拟。
7
+ *
8
+ * 每次调用 createMemoryAdapter() 返回状态独立的新实例,可在同一进程里创建多个互不干扰的
9
+ * 实例(例如并行测试)。
10
+ */
11
+ export declare function createMemoryAdapter(): DbAdapter;
12
+ //#endregion
@@ -0,0 +1,65 @@
1
+ import { i as DbInitializationError, n as DbError, r as DbExecutionError, t as DbCloseError } from "../errors-D9KnLHTp.js";
2
+ import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
3
+ //#region src/adapters/memory.ts
4
+ /**
5
+ * 内存适配器(测试用)。基于 @sqlite.org/sqlite-wasm 官方支持的 Node 单线程用法
6
+ * (sqlite3InitModule() + oo1.DB(':memory:')),跟 web 适配器共享同一个 sqlite3 引擎,
7
+ * 保证同一组 SQL 在两个适配器上的行为一致,而不是用另一套假实现模拟。
8
+ *
9
+ * 每次调用 createMemoryAdapter() 返回状态独立的新实例,可在同一进程里创建多个互不干扰的
10
+ * 实例(例如并行测试)。
11
+ */
12
+ function createMemoryAdapter() {
13
+ let sqlite3 = null;
14
+ let db = null;
15
+ const client = {
16
+ async select(sql, params = []) {
17
+ if (!db) throw new DbError("[Memory DB] Database not initialized. Call initialize() first.");
18
+ try {
19
+ return db.selectObjects(sql, params);
20
+ } catch (error) {
21
+ throw new DbExecutionError(sql, params, error);
22
+ }
23
+ },
24
+ async execute(sql, params = []) {
25
+ if (!db || !sqlite3) throw new DbError("[Memory DB] Database not initialized. Call initialize() first.");
26
+ try {
27
+ db.exec({
28
+ sql,
29
+ bind: params
30
+ });
31
+ const rowsAffected = db.changes(false, false);
32
+ return {
33
+ lastInsertId: db.pointer !== void 0 ? Number(sqlite3.capi.sqlite3_last_insert_rowid(db.pointer)) : void 0,
34
+ rowsAffected
35
+ };
36
+ } catch (error) {
37
+ throw new DbExecutionError(sql, params, error);
38
+ }
39
+ },
40
+ async close() {
41
+ try {
42
+ db?.close();
43
+ } catch (error) {
44
+ throw new DbCloseError(error);
45
+ } finally {
46
+ db = null;
47
+ }
48
+ }
49
+ };
50
+ return {
51
+ singleConnection: true,
52
+ async initialize() {
53
+ if (db) return client;
54
+ try {
55
+ sqlite3 = await sqlite3InitModule();
56
+ db = new sqlite3.oo1.DB(":memory:", "c");
57
+ return client;
58
+ } catch (error) {
59
+ throw new DbInitializationError(error);
60
+ }
61
+ }
62
+ };
63
+ }
64
+ //#endregion
65
+ export { createMemoryAdapter };
@@ -0,0 +1,11 @@
1
+ import { n as DbAdapter } from "../types-XKvAFU82.js";
2
+ //#region src/adapters/tauri.d.ts
3
+ /**
4
+ * Tauri (@tauri-apps/plugin-sql) 适配器。每次调用 createTauriAdapter() 返回一个状态独立的
5
+ * 新实例——db 保存在这个函数的闭包里,不是模块级单例。
6
+ *
7
+ * singleConnection 为 false:@tauri-apps/plugin-sql 底层是 sqlx::Pool<Sqlite> 连接池,
8
+ * 每次 execute()/select() 调用独立获取/归还一个物理连接,不保证跨调用落在同一条连接上。
9
+ */
10
+ export declare function createTauriAdapter(): DbAdapter;
11
+ //#endregion
@@ -0,0 +1,58 @@
1
+ import { i as DbInitializationError, n as DbError, r as DbExecutionError, t as DbCloseError } from "../errors-D9KnLHTp.js";
2
+ import Sqlite from "@tauri-apps/plugin-sql";
3
+ //#region src/adapters/tauri.ts
4
+ /**
5
+ * Tauri (@tauri-apps/plugin-sql) 适配器。每次调用 createTauriAdapter() 返回一个状态独立的
6
+ * 新实例——db 保存在这个函数的闭包里,不是模块级单例。
7
+ *
8
+ * singleConnection 为 false:@tauri-apps/plugin-sql 底层是 sqlx::Pool<Sqlite> 连接池,
9
+ * 每次 execute()/select() 调用独立获取/归还一个物理连接,不保证跨调用落在同一条连接上。
10
+ */
11
+ function createTauriAdapter() {
12
+ let db = null;
13
+ const client = {
14
+ async select(sql, params = []) {
15
+ if (!db) throw new DbError("[Tauri DB] Database not initialized. Call initialize() first.");
16
+ try {
17
+ return await db.select(sql, params);
18
+ } catch (error) {
19
+ throw new DbExecutionError(sql, params, error);
20
+ }
21
+ },
22
+ async execute(sql, params = []) {
23
+ if (!db) throw new DbError("[Tauri DB] Database not initialized. Call initialize() first.");
24
+ try {
25
+ const result = await db.execute(sql, params);
26
+ return {
27
+ lastInsertId: result.lastInsertId,
28
+ rowsAffected: result.rowsAffected
29
+ };
30
+ } catch (error) {
31
+ throw new DbExecutionError(sql, params, error);
32
+ }
33
+ },
34
+ async close() {
35
+ if (db) try {
36
+ if (!await db.close()) throw new DbCloseError();
37
+ } catch (error) {
38
+ throw error instanceof DbCloseError ? error : new DbCloseError(error);
39
+ } finally {
40
+ db = null;
41
+ }
42
+ }
43
+ };
44
+ return {
45
+ singleConnection: false,
46
+ async initialize(config) {
47
+ if (db) return client;
48
+ try {
49
+ db = await Sqlite.load(`sqlite:${config.name}.db`);
50
+ return client;
51
+ } catch (error) {
52
+ throw new DbInitializationError(error);
53
+ }
54
+ }
55
+ };
56
+ }
57
+ //#endregion
58
+ export { createTauriAdapter };
@@ -0,0 +1,43 @@
1
+ import { n as DbAdapter, o as MigrationExecutor } from "../types-XKvAFU82.js";
2
+ //#region src/adapters/web.d.ts
3
+ export interface WebAdapterOptions {
4
+ /** 等待 Worker 就绪的超时时间(毫秒),超时后 initialize() 会 reject 而不是永远 pending */
5
+ timeoutMs?: number;
6
+ /** OPFS 不可用(不支持/未跨域隔离/初始化失败)时是否静默退化为内存模式,默认 true */
7
+ fallbackToMemory?: boolean;
8
+ /**
9
+ * 用 Web Locks API(navigator.locks)在同一 origin 的多个标签页/窗口之间协调对同一个
10
+ * OPFS 文件的访问,默认 true。
11
+ *
12
+ * 背景:sqlite-wasm 的 opfs VFS 本身有锁协议(xLock/xUnlock 走 SharedArrayBuffer +
13
+ * Atomics.wait()),两个标签页同时写同一个 OPFS 文件不会挂死或数据损坏,冲突时会重试一段
14
+ * 时间后返回 SQLITE_BUSY,作为一次普通的、可 catch 的 SQL 错误出现——但这个错误只会在业务
15
+ * 代码真正执行到某条 SQL 语句时才暴露,且没有任何提示"这是因为别的标签页正在用这个库"。
16
+ * 启用 singleTabLock 后,initialize() 会在真正打开 OPFS 文件之前先尝试抢一个跨标签页命名
17
+ * 锁;抢不到(说明另一个标签页已经持有)会立即抛出 DbTabLockError,而不是让业务代码在某次
18
+ * 随机的查询里才踩到一个语义不明的 SQL 错误。
19
+ *
20
+ * 只在真的走 OPFS 持久化时才会用到这把锁——:memory: 每个标签页互相独立,没有需要协调的资源,
21
+ * 不受这个选项影响。
22
+ */
23
+ singleTabLock?: boolean;
24
+ }
25
+ /**
26
+ * 用 Web Locks API 尝试(不等待)拿到一个跨标签页命名锁。拿不到(另一个标签页已持有)时
27
+ * resolve 到 null;拿到时返回一个 release() 函数,调用方后续必须调用它来释放锁。
28
+ *
29
+ * 实现上依赖一个常见技巧:navigator.locks.request() 的回调函数返回什么 Promise,锁就持有到
30
+ * 那个 Promise settle 为止;这里让回调返回一个我们自己创建、直到 release() 被调用才会 resolve
31
+ * 的 Promise,从而把锁"长期持有"而不是只在回调执行期间持有。
32
+ */
33
+ export declare function tryAcquireTabLock(lockName: string): Promise<(() => void) | null>;
34
+ /**
35
+ * Web (sqlite-wasm) 适配器。每次调用 createWebAdapter() 返回一个状态独立的新实例——
36
+ * promiser/dbId 保存在这个函数的闭包里,不是模块级单例,允许同一进程里存在多个互不干扰的
37
+ * 实例(例如测试)。
38
+ */
39
+ export declare function createWebAdapter(options?: WebAdapterOptions): DbAdapter;
40
+ export declare const transactionalExecutor: MigrationExecutor & {
41
+ requiresSingleConnection: true;
42
+ };
43
+ //#endregion
@@ -0,0 +1,154 @@
1
+ import { a as DbTabLockError, i as DbInitializationError, n as DbError, r as DbExecutionError, t as DbCloseError } from "../errors-D9KnLHTp.js";
2
+ import { sqlite3Worker1Promiser } from "@sqlite.org/sqlite-wasm";
3
+ //#region src/adapters/web.ts
4
+ const DEFAULT_TIMEOUT_MS = 15e3;
5
+ /**
6
+ * 用 Web Locks API 尝试(不等待)拿到一个跨标签页命名锁。拿不到(另一个标签页已持有)时
7
+ * resolve 到 null;拿到时返回一个 release() 函数,调用方后续必须调用它来释放锁。
8
+ *
9
+ * 实现上依赖一个常见技巧:navigator.locks.request() 的回调函数返回什么 Promise,锁就持有到
10
+ * 那个 Promise settle 为止;这里让回调返回一个我们自己创建、直到 release() 被调用才会 resolve
11
+ * 的 Promise,从而把锁"长期持有"而不是只在回调执行期间持有。
12
+ */
13
+ async function tryAcquireTabLock(lockName) {
14
+ if (typeof navigator === "undefined" || !navigator.locks) return () => {};
15
+ let release;
16
+ if (!await new Promise((resolveAcquired) => {
17
+ navigator.locks.request(lockName, { ifAvailable: true }, (lock) => {
18
+ if (!lock) {
19
+ resolveAcquired(false);
20
+ return;
21
+ }
22
+ resolveAcquired(true);
23
+ return new Promise((resolveRelease) => {
24
+ release = resolveRelease;
25
+ });
26
+ });
27
+ })) return null;
28
+ return () => release?.();
29
+ }
30
+ /**
31
+ * Web (sqlite-wasm) 适配器。每次调用 createWebAdapter() 返回一个状态独立的新实例——
32
+ * promiser/dbId 保存在这个函数的闭包里,不是模块级单例,允许同一进程里存在多个互不干扰的
33
+ * 实例(例如测试)。
34
+ */
35
+ function createWebAdapter(options = {}) {
36
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
37
+ const fallbackToMemory = options.fallbackToMemory ?? true;
38
+ const singleTabLock = options.singleTabLock ?? true;
39
+ let promiser = null;
40
+ let currentDbId;
41
+ let releaseTabLock = null;
42
+ const client = {
43
+ async select(sql, params = []) {
44
+ if (!promiser || currentDbId === void 0) throw new DbError("[Web DB] Database not initialized. Call initialize() first.");
45
+ const response = await promiser("exec", {
46
+ dbId: currentDbId,
47
+ sql,
48
+ bind: params,
49
+ resultRows: [],
50
+ rowMode: "object"
51
+ });
52
+ if (response.type === "error") throw new DbExecutionError(sql, params, new Error(response.result.message));
53
+ return response.result.resultRows || [];
54
+ },
55
+ async execute(sql, params = []) {
56
+ if (!promiser || currentDbId === void 0) throw new DbError("[Web DB] Database not initialized. Call initialize() first.");
57
+ const response = await promiser("exec", {
58
+ dbId: currentDbId,
59
+ sql,
60
+ bind: params,
61
+ countChanges: true,
62
+ lastInsertRowId: true
63
+ });
64
+ if (response.type === "error") throw new DbExecutionError(sql, params, new Error(response.result.message));
65
+ return {
66
+ lastInsertId: response.result.lastInsertRowId !== void 0 ? Number(response.result.lastInsertRowId) : void 0,
67
+ rowsAffected: response.result.changeCount ?? 0
68
+ };
69
+ },
70
+ async close() {
71
+ if (promiser && currentDbId !== void 0) try {
72
+ const closeResponse = await promiser("close", { dbId: currentDbId });
73
+ if (closeResponse.type === "error") throw new DbCloseError(new Error(closeResponse.result.message));
74
+ } finally {
75
+ promiser = null;
76
+ currentDbId = void 0;
77
+ releaseTabLock?.();
78
+ releaseTabLock = null;
79
+ }
80
+ }
81
+ };
82
+ return {
83
+ singleConnection: true,
84
+ async initialize(config) {
85
+ if (promiser) return client;
86
+ const isOpfsSupported = typeof navigator !== "undefined" && typeof navigator.storage !== "undefined" && !!navigator.storage.getDirectory;
87
+ const isCrossOriginIsolated = typeof window !== "undefined" && window.crossOriginIsolated;
88
+ let filename = ":memory:";
89
+ if (!isOpfsSupported || !isCrossOriginIsolated) {
90
+ if (!fallbackToMemory) throw new DbInitializationError(/* @__PURE__ */ new Error("[Web DB] OPFS is not available (unsupported or not cross-origin isolated) and fallbackToMemory is false."));
91
+ console.warn("[Web DB] OPFS is not fully supported or cross-origin isolated. Falling back to in-memory mode.");
92
+ } else try {
93
+ const root = await navigator.storage.getDirectory();
94
+ await root.getFileHandle("test_opfs_support", { create: true });
95
+ await root.removeEntry("test_opfs_support").catch(() => {});
96
+ filename = `file:${config.name}.db?vfs=opfs`;
97
+ } catch (opfsError) {
98
+ if (!fallbackToMemory) throw new DbInitializationError(new Error("[Web DB] OPFS initialization failed and fallbackToMemory is false.", { cause: opfsError }));
99
+ console.warn("[Web DB] OPFS initialization failed or file access denied. Falling back to in-memory mode.", opfsError);
100
+ filename = ":memory:";
101
+ }
102
+ if (filename !== ":memory:" && singleTabLock) {
103
+ const release = await tryAcquireTabLock(`cross-sqlite-client:${config.name}`);
104
+ if (!release) throw new DbTabLockError();
105
+ releaseTabLock = release;
106
+ }
107
+ try {
108
+ const readyPromise = sqlite3Worker1Promiser({ onerror: (...args) => console.error("[Web DB] sqlite3Worker1Promiser error:", ...args) });
109
+ let timeoutId;
110
+ const timeoutPromise = new Promise((_, reject) => {
111
+ timeoutId = setTimeout(() => reject(/* @__PURE__ */ new Error(`[Web DB] Timed out after ${timeoutMs}ms waiting for the SQLite worker to become ready (it may have failed to load).`)), timeoutMs);
112
+ });
113
+ try {
114
+ promiser = await Promise.race([readyPromise, timeoutPromise]);
115
+ } finally {
116
+ clearTimeout(timeoutId);
117
+ }
118
+ const openResponse = await promiser("open", { filename });
119
+ if (openResponse.type === "error") throw new DbInitializationError(/* @__PURE__ */ new Error(`Failed to open database: ${openResponse.result.message}`));
120
+ currentDbId = openResponse.dbId;
121
+ if (currentDbId === void 0) throw new DbInitializationError(/* @__PURE__ */ new Error("Database opened successfully but dbId is undefined. This indicates an unexpected library behavior."));
122
+ return client;
123
+ } catch (error) {
124
+ promiser = null;
125
+ currentDbId = void 0;
126
+ releaseTabLock?.();
127
+ releaseTabLock = null;
128
+ throw error instanceof DbInitializationError ? error : new DbInitializationError(error);
129
+ }
130
+ }
131
+ };
132
+ }
133
+ /**
134
+ * 自定义事务型 executor,仅用于单连接适配器(web / memory)。
135
+ *
136
+ * 为什么不能给 Tauri 用:@tauri-apps/plugin-sql 底层是 sqlx::Pool<Sqlite> 连接池,每次
137
+ * execute() 调用独立获取/归还连接,不保证 BEGIN 和 COMMIT 落在同一条物理连接上,事务可能被
138
+ * 悄悄拆散且不报错。createDbClient 会在 adapter.singleConnection !== true 时拒绝非默认
139
+ * executor,但直接调用 runMigrations() 时不会有这层保护,调用方需自行确保只在单连接适配器上使用。
140
+ */
141
+ const runTransactionalMigration = async (db, migration, recordVersion) => {
142
+ await db.execute("BEGIN;");
143
+ try {
144
+ for (const statement of migration.statements) await db.execute(statement);
145
+ await recordVersion();
146
+ await db.execute("COMMIT;");
147
+ } catch (e) {
148
+ await db.execute("ROLLBACK;").catch(() => {});
149
+ throw e;
150
+ }
151
+ };
152
+ const transactionalExecutor = Object.assign(runTransactionalMigration, { requiresSingleConnection: true });
153
+ //#endregion
154
+ export { createWebAdapter, transactionalExecutor, tryAcquireTabLock };
@@ -0,0 +1,50 @@
1
+ import { a as Migration, i as DbClient, n as DbAdapter, o as MigrationExecutor, r as DbAdapterConfig, s as MigrationOptions, t as CreateDbClientOptions } from "../types-XKvAFU82.js";
2
+ //#region src/core/migrate.d.ts
3
+ export declare const defaultExecutor: MigrationExecutor;
4
+ /**
5
+ * 按 version 顺序执行尚未应用的迁移。每条语句独立执行,没有事务包裹;若某条迁移执行到
6
+ * 一半失败,下次启动会从同一个 version 重新开始,重新跑一遍这个 version 里的全部语句。
7
+ *
8
+ * 不能简单地在多次 execute() 调用之间手动包一层 BEGIN/COMMIT/ROLLBACK 来补救:部分适配器
9
+ * (例如 Tauri 端的 @tauri-apps/plugin-sql)底层是连接池,每次 execute() 调用都独立获取/
10
+ * 归还一个物理连接,不保证 BEGIN 和 COMMIT 落在同一条连接上,事务可能被悄悄拆散而不报错。
11
+ * 只有 adapter.singleConnection === true 的适配器才能安全地使用自定义的事务型 executor
12
+ * (见 adapters/web.ts 的 transactionalExecutor,以及 createDbClient 里的运行时检查)。
13
+ *
14
+ * 因此,MIGRATIONS 里的每条语句都必须自身是可安全重试的:
15
+ * - 建表/建索引一律用 CREATE TABLE/INDEX IF NOT EXISTS;
16
+ * - 以后如果要写重命名列、迁移数据这类不可重复执行的语句,先查询当前 schema 状态判断
17
+ * 这一步是否已经做过(例如 `SELECT 1 FROM pragma_table_info('t') WHERE name = '...'`),
18
+ * 已完成就跳过,而不是依赖事务回滚。
19
+ */
20
+ export declare function runMigrations(db: Pick<DbClient, "execute" | "select">, migrations: Migration[], options?: MigrationOptions): Promise<void>;
21
+ //#endregion
22
+ //#region src/core/errors.d.ts
23
+ export declare class DbError extends Error {
24
+ cause?: unknown;
25
+ constructor(message: string, cause?: unknown);
26
+ }
27
+ export declare class DbInitializationError extends DbError {
28
+ constructor(cause?: unknown);
29
+ }
30
+ export declare class DbExecutionError extends DbError {
31
+ sql: string;
32
+ params: unknown[];
33
+ constructor(sql: string, params: unknown[], cause?: unknown);
34
+ }
35
+ export declare class DbCloseError extends DbError {
36
+ constructor(cause?: unknown);
37
+ }
38
+ /**
39
+ * 另一个浏览器标签页/窗口已经持有同一个 OPFS 数据库文件的跨标签页锁。
40
+ * 只有 web 适配器在 singleTabLock 启用时会抛出这个类型;调用方可以据此展示
41
+ * "请关闭其他标签页" 之类的提示,而不是把它当成普通的初始化失败处理。
42
+ */
43
+ export declare class DbTabLockError extends DbError {
44
+ constructor(cause?: unknown);
45
+ }
46
+ //#endregion
47
+ //#region src/core/index.d.ts
48
+ export declare function createDbClient(options: CreateDbClientOptions): Promise<DbClient>;
49
+ //#endregion
50
+ export type { CreateDbClientOptions, DbAdapter, DbAdapterConfig, DbClient, Migration, MigrationExecutor, MigrationOptions };
@@ -0,0 +1,47 @@
1
+ import { a as DbTabLockError, i as DbInitializationError, n as DbError, r as DbExecutionError, t as DbCloseError } from "../errors-D9KnLHTp.js";
2
+ //#region src/core/migrate.ts
3
+ const defaultExecutor = async (db, migration, recordVersion) => {
4
+ for (const statement of migration.statements) await db.execute(statement);
5
+ await recordVersion();
6
+ };
7
+ /**
8
+ * 按 version 顺序执行尚未应用的迁移。每条语句独立执行,没有事务包裹;若某条迁移执行到
9
+ * 一半失败,下次启动会从同一个 version 重新开始,重新跑一遍这个 version 里的全部语句。
10
+ *
11
+ * 不能简单地在多次 execute() 调用之间手动包一层 BEGIN/COMMIT/ROLLBACK 来补救:部分适配器
12
+ * (例如 Tauri 端的 @tauri-apps/plugin-sql)底层是连接池,每次 execute() 调用都独立获取/
13
+ * 归还一个物理连接,不保证 BEGIN 和 COMMIT 落在同一条连接上,事务可能被悄悄拆散而不报错。
14
+ * 只有 adapter.singleConnection === true 的适配器才能安全地使用自定义的事务型 executor
15
+ * (见 adapters/web.ts 的 transactionalExecutor,以及 createDbClient 里的运行时检查)。
16
+ *
17
+ * 因此,MIGRATIONS 里的每条语句都必须自身是可安全重试的:
18
+ * - 建表/建索引一律用 CREATE TABLE/INDEX IF NOT EXISTS;
19
+ * - 以后如果要写重命名列、迁移数据这类不可重复执行的语句,先查询当前 schema 状态判断
20
+ * 这一步是否已经做过(例如 `SELECT 1 FROM pragma_table_info('t') WHERE name = '...'`),
21
+ * 已完成就跳过,而不是依赖事务回滚。
22
+ */
23
+ async function runMigrations(db, migrations, options) {
24
+ const tableName = options?.tableName ?? "schema_version";
25
+ const executor = options?.executor ?? defaultExecutor;
26
+ for (const migration of migrations) if (migration.version <= 0) throw new Error(`Migration version must be >= 1, got ${migration.version}.`);
27
+ await db.execute(`CREATE TABLE IF NOT EXISTS ${tableName} (
28
+ version INTEGER PRIMARY KEY,
29
+ applied_at INTEGER NOT NULL DEFAULT (cast(strftime('%s', 'subsec') * 1000 as int))
30
+ );`);
31
+ const currentVersion = (await db.select(`SELECT MAX(version) as version FROM ${tableName};`))[0]?.version ?? 0;
32
+ const pending = migrations.filter((migration) => migration.version > currentVersion).sort((a, b) => a.version - b.version);
33
+ for (const migration of pending) await executor(db, migration, async () => {
34
+ await db.execute(`INSERT INTO ${tableName} (version) VALUES (?);`, [migration.version]);
35
+ });
36
+ }
37
+ //#endregion
38
+ //#region src/core/index.ts
39
+ async function createDbClient(options) {
40
+ const { adapter } = options;
41
+ if ((options.migrationOptions?.executor)?.requiresSingleConnection && !adapter.singleConnection) throw new Error("This MigrationExecutor requires a single-connection adapter (adapter.singleConnection !== true); a custom executor that manually manages BEGIN/COMMIT is not safe here. Use the default executor or an adapter with singleConnection: true.");
42
+ const client = await adapter.initialize({ name: options.name });
43
+ if (options.migrations?.length) await runMigrations(client, options.migrations, options.migrationOptions);
44
+ return client;
45
+ }
46
+ //#endregion
47
+ export { DbCloseError, DbError, DbExecutionError, DbInitializationError, DbTabLockError, createDbClient, defaultExecutor, runMigrations };
@@ -0,0 +1,44 @@
1
+ //#region src/core/errors.ts
2
+ var DbError = class extends Error {
3
+ cause;
4
+ constructor(message, cause) {
5
+ super(message);
6
+ this.cause = cause;
7
+ this.name = "DbError";
8
+ }
9
+ };
10
+ var DbInitializationError = class extends DbError {
11
+ constructor(cause) {
12
+ super("Failed to initialize database", cause);
13
+ this.name = "DbInitializationError";
14
+ }
15
+ };
16
+ var DbExecutionError = class extends DbError {
17
+ sql;
18
+ params;
19
+ constructor(sql, params, cause) {
20
+ super(`SQL execution failed: ${sql}`, cause);
21
+ this.sql = sql;
22
+ this.params = params;
23
+ this.name = "DbExecutionError";
24
+ }
25
+ };
26
+ var DbCloseError = class extends DbError {
27
+ constructor(cause) {
28
+ super("Failed to close database", cause);
29
+ this.name = "DbCloseError";
30
+ }
31
+ };
32
+ /**
33
+ * 另一个浏览器标签页/窗口已经持有同一个 OPFS 数据库文件的跨标签页锁。
34
+ * 只有 web 适配器在 singleTabLock 启用时会抛出这个类型;调用方可以据此展示
35
+ * "请关闭其他标签页" 之类的提示,而不是把它当成普通的初始化失败处理。
36
+ */
37
+ var DbTabLockError = class extends DbError {
38
+ constructor(cause) {
39
+ super("Another browser tab/window already has this database open", cause);
40
+ this.name = "DbTabLockError";
41
+ }
42
+ };
43
+ //#endregion
44
+ export { DbTabLockError as a, DbInitializationError as i, DbError as n, DbExecutionError as r, DbCloseError as t };
@@ -0,0 +1,34 @@
1
+ import { i as DbClient } from "../types-XKvAFU82.js";
2
+ import React from "react";
3
+ //#region src/react/DatabaseProvider.d.ts
4
+ interface DatabaseContextType {
5
+ dbClient: DbClient | null;
6
+ isDbReady: boolean;
7
+ isLoading: boolean;
8
+ dbError: Error | null;
9
+ }
10
+ interface DatabaseProviderProps {
11
+ /**
12
+ * 由调用方负责创建(通常是 createDbClient(...) 的返回值)。DatabaseProvider 不再自己
13
+ * 决定用哪个 adapter/迁移哪些 migrations——那是应用层的职责,见 README 里的 appDb 示例。
14
+ *
15
+ * 没有 retry:这个 Promise 只会 settle 一次,重试的语义交给调用方——想重试就在自己的状态
16
+ * 里创建一个新的 client Promise,再把新引用传给这个 prop,effect 依赖 [client] 会自动
17
+ * 重新走一遍下面的初始化逻辑。
18
+ *
19
+ * DatabaseProvider 不拥有 client 的生命周期,卸载时也不会调用 client.close():谁创建
20
+ * 的 client 谁负责关闭。如果 close() 交给这里,而调用方(比如应用级单例的
21
+ * getAppDbClient())又缓存/复用同一个 client Promise,Provider 一旦被卸载再重新挂载
22
+ * (条件渲染、测试环境、会重新 mount 的路由/key 边界……),第二次挂载就会解析到同一个
23
+ * 已经被 close 过的 client——isDbReady 变成 true 但连接其实是死的。需要跟随组件生命周期
24
+ * 关闭连接的调用方,应该在自己创建 client 的地方管理 close(),而不是依赖这里。
25
+ */
26
+ client: DbClient | Promise<DbClient>;
27
+ children: React.ReactNode;
28
+ }
29
+ export declare const DatabaseProvider: React.FC<DatabaseProviderProps>;
30
+ //#endregion
31
+ //#region src/react/useDatabase.d.ts
32
+ export declare const useDatabase: () => DatabaseContextType;
33
+ //#endregion
34
+ export type { DatabaseContextType, DatabaseProviderProps };
@@ -0,0 +1,47 @@
1
+ import { createContext, useContext, useEffect, useState } from "react";
2
+ import { jsx } from "react/jsx-runtime";
3
+ //#region src/react/DatabaseProvider.tsx
4
+ const DatabaseContext = createContext(void 0);
5
+ const DatabaseProvider = ({ client, children }) => {
6
+ const [dbClient, setDbClient] = useState(null);
7
+ const [isDbReady, setIsDbReady] = useState(false);
8
+ const [isLoading, setIsLoading] = useState(true);
9
+ const [dbError, setDbError] = useState(null);
10
+ useEffect(() => {
11
+ let cancelled = false;
12
+ setIsLoading(true);
13
+ setDbError(null);
14
+ Promise.resolve(client).then((resolvedClient) => {
15
+ if (cancelled) return;
16
+ setDbClient(resolvedClient);
17
+ setIsDbReady(true);
18
+ }).catch((error) => {
19
+ if (cancelled) return;
20
+ setDbError(error instanceof Error ? error : new Error(String(error)));
21
+ }).finally(() => {
22
+ if (!cancelled) setIsLoading(false);
23
+ });
24
+ return () => {
25
+ cancelled = true;
26
+ };
27
+ }, [client]);
28
+ const value = {
29
+ dbClient,
30
+ isDbReady,
31
+ isLoading,
32
+ dbError
33
+ };
34
+ return /* @__PURE__ */ jsx(DatabaseContext.Provider, {
35
+ value,
36
+ children
37
+ });
38
+ };
39
+ //#endregion
40
+ //#region src/react/useDatabase.ts
41
+ const useDatabase = () => {
42
+ const context = useContext(DatabaseContext);
43
+ if (context === void 0) throw new Error("useDatabase must be used within a DatabaseProvider");
44
+ return context;
45
+ };
46
+ //#endregion
47
+ export { DatabaseProvider, useDatabase };
@@ -0,0 +1,45 @@
1
+ //#region src/core/types.d.ts
2
+ interface DbClient {
3
+ select<T>(sql: string, params?: unknown[]): Promise<T[]>;
4
+ execute(sql: string, params?: unknown[]): Promise<{
5
+ lastInsertId?: number;
6
+ rowsAffected?: number;
7
+ }>;
8
+ close(): Promise<void>;
9
+ }
10
+ interface DbAdapterConfig {
11
+ /** 数据库文件名/标识,如 "my-app"(不含扩展名) */
12
+ name: string;
13
+ }
14
+ interface DbAdapter {
15
+ initialize(config: DbAdapterConfig): Promise<DbClient>;
16
+ /**
17
+ * 该适配器的每次 execute()/select() 调用是否保证落在同一条物理连接上。
18
+ * web/memory 适配器是单一持久连接,为 true;Tauri 适配器底层是
19
+ * sqlx::Pool<Sqlite> 连接池,不同调用可能拿到不同物理连接,为 false。
20
+ * 决定了自定义 MigrationExecutor 里手写的 BEGIN/COMMIT 是否安全。
21
+ */
22
+ singleConnection: boolean;
23
+ }
24
+ interface Migration {
25
+ version: number;
26
+ statements: string[];
27
+ }
28
+ type MigrationExecutor = (db: Pick<DbClient, "execute" | "select">, migration: Migration,
29
+ /** 记录 schema_version 的回调;executor 决定何时调用(比如放进自己的事务里) */
30
+ recordVersion: () => Promise<void>) => Promise<void>;
31
+ interface MigrationOptions {
32
+ /** schema 版本表名,默认 "schema_version" */
33
+ tableName?: string;
34
+ /** 自定义事务包裹策略;仅能用于 adapter.singleConnection === true 的适配器 */
35
+ executor?: MigrationExecutor;
36
+ }
37
+ interface CreateDbClientOptions {
38
+ name: string;
39
+ /** 必须显式传入实例,库不提供 'auto'/'web'/'tauri' 字符串快捷方式 */
40
+ adapter: DbAdapter;
41
+ migrations?: Migration[];
42
+ migrationOptions?: MigrationOptions;
43
+ }
44
+ //#endregion
45
+ export { Migration as a, DbClient as i, DbAdapter as n, MigrationExecutor as o, DbAdapterConfig as r, MigrationOptions as s, CreateDbClientOptions as t };
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "cross-sqlite-client",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Cross-platform (Web + Tauri) SQLite client with a shared migration framework and React bindings.",
6
+ "license": "MIT",
7
+ "author": "ueaner",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ueaner/cross-sqlite-client.git"
11
+ },
12
+ "homepage": "https://github.com/ueaner/cross-sqlite-client#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/ueaner/cross-sqlite-client/issues"
15
+ },
16
+ "keywords": [
17
+ "sqlite",
18
+ "tauri",
19
+ "sqlite-wasm",
20
+ "opfs",
21
+ "migration",
22
+ "react"
23
+ ],
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/core/index.d.ts",
27
+ "import": "./dist/core/index.js"
28
+ },
29
+ "./adapters/web": {
30
+ "types": "./dist/adapters/web.d.ts",
31
+ "import": "./dist/adapters/web.js"
32
+ },
33
+ "./adapters/tauri": {
34
+ "types": "./dist/adapters/tauri.d.ts",
35
+ "import": "./dist/adapters/tauri.js"
36
+ },
37
+ "./adapters/memory": {
38
+ "types": "./dist/adapters/memory.d.ts",
39
+ "import": "./dist/adapters/memory.js"
40
+ },
41
+ "./react": {
42
+ "types": "./dist/react/index.d.ts",
43
+ "import": "./dist/react/index.js"
44
+ }
45
+ },
46
+ "files": [
47
+ "dist"
48
+ ],
49
+ "scripts": {
50
+ "build": "tsdown",
51
+ "dev": "tsdown --watch",
52
+ "test": "vitest run",
53
+ "lint": "oxlint src test"
54
+ },
55
+ "peerDependencies": {
56
+ "react": ">=18.0.0"
57
+ },
58
+ "peerDependenciesMeta": {
59
+ "react": {
60
+ "optional": true
61
+ }
62
+ },
63
+ "optionalDependencies": {
64
+ "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
65
+ "@tauri-apps/plugin-sql": "^2.4.1"
66
+ },
67
+ "devDependencies": {
68
+ "@types/react": "^19.2.18",
69
+ "react": "^19.2.8",
70
+ "tsdown": "^0.23.0",
71
+ "typescript": "^7.0.2",
72
+ "vitest": "^5.0.0"
73
+ }
74
+ }