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.
Files changed (44) hide show
  1. package/README.md +4 -0
  2. package/dist/cf-do/worker.d.ts.map +1 -1
  3. package/dist/cf-do/worker.js +89 -14
  4. package/dist/cf-do/worker.js.map +1 -1
  5. package/dist/sync-browser-host/generated/sqlite3-browser.wasm +0 -0
  6. package/dist/sync-browser-host/generated/sync_wasm.js +921 -0
  7. package/dist/sync-browser-host/generated/sync_wasm_bg.wasm +0 -0
  8. package/dist/sync-browser-host/host.d.ts +8 -0
  9. package/dist/sync-browser-host/host.d.ts.map +1 -0
  10. package/dist/sync-browser-host/host.js +435 -0
  11. package/dist/sync-browser-host/host.js.map +1 -0
  12. package/dist/sync-browser-host/idb-snapshot.d.ts +10 -0
  13. package/dist/sync-browser-host/idb-snapshot.d.ts.map +1 -0
  14. package/dist/sync-browser-host/idb-snapshot.js +109 -0
  15. package/dist/sync-browser-host/idb-snapshot.js.map +1 -0
  16. package/dist/sync-browser-host/index.d.ts +4 -0
  17. package/dist/sync-browser-host/index.d.ts.map +1 -0
  18. package/dist/sync-browser-host/index.js +4 -0
  19. package/dist/sync-browser-host/index.js.map +1 -0
  20. package/dist/sync-browser-host/message-port.d.ts +6 -0
  21. package/dist/sync-browser-host/message-port.d.ts.map +1 -0
  22. package/dist/sync-browser-host/message-port.js +182 -0
  23. package/dist/sync-browser-host/message-port.js.map +1 -0
  24. package/dist/sync-browser-host/sqlite-adapter.d.ts +66 -0
  25. package/dist/sync-browser-host/sqlite-adapter.d.ts.map +1 -0
  26. package/dist/sync-browser-host/sqlite-adapter.js +135 -0
  27. package/dist/sync-browser-host/sqlite-adapter.js.map +1 -0
  28. package/dist/sync-browser-host/types.d.ts +88 -0
  29. package/dist/sync-browser-host/types.d.ts.map +1 -0
  30. package/dist/sync-browser-host/types.js +12 -0
  31. package/dist/sync-browser-host/types.js.map +1 -0
  32. package/dist/test-setup.d.ts +2 -0
  33. package/dist/test-setup.d.ts.map +1 -0
  34. package/dist/test-setup.js +35 -0
  35. package/dist/test-setup.js.map +1 -0
  36. package/dist/zero-http/test-harness.d.ts +2 -6
  37. package/dist/zero-http/test-harness.d.ts.map +1 -1
  38. package/dist/zero-http/test-harness.js +4 -2
  39. package/dist/zero-http/test-harness.js.map +1 -1
  40. package/dist/zero-http/transport.d.ts +30 -4
  41. package/dist/zero-http/transport.d.ts.map +1 -1
  42. package/dist/zero-http/transport.js +411 -44
  43. package/dist/zero-http/transport.js.map +1 -1
  44. 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 CONNECTED_QUERY_FLUSH_MS = 25;
3
- export function installZeroHttpTransport(opts) {
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('installZeroHttpTransport requires a fetch implementation');
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
- fetch: fetchImpl,
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
- nextLocalCookieID = 0;
137
+ pushCompletion = Promise.resolve();
138
+ flushGeneration = 0;
139
+ pendingUpstream = new Set();
140
+ nextLocalCookieID;
72
141
  openTimer;
73
- initialPullTimer;
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.openTimer = setTimeout(() => this.open(), 0);
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.receivedDesiredQueries(message[1]);
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.run(this.answerMutationRecoveryPull(message[1]));
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.initialPullTimer)
136
- clearTimeout(this.initialPullTimer);
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 === this.CLOSED)
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
- // zero may diff desired queries asynchronously after the connected frame.
174
- // give that upstream init/change message first chance to drive the pull so
175
- // the first snapshot can carry gotQueriesPatch for newly materialized views.
176
- this.initialPullTimer = setTimeout(() => {
177
- this.initialPullTimer = undefined;
178
- this.run(this.pull());
179
- }, CONNECTED_QUERY_FLUSH_MS);
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.pendingGotQueriesPatch.push(...gotQueriesPatch(desiredQueriesPatch));
187
- }
188
- receivedDesiredQueries(body) {
189
- this.queueDesiredQueries(body);
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
- this.requestPullAfterCurrent();
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
- this.emitMessage(['pushResponse', response.pushResponse]);
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
- return (await this.postJSON('/pull', {
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 response = await this.state.fetch(`${this.state.originString}${path}`, {
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 nextCookie = toWebSocketCookie(response.cookie);
274
- if (isStaleCookie(this.cookie, response.cookie)) {
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
- const gotQueries = this.pendingGotQueriesPatch;
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