@evolu/common 7.2.2 → 7.3.0
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/dist/src/Function.d.ts +61 -0
- package/dist/src/Function.d.ts.map +1 -1
- package/dist/src/Function.js +19 -0
- package/dist/src/Object.d.ts +32 -1
- package/dist/src/Object.d.ts.map +1 -1
- package/dist/src/Object.js +32 -1
- package/dist/src/Order.d.ts +2 -1
- package/dist/src/Order.d.ts.map +1 -1
- package/dist/src/Order.js +1 -0
- package/dist/src/Result.d.ts +36 -29
- package/dist/src/Result.d.ts.map +1 -1
- package/dist/src/Sqlite.d.ts +9 -7
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +8 -0
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +6 -4
- package/dist/src/Type.d.ts +30 -2
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +82 -0
- package/dist/src/local-first/Db.d.ts +1 -5
- package/dist/src/local-first/Db.d.ts.map +1 -1
- package/dist/src/local-first/Db.js +112 -117
- package/dist/src/local-first/Evolu.d.ts +3 -0
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +14 -12
- package/dist/src/local-first/LocalAuth.d.ts +2 -2
- package/dist/src/local-first/Owner.d.ts +3 -0
- package/dist/src/local-first/Owner.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.d.ts +1 -1
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.js +12 -12
- package/dist/src/local-first/PublicKysely.js +1 -1
- package/dist/src/local-first/Query.d.ts +54 -6
- package/dist/src/local-first/Query.d.ts.map +1 -1
- package/dist/src/local-first/Query.js +130 -11
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Relay.js +17 -18
- package/dist/src/local-first/Schema.d.ts +15 -14
- package/dist/src/local-first/Schema.d.ts.map +1 -1
- package/dist/src/local-first/Schema.js +43 -51
- package/dist/src/local-first/Storage.d.ts +2 -2
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/dist/src/local-first/Storage.js +2 -6
- package/dist/src/local-first/Sync.d.ts +10 -7
- package/dist/src/local-first/Sync.d.ts.map +1 -1
- package/dist/src/local-first/Sync.js +154 -103
- package/dist/src/local-first/Timestamp.d.ts +13 -0
- package/dist/src/local-first/Timestamp.d.ts.map +1 -1
- package/dist/src/local-first/Timestamp.js +14 -1
- package/dist/src/local-first/index.d.ts +0 -1
- package/dist/src/local-first/index.d.ts.map +1 -1
- package/dist/src/local-first/index.js +0 -1
- package/package.json +1 -1
- package/src/Function.ts +74 -0
- package/src/Object.ts +40 -1
- package/src/Order.ts +2 -1
- package/src/Result.ts +38 -29
- package/src/Sqlite.ts +18 -7
- package/src/Task.ts +6 -4
- package/src/Type.ts +134 -1
- package/src/local-first/Db.ts +182 -217
- package/src/local-first/Evolu.ts +21 -10
- package/src/local-first/LocalAuth.ts +2 -2
- package/src/local-first/Owner.ts +4 -0
- package/src/local-first/Protocol.ts +13 -19
- package/src/local-first/PublicKysely.ts +1 -1
- package/src/local-first/Query.ts +216 -21
- package/src/local-first/Relay.ts +20 -24
- package/src/local-first/Schema.ts +83 -70
- package/src/local-first/Storage.ts +3 -8
- package/src/local-first/Sync.ts +215 -135
- package/src/local-first/Timestamp.ts +14 -1
- package/src/local-first/index.ts +0 -1
- package/dist/src/local-first/Diff.d.ts +0 -43
- package/dist/src/local-first/Diff.d.ts.map +0 -1
- package/dist/src/local-first/Diff.js +0 -97
- package/src/local-first/Diff.ts +0 -144
|
@@ -26,13 +26,13 @@ import {
|
|
|
26
26
|
TypeError,
|
|
27
27
|
} from "../Type.js";
|
|
28
28
|
import {
|
|
29
|
-
OwnerError,
|
|
30
29
|
Owner,
|
|
30
|
+
OwnerError,
|
|
31
31
|
OwnerId,
|
|
32
32
|
OwnerIdBytes,
|
|
33
33
|
OwnerWriteKey,
|
|
34
34
|
} from "./Owner.js";
|
|
35
|
-
import {
|
|
35
|
+
import { systemColumnsWithId } from "./Schema.js";
|
|
36
36
|
import { orderTimestampBytes, Timestamp, TimestampBytes } from "./Timestamp.js";
|
|
37
37
|
|
|
38
38
|
export interface StorageConfig {
|
|
@@ -256,16 +256,11 @@ export interface CrdtMessage {
|
|
|
256
256
|
export const DbChangeValues = record(String, SqliteValue);
|
|
257
257
|
export type DbChangeValues = typeof DbChangeValues.Type;
|
|
258
258
|
|
|
259
|
-
// "createdAt" and "updatedAt" are derived from Timestamp.
|
|
260
|
-
// "id" and "isDeleted" are encoded separately to save bytes
|
|
261
|
-
// "ownerId" is part of the protocol message
|
|
262
|
-
const forbiddenSystemColumns = systemColumns.concat("id");
|
|
263
|
-
|
|
264
259
|
export const ValidDbChangeValues = brand(
|
|
265
260
|
"ValidDbChangeValues",
|
|
266
261
|
DbChangeValues,
|
|
267
262
|
(value) => {
|
|
268
|
-
const invalidColumns =
|
|
263
|
+
const invalidColumns = systemColumnsWithId.filter((key) => key in value);
|
|
269
264
|
if (invalidColumns.length > 0)
|
|
270
265
|
return err<ValidDbChangeValuesError>({
|
|
271
266
|
type: "ValidDbChangeValues",
|
package/src/local-first/Sync.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
+
appendToArray,
|
|
2
3
|
firstInArray,
|
|
3
4
|
isNonEmptyReadonlyArray,
|
|
4
5
|
NonEmptyArray,
|
|
5
6
|
NonEmptyReadonlyArray,
|
|
6
7
|
} from "../Array.js";
|
|
7
|
-
import { assert } from "../Assert.js";
|
|
8
|
+
import { assert, assertNonEmptyReadonlyArray } from "../Assert.js";
|
|
8
9
|
import { Brand } from "../Brand.js";
|
|
9
10
|
import { ConsoleDep } from "../Console.js";
|
|
10
11
|
import {
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
import { eqArrayNumber } from "../Eq.js";
|
|
16
17
|
import { createTransferableError, TransferableError } from "../Error.js";
|
|
17
18
|
import { constFalse, constTrue } from "../Function.js";
|
|
18
|
-
import { objectToEntries } from "../Object.js";
|
|
19
|
+
import { createRecord, getProperty, objectToEntries } from "../Object.js";
|
|
19
20
|
import { RandomDep } from "../Random.js";
|
|
20
21
|
import { createResources } from "../Resources.js";
|
|
21
22
|
import { err, ok, Result } from "../Result.js";
|
|
@@ -26,22 +27,23 @@ import {
|
|
|
26
27
|
sqliteBooleanToBoolean,
|
|
27
28
|
SqliteDep,
|
|
28
29
|
SqliteError,
|
|
29
|
-
sqliteFalse,
|
|
30
30
|
SqliteValue,
|
|
31
31
|
} from "../Sqlite.js";
|
|
32
32
|
import { AbortError, createMutex } from "../Task.js";
|
|
33
33
|
import { TimeDep } from "../Time.js";
|
|
34
34
|
import {
|
|
35
|
+
Boolean,
|
|
35
36
|
DateIso,
|
|
37
|
+
Id,
|
|
36
38
|
IdBytes,
|
|
37
39
|
idBytesToId,
|
|
38
40
|
idToIdBytes,
|
|
39
41
|
PositiveInt,
|
|
40
42
|
} from "../Type.js";
|
|
41
43
|
import { CreateWebSocketDep, WebSocket } from "../WebSocket.js";
|
|
42
|
-
import type { AppOwnerDep, PostMessageDep } from "./Db.js";
|
|
43
44
|
import {
|
|
44
45
|
AppOwner,
|
|
46
|
+
AppOwnerDep,
|
|
45
47
|
Owner,
|
|
46
48
|
OwnerId,
|
|
47
49
|
OwnerIdBytes,
|
|
@@ -62,7 +64,7 @@ import {
|
|
|
62
64
|
ProtocolTimestampMismatchError,
|
|
63
65
|
SubscriptionFlags,
|
|
64
66
|
} from "./Protocol.js";
|
|
65
|
-
import { MutationChange } from "./Schema.js";
|
|
67
|
+
import { DbSchemaDep, MutationChange, systemColumns } from "./Schema.js";
|
|
66
68
|
import {
|
|
67
69
|
BaseSqliteStorage,
|
|
68
70
|
CrdtMessage,
|
|
@@ -71,7 +73,6 @@ import {
|
|
|
71
73
|
getOwnerUsage,
|
|
72
74
|
getTimestampInsertStrategy,
|
|
73
75
|
Storage,
|
|
74
|
-
StorageInsertTimestampStrategy,
|
|
75
76
|
StorageWriteError,
|
|
76
77
|
updateOwnerUsage,
|
|
77
78
|
} from "./Storage.js";
|
|
@@ -81,6 +82,7 @@ import {
|
|
|
81
82
|
receiveTimestamp,
|
|
82
83
|
sendTimestamp,
|
|
83
84
|
Timestamp,
|
|
85
|
+
TimestampBytes,
|
|
84
86
|
timestampBytesToTimestamp,
|
|
85
87
|
TimestampConfigDep,
|
|
86
88
|
TimestampCounterOverflowError,
|
|
@@ -158,7 +160,8 @@ export const createSync =
|
|
|
158
160
|
deps: ClockDep &
|
|
159
161
|
ConsoleDep &
|
|
160
162
|
CreateWebSocketDep &
|
|
161
|
-
|
|
163
|
+
DbSchemaDep &
|
|
164
|
+
// PostMessageDep &
|
|
162
165
|
RandomBytesDep &
|
|
163
166
|
RandomDep &
|
|
164
167
|
SqliteDep &
|
|
@@ -325,7 +328,6 @@ export const createSync =
|
|
|
325
328
|
resources.addConsumer(owner, transports);
|
|
326
329
|
} else {
|
|
327
330
|
const result = resources.removeConsumer(owner, transports);
|
|
328
|
-
|
|
329
331
|
if (!result.ok) {
|
|
330
332
|
deps.console.warn("[sync]", "Failed to remove consumer", {
|
|
331
333
|
transports,
|
|
@@ -446,13 +448,13 @@ export interface ClientStorageDep {
|
|
|
446
448
|
const createClientStorage =
|
|
447
449
|
(
|
|
448
450
|
deps: ClockDep &
|
|
451
|
+
DbSchemaDep &
|
|
449
452
|
GetSyncOwnerDep &
|
|
450
453
|
RandomDep &
|
|
451
454
|
SqliteDep &
|
|
452
455
|
SymmetricCryptoDep &
|
|
453
456
|
TimeDep &
|
|
454
|
-
TimestampConfigDep
|
|
455
|
-
PostMessageDep,
|
|
457
|
+
TimestampConfigDep,
|
|
456
458
|
) =>
|
|
457
459
|
(config: {
|
|
458
460
|
onError: (
|
|
@@ -486,7 +488,7 @@ const createClientStorage =
|
|
|
486
488
|
const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
|
|
487
489
|
|
|
488
490
|
// Everything is sync now, but we will need async crypto in the future.
|
|
489
|
-
const
|
|
491
|
+
const result = await mutex.withLock<
|
|
490
492
|
boolean,
|
|
491
493
|
| AbortError
|
|
492
494
|
| ProtocolInvalidDataError
|
|
@@ -538,19 +540,13 @@ const createClientStorage =
|
|
|
538
540
|
}
|
|
539
541
|
|
|
540
542
|
if (isNonEmptyReadonlyArray(messages)) {
|
|
541
|
-
const
|
|
543
|
+
const result = applyMessages({ ...deps, storage })(
|
|
542
544
|
owner.id,
|
|
543
545
|
messages,
|
|
544
546
|
);
|
|
545
|
-
if (!
|
|
547
|
+
if (!result.ok) return result;
|
|
546
548
|
}
|
|
547
549
|
|
|
548
|
-
// // Apply local mutations atomically with approved messages
|
|
549
|
-
// for (const change of localMutations) {
|
|
550
|
-
// const result = applyLocalOnlyChange(deps)(change);
|
|
551
|
-
// if (!result.ok) return result;
|
|
552
|
-
// }
|
|
553
|
-
|
|
554
550
|
return deps.clock.save(clockTimestamp);
|
|
555
551
|
});
|
|
556
552
|
|
|
@@ -559,9 +555,9 @@ const createClientStorage =
|
|
|
559
555
|
return ok(true);
|
|
560
556
|
})();
|
|
561
557
|
|
|
562
|
-
if (!
|
|
563
|
-
if (
|
|
564
|
-
config.onError(
|
|
558
|
+
if (!result.ok) {
|
|
559
|
+
if (result.error.type !== "AbortError") {
|
|
560
|
+
config.onError(result.error);
|
|
565
561
|
}
|
|
566
562
|
return err<StorageWriteError>({ type: "StorageWriteError", ownerId });
|
|
567
563
|
}
|
|
@@ -577,26 +573,34 @@ const createClientStorage =
|
|
|
577
573
|
if (!owner) return null;
|
|
578
574
|
|
|
579
575
|
const result = deps.sqlite.exec<{
|
|
580
|
-
table: string;
|
|
581
|
-
id: IdBytes;
|
|
582
|
-
column: string;
|
|
583
|
-
value: SqliteValue;
|
|
576
|
+
readonly table: string;
|
|
577
|
+
readonly id: IdBytes;
|
|
578
|
+
readonly column: string;
|
|
579
|
+
readonly value: SqliteValue;
|
|
584
580
|
}>(sql`
|
|
585
581
|
select "table", "id", "column", "value"
|
|
586
582
|
from evolu_history
|
|
583
|
+
where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}
|
|
584
|
+
union all
|
|
585
|
+
select "table", "id", "column", "value"
|
|
586
|
+
from evolu_message_quarantine
|
|
587
587
|
where "ownerId" = ${ownerId} and "timestamp" = ${timestamp};
|
|
588
588
|
`);
|
|
589
|
+
|
|
589
590
|
if (!result.ok) {
|
|
590
591
|
config.onError(result.error);
|
|
591
592
|
return null;
|
|
592
593
|
}
|
|
593
594
|
|
|
594
595
|
const { rows } = result.value;
|
|
595
|
-
|
|
596
|
+
assertNonEmptyReadonlyArray(
|
|
597
|
+
rows,
|
|
598
|
+
"Every timestamp must have rows in evolu_history or quarantine",
|
|
599
|
+
);
|
|
596
600
|
|
|
597
|
-
const { table, id } = rows
|
|
598
|
-
const values
|
|
599
|
-
let isInsert
|
|
601
|
+
const { table, id } = firstInArray(rows);
|
|
602
|
+
const values = createRecord<string, SqliteValue>();
|
|
603
|
+
let isInsert;
|
|
600
604
|
let isDelete: boolean | null = null;
|
|
601
605
|
|
|
602
606
|
for (const r of rows) {
|
|
@@ -606,10 +610,13 @@ const createClientStorage =
|
|
|
606
610
|
case "createdAt":
|
|
607
611
|
isInsert = true;
|
|
608
612
|
break;
|
|
613
|
+
case "updatedAt":
|
|
614
|
+
isInsert = false;
|
|
615
|
+
break;
|
|
609
616
|
case "isDeleted":
|
|
610
617
|
assert(
|
|
611
618
|
SqliteBoolean.is(r.value),
|
|
612
|
-
"isDeleted column must contain a valid SqliteBoolean
|
|
619
|
+
"isDeleted column must contain a valid SqliteBoolean",
|
|
613
620
|
);
|
|
614
621
|
isDelete = sqliteBooleanToBoolean(r.value);
|
|
615
622
|
break;
|
|
@@ -618,6 +625,8 @@ const createClientStorage =
|
|
|
618
625
|
}
|
|
619
626
|
}
|
|
620
627
|
|
|
628
|
+
assert(Boolean.is(isInsert), "isInsert must be in evolu_history");
|
|
629
|
+
|
|
621
630
|
const message: CrdtMessage = {
|
|
622
631
|
timestamp: timestampBytesToTimestamp(timestamp),
|
|
623
632
|
change: DbChange.orThrow({
|
|
@@ -643,6 +652,24 @@ const createTransportKey = (transport: OwnerTransport): TransportKey => {
|
|
|
643
652
|
return `${transport.type}:${transport.url}` as TransportKey;
|
|
644
653
|
};
|
|
645
654
|
|
|
655
|
+
const dbChangeToColumns = (change: DbChange, now: DateIso) => {
|
|
656
|
+
let values = objectToEntries(change.values);
|
|
657
|
+
|
|
658
|
+
// SystemColumns are not encoded in change.values.
|
|
659
|
+
values = appendToArray(values, [
|
|
660
|
+
change.isInsert ? "createdAt" : "updatedAt",
|
|
661
|
+
now,
|
|
662
|
+
]);
|
|
663
|
+
if (change.isDelete != null) {
|
|
664
|
+
values = appendToArray(values, [
|
|
665
|
+
"isDeleted",
|
|
666
|
+
booleanToSqliteBoolean(change.isDelete),
|
|
667
|
+
]);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return values;
|
|
671
|
+
};
|
|
672
|
+
|
|
646
673
|
export const applyLocalOnlyChange =
|
|
647
674
|
(deps: SqliteDep & TimeDep & AppOwnerDep) =>
|
|
648
675
|
(change: MutationChange): Result<void, SqliteError> => {
|
|
@@ -653,23 +680,16 @@ export const applyLocalOnlyChange =
|
|
|
653
680
|
`);
|
|
654
681
|
if (!result.ok) return result;
|
|
655
682
|
} else {
|
|
656
|
-
const now = deps.time.nowIso();
|
|
657
683
|
const ownerId = deps.appOwner.id;
|
|
684
|
+
const columns = dbChangeToColumns(change, deps.time.nowIso());
|
|
658
685
|
|
|
659
|
-
|
|
660
|
-
if (change.isDelete !== null) {
|
|
661
|
-
entries = [...entries, ["isDeleted", sqliteFalse]];
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
for (const [column, value] of entries) {
|
|
686
|
+
for (const [column, value] of columns) {
|
|
665
687
|
const result = deps.sqlite.exec(sql.prepared`
|
|
666
688
|
insert into ${sql.identifier(change.table)}
|
|
667
|
-
("ownerId", "id", ${sql.identifier(column)}
|
|
668
|
-
values (${ownerId}, ${change.id}, ${value}
|
|
689
|
+
("ownerId", "id", ${sql.identifier(column)})
|
|
690
|
+
values (${ownerId}, ${change.id}, ${value})
|
|
669
691
|
on conflict ("ownerId", "id") do update
|
|
670
|
-
set
|
|
671
|
-
${sql.identifier(column)} = ${value},
|
|
672
|
-
updatedAt = ${now};
|
|
692
|
+
set ${sql.identifier(column)} = ${value};
|
|
673
693
|
`);
|
|
674
694
|
if (!result.ok) return result;
|
|
675
695
|
}
|
|
@@ -679,129 +699,150 @@ export const applyLocalOnlyChange =
|
|
|
679
699
|
};
|
|
680
700
|
|
|
681
701
|
const applyMessages =
|
|
682
|
-
(deps: ClientStorageDep & ClockDep & RandomDep & SqliteDep) =>
|
|
702
|
+
(deps: ClientStorageDep & ClockDep & DbSchemaDep & RandomDep & SqliteDep) =>
|
|
683
703
|
(
|
|
684
704
|
ownerId: OwnerId,
|
|
685
705
|
messages: NonEmptyReadonlyArray<CrdtMessage>,
|
|
686
706
|
): Result<void, SqliteError> => {
|
|
687
707
|
const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
|
|
688
708
|
|
|
689
|
-
const
|
|
709
|
+
const usage = getOwnerUsage(deps)(
|
|
690
710
|
ownerIdBytes,
|
|
691
711
|
timestampToTimestampBytes(firstInArray(messages).timestamp),
|
|
692
712
|
);
|
|
693
|
-
if (!
|
|
694
|
-
|
|
695
|
-
let { firstTimestamp, lastTimestamp } =
|
|
696
|
-
|
|
697
|
-
for (const
|
|
698
|
-
const
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
const
|
|
713
|
+
if (!usage.ok) return usage;
|
|
714
|
+
|
|
715
|
+
let { firstTimestamp, lastTimestamp } = usage.value;
|
|
716
|
+
|
|
717
|
+
for (const { timestamp, change } of messages) {
|
|
718
|
+
const columns = dbChangeToColumns(change, timestampToDateIso(timestamp));
|
|
719
|
+
const idBytes = idToIdBytes(change.id);
|
|
720
|
+
const timestampBytes = timestampToTimestampBytes(timestamp);
|
|
721
|
+
|
|
722
|
+
for (const [column, value] of columns) {
|
|
723
|
+
if (validateColumnValue(deps)(change.table, column, value)) {
|
|
724
|
+
const result = applyColumnChange(deps)(
|
|
725
|
+
ownerIdBytes,
|
|
726
|
+
ownerId,
|
|
727
|
+
change.table,
|
|
728
|
+
idBytes,
|
|
729
|
+
change.id,
|
|
730
|
+
column,
|
|
731
|
+
value,
|
|
732
|
+
timestampBytes,
|
|
733
|
+
);
|
|
734
|
+
if (!result.ok) return result;
|
|
735
|
+
} else {
|
|
736
|
+
const result = deps.sqlite.exec(sql.prepared`
|
|
737
|
+
insert into evolu_message_quarantine
|
|
738
|
+
("ownerId", "timestamp", "table", "id", "column", "value")
|
|
739
|
+
values
|
|
740
|
+
(
|
|
741
|
+
${ownerIdBytes},
|
|
742
|
+
${timestampBytes},
|
|
743
|
+
${change.table},
|
|
744
|
+
${idBytes},
|
|
745
|
+
${column},
|
|
746
|
+
${value}
|
|
747
|
+
)
|
|
748
|
+
on conflict do nothing;
|
|
749
|
+
`);
|
|
750
|
+
if (!result.ok) return result;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
703
753
|
|
|
704
754
|
let strategy;
|
|
705
755
|
[strategy, firstTimestamp, lastTimestamp] = getTimestampInsertStrategy(
|
|
706
|
-
|
|
756
|
+
timestampBytes,
|
|
707
757
|
firstTimestamp,
|
|
708
758
|
lastTimestamp,
|
|
709
759
|
);
|
|
710
760
|
|
|
711
|
-
const
|
|
761
|
+
const result = deps.storage.insertTimestamp(
|
|
712
762
|
ownerIdBytes,
|
|
713
|
-
|
|
763
|
+
timestampBytes,
|
|
714
764
|
strategy,
|
|
715
|
-
date,
|
|
716
765
|
);
|
|
717
|
-
if (!
|
|
766
|
+
if (!result.ok) return result;
|
|
718
767
|
}
|
|
719
768
|
|
|
720
769
|
/**
|
|
721
|
-
* TODO: Implement proper storedBytes tracking for client using
|
|
722
|
-
* message sizes
|
|
723
|
-
* client...).
|
|
770
|
+
* TODO: Implement proper storedBytes tracking for client using received and
|
|
771
|
+
* sent encrypted message sizes.
|
|
724
772
|
*/
|
|
725
|
-
|
|
773
|
+
return updateOwnerUsage(deps)(
|
|
726
774
|
ownerIdBytes,
|
|
727
775
|
1 as PositiveInt, // Placeholder until proper tracking implemented
|
|
728
776
|
firstTimestamp,
|
|
729
777
|
lastTimestamp,
|
|
730
778
|
);
|
|
731
|
-
|
|
779
|
+
};
|
|
732
780
|
|
|
733
|
-
|
|
781
|
+
/**
|
|
782
|
+
* System columns that can appear in sync messages. Excludes `ownerId` because
|
|
783
|
+
* it's handled separately (stored per-row, not per-column in messages).
|
|
784
|
+
*/
|
|
785
|
+
const systemColumnsWithoutOwnerId = systemColumns.difference(
|
|
786
|
+
new Set(["ownerId"]),
|
|
787
|
+
);
|
|
788
|
+
|
|
789
|
+
const validateColumnValue =
|
|
790
|
+
(deps: DbSchemaDep) =>
|
|
791
|
+
(table: string, column: string, _value: SqliteValue): boolean => {
|
|
792
|
+
const schemaColumns = getProperty(deps.dbSchema.tables, table);
|
|
793
|
+
return (
|
|
794
|
+
schemaColumns != null &&
|
|
795
|
+
(systemColumnsWithoutOwnerId.has(column) || schemaColumns.has(column))
|
|
796
|
+
);
|
|
734
797
|
};
|
|
735
798
|
|
|
736
|
-
const
|
|
799
|
+
const applyColumnChange =
|
|
737
800
|
(deps: SqliteDep) =>
|
|
738
801
|
(
|
|
739
802
|
ownerIdBytes: OwnerIdBytes,
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
const result = deps.sqlite.exec(sql.prepared`
|
|
748
|
-
with
|
|
749
|
-
existingTimestamp as (
|
|
750
|
-
select 1
|
|
751
|
-
from evolu_history
|
|
752
|
-
where
|
|
753
|
-
"ownerId" = ${ownerIdBytes}
|
|
754
|
-
and "table" = ${message.change.table}
|
|
755
|
-
and "id" = ${idToIdBytes(message.change.id)}
|
|
756
|
-
and "column" = ${column}
|
|
757
|
-
and "timestamp" >= ${timestampToTimestampBytes(message.timestamp)}
|
|
758
|
-
limit 1
|
|
759
|
-
)
|
|
760
|
-
insert into ${sql.identifier(message.change.table)}
|
|
761
|
-
("ownerId", "id", ${sql.identifier(column)}, updatedAt)
|
|
762
|
-
select ${ownerId}, ${message.change.id}, ${value}, ${date}
|
|
763
|
-
where not exists (select 1 from existingTimestamp)
|
|
764
|
-
on conflict ("ownerId", "id") do update
|
|
765
|
-
set
|
|
766
|
-
${sql.identifier(column)} = ${value},
|
|
767
|
-
updatedAt = ${date}
|
|
768
|
-
where not exists (select 1 from existingTimestamp);
|
|
769
|
-
`);
|
|
770
|
-
|
|
771
|
-
if (!result.ok) return result;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
return ok();
|
|
775
|
-
};
|
|
776
|
-
|
|
777
|
-
export const applyMessageToTimestampAndHistoryTables =
|
|
778
|
-
(deps: ClientStorageDep & SqliteDep) =>
|
|
779
|
-
(
|
|
780
|
-
ownerId: OwnerIdBytes,
|
|
781
|
-
message: CrdtMessage,
|
|
782
|
-
strategy: StorageInsertTimestampStrategy,
|
|
783
|
-
date: DateIso,
|
|
803
|
+
ownerId: OwnerId,
|
|
804
|
+
table: string,
|
|
805
|
+
idBytes: IdBytes,
|
|
806
|
+
id: Id,
|
|
807
|
+
column: string,
|
|
808
|
+
value: SqliteValue,
|
|
809
|
+
timestampBytes: TimestampBytes,
|
|
784
810
|
): Result<void, SqliteError> => {
|
|
785
|
-
const
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
811
|
+
const result = deps.sqlite.exec(sql.prepared`
|
|
812
|
+
with
|
|
813
|
+
existingTimestamp as (
|
|
814
|
+
select 1
|
|
815
|
+
from evolu_history
|
|
816
|
+
where
|
|
817
|
+
"ownerId" = ${ownerIdBytes}
|
|
818
|
+
and "table" = ${table}
|
|
819
|
+
and "id" = ${idBytes}
|
|
820
|
+
and "column" = ${column}
|
|
821
|
+
and "timestamp" >= ${timestampBytes}
|
|
822
|
+
limit 1
|
|
823
|
+
)
|
|
824
|
+
insert into ${sql.identifier(table)}
|
|
825
|
+
("ownerId", "id", ${sql.identifier(column)})
|
|
826
|
+
select ${ownerId}, ${id}, ${value}
|
|
827
|
+
where not exists (select 1 from existingTimestamp)
|
|
828
|
+
on conflict ("ownerId", "id") do update
|
|
829
|
+
set ${sql.identifier(column)} = ${value}
|
|
830
|
+
where not exists (select 1 from existingTimestamp);
|
|
831
|
+
`);
|
|
789
832
|
if (!result.ok) return result;
|
|
790
833
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
for (const [column, value] of columns) {
|
|
834
|
+
{
|
|
794
835
|
const result = deps.sqlite.exec(sql.prepared`
|
|
795
836
|
insert into evolu_history
|
|
796
837
|
("ownerId", "table", "id", "column", "value", "timestamp")
|
|
797
838
|
values
|
|
798
839
|
(
|
|
799
|
-
${
|
|
800
|
-
${
|
|
801
|
-
${
|
|
840
|
+
${ownerIdBytes},
|
|
841
|
+
${table},
|
|
842
|
+
${idBytes},
|
|
802
843
|
${column},
|
|
803
844
|
${value},
|
|
804
|
-
${
|
|
845
|
+
${timestampBytes}
|
|
805
846
|
)
|
|
806
847
|
on conflict do nothing;
|
|
807
848
|
`);
|
|
@@ -811,19 +852,58 @@ export const applyMessageToTimestampAndHistoryTables =
|
|
|
811
852
|
return ok();
|
|
812
853
|
};
|
|
813
854
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
855
|
+
/**
|
|
856
|
+
* Attempts to apply quarantined messages that may now be valid after a schema
|
|
857
|
+
* update. Messages are quarantined when they reference tables or columns that
|
|
858
|
+
* don't exist in the current schema (e.g., from a newer app version).
|
|
859
|
+
*/
|
|
860
|
+
export const tryApplyQuarantinedMessages =
|
|
861
|
+
(deps: DbSchemaDep & SqliteDep) => (): Result<void, SqliteError> => {
|
|
862
|
+
const rows = deps.sqlite.exec<{
|
|
863
|
+
readonly ownerId: OwnerIdBytes;
|
|
864
|
+
readonly timestamp: TimestampBytes;
|
|
865
|
+
readonly table: string;
|
|
866
|
+
readonly id: IdBytes;
|
|
867
|
+
readonly column: string;
|
|
868
|
+
readonly value: SqliteValue;
|
|
869
|
+
}>(sql`
|
|
870
|
+
select "ownerId", "timestamp", "table", "id", "column", "value"
|
|
871
|
+
from evolu_message_quarantine;
|
|
872
|
+
`);
|
|
873
|
+
if (!rows.ok) return rows;
|
|
874
|
+
|
|
875
|
+
for (const row of rows.value.rows) {
|
|
876
|
+
if (!validateColumnValue(deps)(row.table, row.column, row.value))
|
|
877
|
+
continue;
|
|
878
|
+
|
|
879
|
+
const result = applyColumnChange(deps)(
|
|
880
|
+
row.ownerId,
|
|
881
|
+
ownerIdBytesToOwnerId(row.ownerId),
|
|
882
|
+
row.table,
|
|
883
|
+
row.id,
|
|
884
|
+
idBytesToId(row.id),
|
|
885
|
+
row.column,
|
|
886
|
+
row.value,
|
|
887
|
+
row.timestamp,
|
|
888
|
+
);
|
|
889
|
+
if (!result.ok) return result;
|
|
890
|
+
|
|
891
|
+
{
|
|
892
|
+
const result = deps.sqlite.exec(sql`
|
|
893
|
+
delete from evolu_message_quarantine
|
|
894
|
+
where
|
|
895
|
+
"ownerId" = ${row.ownerId}
|
|
896
|
+
and "timestamp" = ${row.timestamp}
|
|
897
|
+
and "table" = ${row.table}
|
|
898
|
+
and "id" = ${row.id}
|
|
899
|
+
and "column" = ${row.column};
|
|
900
|
+
`);
|
|
901
|
+
if (!result.ok) return result;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return ok();
|
|
906
|
+
};
|
|
827
907
|
|
|
828
908
|
/**
|
|
829
909
|
* TODO: Rework for the new owners API.
|
|
@@ -336,8 +336,21 @@ export const timestampBytesToTimestamp = (
|
|
|
336
336
|
return { millis: Number(millis), counter, nodeId } as Timestamp;
|
|
337
337
|
};
|
|
338
338
|
|
|
339
|
+
/**
|
|
340
|
+
* An {@link Order} for {@link TimestampBytes}.
|
|
341
|
+
*
|
|
342
|
+
* This `Order` uses lexicographic byte order to compare serialized
|
|
343
|
+
* {@link TimestampBytes} produced by {@link timestampToTimestampBytes}. See
|
|
344
|
+
* {@link orderUint8Array} for the underlying implementation.
|
|
345
|
+
*/
|
|
339
346
|
export const orderTimestampBytes: Order<TimestampBytes> = orderUint8Array;
|
|
340
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Convert a {@link Timestamp} to an ISO 8601 {@link DateIso} string.
|
|
350
|
+
*
|
|
351
|
+
* The conversion uses the timestamp's `millis` (a {@link Millis} value) and
|
|
352
|
+
* `Date.prototype.toISOString()` to produce a `DateIso`.
|
|
353
|
+
*/
|
|
341
354
|
export const timestampToDateIso = (timestamp: Timestamp): DateIso =>
|
|
342
|
-
// `as DateIso` is safe because
|
|
355
|
+
// `as DateIso` is safe because Timestamp guarantees a valid `millis`
|
|
343
356
|
new Date(timestamp.millis).toISOString() as DateIso;
|
package/src/local-first/index.ts
CHANGED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { Query, Row } from "./Query.js";
|
|
2
|
-
export interface QueryPatches {
|
|
3
|
-
readonly query: Query;
|
|
4
|
-
readonly patches: ReadonlyArray<Patch>;
|
|
5
|
-
}
|
|
6
|
-
export type Patch = ReplaceAllPatch | ReplaceAtPatch;
|
|
7
|
-
export interface ReplaceAllPatch {
|
|
8
|
-
readonly op: "replaceAll";
|
|
9
|
-
readonly value: ReadonlyArray<Row>;
|
|
10
|
-
}
|
|
11
|
-
export interface ReplaceAtPatch {
|
|
12
|
-
readonly op: "replaceAt";
|
|
13
|
-
readonly index: number;
|
|
14
|
-
readonly value: Row;
|
|
15
|
-
}
|
|
16
|
-
export declare const applyPatches: (patches: ReadonlyArray<Patch>, current: ReadonlyArray<Row>) => ReadonlyArray<Row>;
|
|
17
|
-
/**
|
|
18
|
-
* We detect only changes in the whole result and in-place edits. In the future,
|
|
19
|
-
* we will add more heuristics. We will probably not implement the Myers diff
|
|
20
|
-
* algorithm because it's faster to rerender all than to compute many detailed
|
|
21
|
-
* patches. We will only implement logic a developer would implement manually,
|
|
22
|
-
* if necessary.
|
|
23
|
-
*/
|
|
24
|
-
export declare const makePatches: (previousRows: ReadonlyArray<Row> | undefined, nextRows: ReadonlyArray<Row>) => ReadonlyArray<Patch>;
|
|
25
|
-
/**
|
|
26
|
-
* A unique identifier prepended to JSON-encoded strings. This allows safe
|
|
27
|
-
* detection and parsing of only those columns that require JSON.parse.
|
|
28
|
-
*
|
|
29
|
-
* The identifier is a cryptographically random Evolu Id, ensuring uniqueness
|
|
30
|
-
* and preventing malicious actors from inserting fake data that could be
|
|
31
|
-
* misinterpreted as JSON by the application.
|
|
32
|
-
*
|
|
33
|
-
* Note: The same queries created by different browser tabs will have different
|
|
34
|
-
* identifiers and thus be considered different and cached separately. This is
|
|
35
|
-
* usually not a big deal, but if needed, the DB cache can be optimized by
|
|
36
|
-
* passing the kyselyJsonIdentifier into the DB worker during initialization,
|
|
37
|
-
* allowing queries to be grouped and recognized across tabs or sessions.
|
|
38
|
-
*
|
|
39
|
-
* See: https://github.com/kysely-org/kysely/issues/1372#issuecomment-2702773948
|
|
40
|
-
*/
|
|
41
|
-
export declare const kyselyJsonIdentifier: string & import("../Brand.js").Brand<"Id">;
|
|
42
|
-
export declare const parseSqliteJsonArray: <T>(arr: ReadonlyArray<T>) => ReadonlyArray<T>;
|
|
43
|
-
//# sourceMappingURL=Diff.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"Diff.d.ts","sourceRoot":"","sources":["../../../src/local-first/Diff.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAExC,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,KAAK,CAAC,CAAC;CACxC;AAED,MAAM,MAAM,KAAK,GAAG,eAAe,GAAG,cAAc,CAAC;AAErD,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,EAAE,YAAY,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,GAAG,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,EAAE,WAAW,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,GAAG,CAAC;CACrB;AAED,eAAO,MAAM,YAAY,GACvB,SAAS,aAAa,CAAC,KAAK,CAAC,EAC7B,SAAS,aAAa,CAAC,GAAG,CAAC,KAC1B,aAAa,CAAC,GAAG,CAUP,CAAC;AAEd;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,GACtB,cAAc,aAAa,CAAC,GAAG,CAAC,GAAG,SAAS,EAC5C,UAAU,aAAa,CAAC,GAAG,CAAC,KAC3B,aAAa,CAAC,KAAK,CA6BrB,CAAC;AAUF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,oBAAoB,4CAE/B,CAAC;AAEH,eAAO,MAAM,oBAAoB,GAAI,CAAC,EACpC,KAAK,aAAa,CAAC,CAAC,CAAC,KACpB,aAAa,CAAC,CAAC,CAMjB,CAAC"}
|