@evolu/common 7.2.3 → 7.3.0

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 (68) hide show
  1. package/dist/src/Function.d.ts +61 -0
  2. package/dist/src/Function.d.ts.map +1 -1
  3. package/dist/src/Function.js +19 -0
  4. package/dist/src/Object.d.ts +16 -0
  5. package/dist/src/Object.d.ts.map +1 -1
  6. package/dist/src/Object.js +16 -0
  7. package/dist/src/Result.d.ts +36 -29
  8. package/dist/src/Result.d.ts.map +1 -1
  9. package/dist/src/Sqlite.d.ts +9 -7
  10. package/dist/src/Sqlite.d.ts.map +1 -1
  11. package/dist/src/Sqlite.js +8 -0
  12. package/dist/src/Task.d.ts.map +1 -1
  13. package/dist/src/Task.js +6 -4
  14. package/dist/src/Type.d.ts +30 -2
  15. package/dist/src/Type.d.ts.map +1 -1
  16. package/dist/src/Type.js +82 -0
  17. package/dist/src/local-first/Db.d.ts +1 -5
  18. package/dist/src/local-first/Db.d.ts.map +1 -1
  19. package/dist/src/local-first/Db.js +109 -108
  20. package/dist/src/local-first/Evolu.d.ts +3 -0
  21. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  22. package/dist/src/local-first/Evolu.js +6 -7
  23. package/dist/src/local-first/LocalAuth.d.ts +2 -2
  24. package/dist/src/local-first/Owner.d.ts +3 -0
  25. package/dist/src/local-first/Owner.d.ts.map +1 -1
  26. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  27. package/dist/src/local-first/Protocol.js +10 -10
  28. package/dist/src/local-first/PublicKysely.js +1 -1
  29. package/dist/src/local-first/Query.d.ts +54 -6
  30. package/dist/src/local-first/Query.d.ts.map +1 -1
  31. package/dist/src/local-first/Query.js +130 -11
  32. package/dist/src/local-first/Relay.d.ts.map +1 -1
  33. package/dist/src/local-first/Relay.js +17 -18
  34. package/dist/src/local-first/Schema.d.ts +15 -14
  35. package/dist/src/local-first/Schema.d.ts.map +1 -1
  36. package/dist/src/local-first/Schema.js +43 -51
  37. package/dist/src/local-first/Storage.d.ts +2 -2
  38. package/dist/src/local-first/Storage.d.ts.map +1 -1
  39. package/dist/src/local-first/Storage.js +2 -6
  40. package/dist/src/local-first/Sync.d.ts +9 -4
  41. package/dist/src/local-first/Sync.d.ts.map +1 -1
  42. package/dist/src/local-first/Sync.js +132 -59
  43. package/dist/src/local-first/index.d.ts +0 -1
  44. package/dist/src/local-first/index.d.ts.map +1 -1
  45. package/dist/src/local-first/index.js +0 -1
  46. package/package.json +1 -1
  47. package/src/Function.ts +74 -0
  48. package/src/Object.ts +20 -0
  49. package/src/Result.ts +38 -29
  50. package/src/Sqlite.ts +18 -7
  51. package/src/Task.ts +6 -4
  52. package/src/Type.ts +134 -1
  53. package/src/local-first/Db.ts +181 -209
  54. package/src/local-first/Evolu.ts +9 -6
  55. package/src/local-first/LocalAuth.ts +2 -2
  56. package/src/local-first/Owner.ts +4 -0
  57. package/src/local-first/Protocol.ts +10 -16
  58. package/src/local-first/PublicKysely.ts +1 -1
  59. package/src/local-first/Query.ts +216 -21
  60. package/src/local-first/Relay.ts +20 -24
  61. package/src/local-first/Schema.ts +83 -70
  62. package/src/local-first/Storage.ts +3 -8
  63. package/src/local-first/Sync.ts +190 -66
  64. package/src/local-first/index.ts +0 -1
  65. package/dist/src/local-first/Diff.d.ts +0 -43
  66. package/dist/src/local-first/Diff.d.ts.map +0 -1
  67. package/dist/src/local-first/Diff.js +0 -97
  68. package/src/local-first/Diff.ts +0 -144
