@evolu/common 6.0.1-preview.9 → 7.0.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 (184) hide show
  1. package/README.md +2 -2
  2. package/dist/src/Array.d.ts +256 -12
  3. package/dist/src/Array.d.ts.map +1 -1
  4. package/dist/src/Array.js +215 -9
  5. package/dist/src/Assert.d.ts +0 -13
  6. package/dist/src/Assert.d.ts.map +1 -1
  7. package/dist/src/Assert.js +0 -15
  8. package/dist/src/Brand.d.ts +75 -0
  9. package/dist/src/Brand.d.ts.map +1 -0
  10. package/dist/src/Brand.js +1 -0
  11. package/dist/src/Buffer.d.ts +1 -1
  12. package/dist/src/Buffer.d.ts.map +1 -1
  13. package/dist/src/Buffer.js +8 -7
  14. package/dist/src/Cache.d.ts +44 -0
  15. package/dist/src/Cache.d.ts.map +1 -0
  16. package/dist/src/Cache.js +52 -0
  17. package/dist/src/Callbacks.d.ts +45 -12
  18. package/dist/src/Callbacks.d.ts.map +1 -1
  19. package/dist/src/Callbacks.js +14 -7
  20. package/dist/src/Console.d.ts +31 -6
  21. package/dist/src/Console.d.ts.map +1 -1
  22. package/dist/src/Console.js +72 -9
  23. package/dist/src/Crypto.d.ts +56 -42
  24. package/dist/src/Crypto.d.ts.map +1 -1
  25. package/dist/src/Crypto.js +40 -53
  26. package/dist/src/Evolu/Db.d.ts +162 -74
  27. package/dist/src/Evolu/Db.d.ts.map +1 -1
  28. package/dist/src/Evolu/Db.js +284 -702
  29. package/dist/src/Evolu/Diff.d.ts +3 -3
  30. package/dist/src/Evolu/Diff.d.ts.map +1 -1
  31. package/dist/src/Evolu/Diff.js +7 -5
  32. package/dist/src/Evolu/Evolu.d.ts +213 -133
  33. package/dist/src/Evolu/Evolu.d.ts.map +1 -1
  34. package/dist/src/Evolu/Evolu.js +189 -180
  35. package/dist/src/Evolu/Internal.d.ts +0 -2
  36. package/dist/src/Evolu/Internal.d.ts.map +1 -1
  37. package/dist/src/Evolu/Internal.js +0 -2
  38. package/dist/src/Evolu/LocalAuth.d.ts +150 -0
  39. package/dist/src/Evolu/LocalAuth.d.ts.map +1 -0
  40. package/dist/src/Evolu/LocalAuth.js +174 -0
  41. package/dist/src/Evolu/Owner.d.ts +273 -120
  42. package/dist/src/Evolu/Owner.d.ts.map +1 -1
  43. package/dist/src/Evolu/Owner.js +130 -104
  44. package/dist/src/Evolu/Platform.d.ts +9 -7
  45. package/dist/src/Evolu/Platform.d.ts.map +1 -1
  46. package/dist/src/Evolu/Protocol.d.ts +268 -240
  47. package/dist/src/Evolu/Protocol.d.ts.map +1 -1
  48. package/dist/src/Evolu/Protocol.js +595 -447
  49. package/dist/src/Evolu/Public.d.ts +6 -8
  50. package/dist/src/Evolu/Public.d.ts.map +1 -1
  51. package/dist/src/Evolu/Public.js +2 -3
  52. package/dist/src/Evolu/PublicKysely.d.ts.map +1 -1
  53. package/dist/src/Evolu/PublicKysely.js +3 -4
  54. package/dist/src/Evolu/Query.d.ts +2 -1
  55. package/dist/src/Evolu/Query.d.ts.map +1 -1
  56. package/dist/src/Evolu/Query.js +1 -1
  57. package/dist/src/Evolu/Relay.d.ts +91 -8
  58. package/dist/src/Evolu/Relay.d.ts.map +1 -1
  59. package/dist/src/Evolu/Relay.js +214 -88
  60. package/dist/src/Evolu/Schema.d.ts +125 -47
  61. package/dist/src/Evolu/Schema.d.ts.map +1 -1
  62. package/dist/src/Evolu/Schema.js +175 -31
  63. package/dist/src/Evolu/Storage.d.ts +249 -27
  64. package/dist/src/Evolu/Storage.d.ts.map +1 -1
  65. package/dist/src/Evolu/Storage.js +198 -92
  66. package/dist/src/Evolu/Sync.d.ts +68 -13
  67. package/dist/src/Evolu/Sync.d.ts.map +1 -1
  68. package/dist/src/Evolu/Sync.js +469 -20
  69. package/dist/src/Evolu/Timestamp.d.ts +83 -30
  70. package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
  71. package/dist/src/Evolu/Timestamp.js +79 -34
  72. package/dist/src/Identicon.d.ts +35 -0
  73. package/dist/src/Identicon.d.ts.map +1 -0
  74. package/dist/src/Identicon.js +143 -0
  75. package/dist/src/Instances.d.ts +34 -0
  76. package/dist/src/Instances.d.ts.map +1 -0
  77. package/dist/src/Instances.js +44 -0
  78. package/dist/src/Number.d.ts +4 -3
  79. package/dist/src/Number.d.ts.map +1 -1
  80. package/dist/src/Number.js +5 -4
  81. package/dist/src/Object.d.ts +10 -4
  82. package/dist/src/Object.d.ts.map +1 -1
  83. package/dist/src/Object.js +9 -3
  84. package/dist/src/Platform.d.ts +20 -0
  85. package/dist/src/Platform.d.ts.map +1 -0
  86. package/dist/src/Platform.js +22 -0
  87. package/dist/src/Random.d.ts +3 -2
  88. package/dist/src/Random.d.ts.map +1 -1
  89. package/dist/src/Relation.d.ts +79 -0
  90. package/dist/src/Relation.d.ts.map +1 -0
  91. package/dist/src/Relation.js +127 -0
  92. package/dist/src/Resources.d.ts +118 -0
  93. package/dist/src/Resources.d.ts.map +1 -0
  94. package/dist/src/Resources.js +197 -0
  95. package/dist/src/Result.d.ts +184 -52
  96. package/dist/src/Result.d.ts.map +1 -1
  97. package/dist/src/Result.js +30 -241
  98. package/dist/src/Skiplist.js +2 -1
  99. package/dist/src/Sqlite.d.ts +89 -5
  100. package/dist/src/Sqlite.d.ts.map +1 -1
  101. package/dist/src/Sqlite.js +136 -9
  102. package/dist/src/Task.d.ts +586 -0
  103. package/dist/src/Task.d.ts.map +1 -0
  104. package/dist/src/Task.js +469 -0
  105. package/dist/src/Time.d.ts +66 -1
  106. package/dist/src/Time.d.ts.map +1 -1
  107. package/dist/src/Time.js +99 -5
  108. package/dist/src/Type.d.ts +676 -343
  109. package/dist/src/Type.d.ts.map +1 -1
  110. package/dist/src/Type.js +718 -467
  111. package/dist/src/Types.d.ts +1 -75
  112. package/dist/src/Types.d.ts.map +1 -1
  113. package/dist/src/WebSocket.d.ts +5 -2
  114. package/dist/src/WebSocket.d.ts.map +1 -1
  115. package/dist/src/WebSocket.js +12 -18
  116. package/dist/src/Worker.d.ts +39 -11
  117. package/dist/src/Worker.d.ts.map +1 -1
  118. package/dist/src/Worker.js +22 -4
  119. package/dist/src/index.d.ts +8 -3
  120. package/dist/src/index.d.ts.map +1 -1
  121. package/dist/src/index.js +8 -3
  122. package/package.json +14 -13
  123. package/src/Array.ts +301 -19
  124. package/src/Assert.ts +0 -21
  125. package/src/Brand.ts +75 -0
  126. package/src/Buffer.ts +7 -7
  127. package/src/Cache.ts +85 -0
  128. package/src/Callbacks.ts +62 -22
  129. package/src/Console.ts +91 -11
  130. package/src/Crypto.ts +94 -90
  131. package/src/Evolu/Db.ts +519 -1026
  132. package/src/Evolu/Diff.ts +7 -5
  133. package/src/Evolu/Evolu.ts +476 -360
  134. package/src/Evolu/Internal.ts +0 -2
  135. package/src/Evolu/LocalAuth.ts +463 -0
  136. package/src/Evolu/Owner.ts +355 -228
  137. package/src/Evolu/Platform.ts +9 -9
  138. package/src/Evolu/Protocol.ts +846 -742
  139. package/src/Evolu/Public.ts +7 -14
  140. package/src/Evolu/PublicKysely.ts +4 -5
  141. package/src/Evolu/Query.ts +3 -2
  142. package/src/Evolu/Relay.ts +406 -103
  143. package/src/Evolu/Schema.ts +323 -91
  144. package/src/Evolu/Storage.ts +559 -137
  145. package/src/Evolu/Sync.ts +819 -36
  146. package/src/Evolu/Timestamp.ts +90 -58
  147. package/src/Identicon.ts +197 -0
  148. package/src/Instances.ts +90 -0
  149. package/src/Number.ts +6 -10
  150. package/src/Object.ts +13 -5
  151. package/src/Platform.ts +26 -0
  152. package/src/Random.ts +3 -2
  153. package/src/Relation.ts +234 -0
  154. package/src/Resources.ts +367 -0
  155. package/src/Result.ts +191 -54
  156. package/src/Skiplist.ts +1 -1
  157. package/src/Sqlite.ts +152 -17
  158. package/src/Task.ts +901 -0
  159. package/src/Time.ts +180 -5
  160. package/src/Type.ts +1135 -730
  161. package/src/Types.ts +1 -77
  162. package/src/WebSocket.ts +27 -25
  163. package/src/Worker.ts +72 -23
  164. package/src/index.ts +8 -3
  165. package/dist/src/Evolu/Config.d.ts +0 -69
  166. package/dist/src/Evolu/Config.d.ts.map +0 -1
  167. package/dist/src/Evolu/Config.js +0 -9
  168. package/dist/src/Evolu/Kysely.d.ts +0 -6
  169. package/dist/src/Evolu/Kysely.d.ts.map +0 -1
  170. package/dist/src/Evolu/Kysely.js +0 -21
  171. package/dist/src/ManyToManyMap.d.ts +0 -26
  172. package/dist/src/ManyToManyMap.d.ts.map +0 -1
  173. package/dist/src/ManyToManyMap.js +0 -92
  174. package/dist/src/NanoId.d.ts +0 -27
  175. package/dist/src/NanoId.d.ts.map +0 -1
  176. package/dist/src/NanoId.js +0 -6
  177. package/dist/src/Promise.d.ts +0 -180
  178. package/dist/src/Promise.d.ts.map +0 -1
  179. package/dist/src/Promise.js +0 -176
  180. package/src/Evolu/Config.ts +0 -83
  181. package/src/Evolu/Kysely.ts +0 -38
  182. package/src/ManyToManyMap.ts +0 -140
  183. package/src/NanoId.ts +0 -39
  184. package/src/Promise.ts +0 -295
