Skip to content

Commit e6cc564

Browse files
committed
Add timestamp ordering tests
1 parent ba486bf commit e6cc564

2 files changed

Lines changed: 530 additions & 238 deletions

File tree

packages/common/test/Evolu/Db.test.ts

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,311 @@ test("evolu_history unique index prevents duplicates", async () => {
233233
);
234234
expect(count.rows[0].count).toBe(1);
235235
});
236+
237+
test("timestamp ordering - newer mutations overwrite older ones", async () => {
238+
const [, sqlite, db] = await setupInitializedDbWorker();
239+
240+
const recordId = testCreateId();
241+
242+
// Create first mutation
243+
db.postMessage({
244+
type: "mutate",
245+
tabId: testCreateId(),
246+
changes: [
247+
{
248+
id: recordId,
249+
table: "testTable" as Base64Url256,
250+
values: { ["name" as Base64Url256]: "first_value" },
251+
},
252+
],
253+
onCompleteIds: [],
254+
subscribedQueries: [],
255+
});
256+
257+
await wait(10);
258+
259+
// Create second mutation on same record (will have newer timestamp)
260+
db.postMessage({
261+
type: "mutate",
262+
tabId: testCreateId(),
263+
changes: [
264+
{
265+
id: recordId,
266+
table: "testTable" as Base64Url256,
267+
values: { ["name" as Base64Url256]: "second_value" },
268+
},
269+
],
270+
onCompleteIds: [],
271+
subscribedQueries: [],
272+
});
273+
274+
await wait(10);
275+
276+
// Verify the app table has the latest value
277+
const finalResult = getOrThrow(
278+
sqlite.exec<{ name: string }>(sql`
279+
select name from testTable where id = ${recordId};
280+
`),
281+
);
282+
expect(finalResult.rows[0].name).toBe("second_value");
283+
284+
// Verify both mutations are stored in history
285+
const historyCount = getOrThrow(
286+
sqlite.exec<{ count: number }>(sql`
287+
select count(*) as count
288+
from evolu_history
289+
where
290+
"table" = 'testTable'
291+
and "id" = ${idToBinaryId(recordId)}
292+
and "column" = 'name';
293+
`),
294+
);
295+
expect(historyCount.rows[0].count).toBe(2);
296+
});
297+
298+
test("timestamp ordering - multiple columns update independently", async () => {
299+
const [, sqlite, db] = await setupInitializedDbWorker();
300+
301+
const recordId = testCreateId();
302+
303+
// Create first mutation that sets the name
304+
db.postMessage({
305+
type: "mutate",
306+
tabId: testCreateId(),
307+
changes: [
308+
{
309+
id: recordId,
310+
table: "testTable" as Base64Url256,
311+
values: { ["name" as Base64Url256]: "original_name" },
312+
},
313+
],
314+
onCompleteIds: [],
315+
subscribedQueries: [],
316+
});
317+
318+
await wait(10);
319+
320+
// Update the same record with a different value for name
321+
db.postMessage({
322+
type: "mutate",
323+
tabId: testCreateId(),
324+
changes: [
325+
{
326+
id: recordId,
327+
table: "testTable" as Base64Url256,
328+
values: { ["name" as Base64Url256]: "updated_name" },
329+
},
330+
],
331+
onCompleteIds: [],
332+
subscribedQueries: [],
333+
});
334+
335+
await wait(10);
336+
337+
// Verify the app table has the latest name value
338+
const finalResult = getOrThrow(
339+
sqlite.exec<{ name: string }>(sql`
340+
select name from testTable where id = ${recordId};
341+
`),
342+
);
343+
expect(finalResult.rows[0].name).toBe("updated_name");
344+
345+
// Verify we have two entries in history for the name column
346+
const nameHistoryCount = getOrThrow(
347+
sqlite.exec<{ count: number }>(sql`
348+
select count(*) as count
349+
from evolu_history
350+
where
351+
"table" = 'testTable'
352+
and "id" = ${idToBinaryId(recordId)}
353+
and "column" = 'name';
354+
`),
355+
);
356+
expect(nameHistoryCount.rows[0].count).toBe(2);
357+
358+
// Verify the values are stored in chronological order in history
359+
const historyValues = getOrThrow(
360+
sqlite.exec<{ value: string }>(sql`
361+
select value
362+
from evolu_history
363+
where
364+
"table" = 'testTable'
365+
and "id" = ${idToBinaryId(recordId)}
366+
and "column" = 'name'
367+
order by timestamp;
368+
`),
369+
);
370+
expect(historyValues.rows[0].value).toBe("original_name");
371+
expect(historyValues.rows[1].value).toBe("updated_name");
372+
});
373+
374+
test("timestamp ordering - concurrent mutations on different records", async () => {
375+
const [, sqlite, db] = await setupInitializedDbWorker();
376+
377+
const recordId1 = testCreateId();
378+
const recordId2 = testCreateId();
379+
380+
// Create mutations on different records in quick succession
381+
db.postMessage({
382+
type: "mutate",
383+
tabId: testCreateId(),
384+
changes: [
385+
{
386+
id: recordId1,
387+
table: "testTable" as Base64Url256,
388+
values: { ["name" as Base64Url256]: "record1_value" },
389+
},
390+
{
391+
id: recordId2,
392+
table: "testTable" as Base64Url256,
393+
values: { ["name" as Base64Url256]: "record2_value" },
394+
},
395+
],
396+
onCompleteIds: [],
397+
subscribedQueries: [],
398+
});
399+
400+
await wait(10);
401+
402+
// Verify both records exist with correct values
403+
const allRecords = getOrThrow(
404+
sqlite.exec<{ id: string; name: string }>(sql`
405+
select id, name
406+
from testTable
407+
where id in (${recordId1}, ${recordId2})
408+
order by id;
409+
`),
410+
);
411+
expect(allRecords.rows).toHaveLength(2);
412+
413+
const record1 = allRecords.rows.find((r) => r.id === recordId1);
414+
const record2 = allRecords.rows.find((r) => r.id === recordId2);
415+
416+
expect(record1?.name).toBe("record1_value");
417+
expect(record2?.name).toBe("record2_value");
418+
419+
// Verify both records have entries in history
420+
const totalHistoryCount = getOrThrow(
421+
sqlite.exec<{ count: number }>(sql`
422+
select count(*) as count
423+
from evolu_history
424+
where "table" = 'testTable' and "column" = 'name';
425+
`),
426+
);
427+
expect(totalHistoryCount.rows[0].count).toBe(2);
428+
});
429+
430+
test("timestamp ordering - verify CRDT last-write-wins behavior", async () => {
431+
const [, sqlite, db] = await setupInitializedDbWorker();
432+
433+
const recordId = testCreateId();
434+
435+
// Create initial value
436+
db.postMessage({
437+
type: "mutate",
438+
tabId: testCreateId(),
439+
changes: [
440+
{
441+
id: recordId,
442+
table: "testTable" as Base64Url256,
443+
values: { ["name" as Base64Url256]: "initial" },
444+
},
445+
],
446+
onCompleteIds: [],
447+
subscribedQueries: [],
448+
});
449+
450+
await wait(10);
451+
452+
// Update multiple times rapidly to ensure different timestamps
453+
db.postMessage({
454+
type: "mutate",
455+
tabId: testCreateId(),
456+
changes: [
457+
{
458+
id: recordId,
459+
table: "testTable" as Base64Url256,
460+
values: { ["name" as Base64Url256]: "second" },
461+
},
462+
],
463+
onCompleteIds: [],
464+
subscribedQueries: [],
465+
});
466+
467+
await wait(5);
468+
469+
db.postMessage({
470+
type: "mutate",
471+
tabId: testCreateId(),
472+
changes: [
473+
{
474+
id: recordId,
475+
table: "testTable" as Base64Url256,
476+
values: { ["name" as Base64Url256]: "third" },
477+
},
478+
],
479+
onCompleteIds: [],
480+
subscribedQueries: [],
481+
});
482+
483+
await wait(5);
484+
485+
db.postMessage({
486+
type: "mutate",
487+
tabId: testCreateId(),
488+
changes: [
489+
{
490+
id: recordId,
491+
table: "testTable" as Base64Url256,
492+
values: { ["name" as Base64Url256]: "final" },
493+
},
494+
],
495+
onCompleteIds: [],
496+
subscribedQueries: [],
497+
});
498+
499+
await wait(10);
500+
501+
// Verify app table has the final value (last write wins)
502+
const appTableResult = getOrThrow(
503+
sqlite.exec<{ name: string }>(sql`
504+
select name from testTable where id = ${recordId};
505+
`),
506+
);
507+
expect(appTableResult.rows[0].name).toBe("final");
508+
509+
// Verify all mutations are preserved in history in timestamp order
510+
const historyResults = getOrThrow(
511+
sqlite.exec<{ value: string }>(sql`
512+
select value
513+
from evolu_history
514+
where
515+
"table" = 'testTable'
516+
and "id" = ${idToBinaryId(recordId)}
517+
and "column" = 'name'
518+
order by timestamp;
519+
`),
520+
);
521+
522+
expect(historyResults.rows).toHaveLength(4);
523+
expect(historyResults.rows[0].value).toBe("initial");
524+
expect(historyResults.rows[1].value).toBe("second");
525+
expect(historyResults.rows[2].value).toBe("third");
526+
expect(historyResults.rows[3].value).toBe("final");
527+
528+
// Verify that the app table always reflects the value with the highest timestamp
529+
const timestampResults = getOrThrow(
530+
sqlite.exec<{ value: string; timestamp: Uint8Array }>(sql`
531+
select value, timestamp
532+
from evolu_history
533+
where
534+
"table" = 'testTable'
535+
and "id" = ${idToBinaryId(recordId)}
536+
and "column" = 'name'
537+
order by timestamp desc
538+
limit 1;
539+
`),
540+
);
541+
542+
expect(timestampResults.rows[0].value).toBe("final");
543+
});

0 commit comments

Comments
 (0)