@evolu/common 6.0.1-preview.27 → 6.0.1-preview.29

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 (56) hide show
  1. package/dist/src/Array.d.ts +58 -5
  2. package/dist/src/Array.d.ts.map +1 -1
  3. package/dist/src/Array.js +53 -5
  4. package/dist/src/Evolu/Evolu.d.ts +7 -12
  5. package/dist/src/Evolu/Evolu.d.ts.map +1 -1
  6. package/dist/src/Evolu/Evolu.js +18 -23
  7. package/dist/src/Evolu/Owner.d.ts +48 -19
  8. package/dist/src/Evolu/Owner.d.ts.map +1 -1
  9. package/dist/src/Evolu/Owner.js +11 -2
  10. package/dist/src/Evolu/Protocol.d.ts +31 -31
  11. package/dist/src/Evolu/Protocol.d.ts.map +1 -1
  12. package/dist/src/Evolu/Protocol.js +51 -28
  13. package/dist/src/Evolu/Relay.d.ts +40 -25
  14. package/dist/src/Evolu/Relay.d.ts.map +1 -1
  15. package/dist/src/Evolu/Relay.js +106 -49
  16. package/dist/src/Evolu/Storage.d.ts +59 -12
  17. package/dist/src/Evolu/Storage.d.ts.map +1 -1
  18. package/dist/src/Evolu/Storage.js +77 -50
  19. package/dist/src/Evolu/Sync.d.ts.map +1 -1
  20. package/dist/src/Evolu/Sync.js +14 -5
  21. package/dist/src/Evolu/Timestamp.d.ts +25 -0
  22. package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
  23. package/dist/src/Evolu/Timestamp.js +25 -0
  24. package/dist/src/Instances.d.ts +34 -0
  25. package/dist/src/Instances.d.ts.map +1 -0
  26. package/dist/src/Instances.js +44 -0
  27. package/dist/src/Sqlite.d.ts +6 -0
  28. package/dist/src/Sqlite.d.ts.map +1 -1
  29. package/dist/src/Sqlite.js +6 -0
  30. package/dist/src/Task.d.ts +75 -0
  31. package/dist/src/Task.d.ts.map +1 -1
  32. package/dist/src/Task.js +29 -6
  33. package/dist/src/Time.d.ts +7 -1
  34. package/dist/src/Time.d.ts.map +1 -1
  35. package/dist/src/Time.js +13 -2
  36. package/dist/src/Type.d.ts +56 -9
  37. package/dist/src/Type.d.ts.map +1 -1
  38. package/dist/src/Type.js +40 -8
  39. package/dist/src/index.d.ts +1 -0
  40. package/dist/src/index.d.ts.map +1 -1
  41. package/dist/src/index.js +1 -0
  42. package/package.json +1 -1
  43. package/src/Array.ts +76 -11
  44. package/src/Evolu/Evolu.ts +27 -27
  45. package/src/Evolu/Owner.ts +75 -26
  46. package/src/Evolu/Protocol.ts +90 -61
  47. package/src/Evolu/Relay.ts +182 -77
  48. package/src/Evolu/Storage.ts +157 -67
  49. package/src/Evolu/Sync.ts +18 -6
  50. package/src/Evolu/Timestamp.ts +25 -0
  51. package/src/Instances.ts +90 -0
  52. package/src/Sqlite.ts +6 -0
  53. package/src/Task.ts +88 -7
  54. package/src/Time.ts +13 -2
  55. package/src/Type.ts +56 -9
  56. package/src/index.ts +1 -0
@@ -1,24 +1,31 @@
1
- import { isNonEmptyReadonlyArray } from "../Array.js";
1
+ import { filterArray, isNonEmptyReadonlyArray, mapArray } from "../Array.js";
2
2
  import { ConsoleConfig, ConsoleDep } from "../Console.js";
3
3
  import { TimingSafeEqualDep } from "../Crypto.js";
4
4
  import { LazyValue } from "../Function.js";
5
+ import { createInstances } from "../Instances.js";
5
6
  import { err, ok, Result } from "../Result.js";
