@evolu/common 6.0.1-preview.32 → 6.0.1-preview.34

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.
Files changed (49) hide show
  1. package/README.md +2 -2
  2. package/dist/src/Array.d.ts +193 -41
  3. package/dist/src/Array.d.ts.map +1 -1
  4. package/dist/src/Array.js +155 -48
  5. package/dist/src/Crypto.d.ts +6 -8
  6. package/dist/src/Crypto.d.ts.map +1 -1
  7. package/dist/src/Crypto.js +9 -9
  8. package/dist/src/Evolu/Db.d.ts.map +1 -1
  9. package/dist/src/Evolu/Db.js +4 -4
  10. package/dist/src/Evolu/Evolu.d.ts +9 -4
  11. package/dist/src/Evolu/Evolu.d.ts.map +1 -1
  12. package/dist/src/Evolu/Evolu.js +11 -11
  13. package/dist/src/Evolu/Protocol.d.ts +24 -1
  14. package/dist/src/Evolu/Protocol.d.ts.map +1 -1
  15. package/dist/src/Evolu/Protocol.js +59 -30
  16. package/dist/src/Evolu/Query.d.ts +1 -1
  17. package/dist/src/Evolu/Query.d.ts.map +1 -1
  18. package/dist/src/Evolu/Query.js +1 -1
  19. package/dist/src/Evolu/Schema.d.ts +24 -20
  20. package/dist/src/Evolu/Schema.d.ts.map +1 -1
  21. package/dist/src/Evolu/Schema.js +36 -27
  22. package/dist/src/Evolu/Storage.d.ts +10 -2
  23. package/dist/src/Evolu/Storage.d.ts.map +1 -1
  24. package/dist/src/Evolu/Storage.js +18 -4
  25. package/dist/src/Evolu/Sync.d.ts +2 -1
  26. package/dist/src/Evolu/Sync.d.ts.map +1 -1
  27. package/dist/src/Evolu/Sync.js +45 -33
  28. package/dist/src/Evolu/Timestamp.d.ts +12 -17
  29. package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
  30. package/dist/src/Evolu/Timestamp.js +5 -19
  31. package/dist/src/Object.d.ts +10 -4
  32. package/dist/src/Object.d.ts.map +1 -1
  33. package/dist/src/Object.js +9 -3
  34. package/dist/src/Type.d.ts +57 -11
  35. package/dist/src/Type.d.ts.map +1 -1
  36. package/dist/src/Type.js +58 -28
  37. package/package.json +2 -2
  38. package/src/Array.ts +213 -55
  39. package/src/Crypto.ts +11 -9
  40. package/src/Evolu/Db.ts +7 -7
  41. package/src/Evolu/Evolu.ts +21 -20
  42. package/src/Evolu/Protocol.ts +70 -35
  43. package/src/Evolu/Query.ts +2 -2
  44. package/src/Evolu/Schema.ts +44 -32
  45. package/src/Evolu/Storage.ts +40 -2
  46. package/src/Evolu/Sync.ts +54 -32
  47. package/src/Evolu/Timestamp.ts +7 -28
  48. package/src/Object.ts +13 -5
  49. package/src/Type.ts +58 -32
package/src/Evolu/Sync.ts CHANGED
@@ -22,7 +22,13 @@ import { err, ok, Result } from "../Result.js";
22
22
  import { sql, SqliteDep, SqliteError, SqliteValue } from "../Sqlite.js";
23
23
  import { AbortError, createMutex } from "../Task.js";
24
24
  import { TimeDep } from "../Time.js";
25
- import { IdBytes, idBytesToId, idToIdBytes, PositiveInt } from "../Type.js";
25
+ import {
26
+ DateIso,
27
+ IdBytes,
28
+ idBytesToId,
29
+ idToIdBytes,
30
+ PositiveInt,
31
+ } from "../Type.js";
26
32
  import { CreateWebSocketDep, WebSocket } from "../WebSocket.js";
27
33
  import type { PostMessageDep } from "./Db.js";
