@evolu/common 8.9.0 → 8.11.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/Bytes.d.ts +647 -0
- package/dist/src/Bytes.d.ts.map +1 -0
- package/dist/src/{Binary.js → Bytes.js} +266 -16
- package/dist/src/Config.d.ts +142 -0
- package/dist/src/Config.d.ts.map +1 -0
- package/dist/src/Config.js +181 -0
- package/dist/src/Console.d.ts +62 -7
- package/dist/src/Console.d.ts.map +1 -1
- package/dist/src/Console.js +20 -4
- package/dist/src/Crypto.d.ts +76 -4
- package/dist/src/Crypto.d.ts.map +1 -1
- package/dist/src/Crypto.js +55 -4
- package/dist/src/Error.d.ts +45 -0
- package/dist/src/Error.d.ts.map +1 -1
- package/dist/src/Error.js +69 -0
- package/dist/src/Fs.d.ts +376 -0
- package/dist/src/Fs.d.ts.map +1 -0
- package/dist/src/Fs.js +113 -0
- package/dist/src/Identicon.d.ts +2 -2
- package/dist/src/Identicon.js +2 -2
- package/dist/src/LeakDetector.d.ts +22 -3
- package/dist/src/LeakDetector.d.ts.map +1 -1
- package/dist/src/LeakDetector.js +12 -2
- package/dist/src/LockManager.d.ts +8 -0
- package/dist/src/LockManager.d.ts.map +1 -1
- package/dist/src/LockManager.js +6 -0
- package/dist/src/Number.d.ts +50 -7
- package/dist/src/Number.d.ts.map +1 -1
- package/dist/src/Number.js +47 -8
- package/dist/src/Object.d.ts +32 -0
- package/dist/src/Object.d.ts.map +1 -1
- package/dist/src/Object.js +46 -0
- package/dist/src/Platform.d.ts +47 -7
- package/dist/src/Platform.d.ts.map +1 -1
- package/dist/src/Platform.js +24 -5
- package/dist/src/Random.d.ts +25 -2
- package/dist/src/Random.d.ts.map +1 -1
- package/dist/src/Random.js +14 -2
- package/dist/src/Resource.d.ts +156 -1
- package/dist/src/Resource.d.ts.map +1 -1
- package/dist/src/Resource.js +201 -72
- package/dist/src/Schedule.d.ts +11 -10
- package/dist/src/Schedule.d.ts.map +1 -1
- package/dist/src/Schedule.js +1 -1
- package/dist/src/Sqlite.d.ts +132 -16
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +64 -10
- package/dist/src/Task.d.ts +15 -4
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +41 -15
- package/dist/src/Test.d.ts +9 -0
- package/dist/src/Test.d.ts.map +1 -1
- package/dist/src/Test.js +4 -0
- package/dist/src/Time.d.ts +179 -20
- package/dist/src/Time.d.ts.map +1 -1
- package/dist/src/Time.js +95 -6
- package/dist/src/Type.d.ts +3056 -1539
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +2548 -584
- package/dist/src/WebSocket.d.ts +164 -13
- package/dist/src/WebSocket.d.ts.map +1 -1
- package/dist/src/WebSocket.js +133 -24
- package/dist/src/Worker.d.ts +90 -8
- package/dist/src/Worker.d.ts.map +1 -1
- package/dist/src/Worker.js +28 -2
- package/dist/src/index.d.ts +9 -8
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +5 -4
- package/dist/src/intl/_en.d.ts +24 -1
- package/dist/src/intl/_en.d.ts.map +1 -1
- package/dist/src/intl/_en.js +20 -0
- package/dist/src/intl/ar.d.ts +24 -1
- package/dist/src/intl/ar.d.ts.map +1 -1
- package/dist/src/intl/ar.js +20 -0
- package/dist/src/intl/bn.d.ts +24 -1
- package/dist/src/intl/bn.d.ts.map +1 -1
- package/dist/src/intl/bn.js +20 -0
- package/dist/src/intl/ca.d.ts +24 -1
- package/dist/src/intl/ca.d.ts.map +1 -1
- package/dist/src/intl/ca.js +20 -0
- package/dist/src/intl/cs.d.ts +24 -1
- package/dist/src/intl/cs.d.ts.map +1 -1
- package/dist/src/intl/cs.js +20 -0
- package/dist/src/intl/da.d.ts +24 -1
- package/dist/src/intl/da.d.ts.map +1 -1
- package/dist/src/intl/da.js +20 -0
- package/dist/src/intl/de.d.ts +24 -1
- package/dist/src/intl/de.d.ts.map +1 -1
- package/dist/src/intl/de.js +20 -0
- package/dist/src/intl/el.d.ts +24 -1
- package/dist/src/intl/el.d.ts.map +1 -1
- package/dist/src/intl/el.js +20 -0
- package/dist/src/intl/es.d.ts +24 -1
- package/dist/src/intl/es.d.ts.map +1 -1
- package/dist/src/intl/es.js +20 -0
- package/dist/src/intl/fa.d.ts +24 -1
- package/dist/src/intl/fa.d.ts.map +1 -1
- package/dist/src/intl/fa.js +20 -0
- package/dist/src/intl/fi.d.ts +24 -1
- package/dist/src/intl/fi.d.ts.map +1 -1
- package/dist/src/intl/fi.js +20 -0
- package/dist/src/intl/fil.d.ts +24 -1
- package/dist/src/intl/fil.d.ts.map +1 -1
- package/dist/src/intl/fil.js +20 -0
- package/dist/src/intl/fr.d.ts +24 -1
- package/dist/src/intl/fr.d.ts.map +1 -1
- package/dist/src/intl/fr.js +20 -0
- package/dist/src/intl/he.d.ts +24 -1
- package/dist/src/intl/he.d.ts.map +1 -1
- package/dist/src/intl/he.js +20 -0
- package/dist/src/intl/hi.d.ts +24 -1
- package/dist/src/intl/hi.d.ts.map +1 -1
- package/dist/src/intl/hi.js +20 -0
- package/dist/src/intl/hr.d.ts +24 -1
- package/dist/src/intl/hr.d.ts.map +1 -1
- package/dist/src/intl/hr.js +20 -0
- package/dist/src/intl/hu.d.ts +22 -1
- package/dist/src/intl/hu.d.ts.map +1 -1
- package/dist/src/intl/hu.js +18 -0
- package/dist/src/intl/id.d.ts +24 -1
- package/dist/src/intl/id.d.ts.map +1 -1
- package/dist/src/intl/id.js +20 -0
- package/dist/src/intl/it.d.ts +24 -1
- package/dist/src/intl/it.d.ts.map +1 -1
- package/dist/src/intl/it.js +20 -0
- package/dist/src/intl/ja.d.ts +24 -1
- package/dist/src/intl/ja.d.ts.map +1 -1
- package/dist/src/intl/ja.js +20 -0
- package/dist/src/intl/ko.d.ts +24 -1
- package/dist/src/intl/ko.d.ts.map +1 -1
- package/dist/src/intl/ko.js +20 -0
- package/dist/src/intl/ml.d.ts +24 -1
- package/dist/src/intl/ml.d.ts.map +1 -1
- package/dist/src/intl/ml.js +20 -0
- package/dist/src/intl/mr.d.ts +24 -1
- package/dist/src/intl/mr.d.ts.map +1 -1
- package/dist/src/intl/mr.js +20 -0
- package/dist/src/intl/ms.d.ts +24 -1
- package/dist/src/intl/ms.d.ts.map +1 -1
- package/dist/src/intl/ms.js +20 -0
- package/dist/src/intl/nb.d.ts +22 -1
- package/dist/src/intl/nb.d.ts.map +1 -1
- package/dist/src/intl/nb.js +18 -0
- package/dist/src/intl/nl.d.ts +24 -1
- package/dist/src/intl/nl.d.ts.map +1 -1
- package/dist/src/intl/nl.js +20 -0
- package/dist/src/intl/pa.d.ts +24 -1
- package/dist/src/intl/pa.d.ts.map +1 -1
- package/dist/src/intl/pa.js +20 -0
- package/dist/src/intl/pl.d.ts +23 -0
- package/dist/src/intl/pl.d.ts.map +1 -1
- package/dist/src/intl/pl.js +20 -0
- package/dist/src/intl/pt-BR.d.ts +24 -1
- package/dist/src/intl/pt-BR.d.ts.map +1 -1
- package/dist/src/intl/pt-BR.js +20 -0
- package/dist/src/intl/pt.d.ts +24 -1
- package/dist/src/intl/pt.d.ts.map +1 -1
- package/dist/src/intl/pt.js +20 -0
- package/dist/src/intl/ro.d.ts +24 -1
- package/dist/src/intl/ro.d.ts.map +1 -1
- package/dist/src/intl/ro.js +20 -0
- package/dist/src/intl/sk.d.ts +24 -1
- package/dist/src/intl/sk.d.ts.map +1 -1
- package/dist/src/intl/sk.js +20 -0
- package/dist/src/intl/sl.d.ts +24 -1
- package/dist/src/intl/sl.d.ts.map +1 -1
- package/dist/src/intl/sl.js +20 -0
- package/dist/src/intl/sv.d.ts +24 -1
- package/dist/src/intl/sv.d.ts.map +1 -1
- package/dist/src/intl/sv.js +20 -0
- package/dist/src/intl/sw.d.ts +21 -0
- package/dist/src/intl/sw.d.ts.map +1 -1
- package/dist/src/intl/sw.js +18 -0
- package/dist/src/intl/ta.d.ts +24 -1
- package/dist/src/intl/ta.d.ts.map +1 -1
- package/dist/src/intl/ta.js +20 -0
- package/dist/src/intl/te.d.ts +24 -1
- package/dist/src/intl/te.d.ts.map +1 -1
- package/dist/src/intl/te.js +20 -0
- package/dist/src/intl/th.d.ts +24 -1
- package/dist/src/intl/th.d.ts.map +1 -1
- package/dist/src/intl/th.js +20 -0
- package/dist/src/intl/tr.d.ts +24 -1
- package/dist/src/intl/tr.d.ts.map +1 -1
- package/dist/src/intl/tr.js +20 -0
- package/dist/src/intl/uk.d.ts +80 -57
- package/dist/src/intl/uk.d.ts.map +1 -1
- package/dist/src/intl/uk.js +174 -149
- package/dist/src/intl/ur.d.ts +24 -1
- package/dist/src/intl/ur.d.ts.map +1 -1
- package/dist/src/intl/ur.js +20 -0
- package/dist/src/intl/vi.d.ts +24 -1
- package/dist/src/intl/vi.d.ts.map +1 -1
- package/dist/src/intl/vi.js +20 -0
- package/dist/src/intl/zh-CN.d.ts +24 -1
- package/dist/src/intl/zh-CN.d.ts.map +1 -1
- package/dist/src/intl/zh-CN.js +20 -0
- package/dist/src/intl/zh-TW.d.ts +24 -1
- package/dist/src/intl/zh-TW.d.ts.map +1 -1
- package/dist/src/intl/zh-TW.js +20 -0
- package/dist/src/local-first/Db.d.ts +52 -3
- package/dist/src/local-first/Db.d.ts.map +1 -1
- package/dist/src/local-first/Db.js +412 -137
- package/dist/src/local-first/Evolu.d.ts +336 -211
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +102 -15
- package/dist/src/local-first/Owner.d.ts +13 -30
- package/dist/src/local-first/Owner.d.ts.map +1 -1
- package/dist/src/local-first/Owner.js +13 -30
- package/dist/src/local-first/Protocol.d.ts +95 -17
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.js +119 -39
- package/dist/src/local-first/Query.d.ts +8 -15
- package/dist/src/local-first/Query.d.ts.map +1 -1
- package/dist/src/local-first/Schema.d.ts +345 -21
- package/dist/src/local-first/Schema.d.ts.map +1 -1
- package/dist/src/local-first/Schema.js +214 -17
- package/dist/src/local-first/Shared.d.ts +537 -22
- package/dist/src/local-first/Shared.d.ts.map +1 -1
- package/dist/src/local-first/Shared.js +1437 -234
- package/dist/src/local-first/Storage.d.ts +192 -14
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/dist/src/local-first/Storage.js +82 -21
- package/dist/src/local-first/Timestamp.d.ts +392 -41
- package/dist/src/local-first/Timestamp.d.ts.map +1 -1
- package/dist/src/local-first/Timestamp.js +404 -82
- 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/Assert.test.ts +2 -5
- package/src/{Binary.test.ts → Bytes.test.ts} +286 -1
- package/src/{Binary.ts → Bytes.ts} +652 -21
- package/src/Config.test.ts +668 -0
- package/src/Config.ts +410 -0
- package/src/Console.ts +62 -7
- package/src/Crypto.ts +76 -4
- package/src/Eq.test.ts +2 -3
- package/src/Error.test.ts +76 -3
- package/src/Error.ts +71 -0
- package/src/Fs.test.ts +105 -0
- package/src/Fs.ts +488 -0
- package/src/Identicon.ts +2 -2
- package/src/LeakDetector.ts +22 -3
- package/src/LockManager.ts +8 -0
- package/src/Number.test.ts +82 -18
- package/src/Number.ts +76 -8
- package/src/Object.test.ts +139 -10
- package/src/Object.ts +49 -0
- package/src/Platform.ts +50 -8
- package/src/Random.ts +25 -2
- package/src/Resource.test.ts +837 -0
- package/src/Resource.ts +235 -15
- package/src/Schedule.test.ts +50 -12
- package/src/Schedule.ts +24 -14
- package/src/Sqlite.ts +138 -18
- package/src/Task.test.ts +189 -8
- package/src/Task.ts +56 -17
- package/src/Test.ts +9 -0
- package/src/Time.test.ts +82 -11
- package/src/Time.ts +246 -24
- package/src/Type.test.ts +3994 -1119
- package/src/Type.ts +7258 -3842
- package/src/Types.test.ts +4 -14
- package/src/WebSocket.ts +313 -40
- package/src/Worker.ts +90 -8
- package/src/index.ts +18 -7
- package/src/intl/_en.ts +70 -0
- package/src/intl/ar.ts +71 -0
- package/src/intl/bn.ts +70 -0
- package/src/intl/ca.ts +70 -0
- package/src/intl/cs.ts +70 -0
- package/src/intl/da.ts +70 -0
- package/src/intl/de.ts +70 -0
- package/src/intl/el.ts +70 -0
- package/src/intl/es.ts +70 -0
- package/src/intl/fa.ts +70 -0
- package/src/intl/fi.ts +70 -0
- package/src/intl/fil.ts +70 -0
- package/src/intl/fr.ts +70 -0
- package/src/intl/he.ts +70 -0
- package/src/intl/hi.ts +70 -0
- package/src/intl/hr.ts +70 -0
- package/src/intl/hu.ts +69 -0
- package/src/intl/id.ts +70 -0
- package/src/intl/intl.test.ts +819 -1
- package/src/intl/it.ts +70 -0
- package/src/intl/ja.ts +70 -0
- package/src/intl/ko.ts +68 -0
- package/src/intl/ml.ts +70 -0
- package/src/intl/mr.ts +70 -0
- package/src/intl/ms.ts +71 -0
- package/src/intl/nb.ts +69 -0
- package/src/intl/nl.ts +70 -0
- package/src/intl/pa.ts +70 -0
- package/src/intl/pl.ts +63 -0
- package/src/intl/pt-BR.ts +70 -0
- package/src/intl/pt.ts +71 -0
- package/src/intl/ro.ts +70 -0
- package/src/intl/sk.ts +71 -0
- package/src/intl/sl.ts +70 -0
- package/src/intl/sv.ts +70 -0
- package/src/intl/sw.ts +62 -0
- package/src/intl/ta.ts +70 -0
- package/src/intl/te.ts +70 -0
- package/src/intl/th.ts +68 -0
- package/src/intl/tr.ts +70 -0
- package/src/intl/uk.ts +228 -155
- package/src/intl/ur.ts +70 -0
- package/src/intl/vi.ts +70 -0
- package/src/intl/zh-CN.ts +68 -0
- package/src/intl/zh-TW.ts +68 -0
- package/src/local-first/Db.ts +644 -339
- package/src/local-first/Evolu.test.ts +686 -21
- package/src/local-first/Evolu.ts +450 -228
- package/src/local-first/Owner.ts +13 -30
- package/src/local-first/Protocol.test.ts +618 -11
- package/src/local-first/Protocol.ts +197 -73
- package/src/local-first/Query.ts +8 -15
- package/src/local-first/Schema.test.ts +143 -0
- package/src/local-first/Schema.ts +374 -24
- package/src/local-first/Shared.test.ts +7731 -559
- package/src/local-first/Shared.ts +2036 -267
- package/src/local-first/Storage.ts +219 -33
- package/src/local-first/Timestamp.test.ts +344 -70
- package/src/local-first/Timestamp.ts +435 -119
- package/src/local-first/index.ts +0 -1
- package/dist/src/Binary.d.ts +0 -254
- package/dist/src/Binary.d.ts.map +0 -1
- package/dist/src/local-first/Error.d.ts +0 -12
- package/dist/src/local-first/Error.d.ts.map +0 -1
- package/dist/src/local-first/Error.js +0 -6
- package/dist/src/local-first/LocalAuth.d.ts +0 -150
- package/dist/src/local-first/LocalAuth.d.ts.map +0 -1
- package/dist/src/local-first/LocalAuth.js +0 -179
- package/src/local-first/Error.ts +0 -17
- package/src/local-first/LocalAuth.ts +0 -457
package/src/local-first/Db.ts
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Platform-agnostic Evolu DbWorker.
|
|
3
3
|
*
|
|
4
|
+
* ### Database version
|
|
5
|
+
*
|
|
6
|
+
* Every database records `dbVersion` in `evolu_version`. The version describes
|
|
7
|
+
* Evolu's internal persisted format: the layout of its system tables and the
|
|
8
|
+
* meaning of the data stored in them. It is independent of the application
|
|
9
|
+
* schema, which evolves append-only through {@link ensureSqliteSchema}, and of
|
|
10
|
+
* the network protocol version, which is checked per message. Many Evolu
|
|
11
|
+
* releases can share one database version.
|
|
12
|
+
*
|
|
13
|
+
* Bump `dbVersion` for any change that older code would misread, not only for
|
|
14
|
+
* changed SQL. A new quarantine reason, for example, changes what startup may
|
|
15
|
+
* replay even when its columns are additive. Each bump ships with a migration
|
|
16
|
+
* from the previous version; fresh databases are created at the latest layout
|
|
17
|
+
* directly.
|
|
18
|
+
*
|
|
19
|
+
* Startup holds the database leader lock, then checks the stored version record
|
|
20
|
+
* before it reads the clock, ensures the application schema, or replays
|
|
21
|
+
* quarantine. Databases written before the version record existed hold one
|
|
22
|
+
* `protocolVersion` row instead; that known legacy layout is converted to
|
|
23
|
+
* version 1. A newer stored version refuses startup with
|
|
24
|
+
* {@link UnsupportedDbVersionError}. The refusal returns from the startup
|
|
25
|
+
* transaction before anything is written and is posted to the SharedWorker; the
|
|
26
|
+
* worker then exits and releases its resources. The single version row is
|
|
27
|
+
* created in the same transaction as the other system tables.
|
|
28
|
+
*
|
|
29
|
+
* Code released before the version record existed never reads it. It recognizes
|
|
30
|
+
* an initialized database by the `evolu_version` table alone, so it opens a
|
|
31
|
+
* newer database and replays all quarantine regardless of reason, applying
|
|
32
|
+
* drift quarantine at once without advancing the clock. Renaming the table
|
|
33
|
+
* would make such code fail at startup instead. That was declined: the replay
|
|
34
|
+
* needs drift quarantine and an earlier release on the same device at once, and
|
|
35
|
+
* failing would break a rollback to an earlier release outright.
|
|
36
|
+
*
|
|
4
37
|
* @module
|
|
5
38
|
*/
|
|
6
39
|
|
|
@@ -11,21 +44,18 @@ import {
|
|
|
11
44
|
type NonEmptyReadonlyArray,
|
|
12
45
|
} from "../Array.ts";
|
|
13
46
|
import {
|
|
47
|
+
assert,
|
|
14
48
|
assertNonEmptyReadonlyArray,
|
|
15
49
|
assertNonNullable,
|
|
16
50
|
assertNotUndefined,
|
|
17
51
|
} from "../Assert.ts";
|
|
18
52
|
import type { ConsoleLevel } from "../Console.ts";
|
|
19
|
-
import {
|
|
20
|
-
EncryptionKey,
|
|
21
|
-
type DecryptWithXChaCha20Poly1305Error,
|
|
22
|
-
type RandomBytesDep,
|
|
23
|
-
} from "../Crypto.ts";
|
|
53
|
+
import { EncryptionKey, type RandomBytesDep } from "../Crypto.ts";
|
|
24
54
|
import { constFalse, constVoid } from "../Function.ts";
|
|
25
55
|
import type { LockManagerDep } from "../LockManager.ts";
|
|
26
56
|
import { acquireLeaderLock } from "../LockManager.ts";
|
|
27
57
|
import { createMutableRecord, getOwnProp, objectToEntries } from "../Object.ts";
|
|
28
|
-
import { ok, type Result } from "../Result.ts";
|
|
58
|
+
import { err, getOk, ok, type Result } from "../Result.ts";
|
|
29
59
|
import type {
|
|
30
60
|
CreateSqliteDriverDep,
|
|
31
61
|
SqliteDep,
|
|
@@ -42,7 +72,12 @@ import {
|
|
|
42
72
|
SqliteValue,
|
|
43
73
|
} from "../Sqlite.ts";
|
|
44
74
|
import { callback, type Run, type Task } from "../Task.ts";
|
|
45
|
-
import {
|
|
75
|
+
import {
|
|
76
|
+
millisToDateIso,
|
|
77
|
+
saturateMillis,
|
|
78
|
+
type Millis,
|
|
79
|
+
type TimeDep,
|
|
80
|
+
} from "../Time.ts";
|
|
46
81
|
import {
|
|
47
82
|
assertType,
|
|
48
83
|
type FiniteNumber,
|
|
@@ -50,9 +85,12 @@ import {
|
|
|
50
85
|
IdBytes,
|
|
51
86
|
idBytesToId,
|
|
52
87
|
idToIdBytes,
|
|
88
|
+
NonNaNNumber,
|
|
53
89
|
onePositiveInt,
|
|
90
|
+
PositiveInt,
|
|
54
91
|
type ExtractTyped,
|
|
55
92
|
type Name,
|
|
93
|
+
type Typed,
|
|
56
94
|
} from "../Type.ts";
|
|
57
95
|
import type {
|
|
58
96
|
CreateBroadcastChannelDep,
|
|
@@ -68,17 +106,16 @@ import {
|
|
|
68
106
|
createProtocolMessageForSync,
|
|
69
107
|
decryptAndDecodeDbChange,
|
|
70
108
|
encodeAndEncryptDbChange,
|
|
71
|
-
protocolVersion,
|
|
72
109
|
SubscriptionFlags,
|
|
73
|
-
type ProtocolInvalidDataError,
|
|
74
110
|
type ProtocolMessage,
|
|
75
|
-
type ProtocolTimestampMismatchError,
|
|
76
111
|
} from "./Protocol.ts";
|
|
77
112
|
import type { Query, RowsByQueryMap } from "./Query.ts";
|
|
78
113
|
import type { MutationChange, SqliteSchemaDep } from "./Schema.ts";
|
|
79
114
|
import {
|
|
80
115
|
ensureSqliteSchema,
|
|
81
|
-
|
|
116
|
+
isLocalOnlyTable,
|
|
117
|
+
QuarantineOrigin,
|
|
118
|
+
QuarantineReason,
|
|
82
119
|
systemColumns,
|
|
83
120
|
} from "./Schema.ts";
|
|
84
121
|
import type {
|
|
@@ -86,7 +123,7 @@ import type {
|
|
|
86
123
|
DbWorkerInput,
|
|
87
124
|
DbWorkerOutput,
|
|
88
125
|
DbWorkerQueuedResponse,
|
|
89
|
-
|
|
126
|
+
EvoluInput,
|
|
90
127
|
} from "./Shared.ts";
|
|
91
128
|
import { consoleEntryOrErrorBroadcastChannelName } from "./Shared.ts";
|
|
92
129
|
import {
|
|
@@ -101,15 +138,13 @@ import {
|
|
|
101
138
|
type CrdtMessage,
|
|
102
139
|
type Storage,
|
|
103
140
|
} from "./Storage.ts";
|
|
104
|
-
import type {
|
|
105
|
-
Timestamp,
|
|
106
|
-
TimestampCounterOverflowError,
|
|
107
|
-
TimestampDriftError,
|
|
108
|
-
TimestampTimeOutOfRangeError,
|
|
109
|
-
} from "./Timestamp.ts";
|
|
141
|
+
import type { Timestamp } from "./Timestamp.ts";
|
|
110
142
|
import {
|
|
111
143
|
createInitialTimestamp,
|
|
112
144
|
defaultTimestampMaxDrift,
|
|
145
|
+
isTimestampBeyondMaxDrift,
|
|
146
|
+
maxCounter,
|
|
147
|
+
maxNodeId,
|
|
113
148
|
receiveTimestamp,
|
|
114
149
|
sendTimestamp,
|
|
115
150
|
TimestampBytes,
|
|
@@ -141,9 +176,29 @@ export type DbWorkerDeps = WorkerDeps &
|
|
|
141
176
|
LockManagerDep &
|
|
142
177
|
CreateSqliteDriverDep;
|
|
143
178
|
|
|
179
|
+
/** The database version this code creates and supports; see the module doc. */
|
|
180
|
+
const dbVersion = PositiveInt.orThrow(2);
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The stored database version is newer than this code supports. Newer code
|
|
184
|
+
* created or migrated the database, which is left unchanged. This happens when
|
|
185
|
+
* older code opens a database a newer build migrated, for example an older
|
|
186
|
+
* build loaded from a cache, or when the app was downgraded after a newer
|
|
187
|
+
* version migrated the local data. On the web, a refused tab reloads once for
|
|
188
|
+
* each stored version, so it loads the build the server now serves, and the
|
|
189
|
+
* error is reported when that build refuses too. Then close all tabs of the
|
|
190
|
+
* app, which lets a service worker replace a cached older build, or update the
|
|
191
|
+
* app to a version that supports `storedVersion`.
|
|
192
|
+
*/
|
|
193
|
+
export interface UnsupportedDbVersionError extends Typed<"UnsupportedDbVersionError"> {
|
|
194
|
+
readonly storedVersion: PositiveInt;
|
|
195
|
+
readonly supportedVersion: PositiveInt;
|
|
196
|
+
}
|
|
197
|
+
|
|
144
198
|
/**
|
|
145
|
-
* Starts the platform-agnostic Evolu DbWorker and owns its resources until
|
|
146
|
-
* worker receives a dispose message or its {@link Run}
|
|
199
|
+
* Starts the platform-agnostic Evolu DbWorker and owns its resources until
|
|
200
|
+
* startup is refused, the worker receives a dispose message, or its {@link Run}
|
|
201
|
+
* is aborted.
|
|
147
202
|
*/
|
|
148
203
|
export const startDbWorker =
|
|
149
204
|
(self: WorkerSelf<DbWorkerInit>): Task<void, never, DbWorkerDeps> =>
|
|
@@ -199,27 +254,60 @@ export const startDbWorker =
|
|
|
199
254
|
baseSqliteStorage,
|
|
200
255
|
timestampConfig: { maxDrift: defaultTimestampMaxDrift },
|
|
201
256
|
};
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
257
|
+
const startup = sqlite.transaction(
|
|
258
|
+
(): Result<Timestamp, UnsupportedDbVersionError> => {
|
|
259
|
+
// Only the version record is read before the version check, because a
|
|
260
|
+
// newer database can hold schema objects this code cannot read.
|
|
261
|
+
const { rows: versionColumnRows } = sqlite.exec<{ name: string }>(sql`
|
|
262
|
+
select "name" from pragma_table_info('evolu_version');
|
|
263
|
+
`);
|
|
264
|
+
const versionColumns = new Set(
|
|
265
|
+
versionColumnRows.map((row) => row.name),
|
|
266
|
+
);
|
|
267
|
+
let initialClock: Timestamp;
|
|
268
|
+
if (versionColumns.size === 0) {
|
|
269
|
+
initialClock = createInitialTimestamp(dbDeps);
|
|
270
|
+
initializeDb(dbDeps)(initialClock);
|
|
271
|
+
} else {
|
|
272
|
+
const version = ensureDbVersion(dbDeps)(versionColumns);
|
|
273
|
+
if (!version.ok) return version;
|
|
274
|
+
const { rows } = sqlite.exec<{ clock: TimestampBytes }>(sql`
|
|
275
|
+
select "clock" from evolu_config limit 1;
|
|
276
|
+
`);
|
|
277
|
+
assertNonEmptyReadonlyArray(rows);
|
|
278
|
+
initialClock = timestampBytesToTimestamp(firstInArray(rows).clock);
|
|
279
|
+
}
|
|
280
|
+
ensureSqliteSchema(dbDeps)(initMessage.sqliteSchema);
|
|
281
|
+
const released = releaseDriftQuarantine(dbDeps)(initialClock);
|
|
282
|
+
if (released) {
|
|
283
|
+
initialClock = released;
|
|
284
|
+
saveClock(dbDeps)(released);
|
|
285
|
+
}
|
|
286
|
+
tryApplyQuarantinedMessages(dbDeps);
|
|
287
|
+
return ok(initialClock);
|
|
218
288
|
},
|
|
219
|
-
|
|
289
|
+
);
|
|
290
|
+
if (!startup.ok) {
|
|
291
|
+
// Nothing was written. Returning lets the disposer close SQLite and
|
|
292
|
+
// release the database lock, which the SharedWorker's tenant disposal
|
|
293
|
+
// waits on. The tab leader lock is unaffected.
|
|
294
|
+
port.postMessage({
|
|
295
|
+
type: "LeaderRefused",
|
|
296
|
+
name: initMessage.name,
|
|
297
|
+
error: startup.error,
|
|
298
|
+
});
|
|
299
|
+
return ok();
|
|
300
|
+
}
|
|
301
|
+
const initialClock = startup.value;
|
|
302
|
+
|
|
303
|
+
const storage = createClientStorage(dbDeps);
|
|
220
304
|
const dbWorkerRun = disposer.use(run.create({ storage }));
|
|
221
305
|
|
|
222
|
-
port.postMessage({
|
|
306
|
+
port.postMessage({
|
|
307
|
+
type: "LeaderAcquired",
|
|
308
|
+
name: initMessage.name,
|
|
309
|
+
clock: initialClock,
|
|
310
|
+
});
|
|
223
311
|
|
|
224
312
|
await run.ok(
|
|
225
313
|
callback<void>(({ resolve }) => {
|
|
@@ -229,14 +317,14 @@ export const startDbWorker =
|
|
|
229
317
|
return;
|
|
230
318
|
}
|
|
231
319
|
|
|
232
|
-
const {
|
|
320
|
+
const { attemptId } = input;
|
|
233
321
|
const postQueuedResponse = (
|
|
234
322
|
response: DbWorkerQueuedResponse,
|
|
235
323
|
): void => {
|
|
236
324
|
port.postMessage(
|
|
237
325
|
{
|
|
238
326
|
type: "OnQueuedResponse",
|
|
239
|
-
|
|
327
|
+
attemptId,
|
|
240
328
|
response,
|
|
241
329
|
},
|
|
242
330
|
response.type === "ForEvolu" && response.message.type === "Export"
|
|
@@ -245,48 +333,77 @@ export const startDbWorker =
|
|
|
245
333
|
);
|
|
246
334
|
};
|
|
247
335
|
|
|
248
|
-
if (
|
|
249
|
-
|
|
336
|
+
if ("clock" in input) {
|
|
337
|
+
const { clock: inputClock, now } = input;
|
|
338
|
+
let committedClock = inputClock;
|
|
339
|
+
const context: WriteContext = {
|
|
340
|
+
now,
|
|
341
|
+
clock: {
|
|
342
|
+
get: () => committedClock,
|
|
343
|
+
set: (timestamp) => {
|
|
344
|
+
committedClock = timestamp;
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
const request = input.request;
|
|
349
|
+
if (request.type === "ForSharedWorker") {
|
|
250
350
|
const { owner, inputMessage } = request.message;
|
|
251
|
-
|
|
252
351
|
void dbWorkerRun(async (run) => {
|
|
253
|
-
storage.setRequestContext(owner.encryptionKey);
|
|
254
|
-
|
|
352
|
+
storage.setRequestContext(owner.encryptionKey, context);
|
|
255
353
|
const result = await run.abortable(
|
|
256
354
|
applyProtocolMessageAsClient(inputMessage, {
|
|
257
355
|
writeKey: owner.writeKey,
|
|
258
356
|
}),
|
|
259
357
|
);
|
|
260
|
-
|
|
261
358
|
postQueuedResponse({
|
|
262
359
|
type: "ForSharedWorker",
|
|
263
360
|
message: {
|
|
264
361
|
type: "ApplySyncMessage",
|
|
362
|
+
clock: context.clock.get(),
|
|
265
363
|
ownerId: owner.id,
|
|
266
364
|
didWriteMessages: storage.didWriteMessages(),
|
|
267
365
|
result,
|
|
268
366
|
},
|
|
269
367
|
});
|
|
270
|
-
|
|
271
368
|
return ok();
|
|
272
369
|
});
|
|
273
|
-
|
|
370
|
+
} else {
|
|
371
|
+
postQueuedResponse({
|
|
372
|
+
type: "ForEvolu",
|
|
373
|
+
id: request.id,
|
|
374
|
+
message: handleMutation({
|
|
375
|
+
...dbDeps,
|
|
376
|
+
clock: context.clock,
|
|
377
|
+
})(request.message, now),
|
|
378
|
+
});
|
|
274
379
|
}
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
275
382
|
|
|
383
|
+
const request = input.request;
|
|
384
|
+
if (request.type === "ForSharedWorker") {
|
|
276
385
|
const protocolMessagesByOwnerId = new Map<
|
|
277
386
|
OwnerId,
|
|
278
387
|
ProtocolMessage
|
|
279
388
|
>();
|
|
389
|
+
const failedOwnerIds = new Set<OwnerId>();
|
|
280
390
|
|
|
391
|
+
// An unanswered attempt would block the tenant queue, so a failed
|
|
392
|
+
// owner is logged and reported instead of thrown.
|
|
281
393
|
for (const owner of request.message.owners) {
|
|
282
394
|
storage.setRequestContext(owner.encryptionKey);
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
storage
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
395
|
+
try {
|
|
396
|
+
protocolMessagesByOwnerId.set(
|
|
397
|
+
owner.id,
|
|
398
|
+
createProtocolMessageForSync({ storage })(
|
|
399
|
+
owner.id,
|
|
400
|
+
SubscriptionFlags.Subscribe,
|
|
401
|
+
),
|
|
402
|
+
);
|
|
403
|
+
} catch (error) {
|
|
404
|
+
deps.console.error(error);
|
|
405
|
+
failedOwnerIds.add(owner.id);
|
|
406
|
+
}
|
|
290
407
|
}
|
|
291
408
|
|
|
292
409
|
postQueuedResponse({
|
|
@@ -294,6 +411,7 @@ export const startDbWorker =
|
|
|
294
411
|
message: {
|
|
295
412
|
type: "CreateSyncMessages",
|
|
296
413
|
protocolMessagesByOwnerId,
|
|
414
|
+
failedOwnerIds,
|
|
297
415
|
},
|
|
298
416
|
});
|
|
299
417
|
return;
|
|
@@ -320,23 +438,7 @@ export const startDbWorker =
|
|
|
320
438
|
file: sqlite.export(),
|
|
321
439
|
},
|
|
322
440
|
});
|
|
323
|
-
return;
|
|
324
441
|
}
|
|
325
|
-
|
|
326
|
-
const result = handleMutation({ ...dbDeps, clock })(request.message);
|
|
327
|
-
if (!result.ok) {
|
|
328
|
-
consoleEntryOrErrorBroadcastChannel.postMessage({
|
|
329
|
-
type: "Error",
|
|
330
|
-
error: result.error,
|
|
331
|
-
});
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
postQueuedResponse({
|
|
336
|
-
type: "ForEvolu",
|
|
337
|
-
id: request.id,
|
|
338
|
-
message: result.value,
|
|
339
|
-
});
|
|
340
442
|
};
|
|
341
443
|
|
|
342
444
|
return () => {
|
|
@@ -348,64 +450,135 @@ export const startDbWorker =
|
|
|
348
450
|
return ok();
|
|
349
451
|
};
|
|
350
452
|
|
|
351
|
-
/**
|
|
352
|
-
* Hybrid Logical Clock. Keeps the current timestamp in memory to avoid frequent
|
|
353
|
-
* SQLite reads.
|
|
354
|
-
*/
|
|
453
|
+
/** Clock state owned by one write request; published only after commit. */
|
|
355
454
|
interface Clock {
|
|
356
455
|
readonly get: () => Timestamp;
|
|
357
|
-
readonly
|
|
456
|
+
readonly set: (timestamp: Timestamp) => void;
|
|
358
457
|
}
|
|
359
458
|
|
|
360
459
|
interface ClockDep {
|
|
361
460
|
readonly clock: Clock;
|
|
362
461
|
}
|
|
363
462
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
let currentTimestamp: Timestamp;
|
|
463
|
+
interface WriteContext extends ClockDep {
|
|
464
|
+
readonly now: Millis;
|
|
465
|
+
}
|
|
368
466
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
467
|
+
/**
|
|
468
|
+
* Persists `timestamp` only when it is greater than the stored clock.
|
|
469
|
+
*
|
|
470
|
+
* Requests report their computed clock, which can be older than the stored
|
|
471
|
+
* clock when replayed after startup release. The SQL guard keeps the stored
|
|
472
|
+
* clock from moving backwards; the SharedWorker adopts response clocks only
|
|
473
|
+
* when newer than its session clock.
|
|
474
|
+
*
|
|
475
|
+
* Timestamp bytes sort like their timestamps and SQLite compares blobs byte by
|
|
476
|
+
* byte, so persistence takes one statement even when the clock does not
|
|
477
|
+
* advance.
|
|
478
|
+
*
|
|
479
|
+
* Local-only mutations and sync requests that do not invoke `writeMessages`
|
|
480
|
+
* skip this. Successfully processed batches in `writeMessages` call this even
|
|
481
|
+
* when every message is duplicated or quarantined. Duplicate receipts within
|
|
482
|
+
* the drift limit can advance the clock without storing new messages.
|
|
483
|
+
*/
|
|
484
|
+
const saveClock =
|
|
485
|
+
(deps: SqliteDep) =>
|
|
486
|
+
(timestamp: Timestamp): void => {
|
|
487
|
+
const bytes = timestampToTimestampBytes(timestamp);
|
|
488
|
+
deps.sqlite.exec(sql.prepared`
|
|
489
|
+
update evolu_config
|
|
490
|
+
set "clock" = ${bytes}
|
|
491
|
+
where "clock" < ${bytes};
|
|
492
|
+
`);
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Checks the stored database version inside the startup transaction, before any
|
|
497
|
+
* other read. The legacy layout, one `protocolVersion` row that was always 1,
|
|
498
|
+
* is converted to database version 1 first. An older database is migrated one
|
|
499
|
+
* version at a time, and the record is updated in the same transaction, so a
|
|
500
|
+
* failed migration leaves both data and version unchanged.
|
|
501
|
+
*/
|
|
502
|
+
const ensureDbVersion =
|
|
503
|
+
({ sqlite }: SqliteDep) =>
|
|
504
|
+
(
|
|
505
|
+
versionColumns: ReadonlySet<string>,
|
|
506
|
+
): Result<void, UnsupportedDbVersionError> => {
|
|
507
|
+
if (!versionColumns.has("dbVersion")) {
|
|
508
|
+
sqlite.exec(sql`
|
|
509
|
+
alter table evolu_version
|
|
510
|
+
rename column "protocolVersion" to "dbVersion";
|
|
374
511
|
`);
|
|
375
|
-
assertNonEmptyReadonlyArray(rows);
|
|
376
|
-
currentTimestamp = timestampBytesToTimestamp(firstInArray(rows).clock);
|
|
377
|
-
} else {
|
|
378
|
-
currentTimestamp = createInitialTimestamp(deps);
|
|
379
512
|
}
|
|
380
513
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
514
|
+
const { rows } = sqlite.exec<{ dbVersion: PositiveInt }>(sql`
|
|
515
|
+
select "dbVersion" from evolu_version;
|
|
516
|
+
`);
|
|
517
|
+
const storedVersion = rows[0].dbVersion;
|
|
518
|
+
if (storedVersion > dbVersion) {
|
|
519
|
+
return err({
|
|
520
|
+
type: "UnsupportedDbVersionError",
|
|
521
|
+
storedVersion,
|
|
522
|
+
supportedVersion: dbVersion,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
if (storedVersion < dbVersion) {
|
|
526
|
+
if (storedVersion < 2) migrateToVersion2({ sqlite });
|
|
527
|
+
sqlite.exec(sql`update evolu_version set "dbVersion" = ${dbVersion};`);
|
|
528
|
+
}
|
|
529
|
+
return ok();
|
|
393
530
|
};
|
|
394
531
|
|
|
532
|
+
/**
|
|
533
|
+
* Version 2 records why a message is quarantined, whether this database stamped
|
|
534
|
+
* or received it, and when, and adds the index that startup release reads. Rows
|
|
535
|
+
* from version 1 get the defaults: schema quarantine of a received message with
|
|
536
|
+
* an unknown quarantine time. See {@link QuarantineReason}.
|
|
537
|
+
*/
|
|
538
|
+
const migrateToVersion2 = ({ sqlite }: SqliteDep): void => {
|
|
539
|
+
for (const query of [
|
|
540
|
+
sql`
|
|
541
|
+
alter table evolu_message_quarantine
|
|
542
|
+
add column "reason" integer not null default ${sql.raw(
|
|
543
|
+
String(QuarantineReason.Schema),
|
|
544
|
+
)};
|
|
545
|
+
`,
|
|
546
|
+
sql`
|
|
547
|
+
alter table evolu_message_quarantine
|
|
548
|
+
add column "origin" integer not null default ${sql.raw(
|
|
549
|
+
String(QuarantineOrigin.ReceivedMessage),
|
|
550
|
+
)};
|
|
551
|
+
`,
|
|
552
|
+
sql`
|
|
553
|
+
alter table evolu_message_quarantine
|
|
554
|
+
add column "quarantinedAt" integer;
|
|
555
|
+
`,
|
|
556
|
+
sql`
|
|
557
|
+
create index evolu_message_quarantine_reason_timestamp on evolu_message_quarantine (
|
|
558
|
+
"reason",
|
|
559
|
+
"timestamp"
|
|
560
|
+
);
|
|
561
|
+
`,
|
|
562
|
+
]) {
|
|
563
|
+
sqlite.exec(query);
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
|
|
395
567
|
const initializeDb =
|
|
396
568
|
({ sqlite }: SqliteDep) =>
|
|
397
569
|
(initialClock: Timestamp): void => {
|
|
398
570
|
for (const query of [
|
|
571
|
+
// The database version record; see the module documentation.
|
|
399
572
|
sql`
|
|
400
573
|
create table evolu_version (
|
|
401
|
-
"
|
|
574
|
+
"dbVersion" integer not null
|
|
402
575
|
)
|
|
403
576
|
strict;
|
|
404
577
|
`,
|
|
405
578
|
|
|
406
579
|
sql`
|
|
407
|
-
insert into evolu_version ("
|
|
408
|
-
values (${
|
|
580
|
+
insert into evolu_version ("dbVersion")
|
|
581
|
+
values (${dbVersion});
|
|
409
582
|
`,
|
|
410
583
|
|
|
411
584
|
sql`
|
|
@@ -458,7 +631,7 @@ const initializeDb =
|
|
|
458
631
|
`,
|
|
459
632
|
|
|
460
633
|
/**
|
|
461
|
-
* Stores messages with
|
|
634
|
+
* Stores unapplied messages with their quarantine reason.
|
|
462
635
|
*
|
|
463
636
|
* When a device receives sync messages containing tables or columns that
|
|
464
637
|
* don't exist in its current schema (e.g., from a newer app version),
|
|
@@ -470,6 +643,16 @@ const initializeDb =
|
|
|
470
643
|
* 3. Partial messages work - known columns go to app tables, unknown to
|
|
471
644
|
* quarantine
|
|
472
645
|
*
|
|
646
|
+
* Clock-drift quarantine preserves every column of the affected message.
|
|
647
|
+
* It is released at startup once system time comes within the drift limit
|
|
648
|
+
* of the message's timestamp; see the Timestamp module.
|
|
649
|
+
*
|
|
650
|
+
* Each row records why it was not applied (`reason`), whether this
|
|
651
|
+
* database stamped the message for a local mutation or received it
|
|
652
|
+
* (`origin`), and the captured system time of the request that
|
|
653
|
+
* quarantined it (`quarantinedAt`). Quarantine is not reported as an
|
|
654
|
+
* error; applications watch this table through queries.
|
|
655
|
+
*
|
|
473
656
|
* The `union all` query in `readDbChange` combines `evolu_history` and
|
|
474
657
|
* this table, ensuring all data (known and unknown) is included when
|
|
475
658
|
* syncing to other devices.
|
|
@@ -482,6 +665,13 @@ const initializeDb =
|
|
|
482
665
|
"id" blob not null,
|
|
483
666
|
"column" text not null,
|
|
484
667
|
"value" any,
|
|
668
|
+
"reason" integer not null default ${sql.raw(
|
|
669
|
+
String(QuarantineReason.Schema),
|
|
670
|
+
)},
|
|
671
|
+
"origin" integer not null default ${sql.raw(
|
|
672
|
+
String(QuarantineOrigin.ReceivedMessage),
|
|
673
|
+
)},
|
|
674
|
+
"quarantinedAt" integer,
|
|
485
675
|
primary key ("ownerId", "timestamp", "table", "id", "column")
|
|
486
676
|
)
|
|
487
677
|
strict;
|
|
@@ -491,26 +681,36 @@ const initializeDb =
|
|
|
491
681
|
}
|
|
492
682
|
|
|
493
683
|
createBaseSqliteStorageTables({ sqlite });
|
|
684
|
+
|
|
685
|
+
// Startup release reads drift quarantine by reason and timestamp. Created
|
|
686
|
+
// last, as the migration creates it, so Evolu's own indexes are listed in one
|
|
687
|
+
// order in fresh and migrated databases.
|
|
688
|
+
sqlite.exec(sql`
|
|
689
|
+
create index evolu_message_quarantine_reason_timestamp on evolu_message_quarantine (
|
|
690
|
+
"reason",
|
|
691
|
+
"timestamp"
|
|
692
|
+
);
|
|
693
|
+
`);
|
|
494
694
|
};
|
|
495
695
|
|
|
496
696
|
const tryApplyQuarantinedMessages = (
|
|
497
697
|
deps: SqliteDep & SqliteSchemaDep,
|
|
498
698
|
): void => {
|
|
499
|
-
const rows = deps.sqlite.exec<{
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
699
|
+
const { rows } = deps.sqlite.exec<{
|
|
700
|
+
ownerId: OwnerIdBytes;
|
|
701
|
+
timestamp: TimestampBytes;
|
|
702
|
+
table: string;
|
|
703
|
+
id: IdBytes;
|
|
704
|
+
column: string;
|
|
705
|
+
value: SqliteValue;
|
|
506
706
|
}>(sql`
|
|
507
707
|
select "ownerId", "timestamp", "table", "id", "column", "value"
|
|
508
|
-
from evolu_message_quarantine
|
|
708
|
+
from evolu_message_quarantine
|
|
709
|
+
where "reason" = ${QuarantineReason.Schema};
|
|
509
710
|
`);
|
|
510
711
|
|
|
511
|
-
for (const row of rows
|
|
712
|
+
for (const row of rows) {
|
|
512
713
|
if (!validateColumnValue(deps)(row.table, row.column, row.value)) continue;
|
|
513
|
-
|
|
514
714
|
applyColumnChange(deps)(
|
|
515
715
|
row.ownerId,
|
|
516
716
|
ownerIdBytesToOwnerId(row.ownerId),
|
|
@@ -522,7 +722,7 @@ const tryApplyQuarantinedMessages = (
|
|
|
522
722
|
row.timestamp,
|
|
523
723
|
);
|
|
524
724
|
|
|
525
|
-
deps.sqlite.exec(sql`
|
|
725
|
+
deps.sqlite.exec(sql.prepared`
|
|
526
726
|
delete from evolu_message_quarantine
|
|
527
727
|
where
|
|
528
728
|
"ownerId" = ${row.ownerId}
|
|
@@ -534,9 +734,73 @@ const tryApplyQuarantinedMessages = (
|
|
|
534
734
|
}
|
|
535
735
|
};
|
|
536
736
|
|
|
737
|
+
/**
|
|
738
|
+
* Moves drift quarantine within the drift limit to schema quarantine for
|
|
739
|
+
* application. Advances `clock` once per distinct timestamp in timestamp order,
|
|
740
|
+
* using one captured system time, so later local changes sort after released
|
|
741
|
+
* messages. Only timestamps within the drift limit are loaded. Returns the
|
|
742
|
+
* advanced clock, or `null` when nothing was released. Runs inside the startup
|
|
743
|
+
* transaction, before saving the clock and applying schema quarantine, so the
|
|
744
|
+
* SharedWorker learns the clock only after release commits.
|
|
745
|
+
*/
|
|
746
|
+
const releaseDriftQuarantine =
|
|
747
|
+
(deps: SqliteDep & TimeDep & TimestampConfigDep) =>
|
|
748
|
+
(clock: Timestamp): Timestamp | null => {
|
|
749
|
+
const now = deps.time.now();
|
|
750
|
+
// Milliseconds are integers; floor the allowance before adding it so a
|
|
751
|
+
// fractional allowance cannot round up near the timestamp range ceiling.
|
|
752
|
+
const maxReleaseMillis = now + Math.floor(deps.timestampConfig.maxDrift);
|
|
753
|
+
assertType(NonNaNNumber, maxReleaseMillis);
|
|
754
|
+
const bound = timestampToTimestampBytes({
|
|
755
|
+
millis: saturateMillis(maxReleaseMillis),
|
|
756
|
+
counter: maxCounter,
|
|
757
|
+
nodeId: maxNodeId,
|
|
758
|
+
});
|
|
759
|
+
const { rows } = deps.sqlite.exec<{ timestamp: TimestampBytes }>(sql`
|
|
760
|
+
select distinct "timestamp"
|
|
761
|
+
from evolu_message_quarantine
|
|
762
|
+
where
|
|
763
|
+
"reason" = ${QuarantineReason.TimestampDrift}
|
|
764
|
+
and "timestamp" <= ${bound}
|
|
765
|
+
order by "timestamp";
|
|
766
|
+
`);
|
|
767
|
+
if (rows.length === 0) return null;
|
|
768
|
+
|
|
769
|
+
const receive = receiveTimestamp(deps);
|
|
770
|
+
let nextClock = clock;
|
|
771
|
+
for (const { timestamp } of rows) {
|
|
772
|
+
const remote = timestampBytesToTimestamp(timestamp);
|
|
773
|
+
const next = receive(nextClock, remote, now);
|
|
774
|
+
if (next.ok) {
|
|
775
|
+
nextClock = next.value;
|
|
776
|
+
} else {
|
|
777
|
+
assert(
|
|
778
|
+
next.error.cause === "local",
|
|
779
|
+
"The query bound excludes remote drift at the captured time.",
|
|
780
|
+
);
|
|
781
|
+
nextClock = next.error.timestamp;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Drift checks passed; mark these rows for the next schema pass.
|
|
786
|
+
deps.sqlite.exec(sql`
|
|
787
|
+
update evolu_message_quarantine
|
|
788
|
+
set "reason" = ${QuarantineReason.Schema}
|
|
789
|
+
where
|
|
790
|
+
"reason" = ${QuarantineReason.TimestampDrift}
|
|
791
|
+
and "timestamp" <= ${bound};
|
|
792
|
+
`);
|
|
793
|
+
|
|
794
|
+
return nextClock;
|
|
795
|
+
};
|
|
796
|
+
|
|
537
797
|
const validateColumnValue =
|
|
538
798
|
(deps: SqliteSchemaDep) =>
|
|
539
799
|
(table: string, column: string, _value: SqliteValue): boolean => {
|
|
800
|
+
// Local-only tables never sync, so a received change to one comes from
|
|
801
|
+
// non-standard code. It stays in quarantine, stored for sync but never
|
|
802
|
+
// applied.
|
|
803
|
+
if (isLocalOnlyTable(table)) return false;
|
|
540
804
|
const schemaColumns = getOwnProp(deps.sqliteSchema.tables, table);
|
|
541
805
|
return (
|
|
542
806
|
schemaColumns != null &&
|
|
@@ -604,237 +868,231 @@ const applyColumnChange =
|
|
|
604
868
|
* implementation, and switch owner encryption keys between requests.
|
|
605
869
|
*/
|
|
606
870
|
interface ClientStorage extends Storage, BaseSqliteStorage {
|
|
607
|
-
readonly setRequestContext: (
|
|
871
|
+
readonly setRequestContext: (
|
|
872
|
+
encryptionKey: EncryptionKey,
|
|
873
|
+
writeContext?: WriteContext,
|
|
874
|
+
) => void;
|
|
608
875
|
readonly didWriteMessages: () => boolean;
|
|
609
876
|
}
|
|
610
877
|
|
|
611
|
-
const createClientStorage =
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
| TimestampCounterOverflowError
|
|
630
|
-
| TimestampDriftError
|
|
631
|
-
| TimestampTimeOutOfRangeError,
|
|
632
|
-
) => void;
|
|
633
|
-
}): ClientStorage => {
|
|
634
|
-
let encryptionKey: EncryptionKey | null = null;
|
|
635
|
-
let didWriteMessages = false;
|
|
636
|
-
|
|
637
|
-
const getEncryptionKey = (): EncryptionKey => {
|
|
638
|
-
assertNonNullable(
|
|
639
|
-
encryptionKey,
|
|
640
|
-
"ClientStorage encryption key must be set",
|
|
641
|
-
);
|
|
642
|
-
return encryptionKey;
|
|
643
|
-
};
|
|
644
|
-
|
|
645
|
-
return {
|
|
646
|
-
...deps.baseSqliteStorage,
|
|
878
|
+
const createClientStorage = (
|
|
879
|
+
deps: BaseSqliteStorageDep &
|
|
880
|
+
RandomBytesDep &
|
|
881
|
+
SqliteDep &
|
|
882
|
+
SqliteSchemaDep &
|
|
883
|
+
TimestampConfigDep,
|
|
884
|
+
): ClientStorage => {
|
|
885
|
+
let encryptionKey: EncryptionKey | null = null;
|
|
886
|
+
let didWriteMessages = false;
|
|
887
|
+
let writeContext: WriteContext | undefined;
|
|
888
|
+
|
|
889
|
+
const getEncryptionKey = (): EncryptionKey => {
|
|
890
|
+
assertNonNullable(
|
|
891
|
+
encryptionKey,
|
|
892
|
+
"ClientStorage encryption key must be set",
|
|
893
|
+
);
|
|
894
|
+
return encryptionKey;
|
|
895
|
+
};
|
|
647
896
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
// This is safe because the worker handles one message at a time. We will
|
|
651
|
-
// refactor it later, we will probably have to change Protocol API.
|
|
652
|
-
setRequestContext: (nextEncryptionKey) => {
|
|
653
|
-
encryptionKey = nextEncryptionKey;
|
|
654
|
-
didWriteMessages = false;
|
|
655
|
-
},
|
|
897
|
+
return {
|
|
898
|
+
...deps.baseSqliteStorage,
|
|
656
899
|
|
|
657
|
-
|
|
900
|
+
// SharedWorker waits for the response before dispatching another request,
|
|
901
|
+
// so asynchronous sync processing cannot overlap this request context.
|
|
902
|
+
setRequestContext: (nextEncryptionKey, nextWriteContext) => {
|
|
903
|
+
encryptionKey = nextEncryptionKey;
|
|
904
|
+
writeContext = nextWriteContext;
|
|
905
|
+
didWriteMessages = false;
|
|
906
|
+
},
|
|
658
907
|
|
|
659
|
-
|
|
660
|
-
validateWriteKey: constFalse,
|
|
661
|
-
setWriteKey: constVoid,
|
|
908
|
+
didWriteMessages: () => didWriteMessages,
|
|
662
909
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
// check if this owner is within quota before accepting the data.
|
|
667
|
-
// This prevents an owner from exceeding storage limits when receiving
|
|
668
|
-
// data shared by other collaborators.
|
|
910
|
+
// Not implemented yet.
|
|
911
|
+
validateWriteKey: constFalse,
|
|
912
|
+
setWriteKey: constVoid,
|
|
669
913
|
|
|
670
|
-
|
|
671
|
-
|
|
914
|
+
writeMessages: (ownerIdBytes, encryptedMessages) => () => {
|
|
915
|
+
// TODO: Add quota checking for collaborative scenarios.
|
|
916
|
+
// When receiving messages from other owners via relay broadcast,
|
|
917
|
+
// check if this owner is within quota before accepting the data.
|
|
918
|
+
// This prevents an owner from exceeding storage limits when receiving
|
|
919
|
+
// data shared by other collaborators.
|
|
672
920
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
message,
|
|
676
|
-
currentEncryptionKey,
|
|
677
|
-
);
|
|
678
|
-
if (!change.ok) {
|
|
679
|
-
onError(change.error);
|
|
680
|
-
return ok();
|
|
681
|
-
}
|
|
682
|
-
messages.push({ timestamp: message.timestamp, change: change.value });
|
|
683
|
-
}
|
|
921
|
+
const messages: Array<CrdtMessage> = [];
|
|
922
|
+
const currentEncryptionKey = getEncryptionKey();
|
|
684
923
|
|
|
685
|
-
|
|
924
|
+
for (const message of encryptedMessages) {
|
|
925
|
+
const change = decryptAndDecodeDbChange(message, currentEncryptionKey);
|
|
926
|
+
if (!change.ok) return err(change.error);
|
|
927
|
+
messages.push({ timestamp: message.timestamp, change: change.value });
|
|
928
|
+
}
|
|
686
929
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
930
|
+
assertNonNullable(writeContext);
|
|
931
|
+
const { clock, now } = writeContext;
|
|
932
|
+
let clockTimestamp = clock.get();
|
|
933
|
+
const receive = receiveTimestamp(deps);
|
|
934
|
+
|
|
935
|
+
// The clock is computed over every message, duplicates included, so a
|
|
936
|
+
// retry with the same inputs reports the same clock. Writes for
|
|
937
|
+
// timestamps already in the owner's set are skipped by applyMessages.
|
|
938
|
+
for (const message of messages) {
|
|
939
|
+
const nextTimestamp = receive(clockTimestamp, message.timestamp, now);
|
|
940
|
+
if (!nextTimestamp.ok) {
|
|
941
|
+
if (nextTimestamp.error.cause === "remote") continue;
|
|
942
|
+
clockTimestamp = nextTimestamp.error.timestamp;
|
|
943
|
+
} else clockTimestamp = nextTimestamp.value;
|
|
944
|
+
}
|
|
698
945
|
|
|
699
|
-
|
|
946
|
+
assertNonEmptyReadonlyArray(messages);
|
|
700
947
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
948
|
+
let wroteNewMessages = false;
|
|
949
|
+
deps.sqlite.transaction(() => {
|
|
950
|
+
wroteNewMessages = applyMessages(deps)(
|
|
951
|
+
ownerIdBytesToOwnerId(ownerIdBytes),
|
|
952
|
+
messages,
|
|
953
|
+
QuarantineOrigin.ReceivedMessage,
|
|
954
|
+
now,
|
|
955
|
+
);
|
|
956
|
+
saveClock(deps)(clockTimestamp);
|
|
957
|
+
});
|
|
958
|
+
clock.set(clockTimestamp);
|
|
959
|
+
// A batch of duplicates changes no table, so queries need no refresh.
|
|
960
|
+
if (wroteNewMessages) didWriteMessages = true;
|
|
961
|
+
return ok();
|
|
962
|
+
},
|
|
708
963
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
964
|
+
readDbChange: (ownerId, timestamp) => {
|
|
965
|
+
const result = deps.sqlite.exec<{
|
|
966
|
+
readonly table: string;
|
|
967
|
+
readonly id: IdBytes;
|
|
968
|
+
readonly column: string;
|
|
969
|
+
readonly value: SqliteValue;
|
|
970
|
+
}>(sql`
|
|
971
|
+
select "table", "id", "column", "value"
|
|
972
|
+
from evolu_history
|
|
973
|
+
where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}
|
|
974
|
+
union all
|
|
975
|
+
select "table", "id", "column", "value"
|
|
976
|
+
from evolu_message_quarantine
|
|
977
|
+
where "ownerId" = ${ownerId} and "timestamp" = ${timestamp};
|
|
978
|
+
`);
|
|
724
979
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
}
|
|
980
|
+
const { rows } = result;
|
|
981
|
+
assertNonEmptyReadonlyArray(rows, "Every timestamp must have rows");
|
|
982
|
+
const firstRow = firstInArray(rows);
|
|
983
|
+
|
|
984
|
+
const values = createMutableRecord<string, SqliteValue>();
|
|
985
|
+
let isInsert: DbChange["isInsert"] = false;
|
|
986
|
+
let isDelete: DbChange["isDelete"] = null;
|
|
987
|
+
|
|
988
|
+
for (const r of rows) {
|
|
989
|
+
switch (r.column) {
|
|
990
|
+
case "createdAt":
|
|
991
|
+
isInsert = true;
|
|
992
|
+
break;
|
|
993
|
+
case "updatedAt":
|
|
994
|
+
isInsert = false;
|
|
995
|
+
break;
|
|
996
|
+
case "isDeleted":
|
|
997
|
+
assertType(SqliteBoolean, r.value);
|
|
998
|
+
isDelete = sqliteBooleanToBoolean(r.value);
|
|
999
|
+
break;
|
|
1000
|
+
default:
|
|
1001
|
+
values[r.column] = r.value;
|
|
748
1002
|
}
|
|
1003
|
+
}
|
|
749
1004
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
};
|
|
1005
|
+
const message: CrdtMessage = {
|
|
1006
|
+
timestamp: timestampBytesToTimestamp(timestamp),
|
|
1007
|
+
change: DbChange.orThrow({
|
|
1008
|
+
table: firstRow.table,
|
|
1009
|
+
id: idBytesToId(firstRow.id),
|
|
1010
|
+
values,
|
|
1011
|
+
isInsert,
|
|
1012
|
+
isDelete,
|
|
1013
|
+
}),
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
return encodeAndEncryptDbChange(deps)(message, getEncryptionKey());
|
|
1017
|
+
},
|
|
764
1018
|
};
|
|
1019
|
+
};
|
|
765
1020
|
|
|
766
1021
|
const handleMutation =
|
|
767
1022
|
(
|
|
768
1023
|
deps: BaseSqliteStorageDep &
|
|
769
1024
|
ClockDep &
|
|
770
|
-
SqliteSchemaDep &
|
|
771
|
-
RandomBytesDep &
|
|
772
1025
|
SqliteDep &
|
|
773
|
-
|
|
1026
|
+
SqliteSchemaDep &
|
|
774
1027
|
TimestampConfigDep,
|
|
775
1028
|
) =>
|
|
776
1029
|
(
|
|
777
|
-
message: ExtractTyped<
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
const messagesByOwnerId = new Map<OwnerId, NonEmptyArray<CrdtMessage>>();
|
|
796
|
-
let clockTimestamp = deps.clock.get();
|
|
797
|
-
let clockChanged = false;
|
|
798
|
-
|
|
799
|
-
for (const change of message.changes) {
|
|
800
|
-
if (change.table.startsWith("_")) {
|
|
801
|
-
applyLocalOnlyChange(deps)(change);
|
|
802
|
-
continue;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
const nextTimestamp = sendTimestamp(deps)(clockTimestamp);
|
|
806
|
-
if (!nextTimestamp.ok) return nextTimestamp;
|
|
1030
|
+
message: ExtractTyped<EvoluInput, "Mutate">,
|
|
1031
|
+
now: Millis,
|
|
1032
|
+
): {
|
|
1033
|
+
readonly type: "Mutate";
|
|
1034
|
+
readonly clock: Timestamp;
|
|
1035
|
+
readonly messagesByOwnerId: ReadonlyMap<
|
|
1036
|
+
OwnerId,
|
|
1037
|
+
NonEmptyReadonlyArray<CrdtMessage>
|
|
1038
|
+
>;
|
|
1039
|
+
readonly rowsByQuery: RowsByQueryMap;
|
|
1040
|
+
} =>
|
|
1041
|
+
getOk(
|
|
1042
|
+
deps.sqlite.transaction(() => {
|
|
1043
|
+
const messagesByOwnerId = new Map<
|
|
1044
|
+
OwnerId,
|
|
1045
|
+
NonEmptyArray<CrdtMessage>
|
|
1046
|
+
>();
|
|
1047
|
+
let clockTimestamp = deps.clock.get();
|
|
807
1048
|
|
|
808
|
-
|
|
809
|
-
|
|
1049
|
+
for (const change of message.changes) {
|
|
1050
|
+
if (isLocalOnlyTable(change.table)) {
|
|
1051
|
+
applyLocalOnlyChange(deps)(change, now);
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
810
1054
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1055
|
+
// A drifted change still receives the next timestamp; applyMessages
|
|
1056
|
+
// stores it in quarantine instead of its table.
|
|
1057
|
+
const nextTimestamp = sendTimestamp(deps)(clockTimestamp, now);
|
|
1058
|
+
clockTimestamp = nextTimestamp.ok
|
|
1059
|
+
? nextTimestamp.value
|
|
1060
|
+
: nextTimestamp.error.timestamp;
|
|
1061
|
+
|
|
1062
|
+
const { ownerId, ...dbChange } = change;
|
|
1063
|
+
const message: CrdtMessage = {
|
|
1064
|
+
timestamp: clockTimestamp,
|
|
1065
|
+
change: dbChange,
|
|
1066
|
+
};
|
|
816
1067
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1068
|
+
const messages = messagesByOwnerId.get(ownerId);
|
|
1069
|
+
if (messages) messages.push(message);
|
|
1070
|
+
else messagesByOwnerId.set(ownerId, [message]);
|
|
1071
|
+
}
|
|
821
1072
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1073
|
+
for (const [ownerId, messages] of messagesByOwnerId) {
|
|
1074
|
+
applyMessages(deps)(
|
|
1075
|
+
ownerId,
|
|
1076
|
+
messages,
|
|
1077
|
+
QuarantineOrigin.LocalMutation,
|
|
1078
|
+
now,
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
825
1081
|
|
|
826
|
-
|
|
1082
|
+
if (messagesByOwnerId.size > 0) saveClock(deps)(clockTimestamp);
|
|
827
1083
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1084
|
+
return ok({
|
|
1085
|
+
type: "Mutate",
|
|
1086
|
+
clock: clockTimestamp,
|
|
1087
|
+
messagesByOwnerId,
|
|
1088
|
+
rowsByQuery: loadQueries(deps)(message.subscribedQueries),
|
|
1089
|
+
});
|
|
1090
|
+
}),
|
|
1091
|
+
);
|
|
834
1092
|
|
|
835
1093
|
const applyLocalOnlyChange =
|
|
836
|
-
(deps: SqliteDep
|
|
837
|
-
(change: MutationChange): void => {
|
|
1094
|
+
(deps: SqliteDep) =>
|
|
1095
|
+
(change: MutationChange, now: Millis): void => {
|
|
838
1096
|
if (change.isDelete) {
|
|
839
1097
|
deps.sqlite.exec(sql`
|
|
840
1098
|
delete from ${sql.identifier(change.table)}
|
|
@@ -842,7 +1100,7 @@ const applyLocalOnlyChange =
|
|
|
842
1100
|
`);
|
|
843
1101
|
} else {
|
|
844
1102
|
const ownerId = change.ownerId;
|
|
845
|
-
const columns = dbChangeToColumns(change,
|
|
1103
|
+
const columns = dbChangeToColumns(change, now);
|
|
846
1104
|
|
|
847
1105
|
for (const [column, value] of columns) {
|
|
848
1106
|
assertNotUndefined(value);
|
|
@@ -857,10 +1115,28 @@ const applyLocalOnlyChange =
|
|
|
857
1115
|
}
|
|
858
1116
|
};
|
|
859
1117
|
|
|
1118
|
+
/**
|
|
1119
|
+
* Stores messages for an owner and applies them to their tables. Drifted
|
|
1120
|
+
* messages and columns the schema does not define go to quarantine instead.
|
|
1121
|
+
* Uses the request's captured time to classify drift, matching timestamp
|
|
1122
|
+
* generation. Returns whether any message was new; the rest were stored
|
|
1123
|
+
* before.
|
|
1124
|
+
*/
|
|
860
1125
|
const applyMessages =
|
|
861
|
-
(
|
|
862
|
-
|
|
1126
|
+
(
|
|
1127
|
+
deps: BaseSqliteStorageDep &
|
|
1128
|
+
SqliteDep &
|
|
1129
|
+
SqliteSchemaDep &
|
|
1130
|
+
TimestampConfigDep,
|
|
1131
|
+
) =>
|
|
1132
|
+
(
|
|
1133
|
+
ownerId: OwnerId,
|
|
1134
|
+
messages: NonEmptyReadonlyArray<CrdtMessage>,
|
|
1135
|
+
origin: QuarantineOrigin,
|
|
1136
|
+
now: Millis,
|
|
1137
|
+
): boolean => {
|
|
863
1138
|
const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
|
|
1139
|
+
let wroteNewMessages = false;
|
|
864
1140
|
|
|
865
1141
|
const usage = readOwnerUsageOrDefault(deps)(
|
|
866
1142
|
ownerIdBytes,
|
|
@@ -870,13 +1146,36 @@ const applyMessages =
|
|
|
870
1146
|
let { firstTimestamp, lastTimestamp } = usage;
|
|
871
1147
|
|
|
872
1148
|
for (const { timestamp, change } of messages) {
|
|
1149
|
+
const timestampBytes = timestampToTimestampBytes(timestamp);
|
|
1150
|
+
|
|
1151
|
+
let strategy;
|
|
1152
|
+
[strategy, firstTimestamp, lastTimestamp] = getTimestampInsertStrategy(
|
|
1153
|
+
timestampBytes,
|
|
1154
|
+
firstTimestamp,
|
|
1155
|
+
lastTimestamp,
|
|
1156
|
+
);
|
|
1157
|
+
|
|
1158
|
+
// A timestamp already in the set was applied or quarantined before.
|
|
1159
|
+
// Skipping it preserves that decision and makes duplicate delivery and
|
|
1160
|
+
// retries idempotent without a separate lookup.
|
|
1161
|
+
const isNew = deps.baseSqliteStorage.insertTimestamp(
|
|
1162
|
+
ownerIdBytes,
|
|
1163
|
+
timestampBytes,
|
|
1164
|
+
strategy,
|
|
1165
|
+
);
|
|
1166
|
+
if (!isNew) continue;
|
|
1167
|
+
wroteNewMessages = true;
|
|
1168
|
+
|
|
1169
|
+
const hasDrift = isTimestampBeyondMaxDrift(deps)(timestamp.millis, now);
|
|
873
1170
|
const columns = dbChangeToColumns(change, timestamp.millis);
|
|
874
1171
|
const idBytes = idToIdBytes(change.id);
|
|
875
|
-
const timestampBytes = timestampToTimestampBytes(timestamp);
|
|
876
1172
|
|
|
877
1173
|
for (const [column, value] of columns) {
|
|
878
1174
|
assertNotUndefined(value);
|
|
879
|
-
if (
|
|
1175
|
+
if (
|
|
1176
|
+
!hasDrift &&
|
|
1177
|
+
validateColumnValue(deps)(change.table, column, value)
|
|
1178
|
+
) {
|
|
880
1179
|
applyColumnChange(deps)(
|
|
881
1180
|
ownerIdBytes,
|
|
882
1181
|
ownerId,
|
|
@@ -890,7 +1189,17 @@ const applyMessages =
|
|
|
890
1189
|
} else {
|
|
891
1190
|
deps.sqlite.exec(sql.prepared`
|
|
892
1191
|
insert into evolu_message_quarantine
|
|
893
|
-
(
|
|
1192
|
+
(
|
|
1193
|
+
"ownerId",
|
|
1194
|
+
"timestamp",
|
|
1195
|
+
"table",
|
|
1196
|
+
"id",
|
|
1197
|
+
"column",
|
|
1198
|
+
"value",
|
|
1199
|
+
"reason",
|
|
1200
|
+
"origin",
|
|
1201
|
+
"quarantinedAt"
|
|
1202
|
+
)
|
|
894
1203
|
values
|
|
895
1204
|
(
|
|
896
1205
|
${ownerIdBytes},
|
|
@@ -898,38 +1207,34 @@ const applyMessages =
|
|
|
898
1207
|
${change.table},
|
|
899
1208
|
${idBytes},
|
|
900
1209
|
${column},
|
|
901
|
-
${value}
|
|
1210
|
+
${value},
|
|
1211
|
+
${hasDrift
|
|
1212
|
+
? QuarantineReason.TimestampDrift
|
|
1213
|
+
: QuarantineReason.Schema},
|
|
1214
|
+
${origin},
|
|
1215
|
+
${now}
|
|
902
1216
|
)
|
|
903
1217
|
on conflict do nothing;
|
|
904
1218
|
`);
|
|
905
1219
|
}
|
|
906
1220
|
}
|
|
1221
|
+
}
|
|
907
1222
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1223
|
+
if (wroteNewMessages) {
|
|
1224
|
+
/**
|
|
1225
|
+
* TODO: Implement proper storedBytes tracking for client using received
|
|
1226
|
+
* and sent encrypted message sizes.
|
|
1227
|
+
*/
|
|
1228
|
+
updateOwnerUsage(deps)(
|
|
1229
|
+
ownerIdBytes,
|
|
1230
|
+
// Placeholder until proper tracking implemented
|
|
1231
|
+
onePositiveInt,
|
|
911
1232
|
firstTimestamp,
|
|
912
1233
|
lastTimestamp,
|
|
913
1234
|
);
|
|
914
|
-
|
|
915
|
-
deps.baseSqliteStorage.insertTimestamp(
|
|
916
|
-
ownerIdBytes,
|
|
917
|
-
timestampBytes,
|
|
918
|
-
strategy,
|
|
919
|
-
);
|
|
920
1235
|
}
|
|
921
1236
|
|
|
922
|
-
|
|
923
|
-
* TODO: Implement proper storedBytes tracking for client using received and
|
|
924
|
-
* sent encrypted message sizes.
|
|
925
|
-
*/
|
|
926
|
-
updateOwnerUsage(deps)(
|
|
927
|
-
ownerIdBytes,
|
|
928
|
-
// Placeholder until proper tracking implemented
|
|
929
|
-
onePositiveInt,
|
|
930
|
-
firstTimestamp,
|
|
931
|
-
lastTimestamp,
|
|
932
|
-
);
|
|
1237
|
+
return wroteNewMessages;
|
|
933
1238
|
};
|
|
934
1239
|
|
|
935
1240
|
const dbChangeToColumns = (change: DbChange, now: Millis) => {
|