@evolu/common 8.0.0-next.0 → 8.0.0-next.2
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/LICENSE +21 -0
- package/dist/src/Lookup.d.ts +2 -0
- package/dist/src/Lookup.d.ts.map +1 -1
- package/dist/src/Lookup.js +8 -0
- package/dist/src/Polyfills.d.ts +7 -5
- package/dist/src/Polyfills.d.ts.map +1 -1
- package/dist/src/Polyfills.js +35 -5
- package/dist/src/Task.d.ts +11 -0
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +11 -0
- package/dist/src/Test.d.ts +28 -0
- package/dist/src/Test.d.ts.map +1 -1
- package/dist/src/Test.js +31 -0
- package/dist/src/Time.d.ts +4 -3
- package/dist/src/Time.d.ts.map +1 -1
- package/dist/src/Time.js +14 -7
- package/dist/src/Type.d.ts +6 -1
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +8 -0
- package/dist/src/WebSocket.d.ts +21 -4
- package/dist/src/WebSocket.d.ts.map +1 -1
- package/dist/src/WebSocket.js +85 -16
- package/dist/src/local-first/Db.d.ts +2 -2
- package/dist/src/local-first/Db.d.ts.map +1 -1
- package/dist/src/local-first/Db.js +18 -19
- package/dist/src/local-first/Protocol.d.ts +2 -2
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Relay.d.ts +18 -13
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Relay.js +11 -17
- package/dist/src/local-first/Shared.d.ts +1 -1
- package/dist/src/local-first/Shared.d.ts.map +1 -1
- package/dist/src/local-first/Storage.d.ts +6 -7
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/dist/src/local-first/Storage.js +6 -6
- package/package.json +15 -12
- package/src/Lookup.ts +14 -0
- package/src/Polyfills.ts +60 -5
- package/src/Task.ts +11 -0
- package/src/Test.ts +41 -1
- package/src/Time.ts +15 -8
- package/src/Type.ts +15 -0
- package/src/WebSocket.ts +125 -21
- package/src/local-first/Db.ts +20 -24
- package/src/local-first/Protocol.ts +2 -5
- package/src/local-first/Relay.ts +46 -39
- package/src/local-first/Shared.ts +2 -2
- package/src/local-first/Storage.ts +11 -12
package/src/WebSocket.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { assert } from "./Assert.js";
|
|
7
8
|
import { lazyTrue } from "./Function.js";
|
|
8
9
|
import type { Result } from "./Result.js";
|
|
9
10
|
import { err, ok } from "./Result.js";
|
|
@@ -12,8 +13,7 @@ import { exponential, jitter, maxDelay } from "./Schedule.js";
|
|
|
12
13
|
import type { RetryError, Task } from "./Task.js";
|
|
13
14
|
import { callback, retry } from "./Task.js";
|
|
14
15
|
import type { Millis } from "./Time.js";
|
|
15
|
-
import { String, type Typed } from "./Type.js";
|
|
16
|
-
import { assert } from "./Assert.js";
|
|
16
|
+
import { ArrayBuffer, String, Uint8Array, type Typed } from "./Type.js";
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* WebSocket with auto-reconnect.
|
|
@@ -27,7 +27,9 @@ import { assert } from "./Assert.js";
|
|
|
27
27
|
*
|
|
28
28
|
* Created via {@link createWebSocket} which returns a {@link Task}.
|
|
29
29
|
*
|
|
30
|
-
* Disposing the
|
|
30
|
+
* Disposing starts closing the connection without waiting for the close event
|
|
31
|
+
* so disposal stays immediate. This wrapper treats disposal as local teardown,
|
|
32
|
+
* not as waiting for the full WebSocket close handshake to finish.
|
|
31
33
|
*
|
|
32
34
|
* ## How Binary Messages Work
|
|
33
35
|
*
|
|
@@ -69,7 +71,7 @@ export interface WebSocket extends AsyncDisposable {
|
|
|
69
71
|
* error if the data couldn't be sent.
|
|
70
72
|
*/
|
|
71
73
|
send: (
|
|
72
|
-
data:
|
|
74
|
+
data: BufferSource | Blob | string | globalThis.Uint8Array,
|
|
73
75
|
) => Result<void, WebSocketSendError>;
|
|
74
76
|
|
|
75
77
|
readonly getReadyState: () => WebSocketReadyState;
|
|
@@ -201,7 +203,6 @@ export const createWebSocket: CreateWebSocket =
|
|
|
201
203
|
await using stack = new AsyncDisposableStack();
|
|
202
204
|
|
|
203
205
|
let socket: globalThis.WebSocket | null = null;
|
|
204
|
-
let disposed = false;
|
|
205
206
|
|
|
206
207
|
const closeSocket = () => {
|
|
207
208
|
if (!socket) return;
|
|
@@ -284,36 +285,54 @@ export const createWebSocket: CreateWebSocket =
|
|
|
284
285
|
if (!socket || socket.readyState === socket.CONNECTING) {
|
|
285
286
|
return err({ type: "WebSocketSendError" });
|
|
286
287
|
}
|
|
287
|
-
socket.send(data);
|
|
288
|
+
socket.send(ensureSendableData(data));
|
|
288
289
|
return ok();
|
|
289
290
|
},
|
|
290
291
|
|
|
291
292
|
getReadyState: () => {
|
|
292
|
-
if (disposed) return "closed";
|
|
293
|
+
if (moved.disposed) return "closed";
|
|
293
294
|
return socket ? nativeToStringState[socket.readyState] : "connecting";
|
|
294
295
|
},
|
|
295
296
|
|
|
296
297
|
isOpen: () =>
|
|
297
|
-
!disposed && socket?.readyState === globalThis.WebSocket.OPEN,
|
|
298
|
+
!moved.disposed && socket?.readyState === globalThis.WebSocket.OPEN,
|
|
298
299
|
|
|
299
|
-
[Symbol.asyncDispose]:
|
|
300
|
-
disposed = true;
|
|
301
|
-
await moved.disposeAsync();
|
|
302
|
-
},
|
|
300
|
+
[Symbol.asyncDispose]: () => moved.disposeAsync(),
|
|
303
301
|
});
|
|
304
302
|
};
|
|
305
303
|
|
|
306
|
-
/**
|
|
304
|
+
/** Clones SharedArrayBuffer-backed Uint8Array values before WebSocket.send. */
|
|
305
|
+
const ensureSendableData = (
|
|
306
|
+
data: BufferSource | Blob | string | globalThis.Uint8Array,
|
|
307
|
+
): BufferSource | Blob | string => {
|
|
308
|
+
if (!Uint8Array.is(data)) return data;
|
|
309
|
+
return ArrayBuffer.is(data.buffer)
|
|
310
|
+
? (data as globalThis.Uint8Array<ArrayBuffer>)
|
|
311
|
+
: new globalThis.Uint8Array(data);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const nativeToStringState: Record<number, WebSocketReadyState> = {
|
|
315
|
+
[globalThis.WebSocket.CONNECTING]: "connecting",
|
|
316
|
+
[globalThis.WebSocket.OPEN]: "open",
|
|
317
|
+
[globalThis.WebSocket.CLOSING]: "closing",
|
|
318
|
+
[globalThis.WebSocket.CLOSED]: "closed",
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* An inspectable in-memory {@link CreateWebSocket} for testing by
|
|
323
|
+
* {@link testCreateWebSocket}.
|
|
324
|
+
*/
|
|
307
325
|
export interface TestCreateWebSocket extends CreateWebSocket {
|
|
308
326
|
readonly createdUrls: Array<string>;
|
|
309
327
|
readonly sentMessages: Array<{
|
|
310
328
|
readonly url: string;
|
|
311
|
-
readonly data:
|
|
329
|
+
readonly data: BufferSource | Blob | string | globalThis.Uint8Array;
|
|
312
330
|
}>;
|
|
313
331
|
readonly message: (url: string, data: string | ArrayBuffer | Blob) => void;
|
|
314
332
|
readonly open: (url: string) => void;
|
|
315
333
|
}
|
|
316
334
|
|
|
335
|
+
/** Creates {@link TestCreateWebSocket}. */
|
|
317
336
|
export const testCreateWebSocket = (
|
|
318
337
|
options: {
|
|
319
338
|
/** Throw immediately when a socket is created. */
|
|
@@ -326,7 +345,7 @@ export const testCreateWebSocket = (
|
|
|
326
345
|
const createdUrls: Array<string> = [];
|
|
327
346
|
const sentMessages: Array<{
|
|
328
347
|
readonly url: string;
|
|
329
|
-
readonly data:
|
|
348
|
+
readonly data: BufferSource | Blob | string | globalThis.Uint8Array;
|
|
330
349
|
}> = [];
|
|
331
350
|
const stateByUrl = new Map<
|
|
332
351
|
string,
|
|
@@ -361,7 +380,10 @@ export const testCreateWebSocket = (
|
|
|
361
380
|
if (state.isDisposed || !state.isOpen) {
|
|
362
381
|
return err({ type: "WebSocketSendError" });
|
|
363
382
|
}
|
|
364
|
-
sentMessages.push({
|
|
383
|
+
sentMessages.push({
|
|
384
|
+
url,
|
|
385
|
+
data: ensureSendableData(data),
|
|
386
|
+
});
|
|
365
387
|
return ok();
|
|
366
388
|
},
|
|
367
389
|
|
|
@@ -399,9 +421,91 @@ export const testCreateWebSocket = (
|
|
|
399
421
|
});
|
|
400
422
|
};
|
|
401
423
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
424
|
+
/**
|
|
425
|
+
* A native {@link WebSocket} prepared for integration tests by
|
|
426
|
+
* {@link testSetupWebSocket}.
|
|
427
|
+
*/
|
|
428
|
+
export interface TestSetupWebSocket extends AsyncDisposable {
|
|
429
|
+
readonly socket: globalThis.WebSocket;
|
|
430
|
+
readonly send: (
|
|
431
|
+
data: BufferSource | Blob | string | globalThis.Uint8Array,
|
|
432
|
+
) => void;
|
|
433
|
+
readonly waitForMessage: () => Promise<string | globalThis.Uint8Array>;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Opens a native {@link WebSocket} and returns {@link TestSetupWebSocket}. */
|
|
437
|
+
export const testSetupWebSocket = async (
|
|
438
|
+
url: string,
|
|
439
|
+
): Promise<TestSetupWebSocket> => {
|
|
440
|
+
const socket = new globalThis.WebSocket(url);
|
|
441
|
+
socket.binaryType = "arraybuffer";
|
|
442
|
+
|
|
443
|
+
await new Promise<void>((resolve, reject) => {
|
|
444
|
+
const onOpen = () => {
|
|
445
|
+
cleanup();
|
|
446
|
+
resolve();
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const onError = () => {
|
|
450
|
+
cleanup();
|
|
451
|
+
socket.close();
|
|
452
|
+
reject(new Error("WebSocket connection failed"));
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const cleanup = () => {
|
|
456
|
+
socket.removeEventListener("open", onOpen);
|
|
457
|
+
socket.removeEventListener("error", onError);
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
socket.addEventListener("open", onOpen, { once: true });
|
|
461
|
+
socket.addEventListener("error", onError, { once: true });
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
socket,
|
|
466
|
+
send: (data) => {
|
|
467
|
+
socket.send(ensureSendableData(data));
|
|
468
|
+
},
|
|
469
|
+
waitForMessage: () =>
|
|
470
|
+
new Promise((resolve, reject) => {
|
|
471
|
+
if (socket.readyState === globalThis.WebSocket.CLOSED) {
|
|
472
|
+
reject(new Error("WebSocket closed before message"));
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const onMessage = (event: MessageEvent) => {
|
|
477
|
+
cleanup();
|
|
478
|
+
|
|
479
|
+
if (typeof event.data === "string") {
|
|
480
|
+
resolve(event.data);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
resolve(new globalThis.Uint8Array(event.data as ArrayBuffer));
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const onClose = () => {
|
|
488
|
+
cleanup();
|
|
489
|
+
reject(new Error("WebSocket closed before message"));
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const cleanup = () => {
|
|
493
|
+
socket.removeEventListener("message", onMessage);
|
|
494
|
+
socket.removeEventListener("close", onClose);
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
socket.addEventListener("message", onMessage, { once: true });
|
|
498
|
+
socket.addEventListener("close", onClose, { once: true });
|
|
499
|
+
}),
|
|
500
|
+
[Symbol.asyncDispose]: async () => {
|
|
501
|
+
if (socket.readyState === globalThis.WebSocket.CLOSED) return;
|
|
502
|
+
|
|
503
|
+
const closed = new Promise<void>((resolve) => {
|
|
504
|
+
socket.addEventListener("close", () => resolve(), { once: true });
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
socket.close();
|
|
508
|
+
await closed;
|
|
509
|
+
},
|
|
510
|
+
};
|
|
407
511
|
};
|
package/src/local-first/Db.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type NonEmptyArray,
|
|
11
11
|
type NonEmptyReadonlyArray,
|
|
12
12
|
} from "../Array.js";
|
|
13
|
-
import { assert, assertNonEmptyReadonlyArray } from "../Assert.js";
|
|
13
|
+
import { assert, assertNonEmptyReadonlyArray, assertType } from "../Assert.js";
|
|
14
14
|
import type { ConsoleLevel } from "../Console.js";
|
|
15
15
|
import {
|
|
16
16
|
EncryptionKey,
|
|
@@ -77,8 +77,8 @@ import {
|
|
|
77
77
|
createBaseSqliteStorage,
|
|
78
78
|
createBaseSqliteStorageTables,
|
|
79
79
|
DbChange,
|
|
80
|
-
getOwnerUsage,
|
|
81
80
|
getTimestampInsertStrategy,
|
|
81
|
+
readOwnerUsageOrDefault,
|
|
82
82
|
updateOwnerUsage,
|
|
83
83
|
type BaseSqliteStorage,
|
|
84
84
|
type BaseSqliteStorageDep,
|
|
@@ -120,10 +120,7 @@ export interface CreateDbWorkerDep {
|
|
|
120
120
|
readonly createDbWorker: CreateDbWorker;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
export type DbWorkerDeps = WorkerDeps &
|
|
124
|
-
LeaderLockDep &
|
|
125
|
-
CreateSqliteDriverDep &
|
|
126
|
-
RandomBytesDep;
|
|
123
|
+
export type DbWorkerDeps = WorkerDeps & LeaderLockDep & CreateSqliteDriverDep;
|
|
127
124
|
|
|
128
125
|
export const startDbWorker =
|
|
129
126
|
(self: WorkerSelf<DbWorkerInit>): Task<void, never, DbWorkerDeps> =>
|
|
@@ -207,10 +204,14 @@ export const startDbWorker =
|
|
|
207
204
|
const runWithStorage = run.addDeps({ storage });
|
|
208
205
|
|
|
209
206
|
/**
|
|
210
|
-
* SharedWorker
|
|
211
|
-
*
|
|
207
|
+
* SharedWorker retries until some leader replies, so this worker must
|
|
208
|
+
* ignore callback IDs it already completed.
|
|
212
209
|
*
|
|
213
|
-
*
|
|
210
|
+
* The Set intentionally lives for one leader's lifetime. If that leader tab
|
|
211
|
+
* closes, retries go to a new leader with a fresh Set. Revisit this only if
|
|
212
|
+
* memory pressure shows up; even millions of short callback IDs are
|
|
213
|
+
* acceptable here, and naive eviction could let a still-retried request run
|
|
214
|
+
* again.
|
|
214
215
|
*/
|
|
215
216
|
const processedRequestIds = new Set<Id>();
|
|
216
217
|
|
|
@@ -285,15 +286,12 @@ export const startDbWorker =
|
|
|
285
286
|
>();
|
|
286
287
|
|
|
287
288
|
for (const owner of request.message.owners) {
|
|
288
|
-
storage.
|
|
289
|
+
storage.setRequestContext(owner.encryptionKey);
|
|
289
290
|
const protocolMessage = createProtocolMessageForSync({
|
|
290
291
|
storage,
|
|
291
292
|
console,
|
|
292
293
|
})(owner.id, SubscriptionFlags.Subscribe);
|
|
293
|
-
|
|
294
|
-
if (protocolMessage) {
|
|
295
|
-
protocolMessagesByOwnerId.set(owner.id, protocolMessage);
|
|
296
|
-
}
|
|
294
|
+
protocolMessagesByOwnerId.set(owner.id, protocolMessage);
|
|
297
295
|
}
|
|
298
296
|
|
|
299
297
|
postQueuedResponse({
|
|
@@ -310,7 +308,7 @@ export const startDbWorker =
|
|
|
310
308
|
const { owner, inputMessage } = request.message;
|
|
311
309
|
|
|
312
310
|
runWithStorage<void, never>(async (run) => {
|
|
313
|
-
storage.
|
|
311
|
+
storage.setRequestContext(owner.encryptionKey);
|
|
314
312
|
|
|
315
313
|
const result = await run(
|
|
316
314
|
applyProtocolMessageAsClient(inputMessage, {
|
|
@@ -611,7 +609,7 @@ const applyColumnChange =
|
|
|
611
609
|
* implementation, and switch owner encryption keys between requests.
|
|
612
610
|
*/
|
|
613
611
|
interface ClientStorage extends Storage, BaseSqliteStorage {
|
|
614
|
-
readonly
|
|
612
|
+
readonly setRequestContext: (encryptionKey: EncryptionKey) => void;
|
|
615
613
|
readonly didWriteMessages: () => boolean;
|
|
616
614
|
}
|
|
617
615
|
|
|
@@ -653,7 +651,7 @@ const createClientStorage =
|
|
|
653
651
|
// same file.
|
|
654
652
|
// This is safe because the worker handles one message at a time. We will
|
|
655
653
|
// refactor it later, we will probably have to change Protocol API.
|
|
656
|
-
|
|
654
|
+
setRequestContext: (nextEncryptionKey) => {
|
|
657
655
|
encryptionKey = nextEncryptionKey;
|
|
658
656
|
didWriteMessages = false;
|
|
659
657
|
},
|
|
@@ -743,9 +741,8 @@ const createClientStorage =
|
|
|
743
741
|
isInsert = false;
|
|
744
742
|
break;
|
|
745
743
|
case "isDeleted":
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
}
|
|
744
|
+
assertType(SqliteBoolean, r.value);
|
|
745
|
+
isDelete = sqliteBooleanToBoolean(r.value);
|
|
749
746
|
break;
|
|
750
747
|
default:
|
|
751
748
|
values[r.column] = r.value;
|
|
@@ -843,7 +840,7 @@ const applyLocalOnlyChange =
|
|
|
843
840
|
if (change.isDelete) {
|
|
844
841
|
deps.sqlite.exec(sql`
|
|
845
842
|
delete from ${sql.identifier(change.table)}
|
|
846
|
-
where id = ${change.id};
|
|
843
|
+
where "ownerId" = ${change.ownerId} and "id" = ${change.id};
|
|
847
844
|
`);
|
|
848
845
|
} else {
|
|
849
846
|
const ownerId = change.ownerId;
|
|
@@ -866,13 +863,12 @@ const applyMessages =
|
|
|
866
863
|
(ownerId: OwnerId, messages: NonEmptyReadonlyArray<CrdtMessage>): void => {
|
|
867
864
|
const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
|
|
868
865
|
|
|
869
|
-
const usage =
|
|
866
|
+
const usage = readOwnerUsageOrDefault(deps)(
|
|
870
867
|
ownerIdBytes,
|
|
871
868
|
timestampToTimestampBytes(firstInArray(messages).timestamp),
|
|
872
869
|
);
|
|
873
|
-
if (!usage.ok) return;
|
|
874
870
|
|
|
875
|
-
let { firstTimestamp, lastTimestamp } = usage
|
|
871
|
+
let { firstTimestamp, lastTimestamp } = usage;
|
|
876
872
|
|
|
877
873
|
for (const { timestamp, change } of messages) {
|
|
878
874
|
const columns = dbChangeToColumns(change, timestamp.millis);
|
|
@@ -181,7 +181,6 @@ import { Packr } from "msgpackr";
|
|
|
181
181
|
import { isNonEmptyArray, type NonEmptyReadonlyArray } from "../Array.js";
|
|
182
182
|
import { assert } from "../Assert.js";
|
|
183
183
|
import type { Brand } from "../Brand.js";
|
|
184
|
-
import type { ConsoleDep } from "../Console.js";
|
|
185
184
|
import {
|
|
186
185
|
type Buffer,
|
|
187
186
|
bytesToHex,
|
|
@@ -190,6 +189,7 @@ import {
|
|
|
190
189
|
hexToBytes,
|
|
191
190
|
utf8ToBytes,
|
|
192
191
|
} from "../Buffer.js";
|
|
192
|
+
import type { ConsoleDep } from "../Console.js";
|
|
193
193
|
import {
|
|
194
194
|
createPadmePadding,
|
|
195
195
|
decryptWithXChaCha20Poly1305,
|
|
@@ -548,10 +548,7 @@ export const createProtocolMessageFromCrdtMessages =
|
|
|
548
548
|
/** Creates a {@link ProtocolMessage} for sync. */
|
|
549
549
|
export const createProtocolMessageForSync =
|
|
550
550
|
(deps: StorageDep & ConsoleDep) =>
|
|
551
|
-
(
|
|
552
|
-
ownerId: OwnerId,
|
|
553
|
-
subscriptionFlag?: SubscriptionFlag,
|
|
554
|
-
): ProtocolMessage | null => {
|
|
551
|
+
(ownerId: OwnerId, subscriptionFlag?: SubscriptionFlag): ProtocolMessage => {
|
|
555
552
|
const buffer = createProtocolMessageBuffer(ownerId, {
|
|
556
553
|
messageType: MessageType.Request,
|
|
557
554
|
subscriptionFlag: subscriptionFlag ?? SubscriptionFlags.None,
|
package/src/local-first/Relay.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
+
dedupeArray,
|
|
8
9
|
filterArray,
|
|
9
10
|
firstInArray,
|
|
10
11
|
isNonEmptyArray,
|
|
@@ -12,11 +13,11 @@ import {
|
|
|
12
13
|
} from "../Array.js";
|
|
13
14
|
import { assert } from "../Assert.js";
|
|
14
15
|
import type { TimingSafeEqualDep } from "../Crypto.js";
|
|
15
|
-
import { err,
|
|
16
|
+
import { err, ok } from "../Result.js";
|
|
16
17
|
import type { SqliteDep } from "../Sqlite.js";
|
|
17
18
|
import { sql } from "../Sqlite.js";
|
|
18
19
|
import { createMutexByKey } from "../Task.js";
|
|
19
|
-
import { Name, PositiveInt } from "../Type.js";
|
|
20
|
+
import { Name, PositiveInt, uint8ArrayToBase64Url } from "../Type.js";
|
|
20
21
|
import { isPromiseLike, type Awaitable } from "../Types.js";
|
|
21
22
|
import {
|
|
22
23
|
OwnerId,
|
|
@@ -33,8 +34,8 @@ import type {
|
|
|
33
34
|
} from "./Storage.js";
|
|
34
35
|
import {
|
|
35
36
|
createBaseSqliteStorage,
|
|
36
|
-
getOwnerUsage,
|
|
37
37
|
getTimestampInsertStrategy,
|
|
38
|
+
readOwnerUsageOrDefault,
|
|
38
39
|
updateOwnerUsage,
|
|
39
40
|
} from "./Storage.js";
|
|
40
41
|
import { timestampToTimestampBytes } from "./Timestamp.js";
|
|
@@ -52,8 +53,9 @@ export interface RelayConfig extends StorageConfig {
|
|
|
52
53
|
* Optional callback to check if an {@link OwnerId} is allowed to access the
|
|
53
54
|
* relay. If this callback is not provided, all owners are allowed.
|
|
54
55
|
*
|
|
55
|
-
* The callback receives the {@link OwnerId} and
|
|
56
|
-
*
|
|
56
|
+
* The callback receives the {@link OwnerId} and an options object with an
|
|
57
|
+
* abort `AbortSignal`, and returns a {@link Awaitable} boolean: `true` to
|
|
58
|
+
* allow access, or `false` to deny.
|
|
57
59
|
*
|
|
58
60
|
* The callback can be synchronous (for SQLite or in-memory checks) or
|
|
59
61
|
* asynchronous (for calling remote APIs).
|
|
@@ -68,9 +70,9 @@ export interface RelayConfig extends StorageConfig {
|
|
|
68
70
|
*
|
|
69
71
|
* Owners specify which relays to connect to via `OwnerTransport`. In
|
|
70
72
|
* WebSocket-based implementations, this check occurs before accepting the
|
|
71
|
-
* connection, with the OwnerId typically extracted from the URL
|
|
72
|
-
* `ws://localhost:4000
|
|
73
|
-
* correct format for OwnerId extraction.
|
|
73
|
+
* connection, with the OwnerId typically extracted from the URL query string
|
|
74
|
+
* (e.g., `ws://localhost:4000?ownerId=...`). The relay requires the URL to be
|
|
75
|
+
* in the correct format for OwnerId extraction.
|
|
74
76
|
*
|
|
75
77
|
* ### Example
|
|
76
78
|
*
|
|
@@ -87,23 +89,31 @@ export interface RelayConfig extends StorageConfig {
|
|
|
87
89
|
*
|
|
88
90
|
*
|
|
89
91
|
* // Relay
|
|
90
|
-
* isOwnerAllowed: (ownerId) =>
|
|
92
|
+
* isOwnerAllowed: (ownerId, { signal: _signal }) =>
|
|
91
93
|
* Promise.resolve(ownerId === "6jy_2F4RT5qqeLgJ14_dnQ"),
|
|
92
94
|
* ```
|
|
93
95
|
*/
|
|
94
|
-
readonly isOwnerAllowed?: (
|
|
96
|
+
readonly isOwnerAllowed?: (
|
|
97
|
+
ownerId: OwnerId,
|
|
98
|
+
options: {
|
|
99
|
+
/** Aborted when the relay stops waiting for the access check. */
|
|
100
|
+
readonly signal: AbortSignal;
|
|
101
|
+
},
|
|
102
|
+
) => Awaitable<boolean>;
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
/**
|
|
98
|
-
*
|
|
99
|
-
* between Evolu clients.
|
|
106
|
+
* Sync and backup relay for Evolu clients.
|
|
100
107
|
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
108
|
+
* A relay syncs and backs up encrypted data for Evolu apps. Evolu apps can use
|
|
109
|
+
* multiple relays at the same time, combining self-hosted and cloud relays for
|
|
110
|
+
* resilience. Relays are blind by design: they transmit and store encrypted
|
|
111
|
+
* data without understanding its shape or meaning.
|
|
105
112
|
*/
|
|
106
|
-
export interface Relay extends AsyncDisposable {
|
|
113
|
+
export interface Relay extends AsyncDisposable {
|
|
114
|
+
/** The TCP port actually bound by the relay. */
|
|
115
|
+
readonly port: number;
|
|
116
|
+
}
|
|
107
117
|
|
|
108
118
|
export const createRelaySqliteStorage =
|
|
109
119
|
(deps: SqliteStorageDeps & TimingSafeEqualDep) =>
|
|
@@ -158,25 +168,31 @@ export const createRelaySqliteStorage =
|
|
|
158
168
|
|
|
159
169
|
writeMessages: (ownerIdBytes, messages) => async (run) => {
|
|
160
170
|
const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
171
|
+
const uniqueMessagesWithTimestampBytes = dedupeArray(
|
|
172
|
+
mapArray(messages, (m) => ({
|
|
173
|
+
timestamp: timestampToTimestampBytes(m.timestamp),
|
|
174
|
+
change: m.change,
|
|
175
|
+
})),
|
|
176
|
+
(message) => uint8ArrayToBase64Url(message.timestamp),
|
|
177
|
+
);
|
|
165
178
|
|
|
166
|
-
|
|
179
|
+
return run(
|
|
167
180
|
mutexByOwnerId.withLock(ownerId, async () => {
|
|
168
181
|
const existingTimestampsResult =
|
|
169
182
|
sqliteStorageBase.getExistingTimestamps(
|
|
170
183
|
ownerIdBytes,
|
|
171
|
-
mapArray(
|
|
184
|
+
mapArray(uniqueMessagesWithTimestampBytes, (m) => m.timestamp),
|
|
172
185
|
);
|
|
173
186
|
|
|
174
|
-
const
|
|
175
|
-
existingTimestampsResult
|
|
187
|
+
const existingTimestampKeys = new Set(
|
|
188
|
+
mapArray(existingTimestampsResult, uint8ArrayToBase64Url),
|
|
176
189
|
);
|
|
177
190
|
const newMessages = filterArray(
|
|
178
|
-
|
|
179
|
-
(
|
|
191
|
+
uniqueMessagesWithTimestampBytes,
|
|
192
|
+
(message) =>
|
|
193
|
+
!existingTimestampKeys.has(
|
|
194
|
+
uint8ArrayToBase64Url(message.timestamp),
|
|
195
|
+
),
|
|
180
196
|
);
|
|
181
197
|
|
|
182
198
|
// Nothing to write
|
|
@@ -184,11 +200,9 @@ export const createRelaySqliteStorage =
|
|
|
184
200
|
return ok();
|
|
185
201
|
}
|
|
186
202
|
|
|
187
|
-
const usage =
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
firstInArray(newMessages).timestamp,
|
|
191
|
-
),
|
|
203
|
+
const usage = readOwnerUsageOrDefault(deps)(
|
|
204
|
+
ownerIdBytes,
|
|
205
|
+
firstInArray(newMessages).timestamp,
|
|
192
206
|
);
|
|
193
207
|
|
|
194
208
|
const incomingBytes = newMessages.reduce(
|
|
@@ -250,13 +264,6 @@ export const createRelaySqliteStorage =
|
|
|
250
264
|
});
|
|
251
265
|
}),
|
|
252
266
|
);
|
|
253
|
-
|
|
254
|
-
if (!result.ok) {
|
|
255
|
-
if (result.error.type === "AbortError") return ok();
|
|
256
|
-
return result;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
return ok();
|
|
260
267
|
},
|
|
261
268
|
|
|
262
269
|
readDbChange: (ownerId, timestamp) => {
|
|
@@ -324,7 +324,7 @@ interface SharedEvolu extends AsyncDisposable {
|
|
|
324
324
|
|
|
325
325
|
readonly requestApplySyncMessage: (
|
|
326
326
|
ownerId: OwnerId,
|
|
327
|
-
inputMessage: Uint8Array
|
|
327
|
+
inputMessage: Uint8Array,
|
|
328
328
|
) => void;
|
|
329
329
|
}
|
|
330
330
|
|
|
@@ -364,7 +364,7 @@ export interface DbWorkerInput {
|
|
|
364
364
|
| {
|
|
365
365
|
readonly type: "ApplySyncMessage";
|
|
366
366
|
readonly owner: Owner;
|
|
367
|
-
readonly inputMessage: Uint8Array
|
|
367
|
+
readonly inputMessage: Uint8Array;
|
|
368
368
|
};
|
|
369
369
|
};
|
|
370
370
|
}
|
|
@@ -12,7 +12,6 @@ import type { Brand } from "../Brand.js";
|
|
|
12
12
|
import { concatBytes } from "../Buffer.js";
|
|
13
13
|
import { decrement } from "../Number.js";
|
|
14
14
|
import type { RandomDep } from "../Random.js";
|
|
15
|
-
import type { Result } from "../Result.js";
|
|
16
15
|
import { err, ok } from "../Result.js";
|
|
17
16
|
import type { SqliteDep } from "../Sqlite.js";
|
|
18
17
|
import { sql, SqliteValue } from "../Sqlite.js";
|
|
@@ -1569,17 +1568,17 @@ export const getTimestampByIndex =
|
|
|
1569
1568
|
return result.rows[0].pt;
|
|
1570
1569
|
};
|
|
1571
1570
|
|
|
1572
|
-
/**
|
|
1573
|
-
export const
|
|
1571
|
+
/** Reads owner usage from SQLite and returns default bounds when absent. */
|
|
1572
|
+
export const readOwnerUsageOrDefault =
|
|
1574
1573
|
(deps: SqliteDep) =>
|
|
1575
1574
|
(
|
|
1576
1575
|
ownerIdBytes: OwnerIdBytes,
|
|
1577
1576
|
initialTimestamp: TimestampBytes,
|
|
1578
|
-
):
|
|
1579
|
-
storedBytes: NonNegativeInt | null;
|
|
1580
|
-
firstTimestamp: TimestampBytes;
|
|
1581
|
-
lastTimestamp: TimestampBytes;
|
|
1582
|
-
}
|
|
1577
|
+
): {
|
|
1578
|
+
readonly storedBytes: NonNegativeInt | null;
|
|
1579
|
+
readonly firstTimestamp: TimestampBytes;
|
|
1580
|
+
readonly lastTimestamp: TimestampBytes;
|
|
1581
|
+
} => {
|
|
1583
1582
|
const result = deps.sqlite.exec<{
|
|
1584
1583
|
storedBytes: NonNegativeInt;
|
|
1585
1584
|
firstTimestamp: TimestampBytes | null;
|
|
@@ -1591,22 +1590,22 @@ export const getOwnerUsage =
|
|
|
1591
1590
|
`);
|
|
1592
1591
|
|
|
1593
1592
|
if (!isNonEmptyArray(result.rows)) {
|
|
1594
|
-
return
|
|
1593
|
+
return {
|
|
1595
1594
|
storedBytes: null,
|
|
1596
1595
|
firstTimestamp: initialTimestamp,
|
|
1597
1596
|
lastTimestamp: initialTimestamp,
|
|
1598
|
-
}
|
|
1597
|
+
};
|
|
1599
1598
|
}
|
|
1600
1599
|
|
|
1601
1600
|
const row = firstInArray(result.rows);
|
|
1602
1601
|
assert(row.firstTimestamp, "not null");
|
|
1603
1602
|
assert(row.lastTimestamp, "not null");
|
|
1604
1603
|
|
|
1605
|
-
return
|
|
1604
|
+
return {
|
|
1606
1605
|
storedBytes: row.storedBytes,
|
|
1607
1606
|
firstTimestamp: row.firstTimestamp,
|
|
1608
1607
|
lastTimestamp: row.lastTimestamp,
|
|
1609
|
-
}
|
|
1608
|
+
};
|
|
1610
1609
|
};
|
|
1611
1610
|
|
|
1612
1611
|
/**
|