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.
- package/README.md +4 -0
- package/dist/sync-browser-host/generated/sqlite3-browser.wasm +0 -0
- package/dist/sync-browser-host/generated/sync_wasm.js +824 -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 +17 -4
- package/dist/zero-http/transport.d.ts.map +1 -1
- package/dist/zero-http/transport.js +391 -44
- package/dist/zero-http/transport.js.map +1 -1
- package/package.json +14 -4
|
@@ -1,9 +1,22 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
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":"
|
|
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
|
|
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
|
+
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
|
-
|
|
137
|
+
pushCompletion = Promise.resolve();
|
|
138
|
+
flushGeneration = 0;
|
|
139
|
+
pendingUpstream = new Set();
|
|
140
|
+
nextLocalCookieID;
|
|
72
141
|
openTimer;
|
|
73
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
136
|
-
|
|
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
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
this.
|
|
178
|
-
|
|
179
|
-
|
|
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.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
274
|
-
|
|
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
|
-
|
|
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
|