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 +125 -13
- package/README.zh-CN.md +73 -9
- package/dist/adapters/memory.d.ts +1 -1
- package/dist/adapters/memory.js +46 -15
- package/dist/adapters/tauri.d.ts +8 -3
- package/dist/adapters/tauri.js +45 -17
- package/dist/adapters/web.d.ts +6 -6
- package/dist/adapters/web.js +161 -72
- package/dist/core/index.d.ts +19 -4
- package/dist/core/index.js +70 -12
- package/dist/{errors-D9KnLHTp.js → errors-DwJZz1Jx.js} +15 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +2 -0
- package/dist/types-Dm9OqT6i.d.ts +93 -0
- package/package.json +21 -9
- package/dist/types-XKvAFU82.d.ts +0 -45
package/dist/adapters/web.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { i as DbInitializationError, n as DbError, o as DbTabLockError, r as DbExecutionError, t as DbCloseError } from "../errors-DwJZz1Jx.js";
|
|
2
2
|
import { sqlite3Worker1Promiser } from "@sqlite.org/sqlite-wasm";
|
|
3
3
|
//#region src/adapters/web.ts
|
|
4
4
|
const DEFAULT_TIMEOUT_MS = 15e3;
|
|
5
5
|
/**
|
|
6
|
+
* promiser 的错误以 reject 形式出现,且 rejection 值是 {type:"error", result:{message,...}}
|
|
7
|
+
* 形状的普通对象而不是 Error 实例(见 src/types/sqlite-wasm.d.ts 里 Promiser 的说明)。
|
|
8
|
+
*/
|
|
9
|
+
function isPromiserError(error) {
|
|
10
|
+
return typeof error === "object" && error !== null && error.type === "error" && typeof error.result?.message === "string";
|
|
11
|
+
}
|
|
12
|
+
/** 从 promiser 的 rejection 值里提取可读消息;不是 promiser 错误就原样返回 */
|
|
13
|
+
function promiserErrorCause(error) {
|
|
14
|
+
return isPromiserError(error) ? new Error(error.result.message) : error;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
6
17
|
* 用 Web Locks API 尝试(不等待)拿到一个跨标签页命名锁。拿不到(另一个标签页已持有)时
|
|
7
|
-
*
|
|
18
|
+
* 返回 null;拿到时返回一个 release() 函数,调用方后续必须调用它来释放锁。
|
|
8
19
|
*
|
|
9
20
|
* 实现上依赖一个常见技巧:navigator.locks.request() 的回调函数返回什么 Promise,锁就持有到
|
|
10
21
|
* 那个 Promise settle 为止;这里让回调返回一个我们自己创建、直到 release() 被调用才会 resolve
|
|
@@ -36,97 +47,175 @@ function createWebAdapter(options = {}) {
|
|
|
36
47
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
37
48
|
const fallbackToMemory = options.fallbackToMemory ?? true;
|
|
38
49
|
const singleTabLock = options.singleTabLock ?? true;
|
|
50
|
+
const logger = options.logger ?? console;
|
|
39
51
|
let promiser = null;
|
|
40
52
|
let currentDbId;
|
|
41
53
|
let releaseTabLock = null;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
let worker = null;
|
|
55
|
+
let initPromise = null;
|
|
56
|
+
function requirePromiser() {
|
|
57
|
+
if (!promiser || currentDbId === void 0) throw new DbError("[Web DB] Database not initialized. Call initialize() first.");
|
|
58
|
+
return {
|
|
59
|
+
p: promiser,
|
|
60
|
+
dbId: currentDbId
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function exec(sql, params) {
|
|
64
|
+
const { p, dbId } = requirePromiser();
|
|
65
|
+
try {
|
|
66
|
+
return await p("exec", {
|
|
67
|
+
dbId,
|
|
47
68
|
sql,
|
|
48
69
|
bind: params,
|
|
49
70
|
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,
|
|
71
|
+
rowMode: "object",
|
|
61
72
|
countChanges: true,
|
|
62
73
|
lastInsertRowId: true
|
|
63
74
|
});
|
|
64
|
-
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw new DbExecutionError(sql, params, promiserErrorCause(error));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const client = {
|
|
80
|
+
async select(sql, params = []) {
|
|
81
|
+
return (await exec(sql, params)).result.resultRows || [];
|
|
82
|
+
},
|
|
83
|
+
async execute(sql, params = []) {
|
|
84
|
+
const response = await exec(sql, params);
|
|
65
85
|
return {
|
|
66
86
|
lastInsertId: response.result.lastInsertRowId !== void 0 ? Number(response.result.lastInsertRowId) : void 0,
|
|
67
87
|
rowsAffected: response.result.changeCount ?? 0
|
|
68
88
|
};
|
|
69
89
|
},
|
|
90
|
+
async executeBatch(statements) {
|
|
91
|
+
if (statements.length === 0) return;
|
|
92
|
+
if (statements.every((s) => typeof s === "string" || s.params === void 0 || s.params.length === 0)) {
|
|
93
|
+
const { p, dbId } = requirePromiser();
|
|
94
|
+
const sql = statements.map((s) => typeof s === "string" ? s : s.sql).join("\n;\n");
|
|
95
|
+
try {
|
|
96
|
+
await p("exec", {
|
|
97
|
+
dbId,
|
|
98
|
+
sql
|
|
99
|
+
});
|
|
100
|
+
} catch (error) {
|
|
101
|
+
throw new DbExecutionError(sql, [], promiserErrorCause(error));
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
for (const statement of statements) {
|
|
106
|
+
const sql = typeof statement === "string" ? statement : statement.sql;
|
|
107
|
+
const params = typeof statement === "string" ? [] : statement.params ?? [];
|
|
108
|
+
await client.execute(sql, params);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
70
111
|
async close() {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
112
|
+
const pending = initPromise;
|
|
113
|
+
initPromise = null;
|
|
114
|
+
if (pending) await pending.catch(() => {});
|
|
115
|
+
const p = promiser;
|
|
116
|
+
const dbId = currentDbId;
|
|
117
|
+
const w = worker;
|
|
118
|
+
promiser = null;
|
|
119
|
+
currentDbId = void 0;
|
|
120
|
+
worker = null;
|
|
121
|
+
releaseTabLock?.();
|
|
122
|
+
releaseTabLock = null;
|
|
123
|
+
try {
|
|
124
|
+
if (p && dbId !== void 0) await p("close", { dbId });
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw new DbCloseError(promiserErrorCause(error));
|
|
74
127
|
} finally {
|
|
75
|
-
|
|
76
|
-
currentDbId = void 0;
|
|
77
|
-
releaseTabLock?.();
|
|
78
|
-
releaseTabLock = null;
|
|
128
|
+
w?.terminate();
|
|
79
129
|
}
|
|
80
130
|
}
|
|
81
131
|
};
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
132
|
+
function createWorker() {
|
|
133
|
+
try {
|
|
134
|
+
const factory = sqlite3Worker1Promiser.defaultConfig?.worker;
|
|
135
|
+
if (typeof factory === "function") return factory();
|
|
136
|
+
return factory;
|
|
137
|
+
} catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function openDatabase(p, filename) {
|
|
142
|
+
const openResponse = await p("open", { filename });
|
|
143
|
+
if (openResponse.dbId === void 0) throw new DbInitializationError(/* @__PURE__ */ new Error("Database opened successfully but dbId is undefined. This indicates an unexpected library behavior."));
|
|
144
|
+
return openResponse.dbId;
|
|
145
|
+
}
|
|
146
|
+
async function doInitialize(config) {
|
|
147
|
+
const isOpfsSupported = typeof navigator !== "undefined" && typeof navigator.storage !== "undefined" && !!navigator.storage.getDirectory;
|
|
148
|
+
const isCrossOriginIsolated = typeof window !== "undefined" && window.crossOriginIsolated;
|
|
149
|
+
let filename = ":memory:";
|
|
150
|
+
if (!isOpfsSupported || !isCrossOriginIsolated) {
|
|
151
|
+
if (!fallbackToMemory) throw new DbInitializationError(/* @__PURE__ */ new Error("[Web DB] OPFS is not available (unsupported or not cross-origin isolated) and fallbackToMemory is false."));
|
|
152
|
+
logger.warn("[Web DB] OPFS is not fully supported or cross-origin isolated. Falling back to in-memory mode.");
|
|
153
|
+
} else try {
|
|
154
|
+
const root = await navigator.storage.getDirectory();
|
|
155
|
+
await root.getFileHandle("test_opfs_support", { create: true });
|
|
156
|
+
await root.removeEntry("test_opfs_support").catch(() => {});
|
|
157
|
+
filename = `file:${config.name}.db?vfs=opfs`;
|
|
158
|
+
} catch (opfsError) {
|
|
159
|
+
if (!fallbackToMemory) throw new DbInitializationError(new Error("[Web DB] OPFS initialization failed and fallbackToMemory is false.", { cause: opfsError }));
|
|
160
|
+
logger.warn("[Web DB] OPFS initialization failed or file access denied. Falling back to in-memory mode.", opfsError);
|
|
161
|
+
filename = ":memory:";
|
|
162
|
+
}
|
|
163
|
+
if (filename !== ":memory:" && singleTabLock) {
|
|
164
|
+
let release;
|
|
165
|
+
try {
|
|
166
|
+
release = await tryAcquireTabLock(`cross-sqlite-client:${config.name}`);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
throw new DbInitializationError(error);
|
|
101
169
|
}
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
170
|
+
if (!release) throw new DbTabLockError();
|
|
171
|
+
releaseTabLock = release;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
worker = createWorker() ?? null;
|
|
175
|
+
const readyPromise = sqlite3Worker1Promiser({
|
|
176
|
+
worker: worker ?? void 0,
|
|
177
|
+
onerror: (...args) => logger.error("[Web DB] sqlite3Worker1Promiser error:", ...args)
|
|
178
|
+
});
|
|
179
|
+
let rejectTimeout;
|
|
180
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
181
|
+
rejectTimeout = reject;
|
|
182
|
+
});
|
|
183
|
+
const timeoutId = setTimeout(() => rejectTimeout(/* @__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);
|
|
184
|
+
try {
|
|
185
|
+
promiser = await Promise.race([readyPromise, timeoutPromise]);
|
|
186
|
+
} finally {
|
|
187
|
+
clearTimeout(timeoutId);
|
|
106
188
|
}
|
|
107
189
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
190
|
+
currentDbId = await openDatabase(promiser, filename);
|
|
191
|
+
} catch (openError) {
|
|
192
|
+
if (filename !== ":memory:" && fallbackToMemory) {
|
|
193
|
+
logger.warn("[Web DB] Failed to open the OPFS database. Falling back to in-memory mode.", promiserErrorCause(openError));
|
|
194
|
+
currentDbId = await openDatabase(promiser, ":memory:");
|
|
195
|
+
} else throw openError;
|
|
196
|
+
}
|
|
197
|
+
return client;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
promiser = null;
|
|
200
|
+
currentDbId = void 0;
|
|
201
|
+
worker?.terminate();
|
|
202
|
+
worker = null;
|
|
203
|
+
releaseTabLock?.();
|
|
204
|
+
releaseTabLock = null;
|
|
205
|
+
if (error instanceof DbInitializationError) throw error;
|
|
206
|
+
throw new DbInitializationError(promiserErrorCause(error));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
singleConnection: true,
|
|
211
|
+
initialize(config) {
|
|
212
|
+
if (!initPromise) {
|
|
213
|
+
initPromise = doInitialize(config);
|
|
214
|
+
initPromise.catch(() => {
|
|
215
|
+
initPromise = null;
|
|
112
216
|
});
|
|
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
217
|
}
|
|
218
|
+
return initPromise;
|
|
130
219
|
}
|
|
131
220
|
};
|
|
132
221
|
}
|
|
@@ -135,13 +224,13 @@ function createWebAdapter(options = {}) {
|
|
|
135
224
|
*
|
|
136
225
|
* 为什么不能给 Tauri 用:@tauri-apps/plugin-sql 底层是 sqlx::Pool<Sqlite> 连接池,每次
|
|
137
226
|
* execute() 调用独立获取/归还连接,不保证 BEGIN 和 COMMIT 落在同一条物理连接上,事务可能被
|
|
138
|
-
* 悄悄拆散且不报错。createDbClient
|
|
139
|
-
*
|
|
227
|
+
* 悄悄拆散且不报错。createDbClient 靠 executor 上的 requiresSingleConnection 标记拒绝这种
|
|
228
|
+
* 组合,但直接调用 runMigrations() 时不会有这层保护,调用方需自行确保只在单连接适配器上使用。
|
|
140
229
|
*/
|
|
141
230
|
const runTransactionalMigration = async (db, migration, recordVersion) => {
|
|
142
231
|
await db.execute("BEGIN;");
|
|
143
232
|
try {
|
|
144
|
-
|
|
233
|
+
await db.executeBatch(migration.statements);
|
|
145
234
|
await recordVersion();
|
|
146
235
|
await db.execute("COMMIT;");
|
|
147
236
|
} catch (e) {
|
package/dist/core/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as DbClient, c as MigrationExecutor, i as DbAdapterConfig, l as MigrationOptions, n as CreateDbClientOptions, o as Logger, r as DbAdapter, s as Migration, t as BatchStatement } from "../types-Dm9OqT6i.js";
|
|
2
2
|
//#region src/core/migrate.d.ts
|
|
3
3
|
export declare const defaultExecutor: MigrationExecutor;
|
|
4
4
|
/**
|
|
5
|
-
* 按 version
|
|
5
|
+
* 按 version 顺序执行尚未应用的迁移。默认每条语句独立执行,没有事务包裹;若某条迁移执行到
|
|
6
6
|
* 一半失败,下次启动会从同一个 version 重新开始,重新跑一遍这个 version 里的全部语句。
|
|
7
7
|
*
|
|
8
8
|
* 不能简单地在多次 execute() 调用之间手动包一层 BEGIN/COMMIT/ROLLBACK 来补救:部分适配器
|
|
@@ -16,8 +16,14 @@ export declare const defaultExecutor: MigrationExecutor;
|
|
|
16
16
|
* - 以后如果要写重命名列、迁移数据这类不可重复执行的语句,先查询当前 schema 状态判断
|
|
17
17
|
* 这一步是否已经做过(例如 `SELECT 1 FROM pragma_table_info('t') WHERE name = '...'`),
|
|
18
18
|
* 已完成就跳过,而不是依赖事务回滚。
|
|
19
|
+
*
|
|
20
|
+
* executor 抛出的错误会被包装成 DbMigrationError(携带失败的 version)再向上抛。
|
|
21
|
+
*
|
|
22
|
+
* 不可重入:同一个数据库同一时间只允许一个 runMigrations 在执行。并发调用会各自读取
|
|
23
|
+
* 相同起点并重复执行——语句幂等所以无害,但两边都会写版本记录,后到一方撞主键约束,
|
|
24
|
+
* 报出"迁移失败"的误导性错误。
|
|
19
25
|
*/
|
|
20
|
-
export declare function runMigrations(db: Pick<DbClient, "execute" | "select">, migrations: Migration[], options?: MigrationOptions): Promise<void>;
|
|
26
|
+
export declare function runMigrations(db: Pick<DbClient, "execute" | "select" | "executeBatch">, migrations: Migration[], options?: MigrationOptions): Promise<void>;
|
|
21
27
|
//#endregion
|
|
22
28
|
//#region src/core/errors.d.ts
|
|
23
29
|
export declare class DbError extends Error {
|
|
@@ -32,6 +38,15 @@ export declare class DbExecutionError extends DbError {
|
|
|
32
38
|
params: unknown[];
|
|
33
39
|
constructor(sql: string, params: unknown[], cause?: unknown);
|
|
34
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* 某条迁移执行失败。version 指出失败的是哪个迁移版本,cause 是底层原始错误
|
|
43
|
+
* (通常是 DbExecutionError)。runMigrations 会把 executor 抛出的非 DbMigrationError
|
|
44
|
+
* 错误统一包装成这个类型再向上抛。
|
|
45
|
+
*/
|
|
46
|
+
export declare class DbMigrationError extends DbError {
|
|
47
|
+
version: number;
|
|
48
|
+
constructor(version: number, cause?: unknown);
|
|
49
|
+
}
|
|
35
50
|
export declare class DbCloseError extends DbError {
|
|
36
51
|
constructor(cause?: unknown);
|
|
37
52
|
}
|
|
@@ -47,4 +62,4 @@ export declare class DbTabLockError extends DbError {
|
|
|
47
62
|
//#region src/core/index.d.ts
|
|
48
63
|
export declare function createDbClient(options: CreateDbClientOptions): Promise<DbClient>;
|
|
49
64
|
//#endregion
|
|
50
|
-
export type { CreateDbClientOptions, DbAdapter, DbAdapterConfig, DbClient, Migration, MigrationExecutor, MigrationOptions };
|
|
65
|
+
export type { BatchStatement, CreateDbClientOptions, DbAdapter, DbAdapterConfig, DbClient, Logger, Migration, MigrationExecutor, MigrationOptions };
|
package/dist/core/index.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as DbMigrationError, i as DbInitializationError, n as DbError, o as DbTabLockError, r as DbExecutionError, t as DbCloseError } from "../errors-DwJZz1Jx.js";
|
|
2
2
|
//#region src/core/migrate.ts
|
|
3
|
+
/** 表名/PRAGMA 名等要拼进 SQL 的标识符,只允许这个白名单,杜绝注入 */
|
|
4
|
+
const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
5
|
+
function assertIdentifier(value, what) {
|
|
6
|
+
if (!IDENTIFIER_RE.test(value)) throw new Error(`${what} must match ${IDENTIFIER_RE} (letters, digits, underscore; not starting with a digit), got ${JSON.stringify(value)}.`);
|
|
7
|
+
}
|
|
3
8
|
const defaultExecutor = async (db, migration, recordVersion) => {
|
|
4
|
-
|
|
9
|
+
await db.executeBatch(migration.statements);
|
|
5
10
|
await recordVersion();
|
|
6
11
|
};
|
|
7
12
|
/**
|
|
8
|
-
* 按 version
|
|
13
|
+
* 按 version 顺序执行尚未应用的迁移。默认每条语句独立执行,没有事务包裹;若某条迁移执行到
|
|
9
14
|
* 一半失败,下次启动会从同一个 version 重新开始,重新跑一遍这个 version 里的全部语句。
|
|
10
15
|
*
|
|
11
16
|
* 不能简单地在多次 execute() 调用之间手动包一层 BEGIN/COMMIT/ROLLBACK 来补救:部分适配器
|
|
@@ -19,29 +24,82 @@ const defaultExecutor = async (db, migration, recordVersion) => {
|
|
|
19
24
|
* - 以后如果要写重命名列、迁移数据这类不可重复执行的语句,先查询当前 schema 状态判断
|
|
20
25
|
* 这一步是否已经做过(例如 `SELECT 1 FROM pragma_table_info('t') WHERE name = '...'`),
|
|
21
26
|
* 已完成就跳过,而不是依赖事务回滚。
|
|
27
|
+
*
|
|
28
|
+
* executor 抛出的错误会被包装成 DbMigrationError(携带失败的 version)再向上抛。
|
|
29
|
+
*
|
|
30
|
+
* 不可重入:同一个数据库同一时间只允许一个 runMigrations 在执行。并发调用会各自读取
|
|
31
|
+
* 相同起点并重复执行——语句幂等所以无害,但两边都会写版本记录,后到一方撞主键约束,
|
|
32
|
+
* 报出"迁移失败"的误导性错误。
|
|
22
33
|
*/
|
|
23
34
|
async function runMigrations(db, migrations, options) {
|
|
24
35
|
const tableName = options?.tableName ?? "schema_version";
|
|
25
36
|
const executor = options?.executor ?? defaultExecutor;
|
|
26
|
-
|
|
37
|
+
const logger = options?.logger ?? console;
|
|
38
|
+
assertIdentifier(tableName, "Migration tableName");
|
|
39
|
+
const seen = /* @__PURE__ */ new Set();
|
|
40
|
+
for (const migration of migrations) {
|
|
41
|
+
if (!Number.isInteger(migration.version) || migration.version <= 0) throw new Error(`Migration version must be a positive integer, got ${migration.version}.`);
|
|
42
|
+
if (seen.has(migration.version)) throw new Error(`Duplicate migration version ${migration.version}.`);
|
|
43
|
+
seen.add(migration.version);
|
|
44
|
+
}
|
|
27
45
|
await db.execute(`CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
28
46
|
version INTEGER PRIMARY KEY,
|
|
29
|
-
applied_at INTEGER NOT NULL DEFAULT
|
|
47
|
+
applied_at INTEGER NOT NULL DEFAULT 0
|
|
30
48
|
);`);
|
|
31
|
-
const
|
|
49
|
+
const rows = await db.select(`SELECT version FROM ${tableName};`);
|
|
50
|
+
const applied = new Set(rows.map((row) => row.version));
|
|
51
|
+
const currentVersion = Math.max(0, ...applied);
|
|
52
|
+
const maxProvided = migrations.reduce((max, migration) => Math.max(max, migration.version), 0);
|
|
53
|
+
if (currentVersion > maxProvided) logger.warn(`[Migrations] Database schema version (${currentVersion}) is newer than the highest migration provided by this app (${maxProvided}). The database was likely created by a newer app version; no migrations were run.`);
|
|
54
|
+
const holes = migrations.filter((migration) => migration.version < currentVersion && !applied.has(migration.version));
|
|
55
|
+
if (holes.length > 0) logger.warn(`[Migrations] ${holes.length} migration(s) with version below the current schema version (${currentVersion}) were never applied and are being skipped: ${holes.map((migration) => migration.version).join(", ")}. If these are hotfix migrations for an older release line, apply them deliberately (e.g. with a dedicated executor) instead of relying on the default runner.`);
|
|
32
56
|
const pending = migrations.filter((migration) => migration.version > currentVersion).sort((a, b) => a.version - b.version);
|
|
33
|
-
for (const migration of pending)
|
|
34
|
-
await db
|
|
35
|
-
|
|
57
|
+
for (const migration of pending) try {
|
|
58
|
+
await executor(db, migration, async () => {
|
|
59
|
+
await db.execute(`INSERT INTO ${tableName} (version, applied_at) VALUES (?, ?);`, [migration.version, Date.now()]);
|
|
60
|
+
});
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw error instanceof DbMigrationError ? error : new DbMigrationError(migration.version, error);
|
|
63
|
+
}
|
|
36
64
|
}
|
|
37
65
|
//#endregion
|
|
38
66
|
//#region src/core/index.ts
|
|
67
|
+
/** PRAGMA 的字符串值只允许枚举式 token(WAL、NORMAL……),杜绝拼 SQL 注入 */
|
|
68
|
+
const PRAGMA_VALUE_RE = /^[A-Za-z0-9_]+$/;
|
|
69
|
+
async function applyPragmas(client, pragmas) {
|
|
70
|
+
for (const [key, rawValue] of Object.entries(pragmas)) {
|
|
71
|
+
assertIdentifier(key, "PRAGMA name");
|
|
72
|
+
let value;
|
|
73
|
+
if (typeof rawValue === "boolean") value = rawValue ? "1" : "0";
|
|
74
|
+
else if (typeof rawValue === "number") {
|
|
75
|
+
if (!Number.isFinite(rawValue)) throw new Error(`PRAGMA ${key}: number values must be finite, got ${rawValue}.`);
|
|
76
|
+
value = String(rawValue);
|
|
77
|
+
} else {
|
|
78
|
+
if (!PRAGMA_VALUE_RE.test(rawValue)) throw new Error(`PRAGMA ${key}: string values must match ${PRAGMA_VALUE_RE}, got ${JSON.stringify(rawValue)}.`);
|
|
79
|
+
value = rawValue;
|
|
80
|
+
}
|
|
81
|
+
await client.execute(`PRAGMA ${key} = ${value};`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
39
84
|
async function createDbClient(options) {
|
|
40
85
|
const { adapter } = options;
|
|
41
|
-
if (
|
|
86
|
+
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.");
|
|
87
|
+
const logger = options.logger ?? console;
|
|
42
88
|
const client = await adapter.initialize({ name: options.name });
|
|
43
|
-
|
|
89
|
+
try {
|
|
90
|
+
if (options.pragmas) {
|
|
91
|
+
if (!adapter.singleConnection) logger.warn("[PRAGMA] This adapter uses a connection pool; connection-level pragmas (e.g. foreign_keys, busy_timeout) apply only to one pooled connection and will silently not hold for later queries. Database-level pragmas (e.g. journal_mode, user_version) are unaffected.");
|
|
92
|
+
await applyPragmas(client, options.pragmas);
|
|
93
|
+
}
|
|
94
|
+
if (options.migrations) await runMigrations(client, options.migrations, {
|
|
95
|
+
...options.migrationOptions,
|
|
96
|
+
logger: options.migrationOptions?.logger ?? logger
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
await client.close().catch(() => {});
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
44
102
|
return client;
|
|
45
103
|
}
|
|
46
104
|
//#endregion
|
|
47
|
-
export { DbCloseError, DbError, DbExecutionError, DbInitializationError, DbTabLockError, createDbClient, defaultExecutor, runMigrations };
|
|
105
|
+
export { DbCloseError, DbError, DbExecutionError, DbInitializationError, DbMigrationError, DbTabLockError, createDbClient, defaultExecutor, runMigrations };
|
|
@@ -23,6 +23,20 @@ var DbExecutionError = class extends DbError {
|
|
|
23
23
|
this.name = "DbExecutionError";
|
|
24
24
|
}
|
|
25
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* 某条迁移执行失败。version 指出失败的是哪个迁移版本,cause 是底层原始错误
|
|
28
|
+
* (通常是 DbExecutionError)。runMigrations 会把 executor 抛出的非 DbMigrationError
|
|
29
|
+
* 错误统一包装成这个类型再向上抛。
|
|
30
|
+
*/
|
|
31
|
+
var DbMigrationError = class extends DbError {
|
|
32
|
+
version;
|
|
33
|
+
constructor(version, cause) {
|
|
34
|
+
const detail = cause instanceof Error ? cause.message : cause === void 0 ? "" : String(cause);
|
|
35
|
+
super(`Migration ${version} failed${detail ? `: ${detail.slice(0, 500)}` : ""}`, cause);
|
|
36
|
+
this.version = version;
|
|
37
|
+
this.name = "DbMigrationError";
|
|
38
|
+
}
|
|
39
|
+
};
|
|
26
40
|
var DbCloseError = class extends DbError {
|
|
27
41
|
constructor(cause) {
|
|
28
42
|
super("Failed to close database", cause);
|
|
@@ -41,4 +55,4 @@ var DbTabLockError = class extends DbError {
|
|
|
41
55
|
}
|
|
42
56
|
};
|
|
43
57
|
//#endregion
|
|
44
|
-
export {
|
|
58
|
+
export { DbMigrationError as a, DbInitializationError as i, DbError as n, DbTabLockError as o, DbExecutionError as r, DbCloseError as t };
|
package/dist/react/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as DbClient } from "../types-Dm9OqT6i.js";
|
|
2
2
|
import React from "react";
|
|
3
3
|
//#region src/react/DatabaseProvider.d.ts
|
|
4
4
|
interface DatabaseContextType {
|
|
@@ -14,7 +14,8 @@ interface DatabaseProviderProps {
|
|
|
14
14
|
*
|
|
15
15
|
* 没有 retry:这个 Promise 只会 settle 一次,重试的语义交给调用方——想重试就在自己的状态
|
|
16
16
|
* 里创建一个新的 client Promise,再把新引用传给这个 prop,effect 依赖 [client] 会自动
|
|
17
|
-
*
|
|
17
|
+
* 重新走一遍下面的初始化逻辑。换 promise 的瞬间会先回到完全未就绪状态
|
|
18
|
+
* (dbClient: null、isDbReady: false),新 promise resolve 之前消费方不会看到旧连接。
|
|
18
19
|
*
|
|
19
20
|
* DatabaseProvider 不拥有 client 的生命周期,卸载时也不会调用 client.close():谁创建
|
|
20
21
|
* 的 client 谁负责关闭。如果 close() 交给这里,而调用方(比如应用级单例的
|
package/dist/react/index.js
CHANGED
|
@@ -9,6 +9,8 @@ const DatabaseProvider = ({ client, children }) => {
|
|
|
9
9
|
const [dbError, setDbError] = useState(null);
|
|
10
10
|
useEffect(() => {
|
|
11
11
|
let cancelled = false;
|
|
12
|
+
setDbClient(null);
|
|
13
|
+
setIsDbReady(false);
|
|
12
14
|
setIsLoading(true);
|
|
13
15
|
setDbError(null);
|
|
14
16
|
Promise.resolve(client).then((resolvedClient) => {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
//#region src/core/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* 库内部诊断信息的输出通道。默认是 console;生产应用可以传入自己的实现,
|
|
4
|
+
* 把告警/错误接入自己的日志系统或静默掉。
|
|
5
|
+
*/
|
|
6
|
+
interface Logger {
|
|
7
|
+
warn(message: string, ...args: unknown[]): void;
|
|
8
|
+
error(message: string, ...args: unknown[]): void;
|
|
9
|
+
}
|
|
10
|
+
/** executeBatch 的一条语句:纯 SQL 字符串,或带绑定参数的对象形式 */
|
|
11
|
+
type BatchStatement = string | {
|
|
12
|
+
sql: string;
|
|
13
|
+
params?: unknown[];
|
|
14
|
+
};
|
|
15
|
+
interface DbClient {
|
|
16
|
+
select<T>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
17
|
+
execute(sql: string, params?: unknown[]): Promise<{
|
|
18
|
+
lastInsertId?: number;
|
|
19
|
+
rowsAffected?: number;
|
|
20
|
+
}>;
|
|
21
|
+
/**
|
|
22
|
+
* 顺序执行一批语句,无跨语句事务保证(库目前不提供业务侧事务 API:连接池型适配器
|
|
23
|
+
* 无法保证 BEGIN/COMMIT 落在同一物理连接上,业务多语句写入请自行保证幂等)。
|
|
24
|
+
* 全部语句不带 params 时,web/memory 适配器会把它们拼成一条 SQL 一次性发给底层引擎
|
|
25
|
+
* (web 侧省掉每条一次的 worker 往返);只要批内有任何一条带参语句,整批退化为逐条执行。
|
|
26
|
+
* 注意:拼接路径失败时 DbExecutionError.sql 是拼接后的整条 SQL,无法指出失败的是第几句;
|
|
27
|
+
* 需要精确定位就自己逐条 execute()。
|
|
28
|
+
*/
|
|
29
|
+
executeBatch(statements: BatchStatement[]): Promise<void>;
|
|
30
|
+
close(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
interface DbAdapterConfig {
|
|
33
|
+
/** 数据库文件名/标识,如 "my-app"(不含扩展名) */
|
|
34
|
+
name: string;
|
|
35
|
+
}
|
|
36
|
+
interface DbAdapter {
|
|
37
|
+
/**
|
|
38
|
+
* 初始化并返回 client。并发或重复调用共享同一个进行中的初始化(去重),
|
|
39
|
+
* config 以首次调用为准;close() 之后再调用会重开一个全新连接。
|
|
40
|
+
*/
|
|
41
|
+
initialize(config: DbAdapterConfig): Promise<DbClient>;
|
|
42
|
+
/**
|
|
43
|
+
* 该适配器的每次 execute()/select() 调用是否保证落在同一条物理连接上。
|
|
44
|
+
* web/memory 适配器是单一持久连接,为 true;Tauri 适配器底层是
|
|
45
|
+
* sqlx::Pool<Sqlite> 连接池,不同调用可能拿到不同物理连接,为 false。
|
|
46
|
+
* 决定了自定义 MigrationExecutor 里手写的 BEGIN/COMMIT 是否安全。
|
|
47
|
+
*/
|
|
48
|
+
singleConnection: boolean;
|
|
49
|
+
}
|
|
50
|
+
interface Migration {
|
|
51
|
+
version: number;
|
|
52
|
+
statements: string[];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 迁移执行器。内部手写 BEGIN/COMMIT 的 executor 必须把 requiresSingleConnection 设为
|
|
56
|
+
* true(如 adapters/web 的 transactionalExecutor):createDbClient 会拒绝把它用在
|
|
57
|
+
* singleConnection !== true 的适配器上。完全不碰事务的 executor(比如只加日志)不需要
|
|
58
|
+
* 这个标记。
|
|
59
|
+
* 约定:executor 不要自行抛 DbMigrationError——失败时抛底层错误即可,version 由
|
|
60
|
+
* runMigrations 统一包装填充。
|
|
61
|
+
*/
|
|
62
|
+
interface MigrationExecutor {
|
|
63
|
+
(db: Pick<DbClient, "execute" | "select" | "executeBatch">, migration: Migration,
|
|
64
|
+
/** 记录 schema_version 的回调;executor 决定何时调用(比如放进自己的事务里) */
|
|
65
|
+
recordVersion: () => Promise<void>): Promise<void>;
|
|
66
|
+
/** true 表示该 executor 手写 BEGIN/COMMIT,仅可用于 singleConnection === true 的适配器 */
|
|
67
|
+
requiresSingleConnection?: boolean;
|
|
68
|
+
}
|
|
69
|
+
interface MigrationOptions {
|
|
70
|
+
/** schema 版本表名,默认 "schema_version";仅限字母/数字/下划线且不以数字开头 */
|
|
71
|
+
tableName?: string;
|
|
72
|
+
/** 自定义执行策略,见 MigrationExecutor */
|
|
73
|
+
executor?: MigrationExecutor;
|
|
74
|
+
/** 迁移过程中的诊断输出(如"数据库版本高于当前应用"告警),默认 console */
|
|
75
|
+
logger?: Logger;
|
|
76
|
+
}
|
|
77
|
+
interface CreateDbClientOptions {
|
|
78
|
+
name: string;
|
|
79
|
+
/** 必须显式传入实例,库不提供 'auto'/'web'/'tauri' 字符串快捷方式 */
|
|
80
|
+
adapter: DbAdapter;
|
|
81
|
+
migrations?: Migration[];
|
|
82
|
+
migrationOptions?: MigrationOptions;
|
|
83
|
+
/**
|
|
84
|
+
* initialize 之后、迁移之前执行的 PRAGMA 配置,如 { foreign_keys: true, journal_mode: "WAL" }。
|
|
85
|
+
* key 仅限字母/数字/下划线;string 值仅限字母/数字/下划线(覆盖 WAL、NORMAL 这类枚举值),
|
|
86
|
+
* boolean 会转成 0/1。非法的 key/value 会直接抛错而不是拼进 SQL。
|
|
87
|
+
*/
|
|
88
|
+
pragmas?: Record<string, string | number | boolean>;
|
|
89
|
+
/** 诊断输出通道,默认 console;被 migrationOptions.logger 覆盖(迁移阶段) */
|
|
90
|
+
logger?: Logger;
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
export { DbClient as a, MigrationExecutor as c, DbAdapterConfig as i, MigrationOptions as l, CreateDbClientOptions as n, Logger as o, DbAdapter as r, Migration as s, BatchStatement as t };
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cross-sqlite-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
5
6
|
"description": "Cross-platform (Web + Tauri) SQLite client with a shared migration framework and React bindings.",
|
|
6
7
|
"license": "MIT",
|
|
7
8
|
"author": "ueaner",
|
|
@@ -41,17 +42,12 @@
|
|
|
41
42
|
"./react": {
|
|
42
43
|
"types": "./dist/react/index.d.ts",
|
|
43
44
|
"import": "./dist/react/index.js"
|
|
44
|
-
}
|
|
45
|
+
},
|
|
46
|
+
"./package.json": "./package.json"
|
|
45
47
|
},
|
|
46
48
|
"files": [
|
|
47
49
|
"dist"
|
|
48
50
|
],
|
|
49
|
-
"scripts": {
|
|
50
|
-
"build": "tsdown",
|
|
51
|
-
"dev": "tsdown --watch",
|
|
52
|
-
"test": "vitest run",
|
|
53
|
-
"lint": "oxlint src test"
|
|
54
|
-
},
|
|
55
51
|
"peerDependencies": {
|
|
56
52
|
"react": ">=18.0.0"
|
|
57
53
|
},
|
|
@@ -65,10 +61,26 @@
|
|
|
65
61
|
"@tauri-apps/plugin-sql": "^2.4.1"
|
|
66
62
|
},
|
|
67
63
|
"devDependencies": {
|
|
64
|
+
"@changesets/cli": "^3.0.3",
|
|
65
|
+
"@testing-library/react": "^16.3.3",
|
|
68
66
|
"@types/react": "^19.2.18",
|
|
67
|
+
"@types/react-dom": "^19.3.0",
|
|
68
|
+
"jsdom": "^30.1.0",
|
|
69
|
+
"oxlint": "^1.83.0",
|
|
70
|
+
"publint": "^0.3.24",
|
|
69
71
|
"react": "^19.2.8",
|
|
72
|
+
"react-dom": "^19.3.0",
|
|
70
73
|
"tsdown": "^0.23.0",
|
|
71
74
|
"typescript": "^7.0.2",
|
|
72
75
|
"vitest": "^5.0.0"
|
|
76
|
+
},
|
|
77
|
+
"scripts": {
|
|
78
|
+
"build": "tsdown",
|
|
79
|
+
"dev": "tsdown --watch",
|
|
80
|
+
"test": "vitest run",
|
|
81
|
+
"typecheck": "tsc --noEmit",
|
|
82
|
+
"lint": "oxlint src test",
|
|
83
|
+
"pub:check": "publint",
|
|
84
|
+
"changeset": "changeset"
|
|
73
85
|
}
|
|
74
|
-
}
|
|
86
|
+
}
|