@@ -5,23 +5,17 @@
5
5
  */
6
6
 
7
7
  export { createEvolu } from "./Evolu.js";
8
- export type {
9
- Evolu,
10
- EvoluConfigWithInitialData,
11
- EvoluDeps,
12
- EvoluError,
13
- } from "./Evolu.js";
8
+ export type { Evolu, EvoluConfig, EvoluDeps, EvoluError } from "./Evolu.js";
14
9
  export * from "./Owner.js";
15
- export { binaryIdToId, idToBinaryId } from "./Protocol.js";
16
- export type { BinaryId } from "./Protocol.js";
10
+ export * from "./LocalAuth.js";
17
11
  export * as kysely from "./PublicKysely.js";
18
12
  export type { InferRow, Query, QueryRows, Row } from "./Query.js";
19
- export { formatValidMutationSizeError } from "./Schema.js";
20
- export type { EvoluSchema, ValidMutationSizeError } from "./Schema.js";
13
+ export type { EvoluSchema } from "./Schema.js";
21
14
  export type {
22
15
  NetworkError,
23
16
  PaymentRequiredError,
24
17
  ServerError,
18
+ SyncOwner,
25
19
  SyncState,
26
20
  SyncStateInitial,
27
21
  SyncStateIsNotSynced,
@@ -29,15 +23,14 @@ export type {
29
23
  SyncStateIsSyncing,
30
24
  } from "./Sync.js";
31
25
  export {
32
- binaryTimestampToTimestamp,
33
26
  Timestamp,
34
- timestampToBinaryTimestamp,
27
+ timestampBytesToTimestamp,
28
+ timestampToTimestampBytes,
35
29
  } from "./Timestamp.js";
36
30
  export type {
37
- BinaryTimestamp,
31
+ TimestampBytes,
38
32
  TimestampCounterOverflowError,
39
33
  TimestampDriftError,
40
- TimestampDuplicateNodeError,
41
34
  TimestampError,
42
35
  TimestampTimeOutOfRangeError,
43
36
  } from "./Timestamp.js";
@@ -65,7 +65,7 @@ export type { NotNull } from "kysely";
65
65
  * from "person"
66
66
  * ```
67
67
  */
68
- // SQL formatting breaks generated SQL for some reason.
68
+ // Kysely expects strict AST.
69
69
  // prettier-ignore
70
70
  export function jsonArrayFrom<O>(
71
71
  expr: SelectQueryBuilderExpression<O>,
@@ -122,7 +122,7 @@ export function jsonArrayFrom<O>(
122
122
  * from "person";
123
123
  * ```
124
124
  */
125
- // SQL formatting breaks generated SQL for some reason.
125
+ // Kysely expects strict AST.
126
126
  // prettier-ignore
127
127
  export function jsonObjectFrom<O>(
128
128
  expr: SelectQueryBuilderExpression<O>,
@@ -172,7 +172,7 @@ export function jsonObjectFrom<O>(
172
172
  * from "person"
173
173
  * ```
174
174
  */
175
- // SQL formatting breaks generated SQL for some reason.
175
+ // Kysely expects strict AST.
176
176
  // prettier-ignore
177
177
  export function jsonBuildObject<O extends Record<string, Expression<unknown>>>(
178
178
  obj: O,
@@ -209,8 +209,7 @@ export function getJsonObjectArgs(
209
209
  table: string,
210
210
  ): Array<Expression<unknown> | string> {
211
211
  const args: Array<Expression<unknown> | string> = [];
212
- args.push(kyselyJsonIdentifier, kyselyJsonIdentifier);
213
-
212
+
214
213
  for (const { selection: s } of node.selections ?? []) {
215
214
  if (ReferenceNode.is(s) && ColumnNode.is(s.column)) {
216
215
  args.push(
@@ -1,3 +1,4 @@
1
+ import { Brand } from "../Brand.js";
1
2
  import { bytesToHex, hexToBytes } from "../Buffer.js";
2
3
  import { objectToEntries } from "../Object.js";
3
4
  import {
@@ -8,7 +9,7 @@ import {
8
9
  SqliteValue,
9
10
  } from "../Sqlite.js";
10
11
  import { Store, StoreSubscribe } from "../Store.js";
11
- import { Brand, Simplify } from "../Types.js";
12
+ import { Simplify } from "../Types.js";
12
13
 
13
14
  /**
14
15
  * A type-safe SQL query.
@@ -51,7 +52,7 @@ export const serializeQuery = <R extends Row>(query: SqliteQuery): Query<R> => {
51
52
  );
52
53
 
53
54
  const options = query.options
54
- ? objectToEntries(query.options).sort(([a], [b]) => a.localeCompare(b))
55
+ ? objectToEntries(query.options).toSorted(([a], [b]) => a.localeCompare(b))
55
56
  : [];
56
57
 
57
58
  return JSON.stringify([query.sql, params, options]) as Query<R>;
@@ -1,61 +1,123 @@
1
- import { isNonEmptyReadonlyArray } from "../Array.js";
2
- import { ConsoleConfig } from "../Console.js";
1
+ import {
2
+ filterArray,
3
+ firstInArray,
4
+ isNonEmptyReadonlyArray,
5
+ mapArray,
6
+ } from "../Array.js";
7
+ import { ConsoleConfig, ConsoleDep } from "../Console.js";
3
8
  import { TimingSafeEqualDep } from "../Crypto.js";
9
+ import { LazyValue } from "../Function.js";
10
+ import { createInstances } from "../Instances.js";
4
11
  import { err, ok, Result } from "../Result.js";
5
- import { sql, SqliteError } from "../Sqlite.js";
6
- import { SimpleName } from "../Type.js";
7
- import { OwnerId, WriteKey } from "./Owner.js";
8
- import { EncryptedDbChange, Storage } from "./Protocol.js";
12
+ import { sql, SqliteDep, SqliteError } from "../Sqlite.js";
13
+ import { createMutex, isAsync, MaybeAsync, Mutex } from "../Task.js";
14
+ import { PositiveInt, SimpleName } from "../Type.js";
15
+ import {
16
+ OwnerId,
17
+ ownerIdBytesToOwnerId,
18
+ OwnerTransport,
19
+ OwnerWriteKey,
20
+ } from "./Owner.js";
21
+ import { ProtocolInvalidDataError } from "./Protocol.js";
9
22
  import {
10
- createSqliteStorageBase,
11
- CreateSqliteStorageBaseOptions,
23
+ createBaseSqliteStorage,
24
+ CreateBaseSqliteStorageConfig,
25
+ EncryptedDbChange,
26
+ getOwnerUsage,
27
+ getTimestampInsertStrategy,
12
28
  SqliteStorageDeps,
29
+ Storage,
30
+ StorageConfig,
31
+ StorageQuotaError,
32
+ updateOwnerUsage,
13
33
  } from "./Storage.js";
14
- import { timestampToBinaryTimestamp } from "./Timestamp.js";
34
+ import { timestampToTimestampBytes } from "./Timestamp.js";
15
35
 
16
- export interface Relay extends Disposable {}
17
-
18
- export interface RelayConfig extends ConsoleConfig {
36
+ export interface RelayConfig extends ConsoleConfig, StorageConfig {
37
+ /**
38
+ * The relay name.
39
+ *
40
+ * Implementations can use this for identification purposes (e.g., database
41
+ * file name, logging).
42
+ */
19
43
  readonly name?: SimpleName;
44
+
45
+ /**
46
+ * Optional callback to check if an {@link OwnerId} is allowed to access the
47
+ * relay. If this callback is not provided, all owners are allowed.
48
+ *
49
+ * The callback receives the {@link OwnerId} and returns a {@link MaybeAsync}
50
+ * boolean: `true` to allow access, or `false` to deny.
51
+ *
52
+ * The callback can be synchronous (for SQLite or in-memory checks) or
53
+ * asynchronous (for calling remote APIs).
54
+ *
55
+ * The callback returns a boolean rather than an error type because error
56
+ * handling and logging are the responsibility of the callback
57
+ * implementation.
58
+ *
59
+ * OwnerId is used rather than short-lived tokens because this only controls
60
+ * relay access, not write permissions. Since all data is encrypted on the
61
+ * relay, OwnerId exposure is safe.
62
+ *
63
+ * Owners specify which relays to connect to via {@link OwnerTransport}. In
64
+ * WebSocket-based implementations, this check occurs before accepting the
65
+ * connection, with the OwnerId typically extracted from the URL Path (e.g.,
66
+ * `ws://localhost:4000/<ownerId>`). The relay requires the URL to be in the
67
+ * correct format for OwnerId extraction.
68
+ *
69
+ * ### Example
70
+ *
71
+ * ```ts
72
+ * // Client
73
+ * const transport = createOwnerWebSocketTransport({
74
+ * url: "wss://relay.evolu.dev",
75
+ * ownerId: owner.id,
76
+ * });
77
+ *
78
+ * const evolu = createEvolu(deps)(Schema, {
79
+ * transports: [transport],
80
+ * });
81
+ *
82
+ *
83
+ * // Relay
84
+ * isOwnerAllowed: (ownerId) =>
85
+ * Promise.resolve(ownerId === "6jy_2F4RT5qqeLgJ14_dnQ"),
86
+ * ```
87
+ */
88
+ readonly isOwnerAllowed?: (ownerId: OwnerId) => MaybeAsync<boolean>;
20
89
  }
21
90
 
22
- export type RelaySqliteStorageDeps = SqliteStorageDeps & TimingSafeEqualDep;
23
-
24
- export const createRelayStorage =
25
- (deps: RelaySqliteStorageDeps) =>
26
- (options: CreateSqliteStorageBaseOptions): Result<Storage, SqliteError> => {
27
- const sqliteStorageBase = createSqliteStorageBase(deps)(options);
28
- if (!sqliteStorageBase.ok) return sqliteStorageBase;
29
-
30
- for (const query of [
31
- sql`
32
- create table if not exists evolu_writeKey (
33
- "ownerId" blob not null,
34
- "writeKey" blob not null,
35
- primary key ("ownerId")
36
- )
37
- strict;
38
- `,
39
-
40
- sql`
41
- create table if not exists evolu_message (
42
- "ownerId" blob not null,
43
- "timestamp" blob not null,
44
- "change" blob not null,
45
- primary key ("ownerId", "timestamp")
46
- )
47
- strict;
48
- `,
49
- ]) {
50
- const result = deps.sqlite.exec(query);
51
- if (!result.ok) return result;
52
- }
53
-
54
- return ok({
55
- ...sqliteStorageBase.value,
91
+ /**
92
+ * A completely interchangeable server for syncing and backing up encrypted data
93
+ * between Evolu clients.
94
+ *
95
+ * Unlike traditional servers, relays are blind by design—they transmit
96
+ * encrypted data without understanding its shape or meaning. This enables true
97
+ * decentralization and infinite horizontal scalability with minimal
98
+ * infrastructure.
99
+ */
100
+ export interface Relay extends Disposable {}
101
+
102
+ export const createRelaySqliteStorage =
103
+ (deps: SqliteStorageDeps & TimingSafeEqualDep) =>
104
+ (config: CreateBaseSqliteStorageConfig): Storage => {
105
+ const sqliteStorageBase = createBaseSqliteStorage(deps)(config);
106
+
107
+ /**
108
+ * Mutex instances are cached per OwnerId to prevent concurrent writes for
109
+ * the same owner. Instances are never evicted, causing a memory leak
110
+ * proportional to unique owner count. However, per-instance overhead should
111
+ * be small. Monitor production memory usage to determine if
112
+ * eviction/cleanup is needed.
113
+ */
114
+ const ownerMutexes = createInstances<OwnerId, Mutex>();
115
+
116
+ return {
117
+ ...sqliteStorageBase,
56
118
 
57
119
  /**
58
- * Lazily authorizes the initiator's {@link WriteKey} for the given
120
+ * Lazily authorizes the initiator's {@link OwnerWriteKey} for the given
59
121
  * {@link OwnerId}.
60
122
  *
61
123
  * - If the {@link OwnerId} does not exist, it is created and associated with
@@ -64,32 +126,34 @@ export const createRelayStorage =
64
126
  * stored key.
65
127
  */
66
128
  validateWriteKey: (ownerId, writeKey) => {
67
- const selectWriteKey = deps.sqlite.exec<{ writeKey: WriteKey }>(sql`
68
- select writeKey
69
- from evolu_writeKey
70
- where ownerId = ${ownerId};
71
- `);
129
+ const selectWriteKey = deps.sqlite.exec<{ writeKey: OwnerWriteKey }>(
130
+ sql`
131
+ select writeKey
132
+ from evolu_writeKey
133
+ where ownerId = ${ownerId};
134
+ `,
135
+ );
72
136
  if (!selectWriteKey.ok) {
73
- options.onStorageError(selectWriteKey.error);
137
+ config.onStorageError(selectWriteKey.error);
74
138
  return false;
75
139
  }
76
140
 
77
141
  const { rows } = selectWriteKey.value;
78
142
 
79
- if (!isNonEmptyReadonlyArray(rows)) {
80
- const insertWriteKey = deps.sqlite.exec(sql`
81
- insert into evolu_writeKey (ownerId, writeKey)
82
- values (${ownerId}, ${writeKey});
83
- `);
84
- if (!insertWriteKey.ok) {
85
- options.onStorageError(insertWriteKey.error);
86
- return false;
87
- }
143
+ if (isNonEmptyReadonlyArray(rows)) {
144
+ return deps.timingSafeEqual(rows[0].writeKey, writeKey);
145
+ }
88
146
 
89
- return true;
147
+ const insertWriteKey = deps.sqlite.exec(sql`
148
+ insert into evolu_writeKey (ownerId, writeKey)
149
+ values (${ownerId}, ${writeKey});
150
+ `);
151
+ if (!insertWriteKey.ok) {
152
+ config.onStorageError(insertWriteKey.error);
153
+ return false;
90
154
  }
91
155
 
92
- return deps.timingSafeEqual(rows[0].writeKey, writeKey);
156
+ return true;
93
157
  },
94
158
 
95
159
  setWriteKey: (ownerId, writeKey) => {
@@ -100,44 +164,120 @@ export const createRelayStorage =
100
164
  set writeKey = excluded.writeKey;
101
165
  `);
102
166
  if (!upsertWriteKey.ok) {
103
- options.onStorageError(upsertWriteKey.error);
167
+ config.onStorageError(upsertWriteKey.error);
104
168
  return false;
105
169
  }
106
170
 
107
171
  return true;
108
172
  },
109
173
 
110
- writeMessages: (ownerId, messages) => {
111
- const result = deps.sqlite.transaction(() => {
112
- for (const message of messages) {
113
- const insertTimestampResult =
114
- sqliteStorageBase.value.insertTimestamp(
115
- ownerId,
116
- timestampToBinaryTimestamp(message.timestamp),
174
+ writeMessages: async (ownerIdBytes, messages) => {
175
+ const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
176
+ const messagesWithTimestampBytes = mapArray(messages, (m) => ({
177
+ timestamp: timestampToTimestampBytes(m.timestamp),
178
+ change: m.change,
179
+ }));
180
+
181
+ const result = await ownerMutexes
182
+ .ensure(ownerId, createMutex)
183
+ .withLock<void, SqliteError | StorageQuotaError>(async () => {
184
+ const existingTimestampsResult =
185
+ sqliteStorageBase.getExistingTimestamps(
186
+ ownerIdBytes,
187
+ mapArray(messagesWithTimestampBytes, (m) => m.timestamp),
117
188
  );
118
- if (!insertTimestampResult.ok) return insertTimestampResult;
119
-
120
- const insertMessage = deps.sqlite.exec(sql`
121
- insert into evolu_message ("ownerId", "timestamp", "change")
122
- values
123
- (
124
- ${ownerId},
125
- ${timestampToBinaryTimestamp(message.timestamp)},
126
- ${message.change}
127
- )
128
- on conflict do nothing;
129
- `);
130
- if (!insertMessage.ok) return insertMessage;
131
- }
132
- return ok();
133
- });
189
+ if (!existingTimestampsResult.ok) return existingTimestampsResult;
134
190
 
135
- if (!result.ok) {
136
- options.onStorageError(result.error);
137
- return false;
191
+ const existingTimestampsSet = new Set(
192
+ existingTimestampsResult.value.map((t) => t.toString()),
193
+ );
194
+ const newMessages = filterArray(
195
+ messagesWithTimestampBytes,
196
+ (m) => !existingTimestampsSet.has(m.timestamp.toString()),
197
+ );
198
+
199
+ // Nothing to write
200
+ if (!isNonEmptyReadonlyArray(newMessages)) {
201
+ return ok();
202
+ }
203
+
204
+ const usage = getOwnerUsage(deps)(
205
+ ownerIdBytes,
206
+ firstInArray(newMessages).timestamp,
207
+ );
208
+ if (!usage.ok) return usage;
209
+
210
+ const { storedBytes } = usage.value;
211
+
212
+ const incomingBytes = newMessages.reduce(
213
+ (sum, m) => sum + m.change.length,
214
+ 0,
215
+ );
216
+ const newStoredBytes = PositiveInt.orThrow(
217
+ (storedBytes ?? 0) + incomingBytes,
218
+ );
219
+
220
+ const withinQuotaResult = config.isOwnerWithinQuota(
221
+ ownerId,
222
+ newStoredBytes,
223
+ );
224
+ const isWithinQuota = isAsync(withinQuotaResult)
225
+ ? await withinQuotaResult
226
+ : withinQuotaResult;
227
+ if (!isWithinQuota) {
228
+ return err({ type: "StorageQuotaError", ownerId });
229
+ }
230
+
231
+ let { firstTimestamp, lastTimestamp } = usage.value;
232
+
233
+ return deps.sqlite.transaction(() => {
234
+ for (const { timestamp, change } of newMessages) {
235
+ let strategy;
236
+ [strategy, firstTimestamp, lastTimestamp] =
237
+ getTimestampInsertStrategy(
238
+ timestamp,
239
+ firstTimestamp,
240
+ lastTimestamp,
241
+ );
242
+
243
+ const insertTimestampResult = sqliteStorageBase.insertTimestamp(
244
+ ownerIdBytes,
245
+ timestamp,
246
+ strategy,
247
+ );
248
+ if (!insertTimestampResult.ok) return insertTimestampResult;
249
+
250
+ const insertMessage = deps.sqlite.exec(sql`
251
+ insert into evolu_message ("ownerId", "timestamp", "change")
252
+ values (${ownerIdBytes}, ${timestamp}, ${change})
253
+ on conflict do nothing;
254
+ `);
255
+ if (!insertMessage.ok) return insertMessage;
256
+ }
257
+
258
+ const updateUsage = updateOwnerUsage(deps)(
259
+ ownerIdBytes,
260
+ newStoredBytes,
261
+ firstTimestamp,
262
+ lastTimestamp,
263
+ );
264
+ if (!updateUsage.ok) return updateUsage;
265
+
266
+ return ok();
267
+ });
268
+ })();
269
+
270
+ if (!result.ok && result.error.type !== "AbortError") {
271
+ switch (result.error.type) {
272
+ case "SqliteError":
273
+ config.onStorageError(result.error);
274
+ return err({ type: "StorageWriteError", ownerId });
275
+ case "StorageQuotaError":
276
+ return err({ type: "StorageQuotaError", ownerId });
277
+ }
138
278
  }
139
279
 
140
- return true;
280
+ return ok();
141
281
  },
142
282
 
143
283
  readDbChange: (ownerId, timestamp) => {
@@ -149,7 +289,7 @@ export const createRelayStorage =
149
289
  where "ownerId" = ${ownerId} and "timestamp" = ${timestamp};
150
290
  `);
151
291
  if (!result.ok) {
152
- options.onStorageError(result.error);
292
+ config.onStorageError(result.error);
153
293
  return null;
154
294
  }
155
295
 
@@ -157,27 +297,190 @@ export const createRelayStorage =
157
297
  },
158
298
 
159
299
  deleteOwner: (ownerId) => {
160
- const result = deps.sqlite.transaction(() => {
161
- const del1 = deps.sqlite.exec(sql`
300
+ const transactionResult = deps.sqlite.transaction(() => {
301
+ const deleteWriteKey = deps.sqlite.exec(sql`
162
302
  delete from evolu_writeKey where ownerId = ${ownerId};
163
303
  `);
164
- if (!del1.ok) return del1;
304
+ if (!deleteWriteKey.ok) return deleteWriteKey;
165
305
 
166
- const del2 = deps.sqlite.exec(sql`
306
+ const deleteMessages = deps.sqlite.exec(sql`
167
307
  delete from evolu_message where ownerId = ${ownerId};
168
308
  `);
169
- if (!del2.ok) return del2;
309
+ if (!deleteMessages.ok) return deleteMessages;
170
310
 
171
- const del3 = sqliteStorageBase.value.deleteOwner(ownerId);
172
- if (!del3) return err(null);
311
+ const deleteUsage = deps.sqlite.exec(sql`
312
+ delete from evolu_usage where ownerId = ${ownerId};
313
+ `);
314
+ if (!deleteUsage.ok) return deleteUsage;
315
+
316
+ const deleteBaseOwner = sqliteStorageBase.deleteOwner(ownerId);
317
+ if (!deleteBaseOwner) return err(null);
173
318
 
174
319
  return ok();
175
320
  });
176
- if (!result.ok) {
177
- if (result.error) options.onStorageError(result.error);
321
+
322
+ if (!transactionResult.ok) {
323
+ if (transactionResult.error)
324
+ config.onStorageError(transactionResult.error);
178
325
  return false;
179
326
  }
327
+
180
328
  return true;
181
329
  },
182
- });
330
+ };
183
331
  };
332
+
333
+ export const createRelayStorageTables = (
334
+ deps: SqliteDep,
335
+ ): Result<void, SqliteError> => {
336
+ for (const query of [
337
+ sql`
338
+ create table evolu_writeKey (
339
+ "ownerId" blob not null,
340
+ "writeKey" blob not null,
341
+ primary key ("ownerId")
342
+ )
343
+ strict;
344
+ `,
345
+
346
+ sql`
347
+ create table evolu_message (
348
+ "ownerId" blob not null,
349
+ "timestamp" blob not null,
350
+ "change" blob not null,
351
+ primary key ("ownerId", "timestamp")
352
+ )
353
+ strict;
354
+ `,
355
+ ]) {
356
+ const result = deps.sqlite.exec(query);
357
+ if (!result.ok) return result;
358
+ }
359
+
360
+ return ok();
361
+ };
362
+
363
+ export interface RelayLogger {
364
+ readonly started: (enableLogging: boolean, port: number) => void;
365
+ readonly storageError: (error: unknown) => void;
366
+ readonly upgradeSocketError: (error: Error) => void;
367
+ readonly invalidOrMissingOwnerIdInUrl: (url: string | undefined) => void;
368
+ readonly unauthorizedOwner: (ownerId: OwnerId) => void;
369
+ readonly connectionEstablished: (totalConnectionCount: number) => void;
370
+ readonly connectionWebSocketError: (error: Error) => void;
371
+ readonly relayOptionSubscribe: (
372
+ ownerId: OwnerId,
373
+ getSubscriberCount: LazyValue<number>,
374
+ ) => void;
375
+ readonly relayOptionUnsubscribe: (
376
+ ownerId: OwnerId,
377
+ getSubscriberCount: LazyValue<number>,
378
+ ) => void;
379
+ readonly relayOptionBroadcast: (
380
+ ownerId: OwnerId,
381
+ broadcastCount: number,
382
+ subscriberCount: number,
383
+ ) => void;
384
+ readonly messageLength: (messageLength: number) => void;
385
+ readonly applyProtocolMessageAsRelayError: (
386
+ error: ProtocolInvalidDataError,
387
+ ) => void;
388
+ readonly responseLength: (responseLength: number) => void;
389
+ readonly applyProtocolMessageAsRelayUnknownError: (error: unknown) => void;
390
+ readonly connectionClosed: (totalConnectionCount: number) => void;
391
+ readonly shuttingDown: () => void;
392
+ readonly webSocketServerDisposed: () => void;
393
+ readonly httpServerDisposed: () => void;
394
+ }
395
+
396
+ export const createRelayLogger = (deps: ConsoleDep): RelayLogger => ({
397
+ started: (enableLogging, port) => {
398
+ deps.console.enabled = true;
399
+ deps.console.log(`Evolu Relay started on port ${port}`);
400
+ deps.console.enabled = enableLogging;
401
+ },
402
+
403
+ storageError: (error) => {
404
+ deps.console.error("[relay]", "storage", error);
405
+ },
406
+
407
+ upgradeSocketError: (error) => {
408
+ deps.console.warn("[relay]", "socket error", { error });
409
+ },
410
+
411
+ invalidOrMissingOwnerIdInUrl: (url) => {
412
+ deps.console.warn("[relay]", "invalid or missing ownerId in URL", { url });
413
+ },
414
+
415
+ unauthorizedOwner: (ownerId) => {
416
+ deps.console.warn("[relay]", "unauthorized owner", { ownerId });
417
+ },
418
+
419
+ connectionEstablished: (totalConnectionCount) => {
420
+ deps.console.log("[relay]", "connection", { totalConnectionCount });
421
+ },
422
+
423
+ connectionWebSocketError: (error) => {
424
+ deps.console.error("[relay]", "error", { error });
425
+ },
426
+
427
+ relayOptionSubscribe: (ownerId, getSubscriberCount) => {
428
+ if (deps.console.enabled)
429
+ deps.console.log("[relay]", "subscribe", {
430
+ ownerId,
431
+ subscriberCount: getSubscriberCount(),
432
+ });
433
+ },
434
+
435
+ relayOptionUnsubscribe: (ownerId, getSubscriberCount) => {
436
+ if (deps.console.enabled)
437
+ deps.console.log("[relay]", "unsubscribe", {
438
+ ownerId,
439
+ subscriberCount: getSubscriberCount(),
440
+ });
441
+ },
442
+
443
+ relayOptionBroadcast: (ownerId, broadcastCount, totalSubscribers) => {
444
+ deps.console.log("[relay]", "broadcast", {
445
+ ownerId,
446
+ broadcastCount,
447
+ totalSubscribers,
448
+ });
449
+ },
450
+
451
+ messageLength: (messageLength) => {
452
+ deps.console.log("[relay]", "on message", { messageLength });
453
+ },
454
+
455
+ applyProtocolMessageAsRelayError: (error) => {
456
+ deps.console.error("[relay]", "applyProtocolMessageAsRelay", error);
457
+ },
458
+
459
+ responseLength: (responseLength) => {
460
+ deps.console.log("[relay]", "responseLength", { responseLength });
461
+ },
462
+
463
+ applyProtocolMessageAsRelayUnknownError: (error) => {
464
+ deps.console.error(
465
+ "[relay]",
466
+ "applyProtocolMessageAsRelayUnknownError",
467
+ error,
468
+ );
469
+ },
470
+
471
+ connectionClosed: (totalConnectionCount) => {
472
+ deps.console.log("[relay]", "close", { totalConnectionCount });
473
+ },
474
+
475
+ shuttingDown: () => {
476
+ deps.console.log("Shutting down Evolu Relay...");
477
+ },
478
+
479
+ webSocketServerDisposed: () => {
480
+ deps.console.log("Evolu Relay WebSocketServer disposed");
481
+ },
482
+
483
+ httpServerDisposed: () => {
484
+ deps.console.log("Evolu Relay HTTP server disposed");
485
+ },
486
+ });