@xnetjs/sqlite 0.0.3 → 0.1.1
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-t2aqQ__n.d.ts → adapter-imtxkmXZ.d.ts} +22 -1
- package/dist/adapters/electron.d.ts +66 -6
- package/dist/adapters/electron.js +629 -47
- package/dist/adapters/expo.d.ts +6 -2
- package/dist/adapters/expo.js +8 -1
- 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 +54 -2
- package/dist/adapters/web-proxy.js +458 -55
- 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 +45 -1
- package/dist/adapters/web-worker.js +232 -17
- package/dist/adapters/web.d.ts +82 -3
- package/dist/adapters/web.js +7 -4
- package/dist/browser-support.d.ts +10 -1
- package/dist/browser-support.js +5 -1
- package/dist/chunk-5HC5V73T.js +191 -0
- package/dist/{chunk-DCY4LAUD.js → chunk-BBZDKLA3.js} +246 -13
- package/dist/chunk-CGI2YBZY.js +16 -0
- package/dist/{chunk-2OK46ZBU.js → chunk-S6MT6KCI.js} +22 -1
- package/dist/chunk-SV475UL5.js +44 -0
- package/dist/{chunk-NVD7G7DZ.js → chunk-W3L4FXU5.js} +11 -2
- package/dist/index.d.ts +93 -8
- package/dist/index.js +92 -10
- 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/schema-BEgIA-rZ.d.ts +0 -35
- package/dist/types-DMrj3K4o.d.ts +0 -132
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @xnetjs/sqlite - SharedWorker router for multi-tab SQLite (exploration 0263)
|
|
3
|
+
*
|
|
4
|
+
* A deliberately tiny message ferry, NOT a database host: SharedWorkers cannot
|
|
5
|
+
* use OPFS sync access handles, so SQLite itself stays in the leader tab's
|
|
6
|
+
* dedicated worker. This router only (a) remembers which tab is the current
|
|
7
|
+
* leader and (b) ferries `MessagePort`s from the leader to follower tabs — the
|
|
8
|
+
* same role the SharedWorker plays in Notion's and wa-sqlite's (discussion #81)
|
|
9
|
+
* multi-tab architectures.
|
|
10
|
+
*
|
|
11
|
+
* Leadership itself is decided by `navigator.locks` in the tabs (web-leader.ts);
|
|
12
|
+
* the router never elects anyone. When a tab announces `leader-ready` it simply
|
|
13
|
+
* replaces the previous leader registration and followers are told to
|
|
14
|
+
* re-request their DB port.
|
|
15
|
+
*
|
|
16
|
+
* The message handler is exported as a pure function over an explicit state
|
|
17
|
+
* object so it can be unit-tested in Node without a real SharedWorker
|
|
18
|
+
* (mirroring the reader-thread.ts pattern).
|
|
19
|
+
*/
|
|
20
|
+
/** A follower's request for a DB port, waiting on the leader's response. */
|
|
21
|
+
interface PendingPortRequest {
|
|
22
|
+
requesterPort: RouterClientPort;
|
|
23
|
+
requestId: string;
|
|
24
|
+
}
|
|
25
|
+
/** The subset of MessagePort the router needs (test seam). */
|
|
26
|
+
interface RouterClientPort {
|
|
27
|
+
postMessage(message: unknown, transfer?: Transferable[]): void;
|
|
28
|
+
}
|
|
29
|
+
/** Messages a tab sends to the router. */
|
|
30
|
+
type RouterInbound = {
|
|
31
|
+
t: 'leader-ready';
|
|
32
|
+
} | {
|
|
33
|
+
t: 'request-db-port';
|
|
34
|
+
requestId: string;
|
|
35
|
+
} | {
|
|
36
|
+
t: 'db-port';
|
|
37
|
+
requestId: string;
|
|
38
|
+
} | {
|
|
39
|
+
t: 'db-port-failed';
|
|
40
|
+
requestId: string;
|
|
41
|
+
error: string;
|
|
42
|
+
};
|
|
43
|
+
/** Messages the router sends to a tab. */
|
|
44
|
+
type RouterOutbound = {
|
|
45
|
+
t: 'mint-db-port';
|
|
46
|
+
requestId: string;
|
|
47
|
+
} | {
|
|
48
|
+
t: 'db-port';
|
|
49
|
+
requestId: string;
|
|
50
|
+
} | {
|
|
51
|
+
t: 'db-port-failed';
|
|
52
|
+
requestId: string;
|
|
53
|
+
error: string;
|
|
54
|
+
} | {
|
|
55
|
+
t: 'no-leader';
|
|
56
|
+
requestId: string;
|
|
57
|
+
} | {
|
|
58
|
+
t: 'leader-changed';
|
|
59
|
+
};
|
|
60
|
+
interface RouterState {
|
|
61
|
+
leader: RouterClientPort | null;
|
|
62
|
+
/** Every connected tab port, for leader-changed broadcasts. */
|
|
63
|
+
clients: Set<RouterClientPort>;
|
|
64
|
+
/** Port requests forwarded to the leader, awaiting its minted port. */
|
|
65
|
+
pending: Map<string, PendingPortRequest>;
|
|
66
|
+
}
|
|
67
|
+
declare function createRouterState(): RouterState;
|
|
68
|
+
/** Register a newly connected tab port. */
|
|
69
|
+
declare function addRouterClient(state: RouterState, port: RouterClientPort): void;
|
|
70
|
+
/**
|
|
71
|
+
* Dispatch one inbound message. `ports` carries any transferred MessagePorts
|
|
72
|
+
* (the leader's minted DB port rides `db-port` messages).
|
|
73
|
+
*/
|
|
74
|
+
declare function handleRouterMessage(state: RouterState, sender: RouterClientPort, message: RouterInbound, ports?: readonly Transferable[]): void;
|
|
75
|
+
|
|
76
|
+
export { type RouterClientPort, type RouterInbound, type RouterOutbound, type RouterState, addRouterClient, createRouterState, handleRouterMessage };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// src/adapters/web-router-worker.ts
|
|
2
|
+
function createRouterState() {
|
|
3
|
+
return { leader: null, clients: /* @__PURE__ */ new Set(), pending: /* @__PURE__ */ new Map() };
|
|
4
|
+
}
|
|
5
|
+
function addRouterClient(state, port) {
|
|
6
|
+
state.clients.add(port);
|
|
7
|
+
}
|
|
8
|
+
function handleRouterMessage(state, sender, message, ports = []) {
|
|
9
|
+
switch (message.t) {
|
|
10
|
+
case "leader-ready": {
|
|
11
|
+
const previous = state.leader;
|
|
12
|
+
state.leader = sender;
|
|
13
|
+
if (previous !== null && previous !== sender) {
|
|
14
|
+
for (const client of state.clients) {
|
|
15
|
+
if (client !== sender) {
|
|
16
|
+
safePost(client, { t: "leader-changed" });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
case "request-db-port": {
|
|
23
|
+
if (!state.leader) {
|
|
24
|
+
safePost(sender, { t: "no-leader", requestId: message.requestId });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
state.pending.set(message.requestId, { requesterPort: sender, requestId: message.requestId });
|
|
28
|
+
safePost(state.leader, {
|
|
29
|
+
t: "mint-db-port",
|
|
30
|
+
requestId: message.requestId
|
|
31
|
+
});
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
case "db-port": {
|
|
35
|
+
const pending = state.pending.get(message.requestId);
|
|
36
|
+
if (!pending) return;
|
|
37
|
+
state.pending.delete(message.requestId);
|
|
38
|
+
const [port] = ports;
|
|
39
|
+
if (port === void 0) {
|
|
40
|
+
safePost(pending.requesterPort, {
|
|
41
|
+
t: "db-port-failed",
|
|
42
|
+
requestId: message.requestId,
|
|
43
|
+
error: "leader response carried no port"
|
|
44
|
+
});
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
pending.requesterPort.postMessage(
|
|
48
|
+
{ t: "db-port", requestId: message.requestId },
|
|
49
|
+
[port]
|
|
50
|
+
);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
case "db-port-failed": {
|
|
54
|
+
const pending = state.pending.get(message.requestId);
|
|
55
|
+
if (!pending) return;
|
|
56
|
+
state.pending.delete(message.requestId);
|
|
57
|
+
safePost(pending.requesterPort, {
|
|
58
|
+
t: "db-port-failed",
|
|
59
|
+
requestId: message.requestId,
|
|
60
|
+
error: message.error
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function safePost(port, message) {
|
|
67
|
+
try {
|
|
68
|
+
port.postMessage(message);
|
|
69
|
+
} catch {
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function bootstrapRouter() {
|
|
73
|
+
const scope = globalThis;
|
|
74
|
+
if (typeof scope.SharedWorkerGlobalScope === "undefined") return;
|
|
75
|
+
const state = createRouterState();
|
|
76
|
+
scope.onconnect = (event) => {
|
|
77
|
+
const port = event.ports[0];
|
|
78
|
+
if (!port) return;
|
|
79
|
+
addRouterClient(state, port);
|
|
80
|
+
port.onmessage = (msg) => {
|
|
81
|
+
handleRouterMessage(state, port, msg.data, msg.ports);
|
|
82
|
+
};
|
|
83
|
+
port.start?.();
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
bootstrapRouter();
|
|
87
|
+
export {
|
|
88
|
+
addRouterClient,
|
|
89
|
+
createRouterState,
|
|
90
|
+
handleRouterMessage
|
|
91
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { b as SQLiteConfig, a as SQLRow, S as SQLValue, R as RunResult,
|
|
1
|
+
import { b as SQLiteConfig, a as SQLRow, S as SQLValue, c as SQLBatchRead, R as RunResult, l as SQLiteNodeBatchApplyInput, m as SQLiteNodeBatchApplyResult } from '../types-BTabr_VP.js';
|
|
2
|
+
import { S as SchedulerSnapshot, a as SchedulerOpStats } from '../worker-scheduler-D04DqMmR.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* @xnetjs/sqlite - Web Worker entry point for SQLite WASM
|
|
@@ -12,12 +13,38 @@ import { b as SQLiteConfig, a as SQLRow, S as SQLValue, R as RunResult, k as SQL
|
|
|
12
13
|
*/
|
|
13
14
|
declare class SQLiteWorkerHandler {
|
|
14
15
|
private adapter;
|
|
16
|
+
private bootDebug;
|
|
17
|
+
private openedAtMs;
|
|
18
|
+
private firstOpReported;
|
|
19
|
+
private readonly scheduler;
|
|
15
20
|
open(config: SQLiteConfig): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Emit the open-phase split (exploration 0253). Until now the whole open lived
|
|
23
|
+
* in the boot-timeline's opaque `wasm` bucket (init:start → sqlite:open); this
|
|
24
|
+
* breaks it into wasm import/init, OPFS VFS install (+ lock retries), capacity
|
|
25
|
+
* reserve, db open, pragmas and schema apply — so the dominant sub-phase of a
|
|
26
|
+
* slow open is named in one line. Never throws.
|
|
27
|
+
*/
|
|
28
|
+
private logOpenPhases;
|
|
29
|
+
/**
|
|
30
|
+
* One-shot DB stats at open (exploration 0229): file size, page/freelist
|
|
31
|
+
* counts, storage mode. File size is the key signal for the bloat hypothesis
|
|
32
|
+
* (a giant pre-0227 presence blob inflating cold-read cost). Never throws.
|
|
33
|
+
*/
|
|
34
|
+
private logDbStats;
|
|
16
35
|
resetStorage(config: SQLiteConfig): Promise<void>;
|
|
17
36
|
close(): Promise<void>;
|
|
18
37
|
isOpen(): boolean;
|
|
19
38
|
query<T extends SQLRow = SQLRow>(sql: string, params?: SQLValue[]): Promise<T[]>;
|
|
20
39
|
queryOne<T extends SQLRow = SQLRow>(sql: string, params?: SQLValue[]): Promise<T | null>;
|
|
40
|
+
/**
|
|
41
|
+
* Execute a batch of reads as ONE scheduled job — one RPC, one queue slot —
|
|
42
|
+
* instead of one round-trip per query (exploration 0263). The batch runs on
|
|
43
|
+
* the interactive lane uncoalesced: its members are heterogeneous, so a
|
|
44
|
+
* batch-level coalesce key would rarely hit, and member-level coalescing
|
|
45
|
+
* would let other jobs interleave mid-batch.
|
|
46
|
+
*/
|
|
47
|
+
queryBatch(reads: SQLBatchRead[]): Promise<SQLRow[][]>;
|
|
21
48
|
run(sql: string, params?: SQLValue[]): Promise<RunResult>;
|
|
22
49
|
exec(sql: string): Promise<void>;
|
|
23
50
|
transaction(operations: Array<{
|
|
@@ -27,6 +54,13 @@ declare class SQLiteWorkerHandler {
|
|
|
27
54
|
applyNodeBatch(input: SQLiteNodeBatchApplyInput): Promise<SQLiteNodeBatchApplyResult>;
|
|
28
55
|
getSchemaVersion(): Promise<number>;
|
|
29
56
|
vacuum(): Promise<void>;
|
|
57
|
+
incrementalVacuum(maxPages?: number): Promise<number>;
|
|
58
|
+
/** Current scheduler queue depths (diagnostics / the perf panel). */
|
|
59
|
+
getSchedulerSnapshot(): Promise<SchedulerSnapshot>;
|
|
60
|
+
/** Cumulative per-lane op latency percentiles + coalesce hits (0263). */
|
|
61
|
+
getSchedulerOpStats(): Promise<SchedulerOpStats>;
|
|
62
|
+
/** Zero the op-stats counters for a focused before/after measurement. */
|
|
63
|
+
resetSchedulerOpStats(): Promise<void>;
|
|
30
64
|
getDatabaseSize(): Promise<number>;
|
|
31
65
|
getStorageMode(): Promise<'opfs' | 'memory'>;
|
|
32
66
|
/**
|
|
@@ -38,6 +72,16 @@ declare class SQLiteWorkerHandler {
|
|
|
38
72
|
* `WebSQLiteProxy.createMessagePort()` and transferred to the peer.
|
|
39
73
|
*/
|
|
40
74
|
connectPort(port: MessagePort): void;
|
|
75
|
+
/**
|
|
76
|
+
* PowerSync-style lease recovery (exploration 0263): a client that died
|
|
77
|
+
* between a manual BEGIN and its COMMIT leaves the shared connection stuck
|
|
78
|
+
* in a transaction, wedging every other client's writes. When a NEW client
|
|
79
|
+
* connects, roll back any transaction that has been idle long enough that
|
|
80
|
+
* its owner is clearly gone. Batch APIs (`transaction`, `applyNodeBatch`)
|
|
81
|
+
* are single-RPC and can never be abandoned — this only covers the manual
|
|
82
|
+
* BEGIN…COMMIT path.
|
|
83
|
+
*/
|
|
84
|
+
private scheduleAbandonedTransactionRecovery;
|
|
41
85
|
}
|
|
42
86
|
|
|
43
87
|
export { SQLiteWorkerHandler };
|
|
@@ -1,12 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WorkerScheduler,
|
|
3
|
+
firstOpGapFields
|
|
4
|
+
} from "../chunk-5HC5V73T.js";
|
|
1
5
|
import {
|
|
2
6
|
createWebSQLiteAdapter,
|
|
3
7
|
resetWebSQLiteOpfsStorage
|
|
4
|
-
} from "../chunk-
|
|
8
|
+
} from "../chunk-BBZDKLA3.js";
|
|
9
|
+
import "../chunk-SV475UL5.js";
|
|
10
|
+
import {
|
|
11
|
+
bootLogMessage
|
|
12
|
+
} from "../chunk-CGI2YBZY.js";
|
|
5
13
|
import "../chunk-CBU263LI.js";
|
|
6
|
-
import "../chunk-
|
|
14
|
+
import "../chunk-W3L4FXU5.js";
|
|
7
15
|
|
|
8
16
|
// src/adapters/web-worker.ts
|
|
9
17
|
import * as Comlink from "comlink";
|
|
18
|
+
function readKey(op, sql, params) {
|
|
19
|
+
return `${op}\0${sql}\0${params ? JSON.stringify(params) : ""}`;
|
|
20
|
+
}
|
|
21
|
+
function emitBootLog(...args) {
|
|
22
|
+
console.info(...args);
|
|
23
|
+
try {
|
|
24
|
+
const scope = self;
|
|
25
|
+
scope.postMessage(bootLogMessage(args));
|
|
26
|
+
} catch {
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function sqlDetail(sql) {
|
|
30
|
+
const flat = sql.replace(/\s+/g, " ").trim();
|
|
31
|
+
return flat.length > 160 ? `${flat.slice(0, 157)}\u2026` : flat;
|
|
32
|
+
}
|
|
33
|
+
function nowMs() {
|
|
34
|
+
return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
35
|
+
}
|
|
10
36
|
function isDebugEnabled() {
|
|
11
37
|
return typeof self !== "undefined" && typeof localStorage !== "undefined" && localStorage.getItem("xnet:sqlite:debug") === "true";
|
|
12
38
|
}
|
|
@@ -18,13 +44,93 @@ function log(...args) {
|
|
|
18
44
|
log("[SQLiteWorker] Worker script loaded, Comlink imported");
|
|
19
45
|
var SQLiteWorkerHandler = class {
|
|
20
46
|
adapter = null;
|
|
47
|
+
// Boot diagnostics flag (exploration 0229) — workers have no localStorage, so
|
|
48
|
+
// the main thread passes this in via the open config.
|
|
49
|
+
bootDebug = false;
|
|
50
|
+
// Monotonic time `open()` finished (exploration 0253). Lets the first scheduled
|
|
51
|
+
// op report how long the worker sat idle after open() before any op arrived
|
|
52
|
+
// (`idleBeforeFirstOpMs`) — the upstream/transport wait that queueMs/execMs both
|
|
53
|
+
// miss, and where the 17 s relocated once it left `execMs`.
|
|
54
|
+
openedAtMs = 0;
|
|
55
|
+
// One-shot latch so only the FIRST op after open carries the gap measurements.
|
|
56
|
+
firstOpReported = false;
|
|
57
|
+
// All storage work funnels through one priority scheduler so a queued write
|
|
58
|
+
// burst can never starve an interactive read (exploration 0228). The same
|
|
59
|
+
// handler instance is exposed on every port (main thread + data worker), so
|
|
60
|
+
// this one scheduler orders work globally across all clients. When boot debug
|
|
61
|
+
// is on, each op logs its queue-wait vs execution time — the split that
|
|
62
|
+
// finally localizes the boot stall to a single operation (exploration 0229).
|
|
63
|
+
scheduler = new WorkerScheduler((report) => {
|
|
64
|
+
if (!this.bootDebug) return;
|
|
65
|
+
const firstOp = !this.firstOpReported && this.openedAtMs > 0;
|
|
66
|
+
if (firstOp) this.firstOpReported = true;
|
|
67
|
+
emitBootLog("[xNet] sqlite op", report.label ?? report.lane, {
|
|
68
|
+
lane: report.lane,
|
|
69
|
+
// The SQL text (params omitted) names which op a long execMs belongs to —
|
|
70
|
+
// the missing field that kept the cold-open stall unidentified (0249).
|
|
71
|
+
...report.detail ? { sql: report.detail } : {},
|
|
72
|
+
queueMs: Math.round(report.queueMs),
|
|
73
|
+
execMs: Math.round(report.execMs),
|
|
74
|
+
...firstOp ? firstOpGapFields(report, this.openedAtMs) : {}
|
|
75
|
+
});
|
|
76
|
+
});
|
|
21
77
|
async open(config) {
|
|
22
78
|
log("[SQLiteWorkerHandler] open() called with config:", config);
|
|
23
79
|
if (this.adapter) {
|
|
24
80
|
throw new Error("Database already open");
|
|
25
81
|
}
|
|
82
|
+
this.bootDebug = config.bootDebug ?? false;
|
|
26
83
|
this.adapter = await createWebSQLiteAdapter(config);
|
|
84
|
+
this.openedAtMs = nowMs();
|
|
27
85
|
log("[SQLiteWorkerHandler] open() completed");
|
|
86
|
+
if (this.bootDebug) {
|
|
87
|
+
this.logOpenPhases();
|
|
88
|
+
await this.logDbStats();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Emit the open-phase split (exploration 0253). Until now the whole open lived
|
|
93
|
+
* in the boot-timeline's opaque `wasm` bucket (init:start → sqlite:open); this
|
|
94
|
+
* breaks it into wasm import/init, OPFS VFS install (+ lock retries), capacity
|
|
95
|
+
* reserve, db open, pragmas and schema apply — so the dominant sub-phase of a
|
|
96
|
+
* slow open is named in one line. Never throws.
|
|
97
|
+
*/
|
|
98
|
+
logOpenPhases() {
|
|
99
|
+
if (!this.adapter) return;
|
|
100
|
+
try {
|
|
101
|
+
const phases = this.adapter.getOpenPhaseTimings();
|
|
102
|
+
if (!phases) return;
|
|
103
|
+
emitBootLog("[xNet] sqlite open phases", {
|
|
104
|
+
...phases,
|
|
105
|
+
schemaApplyMs: this.adapter.schemaApplyMs,
|
|
106
|
+
retryAttempts: this.adapter.openRetryAttempts,
|
|
107
|
+
mode: this.adapter.getStorageMode()
|
|
108
|
+
});
|
|
109
|
+
} catch {
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* One-shot DB stats at open (exploration 0229): file size, page/freelist
|
|
114
|
+
* counts, storage mode. File size is the key signal for the bloat hypothesis
|
|
115
|
+
* (a giant pre-0227 presence blob inflating cold-read cost). Never throws.
|
|
116
|
+
*/
|
|
117
|
+
async logDbStats() {
|
|
118
|
+
if (!this.adapter) return;
|
|
119
|
+
try {
|
|
120
|
+
const [bytes, mode, pageCount, freelist] = await Promise.all([
|
|
121
|
+
this.adapter.getDatabaseSize(),
|
|
122
|
+
this.adapter.getStorageMode(),
|
|
123
|
+
this.adapter.queryOne("PRAGMA page_count"),
|
|
124
|
+
this.adapter.queryOne("PRAGMA freelist_count")
|
|
125
|
+
]);
|
|
126
|
+
emitBootLog("[xNet] db stats @ open", {
|
|
127
|
+
bytes,
|
|
128
|
+
mode,
|
|
129
|
+
pageCount: pageCount?.page_count,
|
|
130
|
+
freelistCount: freelist?.freelist_count
|
|
131
|
+
});
|
|
132
|
+
} catch {
|
|
133
|
+
}
|
|
28
134
|
}
|
|
29
135
|
async resetStorage(config) {
|
|
30
136
|
if (this.adapter) {
|
|
@@ -34,41 +140,101 @@ var SQLiteWorkerHandler = class {
|
|
|
34
140
|
await resetWebSQLiteOpfsStorage(config);
|
|
35
141
|
}
|
|
36
142
|
async close() {
|
|
37
|
-
if (this.adapter)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
143
|
+
if (!this.adapter) return;
|
|
144
|
+
return this.scheduler.schedule(
|
|
145
|
+
"write",
|
|
146
|
+
async () => {
|
|
147
|
+
if (this.adapter) {
|
|
148
|
+
await this.adapter.close();
|
|
149
|
+
this.adapter = null;
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
void 0,
|
|
153
|
+
"close"
|
|
154
|
+
);
|
|
41
155
|
}
|
|
42
156
|
isOpen() {
|
|
43
157
|
return this.adapter?.isOpen() ?? false;
|
|
44
158
|
}
|
|
45
159
|
async query(sql, params) {
|
|
46
160
|
if (!this.adapter) throw new Error("Database not open");
|
|
47
|
-
return this.
|
|
161
|
+
return this.scheduler.schedule(
|
|
162
|
+
"interactive",
|
|
163
|
+
() => this.adapter.query(sql, params),
|
|
164
|
+
readKey("query", sql, params),
|
|
165
|
+
"query",
|
|
166
|
+
sqlDetail(sql)
|
|
167
|
+
);
|
|
48
168
|
}
|
|
49
169
|
async queryOne(sql, params) {
|
|
50
170
|
if (!this.adapter) throw new Error("Database not open");
|
|
51
|
-
return this.
|
|
171
|
+
return this.scheduler.schedule(
|
|
172
|
+
"interactive",
|
|
173
|
+
() => this.adapter.queryOne(sql, params),
|
|
174
|
+
readKey("queryOne", sql, params),
|
|
175
|
+
"queryOne",
|
|
176
|
+
sqlDetail(sql)
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Execute a batch of reads as ONE scheduled job — one RPC, one queue slot —
|
|
181
|
+
* instead of one round-trip per query (exploration 0263). The batch runs on
|
|
182
|
+
* the interactive lane uncoalesced: its members are heterogeneous, so a
|
|
183
|
+
* batch-level coalesce key would rarely hit, and member-level coalescing
|
|
184
|
+
* would let other jobs interleave mid-batch.
|
|
185
|
+
*/
|
|
186
|
+
async queryBatch(reads) {
|
|
187
|
+
if (!this.adapter) throw new Error("Database not open");
|
|
188
|
+
if (reads.length === 0) return [];
|
|
189
|
+
return this.scheduler.schedule(
|
|
190
|
+
"interactive",
|
|
191
|
+
() => this.adapter.queryBatch(reads),
|
|
192
|
+
void 0,
|
|
193
|
+
"queryBatch",
|
|
194
|
+
`${reads.length} reads: ${sqlDetail(reads[0].sql)}`
|
|
195
|
+
);
|
|
52
196
|
}
|
|
53
197
|
async run(sql, params) {
|
|
54
198
|
if (!this.adapter) throw new Error("Database not open");
|
|
55
|
-
return this.
|
|
199
|
+
return this.scheduler.schedule(
|
|
200
|
+
"write",
|
|
201
|
+
() => this.adapter.run(sql, params),
|
|
202
|
+
void 0,
|
|
203
|
+
"run",
|
|
204
|
+
sqlDetail(sql)
|
|
205
|
+
);
|
|
56
206
|
}
|
|
57
207
|
async exec(sql) {
|
|
58
208
|
if (!this.adapter) throw new Error("Database not open");
|
|
59
|
-
return this.
|
|
209
|
+
return this.scheduler.schedule(
|
|
210
|
+
"write",
|
|
211
|
+
() => this.adapter.exec(sql),
|
|
212
|
+
void 0,
|
|
213
|
+
"exec",
|
|
214
|
+
sqlDetail(sql)
|
|
215
|
+
);
|
|
60
216
|
}
|
|
61
217
|
async transaction(operations) {
|
|
62
218
|
if (!this.adapter) throw new Error("Database not open");
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
219
|
+
return this.scheduler.schedule(
|
|
220
|
+
"write",
|
|
221
|
+
() => this.adapter.transaction(async () => {
|
|
222
|
+
for (const op of operations) {
|
|
223
|
+
await this.adapter.run(op.sql, op.params);
|
|
224
|
+
}
|
|
225
|
+
}),
|
|
226
|
+
void 0,
|
|
227
|
+
"transaction"
|
|
228
|
+
);
|
|
68
229
|
}
|
|
69
230
|
async applyNodeBatch(input) {
|
|
70
231
|
if (!this.adapter) throw new Error("Database not open");
|
|
71
|
-
return this.
|
|
232
|
+
return this.scheduler.schedule(
|
|
233
|
+
"write",
|
|
234
|
+
() => this.adapter.applyNodeBatch(input),
|
|
235
|
+
void 0,
|
|
236
|
+
"applyNodeBatch"
|
|
237
|
+
);
|
|
72
238
|
}
|
|
73
239
|
async getSchemaVersion() {
|
|
74
240
|
if (!this.adapter) throw new Error("Database not open");
|
|
@@ -76,7 +242,28 @@ var SQLiteWorkerHandler = class {
|
|
|
76
242
|
}
|
|
77
243
|
async vacuum() {
|
|
78
244
|
if (!this.adapter) throw new Error("Database not open");
|
|
79
|
-
return this.adapter.vacuum();
|
|
245
|
+
return this.scheduler.schedule("write", () => this.adapter.vacuum(), void 0, "vacuum");
|
|
246
|
+
}
|
|
247
|
+
async incrementalVacuum(maxPages) {
|
|
248
|
+
if (!this.adapter) throw new Error("Database not open");
|
|
249
|
+
return this.scheduler.schedule(
|
|
250
|
+
"write",
|
|
251
|
+
() => this.adapter.incrementalVacuum(maxPages),
|
|
252
|
+
void 0,
|
|
253
|
+
"incremental_vacuum"
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
/** Current scheduler queue depths (diagnostics / the perf panel). */
|
|
257
|
+
async getSchedulerSnapshot() {
|
|
258
|
+
return this.scheduler.snapshot();
|
|
259
|
+
}
|
|
260
|
+
/** Cumulative per-lane op latency percentiles + coalesce hits (0263). */
|
|
261
|
+
async getSchedulerOpStats() {
|
|
262
|
+
return this.scheduler.opStats();
|
|
263
|
+
}
|
|
264
|
+
/** Zero the op-stats counters for a focused before/after measurement. */
|
|
265
|
+
async resetSchedulerOpStats() {
|
|
266
|
+
this.scheduler.resetOpStats();
|
|
80
267
|
}
|
|
81
268
|
async getDatabaseSize() {
|
|
82
269
|
if (!this.adapter) throw new Error("Database not open");
|
|
@@ -96,8 +283,36 @@ var SQLiteWorkerHandler = class {
|
|
|
96
283
|
*/
|
|
97
284
|
connectPort(port) {
|
|
98
285
|
Comlink.expose(this, port);
|
|
286
|
+
this.scheduleAbandonedTransactionRecovery();
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* PowerSync-style lease recovery (exploration 0263): a client that died
|
|
290
|
+
* between a manual BEGIN and its COMMIT leaves the shared connection stuck
|
|
291
|
+
* in a transaction, wedging every other client's writes. When a NEW client
|
|
292
|
+
* connects, roll back any transaction that has been idle long enough that
|
|
293
|
+
* its owner is clearly gone. Batch APIs (`transaction`, `applyNodeBatch`)
|
|
294
|
+
* are single-RPC and can never be abandoned — this only covers the manual
|
|
295
|
+
* BEGIN…COMMIT path.
|
|
296
|
+
*/
|
|
297
|
+
scheduleAbandonedTransactionRecovery() {
|
|
298
|
+
void this.scheduler.schedule(
|
|
299
|
+
"write",
|
|
300
|
+
async () => {
|
|
301
|
+
const adapter = this.adapter;
|
|
302
|
+
if (!adapter || !adapter.isInTransaction()) return;
|
|
303
|
+
if (adapter.transactionIdleMs() < ABANDONED_TRANSACTION_IDLE_MS) return;
|
|
304
|
+
console.warn(
|
|
305
|
+
"[SQLiteWorkerHandler] Rolling back an abandoned transaction \u2014 a client likely died between BEGIN and COMMIT (0263 lease recovery)."
|
|
306
|
+
);
|
|
307
|
+
await adapter.rollback();
|
|
308
|
+
},
|
|
309
|
+
void 0,
|
|
310
|
+
"txn-recovery"
|
|
311
|
+
).catch(() => {
|
|
312
|
+
});
|
|
99
313
|
}
|
|
100
314
|
};
|
|
315
|
+
var ABANDONED_TRANSACTION_IDLE_MS = 5e3;
|
|
101
316
|
var handler = new SQLiteWorkerHandler();
|
|
102
317
|
log("[SQLiteWorker] Handler instance created");
|
|
103
318
|
log("[SQLiteWorker] Exposing handler via Comlink...");
|
package/dist/adapters/web.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { S as SQLiteAdapter, P as PreparedStatement } from '../adapter-
|
|
2
|
-
import { b as SQLiteConfig, a as SQLRow, S as SQLValue, R as RunResult,
|
|
1
|
+
import { S as SQLiteAdapter, P as PreparedStatement } from '../adapter-imtxkmXZ.js';
|
|
2
|
+
import { b as SQLiteConfig, a as SQLRow, S as SQLValue, c as SQLBatchRead, R as RunResult, l as SQLiteNodeBatchApplyInput, m as SQLiteNodeBatchApplyResult } from '../types-BTabr_VP.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* @xnetjs/sqlite - Web SQLite adapter using @sqlite.org/sqlite-wasm
|
|
@@ -8,6 +8,30 @@ import { b as SQLiteConfig, a as SQLRow, S as SQLValue, R as RunResult, k as SQL
|
|
|
8
8
|
* Must run in a Web Worker for OPFS access.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Per-phase timing of {@link WebSQLiteAdapter.open}. Every prior cold-open
|
|
13
|
+
* exploration (0204→0249) timed the layer the stall was *in*, so the cost kept
|
|
14
|
+
* hopping to the next un-timed layer; by the 0253 capture both `queueMs` and
|
|
15
|
+
* `execMs` read 0 and the ~17 s lived entirely in this `open()` window, which no
|
|
16
|
+
* timer bracketed. These fields split it so the dominant sub-phase is named in
|
|
17
|
+
* one boot-log line. All durations are milliseconds, rounded.
|
|
18
|
+
*/
|
|
19
|
+
interface OpenPhaseTimings {
|
|
20
|
+
/** Dynamic `import('@sqlite.org/sqlite-wasm')` (bundle parse/download). */
|
|
21
|
+
wasmImportMs: number;
|
|
22
|
+
/** `sqlite3InitModule()` — WASM instantiate. */
|
|
23
|
+
wasmInitMs: number;
|
|
24
|
+
/** `installOpfsSAHPoolVfs()` — acquires the pool's sync access handles; INCLUDES any lock-retry backoff. */
|
|
25
|
+
vfsInstallMs: number;
|
|
26
|
+
/** `reserveMinimumCapacity()` — may acquire more handles / grow the pool. */
|
|
27
|
+
reserveCapacityMs: number;
|
|
28
|
+
/** `new OpfsSAHPoolDb()` — open the database file in the pool. */
|
|
29
|
+
dbOpenMs: number;
|
|
30
|
+
/** All post-open `PRAGMA` settings (page_size/cache/mmap/journal/…). */
|
|
31
|
+
pragmasMs: number;
|
|
32
|
+
/** Whole `open()` span (import → last pragma). */
|
|
33
|
+
totalOpenMs: number;
|
|
34
|
+
}
|
|
11
35
|
/**
|
|
12
36
|
* Remove xNet's OPFS-backed SQLite storage for the supplied database path.
|
|
13
37
|
*
|
|
@@ -40,13 +64,53 @@ declare class WebSQLiteAdapter implements SQLiteAdapter {
|
|
|
40
64
|
private poolUtil;
|
|
41
65
|
private _config;
|
|
42
66
|
private inTransaction;
|
|
67
|
+
/**
|
|
68
|
+
* Monotonic time of the open transaction's last operation. Lets the worker
|
|
69
|
+
* host distinguish a LIVE manual transaction from one abandoned by a client
|
|
70
|
+
* that died between BEGIN and COMMIT (PowerSync-style lease recovery, 0263).
|
|
71
|
+
*/
|
|
72
|
+
private lastTransactionActivityAt;
|
|
43
73
|
private storageMode;
|
|
74
|
+
/**
|
|
75
|
+
* Per-phase timing of the last {@link open} call, or null until it runs.
|
|
76
|
+
* Read by the worker host to emit `[xNet] sqlite open phases` (exploration
|
|
77
|
+
* 0253). Diagnostic only — never affects behaviour.
|
|
78
|
+
*/
|
|
79
|
+
private openPhaseTimings;
|
|
80
|
+
/** ms spent in `applySchema()` during {@link createWebSQLiteAdapter}, for the open-phases line. */
|
|
81
|
+
schemaApplyMs: number;
|
|
82
|
+
/** How many times the OPFS lock-retry backoff fired during the last {@link open} (0 = first try). */
|
|
83
|
+
openRetryAttempts: number;
|
|
84
|
+
/**
|
|
85
|
+
* Prepared-statement LRU for the hot query/run path (exploration 0263).
|
|
86
|
+
* `db.exec()` re-parses the SQL on every call; the hot path repeats a small
|
|
87
|
+
* statement set, so cached `oo1.Stmt` handles skip the parse+prepare cost.
|
|
88
|
+
* Cleared (finalizing all handles) on {@link exec} — the DDL/script path —
|
|
89
|
+
* and on {@link close}.
|
|
90
|
+
*/
|
|
91
|
+
private stmts;
|
|
92
|
+
/** Per-phase timing of the last {@link open}, or null if it hasn't run. */
|
|
93
|
+
getOpenPhaseTimings(): OpenPhaseTimings | null;
|
|
44
94
|
open(config: SQLiteConfig): Promise<void>;
|
|
45
95
|
close(): Promise<void>;
|
|
46
96
|
isOpen(): boolean;
|
|
47
97
|
getStorageMode(): 'opfs' | 'memory';
|
|
98
|
+
/** True while a manual BEGIN…COMMIT/ROLLBACK transaction is open. */
|
|
99
|
+
isInTransaction(): boolean;
|
|
100
|
+
/** ms since the open transaction last executed an operation (0 when none). */
|
|
101
|
+
transactionIdleMs(): number;
|
|
102
|
+
/** Stamp transaction liveness — called by ops that run inside one. */
|
|
103
|
+
private touchTransaction;
|
|
104
|
+
/**
|
|
105
|
+
* Fetch (or prepare and cache) the statement for `sql`, or null when the SQL
|
|
106
|
+
* must bypass the cache: `db.prepare()` compiles only the FIRST statement of
|
|
107
|
+
* a multi-statement string, whereas `db.exec()` runs them all — serving such
|
|
108
|
+
* SQL from the cache would silently drop statements.
|
|
109
|
+
*/
|
|
110
|
+
private getCachedStmt;
|
|
48
111
|
query<T extends SQLRow = SQLRow>(sql: string, params?: SQLValue[]): Promise<T[]>;
|
|
49
112
|
queryOne<T extends SQLRow = SQLRow>(sql: string, params?: SQLValue[]): Promise<T | null>;
|
|
113
|
+
queryBatch(reads: SQLBatchRead[]): Promise<SQLRow[][]>;
|
|
50
114
|
run(sql: string, params?: SQLValue[]): Promise<RunResult>;
|
|
51
115
|
exec(sql: string): Promise<void>;
|
|
52
116
|
private execSync;
|
|
@@ -61,12 +125,27 @@ declare class WebSQLiteAdapter implements SQLiteAdapter {
|
|
|
61
125
|
applySchema(version: number, sql: string): Promise<boolean>;
|
|
62
126
|
getDatabaseSize(): Promise<number>;
|
|
63
127
|
vacuum(): Promise<void>;
|
|
128
|
+
incrementalVacuum(maxPages?: number): Promise<number>;
|
|
64
129
|
checkpoint(): Promise<number>;
|
|
65
130
|
private ensureOpen;
|
|
66
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Step `PRAGMA incremental_vacuum` to completion (or `maxPages`), returning
|
|
134
|
+
* the number of pages freed. SQLite frees ONE page per `sqlite3_step` of this
|
|
135
|
+
* pragma; the oo1 `exec` path steps a statement only once when it collects no
|
|
136
|
+
* rows, so `exec('PRAGMA incremental_vacuum')` silently freed a single page
|
|
137
|
+
* per call — the compaction reclaim was a near-no-op (caught by the local
|
|
138
|
+
* bloat-seed validation, 2026-07-05). Exported for the engine-level test.
|
|
139
|
+
*/
|
|
140
|
+
declare function stepIncrementalVacuumToCompletion(db: {
|
|
141
|
+
prepare(sql: string): {
|
|
142
|
+
step(): boolean;
|
|
143
|
+
finalize(): unknown;
|
|
144
|
+
};
|
|
145
|
+
}, maxPages?: number): number;
|
|
67
146
|
/**
|
|
68
147
|
* Create a WebSQLiteAdapter with schema applied.
|
|
69
148
|
*/
|
|
70
149
|
declare function createWebSQLiteAdapter(config: SQLiteConfig): Promise<WebSQLiteAdapter>;
|
|
71
150
|
|
|
72
|
-
export { WebSQLiteAdapter, createWebSQLiteAdapter, resetWebSQLiteOpfsStorage };
|
|
151
|
+
export { type OpenPhaseTimings, WebSQLiteAdapter, createWebSQLiteAdapter, resetWebSQLiteOpfsStorage, stepIncrementalVacuumToCompletion };
|