cross-sqlite-client 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -90,7 +90,7 @@ function TodoList() {
90
90
  if (dbError) return <ErrorMessage error={dbError} />;
91
91
  if (!isDbReady || !dbClient) return null;
92
92
 
93
- // dbClient.select<T>(sql, params?) / dbClient.execute(sql, params?) / dbClient.close()
93
+ // dbClient.select<T>(sql, params?) / dbClient.execute(sql, params?) / dbClient.executeBatch(statements) / dbClient.close()
94
94
  // ...
95
95
  }
96
96
  ```
@@ -101,11 +101,12 @@ function TodoList() {
101
101
 
102
102
  | Export | What it is |
103
103
  |---|---|
104
- | `createDbClient(options)` | Resolves the adapter, initializes it, runs pending migrations, returns `Promise<DbClient>`. |
104
+ | `createDbClient(options)` | Resolves the adapter, initializes it, applies `pragmas`, runs pending migrations, returns `Promise<DbClient>`. If PRAGMA/migration setup fails, the already-opened connection is closed before the error propagates. |
105
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. |
106
+ | `defaultExecutor` | The migration executor used when `migrationOptions.executor` isn't set — runs all of a migration's statements via `executeBatch()`, with no transaction. |
107
+ | `DbClient` (type) | `{ select<T>(sql, params?), execute(sql, params?), executeBatch(statements), close() }` — see below. |
108
108
  | `DbAdapter` / `DbAdapterConfig` (types) | The interface each `createXAdapter()` factory returns / the `{ name }` config passed to `initialize()`. |
109
+ | `BatchStatement` / `Logger` (types) | `string \| { sql, params? }` for `executeBatch()` / the diagnostics channel (`{ warn, error }`, defaults to `console`). |
109
110
  | `Migration` / `MigrationExecutor` / `MigrationOptions` (types) | See [Writing migrations](#writing-migrations). |
110
111
  | `DbError` and subclasses | See [Errors](#errors). |
111
112
 
@@ -119,10 +120,41 @@ import { createDbClient, runMigrations, defaultExecutor } from "cross-sqlite-cli
119
120
  interface DbClient {
120
121
  select<T>(sql: string, params?: unknown[]): Promise<T[]>;
121
122
  execute(sql: string, params?: unknown[]): Promise<{ lastInsertId?: number; rowsAffected?: number }>;
123
+ executeBatch(statements: BatchStatement[]): Promise<void>;
122
124
  close(): Promise<void>;
123
125
  }
124
126
  ```
125
127
 
128
+ `executeBatch()` runs a batch of statements in order with **no cross-statement
129
+ transaction guarantee** — when every statement is parameterless, the web and
130
+ memory adapters join them into a single SQL string and send it to the
131
+ underlying engine in one call (the web adapter saves one Worker round-trip per
132
+ statement); if *any* statement carries bound params, the whole batch falls
133
+ back to running one by one. The library deliberately does not
134
+ offer a business-facing transaction API: pooled-connection adapters (Tauri)
135
+ can't guarantee `BEGIN`/`COMMIT` land on the same physical connection, so
136
+ business multi-statement writes should be idempotent instead.
137
+
138
+ `createDbClient()` also accepts two optional fields:
139
+
140
+ ```ts
141
+ await createDbClient({
142
+ name: "my-app",
143
+ adapter,
144
+ migrations: APP_MIGRATIONS,
145
+ // Applied right after initialize, before migrations. Keys must be identifiers;
146
+ // string values must be enum-like tokens (WAL, NORMAL, ...); booleans become 0/1;
147
+ // numbers are inlined as-is.
148
+ // Note: on pooled-connection adapters (Tauri), connection-level pragmas such as
149
+ // foreign_keys/busy_timeout only apply to one pooled connection and silently won't
150
+ // hold for later queries (createDbClient logs a warning); database-level pragmas
151
+ // like journal_mode/user_version work everywhere.
152
+ pragmas: { foreign_keys: true, journal_mode: "WAL" },
153
+ // Diagnostics channel for library warnings/errors (default: console).
154
+ logger: myLogger, // { warn(message, ...args), error(message, ...args) }
155
+ });
156
+ ```
157
+
126
158
  ### Adapters
127
159
 
128
160
  Each `createXAdapter()` call returns a fresh, independent `DbAdapter` instance
@@ -145,8 +177,9 @@ for why that matters.
145
177
  | Option | Default | Meaning |
146
178
  |---|---|---|
147
179
  | `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). |
180
+ | `fallbackToMemory` | `true` | Whether to silently use `:memory:` when OPFS isn't available — including when the OPFS probe passes but opening the file fails — instead of throwing. See [COOP/COEP](#coopcoep-required-for-opfs-persistence). |
149
181
  | `singleTabLock` | `true` | Whether to coordinate access to the same OPFS file across browser tabs. See [Multi-tab coordination](#multi-tab-coordination). |
182
+ | `logger` | `console` | Where diagnostics go (OPFS-fallback warnings, worker errors). Pass your own `{ warn, error }` to route them into your logging/telemetry. |
150
183
 
151
184
  **`createTauriAdapter()`** and **`createMemoryAdapter()`** take no options.
152
185
  `createMemoryAdapter()` is meant for tests — see [Testing](#testing).
@@ -162,7 +195,9 @@ Two things worth knowing about `DatabaseProvider`:
162
195
 
163
196
  - **No `retry`.** The `client` prop only ever settles once — retrying means
164
197
  passing it a *new* promise, which re-triggers initialization because the
165
- prop reference changed:
198
+ prop reference changed. The moment the prop changes, the context drops back
199
+ to fully-not-ready (`dbClient: null`, `isDbReady: false`) until the new
200
+ promise resolves — consumers never see the old connection during the window:
166
201
 
167
202
  ```tsx
