@aiao/rxdb-adapter-electron 0.0.0-stage → 0.0.28

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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +394 -2
  3. package/dist/RxDBAdapterElectron.d.ts +61 -0
  4. package/dist/RxDBAdapterElectron.d.ts.map +1 -0
  5. package/dist/electron-adapter.interface.d.ts +23 -0
  6. package/dist/electron-adapter.interface.d.ts.map +1 -0
  7. package/dist/electron-file-host.d.ts +52 -0
  8. package/dist/electron-file-host.d.ts.map +1 -0
  9. package/dist/electron-sqlite-host.d.ts +65 -0
  10. package/dist/electron-sqlite-host.d.ts.map +1 -0
  11. package/dist/host.d.ts +19 -0
  12. package/dist/host.d.ts.map +1 -0
  13. package/dist/host.js +768 -0
  14. package/dist/index.d.ts +22 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +36 -0
  17. package/dist/node-sqlite-engine.d.ts +97 -0
  18. package/dist/node-sqlite-engine.d.ts.map +1 -0
  19. package/dist/pglite/RxDBAdapterElectronPGlite.d.ts +96 -0
  20. package/dist/pglite/RxDBAdapterElectronPGlite.d.ts.map +1 -0
  21. package/dist/pglite/desktop-pglite-client.d.ts +144 -0
  22. package/dist/pglite/desktop-pglite-client.d.ts.map +1 -0
  23. package/dist/pglite/desktop-pglite-results.d.ts +14 -0
  24. package/dist/pglite/desktop-pglite-results.d.ts.map +1 -0
  25. package/dist/pglite/electron-pglite-backup-error.d.ts +32 -0
  26. package/dist/pglite/electron-pglite-backup-error.d.ts.map +1 -0
  27. package/dist/pglite/pglite-adapter.interface.d.ts +68 -0
  28. package/dist/pglite/pglite-adapter.interface.d.ts.map +1 -0
  29. package/dist/pglite/restore-electron-pglite-database.d.ts +68 -0
  30. package/dist/pglite/restore-electron-pglite-database.d.ts.map +1 -0
  31. package/dist/pglite-host/electron-pglite-host.d.ts +129 -0
  32. package/dist/pglite-host/electron-pglite-host.d.ts.map +1 -0
  33. package/dist/pglite-host/pglite-host-data-dir.d.ts +75 -0
  34. package/dist/pglite-host/pglite-host-data-dir.d.ts.map +1 -0
  35. package/dist/pglite-host/pglite-host-lock.d.ts +48 -0
  36. package/dist/pglite-host/pglite-host-lock.d.ts.map +1 -0
  37. package/dist/pglite-host/pglite-host-restore.d.ts +38 -0
  38. package/dist/pglite-host/pglite-host-restore.d.ts.map +1 -0
  39. package/dist/pglite-host/pglite-host-runtime.d.ts +90 -0
  40. package/dist/pglite-host/pglite-host-runtime.d.ts.map +1 -0
  41. package/dist/pglite-host.d.ts +17 -0
  42. package/dist/pglite-host.d.ts.map +1 -0
  43. package/dist/pglite-host.js +738 -0
  44. package/dist/pglite.d.ts +22 -0
  45. package/dist/pglite.d.ts.map +1 -0
  46. package/dist/pglite.js +493 -0
  47. package/dist/sqlite-script.d.ts +26 -0
  48. package/dist/sqlite-script.d.ts.map +1 -0
  49. package/package.json +94 -3
  50. package/src/RxDBAdapterElectron.ts +105 -0
  51. package/src/electron-adapter.interface.ts +25 -0
  52. package/src/electron-file-host.ts +758 -0
  53. package/src/electron-sqlite-host.ts +301 -0
  54. package/src/host.ts +64 -0
  55. package/src/index.ts +71 -0
  56. package/src/node-sqlite-engine.ts +631 -0
  57. package/src/pglite/RxDBAdapterElectronPGlite.ts +152 -0
  58. package/src/pglite/desktop-pglite-client.ts +614 -0
  59. package/src/pglite/desktop-pglite-results.ts +24 -0
  60. package/src/pglite/electron-pglite-backup-error.ts +128 -0
  61. package/src/pglite/pglite-adapter.interface.ts +78 -0
  62. package/src/pglite/restore-electron-pglite-database.ts +429 -0
  63. package/src/pglite-host/electron-pglite-host.ts +829 -0
  64. package/src/pglite-host/pglite-host-data-dir.ts +224 -0
  65. package/src/pglite-host/pglite-host-lock.ts +154 -0
  66. package/src/pglite-host/pglite-host-restore.ts +460 -0
  67. package/src/pglite-host/pglite-host-runtime.ts +166 -0
  68. package/src/pglite-host.ts +50 -0
  69. package/src/pglite.ts +51 -0
  70. package/src/sqlite-script.ts +134 -0
