@evolu/common 8.17.0 → 8.19.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 +31 -14
- package/dist/src/Bytes.d.ts.map +1 -1
- package/dist/src/Bytes.js +48 -8
- package/dist/src/Error.d.ts.map +1 -1
- package/dist/src/Error.js +5 -1
- package/dist/src/Polyfills.d.ts.map +1 -1
- package/dist/src/Polyfills.js +3 -2
- package/dist/src/Sqlite.d.ts +1 -1
- package/dist/src/Task.d.ts +4 -3
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +4 -3
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/local-first/Db.d.ts +32 -3
- package/dist/src/local-first/Db.d.ts.map +1 -1
- package/dist/src/local-first/Db.js +30 -9
- package/dist/src/local-first/Evolu.d.ts +21 -6
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +5 -1
- package/dist/src/local-first/Protocol.d.ts +215 -93
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.js +799 -489
- package/dist/src/local-first/Relay.d.ts +9 -1
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Relay.js +41 -36
- package/dist/src/local-first/Shared.d.ts +79 -53
- package/dist/src/local-first/Shared.d.ts.map +1 -1
- package/dist/src/local-first/Shared.js +67 -42
- package/dist/src/local-first/Storage.d.ts +80 -18
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/dist/src/local-first/Storage.js +20 -4
- package/package.json +1 -1
- package/src/Bytes.test.ts +212 -0
- package/src/Bytes.ts +67 -17
- package/src/Error.ts +5 -1
- package/src/Polyfills.ts +3 -2
- package/src/Sqlite.ts +1 -1
- package/src/Task.ts +4 -3
- package/src/index.ts +4 -1
- package/src/local-first/Db.ts +76 -17
- package/src/local-first/Evolu.test.ts +43 -12
- package/src/local-first/Evolu.ts +34 -7
- package/src/local-first/Protocol.test.ts +1541 -44
- package/src/local-first/Protocol.ts +1070 -605
- package/src/local-first/Relay.ts +80 -69
- package/src/local-first/Shared.test.ts +37 -1
- package/src/local-first/Shared.ts +124 -73
- package/src/local-first/Storage.ts +100 -27
package/src/local-first/Relay.ts
CHANGED
|
@@ -13,7 +13,8 @@ import {
|
|
|
13
13
|
} from "../Array.ts";
|
|
14
14
|
import { assert } from "../Assert.ts";
|
|
15
15
|
import type { TimingSafeEqualDep } from "../Crypto.ts";
|
|
16
|
-
import {
|
|
16
|
+
import { createUnknownError } from "../Error.ts";
|
|
17
|
+
import { err, ok, trySync } from "../Result.ts";
|
|
17
18
|
import type { SqliteDep } from "../Sqlite.ts";
|
|
18
19
|
import { sql } from "../Sqlite.ts";
|
|
19
20
|
import { createMutexByKey } from "../Task.ts";
|
|
@@ -25,12 +26,13 @@ import {
|
|
|
25
26
|
// OwnerTransport,
|
|
26
27
|
OwnerWriteKey,
|
|
27
28
|
} from "./Owner.ts";
|
|
29
|
+
import type { ProtocolWriteKeyError } from "./Protocol.ts";
|
|
28
30
|
import type {
|
|
29
31
|
EncryptedDbChange,
|
|
30
32
|
SqliteStorageDeps,
|
|
31
33
|
Storage,
|
|
32
34
|
StorageConfig,
|
|
33
|
-
|
|
35
|
+
StorageWriteMessagesError,
|
|
34
36
|
} from "./Storage.ts";
|
|
35
37
|
import {
|
|
36
38
|
createBaseSqliteStorage,
|
|
@@ -66,7 +68,15 @@ export interface RelayConfig extends StorageConfig {
|
|
|
66
68
|
*
|
|
67
69
|
* OwnerId is used rather than short-lived tokens because this only controls
|
|
68
70
|
* relay access, not write permissions. Since all data is encrypted on the
|
|
69
|
-
* relay, OwnerId exposure
|
|
71
|
+
* relay, OwnerId exposure reveals no data.
|
|
72
|
+
*
|
|
73
|
+
* It does allow write-key squatting. The relay stores the first write key
|
|
74
|
+
* presented for an owner, so anyone who learns an OwnerId before the owner's
|
|
75
|
+
* own write key reaches the relay can claim it. The owner then gets
|
|
76
|
+
* {@link ProtocolWriteKeyError} on this relay until the operator deletes the
|
|
77
|
+
* owner's row in `evolu_writeKey`. This callback does not prevent that: it
|
|
78
|
+
* receives only the OwnerId of a connection, which a squatter can name as
|
|
79
|
+
* well, and every message on the connection can name another owner.
|
|
70
80
|
*
|
|
71
81
|
* Owners specify which relays to connect to via `OwnerTransport`. In
|
|
72
82
|
* WebSocket-based implementations, this check occurs before accepting the
|
|
@@ -186,6 +196,9 @@ export const createRelaySqliteStorage =
|
|
|
186
196
|
return deps.timingSafeEqual(rows[0].writeKey, writeKey);
|
|
187
197
|
}
|
|
188
198
|
|
|
199
|
+
// When SQLite fails this insert, for example on a full disk, the throw
|
|
200
|
+
// reaches the protocol, which logs it and answers with WriteError,
|
|
201
|
+
// because a boolean has no room for the error.
|
|
189
202
|
deps.sqlite.exec(sql`
|
|
190
203
|
insert into evolu_writeKey (ownerId, writeKey)
|
|
191
204
|
values (${ownerId}, ${writeKey});
|
|
@@ -194,15 +207,6 @@ export const createRelaySqliteStorage =
|
|
|
194
207
|
return true;
|
|
195
208
|
},
|
|
196
209
|
|
|
197
|
-
setWriteKey: (ownerId, writeKey) => {
|
|
198
|
-
deps.sqlite.exec(sql`
|
|
199
|
-
insert into evolu_writeKey (ownerId, writeKey)
|
|
200
|
-
values (${ownerId}, ${writeKey})
|
|
201
|
-
on conflict (ownerId) do update
|
|
202
|
-
set writeKey = excluded.writeKey;
|
|
203
|
-
`);
|
|
204
|
-
},
|
|
205
|
-
|
|
206
210
|
writeMessages: (ownerIdBytes, messages) => async (run) => {
|
|
207
211
|
const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
|
|
208
212
|
const uniqueMessagesWithTimestampBytes = dedupeArray(
|
|
@@ -215,32 +219,42 @@ export const createRelaySqliteStorage =
|
|
|
215
219
|
|
|
216
220
|
return run(
|
|
217
221
|
mutexByOwnerId.withLock(ownerId, async () => {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
+
// SQLite can fail these reads, for example on a corrupt page, and
|
|
223
|
+
// a throw would panic the relay's shared Run.
|
|
224
|
+
const readResult = trySync(() => {
|
|
225
|
+
const existingTimestampsResult =
|
|
226
|
+
sqliteStorageBase.getExistingTimestamps(
|
|
227
|
+
ownerIdBytes,
|
|
228
|
+
mapArray(
|
|
229
|
+
uniqueMessagesWithTimestampBytes,
|
|
230
|
+
(m) => m.timestamp,
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const existingTimestampKeys = new Set(
|
|
235
|
+
mapArray(existingTimestampsResult, uint8ArrayToBase64Url),
|
|
236
|
+
);
|
|
237
|
+
const newMessages = filterArray(
|
|
238
|
+
uniqueMessagesWithTimestampBytes,
|
|
239
|
+
(message) =>
|
|
240
|
+
!existingTimestampKeys.has(
|
|
241
|
+
uint8ArrayToBase64Url(message.timestamp),
|
|
242
|
+
),
|
|
222
243
|
);
|
|
223
244
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
);
|
|
227
|
-
const newMessages = filterArray(
|
|
228
|
-
uniqueMessagesWithTimestampBytes,
|
|
229
|
-
(message) =>
|
|
230
|
-
!existingTimestampKeys.has(
|
|
231
|
-
uint8ArrayToBase64Url(message.timestamp),
|
|
232
|
-
),
|
|
233
|
-
);
|
|
245
|
+
// Nothing to write
|
|
246
|
+
if (!isNonEmptyArray(newMessages)) return null;
|
|
234
247
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
248
|
+
const usage = readOwnerUsageOrDefault(deps)(
|
|
249
|
+
ownerIdBytes,
|
|
250
|
+
firstInArray(newMessages).timestamp,
|
|
251
|
+
);
|
|
239
252
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
);
|
|
253
|
+
return { newMessages, usage };
|
|
254
|
+
}, createUnknownError);
|
|
255
|
+
if (!readResult.ok) return readResult;
|
|
256
|
+
if (readResult.value === null) return ok();
|
|
257
|
+
const { newMessages, usage } = readResult.value;
|
|
244
258
|
|
|
245
259
|
const incomingBytes = newMessages.reduce(
|
|
246
260
|
(sum, m) => sum + m.change.length,
|
|
@@ -260,7 +274,7 @@ export const createRelaySqliteStorage =
|
|
|
260
274
|
? await quotaResult
|
|
261
275
|
: quotaResult;
|
|
262
276
|
if (!isWithinQuota) {
|
|
263
|
-
return err<
|
|
277
|
+
return err<StorageWriteMessagesError>({
|
|
264
278
|
type: "StorageQuotaError",
|
|
265
279
|
ownerId,
|
|
266
280
|
});
|
|
@@ -268,39 +282,42 @@ export const createRelaySqliteStorage =
|
|
|
268
282
|
|
|
269
283
|
let { firstTimestamp, lastTimestamp } = usage;
|
|
270
284
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
285
|
+
// SQLite can fail the write, for example on a full disk. The
|
|
286
|
+
// transaction has rolled back, and a throw would panic the relay's
|
|
287
|
+
// shared Run.
|
|
288
|
+
return trySync(() => {
|
|
289
|
+
deps.sqlite.transaction(() => {
|
|
290
|
+
for (const { timestamp, change } of newMessages) {
|
|
291
|
+
let strategy;
|
|
292
|
+
[strategy, firstTimestamp, lastTimestamp] =
|
|
293
|
+
getTimestampInsertStrategy(
|
|
294
|
+
timestamp,
|
|
295
|
+
firstTimestamp,
|
|
296
|
+
lastTimestamp,
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
sqliteStorageBase.insertTimestamp(
|
|
300
|
+
ownerIdBytes,
|
|
276
301
|
timestamp,
|
|
277
|
-
|
|
278
|
-
lastTimestamp,
|
|
302
|
+
strategy,
|
|
279
303
|
);
|
|
280
304
|
|
|
281
|
-
|
|
305
|
+
deps.sqlite.exec(sql`
|
|
306
|
+
insert into evolu_message
|
|
307
|
+
("ownerId", "timestamp", "change")
|
|
308
|
+
values (${ownerIdBytes}, ${timestamp}, ${change})
|
|
309
|
+
on conflict do nothing;
|
|
310
|
+
`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
updateOwnerUsage(deps)(
|
|
282
314
|
ownerIdBytes,
|
|
283
|
-
|
|
284
|
-
|
|
315
|
+
newStoredBytes,
|
|
316
|
+
firstTimestamp,
|
|
317
|
+
lastTimestamp,
|
|
285
318
|
);
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
insert into evolu_message
|
|
289
|
-
("ownerId", "timestamp", "change")
|
|
290
|
-
values (${ownerIdBytes}, ${timestamp}, ${change})
|
|
291
|
-
on conflict do nothing;
|
|
292
|
-
`);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
updateOwnerUsage(deps)(
|
|
296
|
-
ownerIdBytes,
|
|
297
|
-
newStoredBytes,
|
|
298
|
-
firstTimestamp,
|
|
299
|
-
lastTimestamp,
|
|
300
|
-
);
|
|
301
|
-
|
|
302
|
-
return ok();
|
|
303
|
-
});
|
|
319
|
+
});
|
|
320
|
+
}, createUnknownError);
|
|
304
321
|
}),
|
|
305
322
|
);
|
|
306
323
|
},
|
|
@@ -329,13 +346,7 @@ export const createRelaySqliteStorage =
|
|
|
329
346
|
delete from evolu_message where ownerId = ${ownerId};
|
|
330
347
|
`);
|
|
331
348
|
|
|
332
|
-
deps.sqlite.exec(sql`
|
|
333
|
-
delete from evolu_usage where ownerId = ${ownerId};
|
|
334
|
-
`);
|
|
335
|
-
|
|
336
349
|
sqliteStorageBase.deleteOwner(ownerId);
|
|
337
|
-
|
|
338
|
-
return ok();
|
|
339
350
|
});
|
|
340
351
|
},
|
|
341
352
|
};
|
|
@@ -21,7 +21,11 @@ import type { ConsoleEntry, ConsoleLevel } from "../Console.ts";
|
|
|
21
21
|
import type { DecryptWithXChaCha20Poly1305Error } from "../Crypto.ts";
|
|
22
22
|
import { createUnknownError, UnknownError } from "../Error.ts";
|
|
23
23
|
import { isPlainObject } from "../Object.ts";
|
|
24
|
-
import type {
|
|
24
|
+
import type {
|
|
25
|
+
DatabaseHeldError,
|
|
26
|
+
DbWorkerInit,
|
|
27
|
+
UnsupportedDbVersionError,
|
|
28
|
+
} from "./Db.ts";
|
|
25
29
|
import {
|
|
26
30
|
createAppOwner,
|
|
27
31
|
createOwnerSecret,
|
|
@@ -11442,4 +11446,36 @@ describe("startup refusal", () => {
|
|
|
11442
11446
|
await testWaitForWorkerMessage();
|
|
11443
11447
|
assertEqual(leaderInputs, [{ type: "Dispose" }]);
|
|
11444
11448
|
});
|
|
11449
|
+
|
|
11450
|
+
const heldRefusal: DatabaseHeldError = {
|
|
11451
|
+
type: "DatabaseHeldError",
|
|
11452
|
+
name: testName,
|
|
11453
|
+
};
|
|
11454
|
+
|
|
11455
|
+
for (const error of [heldRefusal, refusal])
|
|
11456
|
+
it(`disposes a worker that acquired leadership before the ${error.type} refusal of a worker requested earlier arrived`, async () => {
|
|
11457
|
+
await using setup = await setupSharedWorker();
|
|
11458
|
+
await using disposer = new AsyncDisposableStack();
|
|
11459
|
+
const instance = await setup.createEvoluBeforeDbWorkerLeader();
|
|
11460
|
+
const initDbWorker = await setupTabLeader(setup, disposer);
|
|
11461
|
+
using leaderPort = testCreateMessagePort<DbWorkerOutput, DbWorkerInput>(
|
|
11462
|
+
initDbWorker.port,
|
|
11463
|
+
);
|
|
11464
|
+
const leaderInputs: Array<DbWorkerInput> = [];
|
|
11465
|
+
leaderPort.onMessage = (input) => {
|
|
11466
|
+
leaderInputs.push(input);
|
|
11467
|
+
};
|
|
11468
|
+
|
|
11469
|
+
// Browsers deliver the refusal first, because the refusing worker posts
|
|
11470
|
+
// it while it still holds the database lock the other one waits for.
|
|
11471
|
+
// The tenant does not depend on that order.
|
|
11472
|
+
leaderPort.postMessage({
|
|
11473
|
+
type: "LeaderAcquired",
|
|
11474
|
+
clock: createTimestamp(),
|
|
11475
|
+
});
|
|
11476
|
+
await testWaitForWorkerMessage();
|
|
11477
|
+
instance.dbWorkerPort.postMessage({ type: "LeaderRefused", error });
|
|
11478
|
+
await testWaitForWorkerMessage();
|
|
11479
|
+
assertEqual(leaderInputs, [{ type: "Dispose" }]);
|
|
11480
|
+
});
|
|
11445
11481
|
});
|