@evolu/common 6.0.1-preview.20 → 6.0.1-preview.22

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.
@@ -93,48 +93,6 @@ export interface EvoluConfig extends Partial<DbConfig> {
93
93
  readonly reloadUrl?: string;
94
94
  }
95
95
 
96
- // /**
97
- // * Validated database change with schema-typed values.
98
- // *
99
- // * This is a tagged union where the tag is the table name and the values are
100
- // * updateable (validated against the schema). This represents the content of a
101
- // * {@link CrdtMessage} without the timestamp, which is sufficient for business
102
- // * logic validation in {@link EvoluConfig.onMessage}.
103
- // */
104
- // export type ValidatedDbChange<S extends EvoluSchema> = {
105
- // [Table in keyof S]: {
106
- // readonly table: Table;
107
- // readonly id: Id;
108
- // readonly values: Updateable<S[Table]> & { readonly createdAt?: DateIso };
109
- // };
110
- // }[keyof S];
111
-
112
- // /**
113
- // * Local-only mutation interface for use within {@link EvoluConfig.onMessage}
114
- // * callback.
115
- // *
116
- // * Provides type-safe mutation methods that only accept tables with names
117
- // * starting with underscore (local-only tables). All methods require fully
118
- // * validated branded values. No validation is performed as TypeScript ensures
119
- // * type correctness.
120
- // */
121
- // export interface LocalOnly<S extends EvoluSchema> {
122
- // readonly insert: <T extends keyof S & `_${string}`>(
123
- // table: T,
124
- // values: InferType<ObjectType<InsertableProps<S[T]>>>,
125
- // ) => InferType<S[T]["id"]>;
126
-
127
- // readonly update: <T extends keyof S & `_${string}`>(
128
- // table: T,
129
- // values: InferType<ObjectType<UpdateableProps<S[T]>>>,
130
- // ) => void;
131
-
132
- // readonly upsert: <T extends keyof S & `_${string}`>(
133
- // table: T,
134
- // values: InferType<ObjectType<UpsertableProps<S[T]>>>,
135
- // ) => void;
136
- // }
137
-
138
96
  export interface Evolu<S extends EvoluSchema = EvoluSchema> {
139
97
  /**
140
98
  * Subscribe to {@link EvoluError} changes.
@@ -187,13 +145,9 @@ export interface Evolu<S extends EvoluSchema = EvoluSchema> {
187
145
  * reason why loading should fail. All data are local, and the query is typed.
188
146
  * Unexpected errors are handled with {@link Evolu#subscribeError}.
189
147
  *
190
- * Loading is batched, and returned promises are cached, so there is no need
191
- * for an additional cache. Evolu's internal cache is invalidated on mutation.
192
- * Unsubscribed queries are removed from the cache, so loading them again will
193
- * return a new pending promise. Subscribed queries remain in the cache to
194
- * prevent unnecessary Suspense boundaries from activating. Their promises are
195
- * replaced with `Promise.resolve(rows)`, allowing React to synchronously
196
- * unwrap the updated data without suspending.
148
+ * Loading is batched, and returned promises are cached until resolved to
149
+ * prevent redundant database queries and to support React Suspense (which
150
+ * requires stable promise references while pending).
197
151
  *
198
152
  * To subscribe a query for automatic updates, use
199
153
  * {@link Evolu#subscribeQuery}.
@@ -255,6 +209,9 @@ export interface Evolu<S extends EvoluSchema = EvoluSchema> {
255
209
  /**
256
210
  * Promise that resolves to {@link AppOwner} when available.
257
211
  *
212
+ * Note: With web-only deps, this promise will not resolve during SSR because
213
+ * there is no AppOwner on the server.
214
+ *
258
215
  * ### Example
259
216
  *
260
217
  * ```ts
@@ -263,33 +220,6 @@ export interface Evolu<S extends EvoluSchema = EvoluSchema> {
263
220
  */
264
221
  readonly appOwner: Promise<AppOwner>;
265
222
 
266
- // TODO: Update it for the owners
267
- // /**
268
- // * Subscribe to {@link SyncState} changes.
269
- // *
270
- // * ### Example
271
- // *
272
- // * ```ts
273
- // * const unsubscribe = evolu.subscribeSyncState(() => {
274
- // * const syncState = evolu.getSyncState();
275
- // * });
276
- // * ```
277
- // */
278
- // readonly subscribeSyncState: StoreSubscribe;
279
-
280
- // /**
281
- // * Get {@link SyncState}.
282
- // *
283
- // * ### Example
284
- // *
285
- // * ```ts
286
- // * const unsubscribe = evolu.subscribeSyncState(() => {
287
- // * const syncState = evolu.getSyncState();
288
- // * });
289
- // * ```
290
- // */
291
- // readonly getSyncState: () => SyncState;
292
-
293
223
  /**
294
224
  * Inserts a row into the database and returns a {@link Result} with the new
295
225
  * {@link Id}.
@@ -512,23 +442,6 @@ export type EvoluError =
512
442
  | TimestampError
513
443
  | TransferableError;
514
444
 
515
- // /**
516
- // * Error reported when a message is invalid or rejected during processing.
517
- // *
518
- // * This error should never happen because a properly written app should ensure
519
- // * data correctness, but it can occur for two reasons:
520
- // *
521
- // * 1. An attack from someone who modified app code
522
- // * 2. A bug by the developer
523
- // *
524
- // * Both cases are useful to report for debugging and security monitoring.
525
- // */
526
- // export interface OnMessageError {
527
- // readonly type: "OnMessageError";
528
- // readonly invalidChanges: ReadonlyArray<DbChange>;
529
- // readonly rejectedChanges: ReadonlyArray<DbChange>;
530
- // }
531
-
532
445
  interface InternalEvoluInstance<S extends EvoluSchema = EvoluSchema>
533
446
  extends Evolu<S> {
534
447
  /**
@@ -546,30 +459,14 @@ export type EvoluDeps = ConsoleDep &
546
459
  ReloadAppDep &
547
460
  TimeDep;
548
461
 
549
- // For hot reloading and Evolu multitenancy.
550
462
  const evoluInstances = new Map<string, InternalEvoluInstance>();
551
463
 
552
464
  let tabId: Id | null = null;
553
465
 
554
466
  /**
555
- * Creates an {@link Evolu} instance configured with the specified
556
- * {@link EvoluSchema} and optional configuration.
557
- *
558
- * This function returns a configured Evolu instance, providing a typed
559
- * interface for querying, mutating, and syncing your application's data. The
560
- * returned instance includes:
561
- *
562
- * - Subscription methods for receiving updates on queries, the owner, errors, and
563
- * sync state.
564
- * - Methods for creating, updating, or deleting rows in a type-safe manner.
565
- * - Methods for querying data using Evolu's typed SQL queries, leveraging Kysely
566
- * under the hood.
567
- * - Built-in support for local-first and offline-first data with automatic sync
568
- * and merging.
569
- * - Automatic schema evolution that updates the underlying database with new
570
- * columns or tables.
571
- * - Managing owner data with {@link Evolu#resetAppOwner} and
572
- * {@link Evolu#restoreAppOwner}.
467
+ * Creates an {@link Evolu} instance for a platform configured with the specified
468
+ * {@link EvoluSchema} and optional {@link EvoluConfig} providing a typed
469
+ * interface for querying, mutating, and syncing your application's data.
573
470
  *
574
471
  * ### Example
575
472
  *
@@ -598,6 +495,18 @@ let tabId: Id | null = null;
598
495
  *
599
496
  * const evolu = createEvolu(evoluReactDeps)(Schema);
600
497
  * ```
498
+ *
499
+ * ### Instance Caching
500
+ *
501
+ * Evolu caches instances by {@link EvoluConfig} name to enable hot reloading and
502
+ * multitenancy. Multiple calls to `createEvolu` with the same name return the
503
+ * same instance, preserving database connections and state across module
504
+ * reloads during development. This ensures a seamless developer experience
505
+ * where edits don't interrupt ongoing sync or lose in-memory state.
506
+ *
507
+ * For testing, either dispose of instances after each test (TODO: implement
508
+ * dispose method) or use unique instance names to ensure proper isolation
509
+ * between test cases.
601
510
  */
602
511
  export const createEvolu =
603
512
  (deps: EvoluDeps) =>
@@ -658,40 +567,6 @@ const createEvoluInstance =
658
567
  return tabId;
659
568
  };
660
569
 
661
- // const createLocalOnly = (
662
- // localMutations: Array<MutationChange>,
663
- // defaultOwnerId: OwnerId | undefined,
664
- // ): LocalOnly<EvoluSchema> => ({
665
- // insert: (table, values) => {
666
- // const id = createId(deps);
667
- // localMutations.push({
668
- // table,
669
- // id,
670
- // values,
671
- // ownerId: defaultOwnerId,
672
- // });
673
- // return id;
674
- // },
675
- // update: (table, values) => {
676
- // const { id, ...rest } = values;
677
- // localMutations.push({
678
- // table,
679
- // id: id as Id,
680
- // values: rest,
681
- // ownerId: defaultOwnerId,
682
- // });
683
- // },
684
- // upsert: (table, values) => {
685
- // const { id, ...rest } = values as Record<string, unknown> & { id: Id };
686
- // localMutations.push({
687
- // table,
688
- // id: id,
689
- // values: rest as MutationChange["values"],
690
- // ownerId: defaultOwnerId,
691
- // });
692
- // },
693
- // });
694
-
695
570
  // Worker responses are delivered to all tabs. Each case must handle this
696
571
  // properly (e.g., AppOwner promise resolves only once, tabId filtering).
697
572
  dbWorker.onMessage((message) => {
@@ -765,74 +640,6 @@ const createEvoluInstance =
765
640
  break;
766
641
  }
767
642
 
768
- // case "processNewMessages": {
769
- // void requestIdleTask(
770
- // toTask(async () => {
771
- // const approved: Array<Timestamp> = [];
772
- // const invalidChanges: Array<DbChange> = [];
773
- // const rejectedChanges: Array<DbChange> = [];
774
- // const localMutations: Array<MutationChange> = [];
775
-
776
- // for (const crdtMessage of message.messages) {
777
- // let isApproved = true;
778
- // let isValid = true;
779
-
780
- // const table = crdtMessage.change.table;
781
- // if (table in schema) {
782
- // const { createdAt, ...values } = crdtMessage.change.values;
783
- // isValid =
784
- // (createdAt ? DateIso.is(createdAt) : true) &&
785
- // getMutationType(table, "update").is({
786
- // id: crdtMessage.change.id,
787
- // ...values,
788
- // });
789
- // } else {
790
- // isValid = false;
791
- // }
792
-
793
- // if (!isValid) {
794
- // isApproved = false;
795
- // invalidChanges.push(crdtMessage.change);
796
- // } else if (onMessage) {
797
- // // At this point, we've validated that the message conforms to the
798
- // // schema, so the typed callback can safely process it.
799
- // isApproved = await onMessage(crdtMessage.change, {
800
- // ownerId: message.ownerId,
801
- // localOnly: createLocalOnly(localMutations, message.ownerId),
802
- // });
803
- // if (!isApproved) {
804
- // rejectedChanges.push(crdtMessage.change);
805
- // }
806
- // }
807
-
808
- // if (isApproved) {
809
- // approved.push(crdtMessage.timestamp);
810
- // }
811
- // }
812
-
813
- // // Report OnMessageError if there were any invalid or rejected changes
814
- // if (invalidChanges.length > 0 || rejectedChanges.length > 0) {
815
- // const onMessageError: OnMessageError = {
816
- // type: "OnMessageError",
817
- // invalidChanges,
818
- // rejectedChanges,
819
- // };
820
- // errorStore.set(onMessageError);
821
- // }
822
-
823
- // dbWorker.postMessage({
824
- // type: "onProcessNewMessages",
825
- // onCompleteId: message.onCompleteId,
826
- // approved,
827
- // localMutations,
828
- // });
829
-
830
- // return ok();
831
- // }),
832
- // )();
833
- // break;
834
- // }
835
-
836
643
  case "onExport": {
837
644
  exportRegistry.execute(
838
645
  message.onCompleteId,
@@ -972,34 +779,6 @@ const createEvoluInstance =
972
779
 
973
780
  if (!isNonEmptyArray(changes)) return;
974
781
 
975
- // if (onMessage) {
976
- // const rejectedChanges: Array<DbChange> = [];
977
- // const localMutations: Array<MutationChange> = [];
978
-
979
- // for (const change of changes) {
980
- // const localOnly = createLocalOnly(localMutations, change.ownerId);
981
-
982
- // const isApproved = await onMessage(change, {
983
- // ownerId: change.ownerId,
984
- // localOnly,
985
- // });
986
- // if (!isApproved) {
987
- // rejectedChanges.push(change);
988
- // }
989
- // }
990
-
991
- // if (rejectedChanges.length > 0) {
992
- // errorStore.set({
993
- // type: "OnMessageError",
994
- // invalidChanges: [],
995
- // rejectedChanges,
996
- // });
997
- // return;
998
- // }
999
-
1000
- // changes.push(...localMutations);
1001
- // }
1002
-
1003
782
  dbWorker.postMessage({
1004
783
  type: "mutate",
1005
784
  tabId: getTabId(),
@@ -1199,25 +978,8 @@ interface LoadingPromises {
1199
978
  readonly isNew: boolean;
1200
979
  };
1201
980
 
1202
- /**
1203
- * Resolve a cached promise with updated rows.
1204
- *
1205
- * If the promise is not yet fulfilled, it will be resolved normally. If
1206
- * already fulfilled (subscribed query updated after mutation), the promise
1207
- * property is replaced with a new `Promise.resolve(rows)` while keeping the
1208
- * same cached object reference. The promise is not removed from the cache
1209
- * because React Suspense requires repeated calls to return the same promise.
1210
- */
1211
981
  resolve: (query: Query, rows: ReadonlyArray<Row>) => void;
1212
982
 
1213
- /**
1214
- * Release unsubscribed queries from the cache.
1215
- *
1216
- * Loading promises can't be released in `resolve` because they must be cached
1217
- * for React Suspense, but they also can't be cached forever because only
1218
- * subscribed queries are automatically updated (reactivity is expensive
1219
- * because it's implemented via refetching subscribed queries).
1220
- */
1221
983
  releaseUnsubscribedOnMutation: () => void;
1222
984
 
1223
985
  getQueries: () => ReadonlyArray<Query>;
@@ -17,6 +17,7 @@ import {
17
17
  Mnemonic,
18
18
  NonNegativeInt,
19
19
  } from "../Type.js";
20
+ import { getOrNull } from "../Result.js";
20
21
 
21
22
  /**
22
23
  * 32 bytes of cryptographic entropy used to derive {@link Owner} keys.
@@ -173,14 +174,82 @@ export const createAppOwner = (secret: OwnerSecret): AppOwner => ({
173
174
  ...createOwner(secret),
174
175
  });
175
176
 
176
- // DEV: Future transports: Bluetooth, LocalNetwork, etc.
177
+ /**
178
+ * Transport configuration for connecting to relays.
179
+ *
180
+ * Each {@link Owner} can specify one or more transports to connect to different
181
+ * relays for data synchronization. Currently supports WebSocket transport, with
182
+ * future support planned for Bluetooth, LocalNetwork, and other protocols.
183
+ */
177
184
  export type TransportConfig = WebSocketTransportConfig;
178
185
 
186
+ /**
187
+ * WebSocket transport configuration for relay connections.
188
+ *
189
+ * Use {@link createWebSocketTransportConfig} to create a properly formatted URL
190
+ * with {@link OwnerId}. The relay uses {@link parseOwnerIdFromUrl} to extract the
191
+ * OwnerId from the query string.
192
+ *
193
+ * ### Authentication and Error Handling
194
+ *
195
+ * When a relay rejects a connection (invalid OwnerId, unauthorized owner, or
196
+ * server error), the browser WebSocket API does not expose the specific HTTP
197
+ * status code or reason - it only reports a generic connection failure. The
198
+ * client automatically retries with exponential backoff and jitter, eventually
199
+ * succeeding once the configuration or server issue is resolved.
200
+ *
201
+ * Legitimate clients will be properly configured with valid credentials, so
202
+ * automatic retry is appropriate.
203
+ *
204
+ * @see {@link createWebSocketTransportConfig}
205
+ * @see {@link parseOwnerIdFromUrl}
206
+ */
179
207
  export interface WebSocketTransportConfig {
180
208
  readonly type: "WebSocket";
181
209
  readonly url: string;
182
210
  }
183
211
 
212
+ /**
213
+ * Creates a {@link WebSocketTransportConfig} for the given relay URL and
214
+ * {@link OwnerId}.
215
+ *
216
+ * ### Example
217
+ *
218
+ * ```ts
219
+ * const transport = createWebSocketTransportConfig({
220
+ * relayUrl: "wss://relay.evolu.dev",
221
+ * ownerId: owner.id,
222
+ * });
223
+ * // Result: { type: "WebSocket", url: "wss://relay.evolu.dev?ownerId=..." }
224
+ * ```
225
+ */
226
+ export const createWebSocketTransportConfig = ({
227
+ relayUrl,
228
+ ownerId,
229
+ }: {
230
+ readonly relayUrl: string;
231
+ readonly ownerId: OwnerId;
232
+ }): WebSocketTransportConfig => ({
233
+ type: "WebSocket",
234
+ url: `${relayUrl}?ownerId=${ownerId}`,
235
+ });
236
+
237
+ /**
238
+ * Extracts {@link OwnerId} from a URL query string.
239
+ *
240
+ * Parses the query string `?ownerId=...` and validates that the extracted value
241
+ * is a valid {@link OwnerId}.
242
+ *
243
+ * ### Example
244
+ *
245
+ * ```ts
246
+ * parseOwnerIdFromUrl("/sync?ownerId=_12345678abcdefgh");
247
+ * // Returns: OwnerId or null
248
+ * ```
249
+ */
250
+ export const parseOwnerIdFromUrl = (url: string | undefined): OwnerId | null =>
251
+ getOrNull(OwnerId.fromUnknown(url?.split("=")[1]));
252
+
184
253
  /**
185
254
  * An {@link Owner} for sharding data.
186
255
  *