@evolu/common 8.11.0 → 8.13.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 (72) hide show
  1. package/dist/src/Bytes.d.ts +39 -2
  2. package/dist/src/Bytes.d.ts.map +1 -1
  3. package/dist/src/Bytes.js +50 -2
  4. package/dist/src/Callbacks.d.ts +12 -1
  5. package/dist/src/Callbacks.d.ts.map +1 -1
  6. package/dist/src/Callbacks.js +3 -0
  7. package/dist/src/Error.d.ts +10 -4
  8. package/dist/src/Error.d.ts.map +1 -1
  9. package/dist/src/Error.js +10 -4
  10. package/dist/src/Object.d.ts +65 -0
  11. package/dist/src/Object.d.ts.map +1 -1
  12. package/dist/src/Object.js +142 -0
  13. package/dist/src/Resource.d.ts +0 -5
  14. package/dist/src/Resource.d.ts.map +1 -1
  15. package/dist/src/Resource.js +6 -13
  16. package/dist/src/Sqlite.d.ts.map +1 -1
  17. package/dist/src/Sqlite.js +7 -0
  18. package/dist/src/Task.d.ts +10 -8
  19. package/dist/src/Task.d.ts.map +1 -1
  20. package/dist/src/Task.js +41 -5
  21. package/dist/src/Worker.d.ts +3 -3
  22. package/dist/src/index.d.ts +1 -1
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +1 -1
  25. package/dist/src/local-first/Db.d.ts +8 -3
  26. package/dist/src/local-first/Db.d.ts.map +1 -1
  27. package/dist/src/local-first/Db.js +56 -19
  28. package/dist/src/local-first/Evolu.d.ts +142 -24
  29. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  30. package/dist/src/local-first/Evolu.js +109 -8
  31. package/dist/src/local-first/Owner.d.ts +9 -0
  32. package/dist/src/local-first/Owner.d.ts.map +1 -1
  33. package/dist/src/local-first/Owner.js +9 -0
  34. package/dist/src/local-first/Protocol.d.ts +18 -7
  35. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  36. package/dist/src/local-first/Protocol.js +45 -28
  37. package/dist/src/local-first/Relay.d.ts.map +1 -1
  38. package/dist/src/local-first/Relay.js +4 -2
  39. package/dist/src/local-first/Schema.d.ts +18 -3
  40. package/dist/src/local-first/Schema.d.ts.map +1 -1
  41. package/dist/src/local-first/Shared.d.ts +523 -133
  42. package/dist/src/local-first/Shared.d.ts.map +1 -1
  43. package/dist/src/local-first/Shared.js +696 -256
  44. package/dist/src/local-first/Storage.d.ts +19 -15
  45. package/dist/src/local-first/Storage.d.ts.map +1 -1
  46. package/dist/src/local-first/Storage.js +4 -2
  47. package/package.json +1 -1
  48. package/src/Bytes.test.ts +27 -0
  49. package/src/Bytes.ts +58 -2
  50. package/src/Callbacks.test.ts +20 -0
  51. package/src/Callbacks.ts +17 -1
  52. package/src/Error.ts +10 -4
  53. package/src/Object.test.ts +296 -0
  54. package/src/Object.ts +163 -0
  55. package/src/Resource.test.ts +20 -16
  56. package/src/Resource.ts +6 -20
  57. package/src/Sqlite.ts +7 -0
  58. package/src/Task.test.ts +233 -62
  59. package/src/Task.ts +47 -13
  60. package/src/Worker.ts +3 -3
  61. package/src/index.ts +6 -1
  62. package/src/local-first/Db.ts +88 -18
  63. package/src/local-first/Evolu.test.ts +589 -2
  64. package/src/local-first/Evolu.ts +285 -36
  65. package/src/local-first/Owner.ts +9 -0
  66. package/src/local-first/Protocol.test.ts +59 -60
  67. package/src/local-first/Protocol.ts +67 -47
  68. package/src/local-first/Relay.ts +4 -2
  69. package/src/local-first/Schema.ts +20 -3
  70. package/src/local-first/Shared.test.ts +2633 -646
  71. package/src/local-first/Shared.ts +1192 -364
  72. package/src/local-first/Storage.ts +24 -23
@@ -15,6 +15,7 @@ import {
15
15
  assertNonEmptyReadonlyArray,
16
16
  assertNotUndefined,
17
17
  } from "../Assert.ts";