@@ -0,0 +1,631 @@
1
+ /**
2
+ * 基于 `node:sqlite` 的桌面 SQLite 引擎,运行在 host 侧(Electron 主进程 / worker)。
3
+ *
4
+ * @remarks
5
+ * `node:sqlite` 是**同步**接口,因此本类的所有方法都是同步的:一次 `execute()` 在返回前
6
+ * 已经跑完。异步语义留给上层的 host dispatcher,由它决定跑在哪个线程。
7
+ *
8
+ * @module node-sqlite-engine
9
+ */
10
+
11
+ import {
12
+ DEFAULT_BATCH_TIMEOUT,
13
+ DEFAULT_CACHE_SIZE_KB,
14
+ get_init_sql,
15
+ isReadOnlyStatement,
16
+ MAX_BATCH_WAIT_MS,
17
+ SQLiteChangeType,
18
+ validateSqliteNumericOption,
19
+ WATCH_TABLES,
20
+ type SqliteChangeEvent,
21
+ type SQLiteCompatibleType,
22
+ type SqliteData,
23
+ type SqliteResult
24
+ } from '@aiao/rxdb-adapter-sqlite-core';
25
+ import { RxDBAdapterDesktopError, type RxDBAdapterDesktopErrorCode } from '@aiao/rxdb-adapter-sqlite-core/desktop-host';
26
+ import { constants, DatabaseSync, type SQLInputValue, type SQLOutputValue, type StatementSync } from 'node:sqlite';
27
+ import { splitSqliteScript } from './sqlite-script.js';
28
+
29
+ /** {@link NodeSqliteEngine.open} 的入参。 */
30
+ export interface NodeSqliteEngineOptions {
31
+ /** 数据库文件的绝对物理路径,由 host 在应用数据目录内解析得出。 */
32
+ readonly filePath: string;
33
+ /** 逻辑数据库名,仅用于变更事件的诊断字段。 */
34
+ readonly dbName: string;
35
+ /**
36
+ * 变更事件回调。
37
+ *
38
+ * @remarks
39
+ * 在产生变更的语句返回**之后**的任务里调用(批处理见 {@link NodeSqliteEngineOptions.batchTimeout}),
40
+ * 唯一的例外是 {@link NodeSqliteEngine.close} 里的收尾 flush。实现方不该抛错——
41
+ * 此刻写入早已落库,没有任何调用方还能对失败做出反应;真抛了也只会被隔离并 log,
42
+ * 既不会中断同批次的其余分组,也不会传播给写入方。
43
+ */
44
+ readonly onChange: (event: SqliteChangeEvent) => void;
45
+ /** SQLite page cache 大小(KB),默认 {@link DEFAULT_CACHE_SIZE_KB}。 */
46
+ readonly cacheSizeKb?: number;
47
+ /**
48
+ * 变更事件的防抖窗口(毫秒),默认 {@link DEFAULT_BATCH_TIMEOUT}。
49
+ *
50
+ * @remarks
51
+ * 与 wasm 后端的同名选项同义:窗口内的连续写入合并成一次派发。0 表示下一个宏任务立即派发。
52
+ */
53
+ readonly batchTimeout?: number;
54
+ }
55
+
56
+ /** 注册给 SQLite 的通知函数名;触发器体内调用它把行变更捎回 JS。 */
57
+ const NOTIFY_FUNCTION_NAME = 'rxdb_desktop_notify';
58
+
59
+ /** TEMP 触发器名前缀,方便诊断时一眼认出是本适配器装的。 */
60
+ const TRIGGER_PREFIX = 'rxdb_desktop_notify';
61
+
62
+ const NOTIFY_OPERATIONS = [
63
+ { type: SQLiteChangeType.SQLITE_INSERT, event: 'INSERT', row: 'NEW' },
64
+ { type: SQLiteChangeType.SQLITE_UPDATE, event: 'UPDATE', row: 'NEW' },
65
+ { type: SQLiteChangeType.SQLITE_DELETE, event: 'DELETE', row: 'OLD' }
66
+ ] as const;
67
+
68
+ /** SQLite 主结果码 → 桌面错误码。未列出的一律算语句自身失败。 */
69
+ const SQLITE_ERROR_CODES = new Map<number, RxDBAdapterDesktopErrorCode>([
70
+ [3, 'permission_denied'], // SQLITE_PERM
71
+ [8, 'permission_denied'], // SQLITE_READONLY
72
+ [23, 'permission_denied'], // SQLITE_AUTH
73
+ [11, 'database_corrupted'], // SQLITE_CORRUPT
74
+ [26, 'database_corrupted'], // SQLITE_NOTADB
75
+ [14, 'open_failed'], // SQLITE_CANTOPEN
76
+ [5, 'database_busy'], // SQLITE_BUSY
77
+ [6, 'database_busy'] // SQLITE_LOCKED
78
+ ]);
79
+
80
+ /**
81
+ * 授权器拒绝的 opcode:会让 SQLite 自己再打开一个数据库文件的那些。
82
+ *
83
+ * @remarks
84
+ * host 只解析 `open` 请求里的逻辑库名,物理路径不受 renderer 控制;但 SQL 本身是透传的
85
+ * (`rawQuery()` 和事务内的 `execute()` 都要求这一点),`ATTACH DATABASE` 能让 SQLite 绕过
86
+ * 那次路径解析,直接按语句里的字面量打开任意可访问文件。`VACUUM INTO` 不含 ATTACH 关键字,
87
+ * 但 SQLite 同样走 {@link constants.SQLITE_ATTACH} 授权码,所以一并被这条规则挡住——
88
+ * 这正是必须用授权器而不是正则扫 SQL 的原因。
89
+ *
90
+ * 只封文件级 opcode,DDL/DML/事务/TEMP 触发器全部照旧放行,库内能力不受影响。
91
+ * PRAGMA 整体也放行,只按名字挑掉 {@link DENIED_PRAGMAS} 里那两条。
92
+ */
93
+ const DENIED_ACTION_CODES = new Set<number>([constants.SQLITE_ATTACH, constants.SQLITE_DETACH]);
94
+
95
+ /**
96
+ * 授权器拒绝的 pragma:会把 SQLite 的文件落点挪出宿主根的那两条。
97
+ *
98
+ * @remarks
99
+ * `temp_store_directory` 决定临时文件写在哪,`data_store_directory` 决定相对路径的库文件开在哪。
100
+ * 两条都绕开了 host 那次路径解析——与 {@link DENIED_ACTION_CODES} 拦 `ATTACH` 是同一个理由。
101
+ *
102
+ * 而且它们改的是 `sqlite3_temp_directory` / `sqlite3_data_directory` 这两个**进程级全局变量**,
103
+ * 不是连接级设置:一个会话设了,同进程里其余会话(别的窗口、别的库)全都跟着改。
104
+ * SQLite 官方把二者都标为已废弃且非线程安全。
105
+ *
106
+ * 读也一并拒掉:`PRAGMA temp_store_directory` 回给 renderer 的是一个宿主绝对路径。
107
+ *
108
+ * 名字**先转小写再**全等匹配。pragma 名在 SQL 里不区分大小写,但 SQLite 交给授权器的是
109
+ * 用户写的那个拼法:`PRAGMA TEMP_STORE_DIRECTORY` 传过来就是全大写,按原样比对等于没拦。
110
+ * 不做前缀判断:`temp_store`(初始化 SQL 自己要用)必须原样放行。
111
+ */
112
+ const DENIED_PRAGMAS = new Set<string>(['temp_store_directory', 'data_store_directory']);
113
+
114
+ /**
115
+ * 这次授权请求该不该拒。
116
+ *
117
+ * @param actionCode - SQLite 的授权动作码
118
+ * @param arg1 - 该动作码的第一个参数;`SQLITE_PRAGMA` 下是 pragma 名,其余动作码这里用不到
119
+ * @returns 拒绝为 `true`
120
+ */
121
+ const isDenied = (actionCode: number, arg1: string | null): boolean => {
122
+ if (DENIED_ACTION_CODES.has(actionCode)) return true;
123
+ return actionCode === constants.SQLITE_PRAGMA && arg1 !== null && DENIED_PRAGMAS.has(arg1.toLowerCase());
124
+ };
125
+
126
+ const readErrcode = (error: unknown): number | undefined => {
127
+ const errcode = (error as { errcode?: unknown }).errcode;
128
+ return typeof errcode === 'number' ? errcode : undefined;
129
+ };
130
+
131
+ const classify = (error: unknown, fallback: RxDBAdapterDesktopErrorCode): RxDBAdapterDesktopErrorCode => {
132
+ const errcode = readErrcode(error);
133
+ if (errcode === undefined) return fallback;
134
+ return SQLITE_ERROR_CODES.get(errcode) ?? fallback;
135
+ };
136
+
137
+ const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error));
138
+
139
+ /** 把绑定参数转成 `node:sqlite` 认识的形状;`number[]` 是 blob 的等价写法。 */
140
+ const toSupportedValue = (binding: SQLiteCompatibleType): SQLInputValue =>
141
+ Array.isArray(binding) ? Uint8Array.from(binding) : binding;
142
+
143
+ /**
144
+ * 校验通知函数收到的三个参数。
145
+ *
146
+ * @remarks
147
+ * 调用方只有本文件生成的触发器 SQL,形状对不上只可能是本文件自身的缺陷。宁可让这次写入失败,
148
+ * 也不能默默丢掉一个变更事件:丢事件会让 renderer 的缓存与库里的真实状态无声分叉。
149
+ */
150
+ const notifyArguments = (
151
+ type: SQLOutputValue,
152
+ tableName: SQLOutputValue,
153
+ rowId: SQLOutputValue
154
+ ): [SQLiteChangeType, string, bigint] => {
155
+ if (typeof type !== 'bigint' || typeof tableName !== 'string' || typeof rowId !== 'bigint') {
156
+ throw new RxDBAdapterDesktopError(
157
+ 'host_internal_error',
158
+ `the notify trigger passed (${typeof type}, ${typeof tableName}, ${typeof rowId}) instead of (bigint, string, bigint)`
159
+ );
160
+ }
161
+ return [Number(type) as SQLiteChangeType, tableName, rowId];
162
+ };
163
+
164
+ /**
165
+ * 把读回的值归一化成 `SQLiteCompatibleType`。
166
+ *
167
+ * @remarks
168
+ * 语句上开了 `setReadBigInts(true)`,否则超出 `Number.MAX_SAFE_INTEGER` 的整数会静默丢精度。
169
+ * 代价是**所有**整数都变成 `bigint`,连 rowid、`COUNT(*)` 也不例外,上层将被迫到处判类型。
170
+ * 因此这里把安全范围内的整数降回 `number`,与 sqlite-wasm 后端读到的类型保持一致——
171
+ * 同一份仓储代码在两个后端上必须看到同样的值。
172
+ */
173
+ const MIN_SAFE_BIGINT = BigInt(Number.MIN_SAFE_INTEGER);
174
+ const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
175
+
176
+ const normalizeValue = (value: unknown): SQLiteCompatibleType => {
177
+ if (typeof value !== 'bigint') return value as SQLiteCompatibleType;
178
+ return value >= MIN_SAFE_BIGINT && value <= MAX_SAFE_BIGINT ? Number(value) : value;
179
+ };
180
+
181
+ const normalizeRow = (row: readonly unknown[]): SQLiteCompatibleType[] => row.map(normalizeValue);
182
+
183
+ /** 读一条形如 `SELECT changes()` 的单值计数语句。 */
184
+ const readCounter = (statement: StatementSync): number => {
185
+ const [row] = statement.all() as unknown as number[][];
186
+ return Number(row?.[0] ?? 0);
187
+ };
188
+
189
+ const quoteIdentifier = (name: string): string => `"${name.replaceAll('"', '""')}"`;
190
+ const quoteLiteral = (value: string): string => `'${value.replaceAll("'", "''")}'`;
191
+
192
+ type BatchTimer = ReturnType<typeof setTimeout> & { unref?: () => void };
193
+
194
+ /**
195
+ * 启动一个不阻止宿主进程退出的定时器。
196
+ *
197
+ * @remarks
198
+ * 待发的变更通知不该把 Electron 主进程多吊住几十毫秒;正常关停路径由
199
+ * {@link NodeSqliteEngine.close} 的同步 flush 兜底,不依赖这个定时器跑到。
200
+ */
201
+ const startBatchTimer = (callback: () => void, delay: number): BatchTimer => {
202
+ const timer: BatchTimer = setTimeout(callback, delay);
203
+ timer.unref?.();
204
+ return timer;
205
+ };
206
+
207
+ /**
208
+ * 单个数据库连接。
209
+ *
210
+ * @remarks
211
+ * 一个会话一个连接,不共享:两个窗口若共用一条连接,它们的 `BEGIN` 块会互相穿插,
212
+ * 事务隔离直接失效。各自持有连接后,跨窗口的并发交给 SQLite 自己的文件锁与写者租约处理。
213
+ */
214
+ export class NodeSqliteEngine {
215
+ readonly #db: DatabaseSync;
216
+ readonly #dbName: string;
217
+ readonly #onChange: (event: SqliteChangeEvent) => void;
218
+ readonly #batchTimeout: number;
219
+ /** 已装好通知触发器、无需再查的系统表;写入口只有 {@link NodeSqliteEngine.#rememberWatched}。 */
220
+ readonly #watchedTables = new Set<string>();
221
+ /** 当前批次累积的行变更,按「变更类型 + 表」分组。 */
222
+ readonly #pendingChanges = new Map<string, { type: SQLiteChangeType; tableName: string; rowIds: bigint[] }>();
223
+ #batchTimer?: BatchTimer;
224
+ #maxWaitTimer?: BatchTimer;
225
+ #changesStatement?: StatementSync;
226
+ #totalChangesStatement?: StatementSync;
227
+ /** 为 `true` 时通知函数照常被触发器调用,但变更既不入批也不派发。 */
228
+ #muted = false;
229
+ #closed = false;
230
+
231
+ private constructor(db: DatabaseSync, options: NodeSqliteEngineOptions) {
232
+ this.#db = db;
233
+ this.#dbName = options.dbName;
234
+ this.#onChange = options.onChange;
235
+ this.#batchTimeout = validateSqliteNumericOption('batchTimeout', options.batchTimeout, DEFAULT_BATCH_TIMEOUT, {
236
+ allowZero: true
237
+ });
238
+ }
239
+
240
+ /**
241
+ * 打开(必要时创建)一个数据库文件。
242
+ *
243
+ * @remarks
244
+ * 打不开就抛错,**绝不**回退到内存库或另一个位置:用户必须能确信数据确实写进了
245
+ * 他指定的那个文件(AC#4)。初始化中途失败时会把已开的句柄关掉,不留悬挂连接。
246
+ *
247
+ * @param options - 引擎配置
248
+ * @returns 已完成初始化、已装好变更通知的引擎
249
+ * @throws {@link RxDBAdapterDesktopError} code 为 `open_failed` / `permission_denied` / `database_corrupted`
250
+ */
251
+ static open(options: NodeSqliteEngineOptions): NodeSqliteEngine {
252
+ let db: DatabaseSync;
253
+ try {
254
+ // defensive 显式钉住而不靠运行时默认值:host 在特权主进程里执行 renderer 透传的 SQL,
255
+ // 不能让它直接改写影子表或 schema 把库文件改坏。代价是恢复不了含影子表的库,备份侧因此整体拒绝这类库。
256
+ db = new DatabaseSync(options.filePath, { defensive: true });
257
+ } catch (error) {
258
+ throw new RxDBAdapterDesktopError(
259
+ classify(error, 'open_failed'),
260
+ `failed to open desktop database ${options.dbName}: ${messageOf(error)}`,
261
+ { cause: error }
262
+ );
263
+ }
264
+
265
+ try {
266
+ // 构造也在 try 里:选项校验失败同样必须把刚开的句柄关掉,否则文件被一直锁住。
267
+ const engine = new NodeSqliteEngine(db, options);
268
+ engine.#initialize(options.cacheSizeKb ?? DEFAULT_CACHE_SIZE_KB);
269
+ return engine;
270
+ } catch (error) {
271
+ db.close();
272
+ throw new RxDBAdapterDesktopError(
273
+ classify(error, 'open_failed'),
274
+ `failed to initialize desktop database ${options.dbName}: ${messageOf(error)}`,
275
+ { cause: error }
276
+ );
277
+ }
278
+ }
279
+
280
+ /**
281
+ * 执行一条或一组 SQL。
282
+ *
283
+ * @remarks
284
+ * 结果形状与 `executeOo1Helper` 逐字对齐(AC#2):至多一个结果集,且只在语句真的产出列时才有;
285
+ * 只读语句的 `rowsAffected` 恒为 0,不泄漏上一条写语句遗留的计数。
286
+ *
287
+ * 多语句脚本逐条 prepare 执行(切分见 {@link splitSqliteScript}),但**不接受绑定参数**:
288
+ * 参数属于其中某一条语句,静默绑到第一条只会把数据写错位;wasm 后端同样拒绝这个组合。
289
+ *
290
+ * @param sql - 待执行的 SQL
291
+ * @param bindings - 位置绑定参数
292
+ * @returns 与 sqlite 核心一致的执行结果
293
+ * @throws {@link RxDBAdapterDesktopError} 会话已关闭、多语句带参、或 SQL 执行失败时
294
+ */
295
+ execute(sql: string, bindings: readonly SQLiteCompatibleType[] = []): SqliteResult {
296
+ this.#assertOpen();
297
+ const startedAt = performance.now();
298
+ const statements = splitSqliteScript(sql);
299
+ if (statements.length > 1 && bindings.length > 0) {
300
+ throw new RxDBAdapterDesktopError(
301
+ 'protocol_violation',
302
+ `multi statement scripts cannot carry bindings, got ${bindings.length} for SQL "${sql}"`
303
+ );
304
+ }
305
+
306
+ // 前一次是为别的连接刚建好的系统表补装触发器,后一次是为本条语句自己建的表补装。
307
+ this.#ensureNotifyTriggers();
308
+ const { results, rowsAffected } = this.#runStatements(statements, bindings);
309
+ this.#ensureNotifyTriggers();
310
+ return { sql, rowsAffected, elapsed: performance.now() - startedAt, results };
311
+ }
312
+
313
+ /**
314
+ * 报告底层 SQLite 引擎版本。
315
+ *
316
+ * @returns 形如 `3.50.4` 的版本串
317
+ */
318
+ version(): string {
319
+ this.#assertOpen();
320
+ const [row] = this.#runSingleStatement('SELECT sqlite_version()', []);
321
+ return String(row?.rows[0]?.[0]);
322
+ }
323
+
324
+ /**
325
+ * 暂停 / 恢复变更事件采集。
326
+ *
327
+ * @remarks
328
+ * 恢复整库时静音(US-217):恢复写进去的系统表行不是新变更。静音前已经攒下的批次不丢,
329
+ * 照常按定时器发出——它们记录的是静音前真实发生的写入。
330
+ *
331
+ * @param muted - `true` 时触发器报上来的变更直接丢弃,不入批也不派发
332
+ */
333
+ setChangeEventsMuted(muted: boolean): void {
334
+ this.#assertOpen();
335
+ this.#muted = muted;
336
+ }
337
+
338
+ /**
339
+ * 断开连接并释放文件句柄。
340
+ *
341
+ * @remarks
342
+ * 关闭前回滚尚未提交的事务并做一次 TRUNCATE checkpoint(AC#7):前者避免把半截状态留给下次启动,
343
+ * 后者把 WAL 内容并回主库文件,使调用方随后可以直接重命名/备份这个 `.sqlite3`,
344
+ * 而不必额外搬运 `-wal` / `-shm` 旁文件。
345
+ *
346
+ * 还攒在批次里的变更事件在这里**同步**发掉,与 `Oo1ClientBase.disconnect` 一致:
347
+ * 定时器随连接一起消失,不发就等于把最后一批写入的通知悄悄吞掉。
348
+ *
349
+ * 重复调用是安全的。
350
+ */
351
+ close(): void {
352
+ if (this.#closed) return;
353
+ this.#closed = true;
354
+ this.#flushNow();
355
+ try {
356
+ this.#rollbackOpenTransaction();
357
+ this.#db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
358
+ } finally {
359
+ this.#changesStatement = undefined;
360
+ this.#totalChangesStatement = undefined;
361
+ this.#db.close();
362
+ }
363
+ }
364
+
365
+ /**
366
+ * 装好变更通知函数并跑一遍初始化 pragma。
367
+ *
368
+ * @remarks
369
+ * 这里**故意不设** `PRAGMA busy_timeout`:`node:sqlite` 是同步接口,等锁就是同步自旋,
370
+ * 而桌面路径上所有窗口的连接都活在 Electron 主进程的同一条线程上。持锁那个窗口的
371
+ * `COMMIT` 是一个还排在事件循环里的 JS 续体——线程被 busy_timeout 占住,它就永远轮不到,
372
+ * 于是必然等满整个超时再失败,中途整个应用还是冻的。撞锁只能在**异步**层面退让重试,
373
+ * 见 `createElectronSqliteHost` 里的 busy 重试。
374
+ */
375
+ #initialize(cacheSizeKb: number): void {
376
+ // 先装授权器再跑任何 SQL:初始化本身也走同一条边界,不给「初始化期间不设防」留窗口。
377
+ this.#db.setAuthorizer((actionCode, arg1) =>
378
+ isDenied(actionCode, arg1) ? constants.SQLITE_DENY : constants.SQLITE_OK
379
+ );
380
+ this.#db.function(
381
+ NOTIFY_FUNCTION_NAME,
382
+ // directOnly:false 是关键——默认值会禁止在触发器体内调用本函数。
383
+ // useBigIntArguments:true 保证 rowid 原样送达,不会在 2^53 处丢位。
384
+ // varargs:false 时 node 用 fn.length 决定形参个数,因此这三个形参必须写全,不能改成 rest。
385
+ { deterministic: false, directOnly: false, useBigIntArguments: true, varargs: false },
386
+ (type: SQLOutputValue, tableName: SQLOutputValue, rowId: SQLOutputValue): null => {
387
+ this.#recordChange(...notifyArguments(type, tableName, rowId));
388
+ return null;
389
+ }
390
+ );
391
+ // 单文件数据库始终按持久化档位配置:WAL + synchronous=NORMAL。
392
+ this.#db.exec(get_init_sql(cacheSizeKb, true));
393
+ this.#ensureNotifyTriggers();
394
+ }
395
+
396
+ #assertOpen(): void {
397
+ if (this.#closed) {
398
+ throw new RxDBAdapterDesktopError('session_closed', `desktop database ${this.#dbName} is already closed`);
399
+ }
400
+ }
401
+
402
+ #runSingleStatement(sql: string, bindings: readonly SQLiteCompatibleType[]): SqliteData[] {
403
+ try {
404
+ const statement = this.#db.prepare(sql);
405
+ statement.setReturnArrays(true);
406
+ statement.setReadBigInts(true);
407
+ const rows = statement.all(...bindings.map(toSupportedValue)) as unknown as unknown[][];
408
+ const columns = statement.columns().map(column => column.name);
409
+ return columns.length > 0 ? [{ columns, rows: rows.map(normalizeRow) }] : [];
410
+ } catch (error) {
411
+ throw this.#statementError(sql, error);
412
+ }
413
+ }
414
+
415
+ /**
416
+ * 逐条执行脚本里的语句,只保留**第一条产出行**的语句的结果集。
417
+ *
418
+ * @remarks
419
+ * 「只留第一条」是照着 oo1 的 `db.exec` 抄的:它遇到第一个 `columnCount > 0` 的语句后就
420
+ * 不再收集,因此 wasm 后端的一次 `execute()` 至多返回一个 {@link SqliteData}。
421
+ * 桌面端与之对齐,上层才不必分后端处理。
422
+ *
423
+ * 走到这里时 `bindings` 非空即意味着只有一条语句(多语句带参已在 {@link NodeSqliteEngine.execute} 拒绝)。
424
+ *
425
+ * 影响行数**逐条累加**,与 wasm 后端 `execute_helper.ts` 的
426
+ * `rowsAffected += sqlite3.changes(db)` 同形:`changes()` 只反映最后一条语句,
427
+ * 拿它当整个脚本的答案,`DELETE a; DELETE b;` 会漏掉前一条。
428
+ */
429
+ #runStatements(
430
+ statements: readonly string[],
431
+ bindings: readonly SQLiteCompatibleType[]
432
+ ): { results: SqliteData[]; rowsAffected: number } {
433
+ const results: SqliteData[] = [];
434
+ let rowsAffected = 0;
435
+ for (const statement of statements) {
436
+ const totalChangesBefore = this.#totalChanges();
437
+ const data = this.#runSingleStatement(statement, bindings);
438
+ rowsAffected += this.#statementRowsAffected(statement, totalChangesBefore);
439
+ if (results.length === 0) results.push(...data);
440
+ }
441
+ return { results, rowsAffected };
442
+ }
443
+
444
+ /**
445
+ * 单条语句影响的行数。
446
+ *
447
+ * @remarks
448
+ * `changes()` 只被 INSERT / UPDATE / DELETE 重置。任何别的语句跑完,它仍然是**上一条写语句**
449
+ * 留下的数字——SQLC-030 记的就是这类事故。只读语句由 {@link isReadOnlyStatement} 挡掉;
450
+ * DDL、PRAGMA、`BEGIN` / `COMMIT` 这些既不是只读、又不改行的语句挡不住,于是再用
451
+ * `total_changes()` 兜一层:它单调递增且只记真正改动的行,一条语句前后不变就说明它一行没动,
452
+ * 此时 `changes()` 必然是遗留值。
453
+ *
454
+ * 兜底只用来判断「动没动」,报出去的仍是 `changes()`:两者对触发器与外键级联的口径不同
455
+ * (`total_changes()` 把它们算进去,`changes()` 不算),换成差值会悄悄改变级联删除的返回值。
456
+ *
457
+ * @param sql - 单条语句
458
+ * @param totalChangesBefore - 执行前的 `total_changes()`
459
+ * @returns 该语句影响的行数
460
+ */
461
+ #statementRowsAffected(sql: string, totalChangesBefore: number): number {
462
+ if (isReadOnlyStatement(sql) || this.#totalChanges() === totalChangesBefore) return 0;
463
+ return this.#changes();
464
+ }
465
+
466
+ #statementError(sql: string, error: unknown): RxDBAdapterDesktopError {
467
+ return new RxDBAdapterDesktopError(
468
+ classify(error, 'statement_failed'),
469
+ `desktop database ${this.#dbName} failed for SQL "${sql}": ${messageOf(error)}`,
470
+ { cause: error }
471
+ );
472
+ }
473
+
474
+ #changes(): number {
475
+ this.#changesStatement ??= this.#prepareCounterStatement('SELECT changes()');
476
+ return readCounter(this.#changesStatement);
477
+ }
478
+
479
+ #totalChanges(): number {
480
+ this.#totalChangesStatement ??= this.#prepareCounterStatement('SELECT total_changes()');
481
+ return readCounter(this.#totalChangesStatement);
482
+ }
483
+
484
+ #prepareCounterStatement(sql: string): StatementSync {
485
+ const statement = this.#db.prepare(sql);
486
+ statement.setReturnArrays(true);
487
+ return statement;
488
+ }
489
+
490
+ #recordChange(type: SQLiteChangeType, tableName: string, rowId: bigint): void {
491
+ if (this.#muted) return;
492
+ const key = `${type} ${tableName}`;
493
+ const pending = this.#pendingChanges.get(key);
494
+ if (pending) pending.rowIds.push(rowId);
495
+ else this.#pendingChanges.set(key, { type, tableName, rowIds: [rowId] });
496
+ this.#scheduleFlush();
497
+ }
498
+
499
+ /**
500
+ * 防抖 + 硬上限调度,与 `Oo1ClientBase.#schedule_batch_send` 同一套语义。
501
+ *
502
+ * @remarks
503
+ * 本方法是在**触发器体内**被调用的——此刻语句还在执行,事务还开着,行还没提交。
504
+ * 早先在 `execute()` 末尾同步 flush,订阅者因此会在提交前就被叫醒去回查这些 rowid,
505
+ * 回查请求排在写入之后,事件相对后续写入整体晚一拍;wasm 后端从来是把事件推到之后的
506
+ * 宏任务里的,桌面端跟着做,同一份上层代码在两个后端上才会有同样的时序(AC#2)。
507
+ *
508
+ * `#batchTimer` 每来一个事件就重置(合并连续写入),`#maxWaitTimer` 只在批次首个事件时
509
+ * 启动一次且不再重置,保证持续写入下最多 {@link MAX_BATCH_WAIT_MS} 必定强制发一次。
510
+ */
511
+ #scheduleFlush(): void {
512
+ if (this.#batchTimer) clearTimeout(this.#batchTimer);
513
+ this.#batchTimer = startBatchTimer(() => this.#flushNow(), this.#batchTimeout);
514
+ this.#maxWaitTimer ??= startBatchTimer(() => this.#flushNow(), MAX_BATCH_WAIT_MS);
515
+ }
516
+
517
+ #flushNow(): void {
518
+ this.#clearBatchTimers();
519
+ this.#flushChanges();
520
+ }
521
+
522
+ #clearBatchTimers(): void {
523
+ if (this.#batchTimer) clearTimeout(this.#batchTimer);
524
+ if (this.#maxWaitTimer) clearTimeout(this.#maxWaitTimer);
525
+ this.#batchTimer = undefined;
526
+ this.#maxWaitTimer = undefined;
527
+ }
528
+
529
+ /**
530
+ * 派发并清空当前批次,与 `Oo1ClientBase.#flush_pending_events` 同一套语义。
531
+ *
532
+ * @remarks
533
+ * 队列必须在派发**之前**原子换出:留到派发之后再清,任一监听器抛错都会把整批留在
534
+ * `#pendingChanges` 里,下次 flush 连同新事件一起重发,上层把同一条变更处理两遍。
535
+ *
536
+ * 回调异常逐分组隔离并 log:调用点是定时器回调,异常逃出去就是主进程的未捕获异常,
537
+ * 一个订阅者的 bug 会连带整个 Electron 应用一起崩,而此刻写入其实早已落库。
538
+ */
539
+ #flushChanges(): void {
540
+ if (this.#pendingChanges.size === 0) return;
541
+ const batch = [...this.#pendingChanges.values()];
542
+ this.#pendingChanges.clear();
543
+ const recordAt = new Date();
544
+ for (const { type, tableName, rowIds } of batch) {
545
+ try {
546
+ this.#onChange({ type, dbName: 'main', tableName, rowIds, recordAt });
547
+ } catch (error) {
548
+ console.error(`[NodeSqliteEngine] change event listener failed for ${tableName}:`, error);
549
+ }
550
+ }
551
+ }
552
+
553
+ /**
554
+ * 为已经存在的系统表补装 TEMP 通知触发器。
555
+ *
556
+ * @remarks
557
+ * 会话打开时这些表往往还不存在——它们由适配器初始化过程现建,所以触发器只能**惰性**安装。
558
+ * 建表方可能是本连接,也可能是另一个窗口的连接,因此每条语句前后各检查一次:
559
+ * 只在语句后检查会漏掉本连接开启前、由别的连接建好的表上的第一次写入。
560
+ * 三张表全部装好后本方法直接返回,稳态下不再查 `sqlite_master`。
561
+ *
562
+ * 已知边界:同一条多语句脚本里先 `CREATE` 系统表再往里写,这次写入观察不到。
563
+ * 适配器的建表与写入分属不同 `execute()`,不会踩到。
564
+ *
565
+ * 用 TEMP 触发器而非普通触发器:TEMP 对象只活在本连接的 temp schema 里,永远不会写进用户的
566
+ * 库文件,因此不会污染他自己的 schema(AC#6),也不会被别的程序看见。
567
+ */
568
+ #ensureNotifyTriggers(): void {
569
+ if (this.#watchedTables.size === WATCH_TABLES.size) return;
570
+ const watched = [...WATCH_TABLES];
571
+ const placeholders = watched.map(() => '?').join(', ');
572
+ const statement = this.#db.prepare(
573
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`
574
+ );
575
+ statement.setReturnArrays(true);
576
+ const existing = statement.all(...watched) as unknown as string[][];
577
+ for (const [tableName] of existing) {
578
+ if (this.#watchedTables.has(tableName)) continue;
579
+ this.#createNotifyTriggers(tableName);
580
+ this.#rememberWatched(tableName);
581
+ }
582
+ }
583
+
584
+ /**
585
+ * 把「这张表的触发器已装好」记成不必再查的事实。
586
+ *
587
+ * @remarks
588
+ * 只在**事务外**才记:temp schema 与主库共用同一个事务,事务内装的 TEMP 触发器会随回滚一起
589
+ * 消失,而集合里的名字不会——`#ensureNotifyTriggers` 此后一路 `continue` 跳过这张表,
590
+ * 它的变更事件从此静默丢失。适配器的建表脚本正跑在 `BEGIN IMMEDIATE` / `COMMIT` 之间,
591
+ * COMMIT 失败(磁盘满 / IO)后同一会话会重试,这条路径是可达的。
592
+ *
593
+ * 代价是事务期间每条语句都重发一次 `CREATE TEMP TRIGGER IF NOT EXISTS`:幂等、且只在
594
+ * 「表已建好但还没定型」这段窗口里发生——也就是建表的那个事务内部。事务一结束,
595
+ * 下一次 `#ensureNotifyTriggers` 就把它定型,稳态重新回到提前返回。
596
+ *
597
+ * 不改用 rollback hook 校对:`node:sqlite` 不暴露它。也不每次查 `sqlite_temp_master` 对账:
598
+ * 那会把稳态下的零查询变成每条语句一次查询,而这里要挡的只是一个窗口。
599
+ *
600
+ * 已知边界:`DROP TABLE` 同样会带走表上的 TEMP 触发器,本方法不覆盖这种情况——
601
+ * 系统表建了就不再删,代码里没有任何一处 drop 它们。
602
+ */
603
+ #rememberWatched(tableName: string): void {
604
+ if (!this.#db.isTransaction) this.#watchedTables.add(tableName);
605
+ }
606
+
607
+ #createNotifyTriggers(tableName: string): void {
608
+ const table = quoteIdentifier(tableName);
609
+ const literal = quoteLiteral(tableName);
610
+ for (const { type, event, row } of NOTIFY_OPERATIONS) {
611
+ const trigger = quoteIdentifier(`${TRIGGER_PREFIX}$${tableName}$${event}`);
612
+ this.#db.exec(
613
+ `CREATE TEMP TRIGGER IF NOT EXISTS ${trigger} AFTER ${event} ON ${table} ` +
614
+ `BEGIN SELECT ${NOTIFY_FUNCTION_NAME}(${type}, ${literal}, ${row}.rowid); END;`
615
+ );
616
+ }
617
+ }
618
+
619
+ /**
620
+ * 回滚尚未提交的事务;没有事务时什么都不做。
621
+ *
622
+ * @remarks
623
+ * 先问状态再决定发不发 `ROLLBACK`,而不是无条件发了再按 SQLite 的报错文案豁免——
624
+ * 那句文案是实现细节,SQLite 改一个字这里就会把「没有事务」当成真故障抛出去,
625
+ * 而这是关闭路径上最常见的正常情况。`isTransaction` 包的是
626
+ * `sqlite3_get_autocommit()`,是同一个判据的稳定问法。
627
+ */
628
+ #rollbackOpenTransaction(): void {
629
+ if (this.#db.isTransaction) this.#db.exec('ROLLBACK');
630
+ }
631
+ }