@tanstack/electron-db-sqlite-persistence 0.1.33 → 0.2.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/README.md +72 -0
- package/dist/cjs/electron-coordinator.cjs +5 -534
- package/dist/cjs/electron-coordinator.cjs.map +1 -1
- package/dist/cjs/electron-coordinator.d.cts +3 -65
- package/dist/cjs/index.cjs +16 -0
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +2 -2
- package/dist/cjs/main.cjs +13 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/protocol.cjs +1 -1
- package/dist/cjs/protocol.cjs.map +1 -1
- package/dist/cjs/protocol.d.cts +25 -3
- package/dist/cjs/renderer.cjs +24 -8
- package/dist/cjs/renderer.cjs.map +1 -1
- package/dist/esm/electron-coordinator.d.ts +3 -65
- package/dist/esm/electron-coordinator.js +5 -534
- package/dist/esm/electron-coordinator.js.map +1 -1
- package/dist/esm/index.d.ts +2 -2
- package/dist/esm/index.js +5 -1
- package/dist/esm/main.js +13 -0
- package/dist/esm/main.js.map +1 -1
- package/dist/esm/protocol.d.ts +25 -3
- package/dist/esm/protocol.js +1 -1
- package/dist/esm/protocol.js.map +1 -1
- package/dist/esm/renderer.js +24 -8
- package/dist/esm/renderer.js.map +1 -1
- package/package.json +2 -2
- package/src/electron-coordinator.ts +10 -834
- package/src/index.ts +12 -1
- package/src/main.ts +14 -0
- package/src/protocol.ts +22 -1
- package/src/renderer.ts +33 -14
package/README.md
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
Thin Electron bridge for TanStack DB SQLite persistence.
|
|
4
4
|
|
|
5
|
+
Start with the [SQLite Persistence guide](../../docs/guides/sqlite-persistence.md) for Collection setup and lifecycle behavior.
|
|
6
|
+
|
|
5
7
|
## Public API
|
|
6
8
|
|
|
7
9
|
- `exposeElectronSQLitePersistence(...)` (main process)
|
|
8
10
|
- `createElectronSQLitePersistence(...)` (renderer process)
|
|
11
|
+
- `ElectronCollectionCoordinator`
|
|
9
12
|
- `persistedCollectionOptions(...)` (re-exported from core)
|
|
10
13
|
|
|
11
14
|
Use `@tanstack/electron-db-sqlite-persistence/main` and
|
|
@@ -64,8 +67,77 @@ export const todosCollection = createCollection(
|
|
|
64
67
|
)
|
|
65
68
|
```
|
|
66
69
|
|
|
70
|
+
## Multi-renderer ownership
|
|
71
|
+
|
|
72
|
+
Pass an `ElectronCollectionCoordinator` when multiple renderers share the same
|
|
73
|
+
database. Coordinators using the same `dbName` elect a writer independently for
|
|
74
|
+
each collection.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import {
|
|
78
|
+
ElectronCollectionCoordinator,
|
|
79
|
+
createElectronSQLitePersistence,
|
|
80
|
+
} from '@tanstack/electron-db-sqlite-persistence'
|
|
81
|
+
|
|
82
|
+
const coordinator = new ElectronCollectionCoordinator({
|
|
83
|
+
dbName: `tanstack-db`,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const persistence = createElectronSQLitePersistence({
|
|
87
|
+
ipcRenderer,
|
|
88
|
+
coordinator,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// On teardown:
|
|
92
|
+
// coordinator.dispose()
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The persisted sync wrapper routes every source transaction with durable effects
|
|
96
|
+
through the coordinator's required
|
|
97
|
+
`requestApplyCommittedTx(collectionId, tx)` method. The elected renderer applies
|
|
98
|
+
the complete `PersistedTx` through its resolved per-collection renderer adapter,
|
|
99
|
+
which forwards it over IPC to the main-process persistence owner. Resolution
|
|
100
|
+
remains specific to the collection id, mode, and `schemaVersion`.
|
|
101
|
+
|
|
102
|
+
Truncation, row changes, row metadata, collection metadata, and stream position
|
|
103
|
+
stay in the same transaction. Electron does not feature-detect a partial route
|
|
104
|
+
or fall back to row-only mutation RPC. A custom coordinator that omits
|
|
105
|
+
`requestApplyCommittedTx` is rejected while the collection is configured,
|
|
106
|
+
before its sync source can publish rows. An effect-free source commit neither
|
|
107
|
+
publishes nor consumes a coordinator sequence.
|
|
108
|
+
|
|
109
|
+
If a mutating RPC loses its response, Electron coordination replays it only
|
|
110
|
+
while the requester still knows the same non-null leader id and term. An
|
|
111
|
+
unknown initial route or any leader/term change rejects with
|
|
112
|
+
`IndeterminateCommitError`; the application must reconcile the outcome. The
|
|
113
|
+
coordinator does not retry that mutation against an unknown or replacement
|
|
114
|
+
leader.
|
|
115
|
+
|
|
116
|
+
Remote-subset requests use the same crash-only boundary. The coordinator
|
|
117
|
+
projects a live `LoadSubsetOptions` input to the exported
|
|
118
|
+
`TransportedLoadSubsetOptions` type before either local completion or renderer
|
|
119
|
+
transport. Live `signal` and `subscription` fields do not cross the boundary.
|
|
120
|
+
Unsupported nested values throw `RemoteSubsetWireValueError` with the exact
|
|
121
|
+
value path before publication; Electron does not add codecs, coercion, or a
|
|
122
|
+
fallback representation.
|
|
123
|
+
|
|
124
|
+
Electron uses the same explicit lease lifecycle as Browser coordination. The
|
|
125
|
+
same request object reuses one acquisition identity, distinct equal objects
|
|
126
|
+
remain independent, and release unloads the exact transported options once.
|
|
127
|
+
Leadership loss unloads the retiring renderer's leases; still-live requester
|
|
128
|
+
leases replay against the next leader. A second owner registration for one
|
|
129
|
+
collection throws `DuplicateRemoteSubsetOwnerError`, and there is no adapter
|
|
130
|
+
fallback for a missing owner. Remote follower transport/admission failures
|
|
131
|
+
reject and retain demand for its normal retry without entering the follower's
|
|
132
|
+
local owner lifecycle. Terminal release identities expire after the existing
|
|
133
|
+
RPC dedupe horizon while delayed duplicates inside that horizon remain
|
|
134
|
+
idempotent.
|
|
135
|
+
|
|
67
136
|
## Notes
|
|
68
137
|
|
|
69
138
|
- The renderer API mirrors other runtimes: one shared `create...Persistence`.
|
|
70
139
|
- Collection mode (`sync-present` vs `sync-absent`) and `schemaVersion` are
|
|
71
140
|
resolved per collection and forwarded across IPC automatically.
|
|
141
|
+
- Without an `ElectronCollectionCoordinator`, single-renderer mode uses
|
|
142
|
+
`SingleProcessCoordinator`: it is always leader, but still routes complete
|
|
143
|
+
transactions to the collection's resolved IPC adapter.
|
|
@@ -1,542 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
const RPC_TIMEOUT_MS = 1e4;
|
|
6
|
-
const RPC_RETRY_ATTEMPTS = 2;
|
|
7
|
-
const RPC_RETRY_DELAY_MS = 200;
|
|
8
|
-
const WRITER_LOCK_BUSY_RETRY_MS = 50;
|
|
9
|
-
const WRITER_LOCK_MAX_RETRIES = 20;
|
|
10
|
-
class ElectronCollectionCoordinator {
|
|
3
|
+
const broadcastCoordinator = require("@tanstack/db-sqlite-persistence-core/broadcast-coordinator");
|
|
4
|
+
class ElectronCollectionCoordinator extends broadcastCoordinator.BroadcastCollectionCoordinator {
|
|
11
5
|
constructor(options) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
this.appliedEnvelopeIds = /* @__PURE__ */ new Map();
|
|
16
|
-
this.disposed = false;
|
|
17
|
-
this.dbName = options.dbName;
|
|
18
|
-
this.adapter = options.adapter ?? null;
|
|
19
|
-
this.channel = new BroadcastChannel(`tsdb:coord:${this.dbName}`);
|
|
20
|
-
this.channel.onmessage = (event) => {
|
|
21
|
-
this.onChannelMessage(event.data);
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
/** Method indirection to prevent TypeScript from narrowing `disposed` across awaits */
|
|
25
|
-
isDisposed() {
|
|
26
|
-
return this.disposed;
|
|
27
|
-
}
|
|
28
|
-
requireAdapter() {
|
|
29
|
-
if (!this.adapter) {
|
|
30
|
-
throw new Error(
|
|
31
|
-
`ElectronCollectionCoordinator: adapter not set. Call setAdapter() before using leader-side operations.`
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
return this.adapter;
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Set or replace the persistence adapter used for leader-side RPC handling.
|
|
38
|
-
* Called by `createElectronSQLitePersistence` to wire the internally-created
|
|
39
|
-
* adapter into the coordinator.
|
|
40
|
-
*/
|
|
41
|
-
setAdapter(adapter) {
|
|
42
|
-
this.adapter = adapter;
|
|
43
|
-
}
|
|
44
|
-
// -----------------------------------------------------------------------
|
|
45
|
-
// PersistedCollectionCoordinator interface
|
|
46
|
-
// -----------------------------------------------------------------------
|
|
47
|
-
getNodeId() {
|
|
48
|
-
return this.nodeId;
|
|
49
|
-
}
|
|
50
|
-
subscribe(collectionId, onMessage) {
|
|
51
|
-
const state = this.ensureCollectionState(collectionId);
|
|
52
|
-
state.subscribers.add(onMessage);
|
|
53
|
-
return () => {
|
|
54
|
-
state.subscribers.delete(onMessage);
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
publish(_collectionId, message) {
|
|
58
|
-
this.channel.postMessage(message);
|
|
59
|
-
}
|
|
60
|
-
isLeader(collectionId) {
|
|
61
|
-
return this.collections.get(collectionId)?.isLeader ?? false;
|
|
62
|
-
}
|
|
63
|
-
async ensureLeadership(collectionId) {
|
|
64
|
-
const state = this.ensureCollectionState(collectionId);
|
|
65
|
-
if (state.isLeader) return;
|
|
66
|
-
await this.acquireLeadership(collectionId, state);
|
|
67
|
-
}
|
|
68
|
-
async requestEnsureRemoteSubset(collectionId, options) {
|
|
69
|
-
if (this.isLeader(collectionId)) return;
|
|
70
|
-
const response = await this.sendRPC(collectionId, {
|
|
71
|
-
type: `rpc:ensureRemoteSubset:req`,
|
|
72
|
-
rpcId: dbSqlitePersistenceCore.safeRandomUUID(),
|
|
73
|
-
options
|
|
6
|
+
super({
|
|
7
|
+
...options,
|
|
8
|
+
coordinatorName: `ElectronCollectionCoordinator`
|
|
74
9
|
});
|
|
75
|
-
if (!response.ok) {
|
|
76
|
-
throw new Error(
|
|
77
|
-
`ensureRemoteSubset failed: ${response.error ?? `unknown error`}`
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
async requestEnsurePersistedIndex(collectionId, signature, spec) {
|
|
82
|
-
if (this.isLeader(collectionId)) {
|
|
83
|
-
await this.requireAdapter().ensureIndex(collectionId, signature, spec);
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
const response = await this.sendRPC(collectionId, {
|
|
87
|
-
type: `rpc:ensurePersistedIndex:req`,
|
|
88
|
-
rpcId: dbSqlitePersistenceCore.safeRandomUUID(),
|
|
89
|
-
signature,
|
|
90
|
-
spec
|
|
91
|
-
});
|
|
92
|
-
if (!response.ok) {
|
|
93
|
-
throw new Error(
|
|
94
|
-
`ensurePersistedIndex failed: ${response.error ?? `unknown error`}`
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
async requestApplyLocalMutations(collectionId, mutations) {
|
|
99
|
-
if (this.isLeader(collectionId)) {
|
|
100
|
-
return this.handleApplyLocalMutations(collectionId, {
|
|
101
|
-
type: `rpc:applyLocalMutations:req`,
|
|
102
|
-
rpcId: dbSqlitePersistenceCore.safeRandomUUID(),
|
|
103
|
-
envelopeId: dbSqlitePersistenceCore.safeRandomUUID(),
|
|
104
|
-
mutations
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
return this.sendRPC(collectionId, {
|
|
108
|
-
type: `rpc:applyLocalMutations:req`,
|
|
109
|
-
rpcId: dbSqlitePersistenceCore.safeRandomUUID(),
|
|
110
|
-
envelopeId: dbSqlitePersistenceCore.safeRandomUUID(),
|
|
111
|
-
mutations
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
async pullSince(collectionId, fromRowVersion) {
|
|
115
|
-
if (this.isLeader(collectionId)) {
|
|
116
|
-
return this.handlePullSince(collectionId, {
|
|
117
|
-
type: `rpc:pullSince:req`,
|
|
118
|
-
rpcId: dbSqlitePersistenceCore.safeRandomUUID(),
|
|
119
|
-
fromRowVersion
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
return this.sendRPC(collectionId, {
|
|
123
|
-
type: `rpc:pullSince:req`,
|
|
124
|
-
rpcId: dbSqlitePersistenceCore.safeRandomUUID(),
|
|
125
|
-
fromRowVersion
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
// -----------------------------------------------------------------------
|
|
129
|
-
// Lifecycle
|
|
130
|
-
// -----------------------------------------------------------------------
|
|
131
|
-
dispose() {
|
|
132
|
-
this.disposed = true;
|
|
133
|
-
for (const [collectionId, state] of this.collections) {
|
|
134
|
-
this.releaseLeadership(collectionId, state);
|
|
135
|
-
}
|
|
136
|
-
for (const [, pending] of this.pendingRPCs) {
|
|
137
|
-
clearTimeout(pending.timer);
|
|
138
|
-
pending.reject(new Error(`coordinator disposed`));
|
|
139
|
-
}
|
|
140
|
-
this.pendingRPCs.clear();
|
|
141
|
-
this.channel.close();
|
|
142
|
-
this.collections.clear();
|
|
143
|
-
}
|
|
144
|
-
// -----------------------------------------------------------------------
|
|
145
|
-
// Leadership via Web Locks
|
|
146
|
-
// -----------------------------------------------------------------------
|
|
147
|
-
ensureCollectionState(collectionId) {
|
|
148
|
-
let state = this.collections.get(collectionId);
|
|
149
|
-
if (!state) {
|
|
150
|
-
state = {
|
|
151
|
-
isLeader: false,
|
|
152
|
-
lockAbortController: null,
|
|
153
|
-
heartbeatTimer: null,
|
|
154
|
-
latestTerm: 0,
|
|
155
|
-
latestSeq: 0,
|
|
156
|
-
latestRowVersion: 0,
|
|
157
|
-
subscribers: /* @__PURE__ */ new Set()
|
|
158
|
-
};
|
|
159
|
-
this.collections.set(collectionId, state);
|
|
160
|
-
void this.acquireLeadership(collectionId, state);
|
|
161
|
-
}
|
|
162
|
-
return state;
|
|
163
10
|
}
|
|
164
|
-
async acquireLeadership(collectionId, state) {
|
|
165
|
-
if (this.disposed || state.isLeader) return;
|
|
166
|
-
const lockName = `tsdb:leader:${this.dbName}:${collectionId}`;
|
|
167
|
-
const abortController = new AbortController();
|
|
168
|
-
state.lockAbortController = abortController;
|
|
169
|
-
try {
|
|
170
|
-
await navigator.locks.request(
|
|
171
|
-
lockName,
|
|
172
|
-
{ signal: abortController.signal },
|
|
173
|
-
async () => {
|
|
174
|
-
if (this.isDisposed()) return;
|
|
175
|
-
try {
|
|
176
|
-
const adapter = this.requireAdapter();
|
|
177
|
-
if (adapter.getStreamPosition) {
|
|
178
|
-
const pos = await adapter.getStreamPosition(collectionId);
|
|
179
|
-
state.latestTerm = pos.latestTerm;
|
|
180
|
-
state.latestSeq = pos.latestSeq;
|
|
181
|
-
state.latestRowVersion = pos.latestRowVersion;
|
|
182
|
-
}
|
|
183
|
-
state.latestTerm++;
|
|
184
|
-
state.isLeader = true;
|
|
185
|
-
this.emitHeartbeat(collectionId, state);
|
|
186
|
-
state.heartbeatTimer = setInterval(() => {
|
|
187
|
-
this.emitHeartbeat(collectionId, state);
|
|
188
|
-
}, HEARTBEAT_INTERVAL_MS);
|
|
189
|
-
await new Promise((resolve) => {
|
|
190
|
-
const onAbort = () => {
|
|
191
|
-
abortController.signal.removeEventListener(`abort`, onAbort);
|
|
192
|
-
resolve();
|
|
193
|
-
};
|
|
194
|
-
if (abortController.signal.aborted) {
|
|
195
|
-
resolve();
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
abortController.signal.addEventListener(`abort`, onAbort);
|
|
199
|
-
});
|
|
200
|
-
} finally {
|
|
201
|
-
state.isLeader = false;
|
|
202
|
-
if (state.heartbeatTimer) {
|
|
203
|
-
clearInterval(state.heartbeatTimer);
|
|
204
|
-
state.heartbeatTimer = null;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
);
|
|
209
|
-
} catch (error) {
|
|
210
|
-
if (error instanceof DOMException && error.name === `AbortError`) {
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
console.warn(`Failed to acquire leadership for ${collectionId}:`, error);
|
|
214
|
-
}
|
|
215
|
-
if (!this.isDisposed()) {
|
|
216
|
-
void this.acquireLeadership(collectionId, state);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
releaseLeadership(_collectionId, state) {
|
|
220
|
-
if (state.lockAbortController) {
|
|
221
|
-
state.lockAbortController.abort();
|
|
222
|
-
state.lockAbortController = null;
|
|
223
|
-
}
|
|
224
|
-
if (state.heartbeatTimer) {
|
|
225
|
-
clearInterval(state.heartbeatTimer);
|
|
226
|
-
state.heartbeatTimer = null;
|
|
227
|
-
}
|
|
228
|
-
state.isLeader = false;
|
|
229
|
-
}
|
|
230
|
-
emitHeartbeat(collectionId, state) {
|
|
231
|
-
const envelope = {
|
|
232
|
-
v: 1,
|
|
233
|
-
dbName: this.dbName,
|
|
234
|
-
collectionId,
|
|
235
|
-
senderId: this.nodeId,
|
|
236
|
-
ts: Date.now(),
|
|
237
|
-
payload: {
|
|
238
|
-
type: `leader:heartbeat`,
|
|
239
|
-
term: state.latestTerm,
|
|
240
|
-
leaderId: this.nodeId,
|
|
241
|
-
latestSeq: state.latestSeq,
|
|
242
|
-
latestRowVersion: state.latestRowVersion
|
|
243
|
-
}
|
|
244
|
-
};
|
|
245
|
-
this.channel.postMessage(envelope);
|
|
246
|
-
}
|
|
247
|
-
// -----------------------------------------------------------------------
|
|
248
|
-
// BroadcastChannel message handling
|
|
249
|
-
// -----------------------------------------------------------------------
|
|
250
|
-
onChannelMessage(data) {
|
|
251
|
-
if (!isProtocolEnvelope(data)) return;
|
|
252
|
-
const envelope = data;
|
|
253
|
-
if (envelope.senderId === this.nodeId) return;
|
|
254
|
-
const payload = envelope.payload;
|
|
255
|
-
if (!payload || typeof payload !== `object`) return;
|
|
256
|
-
const type = payload.type;
|
|
257
|
-
if (type && type.endsWith(`:res`)) {
|
|
258
|
-
const rpcId = payload.rpcId;
|
|
259
|
-
if (rpcId && this.pendingRPCs.has(rpcId)) {
|
|
260
|
-
const pending = this.pendingRPCs.get(rpcId);
|
|
261
|
-
this.pendingRPCs.delete(rpcId);
|
|
262
|
-
clearTimeout(pending.timer);
|
|
263
|
-
pending.resolve(payload);
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
if (type && type.endsWith(`:req`)) {
|
|
268
|
-
const collectionId = envelope.collectionId;
|
|
269
|
-
if (this.isLeader(collectionId)) {
|
|
270
|
-
void this.handleRPCRequest(collectionId, payload);
|
|
271
|
-
}
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
const state = this.collections.get(envelope.collectionId);
|
|
275
|
-
if (state) {
|
|
276
|
-
for (const subscriber of state.subscribers) {
|
|
277
|
-
subscriber(envelope);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
// -----------------------------------------------------------------------
|
|
282
|
-
// RPC - Outbound (follower side)
|
|
283
|
-
// -----------------------------------------------------------------------
|
|
284
|
-
async sendRPC(collectionId, request) {
|
|
285
|
-
let lastError;
|
|
286
|
-
for (let attempt = 0; attempt <= RPC_RETRY_ATTEMPTS; attempt++) {
|
|
287
|
-
if (attempt > 0) {
|
|
288
|
-
await sleep(RPC_RETRY_DELAY_MS * attempt);
|
|
289
|
-
}
|
|
290
|
-
try {
|
|
291
|
-
return await this.sendRPCOnce(collectionId, request);
|
|
292
|
-
} catch (error) {
|
|
293
|
-
lastError = error instanceof Error ? error : new Error(String(error));
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
throw lastError ?? new Error(`RPC failed after retries`);
|
|
297
|
-
}
|
|
298
|
-
sendRPCOnce(collectionId, request) {
|
|
299
|
-
return new Promise((resolve, reject) => {
|
|
300
|
-
const rpcId = request.rpcId;
|
|
301
|
-
const timer = setTimeout(() => {
|
|
302
|
-
this.pendingRPCs.delete(rpcId);
|
|
303
|
-
reject(
|
|
304
|
-
new Error(`RPC ${request.type} timed out after ${RPC_TIMEOUT_MS}ms`)
|
|
305
|
-
);
|
|
306
|
-
}, RPC_TIMEOUT_MS);
|
|
307
|
-
this.pendingRPCs.set(rpcId, {
|
|
308
|
-
resolve,
|
|
309
|
-
reject,
|
|
310
|
-
timer
|
|
311
|
-
});
|
|
312
|
-
const envelope = {
|
|
313
|
-
v: 1,
|
|
314
|
-
dbName: this.dbName,
|
|
315
|
-
collectionId,
|
|
316
|
-
senderId: this.nodeId,
|
|
317
|
-
ts: Date.now(),
|
|
318
|
-
payload: request
|
|
319
|
-
};
|
|
320
|
-
this.channel.postMessage(envelope);
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
// -----------------------------------------------------------------------
|
|
324
|
-
// RPC - Inbound (leader side)
|
|
325
|
-
// -----------------------------------------------------------------------
|
|
326
|
-
async handleRPCRequest(collectionId, request) {
|
|
327
|
-
let response;
|
|
328
|
-
try {
|
|
329
|
-
switch (request.type) {
|
|
330
|
-
case `rpc:ensureRemoteSubset:req`:
|
|
331
|
-
response = await this.handleEnsureRemoteSubset(collectionId, request);
|
|
332
|
-
break;
|
|
333
|
-
case `rpc:ensurePersistedIndex:req`:
|
|
334
|
-
response = await this.handleEnsurePersistedIndex(
|
|
335
|
-
collectionId,
|
|
336
|
-
request
|
|
337
|
-
);
|
|
338
|
-
break;
|
|
339
|
-
case `rpc:applyLocalMutations:req`:
|
|
340
|
-
response = await this.handleApplyLocalMutations(collectionId, request);
|
|
341
|
-
break;
|
|
342
|
-
case `rpc:pullSince:req`:
|
|
343
|
-
response = await this.handlePullSince(collectionId, request);
|
|
344
|
-
break;
|
|
345
|
-
default:
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
|
-
} catch (error) {
|
|
349
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
350
|
-
response = {
|
|
351
|
-
type: request.type.replace(`:req`, `:res`),
|
|
352
|
-
rpcId: request.rpcId,
|
|
353
|
-
ok: false,
|
|
354
|
-
error: errorMessage
|
|
355
|
-
};
|
|
356
|
-
}
|
|
357
|
-
const envelope = {
|
|
358
|
-
v: 1,
|
|
359
|
-
dbName: this.dbName,
|
|
360
|
-
collectionId,
|
|
361
|
-
senderId: this.nodeId,
|
|
362
|
-
ts: Date.now(),
|
|
363
|
-
payload: response
|
|
364
|
-
};
|
|
365
|
-
this.channel.postMessage(envelope);
|
|
366
|
-
}
|
|
367
|
-
handleEnsureRemoteSubset(_collectionId, request) {
|
|
368
|
-
return {
|
|
369
|
-
type: `rpc:ensureRemoteSubset:res`,
|
|
370
|
-
rpcId: request.rpcId,
|
|
371
|
-
ok: true
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
async handleEnsurePersistedIndex(collectionId, request) {
|
|
375
|
-
await this.withWriterLock(
|
|
376
|
-
() => this.requireAdapter().ensureIndex(
|
|
377
|
-
collectionId,
|
|
378
|
-
request.signature,
|
|
379
|
-
request.spec
|
|
380
|
-
)
|
|
381
|
-
);
|
|
382
|
-
return {
|
|
383
|
-
type: `rpc:ensurePersistedIndex:res`,
|
|
384
|
-
rpcId: request.rpcId,
|
|
385
|
-
ok: true
|
|
386
|
-
};
|
|
387
|
-
}
|
|
388
|
-
async handleApplyLocalMutations(collectionId, request) {
|
|
389
|
-
if (this.appliedEnvelopeIds.has(request.envelopeId)) {
|
|
390
|
-
return {
|
|
391
|
-
type: `rpc:applyLocalMutations:res`,
|
|
392
|
-
rpcId: request.rpcId,
|
|
393
|
-
ok: false,
|
|
394
|
-
code: `CONFLICT`,
|
|
395
|
-
error: `envelope ${request.envelopeId} already applied`
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
const state = this.collections.get(collectionId);
|
|
399
|
-
if (!state || !state.isLeader) {
|
|
400
|
-
return {
|
|
401
|
-
type: `rpc:applyLocalMutations:res`,
|
|
402
|
-
rpcId: request.rpcId,
|
|
403
|
-
ok: false,
|
|
404
|
-
code: `NOT_LEADER`,
|
|
405
|
-
error: `not the leader for ${collectionId}`
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
|
-
state.latestSeq++;
|
|
409
|
-
state.latestRowVersion++;
|
|
410
|
-
const term = state.latestTerm;
|
|
411
|
-
const seq = state.latestSeq;
|
|
412
|
-
const rowVersion = state.latestRowVersion;
|
|
413
|
-
const tx = {
|
|
414
|
-
txId: dbSqlitePersistenceCore.safeRandomUUID(),
|
|
415
|
-
term,
|
|
416
|
-
seq,
|
|
417
|
-
rowVersion,
|
|
418
|
-
mutations: request.mutations.map((m) => ({
|
|
419
|
-
type: m.type,
|
|
420
|
-
key: m.key,
|
|
421
|
-
value: m.value
|
|
422
|
-
}))
|
|
423
|
-
};
|
|
424
|
-
await this.withWriterLock(
|
|
425
|
-
() => this.requireAdapter().applyCommittedTx(collectionId, tx)
|
|
426
|
-
);
|
|
427
|
-
this.appliedEnvelopeIds.set(request.envelopeId, Date.now());
|
|
428
|
-
this.pruneAppliedEnvelopeIds();
|
|
429
|
-
const changedRows = request.mutations.filter((m) => m.type !== `delete`).map((m) => ({ key: m.key, value: m.value }));
|
|
430
|
-
const deletedKeys = request.mutations.filter((m) => m.type === `delete`).map((m) => m.key);
|
|
431
|
-
const txCommitted = {
|
|
432
|
-
v: 1,
|
|
433
|
-
dbName: this.dbName,
|
|
434
|
-
collectionId,
|
|
435
|
-
senderId: this.nodeId,
|
|
436
|
-
ts: Date.now(),
|
|
437
|
-
payload: {
|
|
438
|
-
type: `tx:committed`,
|
|
439
|
-
term,
|
|
440
|
-
seq,
|
|
441
|
-
txId: tx.txId,
|
|
442
|
-
latestRowVersion: rowVersion,
|
|
443
|
-
requiresFullReload: false,
|
|
444
|
-
changedRows,
|
|
445
|
-
deletedKeys
|
|
446
|
-
}
|
|
447
|
-
};
|
|
448
|
-
this.channel.postMessage(txCommitted);
|
|
449
|
-
for (const subscriber of state.subscribers) {
|
|
450
|
-
subscriber(txCommitted);
|
|
451
|
-
}
|
|
452
|
-
return {
|
|
453
|
-
type: `rpc:applyLocalMutations:res`,
|
|
454
|
-
rpcId: request.rpcId,
|
|
455
|
-
ok: true,
|
|
456
|
-
term,
|
|
457
|
-
seq,
|
|
458
|
-
latestRowVersion: rowVersion,
|
|
459
|
-
acceptedMutationIds: request.mutations.map((m) => m.mutationId)
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
async handlePullSince(collectionId, request) {
|
|
463
|
-
const state = this.collections.get(collectionId);
|
|
464
|
-
const adapter = this.requireAdapter();
|
|
465
|
-
if (!adapter.pullSince) {
|
|
466
|
-
return {
|
|
467
|
-
type: `rpc:pullSince:res`,
|
|
468
|
-
rpcId: request.rpcId,
|
|
469
|
-
ok: true,
|
|
470
|
-
latestTerm: state?.latestTerm ?? 0,
|
|
471
|
-
latestSeq: state?.latestSeq ?? 0,
|
|
472
|
-
latestRowVersion: state?.latestRowVersion ?? 0,
|
|
473
|
-
requiresFullReload: true
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
|
-
const result = await adapter.pullSince(collectionId, request.fromRowVersion);
|
|
477
|
-
if (result.requiresFullReload) {
|
|
478
|
-
return {
|
|
479
|
-
type: `rpc:pullSince:res`,
|
|
480
|
-
rpcId: request.rpcId,
|
|
481
|
-
ok: true,
|
|
482
|
-
latestTerm: state?.latestTerm ?? 0,
|
|
483
|
-
latestSeq: state?.latestSeq ?? 0,
|
|
484
|
-
latestRowVersion: result.latestRowVersion,
|
|
485
|
-
requiresFullReload: true
|
|
486
|
-
};
|
|
487
|
-
}
|
|
488
|
-
return {
|
|
489
|
-
type: `rpc:pullSince:res`,
|
|
490
|
-
rpcId: request.rpcId,
|
|
491
|
-
ok: true,
|
|
492
|
-
latestTerm: state?.latestTerm ?? 0,
|
|
493
|
-
latestSeq: state?.latestSeq ?? 0,
|
|
494
|
-
latestRowVersion: result.latestRowVersion,
|
|
495
|
-
requiresFullReload: false,
|
|
496
|
-
changedKeys: result.changedKeys,
|
|
497
|
-
deletedKeys: result.deletedKeys
|
|
498
|
-
};
|
|
499
|
-
}
|
|
500
|
-
// -----------------------------------------------------------------------
|
|
501
|
-
// DB Writer Lock
|
|
502
|
-
// -----------------------------------------------------------------------
|
|
503
|
-
async withWriterLock(fn) {
|
|
504
|
-
const lockName = `tsdb:writer:${this.dbName}`;
|
|
505
|
-
for (let attempt = 0; attempt <= WRITER_LOCK_MAX_RETRIES; attempt++) {
|
|
506
|
-
try {
|
|
507
|
-
return await navigator.locks.request(lockName, async () => fn());
|
|
508
|
-
} catch (error) {
|
|
509
|
-
if (error instanceof DOMException && error.name === `AbortError`) {
|
|
510
|
-
throw error;
|
|
511
|
-
}
|
|
512
|
-
if (attempt < WRITER_LOCK_MAX_RETRIES) {
|
|
513
|
-
await sleep(WRITER_LOCK_BUSY_RETRY_MS * Math.min(attempt + 1, 5));
|
|
514
|
-
continue;
|
|
515
|
-
}
|
|
516
|
-
throw error;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
throw new Error(`writer lock acquisition failed`);
|
|
520
|
-
}
|
|
521
|
-
// -----------------------------------------------------------------------
|
|
522
|
-
// Helpers
|
|
523
|
-
// -----------------------------------------------------------------------
|
|
524
|
-
pruneAppliedEnvelopeIds() {
|
|
525
|
-
const cutoff = Date.now() - 6e4;
|
|
526
|
-
for (const [id, ts] of this.appliedEnvelopeIds) {
|
|
527
|
-
if (ts < cutoff) {
|
|
528
|
-
this.appliedEnvelopeIds.delete(id);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
function isProtocolEnvelope(data) {
|
|
534
|
-
if (!data || typeof data !== `object`) return false;
|
|
535
|
-
const record = data;
|
|
536
|
-
return record.v === 1 && typeof record.dbName === `string` && typeof record.collectionId === `string` && typeof record.senderId === `string` && typeof record.ts === `number`;
|
|
537
|
-
}
|
|
538
|
-
function sleep(ms) {
|
|
539
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
540
11
|
}
|
|
541
12
|
exports.ElectronCollectionCoordinator = ElectronCollectionCoordinator;
|
|
542
13
|
//# sourceMappingURL=electron-coordinator.cjs.map
|