18
+ import { createBuffer } from "../Bytes.ts";
18
19
  import { createCallbacks } from "../Callbacks.ts";
19
20
  import type { ConsoleDep } from "../Console.ts";
20
21
  import { createConsole } from "../Console.ts";
@@ -28,11 +29,22 @@ import {
28
29
  type LockManagerDep,
29
30
  } from "../LockManager.ts";
30
31
  import { createMicrotaskBatch } from "../Microtask.ts";
32
+ import {
33
+ createMutableRecord,
34
+ isPlainObject,
35
+ objectToEntries,
36
+ shareStructure,
37
+ type ReadonlyRecord,
38
+ } from "../Object.ts";
31
39
  import type { FlushSyncDep, ReloadAppDep } from "../Platform.ts";
32
40
  import { createRefCountByKey } from "../RefCount.ts";
33
41
  import { err, ok } from "../Result.ts";
34
42
  import { isNonEmptySet } from "../Set.ts";
35
- import { SqliteBoolean, sqliteBooleanToBoolean } from "../Sqlite.ts";
43
+ import {
44
+ SqliteBoolean,
45
+ sqliteBooleanToBoolean,
46
+ type SqliteValue,
47
+ } from "../Sqlite.ts";
36
48
  import type { Listener, ReadonlyStore, Unsubscribe } from "../Store.ts";
37
49
  import { createStore } from "../Store.ts";
38
50
  import type { Task } from "../Task.ts";
@@ -41,8 +53,9 @@ import {
41
53
  createId,
42
54
  createIdFromString,
43
55
  type ExtractTyped,
44
- type Id,
56
+ Id,
45
57
  Name,
58
+ PositiveInt,
46
59
  type TypeError,
47
60
  UrlSafeString,
48
61
  } from "../Type.ts";
@@ -61,7 +74,14 @@ import type {
61
74
  SyncOwner,
62
75
  } from "./Owner.ts";
63
76
  import { createOwnerWebSocketTransport } from "./Owner.ts";
