@evolu/common 6.0.1-preview.28 → 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.
- package/dist/src/Array.d.ts +58 -5
- package/dist/src/Array.d.ts.map +1 -1
- package/dist/src/Array.js +53 -5
- package/dist/src/Evolu/Evolu.d.ts +3 -3
- package/dist/src/Evolu/Evolu.d.ts.map +1 -1
- package/dist/src/Evolu/Evolu.js +3 -3
- package/dist/src/Evolu/Owner.d.ts +48 -19
- package/dist/src/Evolu/Owner.d.ts.map +1 -1
- package/dist/src/Evolu/Owner.js +11 -2
- package/dist/src/Evolu/Protocol.d.ts +31 -31
- package/dist/src/Evolu/Protocol.d.ts.map +1 -1
- package/dist/src/Evolu/Protocol.js +51 -28
- package/dist/src/Evolu/Relay.d.ts +40 -25
- package/dist/src/Evolu/Relay.d.ts.map +1 -1
- package/dist/src/Evolu/Relay.js +106 -49
- package/dist/src/Evolu/Storage.d.ts +59 -12
- package/dist/src/Evolu/Storage.d.ts.map +1 -1
- package/dist/src/Evolu/Storage.js +77 -50
- package/dist/src/Evolu/Sync.d.ts.map +1 -1
- package/dist/src/Evolu/Sync.js +14 -5
- package/dist/src/Evolu/Timestamp.d.ts +25 -0
- package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
- package/dist/src/Evolu/Timestamp.js +25 -0
- package/dist/src/Instances.d.ts +34 -0
- package/dist/src/Instances.d.ts.map +1 -0
- package/dist/src/{Multiton.js → Instances.js} +20 -9
- package/dist/src/Sqlite.d.ts +6 -0
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +6 -0
- package/dist/src/Task.d.ts +75 -0
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +29 -6
- package/dist/src/Time.d.ts +7 -1
- package/dist/src/Time.d.ts.map +1 -1
- package/dist/src/Time.js +13 -2
- package/dist/src/Type.d.ts +56 -9
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +40 -8
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/package.json +1 -1
- package/src/Array.ts +76 -11
- package/src/Evolu/Evolu.ts +4 -5
- package/src/Evolu/Owner.ts +75 -26
- package/src/Evolu/Protocol.ts +90 -61
- package/src/Evolu/Relay.ts +182 -77
- package/src/Evolu/Storage.ts +157 -67
- package/src/Evolu/Sync.ts +18 -6
- package/src/Evolu/Timestamp.ts +25 -0
- package/src/Instances.ts +90 -0
- package/src/Sqlite.ts +6 -0
- package/src/Task.ts +88 -7
- package/src/Time.ts +13 -2
- package/src/Type.ts +56 -9
- package/src/index.ts +1 -1
- package/dist/src/Multiton.d.ts +0 -50
- package/dist/src/Multiton.d.ts.map +0 -1
- package/src/Multiton.ts +0 -98
package/src/Evolu/Relay.ts
CHANGED
|
@@ -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 {
|
|
8
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
36
|
-
*
|
|
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
|
|
40
|
-
*
|
|
41
|
-
* into the generic relay interface.
|
|
48
|
+
* handling and logging are the responsibility of the callback
|
|
49
|
+
* implementation.
|
|
42
50
|
*
|
|
43
|
-
* OwnerId is used
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
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
|
-
(
|
|
72
|
-
const sqliteStorageBase = createBaseSqliteStorage(deps)(
|
|
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
|
-
|
|
128
|
+
config.onStorageError(selectWriteKey.error);
|
|
96
129
|
return false;
|
|
97
130
|
}
|
|
98
131
|
|
|
99
132
|
const { rows } = selectWriteKey.value;
|
|
100
133
|
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
158
|
+
config.onStorageError(upsertWriteKey.error);
|
|
126
159
|
return false;
|
|
127
160
|
}
|
|
128
161
|
|
|
129
162
|
return true;
|
|
130
163
|
},
|
|
131
164
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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 (!
|
|
154
|
-
}
|
|
155
|
-
return ok();
|
|
156
|
-
});
|
|
202
|
+
if (!storedBytesResult.ok) return storedBytesResult;
|
|
157
203
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
200
|
-
|
|
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
|
},
|