168
203
  function App() {
@@ -195,10 +230,12 @@ interface Migration {
195
230
  }
196
231
  ```
197
232
 
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`.
233
+ - **Versions must be unique positive integers.** With no migrations applied
234
+ yet, `runMigrations` treats the current version as `0`; a migration with
235
+ `version: 0` would never satisfy `version > currentVersion` and would be
236
+ silently skipped forever. `runMigrations` throws immediately if any version
237
+ is not a positive integer, or if two migrations share a version. (They don't
238
+ have to start at 1 or be consecutive — gaps are fine.)
202
239
  - **Every statement must be safe to re-run.** There is no cross-platform
203
240
  transaction guarantee (see below), so if a migration fails partway, the next
204
241
  startup re-runs the *entire* version from scratch. Stick to
@@ -206,11 +243,33 @@ interface Migration {
206
243
  non-idempotent change (e.g. renaming a column), check the current schema
207
244
  state first — e.g. `SELECT 1 FROM pragma_table_info('t') WHERE name = '...'`
208
245
  — and skip the step if it's already done, rather than relying on rollback.
246
+ - **Failures surface as `DbMigrationError`.** The runner wraps whatever the
247
+ executor threw into a `DbMigrationError` carrying the failing `.version`.
248
+ `createDbClient()` additionally closes the already-opened connection before
249
+ re-throwing, so a failed startup leaks no worker, OPFS file handle, or tab
250
+ lock — retrying (e.g. with a fresh client promise) starts clean.
251
+ - **Late migrations below the current version are not silently dropped.** If
252
+ the database already applied `[1, 2, 5]` and a new app build ships the
253
+ missing `3`/`4`, those versions are below `MAX(version)` and would normally
254
+ be skipped forever; the runner logs a `logger.warn` naming them instead.
255
+ They are *not* auto-applied — out-of-order application can break schema
256
+ evolution assumptions — so apply such hotfixes deliberately.
257
+
258
+ `runMigrations(db, migrations, options?)` (and `createDbClient`'s
259
+ `migrationOptions`) accepts:
260
+
261
+ ```ts
262
+ interface MigrationOptions {
263
+ tableName?: string; // version table name, default "schema_version"; identifiers only
264
+ executor?: MigrationExecutor; // see the next section
265
+ logger?: Logger; // overrides createDbClient's logger for migration diagnostics
266
+ }
267
+ ```
209
268
 
210
269
  ## Custom migration executors and `singleConnection`
211
270
 
212
- The default executor (`defaultExecutor`) runs each migration statement
213
- independently, with no transaction. `adapters/web` also exports a
271
+ The default executor (`defaultExecutor`) runs each migration's statements via
272
+ `executeBatch()`, with no transaction. `adapters/web` also exports a
214
273
  `transactionalExecutor` that wraps a migration in `BEGIN`/`COMMIT`/`ROLLBACK` —
215
274
  but that's only safe on an adapter whose `singleConnection` is `true` (a real,
216
275
  single persistent connection). `@tauri-apps/plugin-sql`'s backend is a
@@ -281,6 +340,10 @@ it to show something like "this app is already open in another tab." Pass
281
340
  retry/`SQLITE_BUSY` behavior. This only ever applies to the OPFS-backed path —
282
341
  `:memory:` is private per tab, so there's nothing to coordinate there.
283
342
 
343
+ The lock primitive itself, `tryAcquireTabLock(lockName)`, is also exported
344
+ from `cross-sqlite-client/adapters/web` for apps that need the same
345
+ best-effort cross-tab coordination for something other than the database.
346
+
284
347
  ## Errors
285
348
 
286
349
  Every adapter throws one of these (all extend `DbError extends Error`, and all
@@ -290,7 +353,8 @@ accept an optional `cause`):
290
353
  |---|---|
291
354
  | `DbError` | Generic/usage errors, e.g. calling `select()`/`execute()` before `initialize()` resolves, or after `close()`. |
292
355
  | `DbInitializationError` | `adapter.initialize()` failed (worker/OPFS/Tauri-load failure, etc.). |
293
- | `DbExecutionError` (has `.sql` and `.params`) | A `select()`/`execute()` call failed. |
356
+ | `DbExecutionError` (has `.sql` and `.params`) | A `select()`/`execute()`/`executeBatch()` call failed. |
357
+ | `DbMigrationError` (has `.version`) | A migration failed; wraps the underlying error as `cause`. Thrown by `runMigrations`/`createDbClient`. |
294
358
  | `DbCloseError` | `client.close()` failed. |
295
359
  | `DbTabLockError` | (Web adapter only, `singleTabLock: true`) Another tab already holds the database. |
296
360
 
@@ -326,6 +390,10 @@ await runMigrations(client, APP_MIGRATIONS);
326
390
  Each `createMemoryAdapter()` call is a fresh, independent instance, so
327
391
  separate tests (or parallel tests in the same process) don't share state.
328
392
 
393
+ The library's own suite (`pnpm test`) covers the migration runner, the client
394
+ lifecycle, and the React bindings; CI (`.github/workflows/ci.yml`) runs lint,
395
+ typecheck, tests, build, and `publint` on Node 20/22.
396
+
329
397
  ## Known limitations
330
398
 
331
399
  - **`lastInsertId` precision.** `DbClient.execute()`'s `lastInsertId` is typed
@@ -342,3 +410,47 @@ separate tests (or parallel tests in the same process) don't share state.
342
410
  that could serve as a middle tier, but it wasn't wired in as of this
343
411
  writing — pursue it if you need persistence in deployments that can't
344
412
  achieve cross-origin isolation (see [COOP/COEP](#coopcoep-required-for-opfs-persistence)).
413
+
414
+ ## Runtime environment notes
415
+
416
+ Deployment/runtime behaviors that are deliberate tradeoffs rather than bugs:
417
+
418
+ - **bfcache-frozen tabs hold the database lock.** `singleTabLock` uses the Web
419
+ Locks API; a tab frozen by the browser's back/forward cache (rather than
420
+ closed) keeps holding its lock, so other tabs keep getting `DbTabLockError`
421
+ until the frozen tab is discarded. Locks of genuinely closed or crashed tabs
422
+ are released by the browser automatically.
423
+ - **No Web Locks API → no cross-tab coordination.** On old browsers or
424
+ insecure (non-HTTPS) contexts, `singleTabLock` silently degrades to no
425
+ coordination: multiple tabs can open the same OPFS file at once, and
426
+ contention then surfaces later as catchable "database is locked"
427
+ (`SQLITE_BUSY`) errors from sqlite-wasm itself.
428
+ - **The in-memory fallback loses data on reload.** When OPFS is unavailable
429
+ and `fallbackToMemory` is on, everything works but nothing persists. The
430
+ adapter emits a `logger.warn` when this happens — pass your own `logger` if
431
+ the app needs to detect it and warn the user.
432
+
433
+ ## Versioning
434
+
435
+ This package is pre-1.0: **minor releases may contain breaking changes**
436
+ (0.2.0 added a required `executeBatch` to `DbClient`, which breaks custom
437
+ adapter/client implementations at compile time). Pin exact versions, and read
438
+ the [CHANGELOG](CHANGELOG.md) when upgrading.
439
+
440
+ ## Contributing
441
+
442
+ PRs that change shippable code must include a changeset (`pnpm changeset`) —
443
+ CI enforces this via `changeset status`. Docs-only or chore PRs can opt out
444
+ with `pnpm changeset --empty`.
445
+
446
+ ## Releasing
447
+
448
+ Releases are published by CI from tags, never from a local machine:
449
+
450
+ 1. `pnpm changeset version` — bumps `package.json` and updates `CHANGELOG.md`
451
+ from pending changesets; commit the result.
452
+ 2. Tag that commit `vX.Y.Z` (matching the new version) and push the tag.
453
+ 3. `.github/workflows/release.yml` does a clean checkout of the tag, re-runs
454
+ the full verification chain (build, typecheck, lint, test, publint), and
455
+ publishes with `--provenance`. Requires an `NPM_TOKEN` repository secret
456
+ with publish rights on the package.
package/README.zh-CN.md CHANGED
@@ -74,7 +74,7 @@ function TodoList() {
74
74
  if (dbError) return <ErrorMessage error={dbError} />;
75
75
  if (!isDbReady || !dbClient) return null;
76
76
 
77
- // dbClient.select<T>(sql, params?) / dbClient.execute(sql, params?) / dbClient.close()
77
+ // dbClient.select<T>(sql, params?) / dbClient.execute(sql, params?) / dbClient.executeBatch(statements) / dbClient.close()
78
78
  // ...
79
79
  }
80
80
  ```
@@ -85,11 +85,12 @@ function TodoList() {
85
85
 
86
86
  | 导出 | 说明 |
87
87
  |---|---|
88
- | `createDbClient(options)` | 初始化适配器、执行待应用迁移,返回 `Promise<DbClient>`。 |
88
+ | `createDbClient(options)` | 初始化适配器、应用 `pragmas`、执行待应用迁移,返回 `Promise<DbClient>`。若 PRAGMA/迁移阶段失败,会先关闭已打开的连接再向上抛错。 |
89
89
  | `runMigrations(db, migrations, options?)` | `createDbClient` 内部使用的迁移运行器;不经过 `createDbClient` 时可直接调用。 |
90
- | `defaultExecutor` | 未设置 `migrationOptions.executor` 时使用的默认迁移执行器:逐条执行语句,无事务包裹。 |
91
- | `DbClient`(类型) | `{ select<T>(sql, params?), execute(sql, params?), close() }` —— 详见下文。 |
90
+ | `defaultExecutor` | 未设置 `migrationOptions.executor` 时使用的默认迁移执行器:通过 `executeBatch()` 执行一个迁移的全部语句,无事务包裹。 |
91
+ | `DbClient`(类型) | `{ select<T>(sql, params?), execute(sql, params?), executeBatch(statements), close() }` —— 详见下文。 |
92
92
  | `DbAdapter` / `DbAdapterConfig`(类型) | 各 `createXAdapter()` 工厂返回的接口 / 传给 `initialize()` 的 `{ name }` 配置。 |
93
+ | `BatchStatement` / `Logger`(类型) | `executeBatch()` 的语句类型 `string \| { sql, params? }` / 诊断输出通道(`{ warn, error }`,默认 `console`)。 |
93
94
  | `Migration` / `MigrationExecutor` / `MigrationOptions`(类型) | 见[编写迁移](#编写迁移)。 |
94
95
  | `DbError` 及子类 | 见[错误](#错误)。 |
95
96
 
@@ -103,10 +104,31 @@ import { createDbClient, runMigrations, defaultExecutor } from "cross-sqlite-cli
103
104
  interface DbClient {
104
105
  select<T>(sql: string, params?: unknown[]): Promise<T[]>;
105
106
  execute(sql: string, params?: unknown[]): Promise<{ lastInsertId?: number; rowsAffected?: number }>;
107
+ executeBatch(statements: BatchStatement[]): Promise<void>;
106
108
  close(): Promise<void>;
107
109
  }
108
110
  ```
109
111
 
112
+ `executeBatch()` 按顺序执行一批语句,**无跨语句事务保证**——全部语句不带绑定参数时,web 和 memory 适配器会把它们拼成一条 SQL 一次性发给底层引擎(web 适配器每条语句省一次 Worker 往返);只要批内有**任何**一条带参语句,整批退化为逐条 `execute()`。库刻意不提供业务侧事务 API:连接池型适配器(Tauri)无法保证 `BEGIN`/`COMMIT` 落在同一条物理连接上,因此业务多语句写入应自行保证幂等。
113
+
114
+ `createDbClient()` 还接受两个可选字段:
115
+
116
+ ```ts
117
+ await createDbClient({
118
+ name: "my-app",
119
+ adapter,
120
+ migrations: APP_MIGRATIONS,
121
+ // initialize 之后、迁移之前应用。key 必须是合法标识符;
122
+ // 字符串值必须是枚举式 token(WAL、NORMAL……);boolean 会转成 0/1;number 原样拼入。
123
+ // 注意:在连接池型适配器(Tauri)上,foreign_keys/busy_timeout 这类连接级 PRAGMA 只对
124
+ // 池中一条连接生效,后续查询会静默失效(createDbClient 会打告警日志);journal_mode/
125
+ // user_version 这类库级 PRAGMA 不受影响。
126
+ pragmas: { foreign_keys: true, journal_mode: "WAL" },
127
+ // 库告警/错误的诊断输出通道(默认 console)。
128
+ logger: myLogger, // { warn(message, ...args), error(message, ...args) }
129
+ });
130
+ ```
131
+
110
132
  ### 适配器
111
133
 
112
134
  每次调用 `createXAdapter()` 都会返回一个**全新的、相互独立的** `DbAdapter` 实例(没有模块级单例状态),因此同一进程里可以放心创建多个——例如测试里。
@@ -124,8 +146,9 @@ interface DbClient {
124
146
  | 选项 | 默认值 | 说明 |
125
147
  |---|---|---|
126
148
  | `timeoutMs` | `15000` | 等待 SQLite Worker 就绪的超时时间;若 Worker 脚本加载失败,不设超时会让 `initialize()` 永远 pending。 |
127
- | `fallbackToMemory` | `true` | OPFS 不可用时是否静默回退到 `:memory:`,而不是抛错。详见 [COOP/COEP](#opfs-持久化需要-coopcoep)。 |
149
+ | `fallbackToMemory` | `true` | OPFS 不可用时是否静默回退到 `:memory:`(包括探测通过但打开 OPFS 文件失败的情况),而不是抛错。详见 [COOP/COEP](#opfs-持久化需要-coopcoep)。 |
128
150
  | `singleTabLock` | `true` | 是否跨浏览器标签页协调对同一 OPFS 文件的访问。详见[多标签页协调](#多标签页协调)。 |
151
+ | `logger` | `console` | 诊断信息(OPFS 降级告警、Worker 错误)的输出位置。传入自己的 `{ warn, error }` 可接入应用的日志/监控。 |
129
152
 
130
153
  **`createTauriAdapter()`** 和 **`createMemoryAdapter()`** 不接受选项。`createMemoryAdapter()` 用于测试——见[测试](#测试)。
131
154
 
@@ -138,7 +161,7 @@ interface DbClient {
138
161
 
139
162
  关于 `DatabaseProvider` 有两点值得注意:
140
163
 
141
- - **没有内置 `retry`。** `client` prop 只会进入最终状态一次——重试意味着传给它一个**新的 Promise**,prop 引用变化会重新触发初始化:
164
+ - **没有内置 `retry`。** `client` prop 只会进入最终状态一次——重试意味着传给它一个**新的 Promise**,prop 引用变化会重新触发初始化。prop 变化的瞬间,context 会先回到完全未就绪状态(`dbClient: null`、`isDbReady: false`),直到新 Promise resolve——窗口期内消费方不会看到旧连接:
142
165
 
143
166
  ```tsx
144
167
  function App() {
@@ -164,12 +187,24 @@ interface Migration {
164
187
  }
165
188
  ```
166
189
 
167
- - **版本号必须从 1 开始。** 没有应用任何迁移时,`runMigrations` 把当前版本视为 `0`;一个 `version: 0` 的迁移永远满足不了 `version > currentVersion`,会被永久静默跳过。`runMigrations` 会在传入 `version <= 0` 时立即抛错。
190
+ - **版本号必须是唯一的正整数。** 没有应用任何迁移时,`runMigrations` 把当前版本视为 `0`;一个 `version: 0` 的迁移永远满足不了 `version > currentVersion`,会被永久静默跳过。传入不是正整数的版本号(包括 `1.5` 这类)、或两个迁移共用同一个版本号时,`runMigrations` 会立即抛错。(不要求从 1 开始、也不要求连续——跳号是可以的。)
168
191
  - **每条语句都必须可安全重跑。** 跨平台事务没有统一保证(原因见下文),因此如果某次迁移执行到一半失败,下次启动会**从头重跑整个 version**。新 schema 请使用 `CREATE TABLE/INDEX IF NOT EXISTS`。未来若要写不可重跑的操作(例如重命名列),先查询当前 schema 状态——例如 `SELECT 1 FROM pragma_table_info('t') WHERE name = '...'`——已完成就跳过,而不是依赖回滚。
192
+ - **失败以 `DbMigrationError` 暴露。** 运行器会把执行器抛出的错误包装成携带失败 `.version` 的 `DbMigrationError`。`createDbClient()` 在向上抛错前还会先关闭已打开的连接,因此一次失败的启动不会泄漏 Worker、OPFS 文件句柄或标签页锁——重试(比如换一个新的 client Promise)可以从干净状态开始。
193
+ - **低于当前版本的"迟到迁移"不会被静默丢弃。** 如果数据库已应用 `[1, 2, 5]` 而新构建补发了缺失的 `3`/`4`,这些版本低于 `MAX(version)`,默认情况下会被永远跳过;运行器会用 `logger.warn` 点名它们。它们**不会**被自动补跑——乱序应用可能破坏 schema 演进假设——这类 hotfix 请显式处理。
194
+
195
+ `runMigrations(db, migrations, options?)`(以及 `createDbClient` 的 `migrationOptions`)接受:
196
+
197
+ ```ts
198
+ interface MigrationOptions {
199
+ tableName?: string; // 版本表名,默认 "schema_version";仅限合法标识符
200
+ executor?: MigrationExecutor; // 见下一节
201
+ logger?: Logger; // 覆盖 createDbClient 的 logger,仅作用于迁移阶段的诊断输出
202
+ }
203
+ ```
169
204
 
170
205
  ## 自定义迁移执行器与 `singleConnection`
171
206
 
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` 的适配器时,提前抛错。
207
+ 默认执行器(`defaultExecutor`)通过 `executeBatch()` 执行每个迁移的全部语句,无事务包裹。`adapters/web` 还导出了 `transactionalExecutor`,它把一次迁移包在 `BEGIN`/`COMMIT`/`ROLLBACK` 里——但这只在 `singleConnection: true` 的适配器上安全(真正的一条持久连接)。`@tauri-apps/plugin-sql` 的底层是连接池(`sqlx::Pool<Sqlite>`),多次 `execute()` 调用不保证落在同一条物理连接上,`BEGIN` 和 `COMMIT` 跨调用拆散后甚至可能不报错。`createDbClient()` 会在你把一个标记为 `requiresSingleConnection: true` 的执行器传给 `singleConnection: false` 的适配器时,提前抛错。
173
208
 
174
209
  这个检查基于显式标记,而不是把执行器和 `defaultExecutor` 做引用相等比较:`transactionalExecutor` 通过 `Object.assign(fn, { requiresSingleConnection: true })` 携带标记。如果你也写了一个管理事务的自定义执行器,请用同样方式标记。完全不碰事务的执行器(例如只加日志)不需要这个标记,即使传给连接池适配器也不会被拒绝。
175
210
 
@@ -201,6 +236,8 @@ sqlite-wasm 的 `opfs` VFS 自带锁协议,因此两个标签页同时写同
201
236
 
202
237
  默认开启 `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
238
 
239
+ 锁原语本身 `tryAcquireTabLock(lockName)` 也从 `cross-sqlite-client/adapters/web` 导出,应用如果需要为数据库之外的资源做同样的跨标签页尽力协调,可以直接使用。
240
+
204
241
  ## 错误
205
242
 
206
243
  所有适配器都抛出以下错误类(全部继承 `DbError extends Error`,均可选传入 `cause`):
@@ -209,7 +246,8 @@ sqlite-wasm 的 `opfs` VFS 自带锁协议,因此两个标签页同时写同
209
246
  |---|---|
210
247
  | `DbError` | 通用/用法错误,例如 `initialize()` 还没 resolve 就调用 `select()`/`execute()`,或 `close()` 之后调用。 |
211
248
  | `DbInitializationError` | `adapter.initialize()` 失败(Worker/OPFS/Tauri 加载失败等)。 |
212
- | `DbExecutionError`(带 `.sql` 和 `.params`) | `select()`/`execute()` 调用失败。 |
249
+ | `DbExecutionError`(带 `.sql` 和 `.params`) | `select()`/`execute()`/`executeBatch()` 调用失败。 |
250
+ | `DbMigrationError`(带 `.version`) | 某条迁移失败;底层错误包装在 `cause` 里。由 `runMigrations`/`createDbClient` 抛出。 |
213
251
  | `DbCloseError` | `client.close()` 失败。 |
214
252
  | `DbTabLockError` | (仅 Web 适配器,`singleTabLock: true`)另一个标签页已持有数据库锁。 |
215
253
 
@@ -240,7 +278,33 @@ await runMigrations(client, APP_MIGRATIONS);
240
278
 
241
279
  每次 `createMemoryAdapter()` 调用都是一个全新的独立实例,因此不同测试(或同一进程里的并行测试)不会共享状态。
242
280
 
281
+ 库自身的测试套件(`pnpm test`)覆盖迁移运行器、客户端生命周期和 React 绑定;CI(`.github/workflows/ci.yml`)在 Node 20/22 上运行 lint、typecheck、测试、构建和 `publint`。
282
+
243
283
  ## 已知限制
244
284
 
245
285
  - **`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
286
  - **没有 OPFS 降级层。** OPFS/跨源隔离不可用时,`createWebAdapter()` 只有两种状态:完整 OPFS 持久化,或完全没有持久化的 `:memory:`。sqlite-wasm 提供了基于 `localStorage`/`sessionStorage` 的 `kvvfs` 后端,可作为中间层,但当前版本尚未接入——如果你需要在无法达到跨源隔离的部署环境里获得持久化,可以考虑接入它(见 [COOP/COEP](#opfs-持久化需要-coopcoep))。
287
+
288
+ ## 运行环境说明
289
+
290
+ 以下是刻意的设计取舍,而非缺陷:
291
+
292
+ - **bfcache 冻结的标签页会一直持有数据库锁。** `singleTabLock` 基于 Web Locks API;被浏览器前进/后退缓存(bfcache)冻结(而非关闭)的标签页会持续持有锁,其他标签页会一直收到 `DbTabLockError`,直到被冻结的标签页被丢弃。真正关闭或崩溃的标签页,其锁会由浏览器自动释放。
293
+ - **没有 Web Locks API → 没有跨标签页协调。** 在老旧浏览器或非安全(非 HTTPS)上下文中,`singleTabLock` 会静默退化为不做协调:多个标签页可以同时打开同一个 OPFS 文件,竞争会在此后以 sqlite-wasm 抛出的可捕获的 "database is locked"(`SQLITE_BUSY`)错误形式暴露。
294
+ - **内存降级模式下刷新页面会丢数据。** OPFS 不可用且 `fallbackToMemory` 开启时,一切功能正常但不持久化。发生降级时适配器会发出 `logger.warn`——如果应用需要感知并向用户提示,请传入自己的 `logger`。
295
+
296
+ ## 版本策略
297
+
298
+ 本包处于 0.x 阶段:**minor 版本可能包含破坏性变更**(0.2.0 就给 `DbClient` 新增了必选方法 `executeBatch`,自定义 adapter/client 的实现会在编译期报错)。请锁定精确版本号,升级前阅读 [CHANGELOG](CHANGELOG.md)。
299
+
300
+ ## 贡献
301
+
302
+ 改动可发布代码的 PR 必须附带 changeset(`pnpm changeset`)——CI 会通过 `changeset status` 强制检查。纯文档或杂务类 PR 可以用 `pnpm changeset --empty` 豁免。
303
+
304
+ ## 发布
305
+
306
+ 发布由 CI 从 tag 触发,绝不从本地机器发出:
307
+
308
+ 1. `pnpm changeset version`——根据待发布的 changeset 更新 `package.json` 版本号和 `CHANGELOG.md`,提交结果。
309
+ 2. 给该提交打上与新版本一致的 `vX.Y.Z` tag 并推送。
310
+ 3. `.github/workflows/release.yml` 会干净地 checkout 该 tag,重跑完整验证链(build、typecheck、lint、test、publint),并以 `--provenance` 发布。需要在仓库 secrets 中配置有发布权限的 `NPM_TOKEN`。
@@ -1,4 +1,4 @@
1
- import { n as DbAdapter } from "../types-XKvAFU82.js";
1
+ import { r as DbAdapter } from "../types-Dm9OqT6i.js";
2
2
  //#region src/adapters/memory.d.ts
3
3
  /**
4
4
  * 内存适配器(测试用)。基于 @sqlite.org/sqlite-wasm 官方支持的 Node 单线程用法
@@ -1,4 +1,4 @@
1
- import { i as DbInitializationError, n as DbError, r as DbExecutionError, t as DbCloseError } from "../errors-D9KnLHTp.js";
1
+ import { i as DbInitializationError, n as DbError, r as DbExecutionError, t as DbCloseError } from "../errors-DwJZz1Jx.js";
2
2
  import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
3
3
  //#region src/adapters/memory.ts
4
4
  /**
@@ -12,32 +12,53 @@ import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
12
12
  function createMemoryAdapter() {
13
13
  let sqlite3 = null;
14
14
  let db = null;
15
+ let initPromise = null;
16
+ function requireDb() {
17
+ if (!db) throw new DbError("[Memory DB] Database not initialized. Call initialize() first.");
18
+ return db;
19
+ }
15
20
  const client = {
16
21
  async select(sql, params = []) {
17
- if (!db) throw new DbError("[Memory DB] Database not initialized. Call initialize() first.");
22
+ const d = requireDb();
18
23
  try {
19
- return db.selectObjects(sql, params);
24
+ return d.selectObjects(sql, params);
20
25
  } catch (error) {
21
26
  throw new DbExecutionError(sql, params, error);
22
27
  }
23
28
  },
24
29
  async execute(sql, params = []) {
25
- if (!db || !sqlite3) throw new DbError("[Memory DB] Database not initialized. Call initialize() first.");
30
+ const d = requireDb();
26
31
  try {
27
- db.exec({
32
+ d.exec({
28
33
  sql,
29
34
  bind: params
30
35
  });
31
- const rowsAffected = db.changes(false, false);
36
+ const rowsAffected = d.changes(false, false);
32
37
  return {
33
- lastInsertId: db.pointer !== void 0 ? Number(sqlite3.capi.sqlite3_last_insert_rowid(db.pointer)) : void 0,
38
+ lastInsertId: d.pointer !== void 0 ? Number(sqlite3.capi.sqlite3_last_insert_rowid(d.pointer)) : void 0,
34
39
  rowsAffected
35
40
  };
36
41
  } catch (error) {
37
42
  throw new DbExecutionError(sql, params, error);
38
43
  }
39
44
  },
45
+ async executeBatch(statements) {
46
+ const d = requireDb();
47
+ if (statements.length === 0) return;
48
+ if (statements.every((s) => typeof s === "string" || s.params === void 0 || s.params.length === 0)) {
49
+ d.exec({ sql: statements.map((s) => typeof s === "string" ? s : s.sql).join("\n;\n") });
50
+ return;
51
+ }
52
+ for (const statement of statements) {
53
+ const sql = typeof statement === "string" ? statement : statement.sql;
54
+ const params = typeof statement === "string" ? [] : statement.params ?? [];
55
+ await client.execute(sql, params);
56
+ }
57
+ },
40
58
  async close() {
59
+ const pending = initPromise;
60
+ initPromise = null;
61
+ if (pending) await pending.catch(() => {});
41
62
  try {
42
63
  db?.close();
43
64
  } catch (error) {
@@ -47,17 +68,27 @@ function createMemoryAdapter() {
47
68
  }
48
69
  }
49
70
  };
71
+ async function doInitialize() {
72
+ try {
73
+ sqlite3 = await sqlite3InitModule();
74
+ db = new sqlite3.oo1.DB(":memory:", "c");
75
+ return client;
76
+ } catch (error) {
77
+ sqlite3 = null;
78
+ db = null;
79
+ throw new DbInitializationError(error);
80
+ }
81
+ }
50
82
  return {
51
83
  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);
84
+ initialize() {
85
+ if (!initPromise) {
86
+ initPromise = doInitialize();
87
+ initPromise.catch(() => {
88
+ initPromise = null;
89
+ });
60
90
  }
91
+ return initPromise;
61
92
  }
62
93
  };
63
94
  }
@@ -1,11 +1,16 @@
1
- import { n as DbAdapter } from "../types-XKvAFU82.js";
1
+ import { r as DbAdapter } from "../types-Dm9OqT6i.js";
2
2
  //#region src/adapters/tauri.d.ts
3
3
  /**
4
- * Tauri (@tauri-apps/plugin-sql) 适配器。每次调用 createTauriAdapter() 返回一个状态独立的
5
- * 新实例——db 保存在这个函数的闭包里,不是模块级单例。
4
+ * Tauri (@tauri-apps/plugin-sql) 适配器。
5
+ *
6
+ * 注意:plugin-sql 的 Sqlite.load 在插件侧按数据库路径全局缓存连接——两个同名(同 name)
7
+ * 的适配器实例共享底层连接,其中一个 close 另一个也会断。要多个真正独立的连接,用不同的
8
+ * name(即不同的数据库文件)。
6
9
  *
7
10
  * singleConnection 为 false:@tauri-apps/plugin-sql 底层是 sqlx::Pool<Sqlite> 连接池,
8
11
  * 每次 execute()/select() 调用独立获取/归还一个物理连接,不保证跨调用落在同一条连接上。
12
+ * 这也是库不提供业务侧事务 API 的原因:BEGIN 和 COMMIT 可能被拆到两条物理连接上而不报
13
+ * 任何错。
9
14
  */
10
15
  export declare function createTauriAdapter(): DbAdapter;
11
16
  //#endregion
@@ -1,28 +1,38 @@
1
- import { i as DbInitializationError, n as DbError, r as DbExecutionError, t as DbCloseError } from "../errors-D9KnLHTp.js";
1
+ import { i as DbInitializationError, n as DbError, r as DbExecutionError, t as DbCloseError } from "../errors-DwJZz1Jx.js";
2
2
  import Sqlite from "@tauri-apps/plugin-sql";
3
3
  //#region src/adapters/tauri.ts
4
4
  /**
5
- * Tauri (@tauri-apps/plugin-sql) 适配器。每次调用 createTauriAdapter() 返回一个状态独立的
6
- * 新实例——db 保存在这个函数的闭包里,不是模块级单例。
5
+ * Tauri (@tauri-apps/plugin-sql) 适配器。
6
+ *
7
+ * 注意:plugin-sql 的 Sqlite.load 在插件侧按数据库路径全局缓存连接——两个同名(同 name)
8
+ * 的适配器实例共享底层连接,其中一个 close 另一个也会断。要多个真正独立的连接,用不同的
9
+ * name(即不同的数据库文件)。
7
10
  *
8
11
  * singleConnection 为 false:@tauri-apps/plugin-sql 底层是 sqlx::Pool<Sqlite> 连接池,
9
12
  * 每次 execute()/select() 调用独立获取/归还一个物理连接,不保证跨调用落在同一条连接上。
13
+ * 这也是库不提供业务侧事务 API 的原因:BEGIN 和 COMMIT 可能被拆到两条物理连接上而不报
14
+ * 任何错。
10
15
  */
11
16
  function createTauriAdapter() {
12
17
  let db = null;
18
+ let initPromise = null;
19
+ function requireDb() {
20
+ if (!db) throw new DbError("[Tauri DB] Database not initialized. Call initialize() first.");
21
+ return db;
22
+ }
13
23
  const client = {
14
24
  async select(sql, params = []) {
15
- if (!db) throw new DbError("[Tauri DB] Database not initialized. Call initialize() first.");
25
+ const d = requireDb();
16
26
  try {
17
- return await db.select(sql, params);
27
+ return await d.select(sql, params);
18
28
  } catch (error) {
19
29
  throw new DbExecutionError(sql, params, error);
20
30
  }
21
31
  },
22
32
  async execute(sql, params = []) {
23
- if (!db) throw new DbError("[Tauri DB] Database not initialized. Call initialize() first.");
33
+ const d = requireDb();
24
34
  try {
25
- const result = await db.execute(sql, params);
35
+ const result = await d.execute(sql, params);
26
36
  return {
27
37
  lastInsertId: result.lastInsertId,
28
38
  rowsAffected: result.rowsAffected
@@ -31,26 +41,44 @@ function createTauriAdapter() {
31
41
  throw new DbExecutionError(sql, params, error);
32
42
  }
33
43
  },
44
+ async executeBatch(statements) {
45
+ for (const statement of statements) {
46
+ const sql = typeof statement === "string" ? statement : statement.sql;
47
+ const params = typeof statement === "string" ? [] : statement.params ?? [];
48
+ await client.execute(sql, params);
49
+ }
50
+ },
34
51
  async close() {
52
+ const pending = initPromise;
53
+ initPromise = null;
54
+ if (pending) await pending.catch(() => {});
35
55
  if (db) try {
36
- if (!await db.close()) throw new DbCloseError();
56
+ const current = db;
57
+ db = null;
58
+ if (!await current.close()) throw new DbCloseError();
37
59
  } catch (error) {
38
60
  throw error instanceof DbCloseError ? error : new DbCloseError(error);
39
- } finally {
40
- db = null;
41
61
  }
42
62
  }
43
63
  };
64
+ async function doInitialize(config) {
65
+ try {
66
+ db = await Sqlite.load(`sqlite:${config.name}.db`);
67
+ return client;
68
+ } catch (error) {
69
+ throw new DbInitializationError(error);
70
+ }
71
+ }
44
72
  return {
45
73
  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);
74
+ initialize(config) {
75
+ if (!initPromise) {
76
+ initPromise = doInitialize(config);
77
+ initPromise.catch(() => {
78
+ initPromise = null;
79
+ });
53
80
  }
81
+ return initPromise;
54
82
  }
55
83
  };
56
84
  }
@@ -1,9 +1,9 @@
1
- import { n as DbAdapter, o as MigrationExecutor } from "../types-XKvAFU82.js";
1
+ import { c as MigrationExecutor, o as Logger, r as DbAdapter } from "../types-Dm9OqT6i.js";
2
2
  //#region src/adapters/web.d.ts
3
3
  export interface WebAdapterOptions {
4
4
  /** 等待 Worker 就绪的超时时间(毫秒),超时后 initialize() 会 reject 而不是永远 pending */
5
5
  timeoutMs?: number;
6
- /** OPFS 不可用(不支持/未跨域隔离/初始化失败)时是否静默退化为内存模式,默认 true */
6
+ /** OPFS 不可用(不支持/未跨域隔离/探测或打开失败)时是否静默退化为内存模式,默认 true */
7
7
  fallbackToMemory?: boolean;
8
8
  /**
9
9
  * 用 Web Locks API(navigator.locks)在同一 origin 的多个标签页/窗口之间协调对同一个
@@ -21,10 +21,12 @@ export interface WebAdapterOptions {
21
21
  * 不受这个选项影响。
22
22
  */
23
23
  singleTabLock?: boolean;
24
+ /** 诊断输出(OPFS 降级告警、worker 错误等),默认 console */
25
+ logger?: Logger;
24
26
  }
25
27
  /**
26
28
  * 用 Web Locks API 尝试(不等待)拿到一个跨标签页命名锁。拿不到(另一个标签页已持有)时
27
- * resolve null;拿到时返回一个 release() 函数,调用方后续必须调用它来释放锁。
29
+ * 返回 null;拿到时返回一个 release() 函数,调用方后续必须调用它来释放锁。
28
30
  *
29
31
  * 实现上依赖一个常见技巧:navigator.locks.request() 的回调函数返回什么 Promise,锁就持有到
30
32
  * 那个 Promise settle 为止;这里让回调返回一个我们自己创建、直到 release() 被调用才会 resolve
@@ -37,7 +39,5 @@ export declare function tryAcquireTabLock(lockName: string): Promise<(() => void
37
39
  * 实例(例如测试)。
38
40
  */
39
41
  export declare function createWebAdapter(options?: WebAdapterOptions): DbAdapter;
40
- export declare const transactionalExecutor: MigrationExecutor & {
41
- requiresSingleConnection: true;
42
- };
42
+ export declare const transactionalExecutor: MigrationExecutor;
43
43
  //#endregion