28
34
  import {
@@ -74,8 +80,8 @@ import {
74
80
  TimestampCounterOverflowError,
75
81
  TimestampDriftError,
76
82
  TimestampTimeOutOfRangeError,
83
+ timestampToDateIso,
77
84
  timestampToTimestampBytes,
78
- timestampToTimestampString,
79
85
  } from "./Timestamp.js";
80
86
 
81
87
  export interface Sync extends Disposable {
@@ -341,7 +347,10 @@ export const createSync =
341
347
  clockTimestamp = nextTimestamp.value;
342
348
 
343
349
  const { ownerId = config.appOwner.id, ...dbChange } = change;
344
- const message = { timestamp: clockTimestamp, change: dbChange };
350
+ const message: CrdtMessage = {
351
+ timestamp: clockTimestamp,
352
+ change: dbChange,
353
+ };
345
354
 
346
355
  const messages = ownerMessages.get(ownerId);
347
356
  if (messages) messages.push(message);
@@ -397,7 +406,6 @@ export interface ClockDep {
397
406
  readonly clock: Clock;
398
407
  }
399
408
 
400
- // HLC
401
409
  export interface Clock {
402
410
  readonly get: () => Timestamp;
403
411
  readonly save: (timestamp: Timestamp) => Result<void, SqliteError>;
@@ -413,9 +421,9 @@ export const createClock =
413
421
  save: (timestamp) => {
414
422
  currentTimestamp = timestamp;
415
423
 
416
- const timestampString = timestampToTimestampString(timestamp);
417
424
  const result = deps.sqlite.exec(sql.prepared`
418
- update evolu_config set "clock" = ${timestampString};
425
+ update evolu_config
426
+ set "clock" = ${timestampToTimestampBytes(timestamp)};
419
427
  `);
420
428
  if (!result.ok) return result;
421
429
 
@@ -587,20 +595,26 @@ const createClientStorage =
587
595
 
588
596
  const { table, id } = rows[0];
589
597
  const values: Record<string, SqliteValue> = {};
598
+ let isInsert = false;
590
599
 
591
600
  for (const r of rows) {
592
601
  assert(r.table === table, "All rows must have the same table");
593
602
  assert(eqArrayNumber(r.id, id), "All rows must have the same Id");
594
- values[r.column] = r.value;
603
+ if (r.column === "createdAt") {
604
+ isInsert = true;
605
+ } else {
606
+ values[r.column] = r.value;
607
+ }
595
608
  }
596
609
 
597
610
  const message: CrdtMessage = {
598
611
  timestamp: timestampBytesToTimestamp(timestamp),
599
- change: {
612
+ change: DbChange.orThrow({
600
613
  table: rows[0].table,
601
614
  id: idBytesToId(rows[0].id),
602
615
  values,
603
- },
616
+ isInsert,
617
+ }),
604
618
  };
605
619
 
606
620
  return encodeAndEncryptDbChange(deps)(message, owner.encryptionKey);
@@ -620,33 +634,27 @@ const createTransportKey = (transportConfig: OwnerTransport): TransportKey => {
620
634
  export const applyLocalOnlyChange =
621
635
  (deps: SqliteDep & TimeDep) =>
622
636
  (change: MutationChange): Result<void, SqliteError> => {
623
- const dbChange: DbChange = {
624
- table: change.table,
625
- id: change.id,
626
- values: change.values,
627
- };
628
-
629
637
  const isDeletion =
630
- "isDeleted" in dbChange.values && dbChange.values.isDeleted === 1;
638
+ "isDeleted" in change.values && change.values.isDeleted === 1;
631
639
 
632
640
  if (isDeletion) {
633
641
  const result = deps.sqlite.exec(sql`
634
- delete from ${sql.identifier(dbChange.table)}
635
- where id = ${dbChange.id};
642
+ delete from ${sql.identifier(change.table)}
643
+ where id = ${change.id};
636
644
  `);
637
645
  if (!result.ok) return result;
638
646
  } else {
639
- const date = new Date(deps.time.now()).toISOString();
647
+ const now = deps.time.nowIso();
640
648
 
641
- for (const [column, value] of objectToEntries(dbChange.values)) {
649
+ for (const [column, value] of objectToEntries(change.values)) {
642
650
  const result = deps.sqlite.exec(sql.prepared`
643
- insert into ${sql.identifier(dbChange.table)}
651
+ insert into ${sql.identifier(change.table)}
644
652
  ("id", ${sql.identifier(column)}, createdAt, updatedAt)
645
- values (${dbChange.id}, ${value}, ${date}, ${date})
653
+ values (${change.id}, ${value}, ${now}, ${now})
646
654
  on conflict ("id") do update
647
655
  set
648
656
  ${sql.identifier(column)} = ${value},
649
- updatedAt = ${date};
657
+ updatedAt = ${now};
650
658
  `);
651
659
  if (!result.ok) return result;
652
660
  }
@@ -672,7 +680,8 @@ const applyMessages =
672
680
  let { firstTimestamp, lastTimestamp } = usageResult.value;
673
681
 
674
682
  for (const message of messages) {
675
- const result1 = applyMessageToAppTable(deps)(ownerIdBytes, message);
683
+ const date = timestampToDateIso(message.timestamp);
684
+ const result1 = applyMessageToAppTable(deps)(ownerIdBytes, message, date);
676
685
  if (!result1.ok) return result1;
677
686
 
678
687
  const timestamp = timestampToTimestampBytes(message.timestamp);
@@ -688,6 +697,7 @@ const applyMessages =
688
697
  ownerIdBytes,
689
698
  message,
690
699
  strategy,
700
+ date,
691
701
  );
692
702
  if (!result2.ok) return result2;
693
703
  }
@@ -710,11 +720,17 @@ const applyMessages =
710
720
 
711
721
  const applyMessageToAppTable =
712
722
  (deps: SqliteDep) =>
713
- (ownerId: OwnerIdBytes, message: CrdtMessage): Result<void, SqliteError> => {
714
- const timestamp = timestampToTimestampBytes(message.timestamp);
715
- const updatedAt = new Date(message.timestamp.millis).toISOString();
723
+ (
724
+ ownerId: OwnerIdBytes,
725
+ message: CrdtMessage,
726
+ date: DateIso,
727
+ ): Result<void, SqliteError> => {
728
+ let entries = objectToEntries(message.change.values);
729
+ if (message.change.isInsert) {
730
+ entries = [...entries, ["createdAt", date]];
731
+ }
716
732
 
717
- for (const [column, value] of objectToEntries(message.change.values)) {
733
+ for (const [column, value] of entries) {
718
734
  const result = deps.sqlite.exec(sql.prepared`
719
735
  with
720
736
  existingTimestamp as (
@@ -725,17 +741,17 @@ const applyMessageToAppTable =
725
741
  and "table" = ${message.change.table}
726
742
  and "id" = ${idToIdBytes(message.change.id)}
727
743
  and "column" = ${column}
728
- and "timestamp" >= ${timestamp}
744
+ and "timestamp" >= ${timestampToTimestampBytes(message.timestamp)}
729
745
  limit 1
730
746
  )
731
747
  insert into ${sql.identifier(message.change.table)}
732
748
  ("id", ${sql.identifier(column)}, updatedAt)
733
- select ${message.change.id}, ${value}, ${updatedAt}
749
+ select ${message.change.id}, ${value}, ${date}
734
750
  where not exists (select 1 from existingTimestamp)
735
751
  on conflict ("id") do update
736
752
  set
737
753
  ${sql.identifier(column)} = ${value},
738
- updatedAt = ${updatedAt}
754
+ updatedAt = ${date}
739
755
  where not exists (select 1 from existingTimestamp);
740
756
  `);
741
757
 
@@ -751,6 +767,7 @@ export const applyMessageToTimestampAndHistoryTables =
751
767
  ownerId: OwnerIdBytes,
752
768
  message: CrdtMessage,
753
769
  strategy: StorageInsertTimestampStrategy,
770
+ date: DateIso,
754
771
  ): Result<void, SqliteError> => {
755
772
  const timestamp = timestampToTimestampBytes(message.timestamp);
756
773
  const id = idToIdBytes(message.change.id);
@@ -758,7 +775,12 @@ export const applyMessageToTimestampAndHistoryTables =
758
775
  const result = deps.storage.insertTimestamp(ownerId, timestamp, strategy);
759
776
  if (!result.ok) return result;
760
777
 
761
- for (const [column, value] of Object.entries(message.change.values)) {
778
+ let entries = objectToEntries(message.change.values);
779
+ if (message.change.isInsert) {
780
+ entries = [...entries, ["createdAt", date]];
781
+ }
782
+
783
+ for (const [column, value] of entries) {
762
784
  const result = deps.sqlite.exec(sql.prepared`
763
785
  insert into evolu_history
764
786
  ("ownerId", "table", "id", "column", "value", "timestamp")
@@ -1,5 +1,3 @@
1
- import { assert } from "../Assert.js";
2
- import { Brand } from "../Brand.js";
3
1
  import { bytesToHex } from "../Buffer.js";
4
2
  import { RandomBytesDep } from "../Crypto.js";
5
3
  import { createEqObject, eqNumber, eqString } from "../Eq.js";
@@ -9,6 +7,7 @@ import { err, ok, Result } from "../Result.js";
9
7
  import { TimeDep } from "../Time.js";
10
8
  import {
11
9
  brand,
10
+ DateIso,
12
11
  InferType,
13
12
  lessThanOrEqualTo,
14
13
  NonNegativeInt,
@@ -163,10 +162,9 @@ export const maxNodeId = "ffffffffffffffff" as NodeId;
163
162
  * queue:
164
163
  *
165
164
  * 1. Write changes immediately to a local-only table
166
- * 2. Periodically/randomly flush messages to sync tables
167
- * 3. This decouples user activity from sync timing
165
+ * 2. Periodically and randomly flush messages to sync tables
168
166
  *
169
- * Tradeoff: It breaks real-time collaboration.
167
+ * **Trade-off:** It breaks real-time collaboration.
170
168
  */
171
169
  export const Timestamp = object({
172
170
  millis: Millis,
@@ -193,29 +191,6 @@ export const createInitialTimestamp = (deps: RandomBytesDep): Timestamp => {
193
191
  return createTimestamp({ nodeId });
194
192
  };
195
193
 
196
- /** Sortable string representation of {@link Timestamp}. */
197
- export type TimestampString = string & Brand<"TimestampString">;
198
-
199
- export const timestampToTimestampString = (t: Timestamp): TimestampString =>
200
- [
201
- new Date(t.millis).toISOString(),
202
- t.counter.toString(16).toUpperCase().padStart(4, "0"),
203
- t.nodeId,
204
- ].join("-") as TimestampString;
205
-
206
- export const timestampStringToTimestamp = (
207
- timestampString: TimestampString,
208
- ): Timestamp => {
209
- const array = timestampString.split("-");
210
- const timestamp = {
211
- millis: Date.parse(array.slice(0, 3).join("-")).valueOf(),
212
- counter: parseInt(array[3], 16),
213
- nodeId: array[4],
214
- };
215
- assert(Timestamp.is(timestamp), "timestampString is malformed");
216
- return timestamp;
217
- };
218
-
219
194
  const getNextMillis =
220
195
  (deps: TimeDep & TimestampConfigDep) =>
221
196
  (
@@ -362,3 +337,7 @@ export const timestampBytesToTimestamp = (
362
337
  };
363
338
 
364
339
  export const orderTimestampBytes: Order<TimestampBytes> = orderUint8Array;
340
+
341
+ export const timestampToDateIso = (timestamp: Timestamp): DateIso =>
342
+ // `as DateIso` is safe because the timestamp is always valid
343
+ new Date(timestamp.millis).toISOString() as DateIso;
package/src/Object.ts CHANGED
@@ -25,14 +25,22 @@ export type ReadonlyRecord<K extends keyof any, V> = Readonly<Record<K, V>>;
25
25
  type StringKeyOf<T> = Extract<keyof T, string>;
26
26
 
27
27
  /**
28
- * Converts a record to entries, preserving branded string key types (e.g.,
29
- * `type Id = 'id' & string`) via `StringKeyOf<T>`, unlike `Object.entries`
30
- * which widens keys to `string`.
28
+ * Like `Object.entries` but preserves branded keys.
29
+ *
30
+ * ### Example
31
+ *
32
+ * ```ts
33
+ * type UserId = string & { readonly __brand: "UserId" };
34
+ * const users: Record<UserId, string> = {};
35
+ * const entries = objectToEntries(users); // [UserId, string][]
36
+ * ```
31
37
  */
32
38
  export const objectToEntries = <T extends Record<string, any>>(
33
39
  record: T,
34
- ): Array<[StringKeyOf<T>, T[StringKeyOf<T>]]> =>
35
- Object.entries(record) as Array<[StringKeyOf<T>, T[StringKeyOf<T>]]>;
40
+ ): ReadonlyArray<[StringKeyOf<T>, T[StringKeyOf<T>]]> =>
41
+ Object.entries(record) as Array<
42
+ [StringKeyOf<T>, T[StringKeyOf<T>]]
43
+ > as ReadonlyArray<[StringKeyOf<T>, T[StringKeyOf<T>]]>;
36
44
 
37
45
  /**
38
46
  * Maps a `ReadonlyRecord<K, V>` to a new `ReadonlyRecord<K, U>`, preserving
package/src/Type.ts CHANGED
@@ -3,10 +3,7 @@
3
3
  *
4
4
  * Evolu {@link Type} is like a type guard that returns typed errors (via
5
5
  * {@link Result}) instead of throwing. We either get a safely typed value or a
6
- * precise, composable error value telling us exactly why validation failed.
7
- *
8
- * Evolu Type supports [Standard Schema](https://standardschema.dev/) for
9
- * interoperability with 40+ validation-compatible tools and frameworks.
6
+ * composable typed error telling us exactly why validation failed.
10
7
  *
11
8
  * Why another validation library?
12
9
  *
@@ -15,12 +12,19 @@
15
12
  * messages.
16
13
  * - **Consistent constraints via {@link Brand}** – every constraint becomes part
17
14
  * of the type.
18
- * - **No user-land chaining DSL** – designed with the upcoming ES pipe operator
19
- * in mind.
20
- * - **Selective validation** – parent validations are skipped when already proved
21
- * by typing.
22
- * - **Simple, top-down implementation** – readable source code from top to bottom
23
- * with no hidden magic; just plain functions and composition.
15
+ * - **Skippable validation** – parent validations can be skipped when already
16
+ * proved by types.
17
+ * - **Simple, top-down implementation** – readable source code from top to
18
+ * bottom.
19
+ * - **No user-land chaining DSL** – prepared for TC39 Hack pipes.
20
+ *
21
+ * A distinctive feature of Evolu Type compared to other validation libraries is
22
+ * that it returns typed errors rather than string messages. This allows
23
+ * TypeScript to enforce that all validation errors are handled by type
24
+ * checking, significantly improving the developer experience.
25
+ *
26
+ * Evolu Type supports [Standard Schema](https://standardschema.dev/) for
27
+ * interoperability with 40+ validation-compatible tools and frameworks.
24
28
  *
25
29
  * ### Base Types Quick Start
26
30
  *
@@ -171,6 +175,29 @@
171
175
  * reverse transforms would not buy much. We may revisit this if we can design a
172
176
  * minimal, 100% safe API that preserves simplicity.
173
177
  *
178
+ * ### Prepared for TC39 Hack Pipes
179
+ *
180
+ * Take a look how `SimplePassword` is defined:
181
+ *
182
+ * ```ts
183
+ * export const SimplePassword = brand(
184
+ * "SimplePassword",
185
+ * minLength(8)(maxLength(64)(TrimmedString)),
186
+ * );
187
+ * ```
188
+ *
189
+ * Nested functions are often OK (if not, make a helper) and read well, but with
190
+ * TC39 Hack pipes it would be clearer:
191
+ *
192
+ * ```ts
193
+ * // TrimmedString
194
+ * // |> minLength(8)(%)
195
+ * // |> maxLength(64)(%)
196
+ * // |> brand("SimplePassword", %)
197
+ * ```
198
+ *
199
+ * Note `minLength` and `maxLength` are curried because they are factories.
200
+ *
174
201
  * @module
175
202
  */
176
203
 
@@ -1497,7 +1524,7 @@ export const base64UrlToUint8Array: (str: Base64Url) => Uint8Array =
1497
1524
  * Uses the same safe alphabet as {@link UrlSafeString} (letters, digits, `-`,
1498
1525
  * `_`). See `UrlSafeString` for details.
1499
1526
  *
1500
- * The string must be between 1 and 42 characters.
1527
+ * The string must be between 1 and 64 characters.
1501
1528
  *
1502
1529
  * ### Example
1503
1530
  *
@@ -1513,7 +1540,7 @@ export const base64UrlToUint8Array: (str: Base64Url) => Uint8Array =
1513
1540
  * @category String
1514
1541
  */
1515
1542
  export const SimpleName = brand("SimpleName", UrlSafeString, (value) =>
1516
- value.length >= 1 && value.length <= 42
1543
+ value.length >= 1 && value.length <= 64
1517
1544
  ? ok(value)
1518
1545
  : err<SimpleNameError>({ type: "SimpleName", value }),
1519
1546
  );
@@ -1523,6 +1550,25 @@ export interface SimpleNameError extends TypeError<"SimpleName"> {}
1523
1550
  /**
1524
1551
  * Trimmed string between 8 and 64 characters, branded as `SimplePassword`.
1525
1552
  *
1553
+ * Take a look how `SimplePassword` is defined:
1554
+ *
1555
+ * ```ts
1556
+ * export const SimplePassword = brand(
1557
+ * "SimplePassword",
1558
+ * minLength(8)(maxLength(64)(TrimmedString)),
1559
+ * );
1560
+ * ```
1561
+ *
1562
+ * Nested functions are often OK (if not, make a helper), but with TC39 Hack
1563
+ * pipes it would be clearer:
1564
+ *
1565
+ * ```ts
1566
+ * // TrimmedString
1567
+ * // |> minLength(8)(%)
1568
+ * // |> maxLength(64)(%)
1569
+ * // |> brand("SimplePassword", %)
1570
+ * ```
1571
+ *
1526
1572
  * @category String
1527
1573
  */
1528
1574
  export const SimplePassword = brand(
@@ -3428,26 +3474,6 @@ export const formatInt64Error = createTypeErrorFormatter<Int64Error>(
3428
3474
  `The value ${error.value} is not a valid 64-bit signed integer (Int64).`,
3429
3475
  );
3430
3476
 
3431
- // // co s timhle? je to string, ze ktereho lze udelat bigint
3432
-
3433
- // export const BigIntFromString = transform(
3434
- // String,
3435
- // BigInt,
3436
- // (value) =>
3437
- // trySync(
3438
- // () => globalThis.BigInt(value),
3439
- // (): BigIntFromStringError => ({ type: "BigIntFromString", value }),
3440
- // ),
3441
- // (value) => value.toString(),
3442
- // );
3443
-
3444
- // export interface BigIntFromStringError extends TypeError<"BigIntFromString"> {}
3445
-
3446
- // export const formatBigIntFromStringError =
3447
- // createTypeErrorFormatter<BigIntFromStringError>(
3448
- // (error) => `The value ${error.value} could not be converted to a BigInt.`,
3449
- // );
3450
-
3451
3477
  /**
3452
3478
  * Stringified {@link Int64}.
3453
3479
  *