orez 0.5.16 → 0.5.17

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 (41) hide show
  1. package/README.md +4 -0
  2. package/dist/sync-browser-host/generated/sqlite3-browser.wasm +0 -0
  3. package/dist/sync-browser-host/generated/sync_wasm.js +824 -0
  4. package/dist/sync-browser-host/generated/sync_wasm_bg.wasm +0 -0
  5. package/dist/sync-browser-host/host.d.ts +8 -0
  6. package/dist/sync-browser-host/host.d.ts.map +1 -0
  7. package/dist/sync-browser-host/host.js +435 -0
  8. package/dist/sync-browser-host/host.js.map +1 -0
  9. package/dist/sync-browser-host/idb-snapshot.d.ts +10 -0
  10. package/dist/sync-browser-host/idb-snapshot.d.ts.map +1 -0
  11. package/dist/sync-browser-host/idb-snapshot.js +109 -0
  12. package/dist/sync-browser-host/idb-snapshot.js.map +1 -0
  13. package/dist/sync-browser-host/index.d.ts +4 -0
  14. package/dist/sync-browser-host/index.d.ts.map +1 -0
  15. package/dist/sync-browser-host/index.js +4 -0
  16. package/dist/sync-browser-host/index.js.map +1 -0
  17. package/dist/sync-browser-host/message-port.d.ts +6 -0
  18. package/dist/sync-browser-host/message-port.d.ts.map +1 -0
  19. package/dist/sync-browser-host/message-port.js +182 -0
  20. package/dist/sync-browser-host/message-port.js.map +1 -0
  21. package/dist/sync-browser-host/sqlite-adapter.d.ts +66 -0
  22. package/dist/sync-browser-host/sqlite-adapter.d.ts.map +1 -0
  23. package/dist/sync-browser-host/sqlite-adapter.js +135 -0
  24. package/dist/sync-browser-host/sqlite-adapter.js.map +1 -0
  25. package/dist/sync-browser-host/types.d.ts +88 -0
  26. package/dist/sync-browser-host/types.d.ts.map +1 -0
  27. package/dist/sync-browser-host/types.js +12 -0
  28. package/dist/sync-browser-host/types.js.map +1 -0
  29. package/dist/test-setup.d.ts +2 -0
  30. package/dist/test-setup.d.ts.map +1 -0
  31. package/dist/test-setup.js +35 -0
  32. package/dist/test-setup.js.map +1 -0
  33. package/dist/zero-http/test-harness.d.ts +2 -6
  34. package/dist/zero-http/test-harness.d.ts.map +1 -1
  35. package/dist/zero-http/test-harness.js +4 -2
  36. package/dist/zero-http/test-harness.js.map +1 -1
  37. package/dist/zero-http/transport.d.ts +17 -4
  38. package/dist/zero-http/transport.d.ts.map +1 -1
  39. package/dist/zero-http/transport.js +391 -44
  40. package/dist/zero-http/transport.js.map +1 -1
  41. package/package.json +14 -4
