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 +21 -0
- package/README.md +344 -0
- package/README.zh-CN.md +246 -0
- package/dist/adapters/memory.d.ts +12 -0
- package/dist/adapters/memory.js +65 -0
- package/dist/adapters/tauri.d.ts +11 -0
- package/dist/adapters/tauri.js +58 -0
- package/dist/adapters/web.d.ts +43 -0
- package/dist/adapters/web.js +154 -0
- package/dist/core/index.d.ts +50 -0
- package/dist/core/index.js +47 -0
- package/dist/errors-D9KnLHTp.js +44 -0
- package/dist/react/index.d.ts +34 -0
- package/dist/react/index.js +47 -0
- package/dist/types-XKvAFU82.d.ts +45 -0
- package/package.json +74 -0
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)).
|
package/README.zh-CN.md
ADDED
|
@@ -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
|
+
}
|