Skip to content

Commit 7e7a191

Browse files
committed
Fix handling of empty-update mutations
1 parent 3c6dfdf commit 7e7a191

5 files changed

Lines changed: 213 additions & 163 deletions

File tree

.changeset/vast-bats-kick.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@evolu/common": patch
3+
---
4+
5+
Fix handling of empty-update mutations and readDbChange
6+
7+
This patch fixes a bug where a mutation that contains only an `id` (no values) could result in an empty set of `evolu_history` rows for the corresponding timestamp. That caused `readDbChange` to fail when trying to build a CRDT change for syncing. The fix ensures `evolu_history` includes system columns so the storage and sync code always have at least one column to work with.
8+
9+
Manually tested and snapshots updated.
10+
11+
Manual verification steps: call `update("todo", { id })` and then invoke
12+
`readDbChange` via the sync with an empty relay.Ø

packages/common/src/local-first/Sync.ts

Lines changed: 48 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import {
2+
appendToArray,
23
firstInArray,
34
isNonEmptyReadonlyArray,
45
NonEmptyArray,
56
NonEmptyReadonlyArray,
67
} from "../Array.js";
7-
import { assert } from "../Assert.js";
8+
import { assert, assertNonEmptyReadonlyArray } from "../Assert.js";
89
import { Brand } from "../Brand.js";
910
import { ConsoleDep } from "../Console.js";
1011
import {
@@ -26,12 +27,18 @@ import {
2627
sqliteBooleanToBoolean,
2728
SqliteDep,
2829
SqliteError,
29-
sqliteFalse,
3030
SqliteValue,
3131
} from "../Sqlite.js";
3232
import { AbortError, createMutex } from "../Task.js";
3333
import { TimeDep } from "../Time.js";
34-
import { IdBytes, idBytesToId, idToIdBytes, PositiveInt } from "../Type.js";
34+
import {
35+
Boolean,
36+
DateIso,
37+
IdBytes,
38+
idBytesToId,
39+
idToIdBytes,
40+
PositiveInt,
41+
} from "../Type.js";
3542
import { CreateWebSocketDep, WebSocket } from "../WebSocket.js";
3643
import type { AppOwnerDep, PostMessageDep } from "./Db.js";
3744
import {
@@ -443,8 +450,7 @@ const createClientStorage =
443450
SqliteDep &
444451
SymmetricCryptoDep &
445452
TimeDep &
446-
TimestampConfigDep &
447-
PostMessageDep,
453+
TimestampConfigDep,
448454
) =>
449455
(config: {
450456
onError: (
@@ -578,11 +584,11 @@ const createClientStorage =
578584
}
579585

580586
const { rows } = result.value;
581-
assert(rows.length > 0, "Rows must not be empty");
587+
assertNonEmptyReadonlyArray(rows, "Every timestamp must have rows");
582588

583-
const { table, id } = rows[0];
589+
const { table, id } = firstInArray(rows);
584590
const values = createRecord<string, SqliteValue>();
585-
let isInsert = false;
591+
let isInsert;
586592
let isDelete: boolean | null = null;
587593

588594
for (const r of rows) {
@@ -592,10 +598,13 @@ const createClientStorage =
592598
case "createdAt":
593599
isInsert = true;
594600
break;
601+
case "updatedAt":
602+
isInsert = false;
603+
break;
595604
case "isDeleted":
596605
assert(
597606
SqliteBoolean.is(r.value),
598-
"isDeleted column must contain a valid SqliteBoolean (0 or 1)",
607+
"isDeleted column must contain a valid SqliteBoolean",
599608
);
600609
isDelete = sqliteBooleanToBoolean(r.value);
601610
break;
@@ -604,6 +613,8 @@ const createClientStorage =
604613
}
605614
}
606615

616+
assert(Boolean.is(isInsert), "isInsert must be in evolu_history");
617+
607618
const message: CrdtMessage = {
608619
timestamp: timestampBytesToTimestamp(timestamp),
609620
change: DbChange.orThrow({
@@ -629,6 +640,24 @@ const createTransportKey = (transport: OwnerTransport): TransportKey => {
629640
return `${transport.type}:${transport.url}` as TransportKey;
630641
};
631642

643+
const dbChangeToColumns = (change: DbChange, now: DateIso) => {
644+
let values = objectToEntries(change.values);
645+
646+
// SystemColumns are not encoded in change.values.
647+
values = appendToArray(values, [
648+
change.isInsert ? "createdAt" : "updatedAt",
649+
now,
650+
]);
651+
if (change.isDelete != null) {
652+
values = appendToArray(values, [
653+
"isDeleted",
654+
booleanToSqliteBoolean(change.isDelete),
655+
]);
656+
}
657+
658+
return values;
659+
};
660+
632661
export const applyLocalOnlyChange =
633662
(deps: SqliteDep & TimeDep & AppOwnerDep) =>
634663
(change: MutationChange): Result<void, SqliteError> => {
@@ -639,23 +668,16 @@ export const applyLocalOnlyChange =
639668
`);
640669
if (!result.ok) return result;
641670
} else {
642-
const now = deps.time.nowIso();
643671
const ownerId = deps.appOwner.id;
672+
const columns = dbChangeToColumns(change, deps.time.nowIso());
644673

645-
let entries = objectToEntries(change.values);
646-
if (change.isDelete !== null) {
647-
entries = [...entries, ["isDeleted", sqliteFalse]];
648-
}
649-
650-
for (const [column, value] of entries) {
674+
for (const [column, value] of columns) {
651675
const result = deps.sqlite.exec(sql.prepared`
652676
insert into ${sql.identifier(change.table)}
653-
("ownerId", "id", ${sql.identifier(column)}, createdAt, updatedAt)
654-
values (${ownerId}, ${change.id}, ${value}, ${now}, ${now})
677+
("ownerId", "id", ${sql.identifier(column)})
678+
values (${ownerId}, ${change.id}, ${value})
655679
on conflict ("ownerId", "id") do update
656-
set
657-
${sql.identifier(column)} = ${value},
658-
updatedAt = ${now};
680+
set ${sql.identifier(column)} = ${value};
659681
`);
660682
if (!result.ok) return result;
661683
}
@@ -681,22 +703,11 @@ const applyMessages =
681703
let { firstTimestamp, lastTimestamp } = usage.value;
682704

683705
for (const { timestamp, change } of messages) {
684-
const dateIso = timestampToDateIso(timestamp);
685706
const timestampBytes = timestampToTimestampBytes(timestamp);
686707
const idBytes = idToIdBytes(change.id);
708+
const columns = dbChangeToColumns(change, timestampToDateIso(timestamp));
687709

688-
const values = [...objectToEntries(change.values)];
689-
690-
// SystemColumns are not encoded in change.values.
691-
if (change.isInsert) {
692-
values.push(["createdAt", dateIso]);
693-
}
694-
if (change.isDelete !== null) {
695-
values.push(["isDeleted", booleanToSqliteBoolean(change.isDelete)]);
696-
}
697-
// No `ownerId` and `updatedAt` because they are evolu_history columns.
698-
699-
for (const [column, value] of values) {
710+
for (const [column, value] of columns) {
700711
const updateAppTable = deps.sqlite.exec(sql.prepared`
701712
with
702713
existingTimestamp as (
@@ -711,13 +722,11 @@ const applyMessages =
711722
limit 1
712723
)
713724
insert into ${sql.identifier(change.table)}
714-
("ownerId", "id", ${sql.identifier(column)}, "updatedAt")
715-
select ${ownerId}, ${change.id}, ${value}, ${dateIso}
725+
("ownerId", "id", ${sql.identifier(column)})
726+
select ${ownerId}, ${change.id}, ${value}
716727
where not exists (select 1 from existingTimestamp)
717728
on conflict ("ownerId", "id") do update
718-
set
719-
${sql.identifier(column)} = ${value},
720-
"updatedAt" = ${dateIso}
729+
set ${sql.identifier(column)} = ${value}
721730
where not exists (select 1 from existingTimestamp);
722731
`);
723732

0 commit comments

Comments
 (0)