6
7
  import { sql, SqliteDep, SqliteError } from "../Sqlite.js";
7
- import { SimpleName } from "../Type.js";
8
- import { OwnerId, OwnerTransport, OwnerWriteKey } from "./Owner.js";
8
+ import { createMutex, isAsync, MaybeAsync, Mutex } from "../Task.js";
9
+ import { NonNegativeInt, PositiveInt, SimpleName } from "../Type.js";
10
+ import {
11
+ OwnerId,
12
+ ownerIdBytesToOwnerId,
13
+ OwnerTransport,
14
+ OwnerWriteKey,
15
+ } from "./Owner.js";
9
16
  import { ProtocolInvalidDataError } from "./Protocol.js";
10
17
  import {
11
18
  createBaseSqliteStorage,
12
- CreateBaseSqliteStorageOptions,
19
+ CreateBaseSqliteStorageConfig,
13
20
  EncryptedDbChange,
14
21
  SqliteStorageDeps,
15
22
  Storage,
23
+ StorageConfig,
24
+ StorageQuotaError,
16
25
  } from "./Storage.js";
17
26
  import { timestampToTimestampBytes } from "./Timestamp.js";
18
27
 
19
- export interface Relay extends Disposable {}
20
-
21
- export interface RelayConfig extends ConsoleConfig {
28
+ export interface RelayConfig extends ConsoleConfig, StorageConfig {
22
29
  /**
23
30
  * The relay name.
24
31
  *
@@ -28,21 +35,22 @@ export interface RelayConfig extends ConsoleConfig {
28
35
  readonly name?: SimpleName;
29
36
 
30
37
  /**
31
- * Optional callback to authenticate an {@link OwnerId} with the relay.
38
+ * Optional callback to check if an {@link OwnerId} is allowed to access the
39
+ * relay. If this callback is not provided, all owners are allowed.
32
40
  *
33
- * If this callback is not provided, all owners are allowed.
41
+ * If provided, the callback receives the OwnerId and should return a
42
+ * {@link MaybeAsync} boolean: `true` to allow access, or `false` to deny.
34
43
  *
35
- * If provided, the callback receives the OwnerId and should return a promise
36
- * that resolves to `true` to allow access, or `false` to deny.
44
+ * The callback can be synchronous (for SQLite or in-memory checks) or
45
+ * asynchronous (for calling remote APIs).
37
46
  *
38
47
  * The callback returns a boolean rather than an error type because error
39
- * handling and logging are the responsibility of the callback implementation,
40
- * not the relay. This prevents leaking authentication implementation details
41
- * into the generic relay interface.
48
+ * handling and logging are the responsibility of the callback
49
+ * implementation.
42
50
  *
43
- * OwnerId is used for authentication rather than short-lived tokens because
44
- * this only controls relay access, not write permissions. Since all data is
45
- * encrypted on the relay, OwnerId exposure is safe.
51
+ * OwnerId is used rather than short-lived tokens because this only controls
52
+ * relay access, not write permissions. Since all data is encrypted on the
53
+ * relay, OwnerId exposure is safe.
46
54
  *
47
55
  * Owners specify which relays to connect to via {@link OwnerTransport}. In
48
56
  * WebSocket-based implementations, this check occurs before accepting the
@@ -52,24 +60,49 @@ export interface RelayConfig extends ConsoleConfig {
52
60
  * ### Example
53
61
  *
54
62
  * ```ts
55
- * const relay = await createNodeJsRelay(deps)({
56
- * authenticateOwner: async (ownerId) => {
57
- * const isRegistered = await db.checkOwner(ownerId);
58
- * if (!isRegistered) {
59
- * logger.warn("Unauthorized access attempt", { ownerId });
60
- * }
61
- * return isRegistered;
62
- * },
63
+ * // Client
64
+ * const transport = createOwnerWebSocketTransport({
65
+ * url: "wss://relay.evolu.dev",
66
+ * ownerId: owner.id,
67
+ * });
68
+ *
69
+ * const evolu = createEvolu(deps)(Schema, {
70
+ * transports: [transport],
63
71
  * });
72
+ *
73
+ *
74
+ * // Relay
75
+ * isOwnerAllowed: (ownerId) =>
76
+ * Promise.resolve(ownerId === "6jy_2F4RT5qqeLgJ14_dnQ"),
64
77
  * ```
65
78
  */
66
- readonly authenticateOwner?: (ownerId: OwnerId) => Promise<boolean>;
79
+ readonly isOwnerAllowed?: (ownerId: OwnerId) => MaybeAsync<boolean>;
67
80
  }
68
81
 
82
+ /**
83
+ * A completely interchangeable server for syncing and backing up encrypted data
84
+ * between Evolu clients.
85
+ *
86
+ * Unlike traditional servers, relays are blind by design—they transmit
87
+ * encrypted data without understanding its shape or meaning. This enables true
88
+ * decentralization and infinite horizontal scalability with minimal
89
+ * infrastructure.
90
+ */
91
+ export interface Relay extends Disposable {}
92
+
69
93
  export const createRelaySqliteStorage =
70
94
  (deps: SqliteStorageDeps & TimingSafeEqualDep) =>
71
- (options: CreateBaseSqliteStorageOptions): Storage => {
72
- const sqliteStorageBase = createBaseSqliteStorage(deps)(options);
95
+ (config: CreateBaseSqliteStorageConfig): Storage => {
96
+ const sqliteStorageBase = createBaseSqliteStorage(deps)(config);
97
+
98
+ /**
99
+ * Mutex instances are cached per OwnerId to prevent concurrent writes for
100
+ * the same owner. Instances are never evicted, causing a memory leak
101
+ * proportional to unique owner count. However, per-instance overhead should
102
+ * be small. Monitor production memory usage to determine if
103
+ * eviction/cleanup is needed.
104
+ */
105
+ const ownerMutexes = createInstances<OwnerId, Mutex>();
73
106
 
74
107
  return {
75
108
  ...sqliteStorageBase,
@@ -92,26 +125,26 @@ export const createRelaySqliteStorage =
92
125
  `,
93
126
  );
94
127
  if (!selectWriteKey.ok) {
95
- options.onStorageError(selectWriteKey.error);
128
+ config.onStorageError(selectWriteKey.error);
96
129
  return false;
97
130
  }
98
131
 
99
132
  const { rows } = selectWriteKey.value;
100
133
 
101
- if (!isNonEmptyReadonlyArray(rows)) {
102
- const insertWriteKey = deps.sqlite.exec(sql`
103
- insert into evolu_writeKey (ownerId, writeKey)
104
- values (${ownerId}, ${writeKey});
105
- `);
106
- if (!insertWriteKey.ok) {
107
- options.onStorageError(insertWriteKey.error);
108
- return false;
109
- }
134
+ if (isNonEmptyReadonlyArray(rows)) {
135
+ return deps.timingSafeEqual(rows[0].writeKey, writeKey);
136
+ }
110
137
 
111
- return true;
138
+ const insertWriteKey = deps.sqlite.exec(sql`
139
+ insert into evolu_writeKey (ownerId, writeKey)
140
+ values (${ownerId}, ${writeKey});
141
+ `);
142
+ if (!insertWriteKey.ok) {
143
+ config.onStorageError(insertWriteKey.error);
144
+ return false;
112
145
  }
113
146
 
114
- return deps.timingSafeEqual(rows[0].writeKey, writeKey);
147
+ return true;
115
148
  },
116
149
 
117
150
  setWriteKey: (ownerId, writeKey) => {
@@ -122,45 +155,114 @@ export const createRelaySqliteStorage =
122
155
  set writeKey = excluded.writeKey;
123
156
  `);
124
157
  if (!upsertWriteKey.ok) {
125
- options.onStorageError(upsertWriteKey.error);
158
+ config.onStorageError(upsertWriteKey.error);
126
159
  return false;
127
160
  }
128
161
 
129
162
  return true;
130
163
  },
131
164
 
132
- // https://eslint.org/docs/latest/rules/require-await#when-not-to-use-it
133
- // eslint-disable-next-line @typescript-eslint/require-await
134
- writeMessages: async (ownerId, messages) => {
135
- const result = deps.sqlite.transaction(() => {
136
- for (const message of messages) {
137
- const insertTimestampResult = sqliteStorageBase.insertTimestamp(
138
- ownerId,
139
- timestampToTimestampBytes(message.timestamp),
165
+ writeMessages: async (ownerIdBytes, messages) => {
166
+ const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
167
+ const messagesWithTimestampBytes = mapArray(messages, (m) => ({
168
+ timestamp: timestampToTimestampBytes(m.timestamp),
169
+ change: m.change,
170
+ }));
171
+
172
+ const result = await ownerMutexes
173
+ .ensure(ownerId, createMutex)
174
+ .withLock<void, SqliteError | StorageQuotaError>(async () => {
175
+ const existingTimestampsResult =
176
+ sqliteStorageBase.getExistingTimestamps(
177
+ ownerIdBytes,
178
+ mapArray(messagesWithTimestampBytes, (m) => m.timestamp),
179
+ );
180
+ if (!existingTimestampsResult.ok) return existingTimestampsResult;
181
+
182
+ const existingTimestampsSet = new Set(
183
+ existingTimestampsResult.value.map((t) => t.toString()),
140
184
  );
141
- if (!insertTimestampResult.ok) return insertTimestampResult;
142
-
143
- const insertMessage = deps.sqlite.exec(sql`
144
- insert into evolu_message ("ownerId", "timestamp", "change")
145
- values
146
- (
147
- ${ownerId},
148
- ${timestampToTimestampBytes(message.timestamp)},
149
- ${message.change}
150
- )
151
- on conflict do nothing;
185
+ const newMessages = filterArray(
186
+ messagesWithTimestampBytes,
187
+ (m) => !existingTimestampsSet.has(m.timestamp.toString()),
188
+ );
189
+
190
+ // Nothing to write
191
+ if (!isNonEmptyReadonlyArray(newMessages)) {
192
+ return ok();
193
+ }
194
+
195
+ const storedBytesResult = deps.sqlite.exec<{
196
+ storedBytes: NonNegativeInt;
197
+ }>(sql`
198
+ select storedBytes
199
+ from evolu_usage
200
+ where ownerId = ${ownerIdBytes};
152
201
  `);
153
- if (!insertMessage.ok) return insertMessage;
154
- }
155
- return ok();
156
- });
202
+ if (!storedBytesResult.ok) return storedBytesResult;
157
203
 
158
- if (!result.ok) {
159
- options.onStorageError(result.error);
160
- return false;
204
+ const storedBytes =
205
+ storedBytesResult.value.rows[0]?.storedBytes ?? 0;
206
+ const incomingBytes = newMessages.reduce(
207
+ (sum, m) => sum + m.change.length,
208
+ 0,
209
+ );
210
+ const newStoredBytes = PositiveInt.orThrow(
211
+ storedBytes + incomingBytes,
212
+ );
213
+
214
+ if (config.isOwnerWithinQuota) {
215
+ const withinQuotaResult = config.isOwnerWithinQuota(
216
+ ownerId,
217
+ newStoredBytes,
218
+ );
219
+ const isWithinQuota = isAsync(withinQuotaResult)
220
+ ? await withinQuotaResult
221
+ : withinQuotaResult;
222
+ if (!isWithinQuota) {
223
+ return err({ type: "StorageQuotaError", ownerId });
224
+ }
225
+ }
226
+
227
+ return deps.sqlite.transaction(() => {
228
+ for (const { timestamp, change } of newMessages) {
229
+ const insertTimestampResult = sqliteStorageBase.insertTimestamp(
230
+ ownerIdBytes,
231
+ timestamp,
232
+ );
233
+ if (!insertTimestampResult.ok) return insertTimestampResult;
234
+
235
+ const insertMessage = deps.sqlite.exec(sql`
236
+ insert into evolu_message ("ownerId", "timestamp", "change")
237
+ values (${ownerIdBytes}, ${timestamp}, ${change})
238
+ on conflict do nothing;
239
+ `);
240
+ if (!insertMessage.ok) return insertMessage;
241
+ }
242
+
243
+ const updateUsage = deps.sqlite.exec(sql`
244
+ insert into evolu_usage ("ownerId", "storedBytes")
245
+ values (${ownerIdBytes}, ${newStoredBytes})
246
+ on conflict (ownerId) do update
247
+ set storedBytes = ${newStoredBytes};
248
+ `);
249
+ if (!updateUsage.ok) return updateUsage;
250
+
251
+ return ok();
252
+ });
253
+ })();
254
+
255
+ if (!result.ok && result.error.type !== "AbortError") {
256
+ switch (result.error.type) {
257
+ case "SqliteError":
258
+ config.onStorageError(result.error);
259
+ return err({ type: "StorageWriteError", ownerId });
260
+ case "StorageQuotaError":
261
+ return err({ type: "StorageQuotaError", ownerId });
262
+ }
161
263
  }
162
264
 
163
- return true;
265
+ return ok();
164
266
  },
165
267
 
166
268
  readDbChange: (ownerId, timestamp) => {
@@ -172,7 +274,7 @@ export const createRelaySqliteStorage =
172
274
  where "ownerId" = ${ownerId} and "timestamp" = ${timestamp};
173
275
  `);
174
276
  if (!result.ok) {
175
- options.onStorageError(result.error);
277
+ config.onStorageError(result.error);
176
278
  return null;
177
279
  }
178
280
 
@@ -180,7 +282,7 @@ export const createRelaySqliteStorage =
180
282
  },
181
283
 
182
284
  deleteOwner: (ownerId) => {
183
- const result = deps.sqlite.transaction(() => {
285
+ const transactionResult = deps.sqlite.transaction(() => {
184
286
  const deleteWriteKey = deps.sqlite.exec(sql`
185
287
  delete from evolu_writeKey where ownerId = ${ownerId};
186
288
  `);
@@ -191,15 +293,23 @@ export const createRelaySqliteStorage =
191
293
  `);
192
294
  if (!deleteMessages.ok) return deleteMessages;
193
295
 
296
+ const deleteUsage = deps.sqlite.exec(sql`
297
+ delete from evolu_usage where ownerId = ${ownerId};
298
+ `);
299
+ if (!deleteUsage.ok) return deleteUsage;
300
+
194
301
  const deleteBaseOwner = sqliteStorageBase.deleteOwner(ownerId);
195
302
  if (!deleteBaseOwner) return err(null);
196
303
 
197
304
  return ok();
198
305
  });
199
- if (!result.ok) {
200
- if (result.error) options.onStorageError(result.error);
306
+
307
+ if (!transactionResult.ok) {
308
+ if (transactionResult.error)
309
+ config.onStorageError(transactionResult.error);
201
310
  return false;
202
311
  }
312
+
203
313
  return true;
204
314
  },
205
315
  };
@@ -241,7 +351,6 @@ export interface RelayLogger {
241
351
  readonly upgradeSocketError: (error: Error) => void;
242
352
  readonly invalidOrMissingOwnerIdInUrl: (url: string | undefined) => void;
243
353
  readonly unauthorizedOwner: (ownerId: OwnerId) => void;
244
- readonly authenticateOwnerError: (error: unknown) => void;
245
354
  readonly connectionEstablished: (totalConnectionCount: number) => void;
246
355
  readonly connectionWebSocketError: (error: Error) => void;
247
356
  readonly relayOptionSubscribe: (
@@ -292,10 +401,6 @@ export const createRelayLogger = (deps: ConsoleDep): RelayLogger => ({
292
401
  deps.console.warn("[relay]", "unauthorized owner", { ownerId });
293
402
  },
294
403
 
295
- authenticateOwnerError: (error) => {
296
- deps.console.error("[relay]", "authenticateOwner error", error);
297
- },
298
-
299
404
  connectionEstablished: (totalConnectionCount) => {
300
405
  deps.console.log("[relay]", "connection", { totalConnectionCount });
301
406
  },