@evolu/common 6.0.1-preview.30 → 6.0.1-preview.31

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.
@@ -1,5 +1,9 @@
1
1
  import { sha256 } from "@noble/hashes/sha2.js";
2
- import { NonEmptyReadonlyArray } from "../Array.js";
2
+ import {
3
+ firstInArray,
4
+ isNonEmptyReadonlyArray,
5
+ NonEmptyReadonlyArray,
6
+ } from "../Array.js";
3
7
  import { assert } from "../Assert.js";
4
8
  import { Brand } from "../Brand.js";
5
9
  import { concatBytes } from "../Buffer.js";
@@ -22,7 +26,6 @@ import {
22
26
  Owner,
23
27
  OwnerId,
24
28
  OwnerIdBytes,
25
- ownerIdBytesToOwnerId,
26
29
  OwnerWriteKey,
27
30
  } from "./Owner.js";
28
31
  import { orderTimestampBytes, Timestamp, TimestampBytes } from "./Timestamp.js";
@@ -32,16 +35,16 @@ export interface StorageConfig {
32
35
  * Callback called before an attempt to write, to check if an {@link OwnerId}
33
36
  * has sufficient quota for the write.
34
37
  *
35
- * The callback receives the {@link OwnerId} and the number of bytes required
36
- * for the write, and returns a {@link MaybeAsync} boolean: `true` to allow the
37
- * write, or `false` to deny it due to quota limits.
38
+ * The callback receives the {@link OwnerId} and the total bytes that would be
39
+ * stored after the write (current stored bytes plus incoming bytes), and
40
+ * returns a {@link MaybeAsync} boolean: `true` to allow the write, or `false`
41
+ * to deny it due to quota limits.
38
42
  *
39
43
  * The callback can be synchronous (for SQLite or in-memory checks) or
40
44
  * asynchronous (for calling remote APIs).
41
45
  *
42
- * The callback returns a boolean rather than an error type because error
43
- * handling and logging are the responsibility of the callback
44
- * implementation.
46
+ * The callback returns a boolean rather than an error because error handling
47
+ * and logging are the responsibility of the callback implementation.
45
48
  *
46
49
  * ### Example
47
50
  *
@@ -299,16 +302,11 @@ export interface BaseSqliteStorage
299
302
  | "iterate"
300
303
  | "deleteOwner"
301
304
  > {
302
- /**
303
- * Inserts a timestamp for an owner into the skiplist-based storage.
304
- *
305
- * Must be idempotent - inserting the same timestamp multiple times has no
306
- * effect after the first insertion. This is crucial for sync reliability as
307
- * messages may be received and processed multiple times.
308
- */
305
+ /** Inserts a timestamp for an owner into the skiplist-based storage. */
309
306
  readonly insertTimestamp: (
310
307
  ownerId: OwnerIdBytes,
311
308
  timestamp: TimestampBytes,
309
+ strategy: StorageInsertTimestampStrategy,
312
310
  ) => Result<void, SqliteError>;
313
311
 
314
312
  /**
@@ -331,19 +329,62 @@ export interface CreateBaseSqliteStorageConfig extends StorageConfig {
331
329
  onStorageError: (error: SqliteError) => void;
332
330
  }
333
331
 
332
+ /**
333
+ * Creates a {@link BaseSqliteStorage} implementation.
334
+ *
335
+ * # Stateless Design
336
+ *
337
+ * This implementation is fully stateless - it requires no in-memory state
338
+ * between invocations. All necessary metadata (timestamp bounds for insertion
339
+ * strategy optimization) is persisted in the evolu_usage table. This makes
340
+ * Evolu Relay suitable for stateless serverless environments like AWS Lambda,
341
+ * Cloudflare Workers with Durable Objects, and other platforms where memory
342
+ * doesn't persist between requests. While not extensively tested in all these
343
+ * environments yet, the stateless design should work well across them.
344
+ */
334
345
  export const createBaseSqliteStorage =
335
346
  (deps: SqliteStorageDeps) =>
336
347
  (config: CreateBaseSqliteStorageConfig): BaseSqliteStorage => {
337
- // TODO: Use evolu_usage table.
338
- const ownerStats = new Map<
339
- OwnerId,
340
- {
341
- minT: TimestampBytes;
342
- maxT: TimestampBytes;
343
- }
344
- >();
345
-
346
348
  return {
349
+ insertTimestamp: (
350
+ ownerId: OwnerIdBytes,
351
+ timestamp: TimestampBytes,
352
+ strategy: StorageInsertTimestampStrategy,
353
+ ) => {
354
+ const level = randomSkiplistLevel(deps);
355
+ return insertTimestamp(deps)(ownerId, timestamp, level, strategy);
356
+ },
357
+
358
+ getExistingTimestamps: (ownerIdBytes, timestampsBytes) => {
359
+ const concatenatedTimestamps = concatBytes(...timestampsBytes);
360
+
361
+ const result = deps.sqlite.exec<{
362
+ timestampBytes: TimestampBytes;
363
+ }>(sql`
364
+ with recursive
365
+ split_timestamps(timestampBytes, pos) as (
366
+ select
367
+ substr(${concatenatedTimestamps}, 1, 16),
368
+ 17 as pos
369
+ union all
370
+ select
371
+ substr(${concatenatedTimestamps}, pos, 16),
372
+ pos + 16
373
+ from split_timestamps
374
+ where pos <= length(${concatenatedTimestamps})
375
+ )
376
+ select s.timestampBytes
377
+ from
378
+ split_timestamps s
379
+ join evolu_timestamp t
380
+ on t.ownerId = ${ownerIdBytes} and s.timestampBytes = t.t;
381
+ `);
382
+
383
+ if (!result.ok) return result;
384
+
385
+ return ok(result.value.rows.map((row) => row.timestampBytes));
386
+ },
387
+
347
388
  getSize: (ownerId) => {
348
389
  const size = getSize(deps)(ownerId);
349
390
  if (!size.ok) {
@@ -439,75 +480,6 @@ export const createBaseSqliteStorage =
439
480
  }
440
481
  return true;
441
482
  },
442
-
443
- insertTimestamp: (ownerId: OwnerIdBytes, timestamp: TimestampBytes) => {
444
- const ownerIdString = ownerIdBytesToOwnerId(ownerId);
445
- const level = randomSkiplistLevel(deps);
446
-
447
- let stats = ownerStats.get(ownerIdString);
448
-
449
- if (!stats) {
450
- const result = deps.sqlite.exec<{
451
- maxT: TimestampBytes | null;
452
- minT: TimestampBytes | null;
453
- }>(sql.prepared`
454
- select min(t) as minT, max(t) as maxT
455
- from evolu_timestamp
456
- where ownerId = ${ownerId};
457
- `);
458
- if (!result.ok) return result;
459
-
460
- stats = {
461
- minT: result.value.rows[0].minT ?? timestamp,
462
- maxT: result.value.rows[0].maxT ?? timestamp,
463
- };
464
- ownerStats.set(ownerIdString, stats);
465
- }
466
-
467
- let strategy: InsertTimestampStrategy;
468
-
469
- if (orderTimestampBytes(timestamp, stats.maxT) === 1) {
470
- strategy = "append";
471
- stats.maxT = timestamp;
472
- } else if (orderTimestampBytes(timestamp, stats.minT) === -1) {
473
- strategy = "prepend";
474
- stats.minT = timestamp;
475
- } else {
476
- strategy = "insert";
477
- }
478
-
479
- return insertTimestamp(deps)(ownerId, timestamp, level, strategy);
480
- },
481
-
482
- getExistingTimestamps: (ownerIdBytes, timestampsBytes) => {
483
- const concatenatedTimestamps = concatBytes(...timestampsBytes);
484
-
485
- const result = deps.sqlite.exec<{
486
- timestampBytes: TimestampBytes;
487
- }>(sql`
488
- with recursive
489
- split_timestamps(timestampBytes, pos) as (
490
- select
491
- substr(${concatenatedTimestamps}, 1, 16),
492
- 17 as pos
493
- union all
494
- select
495
- substr(${concatenatedTimestamps}, pos, 16),
496
- pos + 16
497
- from split_timestamps
498
- where pos <= length(${concatenatedTimestamps})
499
- )
500
- select s.timestampBytes
501
- from
502
- split_timestamps s
503
- join evolu_timestamp t
504
- on t.ownerId = ${ownerIdBytes} and s.timestampBytes = t.t;
505
- `);
506
-
507
- if (!result.ok) return result;
508
-
509
- return ok(result.value.rows.map((row) => row.timestampBytes));
510
- },
511
483
  };
512
484
  };
513
485
 
@@ -533,13 +505,7 @@ export const createBaseSqliteStorageTables = (
533
505
  * - `t` – TimestampBytes
534
506
  * - `h1`/`h2` – 12-byte fingerprint split into two integers for fast XOR
535
507
  * - `c` – incremental count
536
- * - `l` – Skiplist level (1 to 32)
537
- *
538
- * For scaling or isolation, sharding is possible—each owner can have a
539
- * separate SQLite database.
540
- *
541
- * Maybe we could use an integer surrogate key for ownerId, but it's fast
542
- * enough even without it.
508
+ * - `l` – Skiplist level (1 to 10)
543
509
  */
544
510
  sql`
545
511
  create table evolu_timestamp (
@@ -572,20 +538,15 @@ export const createBaseSqliteStorageTables = (
572
538
  *
573
539
  * - `ownerId` – OwnerIdBytes (primary key)
574
540
  * - `storedBytes` – total bytes stored in database
575
- * - `receivedBytes` – TODO: Decide how to use
576
- * - `sentBytes` – TODO: Decide how to use
577
- * - `firstTimestamp` – TODO: Decide how to use (nullable)
578
- * - `lastTimestamp` – TODO: Decide how to use (nullable)
541
+ * - `firstTimestamp` – for timestamp insertion strategies
542
+ * - `lastTimestamp` – for timestamp insertion strategies
579
543
  */
580
544
  sql`
581
545
  create table evolu_usage (
582
546
  "ownerId" blob primary key,
583
- "storedBytes" integer not null
584
- -- TODO: Decide how to use receivedBytes, sentBytes, firstTimestamp, lastTimestamp
585
- -- "receivedBytes" integer not null,
586
- -- "sentBytes" integer not null,
587
- -- "firstTimestamp" blob,
588
- -- "lastTimestamp" blob
547
+ "storedBytes" integer not null,
548
+ "firstTimestamp" blob,
549
+ "lastTimestamp" blob
589
550
  )
590
551
  strict;
591
552
  `,
@@ -596,23 +557,53 @@ export const createBaseSqliteStorageTables = (
596
557
  return ok();
597
558
  };
598
559
 
599
- type InsertTimestampStrategy = "append" | "prepend" | "insert";
560
+ export type StorageInsertTimestampStrategy = "append" | "prepend" | "insert";
561
+
562
+ /**
563
+ * Determines the insertion strategy for a timestamp based on its position
564
+ * relative to the current first and last timestamps.
565
+ *
566
+ * Returns a tuple with the strategy and updated timestamp bounds.
567
+ */
568
+ export const getTimestampInsertStrategy = (
569
+ timestamp: TimestampBytes,
570
+ firstTimestamp: TimestampBytes,
571
+ lastTimestamp: TimestampBytes,
572
+ ): [
573
+ strategy: StorageInsertTimestampStrategy,
574
+ firstTimestamp: TimestampBytes,
575
+ lastTimestamp: TimestampBytes,
576
+ ] => {
577
+ if (orderTimestampBytes(timestamp, lastTimestamp) === 1) {
578
+ return ["append", firstTimestamp, timestamp];
579
+ }
580
+ if (orderTimestampBytes(timestamp, firstTimestamp) === -1) {
581
+ return ["prepend", timestamp, lastTimestamp];
582
+ }
583
+ return ["insert", firstTimestamp, lastTimestamp];
584
+ };
600
585
 
601
- // AFAIK, we can't do both insert and update in one query, and that's probably
602
- // why append is 2x faster than insert. Prepend also has to update parents, but
603
- // it's constantly fast. Insert degrades for reversed (yet LIMIT X magically
604
- // makes it much faster) but it's OK for append. It's probably because it's the
605
- // most complicated SQL, but I believe it can be simplified. If not, we can
606
- // optimize prepending by reversing the incoming timestamps if we detect that
607
- // they will prepend. They are always sorted in ascending order by the
608
- // Protocol.
586
+ /**
587
+ * AFAIK, we can't do both insert and update in one query, and that's probably
588
+ * why append is 2x faster than insert. Prepend also has to update parents, but
589
+ * it's constantly fast. Insert degrades for reversed (yet LIMIT X magically
590
+ * fixes that) but it's OK for append.
591
+ *
592
+ * Note: SQL operations are idempotent (using `on conflict do nothing` and
593
+ * `changes() > 0`), but this is no longer required here since we use
594
+ * {@link BaseSqliteStorage.getExistingTimestamps} to filter out duplicates
595
+ * before insertion, which we need for quota checks anyway.
596
+ *
597
+ * TODO: Remove idempotency (`on conflict do nothing` and `changes() > 0`) since
598
+ * duplicates are now filtered before insertion.
599
+ */
609
600
  const insertTimestamp =
610
601
  (deps: SqliteDep) =>
611
602
  (
612
603
  ownerId: OwnerIdBytes,
613
604
  timestamp: TimestampBytes,
614
605
  level: PositiveInt,
615
- strategy: InsertTimestampStrategy,
606
+ strategy: StorageInsertTimestampStrategy,
616
607
  ): Result<void, SqliteError> => {
617
608
  const [h1, h2] = fingerprintToSqliteFingerprint(
618
609
  timestampBytesToFingerprint(timestamp),
@@ -1612,3 +1603,76 @@ export const getTimestampByIndex =
1612
1603
  if (!result.ok) return result;
1613
1604
  return ok(result.value.rows[0].pt);
1614
1605
  };
1606
+
1607
+ /** Retrieves usage information for an owner from the evolu_usage table. */
1608
+ export const getOwnerUsage =
1609
+ (deps: SqliteDep) =>
1610
+ (
1611
+ ownerIdBytes: OwnerIdBytes,
1612
+ initialTimestamp: TimestampBytes,
1613
+ ): Result<
1614
+ {
1615
+ storedBytes: NonNegativeInt | null;
1616
+ firstTimestamp: TimestampBytes;
1617
+ lastTimestamp: TimestampBytes;
1618
+ },
1619
+ SqliteError
1620
+ > => {
1621
+ const result = deps.sqlite.exec<{
1622
+ storedBytes: NonNegativeInt;
1623
+ firstTimestamp: TimestampBytes | null;
1624
+ lastTimestamp: TimestampBytes | null;
1625
+ }>(sql`
1626
+ select storedBytes, firstTimestamp, lastTimestamp
1627
+ from evolu_usage
1628
+ where ownerId = ${ownerIdBytes};
1629
+ `);
1630
+ if (!result.ok) return result;
1631
+
1632
+ if (!isNonEmptyReadonlyArray(result.value.rows)) {
1633
+ return ok({
1634
+ storedBytes: null,
1635
+ firstTimestamp: initialTimestamp,
1636
+ lastTimestamp: initialTimestamp,
1637
+ });
1638
+ }
1639
+
1640
+ const row = firstInArray(result.value.rows);
1641
+ assert(row.firstTimestamp, "not null");
1642
+ assert(row.lastTimestamp, "not null");
1643
+
1644
+ return ok({
1645
+ storedBytes: row.storedBytes,
1646
+ firstTimestamp: row.firstTimestamp,
1647
+ lastTimestamp: row.lastTimestamp,
1648
+ });
1649
+ };
1650
+
1651
+ /**
1652
+ * Updates timestamp bounds in evolu_usage table.
1653
+ *
1654
+ * Used by both relay and client to maintain firstTimestamp/lastTimestamp after
1655
+ * processing messages.
1656
+ */
1657
+ export const updateOwnerUsage =
1658
+ (deps: SqliteDep) =>
1659
+ (
1660
+ ownerIdBytes: OwnerIdBytes,
1661
+ storedBytes: PositiveInt,
1662
+ firstTimestamp: TimestampBytes,
1663
+ lastTimestamp: TimestampBytes,
1664
+ ): Result<void, SqliteError> => {
1665
+ const result = deps.sqlite.exec(sql`
1666
+ insert into evolu_usage
1667
+ ("ownerId", "storedBytes", "firstTimestamp", "lastTimestamp")
1668
+ values
1669
+ (${ownerIdBytes}, ${storedBytes}, ${firstTimestamp}, ${lastTimestamp})
1670
+ on conflict (ownerId) do update
1671
+ set
1672
+ storedBytes = ${storedBytes},
1673
+ firstTimestamp = ${firstTimestamp},
1674
+ lastTimestamp = ${lastTimestamp};
1675
+ `);
1676
+ if (!result.ok) return result;
1677
+ return ok();
1678
+ };
package/src/Evolu/Sync.ts CHANGED
@@ -1,4 +1,9 @@
1
- import { NonEmptyArray, NonEmptyReadonlyArray } from "../Array.js";
1
+ import {
2
+ firstInArray,
3
+ isNonEmptyReadonlyArray,
4
+ NonEmptyArray,
5
+ NonEmptyReadonlyArray,
6
+ } from "../Array.js";
2
7
  import { assert } from "../Assert.js";
3
8
  import { Brand } from "../Brand.js";
4
9
  import { ConsoleDep } from "../Console.js";
@@ -17,7 +22,7 @@ import { err, ok, Result } from "../Result.js";
17
22
  import { sql, SqliteDep, SqliteError, SqliteValue } from "../Sqlite.js";
18
23
  import { AbortError, createMutex } from "../Task.js";
19
24
  import { TimeDep } from "../Time.js";
20
- import { IdBytes, idBytesToId, idToIdBytes } from "../Type.js";
25
+ import { IdBytes, idBytesToId, idToIdBytes, PositiveInt } from "../Type.js";
21
26
  import { CreateWebSocketDep, WebSocket } from "../WebSocket.js";
22
27
  import type { PostMessageDep } from "./Db.js";
23
28
  import {
@@ -51,8 +56,12 @@ import {
51
56
  CrdtMessage,
52
57
  createBaseSqliteStorage,
53
58
  DbChange,
59
+ getOwnerUsage,
60
+ getTimestampInsertStrategy,
54
61
  Storage,
62
+ StorageInsertTimestampStrategy,
55
63
  StorageWriteError,
64
+ updateOwnerUsage,
56
65
  } from "./Storage.js";
57
66
  import {
58
67
  createInitialTimestamp,
@@ -519,11 +528,13 @@ const createClientStorage =
519
528
  clockTimestamp = nextTimestamp.value;
520
529
  }
521
530
 
522
- const applyMessagesResult = applyMessages({ ...deps, storage })(
523
- owner.id,
524
- messages,
525
- );
526
- if (!applyMessagesResult.ok) return applyMessagesResult;
531
+ if (isNonEmptyReadonlyArray(messages)) {
532
+ const applyMessagesResult = applyMessages({ ...deps, storage })(
533
+ owner.id,
534
+ messages,
535
+ );
536
+ if (!applyMessagesResult.ok) return applyMessagesResult;
537
+ }
527
538
 
528
539
  // // Apply local mutations atomically with approved messages
529
540
  // for (const change of localMutations) {
@@ -644,25 +655,56 @@ export const applyLocalOnlyChange =
644
655
  return ok();
645
656
  };
646
657
 
647
- export const applyMessages =
658
+ const applyMessages =
648
659
  (deps: ClientStorageDep & ClockDep & RandomDep & SqliteDep) =>
649
660
  (
650
661
  ownerId: OwnerId,
651
- messages: ReadonlyArray<CrdtMessage>,
662
+ messages: NonEmptyReadonlyArray<CrdtMessage>,
652
663
  ): Result<void, SqliteError> => {
653
664
  const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
654
665
 
666
+ const usageResult = getOwnerUsage(deps)(
667
+ ownerIdBytes,
668
+ timestampToTimestampBytes(firstInArray(messages).timestamp),
669
+ );
670
+ if (!usageResult.ok) return usageResult;
671
+
672
+ let { firstTimestamp, lastTimestamp } = usageResult.value;
673
+
655
674
  for (const message of messages) {
656
675
  const result1 = applyMessageToAppTable(deps)(ownerIdBytes, message);
657
676
  if (!result1.ok) return result1;
658
677
 
678
+ const timestamp = timestampToTimestampBytes(message.timestamp);
679
+
680
+ let strategy;
681
+ [strategy, firstTimestamp, lastTimestamp] = getTimestampInsertStrategy(
682
+ timestamp,
683
+ firstTimestamp,
684
+ lastTimestamp,
685
+ );
686
+
659
687
  const result2 = applyMessageToTimestampAndHistoryTables(deps)(
660
688
  ownerIdBytes,
661
689
  message,
690
+ strategy,
662
691
  );
663
692
  if (!result2.ok) return result2;
664
693
  }
665
694
 
695
+ /**
696
+ * TODO: Implement proper storedBytes tracking for client using encrypted
697
+ * message sizes (need to figure out how to reuse received or postpone
698
+ * client...).
699
+ */
700
+ const updateUsage = updateOwnerUsage(deps)(
701
+ ownerIdBytes,
702
+ 1 as PositiveInt, // Placeholder until proper tracking implemented
703
+ firstTimestamp,
704
+ lastTimestamp,
705
+ );
706
+ if (!updateUsage.ok) return updateUsage;
707
+
666
708
  return ok();
667
709
  };
668
710
 
@@ -705,11 +747,15 @@ const applyMessageToAppTable =
705
747
 
706
748
  export const applyMessageToTimestampAndHistoryTables =
707
749
  (deps: ClientStorageDep & SqliteDep) =>
708
- (ownerId: OwnerIdBytes, message: CrdtMessage): Result<void, SqliteError> => {
750
+ (
751
+ ownerId: OwnerIdBytes,
752
+ message: CrdtMessage,
753
+ strategy: StorageInsertTimestampStrategy,
754
+ ): Result<void, SqliteError> => {
709
755
  const timestamp = timestampToTimestampBytes(message.timestamp);
710
756
  const id = idToIdBytes(message.change.id);
711
757
 
712
- const result = deps.storage.insertTimestamp(ownerId, timestamp);
758
+ const result = deps.storage.insertTimestamp(ownerId, timestamp, strategy);
713
759
  if (!result.ok) return result;
714
760
 
715
761
  for (const [column, value] of Object.entries(message.change.values)) {
package/src/Type.ts CHANGED
@@ -1641,8 +1641,9 @@ export const createId = <B extends string = never>(
1641
1641
  * });
1642
1642
  * ```
1643
1643
  *
1644
- * **Important**: This transformation is one-way. We cannot recover the original
1645
- * external string from the generated {@link Id}. If we need to preserve the
1644
+ * **Important**: This transformation uses the first 16 bytes of SHA-256 hash of
1645
+ * the string bytes, therefore it's not possible to recover the original
1646
+ * external string from the generated {@link Id}. If you need to preserve the
1646
1647
  * original external ID, store it in a separate column.
1647
1648
  *
1648
1649
  * @category String