Skip to main content
Module

x/replicache/replicache-mutation-recovery.test.ts

Realtime Sync for Any Backend Stack
Latest
File
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214
import { initReplicacheTesting, replicacheForTesting, tickAFewTimes, dbsToDrop, clock, createReplicacheNameForTest, replicacheForTestingNoDefaultURLs,} from './test-util';import {makeIDBName, REPLICACHE_FORMAT_VERSION} from './replicache';import {addGenesis, addLocal, addSnapshot, Chain} from './db/test-helpers';import type * as db from './db/mod';import * as dag from './dag/mod';import * as persist from './persist/mod';import * as kv from './kv/mod';import type * as sync from './sync/mod';import {assertHash, assertNotTempHash, makeNewTempHashFunction} from './hash';import {assertNotUndefined} from './asserts';import {expect} from '@esm-bundle/chai';import {uuid} from './uuid';import {assertJSONObject, JSONObject, ReadonlyJSONObject} from './json';import sinon from 'sinon';
// fetch-mock has invalid d.ts file so we removed that on npm install.// eslint-disable-next-line @typescript-eslint/ban-ts-comment// @ts-expect-errorimport fetchMock from 'fetch-mock/esm/client';import {initClientWithClientID} from './persist/clients-test-helpers.js';
initReplicacheTesting();
const dagsToClose: dag.Store[] = [];
teardown(async () => { for (const dagToClose of dagsToClose) { await dagToClose.close(); } dagsToClose.length = 0; sinon.restore();});
async function createPerdag(args: { replicacheName: string; schemaVersion: string;}): Promise<dag.Store> { const {replicacheName, schemaVersion} = args; const idbName = makeIDBName(replicacheName, schemaVersion); dbsToDrop.add(idbName); const idb = new kv.IDBStore(idbName);
const idbDatabases = new persist.IDBDatabasesStore(); try { await idbDatabases.putDatabase({ name: idbName, replicacheName, schemaVersion, replicacheFormatVersion: REPLICACHE_FORMAT_VERSION, }); } finally { await idbDatabases.close(); } const perdag = new dag.StoreImpl( idb, dag.throwChunkHasher, assertNotTempHash, ); dagsToClose.push(perdag); return perdag;}
async function createAndPersistClientWithPendingLocal( clientID: sync.ClientID, perdag: dag.Store, numLocal: number,): Promise<db.LocalMeta[]> { const testMemdag = new dag.TestStore( undefined, makeNewTempHashFunction(), assertHash, ); const chain: Chain = []; await addGenesis(chain, testMemdag); await addSnapshot(chain, testMemdag, [['unique', uuid()]]);
await initClientWithClientID(clientID, perdag);
const localMetas: db.LocalMeta[] = []; for (let i = 0; i < numLocal; i++) { await addLocal(chain, testMemdag); localMetas.push(chain[chain.length - 1].meta as db.LocalMeta); } await persist.persist(clientID, testMemdag, perdag); return localMetas;}
function createPushBody( profileID: string, clientID: sync.ClientID, localMetas: db.LocalMeta[], schemaVersion: string,): ReadonlyJSONObject { return { profileID, clientID, mutations: localMetas.map(localMeta => ({ id: localMeta.mutationID, name: localMeta.mutatorName, args: localMeta.mutatorArgsJSON, timestamp: localMeta.timestamp, })), pushVersion: 0, schemaVersion, };}
async function testRecoveringMutationsOfClient(args: { schemaVersionOfClientWPendingMutations: string; schemaVersionOfClientRecoveringMutations: string; numMutationsNotAcknowledgedByPull?: number;}) { sinon.stub(console, 'error');
const { schemaVersionOfClientWPendingMutations, schemaVersionOfClientRecoveringMutations, numMutationsNotAcknowledgedByPull, } = { numMutationsNotAcknowledgedByPull: 0, ...args, }; const client1ID = 'client1'; const auth = '1'; const pushURL = 'https://test.replicache.dev/push'; const pullURL = 'https://test.replicache.dev/pull'; const rep = await replicacheForTesting( `recoverMutations${schemaVersionOfClientRecoveringMutations}recovering${schemaVersionOfClientWPendingMutations}`, { auth, schemaVersion: schemaVersionOfClientRecoveringMutations, pushURL, pullURL, }, ); const profileID = await rep.profileID;
await tickAFewTimes();
const testPerdag = await createPerdag({ replicacheName: rep.name, schemaVersion: schemaVersionOfClientWPendingMutations, });
const client1PendingLocalMetas = await createAndPersistClientWithPendingLocal( client1ID, testPerdag, 2, ); const client1 = await testPerdag.withRead(read => persist.getClient(client1ID, read), ); assertNotUndefined(client1);
fetchMock.reset(); fetchMock.post(pushURL, 'ok'); const pullLastMutationID = client1.mutationID - numMutationsNotAcknowledgedByPull; fetchMock.post(pullURL, { cookie: 'pull_cookie_1', lastMutationID: pullLastMutationID, patch: [], });
await rep.recoverMutations();
const pushCalls = fetchMock.calls(pushURL); expect(pushCalls.length).to.equal(1); expect(await pushCalls[0].request.json()).to.deep.equal({ profileID, clientID: client1ID, mutations: [ { id: client1PendingLocalMetas[0].mutationID, name: client1PendingLocalMetas[0].mutatorName, args: client1PendingLocalMetas[0].mutatorArgsJSON, timestamp: client1PendingLocalMetas[0].timestamp, }, { id: client1PendingLocalMetas[1].mutationID, name: client1PendingLocalMetas[1].mutatorName, args: client1PendingLocalMetas[1].mutatorArgsJSON, timestamp: client1PendingLocalMetas[1].timestamp, }, ], pushVersion: 0, schemaVersion: schemaVersionOfClientWPendingMutations, });
const pullCalls = fetchMock.calls(pullURL); expect(pullCalls.length).to.equal(1); expect(await pullCalls[0].request.json()).to.deep.equal({ profileID, clientID: client1ID, schemaVersion: schemaVersionOfClientWPendingMutations, cookie: 'cookie_1', lastMutationID: client1.lastServerAckdMutationID, pullVersion: 0, });
const updatedClient1 = await testPerdag.withRead(read => persist.getClient(client1ID, read), ); assertNotUndefined(updatedClient1); expect(updatedClient1.mutationID).to.equal(client1.mutationID); expect(updatedClient1.lastServerAckdMutationID).to.equal(pullLastMutationID); expect(updatedClient1.headHash).to.equal(client1.headHash);}
test('successfully recovering mutations of client with same schema version and replicache format version', async () => { await testRecoveringMutationsOfClient({ schemaVersionOfClientWPendingMutations: 'testSchema1', schemaVersionOfClientRecoveringMutations: 'testSchema1', });});
test('successfully recovering mutations of client with different schema version but same replicache format version', async () => { await testRecoveringMutationsOfClient({ schemaVersionOfClientWPendingMutations: 'testSchema1', schemaVersionOfClientRecoveringMutations: 'testSchema2', });});
test('successfully recovering some but not all mutations of another client (pull does not acknowledge all)', async () => { await testRecoveringMutationsOfClient({ schemaVersionOfClientWPendingMutations: 'testSchema1', schemaVersionOfClientRecoveringMutations: 'testSchema1', numMutationsNotAcknowledgedByPull: 1, });});
test('recovering mutations with pull disabled', async () => { const schemaVersionOfClientWPendingMutations = 'testSchema1'; const schemaVersionOfClientRecoveringMutations = 'testSchema1'; const client1ID = 'client1'; const auth = '1'; const pushURL = 'https://test.replicache.dev/push'; const pullURL = ''; // pull disabled const rep = await replicacheForTesting( `recoverMutations${schemaVersionOfClientRecoveringMutations}recovering${schemaVersionOfClientWPendingMutations}`, { auth, schemaVersion: schemaVersionOfClientRecoveringMutations, pushURL, pullURL, }, ); const profileID = await rep.profileID;
await tickAFewTimes();
const testPerdag = await createPerdag({ replicacheName: rep.name, schemaVersion: schemaVersionOfClientWPendingMutations, });
const client1PendingLocalMetas = await createAndPersistClientWithPendingLocal( client1ID, testPerdag, 2, ); const client1 = await testPerdag.withRead(read => persist.getClient(client1ID, read), ); assertNotUndefined(client1);
fetchMock.reset(); fetchMock.post(pushURL, 'ok'); fetchMock.catch(() => { throw new Error('unexpected fetch in test'); });
await rep.recoverMutations();
const pushCalls = fetchMock.calls(pushURL); expect(pushCalls.length).to.equal(1); expect(await pushCalls[0].request.json()).to.deep.equal({ profileID, clientID: client1ID, mutations: [ { id: client1PendingLocalMetas[0].mutationID, name: client1PendingLocalMetas[0].mutatorName, args: client1PendingLocalMetas[0].mutatorArgsJSON, timestamp: client1PendingLocalMetas[0].timestamp, }, { id: client1PendingLocalMetas[1].mutationID, name: client1PendingLocalMetas[1].mutatorName, args: client1PendingLocalMetas[1].mutatorArgsJSON, timestamp: client1PendingLocalMetas[1].timestamp, }, ], pushVersion: 0, schemaVersion: schemaVersionOfClientWPendingMutations, });
// Expect no unmatched fetches (only a push request should be sent, no pull) expect(fetchMock.calls('unmatched').length).to.equal(0);
const updatedClient1 = await testPerdag.withRead(read => persist.getClient(client1ID, read), ); // unchanged expect(updatedClient1).to.deep.equal(client1);});
test('client does not attempt to recover mutations from IndexedDB with different replicache name', async () => { const clientWPendingMutationsID = 'client1'; const schemaVersion = 'testSchema'; const replicachePartialNameOfClientWPendingMutations = 'diffName-pendingClient'; const replicachePartialNameOfClientRecoveringMutations = 'diffName-recoveringClient';
const auth = '1'; const pushURL = 'https://test.replicache.dev/push'; const pullURL = 'https://test.replicache.dev/pull'; const rep = await replicacheForTesting( replicachePartialNameOfClientRecoveringMutations, { auth, schemaVersion, pushURL, pullURL, }, );
await tickAFewTimes();
const testPerdag = await createPerdag({ replicacheName: createReplicacheNameForTest( replicachePartialNameOfClientWPendingMutations, ), schemaVersion, });
await createAndPersistClientWithPendingLocal( clientWPendingMutationsID, testPerdag, 2, ); const clientWPendingMutations = await testPerdag.withRead(read => persist.getClient(clientWPendingMutationsID, read), ); assertNotUndefined(clientWPendingMutations);
fetchMock.reset(); fetchMock.post(pushURL, 'ok'); fetchMock.post(pullURL, { cookie: 'pull_cookie_1', lastMutationID: clientWPendingMutations.mutationID, patch: [], });
await rep.recoverMutations();
// expect(fetchMock.calls(pushURL).length).to.equal(0); expect(fetchMock.calls(pullURL).length).to.equal(0);});
test('successfully recovering mutations of multiple clients with mix of schema versions and same replicache format version', async () => { const schemaVersionOfClients1Thru3AndClientRecoveringMutations = 'testSchema1'; const schemaVersionOfClient4 = 'testSchema2'; // client1 has same schema version as recovering client and 2 mutations to recover const client1ID = 'client1'; // client2 has same schema version as recovering client and no mutations to recover const client2ID = 'client2'; // client3 has same schema version as recovering client and 1 mutation to recover const client3ID = 'client3'; // client4 has different schema version than recovering client and 2 mutations to recover const client4ID = 'client4'; const replicachePartialName = 'recoverMutationsMix'; const auth = '1'; const pushURL = 'https://test.replicache.dev/push'; const pullURL = 'https://test.replicache.dev/pull'; const rep = await replicacheForTesting(replicachePartialName, { auth, schemaVersion: schemaVersionOfClients1Thru3AndClientRecoveringMutations, pushURL, pullURL, }); const profileID = await rep.profileID;
await tickAFewTimes();
const testPerdagForClients1Thru3 = await createPerdag({ replicacheName: rep.name, schemaVersion: schemaVersionOfClients1Thru3AndClientRecoveringMutations, });
const client1PendingLocalMetas = await createAndPersistClientWithPendingLocal( client1ID, testPerdagForClients1Thru3, 2, ); const client2PendingLocalMetas = await createAndPersistClientWithPendingLocal( client2ID, testPerdagForClients1Thru3, 0, ); expect(client2PendingLocalMetas.length).to.equal(0); const client3PendingLocalMetas = await createAndPersistClientWithPendingLocal( client3ID, testPerdagForClients1Thru3, 1, );
const testPerdagForClient4 = await createPerdag({ replicacheName: rep.name, schemaVersion: schemaVersionOfClient4, }); const client4PendingLocalMetas = await createAndPersistClientWithPendingLocal( client4ID, testPerdagForClient4, 2, );
const clients1Thru3 = await testPerdagForClients1Thru3.withRead(read => persist.getClients(read), ); const client1 = clients1Thru3.get(client1ID); assertNotUndefined(client1); const client2 = clients1Thru3.get(client2ID); assertNotUndefined(client2); const client3 = clients1Thru3.get(client3ID); assertNotUndefined(client3);
const client4 = await testPerdagForClient4.withRead(read => persist.getClient(client4ID, read), ); assertNotUndefined(client4);
const pullRequestJsonBodies: JSONObject[] = []; fetchMock.reset(); fetchMock.post(pushURL, 'ok'); fetchMock.post( pullURL, async (_url: string, _options: RequestInit, request: Request) => { const requestJson = await request.json(); assertJSONObject(requestJson); pullRequestJsonBodies.push(requestJson); const {clientID} = requestJson; switch (clientID) { case client1ID: return { cookie: 'pull_cookie_1', lastMutationID: client1.mutationID, patch: [], }; case client3ID: return { cookie: 'pull_cookie_3', lastMutationID: client3.mutationID, patch: [], }; case client4ID: return { cookie: 'pull_cookie_4', lastMutationID: client4.mutationID, patch: [], }; default: throw new Error(`Unexpected pull ${requestJson}`); } }, );
await rep.recoverMutations();
const pushCalls = fetchMock.calls(pushURL); expect(pushCalls.length).to.equal(3); expect(await pushCalls[0].request.json()).to.deep.equal( createPushBody( profileID, client1ID, client1PendingLocalMetas, schemaVersionOfClients1Thru3AndClientRecoveringMutations, ), ); expect(await pushCalls[1].request.json()).to.deep.equal( createPushBody( profileID, client3ID, client3PendingLocalMetas, schemaVersionOfClients1Thru3AndClientRecoveringMutations, ), ); expect(await pushCalls[2].request.json()).to.deep.equal( createPushBody( profileID, client4ID, client4PendingLocalMetas, schemaVersionOfClient4, ), );
expect(pullRequestJsonBodies.length).to.equal(3); expect(pullRequestJsonBodies[0]).to.deep.equal({ profileID, clientID: client1ID, schemaVersion: schemaVersionOfClients1Thru3AndClientRecoveringMutations, cookie: 'cookie_1', lastMutationID: client1.lastServerAckdMutationID, pullVersion: 0, }); expect(pullRequestJsonBodies[1]).to.deep.equal({ profileID, clientID: client3ID, schemaVersion: schemaVersionOfClients1Thru3AndClientRecoveringMutations, cookie: 'cookie_1', lastMutationID: client3.lastServerAckdMutationID, pullVersion: 0, }); expect(pullRequestJsonBodies[2]).to.deep.equal({ profileID, clientID: client4ID, schemaVersion: schemaVersionOfClient4, cookie: 'cookie_1', lastMutationID: client4.lastServerAckdMutationID, pullVersion: 0, });
const updateClients1Thru3 = await testPerdagForClients1Thru3.withRead(read => persist.getClients(read), ); const updatedClient1 = updateClients1Thru3.get(client1ID); assertNotUndefined(updatedClient1); const updatedClient2 = updateClients1Thru3.get(client2ID); assertNotUndefined(updatedClient2); const updatedClient3 = updateClients1Thru3.get(client3ID); assertNotUndefined(updatedClient3);
const updatedClient4 = await testPerdagForClient4.withRead(read => persist.getClient(client4ID, read), ); assertNotUndefined(updatedClient4);
expect(updatedClient1.mutationID).to.equal(client1.mutationID); // lastServerAckdMutationID is updated to high mutationID as mutations // were recovered expect(updatedClient1.lastServerAckdMutationID).to.equal(client1.mutationID); expect(updatedClient1.headHash).to.equal(client1.headHash);
expect(updatedClient2.mutationID).to.equal(client2.mutationID); expect(updatedClient2.lastServerAckdMutationID).to.equal( client2.lastServerAckdMutationID, ); expect(updatedClient2.headHash).to.equal(client2.headHash);
expect(updatedClient3.mutationID).to.equal(client3.mutationID); // lastServerAckdMutationID is updated to high mutationID as mutations // were recovered expect(updatedClient3.lastServerAckdMutationID).to.equal(client3.mutationID); expect(updatedClient3.headHash).to.equal(client3.headHash);
expect(updatedClient4.mutationID).to.equal(client4.mutationID); // lastServerAckdMutationID is updated to high mutationID as mutations // were recovered expect(updatedClient4.lastServerAckdMutationID).to.equal(client4.mutationID); expect(updatedClient4.headHash).to.equal(client4.headHash);});
test('if a push error occurs, continues to try to recover other clients', async () => { const schemaVersion = 'testSchema1'; // client1 has same schema version as recovering client and 2 mutations to recover const client1ID = 'client1'; // client2 has same schema version as recovering client and 1 mutation to recover const client2ID = 'client2'; // client3 has same schema version as recovering client and 1 mutation to recover const client3ID = 'client3'; const replicachePartialName = 'recoverMutationsRobustToPushError'; const auth = '1'; const pushURL = 'https://test.replicache.dev/push'; const pullURL = 'https://test.replicache.dev/pull'; const rep = await replicacheForTesting(replicachePartialName, { auth, schemaVersion, pushURL, pullURL, }); const profileID = await rep.profileID;
await tickAFewTimes();
const testPerdag = await createPerdag({ replicacheName: rep.name, schemaVersion, });
const client1PendingLocalMetas = await createAndPersistClientWithPendingLocal( client1ID, testPerdag, 2, ); const client2PendingLocalMetas = await createAndPersistClientWithPendingLocal( client2ID, testPerdag, 1, ); const client3PendingLocalMetas = await createAndPersistClientWithPendingLocal( client3ID, testPerdag, 1, );
const clients = await testPerdag.withRead(read => persist.getClients(read)); const client1 = clients.get(client1ID); assertNotUndefined(client1); const client2 = clients.get(client2ID); assertNotUndefined(client2); const client3 = clients.get(client3ID); assertNotUndefined(client3);
const pushRequestJsonBodies: JSONObject[] = []; const pullRequestJsonBodies: JSONObject[] = []; fetchMock.reset(); fetchMock.post( pushURL, async (_url: string, _options: RequestInit, request: Request) => { const requestJson = await request.json(); assertJSONObject(requestJson); pushRequestJsonBodies.push(requestJson); const {clientID} = requestJson; if (clientID === client2ID) { throw new Error('test error in push'); } else { return 'ok'; } }, ); fetchMock.post( pullURL, async (_url: string, _options: RequestInit, request: Request) => { const requestJson = await request.json(); assertJSONObject(requestJson); pullRequestJsonBodies.push(requestJson); const {clientID} = requestJson; switch (clientID) { case client1ID: return { cookie: 'pull_cookie_1', lastMutationID: client1.mutationID, patch: [], }; case client3ID: return { cookie: 'pull_cookie_3', lastMutationID: client3.mutationID, patch: [], }; default: throw new Error(`Unexpected pull ${requestJson}`); } }, );
await rep.recoverMutations();
expect(pushRequestJsonBodies.length).to.equal(3); expect(await pushRequestJsonBodies[0]).to.deep.equal( createPushBody( profileID, client1ID, client1PendingLocalMetas, schemaVersion, ), ); expect(await pushRequestJsonBodies[1]).to.deep.equal( createPushBody( profileID, client2ID, client2PendingLocalMetas, schemaVersion, ), ); expect(await pushRequestJsonBodies[2]).to.deep.equal( createPushBody( profileID, client3ID, client3PendingLocalMetas, schemaVersion, ), );
expect(pullRequestJsonBodies.length).to.equal(2); expect(pullRequestJsonBodies[0]).to.deep.equal({ profileID, clientID: client1ID, schemaVersion, cookie: 'cookie_1', lastMutationID: client1.lastServerAckdMutationID, pullVersion: 0, }); expect(pullRequestJsonBodies[1]).to.deep.equal({ profileID, clientID: client3ID, schemaVersion, cookie: 'cookie_1', lastMutationID: client3.lastServerAckdMutationID, pullVersion: 0, });
const updateClients = await testPerdag.withRead(read => persist.getClients(read), ); const updatedClient1 = updateClients.get(client1ID); assertNotUndefined(updatedClient1); const updatedClient2 = updateClients.get(client2ID); assertNotUndefined(updatedClient2); const updatedClient3 = updateClients.get(client3ID); assertNotUndefined(updatedClient3);
expect(updatedClient1.mutationID).to.equal(client1.mutationID); // lastServerAckdMutationID is updated to high mutationID as mutations // were recovered expect(updatedClient1.lastServerAckdMutationID).to.equal(client1.mutationID); expect(updatedClient1.headHash).to.equal(client1.headHash);
expect(updatedClient2.mutationID).to.equal(client2.mutationID); // lastServerAckdMutationID is not updated due to error expect(updatedClient2.lastServerAckdMutationID).to.equal( client2.lastServerAckdMutationID, ); expect(updatedClient2.headHash).to.equal(client2.headHash);
expect(updatedClient3.mutationID).to.equal(client3.mutationID); // lastServerAckdMutationID is updated to high mutationID as mutations // were recovered, despite error in client 2 expect(updatedClient3.lastServerAckdMutationID).to.equal(client3.mutationID); expect(updatedClient3.headHash).to.equal(client3.headHash);});
test('if an error occurs recovering one client, continues to try to recover other clients', async () => { const schemaVersion = 'testSchema1'; // client1 has same schema version as recovering client and 2 mutations to recover const client1ID = 'client1'; // client2 has same schema version as recovering client and 1 mutation to recover const client2ID = 'client2'; // client3 has same schema version as recovering client and 1 mutation to recover const client3ID = 'client3'; const replicachePartialName = 'recoverMutationsRobustToClientError'; const auth = '1'; const pushURL = 'https://test.replicache.dev/push'; const pullURL = 'https://test.replicache.dev/pull'; const rep = await replicacheForTesting(replicachePartialName, { auth, schemaVersion, pushURL, pullURL, }); const profileID = await rep.profileID;
await tickAFewTimes();
const testPerdag = await createPerdag({ replicacheName: rep.name, schemaVersion, });
const client1PendingLocalMetas = await createAndPersistClientWithPendingLocal( client1ID, testPerdag, 2, ); await createAndPersistClientWithPendingLocal(client2ID, testPerdag, 1); const client3PendingLocalMetas = await createAndPersistClientWithPendingLocal( client3ID, testPerdag, 1, );
const clients = await testPerdag.withRead(read => persist.getClients(read)); const client1 = clients.get(client1ID); assertNotUndefined(client1); const client2 = clients.get(client2ID); assertNotUndefined(client2); const client3 = clients.get(client3ID); assertNotUndefined(client3);
const pullRequestJsonBodies: JSONObject[] = []; fetchMock.reset(); fetchMock.post(pushURL, 'ok'); fetchMock.post( pullURL, async (_url: string, _options: RequestInit, request: Request) => { const requestJson = await request.json(); assertJSONObject(requestJson); pullRequestJsonBodies.push(requestJson); const {clientID} = requestJson; switch (clientID) { case client1ID: return { cookie: 'pull_cookie_1', lastMutationID: client1.mutationID, patch: [], }; case client3ID: return { cookie: 'pull_cookie_3', lastMutationID: client3.mutationID, patch: [], }; default: throw new Error(`Unexpected pull ${requestJson}`); } }, );
const lazyDagWithWriteStub = sinon.stub(dag.LazyStore.prototype, 'withWrite'); const testErrorMsg = 'Test dag.LazyStore.withWrite error'; lazyDagWithWriteStub.onSecondCall().throws(testErrorMsg); lazyDagWithWriteStub.callThrough();
const consoleErrorStub = sinon.stub(console, 'error');
await rep.recoverMutations();
expect(consoleErrorStub.callCount).to.equal(1); expect(consoleErrorStub.firstCall.args.join(' ')).to.contain(testErrorMsg);
const pushCalls = fetchMock.calls(pushURL); expect(pushCalls.length).to.equal(2); expect(await pushCalls[0].request.json()).to.deep.equal( createPushBody( profileID, client1ID, client1PendingLocalMetas, schemaVersion, ), ); expect(await pushCalls[1].request.json()).to.deep.equal( createPushBody( profileID, client3ID, client3PendingLocalMetas, schemaVersion, ), );
expect(pullRequestJsonBodies.length).to.equal(2); expect(pullRequestJsonBodies[0]).to.deep.equal({ profileID, clientID: client1ID, schemaVersion, cookie: 'cookie_1', lastMutationID: client1.lastServerAckdMutationID, pullVersion: 0, }); expect(pullRequestJsonBodies[1]).to.deep.equal({ profileID, clientID: client3ID, schemaVersion, cookie: 'cookie_1', lastMutationID: client3.lastServerAckdMutationID, pullVersion: 0, });
const updateClients = await testPerdag.withRead(read => persist.getClients(read), ); const updatedClient1 = updateClients.get(client1ID); assertNotUndefined(updatedClient1); const updatedClient2 = updateClients.get(client2ID); assertNotUndefined(updatedClient2); const updatedClient3 = updateClients.get(client3ID); assertNotUndefined(updatedClient3);
expect(updatedClient1.mutationID).to.equal(client1.mutationID); // lastServerAckdMutationID is updated to high mutationID as mutations // were recovered expect(updatedClient1.lastServerAckdMutationID).to.equal(client1.mutationID); expect(updatedClient1.headHash).to.equal(client1.headHash);
expect(updatedClient2.mutationID).to.equal(client2.mutationID); // lastServerAckdMutationID is not updated due to error expect(updatedClient2.lastServerAckdMutationID).to.equal( client2.lastServerAckdMutationID, ); expect(updatedClient2.headHash).to.equal(client2.headHash);
expect(updatedClient3.mutationID).to.equal(client3.mutationID); // lastServerAckdMutationID is updated to high mutationID as mutations // were recovered, despite error in client 2 expect(updatedClient3.lastServerAckdMutationID).to.equal(client3.mutationID); expect(updatedClient3.headHash).to.equal(client3.headHash);});
test('if an error occurs recovering one db, continues to try to recover clients from other dbs', async () => { const schemaVersionOfClient1 = 'testSchema1'; const schemaVersionOfClient2 = 'testSchema2'; const schemaVersionOfRecoveringClient = 'testSchemaOfRecovering'; const client1ID = 'client1'; const client2ID = 'client2'; const replicachePartialName = 'recoverMutationsRobustToDBError'; const auth = '1'; const pushURL = 'https://test.replicache.dev/push'; const pullURL = 'https://test.replicache.dev/pull'; const rep = await replicacheForTesting(replicachePartialName, { auth, schemaVersion: schemaVersionOfRecoveringClient, pushURL, pullURL, }); const profileID = await rep.profileID;
await tickAFewTimes();
const testPerdagForClient1 = await createPerdag({ replicacheName: rep.name, schemaVersion: schemaVersionOfClient1, }); await createAndPersistClientWithPendingLocal( client1ID, testPerdagForClient1, 1, );
const testPerdagForClient2 = await createPerdag({ replicacheName: rep.name, schemaVersion: schemaVersionOfClient2, }); const client2PendingLocalMetas = await createAndPersistClientWithPendingLocal( client2ID, testPerdagForClient2, 1, );
const client1 = await testPerdagForClient1.withRead(read => persist.getClient(client1ID, read), ); assertNotUndefined(client1);
const client2 = await testPerdagForClient2.withRead(read => persist.getClient(client2ID, read), ); assertNotUndefined(client2);
const pullRequestJsonBodies: JSONObject[] = []; fetchMock.reset(); fetchMock.post(pushURL, 'ok'); fetchMock.post( pullURL, async (_url: string, _options: RequestInit, request: Request) => { const requestJson = await request.json(); assertJSONObject(requestJson); pullRequestJsonBodies.push(requestJson); const {clientID} = requestJson; switch (clientID) { case client2ID: return { cookie: 'pull_cookie_2', lastMutationID: client2.mutationID, patch: [], }; default: throw new Error(`Unexpected pull ${requestJson}`); } }, );
const dagStoreWithReadStub = sinon.stub(dag.StoreImpl.prototype, 'withRead'); const testErrorMsg = 'Test dag.StoreImpl.withRead error'; dagStoreWithReadStub.onSecondCall().throws(testErrorMsg); dagStoreWithReadStub.callThrough();
const consoleErrorStub = sinon.stub(console, 'error');
await rep.recoverMutations();
expect(consoleErrorStub.callCount).to.equal(1); expect(consoleErrorStub.firstCall.args.join(' ')).to.contain(testErrorMsg);
const pushCalls = fetchMock.calls(pushURL); expect(pushCalls.length).to.equal(1); expect(await pushCalls[0].request.json()).to.deep.equal( createPushBody( profileID, client2ID, client2PendingLocalMetas, schemaVersionOfClient2, ), );
expect(pullRequestJsonBodies.length).to.equal(1); expect(pullRequestJsonBodies[0]).to.deep.equal({ profileID, clientID: client2ID, schemaVersion: schemaVersionOfClient2, cookie: 'cookie_1', lastMutationID: client2.lastServerAckdMutationID, pullVersion: 0, });
const updatedClient1 = await testPerdagForClient1.withRead(read => persist.getClient(client1ID, read), ); assertNotUndefined(updatedClient1);
const updatedClient2 = await testPerdagForClient2.withRead(read => persist.getClient(client2ID, read), ); assertNotUndefined(updatedClient2);
expect(updatedClient1.mutationID).to.equal(client1.mutationID); // lastServerAckdMutationID not updated due to error when recovering this // client's db expect(updatedClient1.lastServerAckdMutationID).to.equal( client1.lastServerAckdMutationID, ); expect(updatedClient1.headHash).to.equal(client1.headHash);
expect(updatedClient2.mutationID).to.equal(client2.mutationID); // lastServerAckdMutationID is updated to high mutationID as mutations // were recovered despite error in other db expect(updatedClient2.lastServerAckdMutationID).to.equal(client2.mutationID); expect(updatedClient2.headHash).to.equal(client2.headHash);});
test('mutation recovery exits early if Replicache is closed', async () => { const schemaVersion = 'testSchema1'; const client1ID = 'client1'; const client2ID = 'client2'; const replicachePartialName = 'recoverMutationsRobustToClientError'; const auth = '1'; const pushURL = 'https://test.replicache.dev/push'; const pullURL = 'https://test.replicache.dev/pull'; const rep = await replicacheForTesting(replicachePartialName, { auth, schemaVersion, pushURL, pullURL, }); const profileID = await rep.profileID;
await tickAFewTimes();
const testPerdag = await createPerdag({ replicacheName: rep.name, schemaVersion, });
const client1PendingLocalMetas = await createAndPersistClientWithPendingLocal( client1ID, testPerdag, 1, ); await createAndPersistClientWithPendingLocal(client2ID, testPerdag, 1);
const clients = await testPerdag.withRead(read => persist.getClients(read)); const client1 = clients.get(client1ID); assertNotUndefined(client1); const client2 = clients.get(client2ID); assertNotUndefined(client2);
const pullRequestJsonBodies: JSONObject[] = []; fetchMock.reset(); fetchMock.post(pushURL, 'ok'); fetchMock.post( pullURL, async (_url: string, _options: RequestInit, request: Request) => { const requestJson = await request.json(); assertJSONObject(requestJson); pullRequestJsonBodies.push(requestJson); const {clientID} = requestJson; switch (clientID) { case client1ID: return { cookie: 'pull_cookie_1', lastMutationID: client1.mutationID, patch: [], }; default: throw new Error(`Unexpected pull ${requestJson}`); } }, );
// At the end of recovering client1 close the recovering Replicache instance const lazyDagWithWriteStub = sinon.stub(dag.LazyStore.prototype, 'close'); lazyDagWithWriteStub.onFirstCall().callsFake(async () => { await rep.close(); }); lazyDagWithWriteStub.callThrough();
await rep.recoverMutations();
const pushCalls = fetchMock.calls(pushURL); expect(pushCalls.length).to.equal(1); expect(await pushCalls[0].request.json()).to.deep.equal( createPushBody( profileID, client1ID, client1PendingLocalMetas, schemaVersion, ), );
expect(pullRequestJsonBodies.length).to.equal(1); expect(pullRequestJsonBodies[0]).to.deep.equal({ profileID, clientID: client1ID, schemaVersion, cookie: 'cookie_1', lastMutationID: client1.lastServerAckdMutationID, pullVersion: 0, });
const updateClients = await testPerdag.withRead(read => persist.getClients(read), ); const updatedClient1 = updateClients.get(client1ID); assertNotUndefined(updatedClient1); const updatedClient2 = updateClients.get(client2ID); assertNotUndefined(updatedClient2);
expect(updatedClient1.mutationID).to.equal(client1.mutationID); // lastServerAckdMutationID is updated to high mutationID as mutations // were recovered expect(updatedClient1.lastServerAckdMutationID).to.equal(client1.mutationID); expect(updatedClient1.headHash).to.equal(client1.headHash);
expect(updatedClient2.mutationID).to.equal(client2.mutationID); // lastServerAckdMutationID is not updated due to close expect(updatedClient2.lastServerAckdMutationID).to.equal( client2.lastServerAckdMutationID, ); expect(updatedClient2.headHash).to.equal(client2.headHash);});
test('mutation recovery is invoked at startup', async () => { const rep = await replicacheForTesting('mutation-recovery-startup'); expect(rep.recoverMutationsSpy.callCount).to.equal(1); expect(rep.recoverMutationsSpy.callCount).to.equal(1); expect(await rep.recoverMutationsSpy.firstCall.returnValue).to.equal(true);});
test('mutation recovery returns early without running if push is disabled', async () => { const rep = await replicacheForTestingNoDefaultURLs( 'mutation-recovery-startup', { pullURL: 'https://diff.com/pull', }, ); expect(rep.recoverMutationsSpy.callCount).to.equal(1); expect(await rep.recoverMutationsSpy.firstCall.returnValue).to.equal(false); expect(await rep.recoverMutations()).to.equal(false);});
test('mutation recovery returns early when internal option enableMutationRecovery is false', async () => { const rep = await replicacheForTestingNoDefaultURLs( 'mutation-recovery-startup', { pullURL: 'https://diff.com/pull', enableMutationRecovery: false, }, ); expect(rep.recoverMutationsSpy.callCount).to.equal(1); expect(await rep.recoverMutationsSpy.firstCall.returnValue).to.equal(false); expect(await rep.recoverMutations()).to.equal(false);});
test('mutation recovery is invoked on change from offline to online', async () => { const pullURL = 'https://test.replicache.dev/pull'; const rep = await replicacheForTesting('mutation-recovery-online', { pullURL, }); expect(rep.recoverMutationsSpy.callCount).to.equal(1); expect(rep.online).to.equal(true);
fetchMock.post(pullURL, async () => { return {throws: new Error('Simulate fetch error in push')}; });
rep.pull();
await tickAFewTimes(); expect(rep.online).to.equal(false); expect(rep.recoverMutationsSpy.callCount).to.equal(1);
fetchMock.reset(); fetchMock.post(pullURL, { cookie: 'test_cookie', lastMutationID: 2, patch: [], });
rep.pull(); expect(rep.recoverMutationsSpy.callCount).to.equal(1); while (!rep.online) { await tickAFewTimes(); } expect(rep.recoverMutationsSpy.callCount).to.equal(2);});
test('mutation recovery is invoked on 5 minute interval', async () => { const rep = await replicacheForTesting('mutation-recovery-startup'); expect(rep.recoverMutationsSpy.callCount).to.equal(1); await clock.tickAsync(5 * 60 * 1000); expect(rep.recoverMutationsSpy.callCount).to.equal(2); await clock.tickAsync(5 * 60 * 1000); expect(rep.recoverMutationsSpy.callCount).to.equal(3);});