@@ -1,9 +1,22 @@
1
- export declare function installZeroHttpTransport(opts: {
2
- origin: string;
3
- fetch?: typeof fetch;
4
- }): {
1
+ export type QueryTransform = (name: string, args: readonly unknown[]) => unknown;
2
+ export type HttpPullTransport = {
5
3
  pull(): Promise<void>;
4
+ flush(): Promise<void>;
6
5
  readonly connections: number;
7
6
  uninstall(): void;
8
7
  };
8
+ export type HttpPullTransportOptions = {
9
+ appID?: string;
10
+ shardNum?: number;
11
+ origin: string;
12
+ pushOrigin?: string;
13
+ fetch?: typeof fetch;
14
+ pullIntervalMs?: number;
15
+ wake?: boolean;
16
+ queryTransform?: QueryTransform;
17
+ queryForward?: boolean;
18
+ };
19
+ export declare function installHttpPullTransport(opts: HttpPullTransportOptions): HttpPullTransport;
20
+ export declare function ensureHttpPullTransport(opts: HttpPullTransportOptions): HttpPullTransport;
21
+ export declare function flushHttpPullTransports(): Promise<void>;
9
22
  //# sourceMappingURL=transport.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../../src/zero-http/transport.ts"],"names":[],"mappings":"AA0CA,wBAAgB,wBAAwB,CAAC,IAAI,EAAE;IAC7C,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;CACrB,GAAG;IACF,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACrB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,SAAS,IAAI,IAAI,CAAA;CAClB,CAgDA"}
1
+ {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../../src/zero-http/transport.ts"],"names":[],"mappings":"AA6CA,MAAM,MAAM,cAAc,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,OAAO,EAAE,KAAK,OAAO,CAAA;AAyChF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IAKrB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,SAAS,IAAI,IAAI,CAAA;CAClB,CAAA;AAED,MAAM,MAAM,wBAAwB,GAAG;IAGrC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IAId,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;IAGpB,cAAc,CAAC,EAAE,MAAM,CAAA;IAMvB,IAAI,CAAC,EAAE,OAAO,CAAA;IAId,cAAc,CAAC,EAAE,cAAc,CAAA;IAI/B,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB,CAAA;AAED,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,wBAAwB,GAC7B,iBAAiB,CA2EnB;AAOD,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,wBAAwB,GAC7B,iBAAiB,CAOnB;AAED,wBAAsB,uBAAuB,kBAI5C"}
@@ -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
+ wakeEnabled: 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,31 @@ 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
+ wakeReconnectTimer;
74
145
  constructor(state, url, protocols) {
75
146
  this.state = state;
76
147
  this.connectURL = toHttpURL(url);
@@ -80,11 +151,20 @@ class ZeroHttpSocket {
80
151
  this.wsid = this.connectURL.searchParams.get('wsid') ?? `zero-http-${Date.now()}`;
81
152
  const baseCookie = this.connectURL.searchParams.get('baseCookie');
82
153
  this.cookie = baseCookie ? baseCookie : null;
154
+ // the local cookie ID is encoded into the suffix of any cookie we
155
+ // already advanced past (toLocalWebSocketCookie). reconnects rehydrate
156
+ // this.cookie from the URL baseCookie param replicache persisted, so we
157
+ // must resume the counter from its suffix — otherwise the first poke on
158
+ // the new socket re-emits an identical suffix and replicache trips
159
+ // "cookie did not change, but patch is not empty" and drops the patch.
160
+ const suffixMatch = this.cookie?.match(/#(\d+)$/);
161
+ this.nextLocalCookieID = suffixMatch ? Number(suffixMatch[1]) : 0;
83
162
  const decoded = decodeSecProtocol(protocols);
84
163
  this.authToken = decoded.authToken;
85
164
  this.queueDesiredQueries(decoded.initConnectionMessage?.[1]);
86
165
  this.state.sockets.add(this);
87
- this.openTimer = setTimeout(() => this.open(), 0);
166
+ this.state.socketGeneration++;
167
+ this.openTimer = setTimeout(() => this.open(), reconnectDelayMs(this.state));
88
168
  }
89
169
  addEventListener(type, listener) {
90
170
  if (listener)
@@ -106,9 +186,12 @@ class ZeroHttpSocket {
106
186
  switch (message[0]) {
107
187
  case 'initConnection':
108
188
  case 'changeDesiredQueries':
109
- this.receivedDesiredQueries(message[1]);
189
+ this.flushGeneration++;
190
+ this.queueDesiredQueries(message[1]);
191
+ this.requestPullAfterCurrent();
110
192
  return;
111
193
  case 'updateAuth':
194
+ this.flushGeneration++;
112
195
  this.authToken = message[1].auth;
113
196
  return;
114
197
  case 'push':
@@ -117,9 +200,13 @@ class ZeroHttpSocket {
117
200
  case 'ping':
118
201
  this.emitMessage(['pong', {}]);
119
202
  return;
120
- case 'pull':
121
- this.run(this.answerMutationRecoveryPull(message[1]));
203
+ case 'pull': {
204
+ this.flushGeneration++;
205
+ const recoveryPull = this.answerMutationRecoveryPull(message[1]);
206
+ this.trackUpstream(recoveryPull);
207
+ this.run(recoveryPull);
122
208
  return;
209
+ }
123
210
  case 'deleteClients':
124
211
  case 'ackMutationResponses':
125
212
  return;
@@ -132,19 +219,23 @@ class ZeroHttpSocket {
132
219
  return;
133
220
  if (this.openTimer)
134
221
  clearTimeout(this.openTimer);
135
- if (this.initialPullTimer)
136
- clearTimeout(this.initialPullTimer);
222
+ if (this.pullTimer)
223
+ clearInterval(this.pullTimer);
224
+ this.closeWakeChannel();
137
225
  this.readyState = this.CLOSED;
138
- this.state.sockets.delete(this);
226
+ if (this.state.sockets.delete(this))
227
+ this.state.socketGeneration++;
139
228
  this.emit('close', { code, reason, wasClean: code <= 1001 });
140
229
  }
141
230
  pull() {
142
- if (this.readyState === this.CLOSED)
231
+ if (this.readyState !== this.OPEN)
143
232
  return Promise.resolve();
144
233
  if (this.pullInFlight)
145
234
  return this.pullInFlight;
146
- this.pullInFlight = this.fetchPull(this.clientGroupID, this.cookie)
235
+ this.pullInFlight = this.fetchPull(this.clientGroupID, this.cookie, true)
147
236
  .then((response) => {
237
+ if (this.state.queryAware)
238
+ this.applyServerGotQueries(response);
148
239
  if (response.unchanged) {
149
240
  this.emitGotQueriesPatch(response.cookie);
150
241
  return;
@@ -164,49 +255,168 @@ class ZeroHttpSocket {
164
255
  });
165
256
  return this.pullInFlight;
166
257
  }
258
+ // settle every tracked upstream effect, then prove quiescence with a double
259
+ // pull round-trip. loops until a full pass completes with no new work
260
+ // (flushGeneration unchanged and nothing pending).
261
+ async flush() {
262
+ for (;;) {
263
+ const generation = this.flushGeneration;
264
+ await Promise.all([...this.pendingUpstream]);
265
+ await this.pushCompletion;
266
+ await this.pull();
267
+ await Promise.all([...this.pendingUpstream]);
268
+ await this.pushCompletion;
269
+ await this.pull();
270
+ await Promise.resolve();
271
+ if (this.flushGeneration === generation && this.pendingUpstream.size === 0) {
272
+ return;
273
+ }
274
+ }
275
+ }
167
276
  open() {
168
277
  if (this.readyState !== this.CONNECTING)
169
278
  return;
170
279
  this.readyState = this.OPEN;
171
280
  this.emit('open', {});
172
281
  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);
282
+ setTimeout(() => this.run(this.pull()), 0);
283
+ if (this.state.pullIntervalMs) {
284
+ this.pullTimer = setInterval(() => {
285
+ this.run(this.pull());
286
+ }, this.state.pullIntervalMs);
287
+ }
288
+ if (this.state.wakeEnabled)
289
+ this.openWakeChannel();
290
+ }
291
+ // notification-only wake channel: a real WebSocket to <origin>/wake that
292
+ // carries no data. any frame means "pull now", so a wake triggers an
293
+ // immediate (coalesced) pull — push-shaped propagation without waiting on
294
+ // the poll interval. advisory only: if it drops we reconnect, and the
295
+ // interval poll remains the safety net that guarantees convergence.
296
+ openWakeChannel() {
297
+ const Native = this.state.nativeWebSocket;
298
+ if (!Native || this.wakeSocket || this.readyState !== this.OPEN)
299
+ return;
300
+ const wsBase = this.state.originString.replace(/^http/, 'ws');
301
+ const url = `${wsBase}/wake?clientID=${encodeURIComponent(this.clientID)}`;
302
+ let socket;
303
+ try {
304
+ socket = new Native(url);
305
+ }
306
+ catch {
307
+ return;
308
+ }
309
+ this.wakeSocket = socket;
310
+ const reconnect = () => {
311
+ if (this.wakeSocket !== socket)
312
+ return;
313
+ this.wakeSocket = undefined;
314
+ if (this.readyState !== this.OPEN || this.wakeReconnectTimer)
315
+ return;
316
+ this.wakeReconnectTimer = setTimeout(() => {
317
+ this.wakeReconnectTimer = undefined;
318
+ this.openWakeChannel();
319
+ }, 500);
320
+ };
321
+ // route through requestPullAfterCurrent, NOT pull() directly: a wake that
322
+ // lands while a pull is already in flight must set pullAfterCurrent so the
323
+ // in-flight pull re-runs and picks up the woken change. calling pull()
324
+ // directly would return the existing promise and silently drop the wake,
325
+ // leaving convergence to the safety poll (a burst-storm latency bug).
326
+ socket.onmessage = () => this.requestPullAfterCurrent();
327
+ socket.onclose = reconnect;
328
+ socket.onerror = reconnect;
329
+ }
330
+ closeWakeChannel() {
331
+ if (this.wakeReconnectTimer) {
332
+ clearTimeout(this.wakeReconnectTimer);
333
+ this.wakeReconnectTimer = undefined;
334
+ }
335
+ const socket = this.wakeSocket;
336
+ this.wakeSocket = undefined;
337
+ if (socket) {
338
+ try {
339
+ socket.close();
340
+ }
341
+ catch {
342
+ // best effort: an already-closing wake socket is harmless
343
+ }
344
+ }
180
345
  }
181
346
  queueDesiredQueries(body) {
182
347
  const desiredQueriesPatch = body
183
348
  ?.desiredQueriesPatch;
184
349
  if (!Array.isArray(desiredQueriesPatch))
185
350
  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;
351
+ if (!this.state.queryAware) {
352
+ // baseline dialect: synthesize the got-query ack locally
353
+ this.pendingGotQueriesPatch.push(...gotQueriesPatch(desiredQueriesPatch));
354
+ return;
193
355
  }
194
- this.requestPullAfterCurrent();
356
+ // query-aware: accumulate the desired-query delta to ship to the server. a
357
+ // put ships its inline ast, or a client-resolved ast (queryTransform), or
358
+ // name+args for the server to resolve (queryForward). the server owns the
359
+ // got-query ack.
360
+ const transform = this.state.queryTransform;
361
+ for (const op of desiredQueriesPatch) {
362
+ if (op.op === 'clear') {
363
+ this.desiredQueryPatch.push({ op: 'clear' });
364
+ }
365
+ else if (op.op === 'del') {
366
+ this.desiredQueryPatch.push({ op: 'del', hash: op.hash });
367
+ }
368
+ else if (op.op === 'put') {
369
+ const inline = op.ast;
370
+ const name = op.name ?? '';
371
+ const args = (op.args ??
372
+ []);
373
+ if (this.state.queryForward) {
374
+ this.desiredQueryPatch.push({ op: 'put', hash: op.hash, name, args });
375
+ }
376
+ else if (inline !== undefined) {
377
+ this.desiredQueryPatch.push({ op: 'put', hash: op.hash, ast: inline });
378
+ }
379
+ else if (transform) {
380
+ this.desiredQueryPatch.push({
381
+ op: 'put',
382
+ hash: op.hash,
383
+ ast: transform(name, args),
384
+ });
385
+ }
386
+ }
387
+ }
388
+ this.queryVersion++;
195
389
  }
196
390
  async push(body) {
197
391
  const response = (await this.postJSON('/push', body));
198
- this.emitMessage(['pushResponse', response.pushResponse]);
392
+ // mutation RECOVERY pushes carry a PREVIOUS client's pending mutations,
393
+ // and the server response echoes that old clientID. zero-cache's pusher
394
+ // groups results by clientID and only delivers a client its OWN results
395
+ // (recovered mutations settle via lastMutationIDChanges in pokes); the
396
+ // raw response would trip zero's "received mutation for the wrong
397
+ // client" assert and kill the connection. mirror the same filtering.
398
+ this.emitMessage([
399
+ 'pushResponse',
400
+ filterMutationResultsToClient('pushResponse' in response ? response.pushResponse : response, this.clientID),
401
+ ]);
199
402
  this.requestPullAfterCurrent();
200
403
  }
201
404
  enqueuePush(body) {
405
+ this.flushGeneration++;
202
406
  const nextPush = this.pushChain.then(async () => {
203
407
  if (this.readyState === this.CLOSED)
204
408
  return;
205
409
  await this.push(body);
206
410
  });
411
+ this.pushCompletion = nextPush;
207
412
  this.pushChain = nextPush.catch(() => { });
413
+ this.trackUpstream(nextPush);
208
414
  this.run(nextPush);
209
415
  }
416
+ trackUpstream(promise) {
417
+ this.pendingUpstream.add(promise);
418
+ void promise.then(() => this.pendingUpstream.delete(promise), () => this.pendingUpstream.delete(promise));
419
+ }
210
420
  requestPullAfterCurrent() {
211
421
  if (this.pullInFlight) {
212
422
  this.pullAfterCurrent = true;
@@ -214,8 +424,23 @@ class ZeroHttpSocket {
214
424
  }
215
425
  this.run(this.pull());
216
426
  }
427
+ // in query-aware mode the got-query ack is authoritative from the server:
428
+ // take the server's gotQueries.patch as the got patch to emit (replacing
429
+ // local synthesis), and clear the acked prefix of the shipped desired delta
430
+ // once the server acks that version (the ack never leads its row effects —
431
+ // invariant 13 — so the client marks a query got only after its rows land).
432
+ applyServerGotQueries(response) {
433
+ const got = response.gotQueries;
434
+ this.pendingGotQueriesPatch = got ? got.patch : [];
435
+ if (got &&
436
+ this.sentQueryVersion !== undefined &&
437
+ got.version >= this.sentQueryVersion) {
438
+ this.desiredQueryPatch.splice(0, this.sentQueryPatchLen);
439
+ this.sentQueryVersion = undefined;
440
+ }
441
+ }
217
442
  async answerMutationRecoveryPull(body) {
218
- const response = await this.fetchPull(body.clientGroupID, body.cookie);
443
+ const response = await this.fetchPull(body.clientGroupID, body.cookie, false);
219
444
  const cookie = toWebSocketCookie(response.cookie);
220
445
  this.emitMessage([
221
446
  'pull',
@@ -226,15 +451,33 @@ class ZeroHttpSocket {
226
451
  },
227
452
  ]);
228
453
  }
229
- async fetchPull(clientGroupID, cookie) {
230
- return (await this.postJSON('/pull', {
454
+ async fetchPull(clientGroupID, cookie, includeQueries) {
455
+ const body = {
231
456
  clientID: this.clientID,
232
457
  clientGroupID,
233
458
  cookie: toHttpCookie(cookie),
234
- }));
459
+ };
460
+ // ship the un-acked desired-query delta with the pull; remember what we
461
+ // sent so the server ack can clear exactly that prefix. a recovery pull
462
+ // (includeQueries=false) never carries desires.
463
+ if (includeQueries && this.state.queryAware && this.desiredQueryPatch.length > 0) {
464
+ this.sentQueryVersion = this.queryVersion;
465
+ this.sentQueryPatchLen = this.desiredQueryPatch.length;
466
+ body.queries = { version: this.queryVersion, patch: [...this.desiredQueryPatch] };
467
+ }
468
+ else {
469
+ this.sentQueryVersion = undefined;
470
+ }
471
+ return (await this.postJSON('/pull', body));
235
472
  }
236
473
  async postJSON(path, body) {
237
- const response = await this.state.fetch(`${this.state.originString}${path}`, {
474
+ const base = path === '/push' ? this.state.pushOriginString : this.state.originString;
475
+ const url = new URL(`${base}${path}`);
476
+ if (path === '/push') {
477
+ url.searchParams.set('schema', `${this.state.appID}_${this.state.shardNum}`);
478
+ url.searchParams.set('appID', this.state.appID);
479
+ }
480
+ const response = await this.state.fetch(url, {
238
481
  method: 'POST',
239
482
  headers: {
240
483
  authorization: this.authToken ? `Bearer ${this.authToken}` : '',
@@ -245,6 +488,7 @@ class ZeroHttpSocket {
245
488
  if (!response.ok) {
246
489
  throw new ZeroHttpResponseError(path, response.status);
247
490
  }
491
+ resetTransientFailures(this.state);
248
492
  return response.json();
249
493
  }
250
494
  run(promise) {
@@ -266,17 +510,59 @@ class ZeroHttpSocket {
266
510
  this.close(1000, error.message);
267
511
  return;
268
512
  }
513
+ if (isStaleClientCookieError(error)) {
514
+ // the client's cookie is AHEAD of the server watermark — the server
515
+ // lost or reset its change-tracking state (replica reset / restore).
516
+ // mirror zero-cache's InvalidConnectionRequestBaseCookie error frame so
517
+ // the stock client drops its local db and rebuilds from scratch instead
518
+ // of reconnect-looping on 409 forever.
519
+ this.emitMessage([
520
+ 'error',
521
+ {
522
+ kind: 'InvalidConnectionRequestBaseCookie',
523
+ message: error.message,
524
+ origin: 'server',
525
+ },
526
+ ]);
527
+ if (this.readyState !== this.CLOSED)
528
+ this.close(1000, error.message);
529
+ return;
530
+ }
531
+ recordTransientFailure(this.state);
269
532
  this.emit('error', { error });
270
533
  this.close(1011, errorMessage(error));
271
534
  }
272
535
  emitPoke(response) {
273
- const nextCookie = toWebSocketCookie(response.cookie);
274
- if (isStaleCookie(this.cookie, response.cookie)) {
536
+ const currentServer = toHttpCookie(this.cookie);
537
+ let nextCookie;
538
+ if (currentServer !== null && response.cookie < currentServer) {
539
+ // the server watermark is BEHIND the client: a real reset/restore. mirror
540
+ // the 409 stale path instead of poking the client backwards.
275
541
  throw new Error(`zero-http pull returned stale cookie ${response.cookie} for ${this.cookie}`);
276
542
  }
543
+ else if (currentServer !== null && response.cookie === currentServer) {
544
+ // same server watermark but a non-empty patch: a query-aware membership
545
+ // delta (a desired-query change recomputes rows without advancing the
546
+ // change log). bump a client-local cookie id so replicache sees a changed
547
+ // cookie — otherwise it trips "cookie did not change, but patch is not
548
+ // empty" and drops the patch.
549
+ nextCookie = toLocalWebSocketCookie(response.cookie, ++this.nextLocalCookieID);
550
+ }
551
+ else {
552
+ nextCookie = toWebSocketCookie(response.cookie);
553
+ }
277
554
  const pokeID = `zero-http-${++this.state.nextPokeID}`;
278
- const gotQueries = this.pendingGotQueriesPatch;
555
+ let gotQueries = dedupeGotQueriesPatch(this.pendingGotQueriesPatch);
279
556
  this.pendingGotQueriesPatch = [];
557
+ const rowsCleared = Array.isArray(response.rowsPatch) &&
558
+ response.rowsPatch.some((op) => op?.op === 'clear');
559
+ if (rowsCleared && this.ackedGotHashes.size > 0) {
560
+ gotQueries = dedupeGotQueriesPatch([
561
+ ...[...this.ackedGotHashes].map((hash) => ({ op: 'put', hash })),
562
+ ...gotQueries,
563
+ ]);
564
+ }
565
+ this.recordAckedGotQueries(gotQueries);
280
566
  this.emitMessage([
281
567
  'pokeStart',
282
568
  {
@@ -317,8 +603,9 @@ class ZeroHttpSocket {
317
603
  return;
318
604
  const nextCookie = toLocalWebSocketCookie(serverCookie, ++this.nextLocalCookieID);
319
605
  const pokeID = `zero-http-${++this.state.nextPokeID}`;
320
- const gotQueries = this.pendingGotQueriesPatch;
606
+ const gotQueries = dedupeGotQueriesPatch(this.pendingGotQueriesPatch);
321
607
  this.pendingGotQueriesPatch = [];
608
+ this.recordAckedGotQueries(gotQueries);
322
609
  this.emitMessage([
323
610
  'pokeStart',
324
611
  {
@@ -341,6 +628,16 @@ class ZeroHttpSocket {
341
628
  this.emitMessage(['pokeEnd', { pokeID, cookie: nextCookie }]);
342
629
  this.cookie = nextCookie;
343
630
  }
631
+ recordAckedGotQueries(patch) {
632
+ for (const op of patch) {
633
+ if (op.op === 'clear')
634
+ this.ackedGotHashes.clear();
635
+ else if (op.op === 'put')
636
+ this.ackedGotHashes.add(op.hash);
637
+ else
638
+ this.ackedGotHashes.delete(op.hash);
639
+ }
640
+ }
344
641
  emitMessage(message) {
345
642
  if (this.readyState !== this.OPEN)
346
643
  return;
@@ -404,6 +701,28 @@ function gotQueriesPatch(patch) {
404
701
  }
405
702
  return got;
406
703
  }
704
+ // the same query hash can be acked twice into one poke: the sec-protocol
705
+ // initConnection queues its got-ack in the constructor, and a racing
706
+ // changeDesiredQueries send() queues it again before the in-flight pull's
707
+ // poke consumes the pending patch. a duplicate put for one hash inside a
708
+ // single gotQueriesPatch stalls the zero client's complete tracking, so
709
+ // collapse to the last op per hash (a clear resets everything before it).
710
+ function dedupeGotQueriesPatch(patch) {
711
+ let clear = false;
712
+ const lastOpByHash = new Map();
713
+ for (const op of patch) {
714
+ if (op.op === 'clear') {
715
+ clear = true;
716
+ lastOpByHash.clear();
717
+ continue;
718
+ }
719
+ lastOpByHash.delete(op.hash);
720
+ lastOpByHash.set(op.hash, op);
721
+ }
722
+ const deduped = clear ? [{ op: 'clear' }] : [];
723
+ deduped.push(...lastOpByHash.values());
724
+ return deduped;
725
+ }
407
726
  function toHttpCookie(cookie) {
408
727
  if (cookie === null || cookie === '')
409
728
  return null;
@@ -419,10 +738,6 @@ function toWebSocketCookie(cookie) {
419
738
  function toLocalWebSocketCookie(cookie, localID) {
420
739
  return `${String(cookie).padStart(COOKIE_WIDTH, '0')}#${String(localID).padStart(6, '0')}`;
421
740
  }
422
- function isStaleCookie(current, next) {
423
- const currentNumber = toHttpCookie(current);
424
- return currentNumber !== null && next <= currentNumber;
425
- }
426
741
  function errorMessage(error) {
427
742
  return error instanceof Error ? error.message : String(error);
428
743
  }
@@ -439,4 +754,36 @@ function isAuthHTTPError(error) {
439
754
  return (error instanceof ZeroHttpResponseError &&
440
755
  (error.status === 401 || error.status === 403));
441
756
  }
757
+ // keep only this client's own mutation results — zero-cache's pusher does the
758
+ // same per-client fan-out (results for clients without a live connection are
759
+ // dropped; their LMID advance arrives via the next pull's
760
+ // lastMutationIDChanges, which is how recovered mutations settle).
761
+ function filterMutationResultsToClient(pushResponse, clientID) {
762
+ if (!pushResponse || typeof pushResponse !== 'object')
763
+ return pushResponse;
764
+ const mutations = pushResponse.mutations;
765
+ if (!Array.isArray(mutations))
766
+ return pushResponse;
767
+ return {
768
+ ...pushResponse,
769
+ mutations: mutations.filter((m) => m?.id?.clientID === clientID),
770
+ };
771
+ }
772
+ function isStaleClientCookieError(error) {
773
+ return (error instanceof ZeroHttpResponseError &&
774
+ error.path === '/pull' &&
775
+ error.status === 409);
776
+ }
777
+ function reconnectDelayMs(state) {
778
+ if (state.transientFailureCount === 0)
779
+ return 0;
780
+ return Math.min(TRANSIENT_RECONNECT_BACKOFF_BASE_MS * 2 ** (state.transientFailureCount - 1), TRANSIENT_RECONNECT_BACKOFF_MAX_MS);
781
+ }
782
+ function recordTransientFailure(state) {
783
+ const maxExponent = Math.log2(TRANSIENT_RECONNECT_BACKOFF_MAX_MS / TRANSIENT_RECONNECT_BACKOFF_BASE_MS);
784
+ state.transientFailureCount = Math.min(state.transientFailureCount + 1, Math.ceil(maxExponent) + 1);
785
+ }
786
+ function resetTransientFailures(state) {
787
+ state.transientFailureCount = 0;
788
+ }
442
789
  //# sourceMappingURL=transport.js.map