orez 0.5.16 → 0.5.18
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 +4 -0
- package/dist/cf-do/worker.d.ts.map +1 -1
- package/dist/cf-do/worker.js +89 -14
- package/dist/cf-do/worker.js.map +1 -1
- package/dist/sync-browser-host/generated/sqlite3-browser.wasm +0 -0
- package/dist/sync-browser-host/generated/sync_wasm.js +921 -0
- package/dist/sync-browser-host/generated/sync_wasm_bg.wasm +0 -0
- package/dist/sync-browser-host/host.d.ts +8 -0
- package/dist/sync-browser-host/host.d.ts.map +1 -0
- package/dist/sync-browser-host/host.js +435 -0
- package/dist/sync-browser-host/host.js.map +1 -0
- package/dist/sync-browser-host/idb-snapshot.d.ts +10 -0
- package/dist/sync-browser-host/idb-snapshot.d.ts.map +1 -0
- package/dist/sync-browser-host/idb-snapshot.js +109 -0
- package/dist/sync-browser-host/idb-snapshot.js.map +1 -0
- package/dist/sync-browser-host/index.d.ts +4 -0
- package/dist/sync-browser-host/index.d.ts.map +1 -0
- package/dist/sync-browser-host/index.js +4 -0
- package/dist/sync-browser-host/index.js.map +1 -0
- package/dist/sync-browser-host/message-port.d.ts +6 -0
- package/dist/sync-browser-host/message-port.d.ts.map +1 -0
- package/dist/sync-browser-host/message-port.js +182 -0
- package/dist/sync-browser-host/message-port.js.map +1 -0
- package/dist/sync-browser-host/sqlite-adapter.d.ts +66 -0
- package/dist/sync-browser-host/sqlite-adapter.d.ts.map +1 -0
- package/dist/sync-browser-host/sqlite-adapter.js +135 -0
- package/dist/sync-browser-host/sqlite-adapter.js.map +1 -0
- package/dist/sync-browser-host/types.d.ts +88 -0
- package/dist/sync-browser-host/types.d.ts.map +1 -0
- package/dist/sync-browser-host/types.js +12 -0
- package/dist/sync-browser-host/types.js.map +1 -0
- package/dist/test-setup.d.ts +2 -0
- package/dist/test-setup.d.ts.map +1 -0
- package/dist/test-setup.js +35 -0
- package/dist/test-setup.js.map +1 -0
- package/dist/zero-http/test-harness.d.ts +2 -6
- package/dist/zero-http/test-harness.d.ts.map +1 -1
- package/dist/zero-http/test-harness.js +4 -2
- package/dist/zero-http/test-harness.js.map +1 -1
- package/dist/zero-http/transport.d.ts +30 -4
- package/dist/zero-http/transport.d.ts.map +1 -1
- package/dist/zero-http/transport.js +411 -44
- package/dist/zero-http/transport.js.map +1 -1
- package/package.json +15 -5
|
@@ -1,18 +1,44 @@
|
|
|
1
|
+
// http-pull transport: runs a stock @rocicorp/zero client over stateless HTTP
|
|
2
|
+
// by intercepting its /sync/v51/connect WebSocket with a shim that translates
|
|
3
|
+
// pull responses into v51 pokes.
|
|
4
|
+
//
|
|
5
|
+
// this file is the CANONICAL copy. takeout's on-zero and chat's
|
|
6
|
+
// httpPullTransport.vendor.ts are downstream snapshots of it — evolve the
|
|
7
|
+
// transport here and refresh them from here, never the other way. the wire
|
|
8
|
+
// contract (lexicographic string cookies, gotQueriesPatch poke-part ordering,
|
|
9
|
+
// FIFO push serialization, updateAuth, 401→Unauthorized frame, teardown
|
|
10
|
+
// drain) is pinned by the sibling *.test.ts suite and documented in
|
|
11
|
+
// plans/zero-http.md. do not "simplify" any of it without re-running those
|
|
12
|
+
// tests against a stock zero client.
|
|
1
13
|
const COOKIE_WIDTH = 20;
|
|
2
|
-
const
|
|
3
|
-
|
|
14
|
+
const TRANSIENT_RECONNECT_BACKOFF_BASE_MS = 1_000;
|
|
15
|
+
const TRANSIENT_RECONNECT_BACKOFF_MAX_MS = 30_000;
|
|
16
|
+
export function installHttpPullTransport(opts) {
|
|
4
17
|
const previousWebSocket = globalThis.WebSocket;
|
|
5
18
|
const fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
6
19
|
if (!fetchImpl) {
|
|
7
|
-
throw new Error('
|
|
20
|
+
throw new Error('installHttpPullTransport requires a fetch implementation');
|
|
8
21
|
}
|
|
9
22
|
const state = {
|
|
23
|
+
appID: opts.appID ?? 'zero',
|
|
10
24
|
origin: new URL(opts.origin),
|
|
11
25
|
originString: trimTrailingSlash(new URL(opts.origin).toString()),
|
|
12
|
-
|
|
26
|
+
pushOriginString: trimTrailingSlash(new URL(opts.pushOrigin ?? opts.origin).toString()),
|
|
27
|
+
// the transport invokes this as `state.fetch(...)` — without binding,
|
|
28
|
+
// window.fetch sees `state` as its receiver and browsers throw
|
|
29
|
+
// "Illegal invocation" (node's fetch doesn't care, so tests can't catch it)
|
|
30
|
+
fetch: fetchImpl.bind(globalThis),
|
|
13
31
|
nativeWebSocket: previousWebSocket,
|
|
14
32
|
sockets: new Set(),
|
|
33
|
+
pullIntervalMs: opts.pullIntervalMs,
|
|
34
|
+
wake: opts.wake ?? false,
|
|
35
|
+
queryTransform: opts.queryTransform,
|
|
36
|
+
queryForward: opts.queryForward === true,
|
|
37
|
+
queryAware: opts.queryTransform !== undefined || opts.queryForward === true,
|
|
38
|
+
shardNum: opts.shardNum ?? 0,
|
|
15
39
|
nextPokeID: 0,
|
|
40
|
+
socketGeneration: 0,
|
|
41
|
+
transientFailureCount: 0,
|
|
16
42
|
};
|
|
17
43
|
const Shim = class {
|
|
18
44
|
static CONNECTING = 0;
|
|
@@ -34,6 +60,16 @@ export function installZeroHttpTransport(opts) {
|
|
|
34
60
|
pull: async () => {
|
|
35
61
|
await Promise.all([...state.sockets].map((socket) => socket.pull()));
|
|
36
62
|
},
|
|
63
|
+
flush: async () => {
|
|
64
|
+
await Promise.resolve();
|
|
65
|
+
const generation = state.socketGeneration;
|
|
66
|
+
const sockets = [...state.sockets];
|
|
67
|
+
await Promise.all(sockets.map((socket) => socket.flush()));
|
|
68
|
+
if (state.socketGeneration !== generation ||
|
|
69
|
+
sockets.some((socket) => !state.sockets.has(socket))) {
|
|
70
|
+
throw new Error('transport changed during flush');
|
|
71
|
+
}
|
|
72
|
+
},
|
|
37
73
|
get connections() {
|
|
38
74
|
return state.sockets.size;
|
|
39
75
|
},
|
|
@@ -44,6 +80,22 @@ export function installZeroHttpTransport(opts) {
|
|
|
44
80
|
},
|
|
45
81
|
};
|
|
46
82
|
}
|
|
83
|
+
// per-origin idempotent install for app usage (ProvideZero rotates zero
|
|
84
|
+
// instances against the same server; installing per rotation would chain
|
|
85
|
+
// shims unboundedly). installed transports live for the page lifetime.
|
|
86
|
+
const transportsByOrigin = new Map();
|
|
87
|
+
export function ensureHttpPullTransport(opts) {
|
|
88
|
+
const key = trimTrailingSlash(new URL(opts.origin).toString());
|
|
89
|
+
const existing = transportsByOrigin.get(key);
|
|
90
|
+
if (existing)
|
|
91
|
+
return existing;
|
|
92
|
+
const transport = installHttpPullTransport(opts);
|
|
93
|
+
transportsByOrigin.set(key, transport);
|
|
94
|
+
return transport;
|
|
95
|
+
}
|
|
96
|
+
export async function flushHttpPullTransports() {
|
|
97
|
+
await Promise.all([...transportsByOrigin.values()].map((transport) => transport.flush()));
|
|
98
|
+
}
|
|
47
99
|
class ZeroHttpSocket {
|
|
48
100
|
state;
|
|
49
101
|
CONNECTING = 0;
|
|
@@ -65,12 +117,32 @@ class ZeroHttpSocket {
|
|
|
65
117
|
wsid;
|
|
66
118
|
cookie;
|
|
67
119
|
pendingGotQueriesPatch = [];
|
|
120
|
+
// every got-query hash acked to the client so far. a rowsPatch 'clear'
|
|
121
|
+
// resets the client's ENTIRE replicache space — rows AND got-query marks —
|
|
122
|
+
// so any clear-bearing poke must re-assert the full got set or queries the
|
|
123
|
+
// client already had marked complete silently regress to unknown forever
|
|
124
|
+
// (the transport never re-sends an ack it believes was delivered).
|
|
125
|
+
ackedGotHashes = new Set();
|
|
126
|
+
// query-aware extension state: the accumulated un-acked desired-query delta
|
|
127
|
+
// to ship, a client-side query-state version that bumps on each change, and
|
|
128
|
+
// the version/length of the delta the in-flight pull sent (to clear the
|
|
129
|
+
// acked prefix on the server's ack).
|
|
130
|
+
desiredQueryPatch = [];
|
|
131
|
+
queryVersion = 0;
|
|
132
|
+
sentQueryVersion;
|
|
133
|
+
sentQueryPatchLen = 0;
|
|
68
134
|
pullInFlight;
|
|
69
135
|
pullAfterCurrent = false;
|
|
70
136
|
pushChain = Promise.resolve();
|
|
71
|
-
|
|
137
|
+
pushCompletion = Promise.resolve();
|
|
138
|
+
flushGeneration = 0;
|
|
139
|
+
pendingUpstream = new Set();
|
|
140
|
+
nextLocalCookieID;
|
|
72
141
|
openTimer;
|
|
73
|
-
|
|
142
|
+
pullTimer;
|
|
143
|
+
wakeSocket;
|
|
144
|
+
wakeConnecting = false;
|
|
145
|
+
wakeReconnectTimer;
|
|
74
146
|
constructor(state, url, protocols) {
|
|
75
147
|
this.state = state;
|
|
76
148
|
this.connectURL = toHttpURL(url);
|
|
@@ -80,11 +152,20 @@ class ZeroHttpSocket {
|
|
|
80
152
|
this.wsid = this.connectURL.searchParams.get('wsid') ?? `zero-http-${Date.now()}`;
|
|
81
153
|
const baseCookie = this.connectURL.searchParams.get('baseCookie');
|
|
82
154
|
this.cookie = baseCookie ? baseCookie : null;
|
|
155
|
+
// the local cookie ID is encoded into the suffix of any cookie we
|
|
156
|
+
// already advanced past (toLocalWebSocketCookie). reconnects rehydrate
|
|
157
|
+
// this.cookie from the URL baseCookie param replicache persisted, so we
|
|
158
|
+
// must resume the counter from its suffix — otherwise the first poke on
|
|
159
|
+
// the new socket re-emits an identical suffix and replicache trips
|
|
160
|
+
// "cookie did not change, but patch is not empty" and drops the patch.
|
|
161
|
+
const suffixMatch = this.cookie?.match(/#(\d+)$/);
|
|
162
|
+
this.nextLocalCookieID = suffixMatch ? Number(suffixMatch[1]) : 0;
|
|
83
163
|
const decoded = decodeSecProtocol(protocols);
|
|
84
164
|
this.authToken = decoded.authToken;
|
|
85
165
|
this.queueDesiredQueries(decoded.initConnectionMessage?.[1]);
|
|
86
166
|
this.state.sockets.add(this);
|
|
87
|
-
this.
|
|
167
|
+
this.state.socketGeneration++;
|
|
168
|
+
this.openTimer = setTimeout(() => this.open(), reconnectDelayMs(this.state));
|
|
88
169
|
}
|
|
89
170
|
addEventListener(type, listener) {
|
|
90
171
|
if (listener)
|
|
@@ -106,9 +187,12 @@ class ZeroHttpSocket {
|
|
|
106
187
|
switch (message[0]) {
|
|
107
188
|
case 'initConnection':
|
|
108
189
|
case 'changeDesiredQueries':
|
|
109
|
-
this.
|
|
190
|
+
this.flushGeneration++;
|
|
191
|
+
this.queueDesiredQueries(message[1]);
|
|
192
|
+
this.requestPullAfterCurrent();
|
|
110
193
|
return;
|
|
111
194
|
case 'updateAuth':
|
|
195
|
+
this.flushGeneration++;
|
|
112
196
|
this.authToken = message[1].auth;
|
|
113
197
|
return;
|
|
114
198
|
case 'push':
|
|
@@ -117,9 +201,13 @@ class ZeroHttpSocket {
|
|
|
117
201
|
case 'ping':
|
|
118
202
|
this.emitMessage(['pong', {}]);
|
|
119
203
|
return;
|
|
120
|
-
case 'pull':
|
|
121
|
-
this.
|
|
204
|
+
case 'pull': {
|
|
205
|
+
this.flushGeneration++;
|
|
206
|
+
const recoveryPull = this.answerMutationRecoveryPull(message[1]);
|
|
207
|
+
this.trackUpstream(recoveryPull);
|
|
208
|
+
this.run(recoveryPull);
|
|
122
209
|
return;
|
|
210
|
+
}
|
|
123
211
|
case 'deleteClients':
|
|
124
212
|
case 'ackMutationResponses':
|
|
125
213
|
return;
|
|
@@ -132,19 +220,23 @@ class ZeroHttpSocket {
|
|
|
132
220
|
return;
|
|
133
221
|
if (this.openTimer)
|
|
134
222
|
clearTimeout(this.openTimer);
|
|
135
|
-
if (this.
|
|
136
|
-
|
|
223
|
+
if (this.pullTimer)
|
|
224
|
+
clearInterval(this.pullTimer);
|
|
225
|
+
this.closeWakeChannel();
|
|
137
226
|
this.readyState = this.CLOSED;
|
|
138
|
-
this.state.sockets.delete(this)
|
|
227
|
+
if (this.state.sockets.delete(this))
|
|
228
|
+
this.state.socketGeneration++;
|
|
139
229
|
this.emit('close', { code, reason, wasClean: code <= 1001 });
|
|
140
230
|
}
|
|
141
231
|
pull() {
|
|
142
|
-
if (this.readyState
|
|
232
|
+
if (this.readyState !== this.OPEN)
|
|
143
233
|
return Promise.resolve();
|
|
144
234
|
if (this.pullInFlight)
|
|
145
235
|
return this.pullInFlight;
|
|
146
|
-
this.pullInFlight = this.fetchPull(this.clientGroupID, this.cookie)
|
|
236
|
+
this.pullInFlight = this.fetchPull(this.clientGroupID, this.cookie, true)
|
|
147
237
|
.then((response) => {
|
|
238
|
+
if (this.state.queryAware)
|
|
239
|
+
this.applyServerGotQueries(response);
|
|
148
240
|
if (response.unchanged) {
|
|
149
241
|
this.emitGotQueriesPatch(response.cookie);
|
|
150
242
|
return;
|
|
@@ -164,49 +256,187 @@ class ZeroHttpSocket {
|
|
|
164
256
|
});
|
|
165
257
|
return this.pullInFlight;
|
|
166
258
|
}
|
|
259
|
+
// settle every tracked upstream effect, then prove quiescence with a double
|
|
260
|
+
// pull round-trip. loops until a full pass completes with no new work
|
|
261
|
+
// (flushGeneration unchanged and nothing pending).
|
|
262
|
+
async flush() {
|
|
263
|
+
for (;;) {
|
|
264
|
+
const generation = this.flushGeneration;
|
|
265
|
+
await Promise.all([...this.pendingUpstream]);
|
|
266
|
+
await this.pushCompletion;
|
|
267
|
+
await this.pull();
|
|
268
|
+
await Promise.all([...this.pendingUpstream]);
|
|
269
|
+
await this.pushCompletion;
|
|
270
|
+
await this.pull();
|
|
271
|
+
await Promise.resolve();
|
|
272
|
+
if (this.flushGeneration === generation && this.pendingUpstream.size === 0) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
167
277
|
open() {
|
|
168
278
|
if (this.readyState !== this.CONNECTING)
|
|
169
279
|
return;
|
|
170
280
|
this.readyState = this.OPEN;
|
|
171
281
|
this.emit('open', {});
|
|
172
282
|
this.emitMessage(['connected', { wsid: this.wsid, timestamp: Date.now() }]);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
this.
|
|
178
|
-
|
|
179
|
-
|
|
283
|
+
setTimeout(() => this.run(this.pull()), 0);
|
|
284
|
+
if (this.state.pullIntervalMs) {
|
|
285
|
+
this.pullTimer = setInterval(() => {
|
|
286
|
+
this.run(this.pull());
|
|
287
|
+
}, this.state.pullIntervalMs);
|
|
288
|
+
}
|
|
289
|
+
if (this.state.wake)
|
|
290
|
+
void this.openWakeChannel();
|
|
291
|
+
}
|
|
292
|
+
// notification-only wake channel: a real WebSocket to <origin>/wake that
|
|
293
|
+
// carries no data. any frame means "pull now", so a wake triggers an
|
|
294
|
+
// immediate (coalesced) pull — push-shaped propagation without waiting on
|
|
295
|
+
// the poll interval. advisory only: if it drops we reconnect, and the
|
|
296
|
+
// interval poll remains the safety net that guarantees convergence.
|
|
297
|
+
async openWakeChannel() {
|
|
298
|
+
const Native = this.state.nativeWebSocket;
|
|
299
|
+
if (!Native ||
|
|
300
|
+
this.wakeSocket ||
|
|
301
|
+
this.wakeConnecting ||
|
|
302
|
+
this.readyState !== this.OPEN) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
this.wakeConnecting = true;
|
|
306
|
+
const wsBase = this.state.originString.replace(/^http/, 'ws');
|
|
307
|
+
let wakeToken;
|
|
308
|
+
let socket;
|
|
309
|
+
try {
|
|
310
|
+
if (typeof this.state.wake === 'object') {
|
|
311
|
+
wakeToken = await this.state.wake.getToken();
|
|
312
|
+
}
|
|
313
|
+
if (this.readyState !== this.OPEN || this.wakeSocket)
|
|
314
|
+
return;
|
|
315
|
+
const url = `${wsBase}/wake?clientID=${encodeURIComponent(this.clientID)}` +
|
|
316
|
+
(wakeToken === undefined ? '' : `&wakeToken=${encodeURIComponent(wakeToken)}`);
|
|
317
|
+
socket = new Native(url);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
this.scheduleWakeReconnect();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
this.wakeConnecting = false;
|
|
325
|
+
}
|
|
326
|
+
this.wakeSocket = socket;
|
|
327
|
+
const reconnect = () => {
|
|
328
|
+
if (this.wakeSocket !== socket)
|
|
329
|
+
return;
|
|
330
|
+
this.wakeSocket = undefined;
|
|
331
|
+
this.scheduleWakeReconnect();
|
|
332
|
+
};
|
|
333
|
+
// route through requestPullAfterCurrent, NOT pull() directly: a wake that
|
|
334
|
+
// lands while a pull is already in flight must set pullAfterCurrent so the
|
|
335
|
+
// in-flight pull re-runs and picks up the woken change. calling pull()
|
|
336
|
+
// directly would return the existing promise and silently drop the wake,
|
|
337
|
+
// leaving convergence to the safety poll (a burst-storm latency bug).
|
|
338
|
+
socket.onmessage = () => this.requestPullAfterCurrent();
|
|
339
|
+
socket.onclose = reconnect;
|
|
340
|
+
socket.onerror = reconnect;
|
|
341
|
+
}
|
|
342
|
+
scheduleWakeReconnect() {
|
|
343
|
+
if (this.readyState !== this.OPEN || this.wakeReconnectTimer)
|
|
344
|
+
return;
|
|
345
|
+
this.wakeReconnectTimer = setTimeout(() => {
|
|
346
|
+
this.wakeReconnectTimer = undefined;
|
|
347
|
+
void this.openWakeChannel();
|
|
348
|
+
}, 500);
|
|
349
|
+
}
|
|
350
|
+
closeWakeChannel() {
|
|
351
|
+
if (this.wakeReconnectTimer) {
|
|
352
|
+
clearTimeout(this.wakeReconnectTimer);
|
|
353
|
+
this.wakeReconnectTimer = undefined;
|
|
354
|
+
}
|
|
355
|
+
const socket = this.wakeSocket;
|
|
356
|
+
this.wakeSocket = undefined;
|
|
357
|
+
if (socket) {
|
|
358
|
+
try {
|
|
359
|
+
socket.close();
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
// best effort: an already-closing wake socket is harmless
|
|
363
|
+
}
|
|
364
|
+
}
|
|
180
365
|
}
|
|
181
366
|
queueDesiredQueries(body) {
|
|
182
367
|
const desiredQueriesPatch = body
|
|
183
368
|
?.desiredQueriesPatch;
|
|
184
369
|
if (!Array.isArray(desiredQueriesPatch))
|
|
185
370
|
return;
|
|
186
|
-
this.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (this.initialPullTimer) {
|
|
191
|
-
clearTimeout(this.initialPullTimer);
|
|
192
|
-
this.initialPullTimer = undefined;
|
|
371
|
+
if (!this.state.queryAware) {
|
|
372
|
+
// baseline dialect: synthesize the got-query ack locally
|
|
373
|
+
this.pendingGotQueriesPatch.push(...gotQueriesPatch(desiredQueriesPatch));
|
|
374
|
+
return;
|
|
193
375
|
}
|
|
194
|
-
|
|
376
|
+
// query-aware: accumulate the desired-query delta to ship to the server. a
|
|
377
|
+
// put ships its inline ast, or a client-resolved ast (queryTransform), or
|
|
378
|
+
// name+args for the server to resolve (queryForward). the server owns the
|
|
379
|
+
// got-query ack.
|
|
380
|
+
const transform = this.state.queryTransform;
|
|
381
|
+
for (const op of desiredQueriesPatch) {
|
|
382
|
+
if (op.op === 'clear') {
|
|
383
|
+
this.desiredQueryPatch.push({ op: 'clear' });
|
|
384
|
+
}
|
|
385
|
+
else if (op.op === 'del') {
|
|
386
|
+
this.desiredQueryPatch.push({ op: 'del', hash: op.hash });
|
|
387
|
+
}
|
|
388
|
+
else if (op.op === 'put') {
|
|
389
|
+
const inline = op.ast;
|
|
390
|
+
const name = op.name ?? '';
|
|
391
|
+
const args = (op.args ??
|
|
392
|
+
[]);
|
|
393
|
+
if (this.state.queryForward) {
|
|
394
|
+
this.desiredQueryPatch.push({ op: 'put', hash: op.hash, name, args });
|
|
395
|
+
}
|
|
396
|
+
else if (inline !== undefined) {
|
|
397
|
+
this.desiredQueryPatch.push({ op: 'put', hash: op.hash, ast: inline });
|
|
398
|
+
}
|
|
399
|
+
else if (transform) {
|
|
400
|
+
this.desiredQueryPatch.push({
|
|
401
|
+
op: 'put',
|
|
402
|
+
hash: op.hash,
|
|
403
|
+
ast: transform(name, args),
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
this.queryVersion++;
|
|
195
409
|
}
|
|
196
410
|
async push(body) {
|
|
197
411
|
const response = (await this.postJSON('/push', body));
|
|
198
|
-
|
|
412
|
+
// mutation RECOVERY pushes carry a PREVIOUS client's pending mutations,
|
|
413
|
+
// and the server response echoes that old clientID. zero-cache's pusher
|
|
414
|
+
// groups results by clientID and only delivers a client its OWN results
|
|
415
|
+
// (recovered mutations settle via lastMutationIDChanges in pokes); the
|
|
416
|
+
// raw response would trip zero's "received mutation for the wrong
|
|
417
|
+
// client" assert and kill the connection. mirror the same filtering.
|
|
418
|
+
this.emitMessage([
|
|
419
|
+
'pushResponse',
|
|
420
|
+
filterMutationResultsToClient('pushResponse' in response ? response.pushResponse : response, this.clientID),
|
|
421
|
+
]);
|
|
199
422
|
this.requestPullAfterCurrent();
|
|
200
423
|
}
|
|
201
424
|
enqueuePush(body) {
|
|
425
|
+
this.flushGeneration++;
|
|
202
426
|
const nextPush = this.pushChain.then(async () => {
|
|
203
427
|
if (this.readyState === this.CLOSED)
|
|
204
428
|
return;
|
|
205
429
|
await this.push(body);
|
|
206
430
|
});
|
|
431
|
+
this.pushCompletion = nextPush;
|
|
207
432
|
this.pushChain = nextPush.catch(() => { });
|
|
433
|
+
this.trackUpstream(nextPush);
|
|
208
434
|
this.run(nextPush);
|
|
209
435
|
}
|
|
436
|
+
trackUpstream(promise) {
|
|
437
|
+
this.pendingUpstream.add(promise);
|
|
438
|
+
void promise.then(() => this.pendingUpstream.delete(promise), () => this.pendingUpstream.delete(promise));
|
|
439
|
+
}
|
|
210
440
|
requestPullAfterCurrent() {
|
|
211
441
|
if (this.pullInFlight) {
|
|
212
442
|
this.pullAfterCurrent = true;
|
|
@@ -214,8 +444,23 @@ class ZeroHttpSocket {
|
|
|
214
444
|
}
|
|
215
445
|
this.run(this.pull());
|
|
216
446
|
}
|
|
447
|
+
// in query-aware mode the got-query ack is authoritative from the server:
|
|
448
|
+
// take the server's gotQueries.patch as the got patch to emit (replacing
|
|
449
|
+
// local synthesis), and clear the acked prefix of the shipped desired delta
|
|
450
|
+
// once the server acks that version (the ack never leads its row effects —
|
|
451
|
+
// invariant 13 — so the client marks a query got only after its rows land).
|
|
452
|
+
applyServerGotQueries(response) {
|
|
453
|
+
const got = response.gotQueries;
|
|
454
|
+
this.pendingGotQueriesPatch = got ? got.patch : [];
|
|
455
|
+
if (got &&
|
|
456
|
+
this.sentQueryVersion !== undefined &&
|
|
457
|
+
got.version >= this.sentQueryVersion) {
|
|
458
|
+
this.desiredQueryPatch.splice(0, this.sentQueryPatchLen);
|
|
459
|
+
this.sentQueryVersion = undefined;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
217
462
|
async answerMutationRecoveryPull(body) {
|
|
218
|
-
const response = await this.fetchPull(body.clientGroupID, body.cookie);
|
|
463
|
+
const response = await this.fetchPull(body.clientGroupID, body.cookie, false);
|
|
219
464
|
const cookie = toWebSocketCookie(response.cookie);
|
|
220
465
|
this.emitMessage([
|
|
221
466
|
'pull',
|
|
@@ -226,15 +471,33 @@ class ZeroHttpSocket {
|
|
|
226
471
|
},
|
|
227
472
|
]);
|
|
228
473
|
}
|
|
229
|
-
async fetchPull(clientGroupID, cookie) {
|
|
230
|
-
|
|
474
|
+
async fetchPull(clientGroupID, cookie, includeQueries) {
|
|
475
|
+
const body = {
|
|
231
476
|
clientID: this.clientID,
|
|
232
477
|
clientGroupID,
|
|
233
478
|
cookie: toHttpCookie(cookie),
|
|
234
|
-
}
|
|
479
|
+
};
|
|
480
|
+
// ship the un-acked desired-query delta with the pull; remember what we
|
|
481
|
+
// sent so the server ack can clear exactly that prefix. a recovery pull
|
|
482
|
+
// (includeQueries=false) never carries desires.
|
|
483
|
+
if (includeQueries && this.state.queryAware && this.desiredQueryPatch.length > 0) {
|
|
484
|
+
this.sentQueryVersion = this.queryVersion;
|
|
485
|
+
this.sentQueryPatchLen = this.desiredQueryPatch.length;
|
|
486
|
+
body.queries = { version: this.queryVersion, patch: [...this.desiredQueryPatch] };
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
this.sentQueryVersion = undefined;
|
|
490
|
+
}
|
|
491
|
+
return (await this.postJSON('/pull', body));
|
|
235
492
|
}
|
|
236
493
|
async postJSON(path, body) {
|
|
237
|
-
const
|
|
494
|
+
const base = path === '/push' ? this.state.pushOriginString : this.state.originString;
|
|
495
|
+
const url = new URL(`${base}${path}`);
|
|
496
|
+
if (path === '/push') {
|
|
497
|
+
url.searchParams.set('schema', `${this.state.appID}_${this.state.shardNum}`);
|
|
498
|
+
url.searchParams.set('appID', this.state.appID);
|
|
499
|
+
}
|
|
500
|
+
const response = await this.state.fetch(url, {
|
|
238
501
|
method: 'POST',
|
|
239
502
|
headers: {
|
|
240
503
|
authorization: this.authToken ? `Bearer ${this.authToken}` : '',
|
|
@@ -245,6 +508,7 @@ class ZeroHttpSocket {
|
|
|
245
508
|
if (!response.ok) {
|
|
246
509
|
throw new ZeroHttpResponseError(path, response.status);
|
|
247
510
|
}
|
|
511
|
+
resetTransientFailures(this.state);
|
|
248
512
|
return response.json();
|
|
249
513
|
}
|
|
250
514
|
run(promise) {
|
|
@@ -266,17 +530,59 @@ class ZeroHttpSocket {
|
|
|
266
530
|
this.close(1000, error.message);
|
|
267
531
|
return;
|
|
268
532
|
}
|
|
533
|
+
if (isStaleClientCookieError(error)) {
|
|
534
|
+
// the client's cookie is AHEAD of the server watermark — the server
|
|
535
|
+
// lost or reset its change-tracking state (replica reset / restore).
|
|
536
|
+
// mirror zero-cache's InvalidConnectionRequestBaseCookie error frame so
|
|
537
|
+
// the stock client drops its local db and rebuilds from scratch instead
|
|
538
|
+
// of reconnect-looping on 409 forever.
|
|
539
|
+
this.emitMessage([
|
|
540
|
+
'error',
|
|
541
|
+
{
|
|
542
|
+
kind: 'InvalidConnectionRequestBaseCookie',
|
|
543
|
+
message: error.message,
|
|
544
|
+
origin: 'server',
|
|
545
|
+
},
|
|
546
|
+
]);
|
|
547
|
+
if (this.readyState !== this.CLOSED)
|
|
548
|
+
this.close(1000, error.message);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
recordTransientFailure(this.state);
|
|
269
552
|
this.emit('error', { error });
|
|
270
553
|
this.close(1011, errorMessage(error));
|
|
271
554
|
}
|
|
272
555
|
emitPoke(response) {
|
|
273
|
-
const
|
|
274
|
-
|
|
556
|
+
const currentServer = toHttpCookie(this.cookie);
|
|
557
|
+
let nextCookie;
|
|
558
|
+
if (currentServer !== null && response.cookie < currentServer) {
|
|
559
|
+
// the server watermark is BEHIND the client: a real reset/restore. mirror
|
|
560
|
+
// the 409 stale path instead of poking the client backwards.
|
|
275
561
|
throw new Error(`zero-http pull returned stale cookie ${response.cookie} for ${this.cookie}`);
|
|
276
562
|
}
|
|
563
|
+
else if (currentServer !== null && response.cookie === currentServer) {
|
|
564
|
+
// same server watermark but a non-empty patch: a query-aware membership
|
|
565
|
+
// delta (a desired-query change recomputes rows without advancing the
|
|
566
|
+
// change log). bump a client-local cookie id so replicache sees a changed
|
|
567
|
+
// cookie — otherwise it trips "cookie did not change, but patch is not
|
|
568
|
+
// empty" and drops the patch.
|
|
569
|
+
nextCookie = toLocalWebSocketCookie(response.cookie, ++this.nextLocalCookieID);
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
nextCookie = toWebSocketCookie(response.cookie);
|
|
573
|
+
}
|
|
277
574
|
const pokeID = `zero-http-${++this.state.nextPokeID}`;
|
|
278
|
-
|
|
575
|
+
let gotQueries = dedupeGotQueriesPatch(this.pendingGotQueriesPatch);
|
|
279
576
|
this.pendingGotQueriesPatch = [];
|
|
577
|
+
const rowsCleared = Array.isArray(response.rowsPatch) &&
|
|
578
|
+
response.rowsPatch.some((op) => op?.op === 'clear');
|
|
579
|
+
if (rowsCleared && this.ackedGotHashes.size > 0) {
|
|
580
|
+
gotQueries = dedupeGotQueriesPatch([
|
|
581
|
+
...[...this.ackedGotHashes].map((hash) => ({ op: 'put', hash })),
|
|
582
|
+
...gotQueries,
|
|
583
|
+
]);
|
|
584
|
+
}
|
|
585
|
+
this.recordAckedGotQueries(gotQueries);
|
|
280
586
|
this.emitMessage([
|
|
281
587
|
'pokeStart',
|
|
282
588
|
{
|
|
@@ -317,8 +623,9 @@ class ZeroHttpSocket {
|
|
|
317
623
|
return;
|
|
318
624
|
const nextCookie = toLocalWebSocketCookie(serverCookie, ++this.nextLocalCookieID);
|
|
319
625
|
const pokeID = `zero-http-${++this.state.nextPokeID}`;
|
|
320
|
-
const gotQueries = this.pendingGotQueriesPatch;
|
|
626
|
+
const gotQueries = dedupeGotQueriesPatch(this.pendingGotQueriesPatch);
|
|
321
627
|
this.pendingGotQueriesPatch = [];
|
|
628
|
+
this.recordAckedGotQueries(gotQueries);
|
|
322
629
|
this.emitMessage([
|
|
323
630
|
'pokeStart',
|
|
324
631
|
{
|
|
@@ -341,6 +648,16 @@ class ZeroHttpSocket {
|
|
|
341
648
|
this.emitMessage(['pokeEnd', { pokeID, cookie: nextCookie }]);
|
|
342
649
|
this.cookie = nextCookie;
|
|
343
650
|
}
|
|
651
|
+
recordAckedGotQueries(patch) {
|
|
652
|
+
for (const op of patch) {
|
|
653
|
+
if (op.op === 'clear')
|
|
654
|
+
this.ackedGotHashes.clear();
|
|
655
|
+
else if (op.op === 'put')
|
|
656
|
+
this.ackedGotHashes.add(op.hash);
|
|
657
|
+
else
|
|
658
|
+
this.ackedGotHashes.delete(op.hash);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
344
661
|
emitMessage(message) {
|
|
345
662
|
if (this.readyState !== this.OPEN)
|
|
346
663
|
return;
|
|
@@ -404,6 +721,28 @@ function gotQueriesPatch(patch) {
|
|
|
404
721
|
}
|
|
405
722
|
return got;
|
|
406
723
|
}
|
|
724
|
+
// the same query hash can be acked twice into one poke: the sec-protocol
|
|
725
|
+
// initConnection queues its got-ack in the constructor, and a racing
|
|
726
|
+
// changeDesiredQueries send() queues it again before the in-flight pull's
|
|
727
|
+
// poke consumes the pending patch. a duplicate put for one hash inside a
|
|
728
|
+
// single gotQueriesPatch stalls the zero client's complete tracking, so
|
|
729
|
+
// collapse to the last op per hash (a clear resets everything before it).
|
|
730
|
+
function dedupeGotQueriesPatch(patch) {
|
|
731
|
+
let clear = false;
|
|
732
|
+
const lastOpByHash = new Map();
|
|
733
|
+
for (const op of patch) {
|
|
734
|
+
if (op.op === 'clear') {
|
|
735
|
+
clear = true;
|
|
736
|
+
lastOpByHash.clear();
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
lastOpByHash.delete(op.hash);
|
|
740
|
+
lastOpByHash.set(op.hash, op);
|
|
741
|
+
}
|
|
742
|
+
const deduped = clear ? [{ op: 'clear' }] : [];
|
|
743
|
+
deduped.push(...lastOpByHash.values());
|
|
744
|
+
return deduped;
|
|
745
|
+
}
|
|
407
746
|
function toHttpCookie(cookie) {
|
|
408
747
|
if (cookie === null || cookie === '')
|
|
409
748
|
return null;
|
|
@@ -419,10 +758,6 @@ function toWebSocketCookie(cookie) {
|
|
|
419
758
|
function toLocalWebSocketCookie(cookie, localID) {
|
|
420
759
|
return `${String(cookie).padStart(COOKIE_WIDTH, '0')}#${String(localID).padStart(6, '0')}`;
|
|
421
760
|
}
|
|
422
|
-
function isStaleCookie(current, next) {
|
|
423
|
-
const currentNumber = toHttpCookie(current);
|
|
424
|
-
return currentNumber !== null && next <= currentNumber;
|
|
425
|
-
}
|
|
426
761
|
function errorMessage(error) {
|
|
427
762
|
return error instanceof Error ? error.message : String(error);
|
|
428
763
|
}
|
|
@@ -439,4 +774,36 @@ function isAuthHTTPError(error) {
|
|
|
439
774
|
return (error instanceof ZeroHttpResponseError &&
|
|
440
775
|
(error.status === 401 || error.status === 403));
|
|
441
776
|
}
|
|
777
|
+
// keep only this client's own mutation results — zero-cache's pusher does the
|
|
778
|
+
// same per-client fan-out (results for clients without a live connection are
|
|
779
|
+
// dropped; their LMID advance arrives via the next pull's
|
|
780
|
+
// lastMutationIDChanges, which is how recovered mutations settle).
|
|
781
|
+
function filterMutationResultsToClient(pushResponse, clientID) {
|
|
782
|
+
if (!pushResponse || typeof pushResponse !== 'object')
|
|
783
|
+
return pushResponse;
|
|
784
|
+
const mutations = pushResponse.mutations;
|
|
785
|
+
if (!Array.isArray(mutations))
|
|
786
|
+
return pushResponse;
|
|
787
|
+
return {
|
|
788
|
+
...pushResponse,
|
|
789
|
+
mutations: mutations.filter((m) => m?.id?.clientID === clientID),
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
function isStaleClientCookieError(error) {
|
|
793
|
+
return (error instanceof ZeroHttpResponseError &&
|
|
794
|
+
error.path === '/pull' &&
|
|
795
|
+
error.status === 409);
|
|
796
|
+
}
|
|
797
|
+
function reconnectDelayMs(state) {
|
|
798
|
+
if (state.transientFailureCount === 0)
|
|
799
|
+
return 0;
|
|
800
|
+
return Math.min(TRANSIENT_RECONNECT_BACKOFF_BASE_MS * 2 ** (state.transientFailureCount - 1), TRANSIENT_RECONNECT_BACKOFF_MAX_MS);
|
|
801
|
+
}
|
|
802
|
+
function recordTransientFailure(state) {
|
|
803
|
+
const maxExponent = Math.log2(TRANSIENT_RECONNECT_BACKOFF_MAX_MS / TRANSIENT_RECONNECT_BACKOFF_BASE_MS);
|
|
804
|
+
state.transientFailureCount = Math.min(state.transientFailureCount + 1, Math.ceil(maxExponent) + 1);
|
|
805
|
+
}
|
|
806
|
+
function resetTransientFailures(state) {
|
|
807
|
+
state.transientFailureCount = 0;
|
|
808
|
+
}
|
|
442
809
|
//# sourceMappingURL=transport.js.map
|