@evolu/common 6.0.1-preview.20 → 6.0.1-preview.21
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/Evolu/Db.d.ts +23 -1
- package/dist/src/Evolu/Db.d.ts.map +1 -1
- package/dist/src/Evolu/Owner.d.ts +60 -0
- package/dist/src/Evolu/Owner.d.ts.map +1 -1
- package/dist/src/Evolu/Owner.js +33 -0
- package/dist/src/Evolu/Protocol.d.ts +91 -30
- package/dist/src/Evolu/Protocol.d.ts.map +1 -1
- package/dist/src/Evolu/Protocol.js +80 -25
- package/dist/src/Evolu/Relay.d.ts +70 -3
- package/dist/src/Evolu/Relay.d.ts.map +1 -1
- package/dist/src/Evolu/Relay.js +74 -1
- package/dist/src/Evolu/Sync.d.ts +2 -11
- package/dist/src/Evolu/Sync.d.ts.map +1 -1
- package/dist/src/Evolu/Sync.js +7 -46
- package/dist/src/Result.d.ts +45 -16
- package/dist/src/Result.d.ts.map +1 -1
- package/dist/src/Result.js +23 -0
- package/dist/src/Task.js +1 -1
- package/dist/src/Type.d.ts +12 -0
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +12 -0
- package/package.json +1 -1
- package/src/Evolu/Db.ts +24 -1
- package/src/Evolu/Owner.ts +70 -1
- package/src/Evolu/Protocol.ts +117 -36
- package/src/Evolu/Relay.ts +180 -6
- package/src/Evolu/Sync.ts +7 -57
- package/src/Result.ts +47 -16
- package/src/Task.ts +3 -3
- package/src/Type.ts +12 -0
package/src/Evolu/Protocol.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* eslint-disable jsdoc/no-undefined-types */
|
|
2
1
|
/**
|
|
3
2
|
* Evolu Protocol
|
|
4
3
|
*
|
|
@@ -21,7 +20,7 @@
|
|
|
21
20
|
* transfer, ownership, real-time broadcasting, request-response semantics, and
|
|
22
21
|
* error handling.
|
|
23
22
|
*
|
|
24
|
-
* ### Message
|
|
23
|
+
* ### Message structure
|
|
25
24
|
*
|
|
26
25
|
* | Field | Notes |
|
|
27
26
|
* | :----------------------------- | :------------------------ |
|
|
@@ -44,7 +43,7 @@
|
|
|
44
43
|
* | - {@link NonNegativeInt} | Number of ranges. |
|
|
45
44
|
* | - {@link Range} | |
|
|
46
45
|
*
|
|
47
|
-
* ### WriteKey
|
|
46
|
+
* ### WriteKey validation
|
|
48
47
|
*
|
|
49
48
|
* The initiator sends a hasWriteKey flag and optionally a WriteKey. The
|
|
50
49
|
* WriteKey is required when sending messages as a secure token proving the
|
|
@@ -80,33 +79,33 @@
|
|
|
80
79
|
* initiator. In relay-to-relay or P2P sync, both sides may require the
|
|
81
80
|
* {@link OwnerWriteKey} depending on who is the initiator.
|
|
82
81
|
*
|
|
83
|
-
* ### Protocol
|
|
82
|
+
* ### Protocol errors
|
|
84
83
|
*
|
|
85
84
|
* The protocol uses error codes in the header to signal issues:
|
|
86
85
|
*
|
|
87
86
|
* - {@link ProtocolWriteKeyError}: The provided WriteKey is invalid or missing.
|
|
88
|
-
* - {@link ProtocolWriteError}: A write
|
|
89
|
-
*
|
|
90
|
-
* - {@link ProtocolSyncError}: A generic or unexpected synchronization failure
|
|
87
|
+
* - {@link ProtocolWriteError}: A serious relay-side write failure occurred.
|
|
88
|
+
* - {@link ProtocolSyncError}: A serious relay-side synchronization failure
|
|
91
89
|
* occurred.
|
|
90
|
+
* - {@link ProtocolQuotaExceededError}: Storage or billing quota exceeded.
|
|
92
91
|
* - {@link ProtocolUnsupportedVersionError}: Protocol version mismatch.
|
|
93
92
|
* - {@link ProtocolInvalidDataError}: The message is malformed or corrupted.
|
|
94
93
|
*
|
|
95
94
|
* All protocol errors except `ProtocolInvalidDataError` include the `OwnerId`
|
|
96
95
|
* to allow clients to associate errors with the correct owner.
|
|
97
96
|
*
|
|
98
|
-
* ### Message
|
|
97
|
+
* ### Message size limit
|
|
99
98
|
*
|
|
100
99
|
* The protocol enforces a strict maximum size for all messages, defined by
|
|
101
|
-
* {@link
|
|
100
|
+
* {@link ProtocolMessageMaxSize}. This ensures every {@link ProtocolMessage} is
|
|
102
101
|
* less than or equal to this limit, enabling stateless transports, simplified
|
|
103
102
|
* relay implementation, and predictable memory usage. When all messages don't
|
|
104
103
|
* fit within the limit, the protocol automatically continues synchronization in
|
|
105
104
|
* subsequent rounds using range-based reconciliation.
|
|
106
105
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* {@link
|
|
106
|
+
* Database mutations are limited to 640KB, which is smaller than the protocol
|
|
107
|
+
* message limit to ensure efficient sync with
|
|
108
|
+
* {@link defaultProtocolMessageRangesMaxSize}.
|
|
110
109
|
*
|
|
111
110
|
* ### Why Binary?
|
|
112
111
|
*
|
|
@@ -149,6 +148,15 @@
|
|
|
149
148
|
* Version negotiation is per-owner, allowing Evolu Protocol to evolve safely
|
|
150
149
|
* over time and provide clear feedback about version mismatches.
|
|
151
150
|
*
|
|
151
|
+
* ### Credible exit
|
|
152
|
+
*
|
|
153
|
+
* The protocol specification is intentionally non-configurable to ensure
|
|
154
|
+
* universal compatibility. This design allows applications (users) to switch
|
|
155
|
+
* between any compliant relay without negotiation or compatibility checks
|
|
156
|
+
* beyond version matching. Relays are generic infrastructure that any
|
|
157
|
+
* application can use interchangeably making exit from any single provider
|
|
158
|
+
* technically feasible and economically viable.
|
|
159
|
+
*
|
|
152
160
|
* @module
|
|
153
161
|
*/
|
|
154
162
|
|
|
@@ -179,12 +187,14 @@ import { SqliteValue } from "../Sqlite.js";
|
|
|
179
187
|
import {
|
|
180
188
|
Base64Url,
|
|
181
189
|
base64UrlToUint8Array,
|
|
190
|
+
between,
|
|
182
191
|
DateIso,
|
|
183
192
|
Id,
|
|
184
193
|
IdBytes,
|
|
185
194
|
idBytesToId,
|
|
186
195
|
idBytesTypeValueLength,
|
|
187
196
|
idToIdBytes,
|
|
197
|
+
Int,
|
|
188
198
|
Json,
|
|
189
199
|
jsonToJsonValue,
|
|
190
200
|
NonNegativeInt,
|
|
@@ -231,18 +241,71 @@ import {
|
|
|
231
241
|
} from "./Timestamp.js";
|
|
232
242
|
|
|
233
243
|
/**
|
|
234
|
-
* MessagePack
|
|
244
|
+
* Evolu uses MessagePack for numbers and JSONs.
|
|
235
245
|
*
|
|
236
246
|
* - `variableMapSize: true` - More compact maps, ~5-10% slower encoding
|
|
237
247
|
* - `useRecords: false` - Standard MessagePack without extensions
|
|
238
248
|
*/
|
|
239
249
|
const packr = new Packr({ variableMapSize: true, useRecords: false });
|
|
240
250
|
|
|
241
|
-
|
|
242
|
-
|
|
251
|
+
const minProtocolMessageMaxSize = 1_000_000;
|
|
252
|
+
const maxProtocolMessageMaxSize = 100_000_000;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Protocol message maximum size.
|
|
256
|
+
*
|
|
257
|
+
* Defines the upper limit for how large a single protocol message can be.
|
|
258
|
+
* Implementations must enforce a maximum size between 1MB and 100MB to ensure
|
|
259
|
+
* compatibility across all Evolu implementations (the maximum size of mutation
|
|
260
|
+
* change is hardcoded and enforced hence the maximum size can't be smaller).
|
|
261
|
+
*
|
|
262
|
+
* Larger maximum sizes can be configured by relays to reduce roundtrips. For
|
|
263
|
+
* example, a dedicated relay with ample resources could configure a 100MB
|
|
264
|
+
* maximum to minimize roundtrips for large syncs.
|
|
265
|
+
*
|
|
266
|
+
* Only relays can safely configure larger sizes, as clients will handle them.
|
|
267
|
+
* Increasing this value on the client side would break compatibility with
|
|
268
|
+
* relays that enforce smaller limits.
|
|
269
|
+
*/
|
|
270
|
+
export const ProtocolMessageMaxSize = between(
|
|
271
|
+
minProtocolMessageMaxSize,
|
|
272
|
+
maxProtocolMessageMaxSize,
|
|
273
|
+
)(Int);
|
|
243
274
|
|
|
244
|
-
|
|
245
|
-
|
|
275
|
+
export type ProtocolMessageMaxSize = typeof ProtocolMessageMaxSize.Type;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Default {@link ProtocolMessageMaxSize} (1MB).
|
|
279
|
+
*
|
|
280
|
+
* The standard size used across Evolu implementations. Relays with more
|
|
281
|
+
* resources can configure larger sizes to reduce roundtrips.
|
|
282
|
+
*/
|
|
283
|
+
export const defaultProtocolMessageMaxSize =
|
|
284
|
+
minProtocolMessageMaxSize as ProtocolMessageMaxSize;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Protocol message ranges maximum size.
|
|
288
|
+
*
|
|
289
|
+
* Defines the upper limit for how large the ranges section of a protocol
|
|
290
|
+
* message can be. Implementations must enforce a maximum size between 3KB and
|
|
291
|
+
* 100KB to ensure compatibility.
|
|
292
|
+
*
|
|
293
|
+
* The upper bound is set to ensure ranges fit within the default 1MB
|
|
294
|
+
* {@link defaultProtocolMessageMaxSize}, maintaining compatibility between all
|
|
295
|
+
* clients and relays.
|
|
296
|
+
*/
|
|
297
|
+
export const ProtocolMessageRangesMaxSize = between(3_000, 100_000)(Int);
|
|
298
|
+
export type ProtocolMessageRangesMaxSize =
|
|
299
|
+
typeof ProtocolMessageRangesMaxSize.Type;
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Default {@link ProtocolMessageRangesMaxSize} (30KB).
|
|
303
|
+
*
|
|
304
|
+
* The standard size used across Evolu implementations. Relays with more
|
|
305
|
+
* resources can configure larger sizes to reduce roundtrips.
|
|
306
|
+
*/
|
|
307
|
+
export const defaultProtocolMessageRangesMaxSize =
|
|
308
|
+
30_000 as ProtocolMessageRangesMaxSize;
|
|
246
309
|
|
|
247
310
|
/** Evolu Protocol Message. */
|
|
248
311
|
export type ProtocolMessage = Uint8Array & Brand<"ProtocolMessage">;
|
|
@@ -281,6 +344,8 @@ export const ProtocolErrorCode = {
|
|
|
281
344
|
WriteError: 2,
|
|
282
345
|
/** A code for {@link ProtocolSyncError}. */
|
|
283
346
|
SyncError: 3,
|
|
347
|
+
/** A code for {@link ProtocolQuotaExceededError}. */
|
|
348
|
+
QuotaExceededError: 4,
|
|
284
349
|
} as const;
|
|
285
350
|
|
|
286
351
|
type ProtocolErrorCode =
|
|
@@ -292,6 +357,7 @@ export type ProtocolError =
|
|
|
292
357
|
| ProtocolWriteKeyError
|
|
293
358
|
| ProtocolWriteError
|
|
294
359
|
| ProtocolSyncError
|
|
360
|
+
| ProtocolQuotaExceededError
|
|
295
361
|
| ProtocolTimestampMismatchError;
|
|
296
362
|
|
|
297
363
|
/** Base interface for all protocol errors. */
|
|
@@ -323,21 +389,32 @@ export interface ProtocolWriteKeyError extends ProtocolErrorBase {
|
|
|
323
389
|
}
|
|
324
390
|
|
|
325
391
|
/**
|
|
326
|
-
* Error
|
|
327
|
-
*
|
|
392
|
+
* Error indicating a serious relay-side write failure. Clients should log this
|
|
393
|
+
* error and show a generic sync error to the user.
|
|
328
394
|
*/
|
|
329
395
|
export interface ProtocolWriteError extends ProtocolErrorBase {
|
|
330
396
|
readonly type: "ProtocolWriteError";
|
|
331
397
|
}
|
|
332
398
|
|
|
333
399
|
/**
|
|
334
|
-
* Error indicating a synchronization failure
|
|
335
|
-
*
|
|
400
|
+
* Error indicating a serious relay-side synchronization failure. Clients should
|
|
401
|
+
* log this error and show a generic sync error to the user.
|
|
336
402
|
*/
|
|
337
403
|
export interface ProtocolSyncError extends ProtocolErrorBase {
|
|
338
404
|
readonly type: "ProtocolSyncError";
|
|
339
405
|
}
|
|
340
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Error when storage or billing quota is exceeded. Clients should prompt the
|
|
409
|
+
* user to upgrade their plan or expand capacity.
|
|
410
|
+
*
|
|
411
|
+
* TODO: Add callback to relay config to check quota and return this error when
|
|
412
|
+
* limits are reached.
|
|
413
|
+
*/
|
|
414
|
+
export interface ProtocolQuotaExceededError extends ProtocolErrorBase {
|
|
415
|
+
readonly type: "ProtocolQuotaExceededError";
|
|
416
|
+
}
|
|
417
|
+
|
|
341
418
|
/**
|
|
342
419
|
* Error when embedded timestamp doesn't match expected timestamp in
|
|
343
420
|
* EncryptedDbChange. Indicates potential tampering or corruption of CRDT
|
|
@@ -352,8 +429,8 @@ export interface ProtocolTimestampMismatchError {
|
|
|
352
429
|
/**
|
|
353
430
|
* Creates a {@link ProtocolMessage} from CRDT messages.
|
|
354
431
|
*
|
|
355
|
-
* If the message size would exceed {@link
|
|
356
|
-
* ensures all messages will be sent in the next round(s) even over
|
|
432
|
+
* If the message size would exceed {@link defaultProtocolMessageMaxSize}, the
|
|
433
|
+
* protocol ensures all messages will be sent in the next round(s) even over
|
|
357
434
|
* unidirectional and stateless transports.
|
|
358
435
|
*/
|
|
359
436
|
export const createProtocolMessageFromCrdtMessages =
|
|
@@ -361,11 +438,11 @@ export const createProtocolMessageFromCrdtMessages =
|
|
|
361
438
|
(
|
|
362
439
|
owner: Owner,
|
|
363
440
|
messages: NonEmptyReadonlyArray<CrdtMessage>,
|
|
364
|
-
maxSize?:
|
|
441
|
+
maxSize?: ProtocolMessageMaxSize,
|
|
365
442
|
): ProtocolMessage => {
|
|
366
443
|
const buffer = createProtocolMessageBuffer(owner.id, {
|
|
367
444
|
messageType: MessageType.Request,
|
|
368
|
-
totalMaxSize: maxSize ??
|
|
445
|
+
totalMaxSize: maxSize ?? defaultProtocolMessageMaxSize,
|
|
369
446
|
writeKey: owner.writeKey,
|
|
370
447
|
});
|
|
371
448
|
|
|
@@ -394,8 +471,8 @@ export const createProtocolMessageFromCrdtMessages =
|
|
|
394
471
|
*
|
|
395
472
|
* The ideal approach would be to send three ranges (skip, fingerprint,
|
|
396
473
|
* skip) where the fingerprint of unsent messages would act as narrow sync
|
|
397
|
-
* probe. I think we can send
|
|
398
|
-
*
|
|
474
|
+
* probe. I think we can send `zeroFingerprint` which can be interpreted
|
|
475
|
+
* as an indication that the other side should reply with
|
|
399
476
|
* {@link TimestampsRange}, so no need to restart syncing.
|
|
400
477
|
*
|
|
401
478
|
* For now, using a random fingerprint avoids extra complexity and is good
|
|
@@ -479,8 +556,8 @@ export interface ProtocolMessageBuffer {
|
|
|
479
556
|
export const createProtocolMessageBuffer = (
|
|
480
557
|
ownerId: OwnerId,
|
|
481
558
|
options: {
|
|
482
|
-
readonly totalMaxSize?:
|
|
483
|
-
readonly rangesMaxSize?:
|
|
559
|
+
readonly totalMaxSize?: ProtocolMessageMaxSize | undefined;
|
|
560
|
+
readonly rangesMaxSize?: ProtocolMessageRangesMaxSize | undefined;
|
|
484
561
|
readonly version?: NonNegativeInt;
|
|
485
562
|
} & (
|
|
486
563
|
| {
|
|
@@ -498,8 +575,8 @@ export const createProtocolMessageBuffer = (
|
|
|
498
575
|
),
|
|
499
576
|
): ProtocolMessageBuffer => {
|
|
500
577
|
const {
|
|
501
|
-
totalMaxSize =
|
|
502
|
-
rangesMaxSize =
|
|
578
|
+
totalMaxSize = defaultProtocolMessageMaxSize,
|
|
579
|
+
rangesMaxSize = defaultProtocolMessageRangesMaxSize,
|
|
503
580
|
version = protocolVersion,
|
|
504
581
|
} = options;
|
|
505
582
|
|
|
@@ -788,8 +865,7 @@ export interface ApplyProtocolMessageAsClientOptions {
|
|
|
788
865
|
/** For testing purposes only; should not be used in production. */
|
|
789
866
|
version?: NonNegativeInt;
|
|
790
867
|
|
|
791
|
-
|
|
792
|
-
rangesMaxSize?: PositiveInt;
|
|
868
|
+
rangesMaxSize?: ProtocolMessageRangesMaxSize;
|
|
793
869
|
}
|
|
794
870
|
|
|
795
871
|
/**
|
|
@@ -814,6 +890,7 @@ export const applyProtocolMessageAsClient =
|
|
|
814
890
|
| ProtocolUnsupportedVersionError
|
|
815
891
|
| ProtocolWriteError
|
|
816
892
|
| ProtocolWriteKeyError
|
|
893
|
+
| ProtocolQuotaExceededError
|
|
817
894
|
>
|
|
818
895
|
> => {
|
|
819
896
|
// try-catch instead of Result for performance and stacktraces
|
|
@@ -857,6 +934,11 @@ export const applyProtocolMessageAsClient =
|
|
|
857
934
|
type: "ProtocolSyncError",
|
|
858
935
|
ownerId,
|
|
859
936
|
});
|
|
937
|
+
case ProtocolErrorCode.QuotaExceededError:
|
|
938
|
+
return err<ProtocolQuotaExceededError>({
|
|
939
|
+
type: "ProtocolQuotaExceededError",
|
|
940
|
+
ownerId,
|
|
941
|
+
});
|
|
860
942
|
default:
|
|
861
943
|
throw new ProtocolDecodeError(
|
|
862
944
|
`Invalid ProtocolErrorCode: ${errorCode}`,
|
|
@@ -898,7 +980,6 @@ export const applyProtocolMessageAsClient =
|
|
|
898
980
|
const output = createProtocolMessageBuffer(ownerId, {
|
|
899
981
|
messageType: MessageType.Request,
|
|
900
982
|
writeKey,
|
|
901
|
-
totalMaxSize: options.totalMaxSize,
|
|
902
983
|
rangesMaxSize: options.rangesMaxSize,
|
|
903
984
|
});
|
|
904
985
|
|
|
@@ -929,8 +1010,8 @@ export interface ApplyProtocolMessageAsRelayOptions {
|
|
|
929
1010
|
/** To broadcast a protocol message to all subscribers. */
|
|
930
1011
|
broadcast?: (ownerId: OwnerId, message: ProtocolMessage) => void;
|
|
931
1012
|
|
|
932
|
-
totalMaxSize?:
|
|
933
|
-
rangesMaxSize?:
|
|
1013
|
+
totalMaxSize?: ProtocolMessageMaxSize;
|
|
1014
|
+
rangesMaxSize?: ProtocolMessageRangesMaxSize;
|
|
934
1015
|
}
|
|
935
1016
|
|
|
936
1017
|
/**
|
package/src/Evolu/Relay.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { isNonEmptyReadonlyArray } from "../Array.js";
|
|
2
|
-
import { ConsoleConfig } from "../Console.js";
|
|
2
|
+
import { ConsoleConfig, ConsoleDep } from "../Console.js";
|
|
3
3
|
import { TimingSafeEqualDep } from "../Crypto.js";
|
|
4
|
+
import { LazyValue } from "../Function.js";
|
|
4
5
|
import { err, ok, Result } from "../Result.js";
|
|
5
6
|
import { sql, SqliteError } from "../Sqlite.js";
|
|
6
7
|
import { SimpleName } from "../Type.js";
|
|
7
|
-
import { OwnerId, OwnerWriteKey } from "./Owner.js";
|
|
8
|
+
import { OwnerId, OwnerWriteKey, TransportConfig } from "./Owner.js";
|
|
9
|
+
import { ProtocolInvalidDataError } from "./Protocol.js";
|
|
8
10
|
import {
|
|
9
11
|
createSqliteStorageBase,
|
|
10
12
|
CreateSqliteStorageBaseOptions,
|
|
@@ -17,13 +19,55 @@ import { timestampToTimestampBytes } from "./Timestamp.js";
|
|
|
17
19
|
export interface Relay extends Disposable {}
|
|
18
20
|
|
|
19
21
|
export interface RelayConfig extends ConsoleConfig {
|
|
22
|
+
/**
|
|
23
|
+
* The relay name.
|
|
24
|
+
*
|
|
25
|
+
* Implementations can use this for identification purposes (e.g., database
|
|
26
|
+
* file name, logging).
|
|
27
|
+
*/
|
|
20
28
|
readonly name?: SimpleName;
|
|
21
|
-
}
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Optional callback to authenticate an {@link OwnerId} with the relay.
|
|
32
|
+
*
|
|
33
|
+
* If this callback is not provided, all owners are allowed.
|
|
34
|
+
*
|
|
35
|
+
* If provided, the callback receives the OwnerId and should return a promise
|
|
36
|
+
* that resolves to `true` to allow access, or `false` to deny.
|
|
37
|
+
*
|
|
38
|
+
* The callback returns a boolean rather than an error type because error
|
|
39
|
+
* handling and logging are the responsibility of the callback implementation,
|
|
40
|
+
* not the relay. This prevents leaking authentication implementation details
|
|
41
|
+
* into the generic relay interface.
|
|
42
|
+
*
|
|
43
|
+
* OwnerId is used for authentication rather than short-lived tokens because
|
|
44
|
+
* this only controls relay access, not write permissions. Since all data is
|
|
45
|
+
* encrypted on the relay, OwnerId exposure is safe.
|
|
46
|
+
*
|
|
47
|
+
* Owners specify which relays to connect to via {@link TransportConfig}. In
|
|
48
|
+
* WebSocket-based implementations, this check occurs before accepting the
|
|
49
|
+
* connection, with the OwnerId typically extracted from the URL path (e.g.,
|
|
50
|
+
* `ws://localhost:4000/<ownerId>`).
|
|
51
|
+
*
|
|
52
|
+
* ### Example
|
|
53
|
+
*
|
|
54
|
+
* ```ts
|
|
55
|
+
* const relay = await createNodeJsRelay(deps)({
|
|
56
|
+
* authenticateOwner: async (ownerId) => {
|
|
57
|
+
* const isRegistered = await db.checkOwner(ownerId);
|
|
58
|
+
* if (!isRegistered) {
|
|
59
|
+
* logger.warn("Unauthorized access attempt", { ownerId });
|
|
60
|
+
* }
|
|
61
|
+
* return isRegistered;
|
|
62
|
+
* },
|
|
63
|
+
* });
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
readonly authenticateOwner?: (ownerId: OwnerId) => Promise<boolean>;
|
|
67
|
+
}
|
|
24
68
|
|
|
25
|
-
export const
|
|
26
|
-
(deps:
|
|
69
|
+
export const createRelaySqliteStorage =
|
|
70
|
+
(deps: SqliteStorageDeps & TimingSafeEqualDep) =>
|
|
27
71
|
(options: CreateSqliteStorageBaseOptions): Result<Storage, SqliteError> => {
|
|
28
72
|
const sqliteStorageBase = createSqliteStorageBase(deps)(options);
|
|
29
73
|
if (!sqliteStorageBase.ok) return sqliteStorageBase;
|
|
@@ -186,3 +230,133 @@ export const createRelayStorage =
|
|
|
186
230
|
},
|
|
187
231
|
});
|
|
188
232
|
};
|
|
233
|
+
|
|
234
|
+
export interface RelayLogger {
|
|
235
|
+
readonly started: (enableLogging: boolean, port: number) => void;
|
|
236
|
+
readonly storageError: (error: unknown) => void;
|
|
237
|
+
readonly upgradeSocketError: (error: Error) => void;
|
|
238
|
+
readonly invalidOrMissingOwnerIdInUrl: (url: string | undefined) => void;
|
|
239
|
+
readonly unauthorizedOwner: (ownerId: OwnerId) => void;
|
|
240
|
+
readonly authenticateOwnerError: (error: unknown) => void;
|
|
241
|
+
readonly connectionEstablished: (totalConnectionCount: number) => void;
|
|
242
|
+
readonly connectionWebSocketError: (error: Error) => void;
|
|
243
|
+
readonly relayOptionSubscribe: (
|
|
244
|
+
ownerId: OwnerId,
|
|
245
|
+
getSubscriberCount: LazyValue<number>,
|
|
246
|
+
) => void;
|
|
247
|
+
readonly relayOptionUnsubscribe: (
|
|
248
|
+
ownerId: OwnerId,
|
|
249
|
+
getSubscriberCount: LazyValue<number>,
|
|
250
|
+
) => void;
|
|
251
|
+
readonly relayOptionBroadcast: (
|
|
252
|
+
ownerId: OwnerId,
|
|
253
|
+
broadcastCount: number,
|
|
254
|
+
subscriberCount: number,
|
|
255
|
+
) => void;
|
|
256
|
+
readonly messageLength: (messageLength: number) => void;
|
|
257
|
+
readonly applyProtocolMessageAsRelayError: (
|
|
258
|
+
error: ProtocolInvalidDataError,
|
|
259
|
+
) => void;
|
|
260
|
+
readonly responseLength: (responseLength: number) => void;
|
|
261
|
+
readonly applyProtocolMessageAsRelayUnknownError: (error: unknown) => void;
|
|
262
|
+
readonly connectionClosed: (totalConnectionCount: number) => void;
|
|
263
|
+
readonly shuttingDown: () => void;
|
|
264
|
+
readonly webSocketServerDisposed: () => void;
|
|
265
|
+
readonly httpServerDisposed: () => void;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export const createRelayLogger = (deps: ConsoleDep): RelayLogger => ({
|
|
269
|
+
started: (enableLogging, port) => {
|
|
270
|
+
deps.console.enabled = true;
|
|
271
|
+
deps.console.log(`Evolu Relay started on port ${port}`);
|
|
272
|
+
deps.console.enabled = enableLogging;
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
storageError: (error) => {
|
|
276
|
+
deps.console.error("[relay]", "storage", error);
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
upgradeSocketError: (error) => {
|
|
280
|
+
deps.console.warn("[relay]", "socket error", { error });
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
invalidOrMissingOwnerIdInUrl: (url) => {
|
|
284
|
+
deps.console.warn("[relay]", "invalid or missing ownerId in URL", { url });
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
unauthorizedOwner: (ownerId) => {
|
|
288
|
+
deps.console.warn("[relay]", "unauthorized owner", { ownerId });
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
authenticateOwnerError: (error) => {
|
|
292
|
+
deps.console.error("[relay]", "authenticateOwner error", error);
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
connectionEstablished: (totalConnectionCount) => {
|
|
296
|
+
deps.console.log("[relay]", "connection", { totalConnectionCount });
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
connectionWebSocketError: (error) => {
|
|
300
|
+
deps.console.error("[relay]", "error", { error });
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
relayOptionSubscribe: (ownerId, getSubscriberCount) => {
|
|
304
|
+
if (deps.console.enabled)
|
|
305
|
+
deps.console.log("[relay]", "subscribe", {
|
|
306
|
+
ownerId,
|
|
307
|
+
subscriberCount: getSubscriberCount(),
|
|
308
|
+
});
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
relayOptionUnsubscribe: (ownerId, getSubscriberCount) => {
|
|
312
|
+
if (deps.console.enabled)
|
|
313
|
+
deps.console.log("[relay]", "unsubscribe", {
|
|
314
|
+
ownerId,
|
|
315
|
+
subscriberCount: getSubscriberCount(),
|
|
316
|
+
});
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
relayOptionBroadcast: (ownerId, broadcastCount, totalSubscribers) => {
|
|
320
|
+
deps.console.log("[relay]", "broadcast", {
|
|
321
|
+
ownerId,
|
|
322
|
+
broadcastCount,
|
|
323
|
+
totalSubscribers,
|
|
324
|
+
});
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
messageLength: (messageLength) => {
|
|
328
|
+
deps.console.log("[relay]", "on message", { messageLength });
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
applyProtocolMessageAsRelayError: (error) => {
|
|
332
|
+
deps.console.error("[relay]", "applyProtocolMessageAsRelay", error);
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
responseLength: (responseLength) => {
|
|
336
|
+
deps.console.log("[relay]", "responseLength", { responseLength });
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
applyProtocolMessageAsRelayUnknownError: (error) => {
|
|
340
|
+
deps.console.error(
|
|
341
|
+
"[relay]",
|
|
342
|
+
"applyProtocolMessageAsRelayUnknownError",
|
|
343
|
+
error,
|
|
344
|
+
);
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
connectionClosed: (totalConnectionCount) => {
|
|
348
|
+
deps.console.log("[relay]", "close", { totalConnectionCount });
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
shuttingDown: () => {
|
|
352
|
+
deps.console.log("Shutting down Evolu Relay...");
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
webSocketServerDisposed: () => {
|
|
356
|
+
deps.console.log("Evolu Relay WebSocketServer disposed");
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
httpServerDisposed: () => {
|
|
360
|
+
deps.console.log("Evolu Relay HTTP server disposed");
|
|
361
|
+
},
|
|
362
|
+
});
|
package/src/Evolu/Sync.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { NonEmptyArray, NonEmptyReadonlyArray } from "../Array.js";
|
|
2
2
|
import { assert } from "../Assert.js";
|
|
3
3
|
import { Brand } from "../Brand.js";
|
|
4
|
-
import { concatBytes } from "../Buffer.js";
|
|
5
4
|
import { ConsoleDep } from "../Console.js";
|
|
6
5
|
import {
|
|
7
6
|
RandomBytesDep,
|
|
@@ -60,7 +59,6 @@ import {
|
|
|
60
59
|
receiveTimestamp,
|
|
61
60
|
sendTimestamp,
|
|
62
61
|
Timestamp,
|
|
63
|
-
TimestampBytes,
|
|
64
62
|
timestampBytesToTimestamp,
|
|
65
63
|
TimestampConfigDep,
|
|
66
64
|
TimestampCounterOverflowError,
|
|
@@ -634,7 +632,7 @@ export const applyLocalOnlyChange =
|
|
|
634
632
|
return ok();
|
|
635
633
|
};
|
|
636
634
|
|
|
637
|
-
const applyMessages =
|
|
635
|
+
export const applyMessages =
|
|
638
636
|
(deps: ClientStorageDep & ClockDep & RandomDep & SqliteDep) =>
|
|
639
637
|
(
|
|
640
638
|
ownerId: OwnerId,
|
|
@@ -665,30 +663,26 @@ const applyMessageToAppTable =
|
|
|
665
663
|
for (const [column, value] of objectToEntries(message.change.values)) {
|
|
666
664
|
const result = deps.sqlite.exec(sql.prepared`
|
|
667
665
|
with
|
|
668
|
-
|
|
669
|
-
select
|
|
666
|
+
existingTimestamp as (
|
|
667
|
+
select 1
|
|
670
668
|
from evolu_history
|
|
671
669
|
where
|
|
672
670
|
"ownerId" = ${ownerId}
|
|
673
671
|
and "table" = ${message.change.table}
|
|
674
|
-
and "id" = ${message.change.id}
|
|
672
|
+
and "id" = ${idToIdBytes(message.change.id)}
|
|
675
673
|
and "column" = ${column}
|
|
676
|
-
|
|
674
|
+
and "timestamp" >= ${timestamp}
|
|
677
675
|
limit 1
|
|
678
676
|
)
|
|
679
677
|
insert into ${sql.identifier(message.change.table)}
|
|
680
678
|
("id", ${sql.identifier(column)}, updatedAt)
|
|
681
679
|
select ${message.change.id}, ${value}, ${updatedAt}
|
|
682
|
-
where
|
|
683
|
-
(select "timestamp" from lastTimestamp) is null
|
|
684
|
-
or (select "timestamp" from lastTimestamp) < ${timestamp}
|
|
680
|
+
where not exists (select 1 from existingTimestamp)
|
|
685
681
|
on conflict ("id") do update
|
|
686
682
|
set
|
|
687
683
|
${sql.identifier(column)} = ${value},
|
|
688
684
|
updatedAt = ${updatedAt}
|
|
689
|
-
where
|
|
690
|
-
(select "timestamp" from lastTimestamp) is null
|
|
691
|
-
or (select "timestamp" from lastTimestamp) < ${timestamp};
|
|
685
|
+
where not exists (select 1 from existingTimestamp);
|
|
692
686
|
`);
|
|
693
687
|
|
|
694
688
|
if (!result.ok) return result;
|
|
@@ -780,47 +774,3 @@ export interface PaymentRequiredError {
|
|
|
780
774
|
}
|
|
781
775
|
|
|
782
776
|
export const initialSyncState: SyncStateInitial = { type: "SyncStateInitial" };
|
|
783
|
-
|
|
784
|
-
/**
|
|
785
|
-
* Efficiently checks which binary timestamps already exist in the database
|
|
786
|
-
* using a single CTE query instead of N individual queries. Crucial for WASM
|
|
787
|
-
* SQLite performance where JS↔WASM boundary crossings are expensive.
|
|
788
|
-
*
|
|
789
|
-
* Used for fast idempotency detection in writeMessages before onMessage
|
|
790
|
-
* validation. While applyMessages ensures internal idempotency, this pre-check
|
|
791
|
-
* is faster and required for main thread message validation.
|
|
792
|
-
*/
|
|
793
|
-
export const getExistingTimestamps =
|
|
794
|
-
(deps: SqliteDep) =>
|
|
795
|
-
(
|
|
796
|
-
ownerIdBytes: OwnerIdBytes,
|
|
797
|
-
timestampsBytes: NonEmptyReadonlyArray<TimestampBytes>,
|
|
798
|
-
): Result<ReadonlyArray<TimestampBytes>, SqliteError> => {
|
|
799
|
-
const concatenatedTimestamps = concatBytes(...timestampsBytes);
|
|
800
|
-
|
|
801
|
-
const result = deps.sqlite.exec<{
|
|
802
|
-
timestampBytes: TimestampBytes;
|
|
803
|
-
}>(sql`
|
|
804
|
-
with recursive
|
|
805
|
-
split_timestamps(timestampBytes, pos) as (
|
|
806
|
-
select
|
|
807
|
-
substr(${concatenatedTimestamps}, 1, 16),
|
|
808
|
-
17 as pos
|
|
809
|
-
union all
|
|
810
|
-
select
|
|
811
|
-
substr(${concatenatedTimestamps}, pos, 16),
|
|
812
|
-
pos + 16
|
|
813
|
-
from split_timestamps
|
|
814
|
-
where pos <= length(${concatenatedTimestamps})
|
|
815
|
-
)
|
|
816
|
-
select s.timestampBytes
|
|
817
|
-
from
|
|
818
|
-
split_timestamps s
|
|
819
|
-
join evolu_timestamp t
|
|
820
|
-
on t.ownerId = ${ownerIdBytes} and s.timestampBytes = t.t;
|
|
821
|
-
`);
|
|
822
|
-
|
|
823
|
-
if (!result.ok) return result;
|
|
824
|
-
|
|
825
|
-
return ok(result.value.rows.map((row) => row.timestampBytes));
|
|
826
|
-
};
|