@evolu/common 8.12.0 → 8.14.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.
- package/dist/src/Callbacks.d.ts +12 -1
- package/dist/src/Callbacks.d.ts.map +1 -1
- package/dist/src/Callbacks.js +3 -0
- package/dist/src/Error.d.ts +10 -4
- package/dist/src/Error.d.ts.map +1 -1
- package/dist/src/Error.js +10 -4
- package/dist/src/Object.d.ts +65 -0
- package/dist/src/Object.d.ts.map +1 -1
- package/dist/src/Object.js +142 -0
- package/dist/src/Resource.d.ts +0 -5
- package/dist/src/Resource.d.ts.map +1 -1
- package/dist/src/Resource.js +6 -13
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +7 -0
- package/dist/src/Task.d.ts +10 -8
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +41 -5
- package/dist/src/Worker.d.ts +3 -3
- package/dist/src/local-first/Db.d.ts +8 -3
- package/dist/src/local-first/Db.d.ts.map +1 -1
- package/dist/src/local-first/Db.js +56 -19
- package/dist/src/local-first/Evolu.d.ts +147 -39
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +53 -9
- package/dist/src/local-first/Owner.d.ts +9 -0
- package/dist/src/local-first/Owner.d.ts.map +1 -1
- package/dist/src/local-first/Owner.js +9 -0
- package/dist/src/local-first/Protocol.d.ts +6 -4
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.js +1 -6
- package/dist/src/local-first/Schema.d.ts +7 -1
- package/dist/src/local-first/Schema.d.ts.map +1 -1
- package/dist/src/local-first/Shared.d.ts +550 -153
- package/dist/src/local-first/Shared.d.ts.map +1 -1
- package/dist/src/local-first/Shared.js +724 -273
- package/dist/src/local-first/Storage.d.ts +16 -12
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/Callbacks.test.ts +20 -0
- package/src/Callbacks.ts +17 -1
- package/src/Error.ts +10 -4
- package/src/Object.test.ts +296 -0
- package/src/Object.ts +163 -0
- package/src/Resource.test.ts +20 -16
- package/src/Resource.ts +6 -20
- package/src/Sqlite.ts +7 -0
- package/src/Task.test.ts +233 -62
- package/src/Task.ts +47 -13
- package/src/Worker.ts +3 -3
- package/src/local-first/Db.ts +88 -18
- package/src/local-first/Evolu.test.ts +381 -11
- package/src/local-first/Evolu.ts +219 -51
- package/src/local-first/Owner.ts +9 -0
- package/src/local-first/Protocol.test.ts +42 -60
- package/src/local-first/Protocol.ts +7 -9
- package/src/local-first/Schema.ts +8 -2
- package/src/local-first/Shared.test.ts +2714 -644
- package/src/local-first/Shared.ts +1245 -401
- package/src/local-first/Storage.ts +18 -19
package/src/local-first/Evolu.ts
CHANGED
|
@@ -31,7 +31,9 @@ import {
|
|
|
31
31
|
import { createMicrotaskBatch } from "../Microtask.ts";
|
|
32
32
|
import {
|
|
33
33
|
createMutableRecord,
|
|
34
|
+
isPlainObject,
|
|
34
35
|
objectToEntries,
|
|
36
|
+
shareStructure,
|
|
35
37
|
type ReadonlyRecord,
|
|
36
38
|
} from "../Object.ts";
|
|
37
39
|
import type { FlushSyncDep, ReloadAppDep } from "../Platform.ts";
|
|
@@ -75,7 +77,9 @@ import { createOwnerWebSocketTransport } from "./Owner.ts";
|
|
|
75
77
|
import {
|
|
76
78
|
encodeDbChange,
|
|
77
79
|
type ProtocolError,
|
|
80
|
+
type ProtocolInvalidDataError,
|
|
78
81
|
type ProtocolQuotaError,
|
|
82
|
+
type ProtocolTimestampMismatchError,
|
|
79
83
|
type ProtocolVersionError,
|
|
80
84
|
} from "./Protocol.ts";
|
|
81
85
|
import type {
|
|
@@ -102,12 +106,15 @@ import type {
|
|
|
102
106
|
EvoluInput,
|
|
103
107
|
EvoluOutput,
|
|
104
108
|
OtherBuildRunningError,
|
|
109
|
+
OwnerSyncStatus,
|
|
110
|
+
PendingSyncRoute,
|
|
105
111
|
SharedWorkerDep,
|
|
106
112
|
SyncState,
|
|
107
|
-
|
|
113
|
+
syncStateToOwnerSyncStatus,
|
|
114
|
+
syncStateToRelaySyncStates,
|
|
108
115
|
} from "./Shared.ts";
|
|
109
116
|
import { consoleEntryOrErrorBroadcastChannelName } from "./Shared.ts";
|
|
110
|
-
import { DbChange
|
|
117
|
+
import { DbChange } from "./Storage.ts";
|
|
111
118
|
import { createTimestamp, type Timestamp } from "./Timestamp.ts";
|
|
112
119
|
|
|
113
120
|
/**
|
|
@@ -378,6 +385,37 @@ export interface Evolu<
|
|
|
378
385
|
/** {@link AppOwner}. */
|
|
379
386
|
readonly appOwner: AppOwner;
|
|
380
387
|
|
|
388
|
+
/**
|
|
389
|
+
* Whether this device keeps the database, resolved once the shared worker
|
|
390
|
+
* serves it, or `Unknown` if this instance is disposed first.
|
|
391
|
+
*
|
|
392
|
+
* Apps tell the user when it is `NotPersisted`, because then data that has
|
|
393
|
+
* not synced is lost when the database closes. Apps must not tell the user
|
|
394
|
+
* that data is saved on the device when it is `Unknown`.
|
|
395
|
+
*
|
|
396
|
+
* ### Example
|
|
397
|
+
*
|
|
398
|
+
* ```ts
|
|
399
|
+
* import { assertEqual } from "@evolu/common";
|
|
400
|
+
* import type { DevicePersistence } from "@evolu/common/local-first";
|
|
401
|
+
*
|
|
402
|
+
* // The notice for `await evolu.devicePersistence`, or null for none.
|
|
403
|
+
* const persistenceNotice = (
|
|
404
|
+
* devicePersistence: DevicePersistence,
|
|
405
|
+
* ): string | null =>
|
|
406
|
+
* devicePersistence === "NotPersisted"
|
|
407
|
+
* ? "Your data isn't kept on this device. Changes that haven't synced are lost when you close this tab."
|
|
408
|
+
* : null;
|
|
409
|
+
*
|
|
410
|
+
* assertEqual(persistenceNotice("Unknown"), null);
|
|
411
|
+
* assertEqual(
|
|
412
|
+
* persistenceNotice("NotPersisted"),
|
|
413
|
+
* "Your data isn't kept on this device. Changes that haven't synced are lost when you close this tab.",
|
|
414
|
+
* );
|
|
415
|
+
* ```
|
|
416
|
+
*/
|
|
417
|
+
readonly devicePersistence: Promise<DevicePersistence>;
|
|
418
|
+
|
|
381
419
|
/**
|
|
382
420
|
* Inserts a row and returns the generated {@link Id}.
|
|
383
421
|
*
|
|
@@ -388,8 +426,10 @@ export interface Evolu<
|
|
|
388
426
|
*
|
|
389
427
|
* Pass `onComplete` when follow-up work must wait until the mutation is
|
|
390
428
|
* stored and subscribed queries reflect it. It never runs when the database
|
|
391
|
-
* is unavailable; see {@link Evolu.loadQuery}.
|
|
392
|
-
*
|
|
429
|
+
* is unavailable; see {@link Evolu.loadQuery}. It also never runs when the
|
|
430
|
+
* mutation could not be stored, which {@link EvoluErrorDep.evoluError}
|
|
431
|
+
* reports. A stored change is not always visible; see
|
|
432
|
+
* {@link MutationOptions.onComplete}.
|
|
393
433
|
*
|
|
394
434
|
* ### Example
|
|
395
435
|
*
|
|
@@ -707,6 +747,9 @@ export interface Evolu<
|
|
|
707
747
|
/**
|
|
708
748
|
* Exports the SQLite database file.
|
|
709
749
|
*
|
|
750
|
+
* The exported file is not encrypted, even when the local database is. Any
|
|
751
|
+
* SQLite tool can read all local data from it, so treat it as plaintext.
|
|
752
|
+
*
|
|
710
753
|
* Exports are sequential: concurrent calls share one pending export instead
|
|
711
754
|
* of starting parallel exports.
|
|
712
755
|
*
|
|
@@ -818,9 +861,12 @@ export interface Evolu<
|
|
|
818
861
|
*
|
|
819
862
|
* Reconciles locally stored changes, including writes previously rejected
|
|
820
863
|
* with {@link ProtocolQuotaError}, through the owner's active transports. Call
|
|
821
|
-
* this after the relay provider confirms additional quota is available.
|
|
822
|
-
*
|
|
823
|
-
*
|
|
864
|
+
* this after the relay provider confirms additional quota is available. It
|
|
865
|
+
* also checks again the changes this database skipped from the owner's
|
|
866
|
+
* relays, and sends such a relay the changes received from other relays,
|
|
867
|
+
* which otherwise wait for its next sync, such as after a reconnect. Existing
|
|
868
|
+
* connections and {@link Evolu.useOwner} registrations are retained, including
|
|
869
|
+
* registrations shared by multiple instances or tabs.
|
|
824
870
|
*
|
|
825
871
|
* The owner must have an active writable registration in this database.
|
|
826
872
|
* Unregistered or read-only owners are ignored. Requests are skipped while
|
|
@@ -828,8 +874,9 @@ export interface Evolu<
|
|
|
828
874
|
* reopen. Disposal drops locally buffered requests. Calling a disposed
|
|
829
875
|
* instance throws, like other Evolu operations.
|
|
830
876
|
*
|
|
831
|
-
* Returns immediately, without waiting for synchronization to complete.
|
|
832
|
-
*
|
|
877
|
+
* Returns immediately, without waiting for synchronization to complete. The
|
|
878
|
+
* owner's routes in sync state show a failure, such as a quota rejection, as
|
|
879
|
+
* `failure`, and a skipped change as `skippedError`.
|
|
833
880
|
*
|
|
834
881
|
* ### Example
|
|
835
882
|
*
|
|
@@ -858,9 +905,33 @@ export interface Evolu<
|
|
|
858
905
|
export type UnuseOwner = () => void;
|
|
859
906
|
|
|
860
907
|
/**
|
|
861
|
-
*
|
|
908
|
+
* Whether this device keeps an {@link Evolu} database, stating only what Evolu
|
|
909
|
+
* knows for sure.
|
|
862
910
|
*
|
|
863
|
-
*
|
|
911
|
+
* - `Persisted`: the platform stores it in the app's own file, as React Native
|
|
912
|
+
* does.
|
|
913
|
+
* - `NotPersisted`: it is kept in memory, because {@link EvoluConfig.memoryOnly}
|
|
914
|
+
* asks for that or the platform offers no persistent storage, as a browser
|
|
915
|
+
* does in Safari's Private Browsing or a Firefox private window. Data that
|
|
916
|
+
* has not synced is lost when the database closes.
|
|
917
|
+
* - `Unknown`: it is stored, but the platform may delete it. A browser can keep
|
|
918
|
+
* storage only for a private session, as Chrome's incognito does without
|
|
919
|
+
* telling the app, delete it after a while or when disk space runs low, and
|
|
920
|
+
* let the user clear it.
|
|
921
|
+
*
|
|
922
|
+
* See [Will my data stay on the
|
|
923
|
+
* device?](https://www.evolu.dev/docs/faq#will-my-data-stay-on-the-device).
|
|
924
|
+
*
|
|
925
|
+
* @group Core
|
|
926
|
+
*/
|
|
927
|
+
export type DevicePersistence = "Persisted" | "NotPersisted" | "Unknown";
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Represents app-level errors that can occur in {@link Evolu}.
|
|
931
|
+
*
|
|
932
|
+
* Apps show them from {@link EvoluErrorDep.evoluError}. Problems a relay causes
|
|
933
|
+
* while syncing an owner are not EvoluErrors; see below. An unexpected failure
|
|
934
|
+
* while syncing is an {@link UnknownError}.
|
|
864
935
|
*
|
|
865
936
|
* An error that leaves the app unusable deserves a modal dialog, which moves
|
|
866
937
|
* focus into itself and restores it when closed. Any other error is a status
|
|
@@ -874,31 +945,46 @@ export type UnuseOwner = () => void;
|
|
|
874
945
|
* - {@link OtherBuildRunningError} blocks the app while it lasts: another version
|
|
875
946
|
* of the app holds the local data. Ask the user to close the app's other
|
|
876
947
|
* tabs. It clears when the wait ends.
|
|
877
|
-
* - {@link ProtocolError} does not block the app: sync with a relay failed for an
|
|
878
|
-
* owner, because the relay rejected or failed a request, or sent data that
|
|
879
|
-
* could not be decoded or verified; sync state shows the affected routes. A
|
|
880
|
-
* {@link ProtocolQuotaError} needs more relay quota, then
|
|
881
|
-
* {@link Evolu.requestSync}; a {@link ProtocolVersionError} needs an app or
|
|
882
|
-
* relay update.
|
|
883
|
-
* - {@link StorageQuotaError} does not block the app: a storage or billing quota
|
|
884
|
-
* was exceeded, so a batch of an owner's changes was not stored. The built-in
|
|
885
|
-
* client storage does not report it yet; a relay's quota arrives as
|
|
886
|
-
* {@link ProtocolQuotaError}.
|
|
887
|
-
* - {@link DecryptWithXChaCha20Poly1305Error} does not block the app: changes
|
|
888
|
-
* received for an owner could not be decrypted, so none of their batch was
|
|
889
|
-
* stored.
|
|
890
948
|
* - {@link UnknownError} does not block the app: Evolu logged an unexpected
|
|
891
|
-
* failure. Show a generic
|
|
949
|
+
* failure, such as a mutation that could not be stored. Show a generic
|
|
950
|
+
* message.
|
|
951
|
+
*
|
|
952
|
+
* A problem syncing an owner through a relay belongs to that relay and often
|
|
953
|
+
* repeats in every round, so sync state shows it on the relay's route, as its
|
|
954
|
+
* {@link PendingSyncRoute.failure} with its details, and apps show it as the
|
|
955
|
+
* owner's `Error` {@link OwnerSyncStatus}. When the relay rejected or failed a
|
|
956
|
+
* request, or sent a frame that could not be decoded, the route shows a
|
|
957
|
+
* {@link ProtocolError}. A {@link ProtocolQuotaError} needs more relay quota,
|
|
958
|
+
* then {@link Evolu.requestSync}, and a {@link ProtocolVersionError} needs an app
|
|
959
|
+
* or relay update.
|
|
960
|
+
*
|
|
961
|
+
* A received change that was not created with the owner's encryption key, was
|
|
962
|
+
* altered afterwards, or cannot be decoded by this app version is skipped,
|
|
963
|
+
* while everything else still syncs. The relay offers it again in every round,
|
|
964
|
+
* so the route shows it as its `skippedError`: the
|
|
965
|
+
* {@link DecryptWithXChaCha20Poly1305Error},
|
|
966
|
+
* {@link ProtocolTimestampMismatchError}, or {@link ProtocolInvalidDataError} of
|
|
967
|
+
* the first change skipped in a reply, with its details. The owner's status is
|
|
968
|
+
* `Error` with it too, unless a relay has a failure. Once this database stores
|
|
969
|
+
* a valid change with that timestamp, for example from another relay, the relay
|
|
970
|
+
* no longer offers its copy, and the route completes with its next sync, such
|
|
971
|
+
* as after a reconnect or {@link Evolu.requestSync}, during which no changes
|
|
972
|
+
* arrive from other relays. If you don't trust that relay, stop using it for
|
|
973
|
+
* the owner. For owners that use the default transports, such as
|
|
974
|
+
* {@link Evolu.appOwner} when {@link EvoluConfig.transports} is not empty,
|
|
975
|
+
* replace the relay there; an empty list stops syncing the app owner and makes
|
|
976
|
+
* {@link Evolu.useOwner} require explicit transports. For an owner you passed
|
|
977
|
+
* explicit transports to {@link Evolu.useOwner}, call its {@link UnuseOwner} and
|
|
978
|
+
* use it again without that relay. Do this wherever the owner is used, such as
|
|
979
|
+
* in every tab, because the owner syncs through every transport any of its uses
|
|
980
|
+
* claims. If every route of the owner shows
|
|
981
|
+
* {@link DecryptWithXChaCha20Poly1305Error} and your code creates or shares the
|
|
982
|
+
* owner, check the owner's keys.
|
|
892
983
|
*
|
|
893
984
|
* @group Core
|
|
894
985
|
*/
|
|
895
986
|
export type EvoluError =
|
|
896
|
-
|
|
|
897
|
-
| OtherBuildRunningError
|
|
898
|
-
| ProtocolError
|
|
899
|
-
| StorageQuotaError
|
|
900
|
-
| UnknownError
|
|
901
|
-
| UnsupportedDbVersionError;
|
|
987
|
+
OtherBuildRunningError | UnknownError | UnsupportedDbVersionError;
|
|
902
988
|
|
|
903
989
|
/**
|
|
904
990
|
* The largest {@link Mutation}, in bytes.
|
|
@@ -975,6 +1061,13 @@ export interface EvoluErrorDep {
|
|
|
975
1061
|
* instead; see {@link UnsupportedDbVersionError}. Show that blocking message
|
|
976
1062
|
* outside any query-loading boundary, so pending queries do not hide it.
|
|
977
1063
|
*
|
|
1064
|
+
* An {@link UnknownError} leaves Evolu in an unknown state, so the app can
|
|
1065
|
+
* only ask the user to close the tab. A failed shared worker closes itself,
|
|
1066
|
+
* so the app opened again starts a new one. Some errors reach every tab, such
|
|
1067
|
+
* as an unexpected failure of the shared worker. An app that forwards this
|
|
1068
|
+
* store to an error tracker from each tab then reports such an error once per
|
|
1069
|
+
* tab.
|
|
1070
|
+
*
|
|
978
1071
|
* ### Example
|
|
979
1072
|
*
|
|
980
1073
|
* ```ts
|
|
@@ -988,14 +1081,13 @@ export interface EvoluErrorDep {
|
|
|
988
1081
|
* // The message for the current error, or null for none.
|
|
989
1082
|
* const errorMessage = (error: EvoluError | null): string | null => {
|
|
990
1083
|
* if (!error) return null;
|
|
991
|
-
* // oxlint-disable-next-line typescript/switch-exhaustiveness-check -- The default handles every other EvoluError.
|
|
992
1084
|
* switch (error.type) {
|
|
993
1085
|
* case "UnsupportedDbVersionError":
|
|
994
1086
|
* return "Your data requires a newer version of this app. Please update it.";
|
|
995
1087
|
* case "OtherBuildRunningError":
|
|
996
1088
|
* return "This app is open in another tab with a different version. Close that tab to continue.";
|
|
997
|
-
*
|
|
998
|
-
* return "Something went wrong. Please
|
|
1089
|
+
* case "UnknownError":
|
|
1090
|
+
* return "Something went wrong. Please close this tab and open the app again.";
|
|
999
1091
|
* }
|
|
1000
1092
|
* };
|
|
1001
1093
|
*
|
|
@@ -1031,8 +1123,15 @@ export interface SyncStateDep {
|
|
|
1031
1123
|
/**
|
|
1032
1124
|
* {@link ReadonlyStore} of the latest {@link SyncState} shared by all
|
|
1033
1125
|
* {@link Evolu} instances, or null before the shared worker sends its first
|
|
1034
|
-
* snapshot.
|
|
1035
|
-
*
|
|
1126
|
+
* snapshot. It lists every database the shared worker serves, including other
|
|
1127
|
+
* tabs', so an app finds its own by {@link Evolu.name}. Apps show users an
|
|
1128
|
+
* owner's {@link OwnerSyncStatus}, from {@link syncStateToOwnerSyncStatus} or a
|
|
1129
|
+
* framework binding's `useOwnerSyncStatus`; views of each relay use
|
|
1130
|
+
* {@link syncStateToRelaySyncStates}.
|
|
1131
|
+
*
|
|
1132
|
+
* Each snapshot keeps the previous snapshot's object for every part that did
|
|
1133
|
+
* not change, and a snapshot equal to the previous one leaves the store
|
|
1134
|
+
* unchanged, so parts and derived statuses can be compared with `===`.
|
|
1036
1135
|
*
|
|
1037
1136
|
* ### Example
|
|
1038
1137
|
*
|
|
@@ -1041,6 +1140,7 @@ export interface SyncStateDep {
|
|
|
1041
1140
|
* assertEqual,
|
|
1042
1141
|
* createId,
|
|
1043
1142
|
* createStore,
|
|
1143
|
+
* Millis,
|
|
1044
1144
|
* testCreateDeps,
|
|
1045
1145
|
* } from "@evolu/common";
|
|
1046
1146
|
* import type {
|
|
@@ -1050,7 +1150,7 @@ export interface SyncStateDep {
|
|
|
1050
1150
|
*
|
|
1051
1151
|
* const openRelayLabels = (deps: SyncStateDep): ReadonlyArray<string> =>
|
|
1052
1152
|
* (deps.syncState.get()?.transports ?? [])
|
|
1053
|
-
* .filter(({
|
|
1153
|
+
* .filter(({ connection }) => connection.type === "Open")
|
|
1054
1154
|
* .map(({ label }) => label);
|
|
1055
1155
|
*
|
|
1056
1156
|
* using syncState = createStore<SyncState | null>(null);
|
|
@@ -1060,12 +1160,14 @@ export interface SyncStateDep {
|
|
|
1060
1160
|
* syncState.set({
|
|
1061
1161
|
* transports: [
|
|
1062
1162
|
* {
|
|
1163
|
+
* type: "WebSocket",
|
|
1063
1164
|
* id: createId<"SyncTransport">(deps),
|
|
1064
1165
|
* label: "wss://relay.example",
|
|
1065
|
-
*
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1166
|
+
* connection: {
|
|
1167
|
+
* type: "Open",
|
|
1168
|
+
* openedAt: Millis.orThrow(1000),
|
|
1169
|
+
* error: null,
|
|
1170
|
+
* },
|
|
1069
1171
|
* },
|
|
1070
1172
|
* ],
|
|
1071
1173
|
* tenants: [],
|
|
@@ -1076,6 +1178,20 @@ export interface SyncStateDep {
|
|
|
1076
1178
|
readonly syncState: ReadonlyStore<SyncState | null>;
|
|
1077
1179
|
}
|
|
1078
1180
|
|
|
1181
|
+
/**
|
|
1182
|
+
* Asks the platform to keep stored databases until the user deletes them.
|
|
1183
|
+
*
|
|
1184
|
+
* Only unsynced local changes exist nowhere else, so each {@link Evolu} instance
|
|
1185
|
+
* calls it after its first local mutation, when its
|
|
1186
|
+
* {@link Evolu.devicePersistence} is `Unknown`. A platform provides it when its
|
|
1187
|
+
* storage can be deleted without the user, as a browser's can.
|
|
1188
|
+
*
|
|
1189
|
+
* @group Construction
|
|
1190
|
+
*/
|
|
1191
|
+
export interface RequestPersistentStorageDep {
|
|
1192
|
+
readonly requestPersistentStorage: () => void;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1079
1195
|
/**
|
|
1080
1196
|
* Shared platform dependencies for creating {@link Evolu} instances.
|
|
1081
1197
|
*
|
|
@@ -1094,7 +1210,7 @@ export type EvoluDeps = EvoluPlatformDeps &
|
|
|
1094
1210
|
* Platform-specific dependencies required to create {@link EvoluDeps}.
|
|
1095
1211
|
*
|
|
1096
1212
|
* Provides worker and channel adapters plus optional platform integrations for
|
|
1097
|
-
* logging
|
|
1213
|
+
* logging, synchronous UI flush, and persistent storage requests.
|
|
1098
1214
|
*
|
|
1099
1215
|
* @group Construction
|
|
1100
1216
|
*/
|
|
@@ -1105,7 +1221,8 @@ export type EvoluPlatformDeps = CreateDbWorkerDep &
|
|
|
1105
1221
|
ReloadAppDep &
|
|
1106
1222
|
SharedWorkerDep &
|
|
1107
1223
|
Partial<ConsoleDep> &
|
|
1108
|
-
Partial<FlushSyncDep
|
|
1224
|
+
Partial<FlushSyncDep> &
|
|
1225
|
+
Partial<RequestPersistentStorageDep>;
|
|
1109
1226
|
|
|
1110
1227
|
/**
|
|
1111
1228
|
* Creates shared dependencies used by all {@link createEvolu} instances on a
|
|
@@ -1155,11 +1272,15 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => {
|
|
|
1155
1272
|
}
|
|
1156
1273
|
break;
|
|
1157
1274
|
|
|
1158
|
-
case "Error":
|
|
1159
|
-
|
|
1275
|
+
case "Error": {
|
|
1276
|
+
// Every build shares this channel, so another build's worker can post
|
|
1277
|
+
// an error type this build does not post, such as an older build's
|
|
1278
|
+
// sync error. That build's own tabs show it, so this tab only logs it.
|
|
1279
|
+
if (message.error.type === "UnknownError") setEvoluError(message.error);
|
|
1160
1280
|
// Keep typed errors visible in logs as operational failures.
|
|
1161
1281
|
console.error(message.error);
|
|
1162
1282
|
break;
|
|
1283
|
+
}
|
|
1163
1284
|
|
|
1164
1285
|
default:
|
|
1165
1286
|
exhaustiveCheck(message);
|
|
@@ -1173,16 +1294,15 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => {
|
|
|
1173
1294
|
break;
|
|
1174
1295
|
|
|
1175
1296
|
case "Error":
|
|
1176
|
-
// Sent to this tab only: its database refused startup,
|
|
1177
|
-
// still waits for another
|
|
1297
|
+
// Sent to this tab only: its database refused startup, a mutation it
|
|
1298
|
+
// made could not be stored, or its worker still waits for another
|
|
1299
|
+
// build.
|
|
1178
1300
|
setEvoluError(message.error);
|
|
1179
1301
|
console.error(message.error);
|
|
1180
1302
|
break;
|
|
1181
1303
|
|
|
1182
1304
|
case "Waiting":
|
|
1183
|
-
|
|
1184
|
-
// Platform adapters act on these; see Builds and Storage in the Shared
|
|
1185
|
-
// module.
|
|
1305
|
+
// Platform adapters act on it; see Builds in the Shared module.
|
|
1186
1306
|
break;
|
|
1187
1307
|
|
|
1188
1308
|
case "Connected": {
|
|
@@ -1197,7 +1317,15 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => {
|
|
|
1197
1317
|
message.syncStateChannelName,
|
|
1198
1318
|
);
|
|
1199
1319
|
syncStateBroadcastChannel.onMessage = (state) => {
|
|
1200
|
-
|
|
1320
|
+
// Every snapshot arrives as a new structured clone, so its unchanged
|
|
1321
|
+
// parts get the previous snapshot's objects back, and an unchanged
|
|
1322
|
+
// snapshot leaves the store as it is.
|
|
1323
|
+
const previous = syncState.get();
|
|
1324
|
+
syncState.set(
|
|
1325
|
+
previous
|
|
1326
|
+
? shareStructure(previous, state, syncStateItemToKey)
|
|
1327
|
+
: state,
|
|
1328
|
+
);
|
|
1201
1329
|
};
|
|
1202
1330
|
// Asking only after listening misses no snapshot.
|
|
1203
1331
|
sharedWorker.port.postMessage({ type: "RequestSyncState" });
|
|
@@ -1235,6 +1363,14 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => {
|
|
|
1235
1363
|
);
|
|
1236
1364
|
};
|
|
1237
1365
|
|
|
1366
|
+
// Transports, tenants, owners, and routes come and go, so each is compared
|
|
1367
|
+
// with its previous self by its ID: a transport's id, a tenant's name, an
|
|
1368
|
+
// owner's ownerId, or a route's transportId. A wrong key only loses sharing.
|
|
1369
|
+
const syncStateItemToKey = (item: unknown): unknown =>
|
|
1370
|
+
isPlainObject(item)
|
|
1371
|
+
? (item.id ?? item.name ?? item.ownerId ?? item.transportId)
|
|
1372
|
+
: undefined;
|
|
1373
|
+
|
|
1238
1374
|
/**
|
|
1239
1375
|
* Creates an {@link Evolu} instance from {@link EvoluSchema} and
|
|
1240
1376
|
* {@link EvoluConfig}.
|
|
@@ -1353,6 +1489,13 @@ export const createEvolu =
|
|
|
1353
1489
|
exportDatabasePending = null;
|
|
1354
1490
|
});
|
|
1355
1491
|
|
|
1492
|
+
const devicePersistence = Promise.withResolvers<DevicePersistence>();
|
|
1493
|
+
// A disposed instance never hears from its tenant, so awaiters settle.
|
|
1494
|
+
disposer.defer(() => {
|
|
1495
|
+
devicePersistence.resolve("Unknown");
|
|
1496
|
+
});
|
|
1497
|
+
let isPersistentStorageRequested = false;
|
|
1498
|
+
|
|
1356
1499
|
let postMessage: (input: EvoluInput) => void;
|
|
1357
1500
|
|
|
1358
1501
|
// Scope worker/channel wiring and keep only postMessage outside.
|
|
@@ -1434,6 +1577,18 @@ export const createEvolu =
|
|
|
1434
1577
|
break;
|
|
1435
1578
|
}
|
|
1436
1579
|
|
|
1580
|
+
case "OnMutateFailed": {
|
|
1581
|
+
// The tab reports the failure, and these callbacks never run.
|
|
1582
|
+
for (const onCompleteId of message.onCompleteIds) {
|
|
1583
|
+
onMutateCompleteCallbacks.unregister(onCompleteId);
|
|
1584
|
+
}
|
|
1585
|
+
break;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
case "OnDevicePersistence":
|
|
1589
|
+
devicePersistence.resolve(message.devicePersistence);
|
|
1590
|
+
break;
|
|
1591
|
+
|
|
1437
1592
|
default:
|
|
1438
1593
|
exhaustiveCheck(message);
|
|
1439
1594
|
}
|
|
@@ -1561,6 +1716,18 @@ export const createEvolu =
|
|
|
1561
1716
|
);
|
|
1562
1717
|
}
|
|
1563
1718
|
|
|
1719
|
+
// Only unsynced local changes exist nowhere else, so the first one is
|
|
1720
|
+
// when asking the platform to keep them starts to matter. Asking on
|
|
1721
|
+
// load would show Firefox's permission prompt to every visitor.
|
|
1722
|
+
if (!isPersistentStorageRequested) {
|
|
1723
|
+
isPersistentStorageRequested = true;
|
|
1724
|
+
void devicePersistence.promise.then((persistence) => {
|
|
1725
|
+
if (persistence === "Unknown" && !disposed) {
|
|
1726
|
+
run.deps.requestPersistentStorage?.();
|
|
1727
|
+
}
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1564
1731
|
mutateBatch.push({
|
|
1565
1732
|
change: { ...dbChange, ownerId: options?.ownerId ?? appOwner.id },
|
|
1566
1733
|
onComplete: options?.onComplete,
|
|
@@ -1644,6 +1811,7 @@ export const createEvolu =
|
|
|
1644
1811
|
{
|
|
1645
1812
|
name,
|
|
1646
1813
|
appOwner,
|
|
1814
|
+
devicePersistence: devicePersistence.promise,
|
|
1647
1815
|
|
|
1648
1816
|
insert: createMutation("insert"),
|
|
1649
1817
|
update: createMutation("update"),
|
package/src/local-first/Owner.ts
CHANGED
|
@@ -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
|
|
|
@@ -950,7 +950,7 @@ describe("E2E errors", () => {
|
|
|
950
950
|
);
|
|
951
951
|
});
|
|
952
952
|
|
|
953
|
-
it("rejected relay
|
|
953
|
+
it("reports a rejected relay write as a quota and a thrown one as a write failure", async () => {
|
|
954
954
|
const deps = testCreateDeps();
|
|
955
955
|
const initiatorMessage = createProtocolMessageFromCrdtMessages(deps)(
|
|
956
956
|
testAppOwner,
|
|
@@ -961,50 +961,48 @@ describe("E2E errors", () => {
|
|
|
961
961
|
},
|
|
962
962
|
],
|
|
963
963
|
);
|
|
964
|
-
/** Returns the relay's response and what it logged for the
|
|
965
|
-
const relayResponseFor = async (
|
|
964
|
+
/** Returns the relay's response and what it logged for the write. */
|
|
965
|
+
const relayResponseFor = async (
|
|
966
|
+
writeMessages: StorageDep["storage"]["writeMessages"],
|
|
967
|
+
) => {
|
|
966
968
|
await using run = testCreateRun({
|
|
967
969
|
storage: {
|
|
968
970
|
...shouldNotBeCalledStorageDep.storage,
|
|
969
971
|
validateWriteKey: () => true,
|
|
970
|
-
writeMessages
|
|
972
|
+
writeMessages,
|
|
971
973
|
},
|
|
972
974
|
} satisfies StorageDep);
|
|
973
975
|
const { message } = await run.orThrow(
|
|
974
976
|
applyProtocolMessageAsRelay(initiatorMessage),
|
|
975
977
|
);
|
|
976
|
-
return {
|
|
977
|
-
message,
|
|
978
|
-
logged: run.deps.console
|
|
979
|
-
.getEntriesSnapshot()
|
|
980
|
-
.map(({ method, args }) => ({ method, args })),
|
|
981
|
-
};
|
|
978
|
+
return { message, logged: run.deps.console.getEntriesSnapshot() };
|
|
982
979
|
};
|
|
983
980
|
|
|
984
981
|
await using run = testCreateRun(shouldNotBeCalledStorageDep);
|
|
985
982
|
// A quota rejection is expected, so the relay does not log it.
|
|
986
|
-
const quota = await relayResponseFor(
|
|
987
|
-
type: "StorageQuotaError",
|
|
988
|
-
|
|
989
|
-
});
|
|
983
|
+
const quota = await relayResponseFor(
|
|
984
|
+
() => () => err({ type: "StorageQuotaError", ownerId: testAppOwner.id }),
|
|
985
|
+
);
|
|
990
986
|
assertEqual(
|
|
991
987
|
await run(applyProtocolMessageAsClient(quota.message)),
|
|
992
988
|
err({ type: "ProtocolQuotaError", ownerId: testAppOwner.id }),
|
|
993
989
|
);
|
|
994
990
|
assertEqual(quota.logged, []);
|
|
995
|
-
//
|
|
996
|
-
//
|
|
997
|
-
const
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
};
|
|
1002
|
-
const other = await relayResponseFor(mismatch);
|
|
991
|
+
// A thrown write is a relay write failure, not a quota, and the relay
|
|
992
|
+
// logs it.
|
|
993
|
+
const failure = new Error("write failed");
|
|
994
|
+
const thrown = await relayResponseFor(() => {
|
|
995
|
+
throw failure;
|
|
996
|
+
});
|
|
1003
997
|
assertEqual(
|
|
1004
|
-
await run(applyProtocolMessageAsClient(
|
|
998
|
+
await run(applyProtocolMessageAsClient(thrown.message)),
|
|
1005
999
|
err({ type: "ProtocolWriteError", ownerId: testAppOwner.id }),
|
|
1006
1000
|
);
|
|
1007
|
-
assertEqual(
|
|
1001
|
+
assertEqual(
|
|
1002
|
+
thrown.logged.map(({ method }) => method),
|
|
1003
|
+
["error"],
|
|
1004
|
+
);
|
|
1005
|
+
assertSame(thrown.logged[0]?.args[0], failure);
|
|
1008
1006
|
});
|
|
1009
1007
|
});
|
|
1010
1008
|
|
|
@@ -1239,7 +1237,7 @@ describe("applyProtocolMessageAsClient results", () => {
|
|
|
1239
1237
|
assertOk(result, { type: "Readonly" });
|
|
1240
1238
|
});
|
|
1241
1239
|
|
|
1242
|
-
it("preserves
|
|
1240
|
+
it("preserves a storage write rejection", async () => {
|
|
1243
1241
|
const deps = testCreateDeps();
|
|
1244
1242
|
const input = createResponse();
|
|
1245
1243
|
input.addMessage(
|
|
@@ -1250,42 +1248,26 @@ describe("applyProtocolMessageAsClient results", () => {
|
|
|
1250
1248
|
);
|
|
1251
1249
|
const message = input.unwrap();
|
|
1252
1250
|
|
|
1253
|
-
const
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
{
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
error: new Error("decoding failed"),
|
|
1262
|
-
},
|
|
1263
|
-
{
|
|
1264
|
-
type: "ProtocolTimestampMismatchError",
|
|
1265
|
-
expected: timestampBytesToTimestamp(testTimestampsAsc[0]),
|
|
1266
|
-
timestamp: timestampBytesToTimestamp(testTimestampsAsc[1]),
|
|
1251
|
+
const error: StorageWriteMessagesError = {
|
|
1252
|
+
type: "StorageQuotaError",
|
|
1253
|
+
ownerId: testAppOwner.id,
|
|
1254
|
+
};
|
|
1255
|
+
await using run = testCreateRun({
|
|
1256
|
+
storage: {
|
|
1257
|
+
...shouldNotBeCalledStorageDep.storage,
|
|
1258
|
+
writeMessages: () => () => err(error),
|
|
1267
1259
|
},
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
writeKey: testAppOwner.writeKey,
|
|
1280
|
-
});
|
|
1281
|
-
assertType<
|
|
1282
|
-
InferTaskErr<typeof task>,
|
|
1283
|
-
ProtocolError | StorageWriteMessagesError
|
|
1284
|
-
>();
|
|
1285
|
-
const result = await run(task);
|
|
1286
|
-
assertErr(result);
|
|
1287
|
-
assertSame(result.error, error);
|
|
1288
|
-
}
|
|
1260
|
+
} satisfies StorageDep);
|
|
1261
|
+
const task = applyProtocolMessageAsClient(message, {
|
|
1262
|
+
writeKey: testAppOwner.writeKey,
|
|
1263
|
+
});
|
|
1264
|
+
assertType<
|
|
1265
|
+
InferTaskErr<typeof task>,
|
|
1266
|
+
ProtocolError | StorageWriteMessagesError
|
|
1267
|
+
>();
|
|
1268
|
+
const result = await run(task);
|
|
1269
|
+
assertErr(result);
|
|
1270
|
+
assertSame(result.error, error);
|
|
1289
1271
|
});
|
|
1290
1272
|
|
|
1291
1273
|
it("reports a thrown write as failed", async () => {
|