64
- import type { ProtocolError, ProtocolQuotaError } from "./Protocol.ts";
77
+ import {
78
+ encodeDbChange,
79
+ type ProtocolError,
80
+ type ProtocolInvalidDataError,
81
+ type ProtocolQuotaError,
82
+ type ProtocolTimestampMismatchError,
83
+ type ProtocolVersionError,
84
+ } from "./Protocol.ts";
65
85
  import type {
66
86
  Queries,
67
87
  QueriesToQueryRowsPromises,
@@ -77,21 +97,25 @@ import type {
77
97
  Mutation,
78
98
  MutationChange,
79
99
  MutationOptions,
100
+ MutationValues,
80
101
  ValidateSchema,
81
102
  } from "./Schema.ts";
82
- import { evoluSchemaToSqliteSchema } from "./Schema.ts";
103
+ import { evoluSchemaToSqliteSchema, isLocalOnlyTable } from "./Schema.ts";
83
104
  import type {
84
105
  ConsoleEntryOrError,
85
106
  EvoluInput,
86
107
  EvoluOutput,
87
108
  OtherBuildRunningError,
109
+ OwnerSyncStatus,
110
+ PendingSyncRoute,
88
111
  SharedWorkerDep,
89
112
  SyncState,
90
- syncStateToOwnerSyncStates,
113
+ syncStateToOwnerSyncStatus,
114
+ syncStateToRelaySyncStates,
91
115
  } from "./Shared.ts";
92
116
  import { consoleEntryOrErrorBroadcastChannelName } from "./Shared.ts";
93
- import { DbChange, type StorageQuotaError } from "./Storage.ts";
94
- import type { Timestamp } from "./Timestamp.ts";
117
+ import { DbChange } from "./Storage.ts";
118
+ import { createTimestamp, type Timestamp } from "./Timestamp.ts";
95
119
 
96
120
  /**
97
121
  * Configuration for {@link createEvolu}.
@@ -371,8 +395,10 @@ export interface Evolu<
371
395
  *
372
396
  * Pass `onComplete` when follow-up work must wait until the mutation is
373
397
  * stored and subscribed queries reflect it. It never runs when the database
374
- * is unavailable; see {@link Evolu.loadQuery}. A stored change is not always
375
- * visible; see {@link MutationOptions.onComplete}.
398
+ * is unavailable; see {@link Evolu.loadQuery}. It also never runs when the
399
+ * mutation could not be stored, which {@link EvoluErrorDep.evoluError}
400
+ * reports. A stored change is not always visible; see
401
+ * {@link MutationOptions.onComplete}.
376
402
  *
377
403
  * ### Example
378
404
  *
@@ -492,6 +518,41 @@ export interface Evolu<
492
518
  */
493
519
  readonly upsert: Mutation<S, "upsert">;
494
520
 
521
+ /**
522
+ * Returns the size of a mutation of `table` with `values`, measured as
523
+ * {@link maxMutationSize} describes.
524
+ *
525
+ * Use it to check values that column Types do not bound before mutating, or
526
+ * to show how much of the limit a mutation uses. It accepts any of the
527
+ * table's columns, so the values of an insert, update, or upsert fit, and it
528
+ * ignores `id` and `isDeleted`, which are not columns. Mutations of
529
+ * local-only tables are measured too, although they are exempt from the
530
+ * limit.
531
+ *
532
+ * ### Example
533
+ *
534
+ * ```ts
535
+ * import {
536
+ * assertType,
537
+ * type Evolu,
538
+ * maxMutationSize,
539
+ * type NonEmptyTrimmedString100,
540
+ * type TestEvoluSchema,
541
+ * } from "@evolu/common";
542
+ *
543
+ * const fitsTodo = (
544
+ * evolu: Evolu<TestEvoluSchema>,
545
+ * title: NonEmptyTrimmedString100,
546
+ * ) => evolu.getMutationSize("todo", { title }) <= maxMutationSize;
547
+ *
548
+ * assertType<ReturnType<typeof fitsTodo>, boolean>();
549
+ * ```
550
+ */
551
+ readonly getMutationSize: <TableName extends keyof S>(
552
+ table: TableName,
553
+ values: Partial<MutationValues<S[TableName], "update">>,
554
+ ) => PositiveInt;
555
+
495
556
  /**
496
557
  * Load {@link Query} and return a promise with {@link QueryRows}.
497
558
  *
@@ -655,6 +716,9 @@ export interface Evolu<
655
716
  /**
656
717
  * Exports the SQLite database file.
657
718
  *
719
+ * The exported file is not encrypted, even when the local database is. Any
720
+ * SQLite tool can read all local data from it, so treat it as plaintext.
721
+ *
658
722
  * Exports are sequential: concurrent calls share one pending export instead
659
723
  * of starting parallel exports.
660
724
  *
@@ -766,9 +830,12 @@ export interface Evolu<
766
830
  *
767
831
  * Reconciles locally stored changes, including writes previously rejected
768
832
  * with {@link ProtocolQuotaError}, through the owner's active transports. Call
769
- * this after the relay provider confirms additional quota is available.
770
- * Existing connections and {@link Evolu.useOwner} registrations are retained,
771
- * including registrations shared by multiple instances or tabs.
833
+ * this after the relay provider confirms additional quota is available. It
834
+ * also checks again the changes this database skipped from the owner's
835
+ * relays, and sends such a relay the changes received from other relays,
836
+ * which otherwise wait for its next sync, such as after a reconnect. Existing
837
+ * connections and {@link Evolu.useOwner} registrations are retained, including
838
+ * registrations shared by multiple instances or tabs.
772
839
  *
773
840
  * The owner must have an active writable registration in this database.
774
841
  * Unregistered or read-only owners are ignored. Requests are skipped while
@@ -776,8 +843,9 @@ export interface Evolu<
776
843
  * reopen. Disposal drops locally buffered requests. Calling a disposed
777
844
  * instance throws, like other Evolu operations.
778
845
  *
779
- * Returns immediately, without waiting for synchronization to complete.
780
- * Errors are reported through {@link EvoluErrorDep.evoluError}.
846
+ * Returns immediately, without waiting for synchronization to complete. The
847
+ * owner's routes in sync state show a failure, such as a quota rejection, as
848
+ * `failure`, and a skipped change as `skippedError`.
781
849
  *
782
850
  * ### Example
783
851
  *
@@ -806,17 +874,112 @@ export interface Evolu<
806
874
  export type UnuseOwner = () => void;
807
875
 
808
876
  /**
809
- * Represents errors that can occur in {@link Evolu}.
877
+ * Represents app-level errors that can occur in {@link Evolu}.
878
+ *
879
+ * Apps show them from {@link EvoluErrorDep.evoluError}. Problems a relay causes
880
+ * while syncing an owner are not EvoluErrors; see below. An unexpected failure
881
+ * while syncing is an {@link UnknownError}.
882
+ *
883
+ * An error that leaves the app unusable deserves a modal dialog, which moves
884
+ * focus into itself and restores it when closed. Any other error is a status
885
+ * message: show it in a region with `role="alert"`, which screen readers
886
+ * announce without moving focus, so the user keeps working and a background tab
887
+ * shows it when the user returns. Avoid `alert()`, which blocks the page, once
888
+ * in every open tab.
889
+ *
890
+ * - {@link UnsupportedDbVersionError} blocks the app: the local data needs a newer
891
+ * version of it. Ask the user to update the app or close all its tabs.
892
+ * - {@link OtherBuildRunningError} blocks the app while it lasts: another version
893
+ * of the app holds the local data. Ask the user to close the app's other
894
+ * tabs. It clears when the wait ends.
895
+ * - {@link UnknownError} does not block the app: Evolu logged an unexpected
896
+ * failure, such as a mutation that could not be stored. Show a generic
897
+ * message.
898
+ *
899
+ * A problem syncing an owner through a relay belongs to that relay and often
900
+ * repeats in every round, so sync state shows it on the relay's route, as its
901
+ * {@link PendingSyncRoute.failure} with its details, and apps show it as the
902
+ * owner's `Error` {@link OwnerSyncStatus}. When the relay rejected or failed a
903
+ * request, or sent a frame that could not be decoded, the route shows a
904
+ * {@link ProtocolError}. A {@link ProtocolQuotaError} needs more relay quota,
905
+ * then {@link Evolu.requestSync}, and a {@link ProtocolVersionError} needs an app
906
+ * or relay update.
907
+ *
908
+ * A received change that was not created with the owner's encryption key, was
909
+ * altered afterwards, or cannot be decoded by this app version is skipped,
910
+ * while everything else still syncs. The relay offers it again in every round,
911
+ * so the route shows it as its `skippedError`: the
912
+ * {@link DecryptWithXChaCha20Poly1305Error},
913
+ * {@link ProtocolTimestampMismatchError}, or {@link ProtocolInvalidDataError} of
914
+ * the first change skipped in a reply, with its details. The owner's status is
915
+ * `Error` with it too, unless a relay has a failure. Once this database stores
916
+ * a valid change with that timestamp, for example from another relay, the relay
917
+ * no longer offers its copy, and the route completes with its next sync, such
918
+ * as after a reconnect or {@link Evolu.requestSync}, during which no changes
919
+ * arrive from other relays. If you don't trust that relay, stop using it for
920
+ * the owner. For owners that use the default transports, such as
921
+ * {@link Evolu.appOwner} when {@link EvoluConfig.transports} is not empty,
922
+ * replace the relay there; an empty list stops syncing the app owner and makes
923
+ * {@link Evolu.useOwner} require explicit transports. For an owner you passed
924
+ * explicit transports to {@link Evolu.useOwner}, call its {@link UnuseOwner} and
925
+ * use it again without that relay. Do this wherever the owner is used, such as
926
+ * in every tab, because the owner syncs through every transport any of its uses
927
+ * claims. If every route of the owner shows
928
+ * {@link DecryptWithXChaCha20Poly1305Error} and your code creates or shares the
929
+ * owner, check the owner's keys.
810
930
  *
811
931
  * @group Core
812
932
  */
813
933
  export type EvoluError =
814
- | DecryptWithXChaCha20Poly1305Error
815
- | OtherBuildRunningError
816
- | ProtocolError
817
- | StorageQuotaError
818
- | UnknownError
819
- | UnsupportedDbVersionError;
934
+ OtherBuildRunningError | UnknownError | UnsupportedDbVersionError;
935
+
936
+ /**
937
+ * The largest {@link Mutation}, in bytes.
938
+ *
939
+ * A mutation's size is its change as encoded for sync: the table name, the ID,
940
+ * and every column name and value, with a few bytes of overhead. A string takes
941
+ * at most three bytes per UTF-16 code unit. Every mutation within the limit
942
+ * fits one protocol message, so it can always sync. A larger mutation throws
943
+ * and is not saved. Mutations of local-only tables are exempt, because they
944
+ * never sync. Measure a mutation in advance with {@link Evolu.getMutationSize}.
945
+ *
946
+ * @group Core
947
+ */
948
+ export const maxMutationSize: PositiveInt =
949
+ /*#__PURE__*/ PositiveInt.orThrow(640_000);
950
+
951
+ /** Measures a mutation as {@link maxMutationSize} describes. */
952
+ const measureMutation = (
953
+ table: string,
954
+ values: ReadonlyRecord<string, SqliteValue | undefined>,
955
+ ): PositiveInt => {
956
+ const columns = createMutableRecord<string, SqliteValue>();
957
+ for (const [column, value] of objectToEntries(values)) {
958
+ if (value !== undefined && column !== "id" && column !== "isDeleted") {
959
+ columns[column] = value;
960
+ }
961
+ }
962
+
963
+ // Measuring with the protocol's encoding keeps the limit and the message size
964
+ // from disagreeing. A separate formula would save the tab about 3 KB
965
+ // compressed, but it would be a second encoding to keep in sync, and it
966
+ // would have to overcount.
967
+ const buffer = createBuffer();
968
+ encodeDbChange(buffer, {
969
+ // Every timestamp and ID takes 16 bytes, and the flags always take one.
970
+ timestamp: createTimestamp(),
971
+ change: DbChange.orThrow({
972
+ table,
973
+ id: mutationSizeId,
974
+ values: columns,
975
+ isInsert: true,
976
+ isDelete: null,
977
+ }),
978
+ });
979
+ return buffer.getLength() as PositiveInt;
980
+ };
981
+
982
+ const mutationSizeId = /*#__PURE__*/ Id.orThrow("A".repeat(22));
820
983
 
821
984
  /**
822
985
  * Dependency wrapper for the shared {@link EvoluError} store.
@@ -845,6 +1008,13 @@ export interface EvoluErrorDep {
845
1008
  * instead; see {@link UnsupportedDbVersionError}. Show that blocking message
846
1009
  * outside any query-loading boundary, so pending queries do not hide it.
847
1010
  *
1011
+ * An {@link UnknownError} leaves Evolu in an unknown state, so the app can
1012
+ * only ask the user to close the tab. A failed shared worker closes itself,
1013
+ * so the app opened again starts a new one. Some errors reach every tab, such
1014
+ * as an unexpected failure of the shared worker. An app that forwards this
1015
+ * store to an error tracker from each tab then reports such an error once per
1016
+ * tab.
1017
+ *
848
1018
  * ### Example
849
1019
  *
850
1020
  * ```ts
@@ -858,14 +1028,13 @@ export interface EvoluErrorDep {
858
1028
  * // The message for the current error, or null for none.
859
1029
  * const errorMessage = (error: EvoluError | null): string | null => {
860
1030
  * if (!error) return null;
861
- * // oxlint-disable-next-line typescript/switch-exhaustiveness-check -- The default handles every other EvoluError.
862
1031
  * switch (error.type) {
863
1032
  * case "UnsupportedDbVersionError":
864
1033
  * return "Your data requires a newer version of this app. Please update it.";
865
1034
  * case "OtherBuildRunningError":
866
1035
  * return "This app is open in another tab with a different version. Close that tab to continue.";
867
- * default:
868
- * return "Something went wrong. Please try again.";
1036
+ * case "UnknownError":
1037
+ * return "Something went wrong. Please close this tab and open the app again.";
869
1038
  * }
870
1039
  * };
871
1040
  *
@@ -901,8 +1070,15 @@ export interface SyncStateDep {
901
1070
  /**
902
1071
  * {@link ReadonlyStore} of the latest {@link SyncState} shared by all
903
1072
  * {@link Evolu} instances, or null before the shared worker sends its first
904
- * snapshot. Derive what to show from it, such as one indicator per relay, or
905
- * use {@link syncStateToOwnerSyncStates} for one state per owner.
1073
+ * snapshot. It lists every database the shared worker serves, including other
1074
+ * tabs', so an app finds its own by {@link Evolu.name}. Apps show users an
1075
+ * owner's {@link OwnerSyncStatus}, from {@link syncStateToOwnerSyncStatus} or a
1076
+ * framework binding's `useOwnerSyncStatus`; views of each relay use
1077
+ * {@link syncStateToRelaySyncStates}.
1078
+ *
1079
+ * Each snapshot keeps the previous snapshot's object for every part that did
1080
+ * not change, and a snapshot equal to the previous one leaves the store
1081
+ * unchanged, so parts and derived statuses can be compared with `===`.
906
1082
  *
907
1083
  * ### Example
908
1084
  *
@@ -911,6 +1087,7 @@ export interface SyncStateDep {
911
1087
  * assertEqual,
912
1088
  * createId,
913
1089
  * createStore,
1090
+ * Millis,
914
1091
  * testCreateDeps,
915
1092
  * } from "@evolu/common";
916
1093
  * import type {
@@ -920,7 +1097,7 @@ export interface SyncStateDep {
920
1097
  *
921
1098
  * const openRelayLabels = (deps: SyncStateDep): ReadonlyArray<string> =>
922
1099
  * (deps.syncState.get()?.transports ?? [])
923
- * .filter(({ readyState }) => readyState === "open")
1100
+ * .filter(({ connection }) => connection.type === "Open")
924
1101
  * .map(({ label }) => label);
925
1102
  *
926
1103
  * using syncState = createStore<SyncState | null>(null);
@@ -930,12 +1107,14 @@ export interface SyncStateDep {
930
1107
  * syncState.set({
931
1108
  * transports: [
932
1109
  * {
1110
+ * type: "WebSocket",
933
1111
  * id: createId<"SyncTransport">(deps),
934
1112
  * label: "wss://relay.example",
935
- * readyState: "open",
936
- * openedAt: null,
937
- * closedAt: null,
938
- * error: null,
1113
+ * connection: {
1114
+ * type: "Open",
1115
+ * openedAt: Millis.orThrow(1000),
1116
+ * error: null,
1117
+ * },
939
1118
  * },
940
1119
  * ],
941
1120
  * tenants: [],
@@ -1025,11 +1204,15 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => {
1025
1204
  }
1026
1205
  break;
1027
1206
 
1028
- case "Error":
1029
- setEvoluError(message.error);
1207
+ case "Error": {
1208
+ // Every build shares this channel, so another build's worker can post
1209
+ // an error type this build does not post, such as an older build's
1210
+ // sync error. That build's own tabs show it, so this tab only logs it.
1211
+ if (message.error.type === "UnknownError") setEvoluError(message.error);
1030
1212
  // Keep typed errors visible in logs as operational failures.
1031
1213
  console.error(message.error);
1032
1214
  break;
1215
+ }
1033
1216
 
1034
1217
  default:
1035
1218
  exhaustiveCheck(message);
@@ -1043,8 +1226,9 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => {
1043
1226
  break;
1044
1227
 
1045
1228
  case "Error":
1046
- // Sent to this tab only: its database refused startup, or its worker
1047
- // still waits for another build.
1229
+ // Sent to this tab only: its database refused startup, a mutation it
1230
+ // made could not be stored, or its worker still waits for another
1231
+ // build.
1048
1232
  setEvoluError(message.error);
1049
1233
  console.error(message.error);
1050
1234
  break;
@@ -1067,7 +1251,15 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => {
1067
1251
  message.syncStateChannelName,
1068
1252
  );
1069
1253
  syncStateBroadcastChannel.onMessage = (state) => {
1070
- syncState.set(state);
1254
+ // Every snapshot arrives as a new structured clone, so its unchanged
1255
+ // parts get the previous snapshot's objects back, and an unchanged
1256
+ // snapshot leaves the store as it is.
1257
+ const previous = syncState.get();
1258
+ syncState.set(
1259
+ previous
1260
+ ? shareStructure(previous, state, syncStateItemToKey)
1261
+ : state,
1262
+ );
1071
1263
  };
1072
1264
  // Asking only after listening misses no snapshot.
1073
1265
  sharedWorker.port.postMessage({ type: "RequestSyncState" });
@@ -1105,6 +1297,14 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => {
1105
1297
  );
1106
1298
  };
1107
1299
 
1300
+ // Transports, tenants, owners, and routes come and go, so each is compared
1301
+ // with its previous self by its ID: a transport's id, a tenant's name, an
1302
+ // owner's ownerId, or a route's transportId. A wrong key only loses sharing.
1303
+ const syncStateItemToKey = (item: unknown): unknown =>
1304
+ isPlainObject(item)
1305
+ ? (item.id ?? item.name ?? item.ownerId ?? item.transportId)
1306
+ : undefined;
1307
+
1108
1308
  /**
1109
1309
  * Creates an {@link Evolu} instance from {@link EvoluSchema} and
1110
1310
  * {@link EvoluConfig}.
@@ -1304,6 +1504,14 @@ export const createEvolu =
1304
1504
  break;
1305
1505
  }
1306
1506
 
1507
+ case "OnMutateFailed": {
1508
+ // The tab reports the failure, and these callbacks never run.
1509
+ for (const onCompleteId of message.onCompleteIds) {
1510
+ onMutateCompleteCallbacks.unregister(onCompleteId);
1511
+ }
1512
+ break;
1513
+ }
1514
+
1307
1515
  default:
1308
1516
  exhaustiveCheck(message);
1309
1517
  }
@@ -1396,6 +1604,41 @@ export const createEvolu =
1396
1604
  `Invalid DbChange for table '${String(table)}'.`,
1397
1605
  );
1398
1606
 
1607
+ // Copy binary values, so what is saved is what was passed: the caller
1608
+ // can change a Uint8Array before the batch is sent, a view of a
1609
+ // resizable buffer can grow, and a view of a shared buffer cannot be
1610
+ // sent to a SharedWorker at all. Node's Buffer passes validation and
1611
+ // its slice shares memory, so only the constructor copies reliably.
1612
+ for (const [column, value] of objectToEntries(dbChange.values)) {
1613
+ if (typeof value === "object" && value !== null) {
1614
+ changeValues[column] = new Uint8Array(value);
1615
+ }
1616
+ }
1617
+
1618
+ // A change over maxMutationSize could never sync, and a shared worker
1619
+ // hosting two databases would stop all work of that database. Correct
1620
+ // apps bound column Types, so it is a programmer error and throws like
1621
+ // the DbChange check above, before batching: it gets no timestamp, the
1622
+ // database worker never sees it, and the code after the call does not
1623
+ // run, so a form keeps its input and no row refers to the missing one.
1624
+ //
1625
+ // Rejected: reporting it through evoluError, which lets that code run
1626
+ // as if the mutation was saved; checking in the database worker, which
1627
+ // gets it already batched and cannot reach the call site; quarantine,
1628
+ // which holds changes stored for sync; and skipping it in sync, which
1629
+ // keeps range fingerprints disagreeing. Received changes are not
1630
+ // checked, and oversized changes stored before this limit are not
1631
+ // recovered, which needs a history-aware design.
1632
+ //
1633
+ // Local-only tables never sync, so they have no limit.
1634
+ if (!isLocalOnlyTable(dbChange.table)) {
1635
+ const size = measureMutation(dbChange.table, dbChange.values);
1636
+ assert(
1637
+ size <= maxMutationSize,
1638
+ `The mutation of table '${dbChange.table}' is ${size} bytes, over maxMutationSize (${maxMutationSize}). Bound the Types of its columns or check it with evolu.getMutationSize.`,
1639
+ );
1640
+ }
1641
+
1399
1642
  mutateBatch.push({
1400
1643
  change: { ...dbChange, ownerId: options?.ownerId ?? appOwner.id },
1401
1644
  onComplete: options?.onComplete,
@@ -1484,6 +1727,12 @@ export const createEvolu =
1484
1727
  update: createMutation("update"),
1485
1728
  upsert: createMutation("upsert"),
1486
1729
 
1730
+ getMutationSize: (table, values) =>
1731
+ measureMutation(
1732
+ String(table),
1733
+ values as ReadonlyRecord<string, SqliteValue | undefined>,
1734
+ ),
1735
+
1487
1736
  loadQuery,
1488
1737
  loadQueries: <Q extends Queries<S>>(
1489
1738
  queries: [...Q],
@@ -28,6 +28,15 @@
28
28
  * - {@link OwnerEncryptionKey}: the symmetric key that protects the data.
29
29
  * - {@link OwnerWriteKey}: the rotatable token that authorizes writes.
30
30
  *
31
+ * Only holders of the encryption key can create an owner's changes. Each change
32
+ * is encrypted and authenticated together with its timestamp, so a relay, or
33
+ * anyone who can write to a relay for the owner, can store bytes for the owner
34
+ * but cannot forge or move a change. A client stores only the changes it can
35
+ * decrypt, verify, and decode, and syncs only what it stored. It skips any
36
+ * other change, which sync state shows on the route of the relay that holds it,
37
+ * and the relay offers it again on each sync until the client stores a valid
38
+ * change with that timestamp.
39
+ *
31
40
  * @module
32
41
  */
33
42