@evolu/common 8.10.0 → 8.12.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 +39 -2
- package/dist/src/Bytes.d.ts.map +1 -1
- package/dist/src/Bytes.js +50 -2
- package/dist/src/Config.d.ts +22 -22
- package/dist/src/Config.d.ts.map +1 -1
- 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 +92 -18
- package/dist/src/Fs.d.ts.map +1 -1
- package/dist/src/Fs.js +2 -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/Object.d.ts.map +1 -1
- package/dist/src/Object.js +5 -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 +63 -9
- 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 +106 -9
- package/dist/src/Time.d.ts.map +1 -1
- package/dist/src/Time.js +55 -4
- package/dist/src/Type.d.ts +1455 -1310
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +1274 -517
- 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 +6 -7
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -3
- 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 +412 -213
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +181 -18
- 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 +106 -19
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.js +162 -60
- 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/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Relay.js +4 -2
- package/dist/src/local-first/Schema.d.ts +346 -23
- 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 +195 -17
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/dist/src/local-first/Storage.js +85 -22
- 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 +403 -81
- 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/Bytes.test.ts +27 -0
- package/src/Bytes.ts +58 -2
- package/src/Config.test.ts +2 -6
- package/src/Config.ts +133 -133
- 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.ts +92 -18
- package/src/Identicon.ts +2 -2
- package/src/LeakDetector.ts +22 -3
- package/src/LockManager.ts +8 -0
- package/src/Object.test.ts +27 -12
- package/src/Object.ts +5 -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 +137 -17
- package/src/Task.test.ts +189 -8
- package/src/Task.ts +56 -17
- package/src/Test.ts +9 -0
- package/src/Time.ts +106 -9
- package/src/Type.test.ts +946 -1028
- package/src/Type.ts +4195 -3136
- package/src/Types.test.ts +4 -14
- package/src/WebSocket.ts +313 -40
- package/src/Worker.ts +90 -8
- package/src/index.ts +20 -6
- package/src/local-first/Db.ts +644 -339
- package/src/local-first/Evolu.test.ts +994 -22
- package/src/local-first/Evolu.ts +625 -232
- package/src/local-first/Owner.ts +13 -30
- package/src/local-first/Protocol.test.ts +634 -10
- package/src/local-first/Protocol.ts +255 -109
- package/src/local-first/Query.ts +8 -15
- package/src/local-first/Relay.ts +4 -2
- package/src/local-first/Schema.test.ts +143 -0
- package/src/local-first/Schema.ts +376 -26
- package/src/local-first/Shared.test.ts +7731 -559
- package/src/local-first/Shared.ts +2036 -267
- package/src/local-first/Storage.ts +224 -36
- package/src/local-first/Timestamp.test.ts +344 -70
- package/src/local-first/Timestamp.ts +434 -118
- package/src/local-first/index.ts +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
|
@@ -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
|
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
|
|
@@ -56,25 +89,28 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
|
|
|
56
89
|
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
57
90
|
});
|
|
58
91
|
import { appendToArray, firstInArray, } from "../Array.js";
|
|
59
|
-
import { assertNonEmptyReadonlyArray, assertNonNullable, assertNotUndefined, } from "../Assert.js";
|
|
60
|
-
import { EncryptionKey
|
|
92
|
+
import { assert, assertNonEmptyReadonlyArray, assertNonNullable, assertNotUndefined, } from "../Assert.js";
|
|
93
|
+
import { EncryptionKey } from "../Crypto.js";
|
|
61
94
|
import { constFalse, constVoid } from "../Function.js";
|
|
62
95
|
import { acquireLeaderLock } from "../LockManager.js";
|
|
63
96
|
import { createMutableRecord, getOwnProp, objectToEntries } from "../Object.js";
|
|
64
|
-
import { ok } from "../Result.js";
|
|
97
|
+
import { err, getOk, ok } from "../Result.js";
|
|
65
98
|
import { booleanToSqliteBoolean, createSqlite, sql, SqliteBoolean, sqliteBooleanToBoolean, sqliteQueryStringToSqliteQuery, SqliteValue, } from "../Sqlite.js";
|
|
66
99
|
import { callback } from "../Task.js";
|
|
67
|
-
import {
|
|
68
|
-
import { assertType, Id, IdBytes, idBytesToId, idToIdBytes, onePositiveInt, } from "../Type.js";
|
|
100
|
+
import { millisToDateIso, saturateMillis, } from "../Time.js";
|
|
101
|
+
import { assertType, Id, IdBytes, idBytesToId, idToIdBytes, NonNaNNumber, onePositiveInt, PositiveInt, } from "../Type.js";
|
|
69
102
|
import { ownerIdBytesToOwnerId, ownerIdToOwnerIdBytes } from "./Owner.js";
|
|
70
|
-
import { applyProtocolMessageAsClient, createProtocolMessageForSync, decryptAndDecodeDbChange, encodeAndEncryptDbChange,
|
|
71
|
-
import { ensureSqliteSchema,
|
|
103
|
+
import { applyProtocolMessageAsClient, createProtocolMessageForSync, decryptAndDecodeDbChange, encodeAndEncryptDbChange, SubscriptionFlags, } from "./Protocol.js";
|
|
104
|
+
import { ensureSqliteSchema, isLocalOnlyTable, QuarantineOrigin, QuarantineReason, systemColumns, } from "./Schema.js";
|
|
72
105
|
import { consoleEntryOrErrorBroadcastChannelName } from "./Shared.js";
|
|
73
106
|
import { createBaseSqliteStorage, createBaseSqliteStorageTables, DbChange, getTimestampInsertStrategy, readOwnerUsageOrDefault, updateOwnerUsage, } from "./Storage.js";
|
|
74
|
-
import { createInitialTimestamp, defaultTimestampMaxDrift, receiveTimestamp, sendTimestamp, TimestampBytes, timestampBytesToTimestamp, timestampToTimestampBytes, } from "./Timestamp.js";
|
|
107
|
+
import { createInitialTimestamp, defaultTimestampMaxDrift, isTimestampBeyondMaxDrift, maxCounter, maxNodeId, receiveTimestamp, sendTimestamp, TimestampBytes, timestampBytesToTimestamp, timestampToTimestampBytes, } from "./Timestamp.js";
|
|
108
|
+
/** The database version this code creates and supports; see the module doc. */
|
|
109
|
+
const dbVersion = PositiveInt.orThrow(2);
|
|
75
110
|
/**
|
|
76
|
-
* Starts the platform-agnostic Evolu DbWorker and owns its resources until
|
|
77
|
-
* worker receives a dispose message or its {@link Run}
|
|
111
|
+
* Starts the platform-agnostic Evolu DbWorker and owns its resources until
|
|
112
|
+
* startup is refused, the worker receives a dispose message, or its {@link Run}
|
|
113
|
+
* is aborted.
|
|
78
114
|
*/
|
|
79
115
|
export const startDbWorker = (self) => async (run) => {
|
|
80
116
|
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
@@ -107,46 +143,89 @@ export const startDbWorker = (self) => async (run) => {
|
|
|
107
143
|
baseSqliteStorage,
|
|
108
144
|
timestampConfig: { maxDrift: defaultTimestampMaxDrift },
|
|
109
145
|
};
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
146
|
+
const startup = sqlite.transaction(() => {
|
|
147
|
+
// Only the version record is read before the version check, because a
|
|
148
|
+
// newer database can hold schema objects this code cannot read.
|
|
149
|
+
const { rows: versionColumnRows } = sqlite.exec(sql `
|
|
150
|
+
select "name" from pragma_table_info('evolu_version');
|
|
151
|
+
`);
|
|
152
|
+
const versionColumns = new Set(versionColumnRows.map((row) => row.name));
|
|
153
|
+
let initialClock;
|
|
154
|
+
if (versionColumns.size === 0) {
|
|
155
|
+
initialClock = createInitialTimestamp(dbDeps);
|
|
156
|
+
initializeDb(dbDeps)(initialClock);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const version = ensureDbVersion(dbDeps)(versionColumns);
|
|
160
|
+
if (!version.ok)
|
|
161
|
+
return version;
|
|
162
|
+
const { rows } = sqlite.exec(sql `
|
|
163
|
+
select "clock" from evolu_config limit 1;
|
|
164
|
+
`);
|
|
165
|
+
assertNonEmptyReadonlyArray(rows);
|
|
166
|
+
initialClock = timestampBytesToTimestamp(firstInArray(rows).clock);
|
|
167
|
+
}
|
|
168
|
+
ensureSqliteSchema(dbDeps)(initMessage.sqliteSchema);
|
|
169
|
+
const released = releaseDriftQuarantine(dbDeps)(initialClock);
|
|
170
|
+
if (released) {
|
|
171
|
+
initialClock = released;
|
|
172
|
+
saveClock(dbDeps)(released);
|
|
173
|
+
}
|
|
117
174
|
tryApplyQuarantinedMessages(dbDeps);
|
|
175
|
+
return ok(initialClock);
|
|
118
176
|
});
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
177
|
+
if (!startup.ok) {
|
|
178
|
+
// Nothing was written. Returning lets the disposer close SQLite and
|
|
179
|
+
// release the database lock, which the SharedWorker's tenant disposal
|
|
180
|
+
// waits on. The tab leader lock is unaffected.
|
|
181
|
+
port.postMessage({
|
|
182
|
+
type: "LeaderRefused",
|
|
183
|
+
name: initMessage.name,
|
|
184
|
+
error: startup.error,
|
|
185
|
+
});
|
|
186
|
+
return ok();
|
|
187
|
+
}
|
|
188
|
+
const initialClock = startup.value;
|
|
189
|
+
const storage = createClientStorage(dbDeps);
|
|
127
190
|
const dbWorkerRun = disposer.use(run.create({ storage }));
|
|
128
|
-
port.postMessage({
|
|
191
|
+
port.postMessage({
|
|
192
|
+
type: "LeaderAcquired",
|
|
193
|
+
name: initMessage.name,
|
|
194
|
+
clock: initialClock,
|
|
195
|
+
});
|
|
129
196
|
await run.ok(callback(({ resolve }) => {
|
|
130
197
|
port.onMessage = (input) => {
|
|
131
198
|
if (input.type === "Dispose") {
|
|
132
199
|
resolve(ok());
|
|
133
200
|
return;
|
|
134
201
|
}
|
|
135
|
-
const {
|
|
202
|
+
const { attemptId } = input;
|
|
136
203
|
const postQueuedResponse = (response) => {
|
|
137
204
|
port.postMessage({
|
|
138
205
|
type: "OnQueuedResponse",
|
|
139
|
-
|
|
206
|
+
attemptId,
|
|
140
207
|
response,
|
|
141
208
|
}, response.type === "ForEvolu" && response.message.type === "Export"
|
|
142
209
|
? [response.message.file.buffer]
|
|
143
210
|
: undefined);
|
|
144
211
|
};
|
|
145
|
-
if (
|
|
146
|
-
|
|
212
|
+
if ("clock" in input) {
|
|
213
|
+
const { clock: inputClock, now } = input;
|
|
214
|
+
let committedClock = inputClock;
|
|
215
|
+
const context = {
|
|
216
|
+
now,
|
|
217
|
+
clock: {
|
|
218
|
+
get: () => committedClock,
|
|
219
|
+
set: (timestamp) => {
|
|
220
|
+
committedClock = timestamp;
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
const request = input.request;
|
|
225
|
+
if (request.type === "ForSharedWorker") {
|
|
147
226
|
const { owner, inputMessage } = request.message;
|
|
148
227
|
void dbWorkerRun(async (run) => {
|
|
149
|
-
storage.setRequestContext(owner.encryptionKey);
|
|
228
|
+
storage.setRequestContext(owner.encryptionKey, context);
|
|
150
229
|
const result = await run.abortable(applyProtocolMessageAsClient(inputMessage, {
|
|
151
230
|
writeKey: owner.writeKey,
|
|
152
231
|
}));
|
|
@@ -154,6 +233,7 @@ export const startDbWorker = (self) => async (run) => {
|
|
|
154
233
|
type: "ForSharedWorker",
|
|
155
234
|
message: {
|
|
156
235
|
type: "ApplySyncMessage",
|
|
236
|
+
clock: context.clock.get(),
|
|
157
237
|
ownerId: owner.id,
|
|
158
238
|
didWriteMessages: storage.didWriteMessages(),
|
|
159
239
|
result,
|
|
@@ -161,21 +241,41 @@ export const startDbWorker = (self) => async (run) => {
|
|
|
161
241
|
});
|
|
162
242
|
return ok();
|
|
163
243
|
});
|
|
164
|
-
return;
|
|
165
244
|
}
|
|
245
|
+
else {
|
|
246
|
+
postQueuedResponse({
|
|
247
|
+
type: "ForEvolu",
|
|
248
|
+
id: request.id,
|
|
249
|
+
message: handleMutation({
|
|
250
|
+
...dbDeps,
|
|
251
|
+
clock: context.clock,
|
|
252
|
+
})(request.message, now),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const request = input.request;
|
|
258
|
+
if (request.type === "ForSharedWorker") {
|
|
166
259
|
const protocolMessagesByOwnerId = new Map();
|
|
260
|
+
const failedOwnerIds = new Set();
|
|
261
|
+
// An unanswered attempt would block the tenant queue, so a failed
|
|
262
|
+
// owner is logged and reported instead of thrown.
|
|
167
263
|
for (const owner of request.message.owners) {
|
|
168
264
|
storage.setRequestContext(owner.encryptionKey);
|
|
169
|
-
|
|
170
|
-
storage,
|
|
171
|
-
|
|
172
|
-
|
|
265
|
+
try {
|
|
266
|
+
protocolMessagesByOwnerId.set(owner.id, createProtocolMessageForSync({ storage })(owner.id, SubscriptionFlags.Subscribe));
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
deps.console.error(error);
|
|
270
|
+
failedOwnerIds.add(owner.id);
|
|
271
|
+
}
|
|
173
272
|
}
|
|
174
273
|
postQueuedResponse({
|
|
175
274
|
type: "ForSharedWorker",
|
|
176
275
|
message: {
|
|
177
276
|
type: "CreateSyncMessages",
|
|
178
277
|
protocolMessagesByOwnerId,
|
|
278
|
+
failedOwnerIds,
|
|
179
279
|
},
|
|
180
280
|
});
|
|
181
281
|
return;
|
|
@@ -200,21 +300,7 @@ export const startDbWorker = (self) => async (run) => {
|
|
|
200
300
|
file: sqlite.export(),
|
|
201
301
|
},
|
|
202
302
|
});
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
const result = handleMutation({ ...dbDeps, clock })(request.message);
|
|
206
|
-
if (!result.ok) {
|
|
207
|
-
consoleEntryOrErrorBroadcastChannel.postMessage({
|
|
208
|
-
type: "Error",
|
|
209
|
-
error: result.error,
|
|
210
|
-
});
|
|
211
|
-
return;
|
|
212
303
|
}
|
|
213
|
-
postQueuedResponse({
|
|
214
|
-
type: "ForEvolu",
|
|
215
|
-
id: request.id,
|
|
216
|
-
message: result.value,
|
|
217
|
-
});
|
|
218
304
|
};
|
|
219
305
|
return () => {
|
|
220
306
|
port.onMessage = null;
|
|
@@ -232,42 +318,105 @@ export const startDbWorker = (self) => async (run) => {
|
|
|
232
318
|
await result_1;
|
|
233
319
|
}
|
|
234
320
|
};
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Persists `timestamp` only when it is greater than the stored clock.
|
|
323
|
+
*
|
|
324
|
+
* Requests report their computed clock, which can be older than the stored
|
|
325
|
+
* clock when replayed after startup release. The SQL guard keeps the stored
|
|
326
|
+
* clock from moving backwards; the SharedWorker adopts response clocks only
|
|
327
|
+
* when newer than its session clock.
|
|
328
|
+
*
|
|
329
|
+
* Timestamp bytes sort like their timestamps and SQLite compares blobs byte by
|
|
330
|
+
* byte, so persistence takes one statement even when the clock does not
|
|
331
|
+
* advance.
|
|
332
|
+
*
|
|
333
|
+
* Local-only mutations and sync requests that do not invoke `writeMessages`
|
|
334
|
+
* skip this. Successfully processed batches in `writeMessages` call this even
|
|
335
|
+
* when every message is duplicated or quarantined. Duplicate receipts within
|
|
336
|
+
* the drift limit can advance the clock without storing new messages.
|
|
337
|
+
*/
|
|
338
|
+
const saveClock = (deps) => (timestamp) => {
|
|
339
|
+
const bytes = timestampToTimestampBytes(timestamp);
|
|
340
|
+
deps.sqlite.exec(sql.prepared `
|
|
341
|
+
update evolu_config
|
|
342
|
+
set "clock" = ${bytes}
|
|
343
|
+
where "clock" < ${bytes};
|
|
344
|
+
`);
|
|
345
|
+
};
|
|
346
|
+
/**
|
|
347
|
+
* Checks the stored database version inside the startup transaction, before any
|
|
348
|
+
* other read. The legacy layout, one `protocolVersion` row that was always 1,
|
|
349
|
+
* is converted to database version 1 first. An older database is migrated one
|
|
350
|
+
* version at a time, and the record is updated in the same transaction, so a
|
|
351
|
+
* failed migration leaves both data and version unchanged.
|
|
352
|
+
*/
|
|
353
|
+
const ensureDbVersion = ({ sqlite }) => (versionColumns) => {
|
|
354
|
+
if (!versionColumns.has("dbVersion")) {
|
|
355
|
+
sqlite.exec(sql `
|
|
356
|
+
alter table evolu_version
|
|
357
|
+
rename column "protocolVersion" to "dbVersion";
|
|
242
358
|
`);
|
|
243
|
-
assertNonEmptyReadonlyArray(rows);
|
|
244
|
-
currentTimestamp = timestampBytesToTimestamp(firstInArray(rows).clock);
|
|
245
359
|
}
|
|
246
|
-
|
|
247
|
-
|
|
360
|
+
const { rows } = sqlite.exec(sql `
|
|
361
|
+
select "dbVersion" from evolu_version;
|
|
362
|
+
`);
|
|
363
|
+
const storedVersion = rows[0].dbVersion;
|
|
364
|
+
if (storedVersion > dbVersion) {
|
|
365
|
+
return err({
|
|
366
|
+
type: "UnsupportedDbVersionError",
|
|
367
|
+
storedVersion,
|
|
368
|
+
supportedVersion: dbVersion,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
if (storedVersion < dbVersion) {
|
|
372
|
+
if (storedVersion < 2)
|
|
373
|
+
migrateToVersion2({ sqlite });
|
|
374
|
+
sqlite.exec(sql `update evolu_version set "dbVersion" = ${dbVersion};`);
|
|
375
|
+
}
|
|
376
|
+
return ok();
|
|
377
|
+
};
|
|
378
|
+
/**
|
|
379
|
+
* Version 2 records why a message is quarantined, whether this database stamped
|
|
380
|
+
* or received it, and when, and adds the index that startup release reads. Rows
|
|
381
|
+
* from version 1 get the defaults: schema quarantine of a received message with
|
|
382
|
+
* an unknown quarantine time. See {@link QuarantineReason}.
|
|
383
|
+
*/
|
|
384
|
+
const migrateToVersion2 = ({ sqlite }) => {
|
|
385
|
+
for (const query of [
|
|
386
|
+
sql `
|
|
387
|
+
alter table evolu_message_quarantine
|
|
388
|
+
add column "reason" integer not null default ${sql.raw(String(QuarantineReason.Schema))};
|
|
389
|
+
`,
|
|
390
|
+
sql `
|
|
391
|
+
alter table evolu_message_quarantine
|
|
392
|
+
add column "origin" integer not null default ${sql.raw(String(QuarantineOrigin.ReceivedMessage))};
|
|
393
|
+
`,
|
|
394
|
+
sql `
|
|
395
|
+
alter table evolu_message_quarantine
|
|
396
|
+
add column "quarantinedAt" integer;
|
|
397
|
+
`,
|
|
398
|
+
sql `
|
|
399
|
+
create index evolu_message_quarantine_reason_timestamp on evolu_message_quarantine (
|
|
400
|
+
"reason",
|
|
401
|
+
"timestamp"
|
|
402
|
+
);
|
|
403
|
+
`,
|
|
404
|
+
]) {
|
|
405
|
+
sqlite.exec(query);
|
|
248
406
|
}
|
|
249
|
-
return {
|
|
250
|
-
get: () => currentTimestamp,
|
|
251
|
-
save: (timestamp) => {
|
|
252
|
-
currentTimestamp = timestamp;
|
|
253
|
-
deps.sqlite.exec(sql.prepared `
|
|
254
|
-
update evolu_config
|
|
255
|
-
set "clock" = ${timestampToTimestampBytes(timestamp)};
|
|
256
|
-
`);
|
|
257
|
-
},
|
|
258
|
-
};
|
|
259
407
|
};
|
|
260
408
|
const initializeDb = ({ sqlite }) => (initialClock) => {
|
|
261
409
|
for (const query of [
|
|
410
|
+
// The database version record; see the module documentation.
|
|
262
411
|
sql `
|
|
263
412
|
create table evolu_version (
|
|
264
|
-
"
|
|
413
|
+
"dbVersion" integer not null
|
|
265
414
|
)
|
|
266
415
|
strict;
|
|
267
416
|
`,
|
|
268
417
|
sql `
|
|
269
|
-
insert into evolu_version ("
|
|
270
|
-
values (${
|
|
418
|
+
insert into evolu_version ("dbVersion")
|
|
419
|
+
values (${dbVersion});
|
|
271
420
|
`,
|
|
272
421
|
sql `
|
|
273
422
|
create table evolu_config (
|
|
@@ -314,7 +463,7 @@ const initializeDb = ({ sqlite }) => (initialClock) => {
|
|
|
314
463
|
);
|
|
315
464
|
`,
|
|
316
465
|
/**
|
|
317
|
-
* Stores messages with
|
|
466
|
+
* Stores unapplied messages with their quarantine reason.
|
|
318
467
|
*
|
|
319
468
|
* When a device receives sync messages containing tables or columns that
|
|
320
469
|
* don't exist in its current schema (e.g., from a newer app version),
|
|
@@ -326,6 +475,16 @@ const initializeDb = ({ sqlite }) => (initialClock) => {
|
|
|
326
475
|
* 3. Partial messages work - known columns go to app tables, unknown to
|
|
327
476
|
* quarantine
|
|
328
477
|
*
|
|
478
|
+
* Clock-drift quarantine preserves every column of the affected message.
|
|
479
|
+
* It is released at startup once system time comes within the drift limit
|
|
480
|
+
* of the message's timestamp; see the Timestamp module.
|
|
481
|
+
*
|
|
482
|
+
* Each row records why it was not applied (`reason`), whether this
|
|
483
|
+
* database stamped the message for a local mutation or received it
|
|
484
|
+
* (`origin`), and the captured system time of the request that
|
|
485
|
+
* quarantined it (`quarantinedAt`). Quarantine is not reported as an
|
|
486
|
+
* error; applications watch this table through queries.
|
|
487
|
+
*
|
|
329
488
|
* The `union all` query in `readDbChange` combines `evolu_history` and
|
|
330
489
|
* this table, ensuring all data (known and unknown) is included when
|
|
331
490
|
* syncing to other devices.
|
|
@@ -338,6 +497,9 @@ const initializeDb = ({ sqlite }) => (initialClock) => {
|
|
|
338
497
|
"id" blob not null,
|
|
339
498
|
"column" text not null,
|
|
340
499
|
"value" any,
|
|
500
|
+
"reason" integer not null default ${sql.raw(String(QuarantineReason.Schema))},
|
|
501
|
+
"origin" integer not null default ${sql.raw(String(QuarantineOrigin.ReceivedMessage))},
|
|
502
|
+
"quarantinedAt" integer,
|
|
341
503
|
primary key ("ownerId", "timestamp", "table", "id", "column")
|
|
342
504
|
)
|
|
343
505
|
strict;
|
|
@@ -346,17 +508,27 @@ const initializeDb = ({ sqlite }) => (initialClock) => {
|
|
|
346
508
|
sqlite.exec(query);
|
|
347
509
|
}
|
|
348
510
|
createBaseSqliteStorageTables({ sqlite });
|
|
511
|
+
// Startup release reads drift quarantine by reason and timestamp. Created
|
|
512
|
+
// last, as the migration creates it, so Evolu's own indexes are listed in one
|
|
513
|
+
// order in fresh and migrated databases.
|
|
514
|
+
sqlite.exec(sql `
|
|
515
|
+
create index evolu_message_quarantine_reason_timestamp on evolu_message_quarantine (
|
|
516
|
+
"reason",
|
|
517
|
+
"timestamp"
|
|
518
|
+
);
|
|
519
|
+
`);
|
|
349
520
|
};
|
|
350
521
|
const tryApplyQuarantinedMessages = (deps) => {
|
|
351
|
-
const rows = deps.sqlite.exec(sql `
|
|
522
|
+
const { rows } = deps.sqlite.exec(sql `
|
|
352
523
|
select "ownerId", "timestamp", "table", "id", "column", "value"
|
|
353
|
-
from evolu_message_quarantine
|
|
524
|
+
from evolu_message_quarantine
|
|
525
|
+
where "reason" = ${QuarantineReason.Schema};
|
|
354
526
|
`);
|
|
355
|
-
for (const row of rows
|
|
527
|
+
for (const row of rows) {
|
|
356
528
|
if (!validateColumnValue(deps)(row.table, row.column, row.value))
|
|
357
529
|
continue;
|
|
358
530
|
applyColumnChange(deps)(row.ownerId, ownerIdBytesToOwnerId(row.ownerId), row.table, row.id, idBytesToId(row.id), row.column, row.value, row.timestamp);
|
|
359
|
-
deps.sqlite.exec(sql `
|
|
531
|
+
deps.sqlite.exec(sql.prepared `
|
|
360
532
|
delete from evolu_message_quarantine
|
|
361
533
|
where
|
|
362
534
|
"ownerId" = ${row.ownerId}
|
|
@@ -367,7 +539,65 @@ const tryApplyQuarantinedMessages = (deps) => {
|
|
|
367
539
|
`);
|
|
368
540
|
}
|
|
369
541
|
};
|
|
542
|
+
/**
|
|
543
|
+
* Moves drift quarantine within the drift limit to schema quarantine for
|
|
544
|
+
* application. Advances `clock` once per distinct timestamp in timestamp order,
|
|
545
|
+
* using one captured system time, so later local changes sort after released
|
|
546
|
+
* messages. Only timestamps within the drift limit are loaded. Returns the
|
|
547
|
+
* advanced clock, or `null` when nothing was released. Runs inside the startup
|
|
548
|
+
* transaction, before saving the clock and applying schema quarantine, so the
|
|
549
|
+
* SharedWorker learns the clock only after release commits.
|
|
550
|
+
*/
|
|
551
|
+
const releaseDriftQuarantine = (deps) => (clock) => {
|
|
552
|
+
const now = deps.time.now();
|
|
553
|
+
// Milliseconds are integers; floor the allowance before adding it so a
|
|
554
|
+
// fractional allowance cannot round up near the timestamp range ceiling.
|
|
555
|
+
const maxReleaseMillis = now + Math.floor(deps.timestampConfig.maxDrift);
|
|
556
|
+
assertType(NonNaNNumber, maxReleaseMillis);
|
|
557
|
+
const bound = timestampToTimestampBytes({
|
|
558
|
+
millis: saturateMillis(maxReleaseMillis),
|
|
559
|
+
counter: maxCounter,
|
|
560
|
+
nodeId: maxNodeId,
|
|
561
|
+
});
|
|
562
|
+
const { rows } = deps.sqlite.exec(sql `
|
|
563
|
+
select distinct "timestamp"
|
|
564
|
+
from evolu_message_quarantine
|
|
565
|
+
where
|
|
566
|
+
"reason" = ${QuarantineReason.TimestampDrift}
|
|
567
|
+
and "timestamp" <= ${bound}
|
|
568
|
+
order by "timestamp";
|
|
569
|
+
`);
|
|
570
|
+
if (rows.length === 0)
|
|
571
|
+
return null;
|
|
572
|
+
const receive = receiveTimestamp(deps);
|
|
573
|
+
let nextClock = clock;
|
|
574
|
+
for (const { timestamp } of rows) {
|
|
575
|
+
const remote = timestampBytesToTimestamp(timestamp);
|
|
576
|
+
const next = receive(nextClock, remote, now);
|
|
577
|
+
if (next.ok) {
|
|
578
|
+
nextClock = next.value;
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
assert(next.error.cause === "local", "The query bound excludes remote drift at the captured time.");
|
|
582
|
+
nextClock = next.error.timestamp;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// Drift checks passed; mark these rows for the next schema pass.
|
|
586
|
+
deps.sqlite.exec(sql `
|
|
587
|
+
update evolu_message_quarantine
|
|
588
|
+
set "reason" = ${QuarantineReason.Schema}
|
|
589
|
+
where
|
|
590
|
+
"reason" = ${QuarantineReason.TimestampDrift}
|
|
591
|
+
and "timestamp" <= ${bound};
|
|
592
|
+
`);
|
|
593
|
+
return nextClock;
|
|
594
|
+
};
|
|
370
595
|
const validateColumnValue = (deps) => (table, column, _value) => {
|
|
596
|
+
// Local-only tables never sync, so a received change to one comes from
|
|
597
|
+
// non-standard code. It stays in quarantine, stored for sync but never
|
|
598
|
+
// applied.
|
|
599
|
+
if (isLocalOnlyTable(table))
|
|
600
|
+
return false;
|
|
371
601
|
const schemaColumns = getOwnProp(deps.sqliteSchema.tables, table);
|
|
372
602
|
return (schemaColumns != null &&
|
|
373
603
|
(systemColumnsWithoutOwnerId.has(column) || schemaColumns.has(column)));
|
|
@@ -410,21 +640,21 @@ const applyColumnChange = (deps) => (ownerIdBytes, ownerId, table, idBytes, id,
|
|
|
410
640
|
on conflict do nothing;
|
|
411
641
|
`);
|
|
412
642
|
};
|
|
413
|
-
const createClientStorage = (deps) =>
|
|
643
|
+
const createClientStorage = (deps) => {
|
|
414
644
|
let encryptionKey = null;
|
|
415
645
|
let didWriteMessages = false;
|
|
646
|
+
let writeContext;
|
|
416
647
|
const getEncryptionKey = () => {
|
|
417
648
|
assertNonNullable(encryptionKey, "ClientStorage encryption key must be set");
|
|
418
649
|
return encryptionKey;
|
|
419
650
|
};
|
|
420
651
|
return {
|
|
421
652
|
...deps.baseSqliteStorage,
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
// refactor it later, we will probably have to change Protocol API.
|
|
426
|
-
setRequestContext: (nextEncryptionKey) => {
|
|
653
|
+
// SharedWorker waits for the response before dispatching another request,
|
|
654
|
+
// so asynchronous sync processing cannot overlap this request context.
|
|
655
|
+
setRequestContext: (nextEncryptionKey, nextWriteContext) => {
|
|
427
656
|
encryptionKey = nextEncryptionKey;
|
|
657
|
+
writeContext = nextWriteContext;
|
|
428
658
|
didWriteMessages = false;
|
|
429
659
|
},
|
|
430
660
|
didWriteMessages: () => didWriteMessages,
|
|
@@ -441,39 +671,49 @@ const createClientStorage = (deps) => ({ onError, }) => {
|
|
|
441
671
|
const currentEncryptionKey = getEncryptionKey();
|
|
442
672
|
for (const message of encryptedMessages) {
|
|
443
673
|
const change = decryptAndDecodeDbChange(message, currentEncryptionKey);
|
|
444
|
-
if (!change.ok)
|
|
445
|
-
|
|
446
|
-
return ok();
|
|
447
|
-
}
|
|
674
|
+
if (!change.ok)
|
|
675
|
+
return err(change.error);
|
|
448
676
|
messages.push({ timestamp: message.timestamp, change: change.value });
|
|
449
677
|
}
|
|
450
|
-
|
|
678
|
+
assertNonNullable(writeContext);
|
|
679
|
+
const { clock, now } = writeContext;
|
|
680
|
+
let clockTimestamp = clock.get();
|
|
681
|
+
const receive = receiveTimestamp(deps);
|
|
682
|
+
// The clock is computed over every message, duplicates included, so a
|
|
683
|
+
// retry with the same inputs reports the same clock. Writes for
|
|
684
|
+
// timestamps already in the owner's set are skipped by applyMessages.
|
|
451
685
|
for (const message of messages) {
|
|
452
|
-
const nextTimestamp =
|
|
686
|
+
const nextTimestamp = receive(clockTimestamp, message.timestamp, now);
|
|
453
687
|
if (!nextTimestamp.ok) {
|
|
454
|
-
|
|
455
|
-
|
|
688
|
+
if (nextTimestamp.error.cause === "remote")
|
|
689
|
+
continue;
|
|
690
|
+
clockTimestamp = nextTimestamp.error.timestamp;
|
|
456
691
|
}
|
|
457
|
-
|
|
692
|
+
else
|
|
693
|
+
clockTimestamp = nextTimestamp.value;
|
|
458
694
|
}
|
|
459
695
|
assertNonEmptyReadonlyArray(messages);
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
deps
|
|
463
|
-
|
|
464
|
-
return ok();
|
|
696
|
+
let wroteNewMessages = false;
|
|
697
|
+
deps.sqlite.transaction(() => {
|
|
698
|
+
wroteNewMessages = applyMessages(deps)(ownerIdBytesToOwnerId(ownerIdBytes), messages, QuarantineOrigin.ReceivedMessage, now);
|
|
699
|
+
saveClock(deps)(clockTimestamp);
|
|
465
700
|
});
|
|
701
|
+
clock.set(clockTimestamp);
|
|
702
|
+
// A batch of duplicates changes no table, so queries need no refresh.
|
|
703
|
+
if (wroteNewMessages)
|
|
704
|
+
didWriteMessages = true;
|
|
705
|
+
return ok();
|
|
466
706
|
},
|
|
467
707
|
readDbChange: (ownerId, timestamp) => {
|
|
468
708
|
const result = deps.sqlite.exec(sql `
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
709
|
+
select "table", "id", "column", "value"
|
|
710
|
+
from evolu_history
|
|
711
|
+
where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}
|
|
712
|
+
union all
|
|
713
|
+
select "table", "id", "column", "value"
|
|
714
|
+
from evolu_message_quarantine
|
|
715
|
+
where "ownerId" = ${ownerId} and "timestamp" = ${timestamp};
|
|
716
|
+
`);
|
|
477
717
|
const { rows } = result;
|
|
478
718
|
assertNonEmptyReadonlyArray(rows, "Every timestamp must have rows");
|
|
479
719
|
const firstRow = firstInArray(rows);
|
|
@@ -510,20 +750,20 @@ const createClientStorage = (deps) => ({ onError, }) => {
|
|
|
510
750
|
},
|
|
511
751
|
};
|
|
512
752
|
};
|
|
513
|
-
const handleMutation = (deps) => (message) => deps.sqlite.transaction(() => {
|
|
753
|
+
const handleMutation = (deps) => (message, now) => getOk(deps.sqlite.transaction(() => {
|
|
514
754
|
const messagesByOwnerId = new Map();
|
|
515
755
|
let clockTimestamp = deps.clock.get();
|
|
516
|
-
let clockChanged = false;
|
|
517
756
|
for (const change of message.changes) {
|
|
518
|
-
if (change.table
|
|
519
|
-
applyLocalOnlyChange(deps)(change);
|
|
757
|
+
if (isLocalOnlyTable(change.table)) {
|
|
758
|
+
applyLocalOnlyChange(deps)(change, now);
|
|
520
759
|
continue;
|
|
521
760
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
clockTimestamp = nextTimestamp.
|
|
526
|
-
|
|
761
|
+
// A drifted change still receives the next timestamp; applyMessages
|
|
762
|
+
// stores it in quarantine instead of its table.
|
|
763
|
+
const nextTimestamp = sendTimestamp(deps)(clockTimestamp, now);
|
|
764
|
+
clockTimestamp = nextTimestamp.ok
|
|
765
|
+
? nextTimestamp.value
|
|
766
|
+
: nextTimestamp.error.timestamp;
|
|
527
767
|
const { ownerId, ...dbChange } = change;
|
|
528
768
|
const message = {
|
|
529
769
|
timestamp: clockTimestamp,
|
|
@@ -536,17 +776,18 @@ const handleMutation = (deps) => (message) => deps.sqlite.transaction(() => {
|
|
|
536
776
|
messagesByOwnerId.set(ownerId, [message]);
|
|
537
777
|
}
|
|
538
778
|
for (const [ownerId, messages] of messagesByOwnerId) {
|
|
539
|
-
applyMessages(deps)(ownerId, messages);
|
|
779
|
+
applyMessages(deps)(ownerId, messages, QuarantineOrigin.LocalMutation, now);
|
|
540
780
|
}
|
|
541
|
-
if (
|
|
542
|
-
deps
|
|
781
|
+
if (messagesByOwnerId.size > 0)
|
|
782
|
+
saveClock(deps)(clockTimestamp);
|
|
543
783
|
return ok({
|
|
544
784
|
type: "Mutate",
|
|
785
|
+
clock: clockTimestamp,
|
|
545
786
|
messagesByOwnerId,
|
|
546
787
|
rowsByQuery: loadQueries(deps)(message.subscribedQueries),
|
|
547
788
|
});
|
|
548
|
-
});
|
|
549
|
-
const applyLocalOnlyChange = (deps) => (change) => {
|
|
789
|
+
}));
|
|
790
|
+
const applyLocalOnlyChange = (deps) => (change, now) => {
|
|
550
791
|
if (change.isDelete) {
|
|
551
792
|
deps.sqlite.exec(sql `
|
|
552
793
|
delete from ${sql.identifier(change.table)}
|
|
@@ -555,7 +796,7 @@ const applyLocalOnlyChange = (deps) => (change) => {
|
|
|
555
796
|
}
|
|
556
797
|
else {
|
|
557
798
|
const ownerId = change.ownerId;
|
|
558
|
-
const columns = dbChangeToColumns(change,
|
|
799
|
+
const columns = dbChangeToColumns(change, now);
|
|
559
800
|
for (const [column, value] of columns) {
|
|
560
801
|
assertNotUndefined(value);
|
|
561
802
|
deps.sqlite.exec(sql.prepared `
|
|
@@ -568,23 +809,52 @@ const applyLocalOnlyChange = (deps) => (change) => {
|
|
|
568
809
|
}
|
|
569
810
|
}
|
|
570
811
|
};
|
|
571
|
-
|
|
812
|
+
/**
|
|
813
|
+
* Stores messages for an owner and applies them to their tables. Drifted
|
|
814
|
+
* messages and columns the schema does not define go to quarantine instead.
|
|
815
|
+
* Uses the request's captured time to classify drift, matching timestamp
|
|
816
|
+
* generation. Returns whether any message was new; the rest were stored
|
|
817
|
+
* before.
|
|
818
|
+
*/
|
|
819
|
+
const applyMessages = (deps) => (ownerId, messages, origin, now) => {
|
|
572
820
|
const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
|
|
821
|
+
let wroteNewMessages = false;
|
|
573
822
|
const usage = readOwnerUsageOrDefault(deps)(ownerIdBytes, timestampToTimestampBytes(firstInArray(messages).timestamp));
|
|
574
823
|
let { firstTimestamp, lastTimestamp } = usage;
|
|
575
824
|
for (const { timestamp, change } of messages) {
|
|
825
|
+
const timestampBytes = timestampToTimestampBytes(timestamp);
|
|
826
|
+
let strategy;
|
|
827
|
+
[strategy, firstTimestamp, lastTimestamp] = getTimestampInsertStrategy(timestampBytes, firstTimestamp, lastTimestamp);
|
|
828
|
+
// A timestamp already in the set was applied or quarantined before.
|
|
829
|
+
// Skipping it preserves that decision and makes duplicate delivery and
|
|
830
|
+
// retries idempotent without a separate lookup.
|
|
831
|
+
const isNew = deps.baseSqliteStorage.insertTimestamp(ownerIdBytes, timestampBytes, strategy);
|
|
832
|
+
if (!isNew)
|
|
833
|
+
continue;
|
|
834
|
+
wroteNewMessages = true;
|
|
835
|
+
const hasDrift = isTimestampBeyondMaxDrift(deps)(timestamp.millis, now);
|
|
576
836
|
const columns = dbChangeToColumns(change, timestamp.millis);
|
|
577
837
|
const idBytes = idToIdBytes(change.id);
|
|
578
|
-
const timestampBytes = timestampToTimestampBytes(timestamp);
|
|
579
838
|
for (const [column, value] of columns) {
|
|
580
839
|
assertNotUndefined(value);
|
|
581
|
-
if (
|
|
840
|
+
if (!hasDrift &&
|
|
841
|
+
validateColumnValue(deps)(change.table, column, value)) {
|
|
582
842
|
applyColumnChange(deps)(ownerIdBytes, ownerId, change.table, idBytes, change.id, column, value, timestampBytes);
|
|
583
843
|
}
|
|
584
844
|
else {
|
|
585
845
|
deps.sqlite.exec(sql.prepared `
|
|
586
846
|
insert into evolu_message_quarantine
|
|
587
|
-
(
|
|
847
|
+
(
|
|
848
|
+
"ownerId",
|
|
849
|
+
"timestamp",
|
|
850
|
+
"table",
|
|
851
|
+
"id",
|
|
852
|
+
"column",
|
|
853
|
+
"value",
|
|
854
|
+
"reason",
|
|
855
|
+
"origin",
|
|
856
|
+
"quarantinedAt"
|
|
857
|
+
)
|
|
588
858
|
values
|
|
589
859
|
(
|
|
590
860
|
${ownerIdBytes},
|
|
@@ -592,23 +862,28 @@ const applyMessages = (deps) => (ownerId, messages) => {
|
|
|
592
862
|
${change.table},
|
|
593
863
|
${idBytes},
|
|
594
864
|
${column},
|
|
595
|
-
${value}
|
|
865
|
+
${value},
|
|
866
|
+
${hasDrift
|
|
867
|
+
? QuarantineReason.TimestampDrift
|
|
868
|
+
: QuarantineReason.Schema},
|
|
869
|
+
${origin},
|
|
870
|
+
${now}
|
|
596
871
|
)
|
|
597
872
|
on conflict do nothing;
|
|
598
873
|
`);
|
|
599
874
|
}
|
|
600
875
|
}
|
|
601
|
-
let strategy;
|
|
602
|
-
[strategy, firstTimestamp, lastTimestamp] = getTimestampInsertStrategy(timestampBytes, firstTimestamp, lastTimestamp);
|
|
603
|
-
deps.baseSqliteStorage.insertTimestamp(ownerIdBytes, timestampBytes, strategy);
|
|
604
876
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
877
|
+
if (wroteNewMessages) {
|
|
878
|
+
/**
|
|
879
|
+
* TODO: Implement proper storedBytes tracking for client using received
|
|
880
|
+
* and sent encrypted message sizes.
|
|
881
|
+
*/
|
|
882
|
+
updateOwnerUsage(deps)(ownerIdBytes,
|
|
883
|
+
// Placeholder until proper tracking implemented
|
|
884
|
+
onePositiveInt, firstTimestamp, lastTimestamp);
|
|
885
|
+
}
|
|
886
|
+
return wroteNewMessages;
|
|
612
887
|
};
|
|
613
888
|
const dbChangeToColumns = (change, now) => {
|
|
614
889
|
let values = objectToEntries(change.values);
|