@syncular/client 0.1.2 → 0.2.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/README.md +227 -709
- package/dist/apply.d.ts +80 -0
- package/dist/apply.js +301 -0
- package/dist/blob.d.ts +127 -0
- package/dist/blob.js +201 -0
- package/dist/bun-database.d.ts +22 -0
- package/dist/bun-database.js +61 -0
- package/dist/client.d.ts +320 -69
- package/dist/client.js +1627 -163
- package/dist/content-type.d.ts +2 -0
- package/dist/content-type.js +2 -0
- package/dist/database.d.ts +35 -80
- package/dist/database.js +32 -947
- package/dist/encryption.d.ts +47 -0
- package/dist/encryption.js +75 -0
- package/dist/errors.d.ts +8 -22
- package/dist/errors.js +10 -207
- package/dist/http.d.ts +32 -0
- package/dist/http.js +196 -0
- package/dist/index.d.ts +28 -16
- package/dist/index.js +25 -12
- package/dist/invalidation.d.ts +69 -0
- package/dist/invalidation.js +84 -0
- package/dist/leader-lock.d.ts +28 -0
- package/dist/leader-lock.js +38 -0
- package/dist/multi-tab.d.ts +134 -0
- package/dist/multi-tab.js +399 -0
- package/dist/node-database.d.ts +41 -0
- package/dist/node-database.js +133 -0
- package/dist/outbox.d.ts +56 -0
- package/dist/outbox.js +140 -0
- package/dist/schema.d.ts +107 -0
- package/dist/schema.js +267 -0
- package/dist/state.d.ts +40 -0
- package/dist/state.js +77 -0
- package/dist/transport.d.ts +44 -0
- package/dist/transport.js +7 -0
- package/dist/wasm-database.d.ts +30 -0
- package/dist/wasm-database.js +170 -0
- package/dist/window.d.ts +64 -0
- package/dist/window.js +0 -0
- package/dist/worker-entry.d.ts +16 -2
- package/dist/worker-entry.js +300 -456
- package/dist/worker-host.d.ts +174 -0
- package/dist/worker-host.js +536 -0
- package/dist/worker-protocol.d.ts +173 -61
- package/dist/worker-protocol.js +7 -16
- package/package.json +45 -112
- package/src/apply.ts +435 -0
- package/src/blob.ts +333 -0
- package/src/bun-database.ts +75 -0
- package/src/client.ts +2247 -277
- package/src/content-type.ts +2 -0
- package/src/database.ts +63 -1389
- package/src/encryption.ts +123 -0
- package/src/errors.ts +11 -265
- package/src/http.ts +254 -0
- package/src/index.ts +25 -20
- package/src/invalidation.ts +128 -0
- package/src/leader-lock.ts +68 -0
- package/src/multi-tab.ts +550 -0
- package/src/node-database.ts +180 -0
- package/src/outbox.ts +206 -0
- package/src/schema.ts +397 -0
- package/src/state.ts +126 -0
- package/src/transport.ts +52 -0
- package/src/wasm-database.ts +279 -0
- package/src/window.ts +0 -0
- package/src/worker-entry.ts +391 -545
- package/src/worker-host.ts +800 -0
- package/src/worker-protocol.ts +204 -99
- package/dist/auth-leases.d.ts +0 -11
- package/dist/auth-leases.d.ts.map +0 -1
- package/dist/auth-leases.js +0 -140
- package/dist/auth-leases.js.map +0 -1
- package/dist/blob-limits.d.ts +0 -15
- package/dist/blob-limits.d.ts.map +0 -1
- package/dist/blob-limits.js +0 -66
- package/dist/blob-limits.js.map +0 -1
- package/dist/bridge-client.d.ts +0 -69
- package/dist/bridge-client.d.ts.map +0 -1
- package/dist/bridge-client.js +0 -282
- package/dist/bridge-client.js.map +0 -1
- package/dist/client-config.d.ts +0 -6
- package/dist/client-config.d.ts.map +0 -1
- package/dist/client-config.js +0 -18
- package/dist/client-config.js.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/command-history.d.ts +0 -35
- package/dist/command-history.d.ts.map +0 -1
- package/dist/command-history.js +0 -378
- package/dist/command-history.js.map +0 -1
- package/dist/console-diagnostics.d.ts +0 -33
- package/dist/console-diagnostics.d.ts.map +0 -1
- package/dist/console-diagnostics.js +0 -496
- package/dist/console-diagnostics.js.map +0 -1
- package/dist/crdt-yjs/index.d.ts +0 -4
- package/dist/crdt-yjs/index.d.ts.map +0 -1
- package/dist/crdt-yjs/index.js +0 -4
- package/dist/crdt-yjs/index.js.map +0 -1
- package/dist/crdt-yjs/webview-host-facade.d.ts +0 -126
- package/dist/crdt-yjs/webview-host-facade.d.ts.map +0 -1
- package/dist/crdt-yjs/webview-host-facade.js +0 -284
- package/dist/crdt-yjs/webview-host-facade.js.map +0 -1
- package/dist/crdt-yjs/yjs-document-field-adapter.d.ts +0 -153
- package/dist/crdt-yjs/yjs-document-field-adapter.d.ts.map +0 -1
- package/dist/crdt-yjs/yjs-document-field-adapter.js +0 -406
- package/dist/crdt-yjs/yjs-document-field-adapter.js.map +0 -1
- package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts +0 -73
- package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts.map +0 -1
- package/dist/crdt-yjs/yjs-prosemirror-bridge.js +0 -169
- package/dist/crdt-yjs/yjs-prosemirror-bridge.js.map +0 -1
- package/dist/database.d.ts.map +0 -1
- package/dist/database.js.map +0 -1
- package/dist/diagnostics.d.ts +0 -10
- package/dist/diagnostics.d.ts.map +0 -1
- package/dist/diagnostics.js +0 -83
- package/dist/diagnostics.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/generated-bridge.d.ts +0 -365
- package/dist/generated-bridge.d.ts.map +0 -1
- package/dist/generated-bridge.js +0 -250
- package/dist/generated-bridge.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/mutations.d.ts +0 -72
- package/dist/mutations.d.ts.map +0 -1
- package/dist/mutations.js +0 -63
- package/dist/mutations.js.map +0 -1
- package/dist/network.d.ts +0 -3
- package/dist/network.d.ts.map +0 -1
- package/dist/network.js +0 -17
- package/dist/network.js.map +0 -1
- package/dist/react/index.d.ts +0 -169
- package/dist/react/index.d.ts.map +0 -1
- package/dist/react/index.js +0 -628
- package/dist/react/index.js.map +0 -1
- package/dist/react-native/index.d.ts +0 -35
- package/dist/react-native/index.d.ts.map +0 -1
- package/dist/react-native/index.js +0 -49
- package/dist/react-native/index.js.map +0 -1
- package/dist/runtime-contract.d.ts +0 -13
- package/dist/runtime-contract.d.ts.map +0 -1
- package/dist/runtime-contract.js +0 -24
- package/dist/runtime-contract.js.map +0 -1
- package/dist/rust-client.d.ts +0 -106
- package/dist/rust-client.d.ts.map +0 -1
- package/dist/rust-client.js +0 -938
- package/dist/rust-client.js.map +0 -1
- package/dist/sentry.d.ts +0 -35
- package/dist/sentry.d.ts.map +0 -1
- package/dist/sentry.js +0 -155
- package/dist/sentry.js.map +0 -1
- package/dist/sql-safety.d.ts +0 -3
- package/dist/sql-safety.d.ts.map +0 -1
- package/dist/sql-safety.js +0 -62
- package/dist/sql-safety.js.map +0 -1
- package/dist/syncular-runtime-artifacts.json +0 -61
- package/dist/tauri/index.d.ts +0 -35
- package/dist/tauri/index.d.ts.map +0 -1
- package/dist/tauri/index.js +0 -114
- package/dist/tauri/index.js.map +0 -1
- package/dist/types.d.ts +0 -1096
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
- package/dist/wasm/.syncular-wasm-profile +0 -1
- package/dist/wasm/syncular-runtime-artifact.json +0 -21
- package/dist/wasm/syncular.d.ts +0 -207
- package/dist/wasm/syncular.js +0 -2341
- package/dist/wasm/syncular_bg.wasm +0 -0
- package/dist/wasm/syncular_bg.wasm.d.ts +0 -97
- package/dist/wasm-bindings/runtime-contract.d.ts +0 -22
- package/dist/wasm-bindings/runtime-contract.d.ts.map +0 -1
- package/dist/wasm-bindings/runtime-contract.js +0 -112
- package/dist/wasm-bindings/runtime-contract.js.map +0 -1
- package/dist/wasm-core/.syncular-wasm-profile +0 -1
- package/dist/wasm-core/syncular-runtime-artifact.json +0 -17
- package/dist/wasm-core/syncular.d.ts +0 -162
- package/dist/wasm-core/syncular.js +0 -1847
- package/dist/wasm-core/syncular_bg.wasm +0 -0
- package/dist/wasm-core/syncular_bg.wasm.d.ts +0 -77
- package/dist/wasm-perf/.syncular-wasm-profile +0 -1
- package/dist/wasm-perf/syncular-runtime-artifact.json +0 -21
- package/dist/wasm-perf/syncular.d.ts +0 -207
- package/dist/wasm-perf/syncular.js +0 -2341
- package/dist/wasm-perf/syncular_bg.wasm +0 -0
- package/dist/wasm-perf/syncular_bg.wasm.d.ts +0 -97
- package/dist/wasm-runtime.d.ts +0 -23
- package/dist/wasm-runtime.d.ts.map +0 -1
- package/dist/wasm-runtime.js +0 -69
- package/dist/wasm-runtime.js.map +0 -1
- package/dist/worker-client.d.ts +0 -123
- package/dist/worker-client.d.ts.map +0 -1
- package/dist/worker-client.js +0 -1735
- package/dist/worker-client.js.map +0 -1
- package/dist/worker-entry.d.ts.map +0 -1
- package/dist/worker-entry.js.map +0 -1
- package/dist/worker-protocol.d.ts.map +0 -1
- package/dist/worker-protocol.js.map +0 -1
- package/dist/worker-realtime.d.ts +0 -39
- package/dist/worker-realtime.d.ts.map +0 -1
- package/dist/worker-realtime.js +0 -677
- package/dist/worker-realtime.js.map +0 -1
- package/src/auth-leases.ts +0 -251
- package/src/blob-limits.ts +0 -98
- package/src/bridge-client.ts +0 -512
- package/src/client-config.ts +0 -29
- package/src/command-history.ts +0 -623
- package/src/console-diagnostics.ts +0 -617
- package/src/crdt-yjs/index.ts +0 -3
- package/src/crdt-yjs/webview-host-facade.ts +0 -477
- package/src/crdt-yjs/yjs-document-field-adapter.ts +0 -733
- package/src/crdt-yjs/yjs-prosemirror-bridge.ts +0 -272
- package/src/diagnostics.ts +0 -116
- package/src/generated-bridge.ts +0 -741
- package/src/mutations.ts +0 -168
- package/src/network.ts +0 -32
- package/src/react/index.ts +0 -1036
- package/src/react-native/index.ts +0 -152
- package/src/runtime-contract.ts +0 -48
- package/src/rust-client.ts +0 -1491
- package/src/sentry.ts +0 -215
- package/src/sql-safety.ts +0 -61
- package/src/tauri/index.ts +0 -211
- package/src/types.ts +0 -1397
- package/src/wasm-bindings/generated-wasm-bindings.d.ts +0 -70
- package/src/wasm-bindings/runtime-contract.ts +0 -158
- package/src/wasm-runtime.ts +0 -145
- package/src/worker-client.ts +0 -2289
- package/src/worker-realtime.ts +0 -843
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-tab followers (TODO 3.2, REVISE B3): one core per origin, N tabs.
|
|
3
|
+
*
|
|
4
|
+
* The leader tab holds the Web Locks lease and runs the worker core (the
|
|
5
|
+
* existing worker-host path, unchanged). Every OTHER tab is a FOLLOWER: it
|
|
6
|
+
* loses the lock election and, instead of a dead not-leader handle, opens a
|
|
7
|
+
* BroadcastChannel to the leader and proxies the whole logical API over it —
|
|
8
|
+
* one sync loop, one WebSocket, one DB, N tabs.
|
|
9
|
+
*
|
|
10
|
+
* Wire (all messages structured-clone-safe; `Set`/`Uint8Array`/`ArrayBuffer`
|
|
11
|
+
* survive `postMessage` on a BroadcastChannel):
|
|
12
|
+
*
|
|
13
|
+
* follower → leader
|
|
14
|
+
* hello {t,epoch?,fromId} — "who's the leader?" on join
|
|
15
|
+
* req {t,epoch,fromId,reqId,method,args}
|
|
16
|
+
* bye {t,fromId} — follower leaving (best effort)
|
|
17
|
+
* leader → all
|
|
18
|
+
* announce {t,epoch,clientId} — "I am the leader, this epoch"
|
|
19
|
+
* res {t,epoch,reqId,ok,value|error} — reply to one req
|
|
20
|
+
* event {t,epoch,event} — fan-out (invalidate/presence/…)
|
|
21
|
+
*
|
|
22
|
+
* Epoch (leader generation token): a monotonic counter carried in a shared
|
|
23
|
+
* BroadcastChannel and bumped by every promotion. Followers stamp requests
|
|
24
|
+
* with the epoch they last heard in an `announce`; a leader ignores requests
|
|
25
|
+
* from a stale epoch, and a follower discards any `res`/`event` that does not
|
|
26
|
+
* match its current epoch — so a late reply from a tab that has since died
|
|
27
|
+
* (or a duplicate from a previous leader) can never be mistaken for a live
|
|
28
|
+
* one. Epoch is derived deterministically from a per-origin clock: each
|
|
29
|
+
* promoter reads the highest epoch it has seen and adds one, so successive
|
|
30
|
+
* leaders always strictly increase it even across the lock-handover gap.
|
|
31
|
+
*
|
|
32
|
+
* Presence identity: all tabs share the leader's one connection, so a device
|
|
33
|
+
* is exactly ONE presence peer collectively — `(actorId, leaderClientId)`.
|
|
34
|
+
* A follower's `setPresence` forwards to the leader's single publisher; there
|
|
35
|
+
* is no per-tab presence peer (documented in the web-client README).
|
|
36
|
+
*/
|
|
37
|
+
import { ClientSyncError } from './errors.js';
|
|
38
|
+
import { WORKER_FAILED_CODE } from './worker-protocol.js';
|
|
39
|
+
/** Client-local: a follower call could not reach a leader before its deadline. */
|
|
40
|
+
export const FOLLOWER_TIMEOUT_CODE = 'client.follower_timeout';
|
|
41
|
+
/** Default deadline for a follower request (covers a leader-handover gap). */
|
|
42
|
+
export const DEFAULT_FOLLOWER_CALL_TIMEOUT_MS = 10_000;
|
|
43
|
+
/** Max follower calls queued across a handover before we fail loudly. */
|
|
44
|
+
export const DEFAULT_FOLLOWER_QUEUE_LIMIT = 256;
|
|
45
|
+
/** Default channel factory: a real `BroadcastChannel` named per lock. */
|
|
46
|
+
export function broadcastChannelFactory() {
|
|
47
|
+
return (name) => new BroadcastChannel(name);
|
|
48
|
+
}
|
|
49
|
+
/** The channel name a leader and its followers rendezvous on. */
|
|
50
|
+
export function multiTabChannelName(lockName) {
|
|
51
|
+
return `syncular-mt:${lockName}`;
|
|
52
|
+
}
|
|
53
|
+
let uniqueCounter = 0;
|
|
54
|
+
/** A per-tab identity for addressing (not a presence identity). */
|
|
55
|
+
export function newTabId() {
|
|
56
|
+
const rand = typeof crypto !== 'undefined' && crypto.randomUUID !== undefined
|
|
57
|
+
? crypto.randomUUID()
|
|
58
|
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
59
|
+
return `tab-${rand}-${(uniqueCounter++).toString(36)}`;
|
|
60
|
+
}
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Leader side: bridge follower requests into the worker, fan-out events
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
/**
|
|
65
|
+
* Runs on the leader tab. Announces leadership, answers follower `req`s by
|
|
66
|
+
* invoking the (already-running) worker via `invoke`, and rebroadcasts every
|
|
67
|
+
* worker event to followers. Owns nothing about the worker lifecycle — the
|
|
68
|
+
* worker-host stays the single core owner; this is a relay.
|
|
69
|
+
*/
|
|
70
|
+
export class LeaderBridge {
|
|
71
|
+
#channel;
|
|
72
|
+
#epoch;
|
|
73
|
+
#clientId;
|
|
74
|
+
#invoke;
|
|
75
|
+
#onMessage;
|
|
76
|
+
#closed = false;
|
|
77
|
+
constructor(options) {
|
|
78
|
+
this.#channel = options.channel;
|
|
79
|
+
this.#epoch = options.epoch;
|
|
80
|
+
this.#clientId = options.clientId;
|
|
81
|
+
this.#invoke = options.invoke;
|
|
82
|
+
this.#onMessage = (event) => this.#handle(event.data);
|
|
83
|
+
this.#channel.addEventListener('message', this.#onMessage);
|
|
84
|
+
this.announce();
|
|
85
|
+
}
|
|
86
|
+
/** Re-announce leadership (on promotion and on a follower `hello`). */
|
|
87
|
+
announce() {
|
|
88
|
+
if (this.#closed)
|
|
89
|
+
return;
|
|
90
|
+
this.#channel.postMessage({
|
|
91
|
+
t: 'announce',
|
|
92
|
+
epoch: this.#epoch,
|
|
93
|
+
clientId: this.#clientId,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Fan a worker event out to all followers on the current epoch. */
|
|
97
|
+
broadcastEvent(event) {
|
|
98
|
+
if (this.#closed)
|
|
99
|
+
return;
|
|
100
|
+
this.#channel.postMessage({ t: 'event', epoch: this.#epoch, event });
|
|
101
|
+
}
|
|
102
|
+
#handle(message) {
|
|
103
|
+
if (this.#closed)
|
|
104
|
+
return;
|
|
105
|
+
if (message.t === 'hello') {
|
|
106
|
+
// A follower joined (or is contesting) — tell it who leads.
|
|
107
|
+
this.announce();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (message.t !== 'req')
|
|
111
|
+
return;
|
|
112
|
+
// Ignore requests stamped for a different (dead/older) leader; that
|
|
113
|
+
// follower will re-stamp once it sees our announce.
|
|
114
|
+
if (message.epoch !== this.#epoch)
|
|
115
|
+
return;
|
|
116
|
+
const { reqId, method, args } = message;
|
|
117
|
+
this.#invoke(method, args).then((value) => {
|
|
118
|
+
this.#channel.postMessage({
|
|
119
|
+
t: 'res',
|
|
120
|
+
epoch: this.#epoch,
|
|
121
|
+
reqId,
|
|
122
|
+
ok: true,
|
|
123
|
+
value,
|
|
124
|
+
});
|
|
125
|
+
}, (error) => {
|
|
126
|
+
const shape = error instanceof ClientSyncError
|
|
127
|
+
? {
|
|
128
|
+
code: error.code,
|
|
129
|
+
message: error.message,
|
|
130
|
+
retryable: error.retryable,
|
|
131
|
+
}
|
|
132
|
+
: {
|
|
133
|
+
code: WORKER_FAILED_CODE,
|
|
134
|
+
message: error instanceof Error ? error.message : 'unknown error',
|
|
135
|
+
retryable: false,
|
|
136
|
+
};
|
|
137
|
+
this.#channel.postMessage({
|
|
138
|
+
t: 'res',
|
|
139
|
+
epoch: this.#epoch,
|
|
140
|
+
reqId,
|
|
141
|
+
ok: false,
|
|
142
|
+
error: shape,
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
close() {
|
|
147
|
+
if (this.#closed)
|
|
148
|
+
return;
|
|
149
|
+
this.#closed = true;
|
|
150
|
+
this.#channel.removeEventListener('message', this.#onMessage);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Runs on a follower tab. Sends `req`s to the leader and settles them on the
|
|
155
|
+
* matching `res`; queues calls (bounded, timed) while no leader is bound (the
|
|
156
|
+
* handover gap) and flushes them once an `announce` binds a new leader.
|
|
157
|
+
* Feeds fanned-out events to `onEvent`. Learns leadership changes and hands
|
|
158
|
+
* the resolved leader `clientId` back through `onLeaderChange`.
|
|
159
|
+
*/
|
|
160
|
+
export class FollowerLink {
|
|
161
|
+
#channel;
|
|
162
|
+
#fromId;
|
|
163
|
+
#onEvent;
|
|
164
|
+
#onLeaderChange;
|
|
165
|
+
#callTimeoutMs;
|
|
166
|
+
#queueLimit;
|
|
167
|
+
#onMessage;
|
|
168
|
+
/** -1 until the first `announce` binds us to a leader. */
|
|
169
|
+
#epoch = -1;
|
|
170
|
+
/** Highest epoch ever seen (survives a leader gap for monotonic promotion). */
|
|
171
|
+
#maxEpochSeen = -1;
|
|
172
|
+
#leaderClientId = '';
|
|
173
|
+
#nextReqId = 1;
|
|
174
|
+
#inFlight = new Map();
|
|
175
|
+
#queue = [];
|
|
176
|
+
#closed = false;
|
|
177
|
+
/** Resolvers waiting for the first `announce` to bind a leader. */
|
|
178
|
+
#bindWaiters = [];
|
|
179
|
+
constructor(options) {
|
|
180
|
+
this.#channel = options.channel;
|
|
181
|
+
this.#fromId = options.fromId;
|
|
182
|
+
this.#onEvent = options.onEvent;
|
|
183
|
+
this.#onLeaderChange = options.onLeaderChange;
|
|
184
|
+
this.#callTimeoutMs =
|
|
185
|
+
options.callTimeoutMs ?? DEFAULT_FOLLOWER_CALL_TIMEOUT_MS;
|
|
186
|
+
this.#queueLimit = options.queueLimit ?? DEFAULT_FOLLOWER_QUEUE_LIMIT;
|
|
187
|
+
this.#onMessage = (event) => this.#handle(event.data);
|
|
188
|
+
this.#channel.addEventListener('message', this.#onMessage);
|
|
189
|
+
// Ask the current leader to announce itself.
|
|
190
|
+
this.#channel.postMessage({ t: 'hello', fromId: this.#fromId });
|
|
191
|
+
}
|
|
192
|
+
get epoch() {
|
|
193
|
+
return this.#epoch;
|
|
194
|
+
}
|
|
195
|
+
get maxEpochSeen() {
|
|
196
|
+
return this.#maxEpochSeen;
|
|
197
|
+
}
|
|
198
|
+
get leaderClientId() {
|
|
199
|
+
return this.#leaderClientId;
|
|
200
|
+
}
|
|
201
|
+
/** Whether a leader is currently bound (an announce has been heard). */
|
|
202
|
+
get bound() {
|
|
203
|
+
return this.#epoch >= 0;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Resolve once the link is bound to a leader (the first `announce` arrived),
|
|
207
|
+
* or reject if `timeoutMs` elapses first. A caller awaits this before
|
|
208
|
+
* treating the follower as ready: until an announce is processed the link's
|
|
209
|
+
* epoch is -1, so any fanned-out `event` in that window is dropped (an
|
|
210
|
+
* unbound follower cannot match the leader's epoch). Awaiting binding closes
|
|
211
|
+
* that gap — a just-opened follower tab never misses an invalidation emitted
|
|
212
|
+
* between its `hello` and the leader's `announce`. Resolves synchronously
|
|
213
|
+
* when already bound.
|
|
214
|
+
*/
|
|
215
|
+
waitUntilBound(timeoutMs = this.#callTimeoutMs) {
|
|
216
|
+
if (this.#epoch >= 0)
|
|
217
|
+
return Promise.resolve();
|
|
218
|
+
if (this.#closed) {
|
|
219
|
+
return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the follower link is closed'));
|
|
220
|
+
}
|
|
221
|
+
return new Promise((resolve, reject) => {
|
|
222
|
+
const timer = setTimeout(() => {
|
|
223
|
+
this.#dropBindWaiter(settle);
|
|
224
|
+
reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'no leader announced within the follower bind timeout'));
|
|
225
|
+
}, timeoutMs);
|
|
226
|
+
const settle = () => {
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
resolve();
|
|
229
|
+
};
|
|
230
|
+
this.#bindWaiters.push(settle);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
#dropBindWaiter(waiter) {
|
|
234
|
+
const index = this.#bindWaiters.indexOf(waiter);
|
|
235
|
+
if (index >= 0)
|
|
236
|
+
this.#bindWaiters.splice(index, 1);
|
|
237
|
+
}
|
|
238
|
+
#resolveBindWaiters() {
|
|
239
|
+
if (this.#bindWaiters.length === 0)
|
|
240
|
+
return;
|
|
241
|
+
const waiters = this.#bindWaiters;
|
|
242
|
+
this.#bindWaiters = [];
|
|
243
|
+
for (const settle of waiters)
|
|
244
|
+
settle();
|
|
245
|
+
}
|
|
246
|
+
/** Forward one logical API call to the leader (queued if unbound). */
|
|
247
|
+
call(method, args) {
|
|
248
|
+
if (this.#closed) {
|
|
249
|
+
return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the follower link is closed'));
|
|
250
|
+
}
|
|
251
|
+
return new Promise((resolve, reject) => {
|
|
252
|
+
const queued = {
|
|
253
|
+
method,
|
|
254
|
+
args,
|
|
255
|
+
resolve,
|
|
256
|
+
reject,
|
|
257
|
+
timer: undefined,
|
|
258
|
+
};
|
|
259
|
+
if (this.#epoch < 0) {
|
|
260
|
+
// No leader bound yet — queue with a deadline so we never hang.
|
|
261
|
+
if (this.#queue.length >= this.#queueLimit) {
|
|
262
|
+
reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'follower call queue overflow while awaiting a leader'));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
queued.timer = setTimeout(() => {
|
|
266
|
+
this.#dropQueued(queued);
|
|
267
|
+
reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'no leader answered within the follower call timeout'));
|
|
268
|
+
}, this.#callTimeoutMs);
|
|
269
|
+
this.#queue.push(queued);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
this.#send(queued);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
#send(queued) {
|
|
276
|
+
const reqId = this.#nextReqId++;
|
|
277
|
+
const inflight = {
|
|
278
|
+
resolve: queued.resolve,
|
|
279
|
+
reject: queued.reject,
|
|
280
|
+
timer: setTimeout(() => {
|
|
281
|
+
this.#inFlight.delete(reqId);
|
|
282
|
+
queued.reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'the leader did not answer within the follower call timeout'));
|
|
283
|
+
}, this.#callTimeoutMs),
|
|
284
|
+
};
|
|
285
|
+
this.#inFlight.set(reqId, inflight);
|
|
286
|
+
this.#channel.postMessage({
|
|
287
|
+
t: 'req',
|
|
288
|
+
epoch: this.#epoch,
|
|
289
|
+
fromId: this.#fromId,
|
|
290
|
+
reqId,
|
|
291
|
+
method: queued.method,
|
|
292
|
+
args: queued.args,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
#dropQueued(queued) {
|
|
296
|
+
const index = this.#queue.indexOf(queued);
|
|
297
|
+
if (index >= 0)
|
|
298
|
+
this.#queue.splice(index, 1);
|
|
299
|
+
}
|
|
300
|
+
#handle(message) {
|
|
301
|
+
if (this.#closed)
|
|
302
|
+
return;
|
|
303
|
+
if (message.t === 'announce') {
|
|
304
|
+
if (message.epoch > this.#maxEpochSeen) {
|
|
305
|
+
this.#maxEpochSeen = message.epoch;
|
|
306
|
+
}
|
|
307
|
+
// Ignore a stale announce (older than the leader we already track).
|
|
308
|
+
if (message.epoch < this.#epoch)
|
|
309
|
+
return;
|
|
310
|
+
const changed = message.epoch !== this.#epoch ||
|
|
311
|
+
message.clientId !== this.#leaderClientId;
|
|
312
|
+
this.#epoch = message.epoch;
|
|
313
|
+
this.#leaderClientId = message.clientId;
|
|
314
|
+
if (changed)
|
|
315
|
+
this.#onLeaderChange(message.clientId);
|
|
316
|
+
this.#resolveBindWaiters();
|
|
317
|
+
this.#flushQueue();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (message.t === 'res') {
|
|
321
|
+
// Discard a reply for a stale epoch — the leader that produced it is
|
|
322
|
+
// gone; the request (if still pending) is being retried elsewhere.
|
|
323
|
+
if (message.epoch !== this.#epoch)
|
|
324
|
+
return;
|
|
325
|
+
const inflight = this.#inFlight.get(message.reqId);
|
|
326
|
+
if (inflight === undefined)
|
|
327
|
+
return;
|
|
328
|
+
this.#inFlight.delete(message.reqId);
|
|
329
|
+
if (inflight.timer !== undefined)
|
|
330
|
+
clearTimeout(inflight.timer);
|
|
331
|
+
if (message.ok) {
|
|
332
|
+
inflight.resolve(message.value);
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
const err = message.error;
|
|
336
|
+
inflight.reject(new ClientSyncError(err?.code ?? WORKER_FAILED_CODE, err?.message ?? 'follower request failed', err?.retryable ?? false));
|
|
337
|
+
}
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (message.t === 'event') {
|
|
341
|
+
if (message.epoch !== this.#epoch)
|
|
342
|
+
return;
|
|
343
|
+
this.#onEvent(message.event);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/** A new leader bound: re-send everything that was waiting for one. */
|
|
348
|
+
#flushQueue() {
|
|
349
|
+
const pending = this.#queue;
|
|
350
|
+
this.#queue = [];
|
|
351
|
+
for (const queued of pending) {
|
|
352
|
+
if (queued.timer !== undefined)
|
|
353
|
+
clearTimeout(queued.timer);
|
|
354
|
+
queued.timer = undefined;
|
|
355
|
+
this.#send(queued);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The leader we were bound to went away (its lock released). Un-bind so new
|
|
360
|
+
* calls queue again; re-request an announce so the next leader finds us.
|
|
361
|
+
* In-flight calls stay pending — their per-call timeout is the backstop,
|
|
362
|
+
* and the winner's announce will flush any that were queued.
|
|
363
|
+
*/
|
|
364
|
+
unbind() {
|
|
365
|
+
if (this.#closed)
|
|
366
|
+
return;
|
|
367
|
+
this.#epoch = -1;
|
|
368
|
+
this.#leaderClientId = '';
|
|
369
|
+
this.#channel.postMessage({
|
|
370
|
+
t: 'hello',
|
|
371
|
+
fromId: this.#fromId,
|
|
372
|
+
epoch: this.#maxEpochSeen,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
close() {
|
|
376
|
+
if (this.#closed)
|
|
377
|
+
return;
|
|
378
|
+
this.#closed = true;
|
|
379
|
+
this.#channel.removeEventListener('message', this.#onMessage);
|
|
380
|
+
this.#channel.postMessage({ t: 'bye', fromId: this.#fromId });
|
|
381
|
+
const closedError = new ClientSyncError(WORKER_FAILED_CODE, 'the follower link was closed');
|
|
382
|
+
for (const inflight of this.#inFlight.values()) {
|
|
383
|
+
if (inflight.timer !== undefined)
|
|
384
|
+
clearTimeout(inflight.timer);
|
|
385
|
+
inflight.reject(closedError);
|
|
386
|
+
}
|
|
387
|
+
this.#inFlight.clear();
|
|
388
|
+
for (const queued of this.#queue) {
|
|
389
|
+
if (queued.timer !== undefined)
|
|
390
|
+
clearTimeout(queued.timer);
|
|
391
|
+
queued.reject(closedError);
|
|
392
|
+
}
|
|
393
|
+
this.#queue = [];
|
|
394
|
+
// Bind waiters carry their own timeout timers; resolving them here lets an
|
|
395
|
+
// awaiting boot path settle promptly instead of hanging until that timeout.
|
|
396
|
+
this.#resolveBindWaiters();
|
|
397
|
+
this.#channel.close();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type ClientDatabase, type SqlRow, type SqlValue } from './database.js';
|
|
2
|
+
/**
|
|
3
|
+
* Structural view of the tiny better-sqlite3 surface this binding uses. We
|
|
4
|
+
* type it locally (rather than importing `better-sqlite3`'s types) so the
|
|
5
|
+
* package typechecks without the optional peer installed.
|
|
6
|
+
*/
|
|
7
|
+
interface BetterSqliteStatement {
|
|
8
|
+
run(...params: NodeParam[]): unknown;
|
|
9
|
+
all(...params: NodeParam[]): unknown[];
|
|
10
|
+
}
|
|
11
|
+
interface BetterSqliteDatabase {
|
|
12
|
+
readonly inTransaction: boolean;
|
|
13
|
+
prepare(sql: string): BetterSqliteStatement;
|
|
14
|
+
exec(sql: string): unknown;
|
|
15
|
+
close(): void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* better-sqlite3 accepts string / number / bigint / null / Buffer|Uint8Array
|
|
19
|
+
* bind values, but NOT booleans (it throws "TypeError: can only bind …"). We
|
|
20
|
+
* coerce booleans to 0/1 exactly like the bun adapter so callers see one
|
|
21
|
+
* uniform bind contract across every backend.
|
|
22
|
+
*/
|
|
23
|
+
type NodeParam = string | number | bigint | Uint8Array | null;
|
|
24
|
+
export declare class NodeClientDatabase implements ClientDatabase {
|
|
25
|
+
#private;
|
|
26
|
+
readonly db: BetterSqliteDatabase;
|
|
27
|
+
constructor(path?: string);
|
|
28
|
+
exec(sql: string, params?: readonly SqlValue[]): void;
|
|
29
|
+
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
|
30
|
+
transaction<T>(fn: () => T): T;
|
|
31
|
+
/**
|
|
32
|
+
* §5.3 image import: better-sqlite3 (like bun:sqlite) attaches files, not
|
|
33
|
+
* buffers, so the image lands in a private temp file for the duration of
|
|
34
|
+
* the ATTACH. Must be called outside any open transaction (SQLite cannot
|
|
35
|
+
* ATTACH inside one).
|
|
36
|
+
*/
|
|
37
|
+
withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T;
|
|
38
|
+
close(): void;
|
|
39
|
+
}
|
|
40
|
+
export declare function openNodeDatabase(path?: string): ClientDatabase;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ClientDatabase` on better-sqlite3 — the Electron-main / plain-Node
|
|
3
|
+
* backend (TODO 2: Node ClientDatabase). Semantics mirror `./bun-database`
|
|
4
|
+
* exactly (synchronous exec/query/transaction with the shared savepoint
|
|
5
|
+
* helper, and the same §5.3 sqlite-image ATTACH path), so the core behaves
|
|
6
|
+
* identically whether it runs on bun:sqlite (tests), sqlite-wasm (browser)
|
|
7
|
+
* or better-sqlite3 (Node/Electron-main).
|
|
8
|
+
*
|
|
9
|
+
* better-sqlite3 is an OPTIONAL peer dependency, not a hard one: the package
|
|
10
|
+
* installs cleanly without it and this module errors helpfully only when a
|
|
11
|
+
* host actually calls `openNodeDatabase()` without having installed the peer.
|
|
12
|
+
* Not exported from the package root, so browser/bun entries never resolve
|
|
13
|
+
* the native module. Subpath export: `@syncular/client/node`.
|
|
14
|
+
*
|
|
15
|
+
* bun CANNOT dlopen better-sqlite3 (ERR_DLOPEN_FAILED, oven-sh/bun#4290), so
|
|
16
|
+
* this adapter is verified under real Node — see the README "Electron-main /
|
|
17
|
+
* plain-Node" section for the one-command recipe and `test/node-database`.
|
|
18
|
+
*/
|
|
19
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { assertImageAlias, runTransaction, } from './database.js';
|
|
24
|
+
function coerceParams(params) {
|
|
25
|
+
return params.map((value) => {
|
|
26
|
+
if (typeof value === 'boolean')
|
|
27
|
+
return value ? 1 : 0;
|
|
28
|
+
return value;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* better-sqlite3 returns BLOB columns as Node `Buffer`s. A Buffer IS a
|
|
33
|
+
* Uint8Array subclass, but it can be a view onto a shared pool buffer, so we
|
|
34
|
+
* normalize to a standalone Uint8Array — matching what bun:sqlite hands back
|
|
35
|
+
* and keeping the buffer-ownership assumptions elsewhere (worker transfer,
|
|
36
|
+
* structured clone) honest.
|
|
37
|
+
*/
|
|
38
|
+
function normalizeRow(row) {
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const key in row) {
|
|
41
|
+
const value = row[key];
|
|
42
|
+
if (Buffer.isBuffer(value)) {
|
|
43
|
+
out[key] = new Uint8Array(value); // copies out of the pool
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
out[key] = value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Load the optional peer AND open the database in one guarded step, so BOTH
|
|
53
|
+
* failure modes are turned into a clear, actionable error rather than a raw
|
|
54
|
+
* one:
|
|
55
|
+
*
|
|
56
|
+
* - `require('better-sqlite3')` throwing MODULE_NOT_FOUND — the peer is not
|
|
57
|
+
* installed (the common browser-only-host case), and
|
|
58
|
+
* - `new Database()` throwing ERR_DLOPEN_FAILED — the module resolves but the
|
|
59
|
+
* native addon cannot load, which is exactly what bun does for
|
|
60
|
+
* better-sqlite3 (oven-sh/bun#4290); the addon only dlopens at construction.
|
|
61
|
+
*/
|
|
62
|
+
function openBetterSqlite(path) {
|
|
63
|
+
const require = createRequire(import.meta.url);
|
|
64
|
+
try {
|
|
65
|
+
const mod = require('better-sqlite3');
|
|
66
|
+
const Database = mod.default ??
|
|
67
|
+
mod;
|
|
68
|
+
return new Database(path);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
const code = error?.code;
|
|
72
|
+
if (code === 'ERR_DLOPEN_FAILED') {
|
|
73
|
+
throw new Error("openNodeDatabase() requires the 'better-sqlite3' native module, but " +
|
|
74
|
+
'it failed to load. This most commonly means you are running under ' +
|
|
75
|
+
'bun, which cannot dlopen better-sqlite3 (oven-sh/bun#4290) — use ' +
|
|
76
|
+
"the bun:sqlite backend ('@syncular/client/bun') under bun, " +
|
|
77
|
+
"and reserve '@syncular/client/node' for Node/Electron-main. " +
|
|
78
|
+
`Underlying error: ${String(error)}`);
|
|
79
|
+
}
|
|
80
|
+
throw new Error('openNodeDatabase() requires the optional peer dependency ' +
|
|
81
|
+
"'better-sqlite3', which is not installed. Add it to your app " +
|
|
82
|
+
'(`npm install better-sqlite3` / `bun add better-sqlite3`) — it is ' +
|
|
83
|
+
'kept optional so @syncular/client installs without a native ' +
|
|
84
|
+
`build for browser-only hosts. Underlying error: ${String(error)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export class NodeClientDatabase {
|
|
88
|
+
db;
|
|
89
|
+
#tx = { depth: 0 };
|
|
90
|
+
constructor(path = ':memory:') {
|
|
91
|
+
this.db = openBetterSqlite(path);
|
|
92
|
+
}
|
|
93
|
+
exec(sql, params = []) {
|
|
94
|
+
this.db.prepare(sql).run(...coerceParams(params));
|
|
95
|
+
}
|
|
96
|
+
query(sql, params = []) {
|
|
97
|
+
const rows = this.db.prepare(sql).all(...coerceParams(params));
|
|
98
|
+
return rows.map(normalizeRow);
|
|
99
|
+
}
|
|
100
|
+
transaction(fn) {
|
|
101
|
+
return runTransaction(this.#tx, (sql) => this.db.exec(sql), fn);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* §5.3 image import: better-sqlite3 (like bun:sqlite) attaches files, not
|
|
105
|
+
* buffers, so the image lands in a private temp file for the duration of
|
|
106
|
+
* the ATTACH. Must be called outside any open transaction (SQLite cannot
|
|
107
|
+
* ATTACH inside one).
|
|
108
|
+
*/
|
|
109
|
+
withSqliteImage(bytes, alias, fn) {
|
|
110
|
+
assertImageAlias(alias);
|
|
111
|
+
const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
|
|
112
|
+
const path = join(dir, 'segment.db');
|
|
113
|
+
try {
|
|
114
|
+
writeFileSync(path, bytes);
|
|
115
|
+
this.db.prepare(`ATTACH DATABASE ? AS ${alias}`).run(path);
|
|
116
|
+
try {
|
|
117
|
+
return fn();
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
this.db.prepare(`DETACH DATABASE ${alias}`).run();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
rmSync(dir, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
close() {
|
|
128
|
+
this.db.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export function openNodeDatabase(path = ':memory:') {
|
|
132
|
+
return new NodeClientDatabase(path);
|
|
133
|
+
}
|
package/dist/outbox.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable outbox (SPEC.md §7.1) with encode-at-send (the §0 binary-push
|
|
3
|
+
* outbox rule): local mutations are persisted in a schema-agnostic JSON
|
|
4
|
+
* form and encoded with the *current* generated row codec only when a push
|
|
5
|
+
* request is built — a commit recorded under schema N replays after an
|
|
6
|
+
* upgrade to N+1 by re-encoding.
|
|
7
|
+
*/
|
|
8
|
+
import { type PushCommitFrame, type ScopeMap } from '@syncular/core';
|
|
9
|
+
import type { ClientDatabase } from './database.js';
|
|
10
|
+
import type { EncryptionConfig } from './encryption.js';
|
|
11
|
+
import { ClientSyncError } from './errors.js';
|
|
12
|
+
import { type CompiledClientSchema, type CompiledClientTable, type JsonRowValue } from './schema.js';
|
|
13
|
+
export interface OutboxOperation {
|
|
14
|
+
readonly table: string;
|
|
15
|
+
readonly rowId: string;
|
|
16
|
+
readonly op: 'upsert' | 'delete';
|
|
17
|
+
/** Optimistic-concurrency token (§6.2); absent = last-write-wins. */
|
|
18
|
+
readonly baseVersion?: number;
|
|
19
|
+
/** Full-row values keyed by column name; present iff `op` is `upsert`. */
|
|
20
|
+
readonly values?: Readonly<Record<string, JsonRowValue>>;
|
|
21
|
+
}
|
|
22
|
+
export interface OutboxCommit {
|
|
23
|
+
readonly seq: number;
|
|
24
|
+
readonly clientCommitId: string;
|
|
25
|
+
readonly createdAtMs: number;
|
|
26
|
+
readonly operations: readonly OutboxOperation[];
|
|
27
|
+
}
|
|
28
|
+
export declare function appendOutboxCommit(db: ClientDatabase, clientCommitId: string, operations: readonly OutboxOperation[], nowMs: number): void;
|
|
29
|
+
/** Pending commits in FIFO creation order (§7.1). */
|
|
30
|
+
export declare function listOutbox(db: ClientDatabase): OutboxCommit[];
|
|
31
|
+
export declare function deleteOutboxCommit(db: ClientDatabase, clientCommitId: string): void;
|
|
32
|
+
/**
|
|
33
|
+
* §7.4.4: after a schema bump, a persisted upsert may name a column the new
|
|
34
|
+
* schema no longer has. The value has nowhere to go and there is no
|
|
35
|
+
* migration — surface it as `sync.outbox_incompatible` (client-local, §10.3)
|
|
36
|
+
* so the caller can drop the commit through the rejection channel.
|
|
37
|
+
*/
|
|
38
|
+
export declare class OutboxEncodeError extends ClientSyncError {
|
|
39
|
+
constructor(message: string);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Encode one outbox commit as a `PUSH_COMMIT` frame with the current
|
|
43
|
+
* schema's row codec (§6.1). When `encryption` is configured, encrypted
|
|
44
|
+
* columns (§5.11) are encrypted here — the encode-at-send seam — before the
|
|
45
|
+
* row codec serializes them as ciphertext-envelope `bytes`. Async because
|
|
46
|
+
* WebCrypto is async.
|
|
47
|
+
*/
|
|
48
|
+
export declare function encodeOutboxCommit(schema: CompiledClientSchema, commit: OutboxCommit, encryption?: EncryptionConfig): Promise<PushCommitFrame>;
|
|
49
|
+
/**
|
|
50
|
+
* §3.3: drop pending commits that write into a revoked scope instead of
|
|
51
|
+
* replaying them into guaranteed rejections. A whole commit is dropped when
|
|
52
|
+
* any of its upserts provably lands in the revoked effective scopes —
|
|
53
|
+
* commits are atomic and their content is pinned by the idempotency key,
|
|
54
|
+
* so ops are never removed individually. Returns dropped commit ids.
|
|
55
|
+
*/
|
|
56
|
+
export declare function dropOutboxCommitsInScope(db: ClientDatabase, table: CompiledClientTable, effective: ScopeMap): string[];
|