@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
|
@@ -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 } from '../types-
|
|
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,21 +13,75 @@ import { b as SQLiteConfig, a as SQLRow, S as SQLValue, R as RunResult } from '.
|
|
|
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;
|
|
35
|
+
resetStorage(config: SQLiteConfig): Promise<void>;
|
|
16
36
|
close(): Promise<void>;
|
|
17
37
|
isOpen(): boolean;
|
|
18
38
|
query<T extends SQLRow = SQLRow>(sql: string, params?: SQLValue[]): Promise<T[]>;
|
|
19
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[][]>;
|
|
20
48
|
run(sql: string, params?: SQLValue[]): Promise<RunResult>;
|
|
21
49
|
exec(sql: string): Promise<void>;
|
|
22
50
|
transaction(operations: Array<{
|
|
23
51
|
sql: string;
|
|
24
52
|
params?: SQLValue[];
|
|
25
53
|
}>): Promise<void>;
|
|
54
|
+
applyNodeBatch(input: SQLiteNodeBatchApplyInput): Promise<SQLiteNodeBatchApplyResult>;
|
|
26
55
|
getSchemaVersion(): Promise<number>;
|
|
27
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>;
|
|
28
64
|
getDatabaseSize(): Promise<number>;
|
|
29
65
|
getStorageMode(): Promise<'opfs' | 'memory'>;
|
|
66
|
+
/**
|
|
67
|
+
* Expose this handler on an additional MessagePort.
|
|
68
|
+
*
|
|
69
|
+
* Lets another worker (e.g. the data worker from @xnetjs/data-bridge)
|
|
70
|
+
* talk to this SQLite worker directly without routing every storage call
|
|
71
|
+
* through the main thread. The port is created on the main thread with
|
|
72
|
+
* `WebSQLiteProxy.createMessagePort()` and transferred to the peer.
|
|
73
|
+
*/
|
|
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;
|
|
30
85
|
}
|
|
31
86
|
|
|
32
87
|
export { SQLiteWorkerHandler };
|
|
@@ -1,10 +1,38 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
WorkerScheduler,
|
|
3
|
+
firstOpGapFields
|
|
4
|
+
} from "../chunk-5HC5V73T.js";
|
|
5
|
+
import {
|
|
6
|
+
createWebSQLiteAdapter,
|
|
7
|
+
resetWebSQLiteOpfsStorage
|
|
8
|
+
} from "../chunk-BBZDKLA3.js";
|
|
9
|
+
import "../chunk-SV475UL5.js";
|
|
10
|
+
import {
|
|
11
|
+
bootLogMessage
|
|
12
|
+
} from "../chunk-CGI2YBZY.js";
|
|
13
|
+
import "../chunk-CBU263LI.js";
|
|
14
|
+
import "../chunk-W3L4FXU5.js";
|
|
5
15
|
|
|
6
16
|
// src/adapters/web-worker.ts
|
|
7
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
|
+
}
|
|
8
36
|
function isDebugEnabled() {
|
|
9
37
|
return typeof self !== "undefined" && typeof localStorage !== "undefined" && localStorage.getItem("xnet:sqlite:debug") === "true";
|
|
10
38
|
}
|
|
@@ -16,46 +44,197 @@ function log(...args) {
|
|
|
16
44
|
log("[SQLiteWorker] Worker script loaded, Comlink imported");
|
|
17
45
|
var SQLiteWorkerHandler = class {
|
|
18
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
|
+
});
|
|
19
77
|
async open(config) {
|
|
20
78
|
log("[SQLiteWorkerHandler] open() called with config:", config);
|
|
21
79
|
if (this.adapter) {
|
|
22
80
|
throw new Error("Database already open");
|
|
23
81
|
}
|
|
82
|
+
this.bootDebug = config.bootDebug ?? false;
|
|
24
83
|
this.adapter = await createWebSQLiteAdapter(config);
|
|
84
|
+
this.openedAtMs = nowMs();
|
|
25
85
|
log("[SQLiteWorkerHandler] open() completed");
|
|
86
|
+
if (this.bootDebug) {
|
|
87
|
+
this.logOpenPhases();
|
|
88
|
+
await this.logDbStats();
|
|
89
|
+
}
|
|
26
90
|
}
|
|
27
|
-
|
|
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
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async resetStorage(config) {
|
|
28
136
|
if (this.adapter) {
|
|
29
137
|
await this.adapter.close();
|
|
30
138
|
this.adapter = null;
|
|
31
139
|
}
|
|
140
|
+
await resetWebSQLiteOpfsStorage(config);
|
|
141
|
+
}
|
|
142
|
+
async close() {
|
|
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
|
+
);
|
|
32
155
|
}
|
|
33
156
|
isOpen() {
|
|
34
157
|
return this.adapter?.isOpen() ?? false;
|
|
35
158
|
}
|
|
36
159
|
async query(sql, params) {
|
|
37
160
|
if (!this.adapter) throw new Error("Database not open");
|
|
38
|
-
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
|
+
);
|
|
39
168
|
}
|
|
40
169
|
async queryOne(sql, params) {
|
|
41
170
|
if (!this.adapter) throw new Error("Database not open");
|
|
42
|
-
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
|
+
);
|
|
43
196
|
}
|
|
44
197
|
async run(sql, params) {
|
|
45
198
|
if (!this.adapter) throw new Error("Database not open");
|
|
46
|
-
return this.
|
|
199
|
+
return this.scheduler.schedule(
|
|
200
|
+
"write",
|
|
201
|
+
() => this.adapter.run(sql, params),
|
|
202
|
+
void 0,
|
|
203
|
+
"run",
|
|
204
|
+
sqlDetail(sql)
|
|
205
|
+
);
|
|
47
206
|
}
|
|
48
207
|
async exec(sql) {
|
|
49
208
|
if (!this.adapter) throw new Error("Database not open");
|
|
50
|
-
return this.
|
|
209
|
+
return this.scheduler.schedule(
|
|
210
|
+
"write",
|
|
211
|
+
() => this.adapter.exec(sql),
|
|
212
|
+
void 0,
|
|
213
|
+
"exec",
|
|
214
|
+
sqlDetail(sql)
|
|
215
|
+
);
|
|
51
216
|
}
|
|
52
217
|
async transaction(operations) {
|
|
53
218
|
if (!this.adapter) throw new Error("Database not open");
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
);
|
|
229
|
+
}
|
|
230
|
+
async applyNodeBatch(input) {
|
|
231
|
+
if (!this.adapter) throw new Error("Database not open");
|
|
232
|
+
return this.scheduler.schedule(
|
|
233
|
+
"write",
|
|
234
|
+
() => this.adapter.applyNodeBatch(input),
|
|
235
|
+
void 0,
|
|
236
|
+
"applyNodeBatch"
|
|
237
|
+
);
|
|
59
238
|
}
|
|
60
239
|
async getSchemaVersion() {
|
|
61
240
|
if (!this.adapter) throw new Error("Database not open");
|
|
@@ -63,7 +242,28 @@ var SQLiteWorkerHandler = class {
|
|
|
63
242
|
}
|
|
64
243
|
async vacuum() {
|
|
65
244
|
if (!this.adapter) throw new Error("Database not open");
|
|
66
|
-
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();
|
|
67
267
|
}
|
|
68
268
|
async getDatabaseSize() {
|
|
69
269
|
if (!this.adapter) throw new Error("Database not open");
|
|
@@ -73,7 +273,46 @@ var SQLiteWorkerHandler = class {
|
|
|
73
273
|
if (!this.adapter) throw new Error("Database not open");
|
|
74
274
|
return this.adapter.getStorageMode();
|
|
75
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* Expose this handler on an additional MessagePort.
|
|
278
|
+
*
|
|
279
|
+
* Lets another worker (e.g. the data worker from @xnetjs/data-bridge)
|
|
280
|
+
* talk to this SQLite worker directly without routing every storage call
|
|
281
|
+
* through the main thread. The port is created on the main thread with
|
|
282
|
+
* `WebSQLiteProxy.createMessagePort()` and transferred to the peer.
|
|
283
|
+
*/
|
|
284
|
+
connectPort(port) {
|
|
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
|
+
});
|
|
313
|
+
}
|
|
76
314
|
};
|
|
315
|
+
var ABANDONED_TRANSACTION_IDLE_MS = 5e3;
|
|
77
316
|
var handler = new SQLiteWorkerHandler();
|
|
78
317
|
log("[SQLiteWorker] Handler instance created");
|
|
79
318
|
log("[SQLiteWorker] Exposing handler via Comlink...");
|