@@ -16,7 +16,7 @@ import {
16
16
  import { eqArrayNumber } from "../Eq.js";
17
17
  import { createTransferableError, TransferableError } from "../Error.js";
18
18
  import { constFalse, constTrue } from "../Function.js";
19
- import { createRecord, objectToEntries } from "../Object.js";
19
+ import { createRecord, getProperty, objectToEntries } from "../Object.js";
20
20
  import { RandomDep } from "../Random.js";
21
21
  import { createResources } from "../Resources.js";
22
22
  import { err, ok, Result } from "../Result.js";
@@ -34,17 +34,19 @@ import { TimeDep } from "../Time.js";
34
34
  import {
35
35
  Boolean,
36
36
  DateIso,
37
+ Id,
37
38
  IdBytes,
38
39
  idBytesToId,
39
40
  idToIdBytes,
40
41
  PositiveInt,
41
42
  } from "../Type.js";
42
43
  import { CreateWebSocketDep, WebSocket } from "../WebSocket.js";
43
- import type { AppOwnerDep, PostMessageDep } from "./Db.js";
44
44
  import {
45
45
  AppOwner,
46
+ AppOwnerDep,
46
47
  Owner,
47
48
  OwnerId,
49
+ OwnerIdBytes,
48
50
  ownerIdBytesToOwnerId,
49
51
  ownerIdToOwnerIdBytes,
50
52
  OwnerTransport,
@@ -62,7 +64,7 @@ import {
62
64
  ProtocolTimestampMismatchError,
63
65
  SubscriptionFlags,
64
66
  } from "./Protocol.js";
65
- import { MutationChange } from "./Schema.js";
67
+ import { DbSchemaDep, MutationChange, systemColumns } from "./Schema.js";
66
68
  import {
67
69
  BaseSqliteStorage,
68
70
  CrdtMessage,
@@ -80,6 +82,7 @@ import {
80
82
  receiveTimestamp,
81
83
  sendTimestamp,
82
84
  Timestamp,
85
+ TimestampBytes,
83
86
  timestampBytesToTimestamp,
84
87
  TimestampConfigDep,
85
88
  TimestampCounterOverflowError,
@@ -157,7 +160,8 @@ export const createSync =
157
160
  deps: ClockDep &
158
161
  ConsoleDep &
159
162
  CreateWebSocketDep &
160
- PostMessageDep &
163
+ DbSchemaDep &
164
+ // PostMessageDep &
161
165
  RandomBytesDep &
162
166
  RandomDep &
163
167
  SqliteDep &
@@ -324,7 +328,6 @@ export const createSync =
324
328
  resources.addConsumer(owner, transports);
325
329
  } else {
326
330
  const result = resources.removeConsumer(owner, transports);
327
-
328
331
  if (!result.ok) {
329
332
  deps.console.warn("[sync]", "Failed to remove consumer", {
330
333
  transports,
@@ -445,6 +448,7 @@ export interface ClientStorageDep {
445
448
  const createClientStorage =
446
449
  (
447
450
  deps: ClockDep &
451
+ DbSchemaDep &
448
452
  GetSyncOwnerDep &
449
453
  RandomDep &
450
454
  SqliteDep &
@@ -484,7 +488,7 @@ const createClientStorage =
484
488
  const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
485
489
 
486
490
  // Everything is sync now, but we will need async crypto in the future.
487
- const writeResult = await mutex.withLock<
491
+ const result = await mutex.withLock<
488
492
  boolean,
489
493
  | AbortError
490
494
  | ProtocolInvalidDataError
@@ -536,11 +540,11 @@ const createClientStorage =
536
540
  }
537
541
 
538
542
  if (isNonEmptyReadonlyArray(messages)) {
539
- const applyMessagesResult = applyMessages({ ...deps, storage })(
543
+ const result = applyMessages({ ...deps, storage })(
540
544
  owner.id,
541
545
  messages,
542
546
  );
543
- if (!applyMessagesResult.ok) return applyMessagesResult;
547
+ if (!result.ok) return result;
544
548
  }
545
549
 
546
550
  return deps.clock.save(clockTimestamp);
@@ -551,9 +555,9 @@ const createClientStorage =
551
555
  return ok(true);
552
556
  })();
553
557
 
554
- if (!writeResult.ok) {
555
- if (writeResult.error.type !== "AbortError") {
556
- config.onError(writeResult.error);
558
+ if (!result.ok) {
559
+ if (result.error.type !== "AbortError") {
560
+ config.onError(result.error);
557
561
  }
558
562
  return err<StorageWriteError>({ type: "StorageWriteError", ownerId });
559
563
  }
@@ -569,22 +573,30 @@ const createClientStorage =
569
573
  if (!owner) return null;
570
574
 
571
575
  const result = deps.sqlite.exec<{
572
- table: string;
573
- id: IdBytes;
574
- column: string;
575
- value: SqliteValue;
576
+ readonly table: string;
577
+ readonly id: IdBytes;
578
+ readonly column: string;
579
+ readonly value: SqliteValue;
576
580
  }>(sql`
577
581
  select "table", "id", "column", "value"
578
582
  from evolu_history
583
+ where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}
584
+ union all
585
+ select "table", "id", "column", "value"
586
+ from evolu_message_quarantine
579
587
  where "ownerId" = ${ownerId} and "timestamp" = ${timestamp};
580
588
  `);
589
+
581
590
  if (!result.ok) {
582
591
  config.onError(result.error);
583
592
  return null;
584
593
  }
585
594
 
586
595
  const { rows } = result.value;
587
- assertNonEmptyReadonlyArray(rows, "Every timestamp must have rows");
596
+ assertNonEmptyReadonlyArray(
597
+ rows,
598
+ "Every timestamp must have rows in evolu_history or quarantine",
599
+ );
588
600
 
589
601
  const { table, id } = firstInArray(rows);
590
602
  const values = createRecord<string, SqliteValue>();
@@ -687,7 +699,7 @@ export const applyLocalOnlyChange =
687
699
  };
688
700
 
689
701
  const applyMessages =
690
- (deps: ClientStorageDep & ClockDep & RandomDep & SqliteDep) =>
702
+ (deps: ClientStorageDep & ClockDep & DbSchemaDep & RandomDep & SqliteDep) =>
691
703
  (
692
704
  ownerId: OwnerId,
693
705
  messages: NonEmptyReadonlyArray<CrdtMessage>,
@@ -703,51 +715,40 @@ const applyMessages =
703
715
  let { firstTimestamp, lastTimestamp } = usage.value;
704
716
 
705
717
  for (const { timestamp, change } of messages) {
706
- const timestampBytes = timestampToTimestampBytes(timestamp);
707
- const idBytes = idToIdBytes(change.id);
708
718
  const columns = dbChangeToColumns(change, timestampToDateIso(timestamp));
719
+ const idBytes = idToIdBytes(change.id);
720
+ const timestampBytes = timestampToTimestampBytes(timestamp);
709
721
 
710
722
  for (const [column, value] of columns) {
711
- const updateAppTable = deps.sqlite.exec(sql.prepared`
712
- with
713
- existingTimestamp as (
714
- select 1
715
- from evolu_history
716
- where
717
- "ownerId" = ${ownerIdBytes}
718
- and "table" = ${change.table}
719
- and "id" = ${idBytes}
720
- and "column" = ${column}
721
- and "timestamp" >= ${timestampBytes}
722
- limit 1
723
- )
724
- insert into ${sql.identifier(change.table)}
725
- ("ownerId", "id", ${sql.identifier(column)})
726
- select ${ownerId}, ${change.id}, ${value}
727
- where not exists (select 1 from existingTimestamp)
728
- on conflict ("ownerId", "id") do update
729
- set ${sql.identifier(column)} = ${value}
730
- where not exists (select 1 from existingTimestamp);
731
- `);
732
-
733
- if (!updateAppTable.ok) return updateAppTable;
734
-
735
- const insertHistory = deps.sqlite.exec(sql.prepared`
736
- insert into evolu_history
737
- ("ownerId", "table", "id", "column", "value", "timestamp")
738
- values
739
- (
740
- ${ownerIdBytes},
741
- ${change.table},
742
- ${idBytes},
743
- ${column},
744
- ${value},
745
- ${timestampBytes}
746
- )
747
- on conflict do nothing;
748
- `);
749
-
750
- if (!insertHistory.ok) return insertHistory;
723
+ if (validateColumnValue(deps)(change.table, column, value)) {
724
+ const result = applyColumnChange(deps)(
725
+ ownerIdBytes,
726
+ ownerId,
727
+ change.table,
728
+ idBytes,
729
+ change.id,
730
+ column,
731
+ value,
732
+ timestampBytes,
733
+ );
734
+ if (!result.ok) return result;
735
+ } else {
736
+ const result = deps.sqlite.exec(sql.prepared`
737
+ insert into evolu_message_quarantine
738
+ ("ownerId", "timestamp", "table", "id", "column", "value")
739
+ values
740
+ (
741
+ ${ownerIdBytes},
742
+ ${timestampBytes},
743
+ ${change.table},
744
+ ${idBytes},
745
+ ${column},
746
+ ${value}
747
+ )
748
+ on conflict do nothing;
749
+ `);
750
+ if (!result.ok) return result;
751
+ }
751
752
  }
752
753
 
753
754
  let strategy;
@@ -757,26 +758,149 @@ const applyMessages =
757
758
  lastTimestamp,
758
759
  );
759
760
 
760
- const insertTimestamp = deps.storage.insertTimestamp(
761
+ const result = deps.storage.insertTimestamp(
761
762
  ownerIdBytes,
762
763
  timestampBytes,
763
764
  strategy,
764
765
  );
765
- if (!insertTimestamp.ok) return insertTimestamp;
766
+ if (!result.ok) return result;
766
767
  }
767
768
 
768
769
  /**
769
- * TODO: Implement proper storedBytes tracking for client using encrypted
770
- * message sizes (need to figure out how to reuse received or postpone
771
- * client...).
770
+ * TODO: Implement proper storedBytes tracking for client using received and
771
+ * sent encrypted message sizes.
772
772
  */
773
- const updateUsage = updateOwnerUsage(deps)(
773
+ return updateOwnerUsage(deps)(
774
774
  ownerIdBytes,
775
775
  1 as PositiveInt, // Placeholder until proper tracking implemented
776
776
  firstTimestamp,
777
777
  lastTimestamp,
778
778
  );
779
- if (!updateUsage.ok) return updateUsage;
779
+ };
780
+
781
+ /**
782
+ * System columns that can appear in sync messages. Excludes `ownerId` because
783
+ * it's handled separately (stored per-row, not per-column in messages).
784
+ */
785
+ const systemColumnsWithoutOwnerId = systemColumns.difference(
786
+ new Set(["ownerId"]),
787
+ );
788
+
789
+ const validateColumnValue =
790
+ (deps: DbSchemaDep) =>
791
+ (table: string, column: string, _value: SqliteValue): boolean => {
792
+ const schemaColumns = getProperty(deps.dbSchema.tables, table);
793
+ return (
794
+ schemaColumns != null &&
795
+ (systemColumnsWithoutOwnerId.has(column) || schemaColumns.has(column))
796
+ );
797
+ };
798
+
799
+ const applyColumnChange =
800
+ (deps: SqliteDep) =>
801
+ (
802
+ ownerIdBytes: OwnerIdBytes,
803
+ ownerId: OwnerId,
804
+ table: string,
805
+ idBytes: IdBytes,
806
+ id: Id,
807
+ column: string,
808
+ value: SqliteValue,
809
+ timestampBytes: TimestampBytes,
810
+ ): Result<void, SqliteError> => {
811
+ const result = deps.sqlite.exec(sql.prepared`
812
+ with
813
+ existingTimestamp as (
814
+ select 1
815
+ from evolu_history
816
+ where
817
+ "ownerId" = ${ownerIdBytes}
818
+ and "table" = ${table}
819
+ and "id" = ${idBytes}
820
+ and "column" = ${column}
821
+ and "timestamp" >= ${timestampBytes}
822
+ limit 1
823
+ )
824
+ insert into ${sql.identifier(table)}
825
+ ("ownerId", "id", ${sql.identifier(column)})
826
+ select ${ownerId}, ${id}, ${value}
827
+ where not exists (select 1 from existingTimestamp)
828
+ on conflict ("ownerId", "id") do update
829
+ set ${sql.identifier(column)} = ${value}
830
+ where not exists (select 1 from existingTimestamp);
831
+ `);
832
+ if (!result.ok) return result;
833
+
834
+ {
835
+ const result = deps.sqlite.exec(sql.prepared`
836
+ insert into evolu_history
837
+ ("ownerId", "table", "id", "column", "value", "timestamp")
838
+ values
839
+ (
840
+ ${ownerIdBytes},
841
+ ${table},
842
+ ${idBytes},
843
+ ${column},
844
+ ${value},
845
+ ${timestampBytes}
846
+ )
847
+ on conflict do nothing;
848
+ `);
849
+ if (!result.ok) return result;
850
+ }
851
+
852
+ return ok();
853
+ };
854
+
855
+ /**
856
+ * Attempts to apply quarantined messages that may now be valid after a schema
857
+ * update. Messages are quarantined when they reference tables or columns that
858
+ * don't exist in the current schema (e.g., from a newer app version).
859
+ */
860
+ export const tryApplyQuarantinedMessages =
861
+ (deps: DbSchemaDep & SqliteDep) => (): Result<void, SqliteError> => {
862
+ const rows = deps.sqlite.exec<{
863
+ readonly ownerId: OwnerIdBytes;
864
+ readonly timestamp: TimestampBytes;
865
+ readonly table: string;
866
+ readonly id: IdBytes;
867
+ readonly column: string;
868
+ readonly value: SqliteValue;
869
+ }>(sql`
870
+ select "ownerId", "timestamp", "table", "id", "column", "value"
871
+ from evolu_message_quarantine;
872
+ `);
873
+ if (!rows.ok) return rows;
874
+
875
+ for (const row of rows.value.rows) {
876
+ if (!validateColumnValue(deps)(row.table, row.column, row.value))
877
+ continue;
878
+
879
+ const result = applyColumnChange(deps)(
880
+ row.ownerId,
881
+ ownerIdBytesToOwnerId(row.ownerId),
882
+ row.table,
883
+ row.id,
884
+ idBytesToId(row.id),
885
+ row.column,
886
+ row.value,
887
+ row.timestamp,
888
+ );
889
+ if (!result.ok) return result;
890
+
891
+ {
892
+ const result = deps.sqlite.exec(sql`
893
+ delete from evolu_message_quarantine
894
+ where
895
+ "ownerId" = ${row.ownerId}
896
+ and "timestamp" = ${row.timestamp}
897
+ and "table" = ${row.table}
898
+ and "id" = ${row.id}
899
+ and "column" = ${row.column};
900
+ `);
901
+ if (!result.ok) return result;
902
+ }
903
+ }
780
904
 
781
905
  return ok();
782
906
  };
@@ -5,7 +5,6 @@
5
5
  */
6
6
 
7
7
  export * from "./Db.js";
8
- export * from "./Diff.js";
9
8
  export * from "./Evolu.js";
10
9
  export * from "./Owner.js";
11
10
  export * from "./Platform.js";
@@ -1,43 +0,0 @@
1
- import { Query, Row } from "./Query.js";
2
- export interface QueryPatches {
3
- readonly query: Query;
4
- readonly patches: ReadonlyArray<Patch>;
5
- }
6
- export type Patch = ReplaceAllPatch | ReplaceAtPatch;
7
- export interface ReplaceAllPatch {
8
- readonly op: "replaceAll";
9
- readonly value: ReadonlyArray<Row>;
10
- }
11
- export interface ReplaceAtPatch {
12
- readonly op: "replaceAt";
13
- readonly index: number;
14
- readonly value: Row;
15
- }
16
- export declare const applyPatches: (patches: ReadonlyArray<Patch>, current: ReadonlyArray<Row>) => ReadonlyArray<Row>;
17
- /**
18
- * We detect only changes in the whole result and in-place edits. In the future,
19
- * we will add more heuristics. We will probably not implement the Myers diff
20
- * algorithm because it's faster to rerender all than to compute many detailed
21
- * patches. We will only implement logic a developer would implement manually,
22
- * if necessary.
23
- */
24
- export declare const makePatches: (previousRows: ReadonlyArray<Row> | undefined, nextRows: ReadonlyArray<Row>) => ReadonlyArray<Patch>;
25
- /**
26
- * A unique identifier prepended to JSON-encoded strings. This allows safe
27
- * detection and parsing of only those columns that require JSON.parse.
28
- *
29
- * The identifier is a cryptographically random Evolu Id, ensuring uniqueness
30
- * and preventing malicious actors from inserting fake data that could be
31
- * misinterpreted as JSON by the application.
32
- *
33
- * Note: The same queries created by different browser tabs will have different
34
- * identifiers and thus be considered different and cached separately. This is
35
- * usually not a big deal, but if needed, the DB cache can be optimized by
36
- * passing the kyselyJsonIdentifier into the DB worker during initialization,
37
- * allowing queries to be grouped and recognized across tabs or sessions.
38
- *
39
- * See: https://github.com/kysely-org/kysely/issues/1372#issuecomment-2702773948
40
- */
41
- export declare const kyselyJsonIdentifier: string & import("../Brand.js").Brand<"Id">;
42
- export declare const parseSqliteJsonArray: <T>(arr: ReadonlyArray<T>) => ReadonlyArray<T>;
43
- //# sourceMappingURL=Diff.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"Diff.d.ts","sourceRoot":"","sources":["../../../src/local-first/Diff.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAExC,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,KAAK,CAAC,CAAC;CACxC;AAED,MAAM,MAAM,KAAK,GAAG,eAAe,GAAG,cAAc,CAAC;AAErD,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,EAAE,YAAY,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,GAAG,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,EAAE,WAAW,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,GAAG,CAAC;CACrB;AAED,eAAO,MAAM,YAAY,GACvB,SAAS,aAAa,CAAC,KAAK,CAAC,EAC7B,SAAS,aAAa,CAAC,GAAG,CAAC,KAC1B,aAAa,CAAC,GAAG,CAUP,CAAC;AAEd;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,GACtB,cAAc,aAAa,CAAC,GAAG,CAAC,GAAG,SAAS,EAC5C,UAAU,aAAa,CAAC,GAAG,CAAC,KAC3B,aAAa,CAAC,KAAK,CA6BrB,CAAC;AAUF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,oBAAoB,4CAE/B,CAAC;AAEH,eAAO,MAAM,oBAAoB,GAAI,CAAC,EACpC,KAAK,aAAa,CAAC,CAAC,CAAC,KACpB,aAAa,CAAC,CAAC,CAMjB,CAAC"}
@@ -1,97 +0,0 @@
1
- import { createRandomBytes } from "../Crypto.js";
2
- import { createRecord, isPlainObject } from "../Object.js";
3
- import { orderUint8Array } from "../Order.js";
4
- import { createId, String } from "../Type.js";
5
- export const applyPatches = (patches, current) => patches.reduce((next, patch) => {
6
- switch (patch.op) {
7
- case "replaceAll":
8
- return parseSqliteJsonArray(patch.value);
9
- case "replaceAt": {
10
- const parsedRow = parseSqliteJsonArray([patch.value])[0];
11
- return next.toSpliced(patch.index, 1, parsedRow);
12
- }
13
- }
14
- }, current);
15
- /**
16
- * We detect only changes in the whole result and in-place edits. In the future,
17
- * we will add more heuristics. We will probably not implement the Myers diff
18
- * algorithm because it's faster to rerender all than to compute many detailed
19
- * patches. We will only implement logic a developer would implement manually,
20
- * if necessary.
21
- */
22
- export const makePatches = (previousRows, nextRows) => {
23
- if (previousRows === undefined)
24
- return [{ op: "replaceAll", value: nextRows }];
25
- // TODO: Detect prepend and append, it's cheap.
26
- if (previousRows.length !== nextRows.length) {
27
- return [{ op: "replaceAll", value: nextRows }];
28
- }
29
- const length = previousRows.length;
30
- const replaceAtPatches = [];
31
- for (let i = 0; i < length; i++) {
32
- const previousRow = previousRows[i];
33
- const nextRow = nextRows[i];
34
- // We expect the same shape for both rows.
35
- for (const key in previousRow)
36
- if (!areEqual(previousRow[key], nextRow[key])) {
37
- replaceAtPatches.push({ op: "replaceAt", value: nextRow, index: i });
38
- break;
39
- }
40
- }
41
- if (length > 0 && replaceAtPatches.length === length) {
42
- return [{ op: "replaceAll", value: nextRows }];
43
- }
44
- return replaceAtPatches;
45
- };
46
- // TODO: Replace with eqSqliteValue.
47
- const areEqual = (a, b) => {
48
- if (a instanceof Uint8Array && b instanceof Uint8Array) {
49
- return orderUint8Array(a, b) === 0;
50
- }
51
- return a === b;
52
- };
53
- /**
54
- * A unique identifier prepended to JSON-encoded strings. This allows safe
55
- * detection and parsing of only those columns that require JSON.parse.
56
- *
57
- * The identifier is a cryptographically random Evolu Id, ensuring uniqueness
58
- * and preventing malicious actors from inserting fake data that could be
59
- * misinterpreted as JSON by the application.
60
- *
61
- * Note: The same queries created by different browser tabs will have different
62
- * identifiers and thus be considered different and cached separately. This is
63
- * usually not a big deal, but if needed, the DB cache can be optimized by
64
- * passing the kyselyJsonIdentifier into the DB worker during initialization,
65
- * allowing queries to be grouped and recognized across tabs or sessions.
66
- *
67
- * See: https://github.com/kysely-org/kysely/issues/1372#issuecomment-2702773948
68
- */
69
- export const kyselyJsonIdentifier = createId({
70
- randomBytes: createRandomBytes(),
71
- });
72
- export const parseSqliteJsonArray = (arr) => {
73
- const result = new Array(arr.length);
74
- for (let i = 0; i < arr.length; ++i) {
75
- result[i] = parse(arr[i]);
76
- }
77
- return result;
78
- };
79
- const parse = (obj) => {
80
- if (String.is(obj) && obj.startsWith(kyselyJsonIdentifier)) {
81
- return JSON.parse(obj.slice(kyselyJsonIdentifier.length));
82
- }
83
- if (Array.isArray(obj)) {
84
- return parseSqliteJsonArray(obj);
85
- }
86
- if (isPlainObject(obj)) {
87
- return parseObject(obj);
88
- }
89
- return obj;
90
- };
91
- const parseObject = (obj) => {
92
- const result = createRecord();
93
- for (const key in obj) {
94
- result[key] = parse(obj[key]);
95
- }
96
- return result;
97
- };
@@ -1,144 +0,0 @@
1
- import { createRandomBytes } from "../Crypto.js";
2
- import { createRecord, isPlainObject, ReadonlyRecord } from "../Object.js";
3
- import { orderUint8Array } from "../Order.js";
4
- import { SqliteValue } from "../Sqlite.js";
5
- import { createId, String } from "../Type.js";
6
- import { Query, Row } from "./Query.js";
7
-
8
- export interface QueryPatches {
9
- readonly query: Query;
10
- readonly patches: ReadonlyArray<Patch>;
11
- }
12
-
13
- export type Patch = ReplaceAllPatch | ReplaceAtPatch;
14
-
15
- export interface ReplaceAllPatch {
16
- readonly op: "replaceAll";
17
- readonly value: ReadonlyArray<Row>;
18
- }
19
-
20
- export interface ReplaceAtPatch {
21
- readonly op: "replaceAt";
22
- readonly index: number;
23
- readonly value: Row;
24
- }
25
-
26
- export const applyPatches = (
27
- patches: ReadonlyArray<Patch>,
28
- current: ReadonlyArray<Row>,
29
- ): ReadonlyArray<Row> =>
30
- patches.reduce((next, patch) => {
31
- switch (patch.op) {
32
- case "replaceAll":
33
- return parseSqliteJsonArray(patch.value);
34
- case "replaceAt": {
35
- const parsedRow = parseSqliteJsonArray([patch.value])[0];
36
- return next.toSpliced(patch.index, 1, parsedRow);
37
- }
38
- }
39
- }, current);
40
-
41
- /**
42
- * We detect only changes in the whole result and in-place edits. In the future,
43
- * we will add more heuristics. We will probably not implement the Myers diff
44
- * algorithm because it's faster to rerender all than to compute many detailed
45
- * patches. We will only implement logic a developer would implement manually,
46
- * if necessary.
47
- */
48
- export const makePatches = (
49
- previousRows: ReadonlyArray<Row> | undefined,
50
- nextRows: ReadonlyArray<Row>,
51
- ): ReadonlyArray<Patch> => {
52
- if (previousRows === undefined)
53
- return [{ op: "replaceAll", value: nextRows }];
54
- // TODO: Detect prepend and append, it's cheap.
55
- if (previousRows.length !== nextRows.length) {
56
- return [{ op: "replaceAll", value: nextRows }];
57
- }
58
-
59
- const length = previousRows.length;
60
- const replaceAtPatches: Array<ReplaceAtPatch> = [];
61
-
62
- for (let i = 0; i < length; i++) {
63
- const previousRow = previousRows[i];
64
- const nextRow = nextRows[i];
65
-
66
- // We expect the same shape for both rows.
67
- for (const key in previousRow)
68
- if (
69
- !areEqual(previousRow[key] as SqliteValue, nextRow[key] as SqliteValue)
70
- ) {
71
- replaceAtPatches.push({ op: "replaceAt", value: nextRow, index: i });
72
- break;
73
- }
74
- }
75
-
76
- if (length > 0 && replaceAtPatches.length === length) {
77
- return [{ op: "replaceAll", value: nextRows }];
78
- }
79
- return replaceAtPatches;
80
- };
81
-
82
- // TODO: Replace with eqSqliteValue.
83
- const areEqual = (a: SqliteValue, b: SqliteValue): boolean => {
84
- if (a instanceof Uint8Array && b instanceof Uint8Array) {
85
- return orderUint8Array(a, b) === 0;
86
- }
87
- return a === b;
88
- };
89
-
90
- /**
91
- * A unique identifier prepended to JSON-encoded strings. This allows safe
92
- * detection and parsing of only those columns that require JSON.parse.
93
- *
94
- * The identifier is a cryptographically random Evolu Id, ensuring uniqueness
95
- * and preventing malicious actors from inserting fake data that could be
96
- * misinterpreted as JSON by the application.
97
- *
98
- * Note: The same queries created by different browser tabs will have different
99
- * identifiers and thus be considered different and cached separately. This is
100
- * usually not a big deal, but if needed, the DB cache can be optimized by
101
- * passing the kyselyJsonIdentifier into the DB worker during initialization,
102
- * allowing queries to be grouped and recognized across tabs or sessions.
103
- *
104
- * See: https://github.com/kysely-org/kysely/issues/1372#issuecomment-2702773948
105
- */
106
- export const kyselyJsonIdentifier = createId({
107
- randomBytes: createRandomBytes(),
108
- });
109
-
110
- export const parseSqliteJsonArray = <T>(
111
- arr: ReadonlyArray<T>,
112
- ): ReadonlyArray<T> => {
113
- const result = new Array<T>(arr.length);
114
- for (let i = 0; i < arr.length; ++i) {
115
- result[i] = parse(arr[i]) as T;
116
- }
117
- return result;
118
- };
119
-
120
- const parse = (obj: unknown): unknown => {
121
- if (String.is(obj) && obj.startsWith(kyselyJsonIdentifier)) {
122
- return JSON.parse(obj.slice(kyselyJsonIdentifier.length));
123
- }
124
-
125
- if (Array.isArray(obj)) {
126
- return parseSqliteJsonArray(obj);
127
- }
128
-
129
- if (isPlainObject(obj)) {
130
- return parseObject(obj);
131
- }
132
-
133
- return obj;
134
- };
135
-
136
- const parseObject = (
137
- obj: ReadonlyRecord<string, unknown>,
138
- ): ReadonlyRecord<string, unknown> => {
139
- const result = createRecord();
140
- for (const key in obj) {
141
- result[key] = parse(obj[key]);
142
- }
143
- return result as ReadonlyRecord<string, unknown>;
144
- };