mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
feat(offline): stamp queued writes with their format and skip newer ones on replay
A queued write outlives the bundle that wrote it. After a deploy a tab can still run the previous bundle, and an install can be rolled back, so a row written by a newer build may be replayed by an older one that does not know its shape. Each row enqueue() writes now carries schemaVersion (MUTATION_SCHEMA_VERSION) and the UI build that queued it. Both fields are optional, so rows queued before this read as format 1 and replay as before. A flush leaves a row from a newer format in the queue without sending, marking or dropping it, and holds back the later writes to the same entity as it does behind a parked write, so they keep their order until a build that knows the format replays them.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* The format stamp on queued writes.
|
||||
*
|
||||
* A queued write can be replayed by a different build than the one that queued
|
||||
* it. Rows are stamped with MUTATION_SCHEMA_VERSION; a build replays its own
|
||||
* format and older ones (an unstamped row is format 1), and leaves a row from a
|
||||
* newer format in the queue untouched, holding back the later writes to the
|
||||
* same entity.
|
||||
*/
|
||||
import 'fake-indexeddb/auto';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { clearAll, MUTATION_SCHEMA_VERSION, offlineDb, type QueuedMutation } from '../../../src/db/offlineDb';
|
||||
import { setAuthed } from '../../../src/sync/authGate';
|
||||
import { generateUUID, mutationQueue } from '../../../src/sync/mutationQueue';
|
||||
import { buildPlace } from '../../helpers/factories';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
mutationQueue._resetFlushing();
|
||||
setAuthed(true);
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
setAuthed(false);
|
||||
});
|
||||
|
||||
/** A queued PUT of place `placeId`, written straight into the queue as a build would. */
|
||||
function queuedPut(placeId: number, createdAt: number, extra: Partial<QueuedMutation> = {}): QueuedMutation {
|
||||
return {
|
||||
id: generateUUID(),
|
||||
tripId: 1,
|
||||
method: 'PUT',
|
||||
url: `/trips/1/places/${placeId}`,
|
||||
body: { name: `Place ${placeId}` },
|
||||
createdAt,
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
lastError: null,
|
||||
resource: 'places',
|
||||
entityId: placeId,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** Answer every place PUT with the place, and record which ids were sent. */
|
||||
function answerPlacePuts(): number[] {
|
||||
const sent: number[] = [];
|
||||
server.use(
|
||||
http.put('/api/trips/1/places/:id', ({ params }) => {
|
||||
const id = Number(params.id);
|
||||
sent.push(id);
|
||||
return HttpResponse.json({ place: buildPlace({ id, trip_id: 1 }) });
|
||||
})
|
||||
);
|
||||
return sent;
|
||||
}
|
||||
|
||||
describe('mutationQueue format stamp', () => {
|
||||
it('stamps a queued write with the format and the build that queued it', async () => {
|
||||
const id = await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: 1,
|
||||
method: 'POST',
|
||||
url: '/trips/1/places',
|
||||
body: { name: 'Belem' },
|
||||
resource: 'places',
|
||||
});
|
||||
|
||||
const stored = await offlineDb.mutationQueue.get(id);
|
||||
expect(stored?.schemaVersion).toBe(MUTATION_SCHEMA_VERSION);
|
||||
expect(stored?.buildVersion).toBe(__TREK_UI_VERSION__);
|
||||
});
|
||||
|
||||
it('replays a write queued before the stamp existed', async () => {
|
||||
const legacy = queuedPut(21, 1);
|
||||
expect(legacy.schemaVersion).toBeUndefined();
|
||||
await offlineDb.mutationQueue.put(legacy);
|
||||
const sent = answerPlacePuts();
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
expect(sent).toEqual([21]);
|
||||
expect(await offlineDb.mutationQueue.get(legacy.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('replays a write stamped with its own format', async () => {
|
||||
const own = queuedPut(21, 1, { schemaVersion: MUTATION_SCHEMA_VERSION });
|
||||
await offlineDb.mutationQueue.put(own);
|
||||
const sent = answerPlacePuts();
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
expect(sent).toEqual([21]);
|
||||
expect(await offlineDb.mutationQueue.get(own.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves a write from a newer format in the queue, unsent and unchanged', async () => {
|
||||
const newer = queuedPut(21, 1, { schemaVersion: MUTATION_SCHEMA_VERSION + 1, buildVersion: '99.0.0' });
|
||||
await offlineDb.mutationQueue.put(newer);
|
||||
const sent = answerPlacePuts();
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
expect(sent).toEqual([]);
|
||||
expect(await offlineDb.mutationQueue.get(newer.id)).toEqual(newer);
|
||||
expect(await mutationQueue.pendingCount()).toBe(1);
|
||||
expect(await mutationQueue.failedCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('holds back later writes to the same entity, and only those', async () => {
|
||||
const newer = queuedPut(21, 1, { schemaVersion: MUTATION_SCHEMA_VERSION + 1 });
|
||||
const sameEntity = queuedPut(21, 2, { schemaVersion: MUTATION_SCHEMA_VERSION });
|
||||
const otherEntity = queuedPut(22, 3, { schemaVersion: MUTATION_SCHEMA_VERSION });
|
||||
await offlineDb.mutationQueue.bulkPut([newer, sameEntity, otherEntity]);
|
||||
const sent = answerPlacePuts();
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
expect(sent).toEqual([22]);
|
||||
expect(await offlineDb.mutationQueue.get(otherEntity.id)).toBeUndefined();
|
||||
expect((await offlineDb.mutationQueue.get(newer.id))?.status).toBe('pending');
|
||||
expect((await offlineDb.mutationQueue.get(sameEntity.id))?.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('keeps a write from a newer format pending when Try again puts parked writes back', async () => {
|
||||
const newer = queuedPut(21, 1, { schemaVersion: MUTATION_SCHEMA_VERSION + 1, status: 'failed', attempts: 3 });
|
||||
await offlineDb.mutationQueue.put(newer);
|
||||
const sent = answerPlacePuts();
|
||||
|
||||
await mutationQueue.retryFailed();
|
||||
|
||||
expect(sent).toEqual([]);
|
||||
const stored = await offlineDb.mutationQueue.get(newer.id);
|
||||
expect(stored).toMatchObject({ status: 'pending', schemaVersion: MUTATION_SCHEMA_VERSION + 1 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user