@xnetjs/sqlite 0.0.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{adapter-I7yAV6iu.d.ts → adapter-imtxkmXZ.d.ts} +48 -1
- package/dist/adapters/electron.d.ts +70 -6
- package/dist/adapters/electron.js +662 -36
- package/dist/adapters/expo.d.ts +6 -2
- package/dist/adapters/expo.js +34 -6
- package/dist/adapters/memory.d.ts +6 -2
- package/dist/adapters/memory.js +8 -1
- package/dist/adapters/reader-thread.d.ts +78 -0
- package/dist/adapters/reader-thread.js +57 -0
- package/dist/adapters/web-proxy.d.ts +71 -3
- package/dist/adapters/web-proxy.js +554 -39
- package/dist/adapters/web-router-worker.d.ts +76 -0
- package/dist/adapters/web-router-worker.js +91 -0
- package/dist/adapters/web-worker.d.ts +56 -1
- package/dist/adapters/web-worker.js +253 -14
- package/dist/adapters/web.d.ts +90 -3
- package/dist/adapters/web.js +10 -4
- package/dist/browser-support.d.ts +65 -1
- package/dist/browser-support.js +15 -3
- package/dist/chunk-5HC5V73T.js +191 -0
- package/dist/chunk-BBZDKLA3.js +727 -0
- package/dist/chunk-CBU263LI.js +30 -0
- package/dist/chunk-CGI2YBZY.js +16 -0
- package/dist/chunk-S6MT6KCI.js +279 -0
- package/dist/chunk-SV475UL5.js +44 -0
- package/dist/chunk-W3L4FXU5.js +415 -0
- package/dist/index.d.ts +111 -8
- package/dist/index.js +219 -130
- package/dist/schema-C4gufY3B.d.ts +35 -0
- package/dist/types-BTabr_VP.d.ts +225 -0
- package/dist/worker-scheduler-D04DqMmR.d.ts +56 -0
- package/package.json +5 -1
- package/dist/chunk-BXYZU3OL.js +0 -245
- package/dist/chunk-HIREU5S5.js +0 -193
- package/dist/chunk-ZRR5D2OD.js +0 -140
- package/dist/schema-CjkXTqxn.d.ts +0 -35
- package/dist/types-C_aHfRDF.d.ts +0 -42
|
@@ -1,9 +1,159 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WorkerScheduler
|
|
3
|
+
} from "../chunk-5HC5V73T.js";
|
|
4
|
+
import {
|
|
5
|
+
isSQLiteCorruptionError
|
|
6
|
+
} from "../chunk-CBU263LI.js";
|
|
1
7
|
import {
|
|
2
8
|
SCHEMA_DDL,
|
|
3
9
|
SCHEMA_VERSION
|
|
4
|
-
} from "../chunk-
|
|
10
|
+
} from "../chunk-W3L4FXU5.js";
|
|
11
|
+
|
|
12
|
+
// src/adapters/reader-pool.ts
|
|
13
|
+
function isHeavyRead(sql) {
|
|
14
|
+
return /\bMATCH\b|\bGROUP\s+BY\b|\bCOUNT\s*\(|\bSUM\s*\(|\bAVG\s*\(|\bjson_extract\s*\(|\bORDER\s+BY\b[\s\S]*\bLIMIT\b\s*\d{3,}/i.test(
|
|
15
|
+
sql
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
function resolveReaderPoolSize(size, dbPath, availableCores) {
|
|
19
|
+
if (dbPath === ":memory:" || dbPath === "") return 0;
|
|
20
|
+
if (size === void 0 || size === 0) return 0;
|
|
21
|
+
if (size === "auto") return Math.max(0, Math.min(4, availableCores - 2));
|
|
22
|
+
return Math.max(0, Math.floor(size));
|
|
23
|
+
}
|
|
24
|
+
var ReaderPool = class {
|
|
25
|
+
readers = [];
|
|
26
|
+
pending = /* @__PURE__ */ new Map();
|
|
27
|
+
seq = 0;
|
|
28
|
+
dispatched = 0;
|
|
29
|
+
failures = 0;
|
|
30
|
+
closed = false;
|
|
31
|
+
constructor(options) {
|
|
32
|
+
const create = options.createWorker;
|
|
33
|
+
for (let i = 0; i < options.size; i++) {
|
|
34
|
+
let worker;
|
|
35
|
+
try {
|
|
36
|
+
worker = create(i);
|
|
37
|
+
} catch {
|
|
38
|
+
this.failures++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const reader = { index: i, worker, inFlight: 0, healthy: true };
|
|
42
|
+
worker.on("message", (msg) => this.onMessage(reader, msg));
|
|
43
|
+
worker.on("error", () => this.onReaderDown(reader));
|
|
44
|
+
worker.on("exit", (code) => {
|
|
45
|
+
if (code !== 0) this.onReaderDown(reader);
|
|
46
|
+
});
|
|
47
|
+
this.readers.push(reader);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Whether at least one reader can currently serve requests. */
|
|
51
|
+
isHealthy() {
|
|
52
|
+
return !this.closed && this.readers.some((r) => r.healthy);
|
|
53
|
+
}
|
|
54
|
+
/** Run a SELECT on the least-busy healthy reader. Rejects if none are healthy. */
|
|
55
|
+
query(sql, params) {
|
|
56
|
+
return this.dispatch({ op: "query", sql, params }).then((res) => {
|
|
57
|
+
if (res.ok && "rows" in res) return res.rows;
|
|
58
|
+
throw new Error(res.ok ? "reader returned no rows" : res.error);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** Run a single-row SELECT on the least-busy healthy reader. */
|
|
62
|
+
queryOne(sql, params) {
|
|
63
|
+
return this.dispatch({ op: "queryOne", sql, params }).then((res) => {
|
|
64
|
+
if (res.ok && "row" in res) return res.row;
|
|
65
|
+
throw new Error(res.ok ? "reader returned no row" : res.error);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
stats() {
|
|
69
|
+
return {
|
|
70
|
+
size: this.readers.length,
|
|
71
|
+
healthy: this.readers.filter((r) => r.healthy).length,
|
|
72
|
+
inFlight: this.pending.size,
|
|
73
|
+
dispatched: this.dispatched,
|
|
74
|
+
failures: this.failures
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async close() {
|
|
78
|
+
this.closed = true;
|
|
79
|
+
for (const reader of this.readers) {
|
|
80
|
+
reader.healthy = false;
|
|
81
|
+
try {
|
|
82
|
+
await reader.worker.terminate();
|
|
83
|
+
} catch {
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const [, p] of this.pending) {
|
|
87
|
+
p.reject(new Error("reader pool closed"));
|
|
88
|
+
}
|
|
89
|
+
this.pending.clear();
|
|
90
|
+
}
|
|
91
|
+
dispatch(req) {
|
|
92
|
+
const reader = this.pickLeastBusy();
|
|
93
|
+
if (!reader) return Promise.reject(new Error("no healthy reader available"));
|
|
94
|
+
const id = ++this.seq;
|
|
95
|
+
const message = { id, ...req };
|
|
96
|
+
reader.inFlight++;
|
|
97
|
+
this.dispatched++;
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
this.pending.set(id, { resolve, reject, reader });
|
|
100
|
+
try {
|
|
101
|
+
reader.worker.postMessage(message);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
this.pending.delete(id);
|
|
104
|
+
reader.inFlight--;
|
|
105
|
+
this.onReaderDown(reader);
|
|
106
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
pickLeastBusy() {
|
|
111
|
+
let best = null;
|
|
112
|
+
for (const reader of this.readers) {
|
|
113
|
+
if (!reader.healthy) continue;
|
|
114
|
+
if (!best || reader.inFlight < best.inFlight) best = reader;
|
|
115
|
+
}
|
|
116
|
+
return best;
|
|
117
|
+
}
|
|
118
|
+
onMessage(reader, msg) {
|
|
119
|
+
if (msg.id === 0 && "ready" in msg) {
|
|
120
|
+
if (!msg.ready) this.onReaderDown(reader);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const pending = this.pending.get(msg.id);
|
|
124
|
+
if (!pending) return;
|
|
125
|
+
this.pending.delete(msg.id);
|
|
126
|
+
reader.inFlight = Math.max(0, reader.inFlight - 1);
|
|
127
|
+
pending.resolve(msg);
|
|
128
|
+
}
|
|
129
|
+
onReaderDown(reader) {
|
|
130
|
+
if (!reader.healthy) return;
|
|
131
|
+
reader.healthy = false;
|
|
132
|
+
this.failures++;
|
|
133
|
+
for (const [id, p] of this.pending) {
|
|
134
|
+
if (p.reader === reader) {
|
|
135
|
+
this.pending.delete(id);
|
|
136
|
+
p.reject(new Error(`reader ${reader.index} is unavailable`));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
function workerThreadsFactory(WorkerCtor, dbPath) {
|
|
142
|
+
const scriptUrl = new URL("./reader-thread.js", import.meta.url);
|
|
143
|
+
return () => new WorkerCtor(scriptUrl, { workerData: { dbPath } });
|
|
144
|
+
}
|
|
5
145
|
|
|
6
146
|
// src/adapters/electron.ts
|
|
147
|
+
function readKey(op, sql, params) {
|
|
148
|
+
return `${op} ${sql} ${params ? JSON.stringify(params) : ""}`;
|
|
149
|
+
}
|
|
150
|
+
var APPLY_NODE_BATCH_CHUNK_ROWS = 250;
|
|
151
|
+
function nowMs() {
|
|
152
|
+
return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
153
|
+
}
|
|
154
|
+
function yieldToEventLoop() {
|
|
155
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
156
|
+
}
|
|
7
157
|
var DatabaseConstructor = null;
|
|
8
158
|
async function getBetterSqlite3() {
|
|
9
159
|
if (DatabaseConstructor) return DatabaseConstructor;
|
|
@@ -13,10 +163,19 @@ async function getBetterSqlite3() {
|
|
|
13
163
|
}
|
|
14
164
|
var ElectronSQLiteAdapter = class {
|
|
15
165
|
db = null;
|
|
166
|
+
/** Optional read-only secondary connection (exploration 0230, Phase 0.5). */
|
|
167
|
+
readDb = null;
|
|
16
168
|
config = null;
|
|
17
169
|
inTransaction = false;
|
|
18
|
-
// Cached prepared statements for performance
|
|
170
|
+
// Cached prepared statements for performance (per connection)
|
|
19
171
|
statementCache = /* @__PURE__ */ new Map();
|
|
172
|
+
readStatementCache = /* @__PURE__ */ new Map();
|
|
173
|
+
/** Priority scheduler fronting the writer connection (exploration 0230). */
|
|
174
|
+
scheduler = null;
|
|
175
|
+
/** Read-only reader-thread pool for heavy parallel reads (Phase 1). */
|
|
176
|
+
readerPool = null;
|
|
177
|
+
/** Monotonic clock of the most recent commit, for read-your-writes routing. */
|
|
178
|
+
lastCommitAt = 0;
|
|
20
179
|
async open(config) {
|
|
21
180
|
if (this.db) {
|
|
22
181
|
throw new Error("Database already open. Call close() first.");
|
|
@@ -38,10 +197,58 @@ var ElectronSQLiteAdapter = class {
|
|
|
38
197
|
this.db.pragma("synchronous = NORMAL");
|
|
39
198
|
this.db.pragma("cache_size = -64000");
|
|
40
199
|
this.db.pragma("temp_store = MEMORY");
|
|
200
|
+
try {
|
|
201
|
+
this.db.pragma("analysis_limit = 400");
|
|
202
|
+
this.db.pragma("optimize = 0x10002");
|
|
203
|
+
} catch {
|
|
204
|
+
}
|
|
205
|
+
if (config.scheduler !== false) {
|
|
206
|
+
this.scheduler = new WorkerScheduler();
|
|
207
|
+
}
|
|
208
|
+
const fileBacked = config.path !== ":memory:" && config.path !== "";
|
|
209
|
+
if (config.readonlyReadConnection && fileBacked) {
|
|
210
|
+
try {
|
|
211
|
+
this.readDb = new Database(config.path, { readonly: true, fileMustExist: true });
|
|
212
|
+
this.readDb.pragma("busy_timeout = 5000");
|
|
213
|
+
this.readDb.pragma("cache_size = -32000");
|
|
214
|
+
this.readDb.pragma("temp_store = MEMORY");
|
|
215
|
+
} catch {
|
|
216
|
+
this.readDb = null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (fileBacked && config.readerPoolSize) {
|
|
220
|
+
try {
|
|
221
|
+
const os = await import("os");
|
|
222
|
+
const cores = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length;
|
|
223
|
+
const poolSize = resolveReaderPoolSize(config.readerPoolSize, config.path, cores);
|
|
224
|
+
if (poolSize > 0) {
|
|
225
|
+
const { Worker } = await import("worker_threads");
|
|
226
|
+
this.readerPool = new ReaderPool({
|
|
227
|
+
dbPath: config.path,
|
|
228
|
+
size: poolSize,
|
|
229
|
+
createWorker: workerThreadsFactory(Worker, config.path)
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
this.readerPool = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
41
236
|
}
|
|
42
237
|
async close() {
|
|
43
238
|
if (!this.db) return;
|
|
44
239
|
this.statementCache.clear();
|
|
240
|
+
this.readStatementCache.clear();
|
|
241
|
+
if (this.readerPool) {
|
|
242
|
+
await this.readerPool.close();
|
|
243
|
+
this.readerPool = null;
|
|
244
|
+
}
|
|
245
|
+
if (this.readDb) {
|
|
246
|
+
try {
|
|
247
|
+
this.readDb.close();
|
|
248
|
+
} catch {
|
|
249
|
+
}
|
|
250
|
+
this.readDb = null;
|
|
251
|
+
}
|
|
45
252
|
if (this.config?.walMode !== false) {
|
|
46
253
|
try {
|
|
47
254
|
this.db.pragma("wal_checkpoint(TRUNCATE)");
|
|
@@ -51,50 +258,79 @@ var ElectronSQLiteAdapter = class {
|
|
|
51
258
|
this.db.close();
|
|
52
259
|
this.db = null;
|
|
53
260
|
this.config = null;
|
|
261
|
+
this.scheduler = null;
|
|
262
|
+
this.inTransaction = false;
|
|
54
263
|
}
|
|
55
264
|
isOpen() {
|
|
56
265
|
return this.db !== null;
|
|
57
266
|
}
|
|
58
267
|
async query(sql, params) {
|
|
59
268
|
this.ensureOpen();
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
269
|
+
if (this.inTransaction) return this.queryRaw(this.db, this.statementCache, sql, params);
|
|
270
|
+
if (this.shouldUsePool(sql)) {
|
|
271
|
+
try {
|
|
272
|
+
return await this.readerPool.query(sql, params);
|
|
273
|
+
} catch {
|
|
274
|
+
}
|
|
66
275
|
}
|
|
276
|
+
const conn = this.readConnection();
|
|
277
|
+
if (this.scheduler) {
|
|
278
|
+
return this.scheduler.schedule(
|
|
279
|
+
"interactive",
|
|
280
|
+
async () => this.queryRaw(conn.db, conn.cache, sql, params),
|
|
281
|
+
readKey("query", sql, params),
|
|
282
|
+
"query"
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return this.queryRaw(conn.db, conn.cache, sql, params);
|
|
286
|
+
}
|
|
287
|
+
async queryBatch(reads) {
|
|
288
|
+
const results = [];
|
|
289
|
+
for (const read of reads) {
|
|
290
|
+
results.push(await this.query(read.sql, read.params));
|
|
291
|
+
}
|
|
292
|
+
return results;
|
|
67
293
|
}
|
|
68
294
|
async queryOne(sql, params) {
|
|
69
295
|
this.ensureOpen();
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
296
|
+
if (this.inTransaction) return this.queryOneRaw(this.db, this.statementCache, sql, params);
|
|
297
|
+
if (this.shouldUsePool(sql)) {
|
|
298
|
+
try {
|
|
299
|
+
return await this.readerPool.queryOne(sql, params);
|
|
300
|
+
} catch {
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const conn = this.readConnection();
|
|
304
|
+
if (this.scheduler) {
|
|
305
|
+
return this.scheduler.schedule(
|
|
306
|
+
"interactive",
|
|
307
|
+
async () => this.queryOneRaw(conn.db, conn.cache, sql, params),
|
|
308
|
+
readKey("queryOne", sql, params),
|
|
309
|
+
"queryOne"
|
|
310
|
+
);
|
|
76
311
|
}
|
|
312
|
+
return this.queryOneRaw(conn.db, conn.cache, sql, params);
|
|
77
313
|
}
|
|
78
314
|
async run(sql, params) {
|
|
79
315
|
this.ensureOpen();
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
throw this.wrapError(err, sql);
|
|
316
|
+
if (this.inTransaction) return this.runRaw(sql, params);
|
|
317
|
+
if (this.scheduler) {
|
|
318
|
+
return this.scheduler.schedule(
|
|
319
|
+
"write",
|
|
320
|
+
async () => this.runRaw(sql, params),
|
|
321
|
+
void 0,
|
|
322
|
+
"run"
|
|
323
|
+
);
|
|
89
324
|
}
|
|
325
|
+
return this.runRaw(sql, params);
|
|
90
326
|
}
|
|
91
327
|
async exec(sql) {
|
|
92
328
|
this.ensureOpen();
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
throw this.wrapError(err, sql);
|
|
329
|
+
if (this.inTransaction) return this.execRaw(sql);
|
|
330
|
+
if (this.scheduler) {
|
|
331
|
+
return this.scheduler.schedule("write", async () => this.execRaw(sql), void 0, "exec");
|
|
97
332
|
}
|
|
333
|
+
return this.execRaw(sql);
|
|
98
334
|
}
|
|
99
335
|
async transaction(fn) {
|
|
100
336
|
this.ensureOpen();
|
|
@@ -104,10 +340,50 @@ var ElectronSQLiteAdapter = class {
|
|
|
104
340
|
await this.commit();
|
|
105
341
|
return result;
|
|
106
342
|
} catch (err) {
|
|
107
|
-
|
|
343
|
+
if (this.inTransaction) {
|
|
344
|
+
try {
|
|
345
|
+
await this.rollback();
|
|
346
|
+
} catch (rollbackErr) {
|
|
347
|
+
if (isSQLiteCorruptionError(rollbackErr) && !isSQLiteCorruptionError(err)) {
|
|
348
|
+
throw rollbackErr;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
108
352
|
throw err;
|
|
109
353
|
}
|
|
110
354
|
}
|
|
355
|
+
async transactionBatch(operations) {
|
|
356
|
+
this.ensureOpen();
|
|
357
|
+
if (operations.length === 0) return;
|
|
358
|
+
const apply = () => {
|
|
359
|
+
let currentSql = operations[0].sql;
|
|
360
|
+
try {
|
|
361
|
+
this.transactionSync(() => {
|
|
362
|
+
for (const operation of operations) {
|
|
363
|
+
currentSql = operation.sql;
|
|
364
|
+
const stmt = this.getOrPrepare(operation.sql);
|
|
365
|
+
if (operation.params) {
|
|
366
|
+
stmt.run(...operation.params);
|
|
367
|
+
} else {
|
|
368
|
+
stmt.run();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
} catch (err) {
|
|
373
|
+
throw this.wrapError(err, currentSql);
|
|
374
|
+
}
|
|
375
|
+
this.lastCommitAt = nowMs();
|
|
376
|
+
};
|
|
377
|
+
if (this.inTransaction) {
|
|
378
|
+
apply();
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (this.scheduler) {
|
|
382
|
+
await this.scheduler.schedule("write", async () => apply(), void 0, "transactionBatch");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
apply();
|
|
386
|
+
}
|
|
111
387
|
/**
|
|
112
388
|
* Synchronous transaction for performance-critical batch operations.
|
|
113
389
|
* Prefer this over async transaction when the body is synchronous.
|
|
@@ -118,6 +394,7 @@ var ElectronSQLiteAdapter = class {
|
|
|
118
394
|
return txn();
|
|
119
395
|
}
|
|
120
396
|
async beginTransaction() {
|
|
397
|
+
this.ensureOpen();
|
|
121
398
|
if (this.inTransaction) {
|
|
122
399
|
throw new Error("Transaction already in progress");
|
|
123
400
|
}
|
|
@@ -128,15 +405,26 @@ var ElectronSQLiteAdapter = class {
|
|
|
128
405
|
if (!this.inTransaction) {
|
|
129
406
|
throw new Error("No transaction in progress");
|
|
130
407
|
}
|
|
131
|
-
|
|
132
|
-
|
|
408
|
+
try {
|
|
409
|
+
this.db.exec("COMMIT");
|
|
410
|
+
this.inTransaction = false;
|
|
411
|
+
this.lastCommitAt = nowMs();
|
|
412
|
+
} catch (err) {
|
|
413
|
+
if (isSQLiteCorruptionError(err)) {
|
|
414
|
+
this.inTransaction = false;
|
|
415
|
+
}
|
|
416
|
+
throw err;
|
|
417
|
+
}
|
|
133
418
|
}
|
|
134
419
|
async rollback() {
|
|
135
420
|
if (!this.inTransaction) {
|
|
136
421
|
return;
|
|
137
422
|
}
|
|
138
|
-
|
|
139
|
-
|
|
423
|
+
try {
|
|
424
|
+
this.db.exec("ROLLBACK");
|
|
425
|
+
} finally {
|
|
426
|
+
this.inTransaction = false;
|
|
427
|
+
}
|
|
140
428
|
}
|
|
141
429
|
async prepare(sql) {
|
|
142
430
|
this.ensureOpen();
|
|
@@ -215,24 +503,362 @@ var ElectronSQLiteAdapter = class {
|
|
|
215
503
|
}
|
|
216
504
|
async vacuum() {
|
|
217
505
|
this.ensureOpen();
|
|
506
|
+
if (this.inTransaction) {
|
|
507
|
+
this.db.exec("VACUUM");
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (this.scheduler) {
|
|
511
|
+
await this.scheduler.schedule(
|
|
512
|
+
"write",
|
|
513
|
+
async () => this.db.exec("VACUUM"),
|
|
514
|
+
void 0,
|
|
515
|
+
"vacuum"
|
|
516
|
+
);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
218
519
|
this.db.exec("VACUUM");
|
|
219
520
|
}
|
|
521
|
+
async incrementalVacuum(maxPages) {
|
|
522
|
+
this.ensureOpen();
|
|
523
|
+
const pragma = maxPages !== void 0 && maxPages > 0 ? `incremental_vacuum(${Math.floor(maxPages)})` : "incremental_vacuum";
|
|
524
|
+
const runIt = () => {
|
|
525
|
+
const before = this.db.pragma("freelist_count", { simple: true });
|
|
526
|
+
this.db.pragma(pragma);
|
|
527
|
+
const after = this.db.pragma("freelist_count", { simple: true });
|
|
528
|
+
return Math.max(0, before - after);
|
|
529
|
+
};
|
|
530
|
+
if (this.inTransaction) {
|
|
531
|
+
return runIt();
|
|
532
|
+
}
|
|
533
|
+
if (this.scheduler) {
|
|
534
|
+
return this.scheduler.schedule("write", async () => runIt(), void 0, "incremental_vacuum");
|
|
535
|
+
}
|
|
536
|
+
return runIt();
|
|
537
|
+
}
|
|
220
538
|
async checkpoint() {
|
|
221
539
|
return 0;
|
|
222
540
|
}
|
|
223
541
|
getStorageMode() {
|
|
224
542
|
return "opfs";
|
|
225
543
|
}
|
|
544
|
+
/**
|
|
545
|
+
* Apply a node-store batch with cooperative yielding (exploration 0230).
|
|
546
|
+
*
|
|
547
|
+
* A bulk import is the one synchronous op long enough to head-of-line block
|
|
548
|
+
* interactive reads on the data-process thread. This splits the batch into
|
|
549
|
+
* chunks, each its own exclusive transaction, and yields to the event loop
|
|
550
|
+
* between chunks so a queued interactive read (or a reader-pool read) can
|
|
551
|
+
* interleave. Whole-batch atomicity is traded for yield points — safe here
|
|
552
|
+
* because node-batch writes are idempotent LWW upserts.
|
|
553
|
+
*/
|
|
554
|
+
async applyNodeBatch(input) {
|
|
555
|
+
this.ensureOpen();
|
|
556
|
+
const indexed = input.indexMode !== "defer-schema";
|
|
557
|
+
const invalidatedAt = Date.now();
|
|
558
|
+
const ops = [];
|
|
559
|
+
const nodeStmt = () => this.getOrPrepare(
|
|
560
|
+
`INSERT INTO nodes (id, schema_id, created_at, updated_at, created_by, deleted_at)
|
|
561
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
562
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
563
|
+
schema_id = excluded.schema_id,
|
|
564
|
+
updated_at = excluded.updated_at,
|
|
565
|
+
deleted_at = excluded.deleted_at`
|
|
566
|
+
);
|
|
567
|
+
for (const node of input.nodes) {
|
|
568
|
+
ops.push(
|
|
569
|
+
() => nodeStmt().run(
|
|
570
|
+
node.id,
|
|
571
|
+
node.schemaId,
|
|
572
|
+
node.createdAt,
|
|
573
|
+
node.updatedAt,
|
|
574
|
+
node.createdBy,
|
|
575
|
+
node.deletedAt
|
|
576
|
+
)
|
|
577
|
+
);
|
|
578
|
+
if (node.propertyKeys.length === 0) {
|
|
579
|
+
ops.push(
|
|
580
|
+
() => this.getOrPrepare("DELETE FROM node_properties WHERE node_id = ?").run(node.id)
|
|
581
|
+
);
|
|
582
|
+
} else {
|
|
583
|
+
const placeholders = node.propertyKeys.map(() => "?").join(", ");
|
|
584
|
+
ops.push(
|
|
585
|
+
() => this.getOrPrepare(
|
|
586
|
+
`DELETE FROM node_properties WHERE node_id = ? AND property_key NOT IN (${placeholders})`
|
|
587
|
+
).run(node.id, ...node.propertyKeys)
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
for (const property of input.properties) {
|
|
592
|
+
ops.push(
|
|
593
|
+
() => this.getOrPrepare(
|
|
594
|
+
`INSERT INTO node_properties
|
|
595
|
+
(node_id, property_key, value, lamport_time, updated_by, updated_at)
|
|
596
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
597
|
+
ON CONFLICT(node_id, property_key) DO UPDATE SET
|
|
598
|
+
value = excluded.value,
|
|
599
|
+
lamport_time = excluded.lamport_time,
|
|
600
|
+
updated_by = excluded.updated_by,
|
|
601
|
+
updated_at = excluded.updated_at
|
|
602
|
+
WHERE excluded.lamport_time > node_properties.lamport_time`
|
|
603
|
+
).run(
|
|
604
|
+
property.nodeId,
|
|
605
|
+
property.propertyKey,
|
|
606
|
+
property.value,
|
|
607
|
+
property.lamportTime,
|
|
608
|
+
property.updatedBy,
|
|
609
|
+
property.updatedAt
|
|
610
|
+
)
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
if (indexed) {
|
|
614
|
+
for (const node of input.nodes) {
|
|
615
|
+
ops.push(
|
|
616
|
+
() => this.getOrPrepare("DELETE FROM node_property_scalars WHERE node_id = ?").run(node.id)
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
for (const row of input.scalarIndexRows) {
|
|
620
|
+
ops.push(
|
|
621
|
+
() => this.getOrPrepare(
|
|
622
|
+
`INSERT INTO node_property_scalars
|
|
623
|
+
(node_id, schema_id, property_key, value_type, value_text,
|
|
624
|
+
value_number, value_boolean, value_hash, updated_at, lamport_time)
|
|
625
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
626
|
+
).run(
|
|
627
|
+
row.nodeId,
|
|
628
|
+
row.schemaId,
|
|
629
|
+
row.propertyKey,
|
|
630
|
+
row.valueType,
|
|
631
|
+
row.valueText,
|
|
632
|
+
row.valueNumber,
|
|
633
|
+
row.valueBoolean,
|
|
634
|
+
row.valueHash,
|
|
635
|
+
row.updatedAt,
|
|
636
|
+
row.lamportTime
|
|
637
|
+
)
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
for (const nodeId of input.ftsNodeIds) {
|
|
641
|
+
ops.push(() => this.getOrPrepare("DELETE FROM nodes_fts WHERE node_id = ?").run(nodeId));
|
|
642
|
+
}
|
|
643
|
+
for (const row of input.ftsRows) {
|
|
644
|
+
ops.push(
|
|
645
|
+
() => this.getOrPrepare("INSERT INTO nodes_fts (node_id, title, content) VALUES (?, ?, ?)").run(
|
|
646
|
+
row.nodeId,
|
|
647
|
+
row.title,
|
|
648
|
+
row.content
|
|
649
|
+
)
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
for (const change of input.changes) {
|
|
654
|
+
ops.push(
|
|
655
|
+
() => this.getOrPrepare(
|
|
656
|
+
`INSERT OR IGNORE INTO changes
|
|
657
|
+
(hash, node_id, payload, lamport_time, lamport_peer, wall_time, author, parent_hash, batch_id, signature)
|
|
658
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
659
|
+
).run(
|
|
660
|
+
change.hash,
|
|
661
|
+
change.nodeId,
|
|
662
|
+
change.payload,
|
|
663
|
+
change.lamportTime,
|
|
664
|
+
change.lamportPeer,
|
|
665
|
+
change.wallTime,
|
|
666
|
+
change.author,
|
|
667
|
+
change.parentHash,
|
|
668
|
+
change.batchId,
|
|
669
|
+
change.signature
|
|
670
|
+
)
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
ops.push(
|
|
674
|
+
() => this.getOrPrepare(
|
|
675
|
+
`INSERT INTO sync_state (key, value) VALUES ('lastLamportTime', ?)
|
|
676
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
677
|
+
).run(String(input.lastLamportTime))
|
|
678
|
+
);
|
|
679
|
+
if (indexed) {
|
|
680
|
+
for (const schemaId of input.affectedSchemaIds) {
|
|
681
|
+
ops.push(
|
|
682
|
+
() => this.getOrPrepare(
|
|
683
|
+
`UPDATE node_query_materializations
|
|
684
|
+
SET invalidated_at = ?
|
|
685
|
+
WHERE schema_id = ? AND invalidated_at IS NULL`
|
|
686
|
+
).run(invalidatedAt, schemaId)
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (this.inTransaction) {
|
|
691
|
+
for (const op of ops) op();
|
|
692
|
+
} else {
|
|
693
|
+
for (let i = 0; i < ops.length; i += APPLY_NODE_BATCH_CHUNK_ROWS) {
|
|
694
|
+
const chunk = ops.slice(i, i + APPLY_NODE_BATCH_CHUNK_ROWS);
|
|
695
|
+
await this.runChunkTransaction(() => {
|
|
696
|
+
for (const op of chunk) op();
|
|
697
|
+
});
|
|
698
|
+
if (i + APPLY_NODE_BATCH_CHUNK_ROWS < ops.length) await yieldToEventLoop();
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return {
|
|
702
|
+
nodeRowsWritten: input.nodes.length,
|
|
703
|
+
propertyRowsWritten: input.properties.length,
|
|
704
|
+
changeRowsWritten: input.changes.length,
|
|
705
|
+
scalarRowsWritten: input.scalarIndexRows.length,
|
|
706
|
+
ftsRowsWritten: input.ftsRows.length
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
/** Scheduler queue depths (diagnostics / desktop perf panel). */
|
|
710
|
+
getSchedulerSnapshot() {
|
|
711
|
+
return this.scheduler ? this.scheduler.snapshot() : null;
|
|
712
|
+
}
|
|
713
|
+
/** Reader-thread pool occupancy, or null when no pool is configured. */
|
|
714
|
+
getReaderPoolStats() {
|
|
715
|
+
return this.readerPool ? this.readerPool.stats() : null;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* WAL growth: `-wal` sidecar size + page count. Long-lived readers can pin an
|
|
719
|
+
* old snapshot and hold back checkpointing; our one-shot reader selects don't,
|
|
720
|
+
* so this should stay bounded. Surfaced to the diagnostics seam (0230).
|
|
721
|
+
*/
|
|
722
|
+
async getWalStats() {
|
|
723
|
+
if (!this.db || this.config?.walMode === false) return null;
|
|
724
|
+
try {
|
|
725
|
+
const path = this.config?.path;
|
|
726
|
+
let walBytes = 0;
|
|
727
|
+
if (path && path !== ":memory:") {
|
|
728
|
+
const { statSync } = await import("fs");
|
|
729
|
+
try {
|
|
730
|
+
walBytes = statSync(`${path}-wal`).size;
|
|
731
|
+
} catch {
|
|
732
|
+
walBytes = 0;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const page = this.db.pragma("page_count", { simple: true });
|
|
736
|
+
return { walBytes, pageCount: typeof page === "number" ? page : 0 };
|
|
737
|
+
} catch {
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/** Run a passive WAL checkpoint; returns frames checkpointed (best-effort). */
|
|
742
|
+
async checkpointWal() {
|
|
743
|
+
if (!this.db || this.config?.walMode === false) return 0;
|
|
744
|
+
return this.scheduler ? this.scheduler.schedule("bulk", async () => this.runCheckpoint(), void 0, "checkpoint") : this.runCheckpoint();
|
|
745
|
+
}
|
|
746
|
+
runCheckpoint() {
|
|
747
|
+
try {
|
|
748
|
+
const result = this.db.pragma("wal_checkpoint(PASSIVE)");
|
|
749
|
+
return result?.[0]?.checkpointed ?? 0;
|
|
750
|
+
} catch {
|
|
751
|
+
return 0;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
/** Combined point-in-time diagnostics for the desktop diagnostics seam. */
|
|
755
|
+
async getDiagnostics() {
|
|
756
|
+
return {
|
|
757
|
+
scheduler: this.getSchedulerSnapshot(),
|
|
758
|
+
readerPool: this.getReaderPoolStats(),
|
|
759
|
+
wal: await this.getWalStats(),
|
|
760
|
+
readonlyConnection: this.readDb !== null
|
|
761
|
+
};
|
|
762
|
+
}
|
|
226
763
|
// ─── Helper Methods ─────────────────────────────────────────────────────
|
|
764
|
+
/** Whether a heavy, non-transactional read should be offloaded to the pool. */
|
|
765
|
+
shouldUsePool(sql) {
|
|
766
|
+
return this.readerPool !== null && this.readerPool.isHealthy() && isHeavyRead(sql) && !this.inReadYourWritesWindow();
|
|
767
|
+
}
|
|
768
|
+
/** Reads within the configured window after a commit route to the writer. */
|
|
769
|
+
inReadYourWritesWindow() {
|
|
770
|
+
const window = this.config?.readYourWritesWindowMs ?? 0;
|
|
771
|
+
if (window <= 0) return false;
|
|
772
|
+
return nowMs() - this.lastCommitAt < window;
|
|
773
|
+
}
|
|
774
|
+
/** Pick the connection (+ its statement cache) a plain read should use. */
|
|
775
|
+
readConnection() {
|
|
776
|
+
if (this.readDb && !this.inReadYourWritesWindow()) {
|
|
777
|
+
return { db: this.readDb, cache: this.readStatementCache };
|
|
778
|
+
}
|
|
779
|
+
return { db: this.db, cache: this.statementCache };
|
|
780
|
+
}
|
|
227
781
|
/**
|
|
228
|
-
*
|
|
782
|
+
* Run `apply` as a single atomic chunk transaction. Scheduled as one write-lane
|
|
783
|
+
* job so it is indivisible w.r.t. the scheduler — no queued read or write can
|
|
784
|
+
* interleave mid-chunk — while the surrounding `applyNodeBatch` yields *between*
|
|
785
|
+
* chunks. `apply` is fully synchronous (no awaits), so `inTransaction` is set
|
|
786
|
+
* only for its duration and is invisible to other async callers.
|
|
787
|
+
*/
|
|
788
|
+
runChunkTransaction(apply) {
|
|
789
|
+
const exec = () => {
|
|
790
|
+
this.inTransaction = true;
|
|
791
|
+
try {
|
|
792
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
793
|
+
try {
|
|
794
|
+
apply();
|
|
795
|
+
this.db.exec("COMMIT");
|
|
796
|
+
this.lastCommitAt = nowMs();
|
|
797
|
+
} catch (err) {
|
|
798
|
+
try {
|
|
799
|
+
this.db.exec("ROLLBACK");
|
|
800
|
+
} catch {
|
|
801
|
+
}
|
|
802
|
+
throw err;
|
|
803
|
+
}
|
|
804
|
+
} finally {
|
|
805
|
+
this.inTransaction = false;
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
return this.scheduler ? this.scheduler.schedule("write", async () => exec(), void 0, "applyNodeBatch") : Promise.resolve().then(exec);
|
|
809
|
+
}
|
|
810
|
+
queryRaw(db, cache, sql, params) {
|
|
811
|
+
try {
|
|
812
|
+
const stmt = this.getOrPrepareOn(db, cache, sql);
|
|
813
|
+
const rows = params ? stmt.all(...params) : stmt.all();
|
|
814
|
+
return rows;
|
|
815
|
+
} catch (err) {
|
|
816
|
+
throw this.wrapError(err, sql);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
queryOneRaw(db, cache, sql, params) {
|
|
820
|
+
try {
|
|
821
|
+
const stmt = this.getOrPrepareOn(db, cache, sql);
|
|
822
|
+
const row = params ? stmt.get(...params) : stmt.get();
|
|
823
|
+
return row ?? null;
|
|
824
|
+
} catch (err) {
|
|
825
|
+
throw this.wrapError(err, sql);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
runRaw(sql, params) {
|
|
829
|
+
try {
|
|
830
|
+
const stmt = this.getOrPrepare(sql);
|
|
831
|
+
const result = params ? stmt.run(...params) : stmt.run();
|
|
832
|
+
this.lastCommitAt = nowMs();
|
|
833
|
+
return {
|
|
834
|
+
changes: result.changes,
|
|
835
|
+
lastInsertRowid: BigInt(result.lastInsertRowid)
|
|
836
|
+
};
|
|
837
|
+
} catch (err) {
|
|
838
|
+
throw this.wrapError(err, sql);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
execRaw(sql) {
|
|
842
|
+
try {
|
|
843
|
+
this.db.exec(sql);
|
|
844
|
+
this.lastCommitAt = nowMs();
|
|
845
|
+
} catch (err) {
|
|
846
|
+
throw this.wrapError(err, sql);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Get a statement from cache or prepare it on the writer connection.
|
|
229
851
|
* Statement caching significantly improves performance for repeated queries.
|
|
230
852
|
*/
|
|
231
853
|
getOrPrepare(sql) {
|
|
232
|
-
|
|
854
|
+
return this.getOrPrepareOn(this.db, this.statementCache, sql);
|
|
855
|
+
}
|
|
856
|
+
/** Statement cache keyed per connection (writer + read-only differ). */
|
|
857
|
+
getOrPrepareOn(db, cache, sql) {
|
|
858
|
+
let stmt = cache.get(sql);
|
|
233
859
|
if (!stmt) {
|
|
234
|
-
stmt =
|
|
235
|
-
|
|
860
|
+
stmt = db.prepare(sql);
|
|
861
|
+
cache.set(sql, stmt);
|
|
236
862
|
}
|
|
237
863
|
return stmt;
|
|
238
864
|
}
|