@evolu/common 6.0.1-preview.28 → 6.0.1-preview.29
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/Array.d.ts +58 -5
- package/dist/src/Array.d.ts.map +1 -1
- package/dist/src/Array.js +53 -5
- package/dist/src/Evolu/Evolu.d.ts +3 -3
- package/dist/src/Evolu/Evolu.d.ts.map +1 -1
- package/dist/src/Evolu/Evolu.js +3 -3
- package/dist/src/Evolu/Owner.d.ts +48 -19
- package/dist/src/Evolu/Owner.d.ts.map +1 -1
- package/dist/src/Evolu/Owner.js +11 -2
- package/dist/src/Evolu/Protocol.d.ts +31 -31
- package/dist/src/Evolu/Protocol.d.ts.map +1 -1
- package/dist/src/Evolu/Protocol.js +51 -28
- package/dist/src/Evolu/Relay.d.ts +40 -25
- package/dist/src/Evolu/Relay.d.ts.map +1 -1
- package/dist/src/Evolu/Relay.js +106 -49
- package/dist/src/Evolu/Storage.d.ts +59 -12
- package/dist/src/Evolu/Storage.d.ts.map +1 -1
- package/dist/src/Evolu/Storage.js +77 -50
- package/dist/src/Evolu/Sync.d.ts.map +1 -1
- package/dist/src/Evolu/Sync.js +14 -5
- package/dist/src/Evolu/Timestamp.d.ts +25 -0
- package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
- package/dist/src/Evolu/Timestamp.js +25 -0
- package/dist/src/Instances.d.ts +34 -0
- package/dist/src/Instances.d.ts.map +1 -0
- package/dist/src/{Multiton.js → Instances.js} +20 -9
- package/dist/src/Sqlite.d.ts +6 -0
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +6 -0
- package/dist/src/Task.d.ts +75 -0
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +29 -6
- package/dist/src/Time.d.ts +7 -1
- package/dist/src/Time.d.ts.map +1 -1
- package/dist/src/Time.js +13 -2
- package/dist/src/Type.d.ts +56 -9
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +40 -8
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/package.json +1 -1
- package/src/Array.ts +76 -11
- package/src/Evolu/Evolu.ts +4 -5
- package/src/Evolu/Owner.ts +75 -26
- package/src/Evolu/Protocol.ts +90 -61
- package/src/Evolu/Relay.ts +182 -77
- package/src/Evolu/Storage.ts +157 -67
- package/src/Evolu/Sync.ts +18 -6
- package/src/Evolu/Timestamp.ts +25 -0
- package/src/Instances.ts +90 -0
- package/src/Sqlite.ts +6 -0
- package/src/Task.ts +88 -7
- package/src/Time.ts +13 -2
- package/src/Type.ts +56 -9
- package/src/index.ts +1 -1
- package/dist/src/Multiton.d.ts +0 -50
- package/dist/src/Multiton.d.ts.map +0 -1
- package/src/Multiton.ts +0 -98
package/src/Evolu/Storage.ts
CHANGED
|
@@ -2,10 +2,12 @@ import { sha256 } from "@noble/hashes/sha2.js";
|
|
|
2
2
|
import { NonEmptyReadonlyArray } from "../Array.js";
|
|
3
3
|
import { assert } from "../Assert.js";
|
|
4
4
|
import { Brand } from "../Brand.js";
|
|
5
|
+
import { concatBytes } from "../Buffer.js";
|
|
5
6
|
import { decrement } from "../Number.js";
|
|
6
7
|
import { RandomDep } from "../Random.js";
|
|
7
8
|
import { ok, Result } from "../Result.js";
|
|
8
9
|
import { sql, SqliteDep, SqliteError, SqliteValue } from "../Sqlite.js";
|
|
10
|
+
import { MaybeAsync } from "../Task.js";
|
|
9
11
|
import {
|
|
10
12
|
Id,
|
|
11
13
|
Int64String,
|
|
@@ -16,6 +18,7 @@ import {
|
|
|
16
18
|
String,
|
|
17
19
|
} from "../Type.js";
|
|
18
20
|
import {
|
|
21
|
+
BaseOwnerError,
|
|
19
22
|
Owner,
|
|
20
23
|
OwnerId,
|
|
21
24
|
OwnerIdBytes,
|
|
@@ -24,13 +27,50 @@ import {
|
|
|
24
27
|
} from "./Owner.js";
|
|
25
28
|
import { orderTimestampBytes, Timestamp, TimestampBytes } from "./Timestamp.js";
|
|
26
29
|
|
|
30
|
+
export interface StorageConfig {
|
|
31
|
+
/**
|
|
32
|
+
* Optional callback to check if an {@link OwnerId} is within their quota for
|
|
33
|
+
* the requested write. If this callback is not provided, all writes are
|
|
34
|
+
* allowed regardless of size.
|
|
35
|
+
*
|
|
36
|
+
* If provided, the callback receives the OwnerId and the number of bytes
|
|
37
|
+
* required for the write, and should return a {@link MaybeAsync} boolean:
|
|
38
|
+
* `true` to allow the write, or `false` to deny it due to quota limits.
|
|
39
|
+
*
|
|
40
|
+
* The callback can be synchronous (for SQLite or in-memory checks) or
|
|
41
|
+
* asynchronous (for calling remote APIs).
|
|
42
|
+
*
|
|
43
|
+
* The callback returns a boolean rather than an error type because error
|
|
44
|
+
* handling and logging are the responsibility of the callback
|
|
45
|
+
* implementation.
|
|
46
|
+
*
|
|
47
|
+
* ### Example
|
|
48
|
+
*
|
|
49
|
+
* ```ts
|
|
50
|
+
* // Client
|
|
51
|
+
* // evolu.subscribeError
|
|
52
|
+
*
|
|
53
|
+
* // Relay
|
|
54
|
+
* isOwnerWithinQuota: (ownerId, requiredBytes) => {
|
|
55
|
+
* console.log(ownerId, requiredBytes);
|
|
56
|
+
* // Check error via evolu.subscribeError
|
|
57
|
+
* return true;
|
|
58
|
+
* };
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
readonly isOwnerWithinQuota?: (
|
|
62
|
+
ownerId: OwnerId,
|
|
63
|
+
requiredBytes: PositiveInt,
|
|
64
|
+
) => MaybeAsync<boolean>;
|
|
65
|
+
}
|
|
66
|
+
|
|
27
67
|
/**
|
|
28
68
|
* Evolu Storage
|
|
29
69
|
*
|
|
30
|
-
*
|
|
31
|
-
* storage can be plugged in, as long as it implements this
|
|
32
|
-
* Implementations must handle their own errors; return values only
|
|
33
|
-
* overall success or failure.
|
|
70
|
+
* Evolu protocol using Storage is agnostic to storage implementation
|
|
71
|
+
* details—any storage can be plugged in, as long as it implements this
|
|
72
|
+
* interface. Implementations must handle their own errors; return values only
|
|
73
|
+
* indicate overall success or failure.
|
|
34
74
|
*
|
|
35
75
|
* The Storage API is synchronous because SQLite's synchronous API is the
|
|
36
76
|
* fastest way to use SQLite. Synchronous bindings (like better-sqlite3) call
|
|
@@ -96,15 +136,15 @@ export interface Storage {
|
|
|
96
136
|
/**
|
|
97
137
|
* Write encrypted {@link CrdtMessage}s to storage.
|
|
98
138
|
*
|
|
99
|
-
* Must use a mutex
|
|
100
|
-
*
|
|
139
|
+
* Must use a mutex per ownerId to ensure sequential processing and proper
|
|
140
|
+
* protocol logic handling during sync operations.
|
|
101
141
|
*
|
|
102
|
-
*
|
|
142
|
+
* TODO: Use MaybeAsync
|
|
103
143
|
*/
|
|
104
144
|
readonly writeMessages: (
|
|
105
|
-
|
|
145
|
+
ownerIdBytes: OwnerIdBytes,
|
|
106
146
|
messages: NonEmptyReadonlyArray<EncryptedCrdtMessage>,
|
|
107
|
-
) =>
|
|
147
|
+
) => MaybeAsync<Result<void, StorageWriteError | StorageQuotaError>>;
|
|
108
148
|
|
|
109
149
|
/** Read encrypted {@link DbChange}s from storage. */
|
|
110
150
|
readonly readDbChange: (
|
|
@@ -124,6 +164,16 @@ export interface StorageDep {
|
|
|
124
164
|
readonly storage: Storage;
|
|
125
165
|
}
|
|
126
166
|
|
|
167
|
+
/** Error indicating a serious write failure. */
|
|
168
|
+
export interface StorageWriteError extends BaseOwnerError {
|
|
169
|
+
readonly type: "StorageWriteError";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Error when storage or billing quota is exceeded. */
|
|
173
|
+
export interface StorageQuotaError extends BaseOwnerError {
|
|
174
|
+
readonly type: "StorageQuotaError";
|
|
175
|
+
}
|
|
176
|
+
|
|
127
177
|
/**
|
|
128
178
|
* A cryptographic hash used for efficiently comparing collections of
|
|
129
179
|
* {@link TimestampBytes}s.
|
|
@@ -261,6 +311,15 @@ export interface BaseSqliteStorage
|
|
|
261
311
|
ownerId: OwnerIdBytes,
|
|
262
312
|
timestamp: TimestampBytes,
|
|
263
313
|
) => Result<void, SqliteError>;
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Efficiently checks which timestamps already exist in the database using a
|
|
317
|
+
* single CTE query instead of N individual queries.
|
|
318
|
+
*/
|
|
319
|
+
readonly getExistingTimestamps: (
|
|
320
|
+
ownerIdBytes: OwnerIdBytes,
|
|
321
|
+
timestampsBytes: NonEmptyReadonlyArray<TimestampBytes>,
|
|
322
|
+
) => Result<ReadonlyArray<TimestampBytes>, SqliteError>;
|
|
264
323
|
}
|
|
265
324
|
|
|
266
325
|
export interface BaseSqliteStorageDep {
|
|
@@ -269,14 +328,14 @@ export interface BaseSqliteStorageDep {
|
|
|
269
328
|
|
|
270
329
|
export type SqliteStorageDeps = RandomDep & SqliteDep;
|
|
271
330
|
|
|
272
|
-
export interface
|
|
331
|
+
export interface CreateBaseSqliteStorageConfig extends StorageConfig {
|
|
273
332
|
onStorageError: (error: SqliteError) => void;
|
|
274
333
|
}
|
|
275
334
|
|
|
276
335
|
export const createBaseSqliteStorage =
|
|
277
336
|
(deps: SqliteStorageDeps) =>
|
|
278
|
-
(
|
|
279
|
-
// TODO: Use
|
|
337
|
+
(config: CreateBaseSqliteStorageConfig): BaseSqliteStorage => {
|
|
338
|
+
// TODO: Use evolu_usage table.
|
|
280
339
|
const ownerStats = new Map<
|
|
281
340
|
OwnerId,
|
|
282
341
|
{
|
|
@@ -286,49 +345,10 @@ export const createBaseSqliteStorage =
|
|
|
286
345
|
>();
|
|
287
346
|
|
|
288
347
|
return {
|
|
289
|
-
insertTimestamp: (ownerId: OwnerIdBytes, timestamp: TimestampBytes) => {
|
|
290
|
-
const ownerIdString = ownerIdBytesToOwnerId(ownerId);
|
|
291
|
-
const level = randomSkiplistLevel(deps);
|
|
292
|
-
|
|
293
|
-
let stats = ownerStats.get(ownerIdString);
|
|
294
|
-
|
|
295
|
-
if (!stats) {
|
|
296
|
-
const result = deps.sqlite.exec<{
|
|
297
|
-
maxT: TimestampBytes | null;
|
|
298
|
-
minT: TimestampBytes | null;
|
|
299
|
-
}>(sql.prepared`
|
|
300
|
-
select min(t) as minT, max(t) as maxT
|
|
301
|
-
from evolu_timestamp
|
|
302
|
-
where ownerId = ${ownerId};
|
|
303
|
-
`);
|
|
304
|
-
if (!result.ok) return result;
|
|
305
|
-
|
|
306
|
-
stats = {
|
|
307
|
-
minT: result.value.rows[0].minT ?? timestamp,
|
|
308
|
-
maxT: result.value.rows[0].maxT ?? timestamp,
|
|
309
|
-
};
|
|
310
|
-
ownerStats.set(ownerIdString, stats);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
let strategy: InsertTimestampStrategy;
|
|
314
|
-
|
|
315
|
-
if (orderTimestampBytes(timestamp, stats.maxT) === 1) {
|
|
316
|
-
strategy = "append";
|
|
317
|
-
stats.maxT = timestamp;
|
|
318
|
-
} else if (orderTimestampBytes(timestamp, stats.minT) === -1) {
|
|
319
|
-
strategy = "prepend";
|
|
320
|
-
stats.minT = timestamp;
|
|
321
|
-
} else {
|
|
322
|
-
strategy = "insert";
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
return insertTimestamp(deps)(ownerId, timestamp, level, strategy);
|
|
326
|
-
},
|
|
327
|
-
|
|
328
348
|
getSize: (ownerId) => {
|
|
329
349
|
const size = getSize(deps)(ownerId);
|
|
330
350
|
if (!size.ok) {
|
|
331
|
-
|
|
351
|
+
config.onStorageError(size.error);
|
|
332
352
|
return null;
|
|
333
353
|
}
|
|
334
354
|
return size.value;
|
|
@@ -338,7 +358,7 @@ export const createBaseSqliteStorage =
|
|
|
338
358
|
assertBeginEnd(begin, end);
|
|
339
359
|
const result = fingerprint(deps)(ownerId, begin, end);
|
|
340
360
|
if (!result.ok) {
|
|
341
|
-
|
|
361
|
+
config.onStorageError(result.error);
|
|
342
362
|
return null;
|
|
343
363
|
}
|
|
344
364
|
return result.value;
|
|
@@ -347,7 +367,7 @@ export const createBaseSqliteStorage =
|
|
|
347
367
|
fingerprintRanges: (ownerId, buckets, upperBound) => {
|
|
348
368
|
const ranges = fingerprintRanges(deps)(ownerId, buckets, upperBound);
|
|
349
369
|
if (!ranges.ok) {
|
|
350
|
-
|
|
370
|
+
config.onStorageError(ranges.error);
|
|
351
371
|
return null;
|
|
352
372
|
}
|
|
353
373
|
return ranges.value;
|
|
@@ -361,7 +381,7 @@ export const createBaseSqliteStorage =
|
|
|
361
381
|
upperBound,
|
|
362
382
|
);
|
|
363
383
|
if (!lowerBound.ok) {
|
|
364
|
-
|
|
384
|
+
config.onStorageError(lowerBound.error);
|
|
365
385
|
return null;
|
|
366
386
|
}
|
|
367
387
|
return lowerBound.value;
|
|
@@ -375,7 +395,7 @@ export const createBaseSqliteStorage =
|
|
|
375
395
|
// This is much faster than SQL limit with offset.
|
|
376
396
|
const first = getTimestampByIndex(deps)(ownerId, begin);
|
|
377
397
|
if (!first.ok) {
|
|
378
|
-
|
|
398
|
+
config.onStorageError(first.error);
|
|
379
399
|
return;
|
|
380
400
|
}
|
|
381
401
|
|
|
@@ -400,7 +420,7 @@ export const createBaseSqliteStorage =
|
|
|
400
420
|
limit ${length - 1};
|
|
401
421
|
`);
|
|
402
422
|
if (!result.ok) {
|
|
403
|
-
|
|
423
|
+
config.onStorageError(result.error);
|
|
404
424
|
return;
|
|
405
425
|
}
|
|
406
426
|
|
|
@@ -415,11 +435,80 @@ export const createBaseSqliteStorage =
|
|
|
415
435
|
delete from evolu_timestamp where ownerId = ${ownerId};
|
|
416
436
|
`);
|
|
417
437
|
if (!result.ok) {
|
|
418
|
-
|
|
438
|
+
config.onStorageError(result.error);
|
|
419
439
|
return false;
|
|
420
440
|
}
|
|
421
441
|
return true;
|
|
422
442
|
},
|
|
443
|
+
|
|
444
|
+
insertTimestamp: (ownerId: OwnerIdBytes, timestamp: TimestampBytes) => {
|
|
445
|
+
const ownerIdString = ownerIdBytesToOwnerId(ownerId);
|
|
446
|
+
const level = randomSkiplistLevel(deps);
|
|
447
|
+
|
|
448
|
+
let stats = ownerStats.get(ownerIdString);
|
|
449
|
+
|
|
450
|
+
if (!stats) {
|
|
451
|
+
const result = deps.sqlite.exec<{
|
|
452
|
+
maxT: TimestampBytes | null;
|
|
453
|
+
minT: TimestampBytes | null;
|
|
454
|
+
}>(sql.prepared`
|
|
455
|
+
select min(t) as minT, max(t) as maxT
|
|
456
|
+
from evolu_timestamp
|
|
457
|
+
where ownerId = ${ownerId};
|
|
458
|
+
`);
|
|
459
|
+
if (!result.ok) return result;
|
|
460
|
+
|
|
461
|
+
stats = {
|
|
462
|
+
minT: result.value.rows[0].minT ?? timestamp,
|
|
463
|
+
maxT: result.value.rows[0].maxT ?? timestamp,
|
|
464
|
+
};
|
|
465
|
+
ownerStats.set(ownerIdString, stats);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
let strategy: InsertTimestampStrategy;
|
|
469
|
+
|
|
470
|
+
if (orderTimestampBytes(timestamp, stats.maxT) === 1) {
|
|
471
|
+
strategy = "append";
|
|
472
|
+
stats.maxT = timestamp;
|
|
473
|
+
} else if (orderTimestampBytes(timestamp, stats.minT) === -1) {
|
|
474
|
+
strategy = "prepend";
|
|
475
|
+
stats.minT = timestamp;
|
|
476
|
+
} else {
|
|
477
|
+
strategy = "insert";
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return insertTimestamp(deps)(ownerId, timestamp, level, strategy);
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
getExistingTimestamps: (ownerIdBytes, timestampsBytes) => {
|
|
484
|
+
const concatenatedTimestamps = concatBytes(...timestampsBytes);
|
|
485
|
+
|
|
486
|
+
const result = deps.sqlite.exec<{
|
|
487
|
+
timestampBytes: TimestampBytes;
|
|
488
|
+
}>(sql`
|
|
489
|
+
with recursive
|
|
490
|
+
split_timestamps(timestampBytes, pos) as (
|
|
491
|
+
select
|
|
492
|
+
substr(${concatenatedTimestamps}, 1, 16),
|
|
493
|
+
17 as pos
|
|
494
|
+
union all
|
|
495
|
+
select
|
|
496
|
+
substr(${concatenatedTimestamps}, pos, 16),
|
|
497
|
+
pos + 16
|
|
498
|
+
from split_timestamps
|
|
499
|
+
where pos <= length(${concatenatedTimestamps})
|
|
500
|
+
)
|
|
501
|
+
select s.timestampBytes
|
|
502
|
+
from
|
|
503
|
+
split_timestamps s
|
|
504
|
+
join evolu_timestamp t
|
|
505
|
+
on t.ownerId = ${ownerIdBytes} and s.timestampBytes = t.t;
|
|
506
|
+
`);
|
|
507
|
+
|
|
508
|
+
if (!result.ok) return result;
|
|
509
|
+
|
|
510
|
+
return ok(result.value.rows.map((row) => row.timestampBytes));
|
|
511
|
+
},
|
|
423
512
|
};
|
|
424
513
|
};
|
|
425
514
|
|
|
@@ -484,19 +573,20 @@ export const createBaseSqliteStorageTables = (
|
|
|
484
573
|
*
|
|
485
574
|
* - `ownerId` – OwnerIdBytes (primary key)
|
|
486
575
|
* - `storedBytes` – total bytes stored in database
|
|
487
|
-
* - `receivedBytes` –
|
|
488
|
-
* - `sentBytes` –
|
|
489
|
-
* - `firstTimestamp` –
|
|
490
|
-
* - `lastTimestamp` –
|
|
576
|
+
* - `receivedBytes` – TODO: Decide how to use
|
|
577
|
+
* - `sentBytes` – TODO: Decide how to use
|
|
578
|
+
* - `firstTimestamp` – TODO: Decide how to use (nullable)
|
|
579
|
+
* - `lastTimestamp` – TODO: Decide how to use (nullable)
|
|
491
580
|
*/
|
|
492
581
|
sql`
|
|
493
582
|
create table evolu_usage (
|
|
494
583
|
"ownerId" blob primary key,
|
|
495
|
-
"storedBytes" integer not null
|
|
496
|
-
|
|
497
|
-
"
|
|
498
|
-
"
|
|
499
|
-
"
|
|
584
|
+
"storedBytes" integer not null
|
|
585
|
+
-- TODO: Decide how to use receivedBytes, sentBytes, firstTimestamp, lastTimestamp
|
|
586
|
+
-- "receivedBytes" integer not null,
|
|
587
|
+
-- "sentBytes" integer not null,
|
|
588
|
+
-- "firstTimestamp" blob,
|
|
589
|
+
-- "lastTimestamp" blob
|
|
500
590
|
)
|
|
501
591
|
strict;
|
|
502
592
|
`,
|
package/src/Evolu/Sync.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { constFalse } from "../Function.js";
|
|
|
13
13
|
import { objectToEntries } from "../Object.js";
|
|
14
14
|
import { RandomDep } from "../Random.js";
|
|
15
15
|
import { createRefCountedResourceManager } from "../RefCountedResourceManager.js";
|
|
16
|
-
import { ok, Result } from "../Result.js";
|
|
16
|
+
import { err, ok, Result } from "../Result.js";
|
|
17
17
|
import { sql, SqliteDep, SqliteError, SqliteValue } from "../Sqlite.js";
|
|
18
18
|
import { AbortError, createMutex } from "../Task.js";
|
|
19
19
|
import { TimeDep } from "../Time.js";
|
|
@@ -48,10 +48,11 @@ import {
|
|
|
48
48
|
import { MutationChange } from "./Schema.js";
|
|
49
49
|
import {
|
|
50
50
|
BaseSqliteStorage,
|
|
51
|
-
createBaseSqliteStorage,
|
|
52
51
|
CrdtMessage,
|
|
52
|
+
createBaseSqliteStorage,
|
|
53
53
|
DbChange,
|
|
54
54
|
Storage,
|
|
55
|
+
StorageWriteError,
|
|
55
56
|
} from "./Storage.js";
|
|
56
57
|
import {
|
|
57
58
|
createInitialTimestamp,
|
|
@@ -452,15 +453,20 @@ const createClientStorage =
|
|
|
452
453
|
onStorageError: config.onError,
|
|
453
454
|
});
|
|
454
455
|
|
|
456
|
+
// TODO: Mutex per OwnerId
|
|
455
457
|
const mutex = createMutex();
|
|
456
458
|
|
|
457
459
|
const storage: ClientStorage = {
|
|
458
460
|
...sqliteStorageBase,
|
|
459
461
|
|
|
462
|
+
// Not implemented yet.
|
|
460
463
|
validateWriteKey: constFalse,
|
|
461
464
|
setWriteKey: constFalse,
|
|
462
465
|
|
|
463
466
|
writeMessages: async (ownerIdBytes, encryptedMessages) => {
|
|
467
|
+
const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
|
|
468
|
+
|
|
469
|
+
// Everything is sync now, but we will need async crypto in the future.
|
|
464
470
|
const writeResult = await mutex.withLock<
|
|
465
471
|
boolean,
|
|
466
472
|
| AbortError
|
|
@@ -473,10 +479,16 @@ const createClientStorage =
|
|
|
473
479
|
| TimestampTimeOutOfRangeError
|
|
474
480
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
475
481
|
>(async () => {
|
|
476
|
-
const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
|
|
477
482
|
const owner = deps.getSyncOwner(ownerId);
|
|
478
483
|
// Owner can be removed during syncing.
|
|
479
|
-
|
|
484
|
+
// `ok(true)` means success, we just skipped the write.
|
|
485
|
+
if (!owner) return ok(true);
|
|
486
|
+
|
|
487
|
+
// TODO: Add quota checking for collaborative scenarios.
|
|
488
|
+
// When receiving messages from other owners via relay broadcast,
|
|
489
|
+
// check if this owner is within quota before accepting the data.
|
|
490
|
+
// This prevents an owner from exceeding storage limits when receiving
|
|
491
|
+
// data shared by other collaborators.
|
|
480
492
|
|
|
481
493
|
const messages: Array<CrdtMessage> = [];
|
|
482
494
|
|
|
@@ -530,12 +542,12 @@ const createClientStorage =
|
|
|
530
542
|
if (writeResult.error.type !== "AbortError") {
|
|
531
543
|
config.onError(writeResult.error);
|
|
532
544
|
}
|
|
533
|
-
return
|
|
545
|
+
return err<StorageWriteError>({ type: "StorageWriteError", ownerId });
|
|
534
546
|
}
|
|
535
547
|
|
|
536
548
|
config.onReceive();
|
|
537
549
|
|
|
538
|
-
return
|
|
550
|
+
return ok();
|
|
539
551
|
},
|
|
540
552
|
|
|
541
553
|
readDbChange: (ownerId, timestamp) => {
|
package/src/Evolu/Timestamp.ts
CHANGED
|
@@ -117,6 +117,31 @@ export const maxNodeId = "ffffffffffffffff" as NodeId;
|
|
|
117
117
|
* Timestamps serve as globally unique, causally ordered identifiers for CRDT
|
|
118
118
|
* messages in Evolu's sync protocol.
|
|
119
119
|
*
|
|
120
|
+
* ### Why Hybrid Logical Clocks
|
|
121
|
+
*
|
|
122
|
+
* Evolu uses Hybrid Logical Clocks (HLC), which combine physical time (millis)
|
|
123
|
+
* with a logical counter. This hybrid approach preserves causality like logical
|
|
124
|
+
* clocks while staying close to physical time for better human
|
|
125
|
+
* interpretability.
|
|
126
|
+
*
|
|
127
|
+
* The counter component ensures causality is maintained even when physical
|
|
128
|
+
* clocks are imperfect. When clocks drift or operations occur concurrently, the
|
|
129
|
+
* counter increments to establish a total order. This means Evolu achieves
|
|
130
|
+
* well-defined, eventually-consistent behavior regardless of physical clock
|
|
131
|
+
* accuracy.
|
|
132
|
+
*
|
|
133
|
+
* Vector clocks can accurately track causality and detect concurrent
|
|
134
|
+
* operations, but they require unbounded space in peer-to-peer systems and
|
|
135
|
+
* crucially, still don't solve our fundamental problem: when they detect
|
|
136
|
+
* operations as concurrent, we still need a deterministic way to choose a
|
|
137
|
+
* winner. Additionally, any deterministic conflict resolution can be gamed by
|
|
138
|
+
* malicious actors.
|
|
139
|
+
*
|
|
140
|
+
* HLC timestamps work well in practice because modern device clocks accurately
|
|
141
|
+
* reflect the order of sequential edits in the common case. Evolu's `maxDrift`
|
|
142
|
+
* configuration protects against buggy clocks and prevents problematic
|
|
143
|
+
* future-dated entries from propagating through the network.
|
|
144
|
+
*
|
|
120
145
|
* ### References
|
|
121
146
|
*
|
|
122
147
|
* - https://muratbuffalo.blogspot.com/2014/07/hybrid-logical-clocks.html
|
package/src/Instances.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manages disposable instances by key, ensuring exactly one instance per key.
|
|
3
|
+
*
|
|
4
|
+
* Use cases:
|
|
5
|
+
*
|
|
6
|
+
* - One mutex per key to prevent concurrent writes
|
|
7
|
+
* - Preserving state during hot module reloading
|
|
8
|
+
*
|
|
9
|
+
* **Important:** Do not use this as global shared state. Use it locally or pass
|
|
10
|
+
* it as a dependency instead. The only exception is for hot reloading support,
|
|
11
|
+
* where Evolu uses it to ensure only one instance exists across module reloads
|
|
12
|
+
* (having two Evolu instances with the same name would mean two SQLite
|
|
13
|
+
* connections to the same file, which could corrupt data).
|
|
14
|
+
*/
|
|
15
|
+
export interface Instances<K extends string, T extends Disposable>
|
|
16
|
+
extends Disposable {
|
|
17
|
+
/**
|
|
18
|
+
* Ensures an instance exists for the given key, creating it if necessary. If
|
|
19
|
+
* the instance already exists, the optional `onCacheHit` callback is invoked
|
|
20
|
+
* to update the existing instance.
|
|
21
|
+
*/
|
|
22
|
+
readonly ensure: (
|
|
23
|
+
key: K,
|
|
24
|
+
create: () => T,
|
|
25
|
+
onCacheHit?: (instance: T) => void,
|
|
26
|
+
) => T;
|
|
27
|
+
|
|
28
|
+
/** Gets an instance by key, or returns `null` if it doesn't exist. */
|
|
29
|
+
readonly get: (key: K) => T | null;
|
|
30
|
+
|
|
31
|
+
/** Checks if an instance exists for the given key. */
|
|
32
|
+
readonly has: (key: K) => boolean;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Deletes and disposes an instance by key. Returns `true` if the instance
|
|
36
|
+
* existed and was deleted, `false` otherwise.
|
|
37
|
+
*/
|
|
38
|
+
readonly delete: (key: K) => boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Creates an {@link Instances} manager. */
|
|
42
|
+
export const createInstances = <
|
|
43
|
+
K extends string,
|
|
44
|
+
T extends Disposable,
|
|
45
|
+
>(): Instances<K, T> => {
|
|
46
|
+
const instances = new Map<K, T>();
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
ensure: (key, create, onCacheHit) => {
|
|
50
|
+
let instance = instances.get(key);
|
|
51
|
+
|
|
52
|
+
if (instance == null) {
|
|
53
|
+
instance = create();
|
|
54
|
+
instances.set(key, instance);
|
|
55
|
+
} else if (onCacheHit) {
|
|
56
|
+
onCacheHit(instance);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return instance;
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
get: (key) => instances.get(key) ?? null,
|
|
63
|
+
|
|
64
|
+
has: (key) => instances.has(key),
|
|
65
|
+
|
|
66
|
+
delete: (key) => {
|
|
67
|
+
const instance = instances.get(key);
|
|
68
|
+
if (instance == null) return false;
|
|
69
|
+
instances.delete(key);
|
|
70
|
+
instance[Symbol.dispose]();
|
|
71
|
+
return true;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
[Symbol.dispose]: () => {
|
|
75
|
+
const errors: Array<unknown> = [];
|
|
76
|
+
for (const instance of instances.values()) {
|
|
77
|
+
try {
|
|
78
|
+
instance[Symbol.dispose]();
|
|
79
|
+
} catch (error) {
|
|
80
|
+
errors.push(error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
instances.clear();
|
|
84
|
+
if (errors.length === 1) throw errors[0];
|
|
85
|
+
if (errors.length > 1) {
|
|
86
|
+
throw new AggregateError(errors, "Multiple disposal errors occurred");
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
};
|
package/src/Sqlite.ts
CHANGED
|
@@ -334,6 +334,12 @@ export type SqlTemplateParam = SqliteValue | SqlIdentifier | RawSql;
|
|
|
334
334
|
* const orderBy = "created_at desc";
|
|
335
335
|
* sqlite.exec(sql`select * from users order by ${sql.raw(orderBy)};`);
|
|
336
336
|
* ```
|
|
337
|
+
*
|
|
338
|
+
* ### TIP
|
|
339
|
+
*
|
|
340
|
+
* Use `prettier-plugin-sql-cst` for SQL formatting. Like Prettier for
|
|
341
|
+
* JavaScript, this plugin formats SQL expressions differently depending on
|
|
342
|
+
* their length.
|
|
337
343
|
*/
|
|
338
344
|
export const sql = (
|
|
339
345
|
strings: TemplateStringsArray,
|
package/src/Task.ts
CHANGED
|
@@ -778,13 +778,6 @@ export const createMutex = (): Mutex => {
|
|
|
778
778
|
};
|
|
779
779
|
};
|
|
780
780
|
|
|
781
|
-
// TODO: Add tracing support
|
|
782
|
-
// - Extend TaskContext with optional tracing field
|
|
783
|
-
// - Add traced(name, task) helper that wraps Task execution
|
|
784
|
-
// - Collect span data (name, timing, parent-child relationships, status)
|
|
785
|
-
// - Support OpenTelemetry export format with proper traceId/spanId generation
|
|
786
|
-
// - Automatic parent-child span relationships through context propagation
|
|
787
|
-
|
|
788
781
|
/**
|
|
789
782
|
* Schedule a task to run after all interactions (animations, gestures,
|
|
790
783
|
* navigation) have completed.
|
|
@@ -818,3 +811,91 @@ const idleCallback: (callback: () => void) => void =
|
|
|
818
811
|
typeof globalThis.requestIdleCallback === "function"
|
|
819
812
|
? globalThis.requestIdleCallback
|
|
820
813
|
: (callback) => setTimeout(callback, 0);
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Represents a value that can be either synchronous or asynchronous.
|
|
817
|
+
*
|
|
818
|
+
* This type is useful for functions that may complete synchronously or
|
|
819
|
+
* asynchronously depending on runtime conditions (e.g., cache hit vs network
|
|
820
|
+
* fetch).
|
|
821
|
+
*
|
|
822
|
+
* ### Why MaybeAsync?
|
|
823
|
+
*
|
|
824
|
+
* When a function can be sync or async, the typical approaches are:
|
|
825
|
+
*
|
|
826
|
+
* 1. **Always return Promise** - Simple but forces microtask overhead even for
|
|
827
|
+
* sync values (see "await always adds microtask" test in Task.test.ts)
|
|
828
|
+
* 2. **Use callbacks** - Can avoid microtask, but calling code must still `await`
|
|
829
|
+
* for sane composition, which adds microtask anyway
|
|
830
|
+
* 3. **Return `T | PromiseLike<T>`** - Calling code can check the value and only
|
|
831
|
+
* `await` when needed, avoiding microtask overhead for sync cases
|
|
832
|
+
*
|
|
833
|
+
* The third approach (MaybeAsync) provides:
|
|
834
|
+
*
|
|
835
|
+
* - **Performance**: No microtask overhead for synchronous operations
|
|
836
|
+
* - **Reliability**: No interleaving via microtask queue when operations are
|
|
837
|
+
* _synchronous_, reducing need for mutexes to protect shared state
|
|
838
|
+
*
|
|
839
|
+
* ### Example
|
|
840
|
+
*
|
|
841
|
+
* ```ts
|
|
842
|
+
* // Function that may be sync or async
|
|
843
|
+
* const getData = (id: string): MaybeAsync<Data> => {
|
|
844
|
+
* const cached = cache.get(id);
|
|
845
|
+
* if (cached) return cached; // Sync path
|
|
846
|
+
* return fetchData(id); // Async path
|
|
847
|
+
* };
|
|
848
|
+
*
|
|
849
|
+
* // Caller can optimize based on actual behavior
|
|
850
|
+
* const result = getData(id);
|
|
851
|
+
* const data = isAsync(result) ? await result : result;
|
|
852
|
+
* ```
|
|
853
|
+
*
|
|
854
|
+
* ### Alternative Approaches
|
|
855
|
+
*
|
|
856
|
+
* It's possible to eliminate the sync/async distinction using complex
|
|
857
|
+
* frameworks with custom schedulers. However, such frameworks require depending
|
|
858
|
+
* on other people's code that controls how your code executes, resulting in
|
|
859
|
+
* more complex stack traces and debugging experiences. With MaybeAsync, we
|
|
860
|
+
* don't need that machinery - it works directly with JavaScript's native
|
|
861
|
+
* primitives and TypeScript's type system.
|
|
862
|
+
*
|
|
863
|
+
* ### TODO: Consider
|
|
864
|
+
*
|
|
865
|
+
* Use MaybeAsync in Task and Task helpers to preserve synchronous execution
|
|
866
|
+
* when possible (e.g., mutex with available permit, retry on first success).
|
|
867
|
+
*/
|
|
868
|
+
export type MaybeAsync<T> = T | PromiseLike<T>;
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Type guard to check if a {@link MaybeAsync} value is async (a promise).
|
|
872
|
+
*
|
|
873
|
+
* This function narrows the type of a {@link MaybeAsync} value, allowing you to
|
|
874
|
+
* conditionally `await` only when necessary.
|
|
875
|
+
*
|
|
876
|
+
* ### Example
|
|
877
|
+
*
|
|
878
|
+
* ```ts
|
|
879
|
+
* const getData = (id: string): MaybeAsync<Data> => {
|
|
880
|
+
* const cached = cache.get(id);
|
|
881
|
+
* if (cached) return cached; // Sync path
|
|
882
|
+
* return fetchData(id); // Async path
|
|
883
|
+
* };
|
|
884
|
+
*
|
|
885
|
+
* const result = getData(id);
|
|
886
|
+
* const data = isAsync(result) ? await result : result;
|
|
887
|
+
* // No microtask overhead when cached!
|
|
888
|
+
* ```
|
|
889
|
+
*/
|
|
890
|
+
export const isAsync = <T>(
|
|
891
|
+
value: MaybeAsync<T>,
|
|
892
|
+
): value is T extends PromiseLike<unknown> ? never : PromiseLike<T> =>
|
|
893
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
894
|
+
typeof (value as any)?.then === "function";
|
|
895
|
+
|
|
896
|
+
// TODO: Add tracing support
|
|
897
|
+
// - Extend TaskContext with optional tracing field
|
|
898
|
+
// - Add traced(name, task) helper that wraps Task execution
|
|
899
|
+
// - Collect span data (name, timing, parent-child relationships, status)
|
|
900
|
+
// - Support OpenTelemetry export format with proper traceId/spanId generation
|
|
901
|
+
// - Automatic parent-child span relationships through context propagation
|