@evolu/common 6.0.1-preview.3 → 6.0.1-preview.31

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 (173) hide show
  1. package/dist/src/Array.d.ts +69 -5
  2. package/dist/src/Array.d.ts.map +1 -1
  3. package/dist/src/Array.js +64 -5
  4. package/dist/src/Assert.d.ts +6 -16
  5. package/dist/src/Assert.d.ts.map +1 -1
  6. package/dist/src/Assert.js +6 -18
  7. package/dist/src/Brand.d.ts +75 -0
  8. package/dist/src/Brand.d.ts.map +1 -0
  9. package/dist/src/Brand.js +1 -0
  10. package/dist/src/Buffer.d.ts +1 -1
  11. package/dist/src/Buffer.d.ts.map +1 -1
  12. package/dist/src/Buffer.js +8 -7
  13. package/dist/src/Cache.d.ts +44 -0
  14. package/dist/src/Cache.d.ts.map +1 -0
  15. package/dist/src/Cache.js +52 -0
  16. package/dist/src/Callbacks.d.ts +45 -12
  17. package/dist/src/Callbacks.d.ts.map +1 -1
  18. package/dist/src/Callbacks.js +14 -7
  19. package/dist/src/Console.d.ts +31 -6
  20. package/dist/src/Console.d.ts.map +1 -1
  21. package/dist/src/Console.js +72 -9
  22. package/dist/src/Crypto.d.ts +61 -34
  23. package/dist/src/Crypto.d.ts.map +1 -1
  24. package/dist/src/Crypto.js +32 -45
  25. package/dist/src/Evolu/Db.d.ts +161 -65
  26. package/dist/src/Evolu/Db.d.ts.map +1 -1
  27. package/dist/src/Evolu/Db.js +286 -694
  28. package/dist/src/Evolu/Diff.d.ts +3 -3
  29. package/dist/src/Evolu/Diff.d.ts.map +1 -1
  30. package/dist/src/Evolu/Diff.js +7 -5
  31. package/dist/src/Evolu/Evolu.d.ts +208 -133
  32. package/dist/src/Evolu/Evolu.d.ts.map +1 -1
  33. package/dist/src/Evolu/Evolu.js +188 -183
  34. package/dist/src/Evolu/Internal.d.ts +0 -2
  35. package/dist/src/Evolu/Internal.d.ts.map +1 -1
  36. package/dist/src/Evolu/Internal.js +0 -2
  37. package/dist/src/Evolu/LocalAuth.d.ts +150 -0
  38. package/dist/src/Evolu/LocalAuth.d.ts.map +1 -0
  39. package/dist/src/Evolu/LocalAuth.js +174 -0
  40. package/dist/src/Evolu/Owner.d.ts +273 -120
  41. package/dist/src/Evolu/Owner.d.ts.map +1 -1
  42. package/dist/src/Evolu/Owner.js +130 -104
  43. package/dist/src/Evolu/Platform.d.ts +9 -7
  44. package/dist/src/Evolu/Platform.d.ts.map +1 -1
  45. package/dist/src/Evolu/Protocol.d.ts +277 -232
  46. package/dist/src/Evolu/Protocol.d.ts.map +1 -1
  47. package/dist/src/Evolu/Protocol.js +603 -378
  48. package/dist/src/Evolu/Public.d.ts +6 -8
  49. package/dist/src/Evolu/Public.d.ts.map +1 -1
  50. package/dist/src/Evolu/Public.js +2 -3
  51. package/dist/src/Evolu/PublicKysely.js +3 -3
  52. package/dist/src/Evolu/Query.d.ts +2 -1
  53. package/dist/src/Evolu/Query.d.ts.map +1 -1
  54. package/dist/src/Evolu/Relay.d.ts +92 -7
  55. package/dist/src/Evolu/Relay.d.ts.map +1 -1
  56. package/dist/src/Evolu/Relay.js +238 -76
  57. package/dist/src/Evolu/Schema.d.ts +129 -73
  58. package/dist/src/Evolu/Schema.d.ts.map +1 -1
  59. package/dist/src/Evolu/Schema.js +169 -89
  60. package/dist/src/Evolu/Storage.d.ts +240 -26
  61. package/dist/src/Evolu/Storage.d.ts.map +1 -1
  62. package/dist/src/Evolu/Storage.js +189 -91
  63. package/dist/src/Evolu/Sync.d.ts +67 -13
  64. package/dist/src/Evolu/Sync.d.ts.map +1 -1
  65. package/dist/src/Evolu/Sync.js +441 -20
  66. package/dist/src/Evolu/Timestamp.d.ts +85 -27
  67. package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
  68. package/dist/src/Evolu/Timestamp.js +77 -18
  69. package/dist/src/Identicon.d.ts +35 -0
  70. package/dist/src/Identicon.d.ts.map +1 -0
  71. package/dist/src/Identicon.js +143 -0
  72. package/dist/src/Instances.d.ts +34 -0
  73. package/dist/src/Instances.d.ts.map +1 -0
  74. package/dist/src/Instances.js +44 -0
  75. package/dist/src/ManyToManyMap.d.ts +71 -10
  76. package/dist/src/ManyToManyMap.d.ts.map +1 -1
  77. package/dist/src/ManyToManyMap.js +41 -6
  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/Platform.d.ts +20 -0
  82. package/dist/src/Platform.d.ts.map +1 -0
  83. package/dist/src/Platform.js +22 -0
  84. package/dist/src/Random.d.ts +3 -2
  85. package/dist/src/Random.d.ts.map +1 -1
  86. package/dist/src/Resources.d.ts +118 -0
  87. package/dist/src/Resources.d.ts.map +1 -0
  88. package/dist/src/Resources.js +197 -0
  89. package/dist/src/Result.d.ts +184 -52
  90. package/dist/src/Result.d.ts.map +1 -1
  91. package/dist/src/Result.js +30 -241
  92. package/dist/src/Skiplist.js +2 -1
  93. package/dist/src/Sqlite.d.ts +63 -5
  94. package/dist/src/Sqlite.d.ts.map +1 -1
  95. package/dist/src/Sqlite.js +110 -9
  96. package/dist/src/Task.d.ts +586 -0
  97. package/dist/src/Task.d.ts.map +1 -0
  98. package/dist/src/Task.js +469 -0
  99. package/dist/src/Time.d.ts +66 -1
  100. package/dist/src/Time.d.ts.map +1 -1
  101. package/dist/src/Time.js +99 -5
  102. package/dist/src/Type.d.ts +622 -340
  103. package/dist/src/Type.d.ts.map +1 -1
  104. package/dist/src/Type.js +666 -464
  105. package/dist/src/Types.d.ts +1 -75
  106. package/dist/src/Types.d.ts.map +1 -1
  107. package/dist/src/WebSocket.d.ts +5 -2
  108. package/dist/src/WebSocket.d.ts.map +1 -1
  109. package/dist/src/WebSocket.js +12 -18
  110. package/dist/src/Worker.d.ts +39 -11
  111. package/dist/src/Worker.d.ts.map +1 -1
  112. package/dist/src/Worker.js +22 -4
  113. package/dist/src/index.d.ts +7 -2
  114. package/dist/src/index.d.ts.map +1 -1
  115. package/dist/src/index.js +7 -2
  116. package/package.json +14 -13
  117. package/src/Array.ts +90 -11
  118. package/src/Assert.ts +6 -24
  119. package/src/Brand.ts +75 -0
  120. package/src/Buffer.ts +7 -7
  121. package/src/Cache.ts +85 -0
  122. package/src/Callbacks.ts +62 -22
  123. package/src/Console.ts +91 -11
  124. package/src/Crypto.ts +97 -82
  125. package/src/Evolu/Db.ts +517 -1020
  126. package/src/Evolu/Diff.ts +7 -5
  127. package/src/Evolu/Evolu.ts +464 -355
  128. package/src/Evolu/Internal.ts +0 -2
  129. package/src/Evolu/LocalAuth.ts +463 -0
  130. package/src/Evolu/Owner.ts +355 -228
  131. package/src/Evolu/Platform.ts +9 -9
  132. package/src/Evolu/Protocol.ts +859 -676
  133. package/src/Evolu/Public.ts +7 -14
  134. package/src/Evolu/PublicKysely.ts +3 -3
  135. package/src/Evolu/Query.ts +2 -1
  136. package/src/Evolu/Relay.ts +437 -93
  137. package/src/Evolu/Schema.ts +391 -191
  138. package/src/Evolu/Storage.ts +532 -135
  139. package/src/Evolu/Sync.ts +766 -37
  140. package/src/Evolu/Timestamp.ts +88 -35
  141. package/src/Identicon.ts +197 -0
  142. package/src/Instances.ts +90 -0
  143. package/src/ManyToManyMap.ts +124 -24
  144. package/src/Number.ts +6 -10
  145. package/src/Platform.ts +26 -0
  146. package/src/Random.ts +3 -2
  147. package/src/Resources.ts +367 -0
  148. package/src/Result.ts +191 -54
  149. package/src/Skiplist.ts +1 -1
  150. package/src/Sqlite.ts +122 -17
  151. package/src/Task.ts +901 -0
  152. package/src/Time.ts +180 -5
  153. package/src/Type.ts +1084 -727
  154. package/src/Types.ts +1 -77
  155. package/src/WebSocket.ts +27 -25
  156. package/src/Worker.ts +72 -23
  157. package/src/index.ts +7 -2
  158. package/dist/src/Evolu/Config.d.ts +0 -69
  159. package/dist/src/Evolu/Config.d.ts.map +0 -1
  160. package/dist/src/Evolu/Config.js +0 -9
  161. package/dist/src/Evolu/Kysely.d.ts +0 -6
  162. package/dist/src/Evolu/Kysely.d.ts.map +0 -1
  163. package/dist/src/Evolu/Kysely.js +0 -21
  164. package/dist/src/NanoId.d.ts +0 -27
  165. package/dist/src/NanoId.d.ts.map +0 -1
  166. package/dist/src/NanoId.js +0 -6
  167. package/dist/src/Promise.d.ts +0 -180
  168. package/dist/src/Promise.d.ts.map +0 -1
  169. package/dist/src/Promise.js +0 -176
  170. package/src/Evolu/Config.ts +0 -83
  171. package/src/Evolu/Kysely.ts +0 -38
  172. package/src/NanoId.ts +0 -39
  173. package/src/Promise.ts +0 -295
@@ -1,10 +1,282 @@
1
+ import { sha256 } from "@noble/hashes/sha2.js";
2
+ import {
3
+ firstInArray,
4
+ isNonEmptyReadonlyArray,
5
+ NonEmptyReadonlyArray,
6
+ } from "../Array.js";
7
+ import { assert } from "../Assert.js";
8
+ import { Brand } from "../Brand.js";
9
+ import { concatBytes } from "../Buffer.js";
10
+ import { decrement } from "../Number.js";
11
+ import { RandomDep } from "../Random.js";
12
+ import { ok, Result } from "../Result.js";
13
+ import { sql, SqliteDep, SqliteError, SqliteValue } from "../Sqlite.js";
14
+ import { MaybeAsync } from "../Task.js";
15
+ import {
16
+ Id,
17
+ Int64String,
18
+ NonNegativeInt,
19
+ object,
20
+ PositiveInt,
21
+ record,
22
+ String,
23
+ } from "../Type.js";
24
+ import {
25
+ BaseOwnerError,
26
+ Owner,
27
+ OwnerId,
28
+ OwnerIdBytes,
29
+ OwnerWriteKey,
30
+ } from "./Owner.js";
31
+ import { orderTimestampBytes, Timestamp, TimestampBytes } from "./Timestamp.js";
32
+
33
+ export interface StorageConfig {
34
+ /**
35
+ * Callback called before an attempt to write, to check if an {@link OwnerId}
36
+ * has sufficient quota for the write.
37
+ *
38
+ * The callback receives the {@link OwnerId} and the total bytes that would be
39
+ * stored after the write (current stored bytes plus incoming bytes), and
40
+ * returns a {@link MaybeAsync} boolean: `true` to allow the write, or `false`
41
+ * to deny it due to quota limits.
42
+ *
43
+ * The callback can be synchronous (for SQLite or in-memory checks) or
44
+ * asynchronous (for calling remote APIs).
45
+ *
46
+ * The callback returns a boolean rather than an error because error handling
47
+ * and logging are the responsibility of the callback implementation.
48
+ *
49
+ * ### Example
50
+ *
51
+ * ```ts
52
+ * // Client
53
+ * // evolu.subscribeError
54
+ *
55
+ * // Relay
56
+ * isOwnerWithinQuota: (ownerId, requiredBytes) => {
57
+ * console.log(ownerId, requiredBytes);
58
+ * // Check error via evolu.subscribeError
59
+ * return true;
60
+ * };
61
+ * ```
62
+ */
63
+ readonly isOwnerWithinQuota: (
64
+ ownerId: OwnerId,
65
+ requiredBytes: PositiveInt,
66
+ ) => MaybeAsync<boolean>;
67
+ }
68
+
1
69
  /**
2
- * Evolu Storage for SQLite
70
+ * Evolu Storage
71
+ *
72
+ * Evolu protocol using Storage is agnostic to storage implementation
73
+ * details—any storage can be plugged in, as long as it implements this
74
+ * interface. Implementations must handle their own errors; return values only
75
+ * indicate overall success or failure.
76
+ *
77
+ * The Storage API is synchronous because SQLite's synchronous API is the
78
+ * fastest way to use SQLite. Synchronous bindings (like better-sqlite3) call
79
+ * SQLite's C API directly with no context switching between the event loop and
80
+ * native code, and no promise microtasks or await overhead.
81
+ *
82
+ * The only exception is {@link Storage#writeMessages}, which is async to allow
83
+ * for async validation logic before writing to storage. The write operation
84
+ * itself remains synchronous.
85
+ */
86
+ export interface Storage {
87
+ readonly getSize: (ownerId: OwnerIdBytes) => NonNegativeInt | null;
88
+
89
+ readonly fingerprint: (
90
+ ownerId: OwnerIdBytes,
91
+ begin: NonNegativeInt,
92
+ end: NonNegativeInt,
93
+ ) => Fingerprint | null;
94
+
95
+ /**
96
+ * Computes fingerprints with their upper bounds in one call.
97
+ *
98
+ * This function can be replaced with many fingerprint/findLowerBound calls,
99
+ * but implementations can leverage it for batching and more efficient
100
+ * fingerprint computation.
101
+ */
102
+ readonly fingerprintRanges: (
103
+ ownerId: OwnerIdBytes,
104
+ buckets: ReadonlyArray<NonNegativeInt>,
105
+ upperBound?: RangeUpperBound,
106
+ ) => ReadonlyArray<FingerprintRange> | null;
107
+
108
+ readonly findLowerBound: (
109
+ ownerId: OwnerIdBytes,
110
+ begin: NonNegativeInt,
111
+ end: NonNegativeInt,
112
+ upperBound: RangeUpperBound,
113
+ ) => NonNegativeInt | null;
114
+
115
+ readonly iterate: (
116
+ ownerId: OwnerIdBytes,
117
+ begin: NonNegativeInt,
118
+ end: NonNegativeInt,
119
+ callback: (timestamp: TimestampBytes, index: NonNegativeInt) => boolean,
120
+ ) => void;
121
+
122
+ /**
123
+ * Validates the {@link OwnerWriteKey} for the given {@link Owner}.
124
+ *
125
+ * Returns `true` if the write key is valid, `false` otherwise.
126
+ */
127
+ readonly validateWriteKey: (
128
+ ownerId: OwnerIdBytes,
129
+ writeKey: OwnerWriteKey,
130
+ ) => boolean;
131
+
132
+ /** Sets the {@link OwnerWriteKey} for the given {@link Owner}. */
133
+ readonly setWriteKey: (
134
+ ownerId: OwnerIdBytes,
135
+ writeKey: OwnerWriteKey,
136
+ ) => boolean;
137
+
138
+ /**
139
+ * Write encrypted {@link CrdtMessage}s to storage.
140
+ *
141
+ * Must use a mutex per ownerId to ensure sequential processing and proper
142
+ * protocol logic handling during sync operations.
143
+ *
144
+ * TODO: Use MaybeAsync
145
+ */
146
+ readonly writeMessages: (
147
+ ownerIdBytes: OwnerIdBytes,
148
+ messages: NonEmptyReadonlyArray<EncryptedCrdtMessage>,
149
+ ) => MaybeAsync<Result<void, StorageWriteError | StorageQuotaError>>;
150
+
151
+ /** Read encrypted {@link DbChange}s from storage. */
152
+ readonly readDbChange: (
153
+ ownerId: OwnerIdBytes,
154
+ timestamp: TimestampBytes,
155
+ ) => EncryptedDbChange | null;
156
+
157
+ /**
158
+ * Delete all data for the given {@link Owner}.
159
+ *
160
+ * Returns `true` on success, `false` on failure.
161
+ */
162
+ readonly deleteOwner: (ownerId: OwnerIdBytes) => boolean;
163
+ }
164
+
165
+ export interface StorageDep {
166
+ readonly storage: Storage;
167
+ }
168
+
169
+ /** Error indicating a serious write failure. */
170
+ export interface StorageWriteError extends BaseOwnerError {
171
+ readonly type: "StorageWriteError";
172
+ }
173
+
174
+ /** Error when storage or billing quota is exceeded. */
175
+ export interface StorageQuotaError extends BaseOwnerError {
176
+ readonly type: "StorageQuotaError";
177
+ }
178
+
179
+ /**
180
+ * A cryptographic hash used for efficiently comparing collections of
181
+ * {@link TimestampBytes}s.
182
+ *
183
+ * It consists of the first {@link fingerprintSize} bytes of the SHA-256 hash of
184
+ * one or more timestamps.
185
+ */
186
+ export type Fingerprint = Uint8Array & Brand<"Fingerprint">;
187
+
188
+ export const fingerprintSize = NonNegativeInt.orThrow(12);
189
+
190
+ /** A fingerprint of an empty range. */
191
+ export const zeroFingerprint = new Uint8Array(fingerprintSize) as Fingerprint;
192
+
193
+ export interface BaseRange {
194
+ readonly upperBound: RangeUpperBound;
195
+ }
196
+
197
+ /**
198
+ * Union type for Range's upperBound: either a {@link TimestampBytes} or
199
+ * {@link InfiniteUpperBound}.
200
+ */
201
+ export type RangeUpperBound = TimestampBytes | InfiniteUpperBound;
202
+
203
+ export const InfiniteUpperBound = Symbol("InfiniteUpperBound");
204
+ export type InfiniteUpperBound = typeof InfiniteUpperBound;
205
+
206
+ export const RangeType = {
207
+ Fingerprint: 1,
208
+ Skip: 0,
209
+ Timestamps: 2,
210
+ } as const;
211
+
212
+ export type RangeType = (typeof RangeType)[keyof typeof RangeType];
213
+
214
+ export interface SkipRange extends BaseRange {
215
+ readonly type: typeof RangeType.Skip;
216
+ }
217
+
218
+ export interface FingerprintRange extends BaseRange {
219
+ readonly type: typeof RangeType.Fingerprint;
220
+ readonly fingerprint: Fingerprint;
221
+ }
222
+
223
+ export interface TimestampsRange extends BaseRange {
224
+ readonly type: typeof RangeType.Timestamps;
225
+ readonly timestamps: ReadonlyArray<TimestampBytes>;
226
+ }
227
+
228
+ export type Range = SkipRange | FingerprintRange | TimestampsRange;
229
+
230
+ /** An encrypted {@link CrdtMessage}. */
231
+ export interface EncryptedCrdtMessage {
232
+ readonly timestamp: Timestamp;
233
+ readonly change: EncryptedDbChange;
234
+ }
235
+
236
+ /** Encrypted DbChange */
237
+ export type EncryptedDbChange = Uint8Array & Brand<"EncryptedDbChange">;
238
+
239
+ /**
240
+ * A CRDT message combining a unique {@link Timestamp} with a {@link DbChange}.
241
+ *
242
+ * Used in Evolu's sync protocol to replicate data changes across devices. Evolu
243
+ * operates as a durable queue, providing exactly-once delivery guarantees for
244
+ * reliable synchronization across application restarts and network failures.
245
+ */
246
+ export interface CrdtMessage {
247
+ readonly timestamp: Timestamp;
248
+ readonly change: DbChange;
249
+ }
250
+
251
+ /**
252
+ * A DbChange is a change to a table row. Together with a unique
253
+ * {@link Timestamp}, it forms a {@link CrdtMessage}.
254
+ */
255
+ export const DbChange = object({
256
+ table: String,
257
+ id: Id,
258
+ values: record(String, SqliteValue),
259
+ });
260
+ export type DbChange = typeof DbChange.Type;
261
+
262
+ /**
263
+ * Common interface for both client and relay SQLite storages.
3
264
  *
4
265
  * Evolu uses a Skiplist, which leverages SQLite indexes. The core logic is
5
266
  * implemented in SQL, so it doesn't have to make roundtrips to the DB.
6
267
  *
7
- * The ideal storage for a Relay should use a similar architecture to
268
+ * While the SQL implementation may look sophisticated, it's conceptually simple
269
+ * and LLMs can explain how it works. The Skiplist data structure is well
270
+ * explained in [this Stack Overflow
271
+ * answer](https://stackoverflow.com/questions/61944198/what-is-a-zip-tree-and-how-does-it-work).
272
+ * The logic resembles [Negentropy's C++
273
+ * storage](https://github.com/hoytech/negentropy), except we use a Skiplist to
274
+ * leverage SQLite indexes, which makes the code simpler.
275
+ *
276
+ * Note: A paid review by the SQLite team is planned, as they use the same
277
+ * algorithm for their rsync tool.
278
+ *
279
+ * The ideal storage for a Relay should use an architecture like
8
280
  * [strfry](https://github.com/hoytech/strfry) (a KV storage), but with Skiplist
9
281
  * to ensure that insertion order doesn't matter (local-first apps can often
10
282
  * write in the past.)
@@ -19,116 +291,104 @@
19
291
  * each other, if necessary. One relay should handle hundreds of thousands of
20
292
  * users, and when it goes down, nothing happens, because it will be
21
293
  * synchronized later.
22
- *
23
- * @module
24
294
  */
25
-
26
- import { assert } from "../Assert.js";
27
- import { decrement } from "../Number.js";
28
- import { RandomDep } from "../Random.js";
29
- import { ok, Result } from "../Result.js";
30
- import { sql, SqliteDep, SqliteError } from "../Sqlite.js";
31
- import { Int64String, NonNegativeInt, PositiveInt } from "../Type.js";
32
- import { Brand } from "../Types.js";
33
- import { OwnerId } from "./Owner.js";
34
- import {
35
- BinaryOwnerId,
36
- binaryOwnerIdToOwnerId,
37
- binaryTimestampToFingerprint,
38
- Fingerprint,
39
- FingerprintRange,
40
- InfiniteUpperBound,
41
- RangeType,
42
- RangeUpperBound,
43
- Storage,
44
- zeroFingerprint,
45
- } from "./Protocol.js";
46
- import { BinaryTimestamp, orderBinaryTimestamp } from "./Timestamp.js";
47
-
48
- /** Common interface for both client and relay SQLite storages. */
49
- export interface SqliteStorageBase {
295
+ export interface BaseSqliteStorage
296
+ extends Pick<
297
+ Storage,
298
+ | "getSize"
299
+ | "fingerprint"
300
+ | "fingerprintRanges"
301
+ | "findLowerBound"
302
+ | "iterate"
303
+ | "deleteOwner"
304
+ > {
305
+ /** Inserts a timestamp for an owner into the skiplist-based storage. */
50
306
  readonly insertTimestamp: (
51
- ownerId: BinaryOwnerId,
52
- timestamp: BinaryTimestamp,
307
+ ownerId: OwnerIdBytes,
308
+ timestamp: TimestampBytes,
309
+ strategy: StorageInsertTimestampStrategy,
53
310
  ) => Result<void, SqliteError>;
54
311
 
55
- readonly getSize: Storage["getSize"];
56
- readonly fingerprint: Storage["fingerprint"];
57
- readonly fingerprintRanges: Storage["fingerprintRanges"];
58
- readonly findLowerBound: Storage["findLowerBound"];
59
- readonly iterate: Storage["iterate"];
312
+ /**
313
+ * Efficiently checks which timestamps already exist in the database using a
314
+ * single CTE query instead of N individual queries.
315
+ */
316
+ readonly getExistingTimestamps: (
317
+ ownerIdBytes: OwnerIdBytes,
318
+ timestampsBytes: NonEmptyReadonlyArray<TimestampBytes>,
319
+ ) => Result<ReadonlyArray<TimestampBytes>, SqliteError>;
60
320
  }
61
321
 
62
- export interface SqliteStorageBaseDep {
63
- readonly storage: SqliteStorageBase;
322
+ export interface BaseSqliteStorageDep {
323
+ readonly storage: BaseSqliteStorage;
64
324
  }
65
325
 
66
- export type SqliteStorageDeps = SqliteDep & RandomDep;
326
+ export type SqliteStorageDeps = RandomDep & SqliteDep;
67
327
 
68
- export interface CreateSqliteStorageBaseOptions {
328
+ export interface CreateBaseSqliteStorageConfig extends StorageConfig {
69
329
  onStorageError: (error: SqliteError) => void;
70
330
  }
71
331
 
72
- export const createSqliteStorageBase =
332
+ /**
333
+ * Creates a {@link BaseSqliteStorage} implementation.
334
+ *
335
+ * # Stateless Design
336
+ *
337
+ * This implementation is fully stateless - it requires no in-memory state
338
+ * between invocations. All necessary metadata (timestamp bounds for insertion
339
+ * strategy optimization) is persisted in the evolu_usage table. This makes
340
+ * Evolu Relay suitable for stateless serverless environments like AWS Lambda,
341
+ * Cloudflare Workers with Durable Objects, and other platforms where memory
342
+ * doesn't persist between requests. While not extensively tested in all these
343
+ * environments yet, the stateless design should work well across them.
344
+ */
345
+ export const createBaseSqliteStorage =
73
346
  (deps: SqliteStorageDeps) =>
74
- (
75
- options: CreateSqliteStorageBaseOptions,
76
- ): Result<SqliteStorageBase, SqliteError> => {
77
- const createTablesResult = createTables(deps);
78
- if (!createTablesResult.ok) return createTablesResult;
79
-
80
- const ownerStats = new Map<
81
- OwnerId,
82
- {
83
- minT: BinaryTimestamp;
84
- maxT: BinaryTimestamp;
85
- }
86
- >();
87
-
88
- return ok({
89
- insertTimestamp: (ownerId: BinaryOwnerId, timestamp: BinaryTimestamp) => {
90
- const ownerIdString = binaryOwnerIdToOwnerId(ownerId);
347
+ (config: CreateBaseSqliteStorageConfig): BaseSqliteStorage => {
348
+ return {
349
+ insertTimestamp: (
350
+ ownerId: OwnerIdBytes,
351
+ timestamp: TimestampBytes,
352
+ strategy: StorageInsertTimestampStrategy,
353
+ ) => {
91
354
  const level = randomSkiplistLevel(deps);
355
+ return insertTimestamp(deps)(ownerId, timestamp, level, strategy);
356
+ },
92
357
 
93
- let stats = ownerStats.get(ownerIdString);
94
-
95
- if (!stats) {
96
- const result = deps.sqlite.exec<{
97
- maxT: BinaryTimestamp | null;
98
- minT: BinaryTimestamp | null;
99
- }>(sql.prepared`
100
- select min(t) as minT, max(t) as maxT
101
- from evolu_timestamp
102
- where ownerId = ${ownerId};
103
- `);
104
- if (!result.ok) return result;
105
-
106
- stats = {
107
- minT: result.value.rows[0].minT ?? timestamp,
108
- maxT: result.value.rows[0].maxT ?? timestamp,
109
- };
110
- ownerStats.set(ownerIdString, stats);
111
- }
112
-
113
- let strategy: InsertTimestampStrategy;
358
+ getExistingTimestamps: (ownerIdBytes, timestampsBytes) => {
359
+ const concatenatedTimestamps = concatBytes(...timestampsBytes);
360
+
361
+ const result = deps.sqlite.exec<{
362
+ timestampBytes: TimestampBytes;
363
+ }>(sql`
364
+ with recursive
365
+ split_timestamps(timestampBytes, pos) as (
366
+ select
367
+ substr(${concatenatedTimestamps}, 1, 16),
368
+ 17 as pos
369
+ union all
370
+ select
371
+ substr(${concatenatedTimestamps}, pos, 16),
372
+ pos + 16
373
+ from split_timestamps
374
+ where pos <= length(${concatenatedTimestamps})
375
+ )
376
+ select s.timestampBytes
377
+ from
378
+ split_timestamps s
379
+ join evolu_timestamp t
380
+ on t.ownerId = ${ownerIdBytes} and s.timestampBytes = t.t;
381
+ `);
114
382
 
115
- if (orderBinaryTimestamp(timestamp, stats.maxT) === 1) {
116
- strategy = "append";
117
- stats.maxT = timestamp;
118
- } else if (orderBinaryTimestamp(timestamp, stats.minT) === -1) {
119
- strategy = "prepend";
120
- stats.minT = timestamp;
121
- } else {
122
- strategy = "insert";
123
- }
383
+ if (!result.ok) return result;
124
384
 
125
- return insertTimestamp(deps)(ownerId, timestamp, level, strategy);
385
+ return ok(result.value.rows.map((row) => row.timestampBytes));
126
386
  },
127
387
 
128
388
  getSize: (ownerId) => {
129
389
  const size = getSize(deps)(ownerId);
130
390
  if (!size.ok) {
131
- options.onStorageError(size.error);
391
+ config.onStorageError(size.error);
132
392
  return null;
133
393
  }
134
394
  return size.value;
@@ -138,7 +398,7 @@ export const createSqliteStorageBase =
138
398
  assertBeginEnd(begin, end);
139
399
  const result = fingerprint(deps)(ownerId, begin, end);
140
400
  if (!result.ok) {
141
- options.onStorageError(result.error);
401
+ config.onStorageError(result.error);
142
402
  return null;
143
403
  }
144
404
  return result.value;
@@ -147,7 +407,7 @@ export const createSqliteStorageBase =
147
407
  fingerprintRanges: (ownerId, buckets, upperBound) => {
148
408
  const ranges = fingerprintRanges(deps)(ownerId, buckets, upperBound);
149
409
  if (!ranges.ok) {
150
- options.onStorageError(ranges.error);
410
+ config.onStorageError(ranges.error);
151
411
  return null;
152
412
  }
153
413
  return ranges.value;
@@ -161,7 +421,7 @@ export const createSqliteStorageBase =
161
421
  upperBound,
162
422
  );
163
423
  if (!lowerBound.ok) {
164
- options.onStorageError(lowerBound.error);
424
+ config.onStorageError(lowerBound.error);
165
425
  return null;
166
426
  }
167
427
  return lowerBound.value;
@@ -175,7 +435,7 @@ export const createSqliteStorageBase =
175
435
  // This is much faster than SQL limit with offset.
176
436
  const first = getTimestampByIndex(deps)(ownerId, begin);
177
437
  if (!first.ok) {
178
- options.onStorageError(first.error);
438
+ config.onStorageError(first.error);
179
439
  return;
180
440
  }
181
441
 
@@ -192,7 +452,7 @@ export const createSqliteStorageBase =
192
452
  * implementing chunking, be sure to run performance tests (including
193
453
  * fetching one by one).
194
454
  */
195
- const result = deps.sqlite.exec<{ t: BinaryTimestamp }>(sql`
455
+ const result = deps.sqlite.exec<{ t: TimestampBytes }>(sql`
196
456
  select t
197
457
  from evolu_timestamp
198
458
  where ownerId = ${ownerId} and t > ${first.value}
@@ -200,23 +460,36 @@ export const createSqliteStorageBase =
200
460
  limit ${length - 1};
201
461
  `);
202
462
  if (!result.ok) {
203
- options.onStorageError(result.error);
463
+ config.onStorageError(result.error);
204
464
  return;
205
465
  }
206
466
 
207
467
  for (let i = 0; i < result.value.rows.length; i++) {
208
- const index = (begin + 1 + i) as NonNegativeInt;
468
+ const index = NonNegativeInt.orThrow(begin + 1 + i);
209
469
  if (!callback(result.value.rows[i].t, index)) return;
210
470
  }
211
471
  },
212
- });
472
+
473
+ deleteOwner: (ownerId) => {
474
+ const result = deps.sqlite.exec(sql`
475
+ delete from evolu_timestamp where ownerId = ${ownerId};
476
+ `);
477
+ if (!result.ok) {
478
+ config.onStorageError(result.error);
479
+ return false;
480
+ }
481
+ return true;
482
+ },
483
+ };
213
484
  };
214
485
 
215
486
  const assertBeginEnd = (begin: NonNegativeInt, end: NonNegativeInt) => {
216
487
  assert(begin <= end, "invalid begin or end");
217
488
  };
218
489
 
219
- const createTables = (deps: SqliteDep): Result<void, SqliteError> => {
490
+ export const createBaseSqliteStorageTables = (
491
+ deps: SqliteDep,
492
+ ): Result<void, SqliteError> => {
220
493
  for (const query of [
221
494
  /**
222
495
  * Creates the `evolu_timestamp` table for storing timestamps of multiple
@@ -229,19 +502,13 @@ const createTables = (deps: SqliteDep): Result<void, SqliteError> => {
229
502
  *
230
503
  * Columns:
231
504
  *
232
- * - `t` – globally unique binary timestamp
505
+ * - `t` – TimestampBytes
233
506
  * - `h1`/`h2` – 12-byte fingerprint split into two integers for fast XOR
234
507
  * - `c` – incremental count
235
- * - `l` – Skiplist level (1 to 32)
236
- *
237
- * For scaling or isolation, sharding is possible—each owner can have a
238
- * separate SQLite database.
239
- *
240
- * Maybe we could use an integer surrogate key for ownerId, but it's fast
241
- * enough even without it.
508
+ * - `l` – Skiplist level (1 to 10)
242
509
  */
243
510
  sql`
244
- create table if not exists evolu_timestamp (
511
+ create table evolu_timestamp (
245
512
  "ownerId" blob not null,
246
513
  "t" blob not null,
247
514
  "h1" integer,
@@ -254,7 +521,7 @@ const createTables = (deps: SqliteDep): Result<void, SqliteError> => {
254
521
  `,
255
522
 
256
523
  sql`
257
- create index if not exists evolu_timestamp_index on evolu_timestamp (
524
+ create index evolu_timestamp_index on evolu_timestamp (
258
525
  "ownerId",
259
526
  "l",
260
527
  "t",
@@ -263,6 +530,26 @@ const createTables = (deps: SqliteDep): Result<void, SqliteError> => {
263
530
  "c"
264
531
  );
265
532
  `,
533
+
534
+ /**
535
+ * Creates the `evolu_usage` table for tracking data consumption per owner.
536
+ *
537
+ * Columns:
538
+ *
539
+ * - `ownerId` – OwnerIdBytes (primary key)
540
+ * - `storedBytes` – total bytes stored in database
541
+ * - `firstTimestamp` – for timestamp insertion strategies
542
+ * - `lastTimestamp` – for timestamp insertion strategies
543
+ */
544
+ sql`
545
+ create table evolu_usage (
546
+ "ownerId" blob primary key,
547
+ "storedBytes" integer not null,
548
+ "firstTimestamp" blob,
549
+ "lastTimestamp" blob
550
+ )
551
+ strict;
552
+ `,
266
553
  ]) {
267
554
  const result = deps.sqlite.exec(query);
268
555
  if (!result.ok) return result;
@@ -270,26 +557,56 @@ const createTables = (deps: SqliteDep): Result<void, SqliteError> => {
270
557
  return ok();
271
558
  };
272
559
 
273
- type InsertTimestampStrategy = "append" | "prepend" | "insert";
560
+ export type StorageInsertTimestampStrategy = "append" | "prepend" | "insert";
274
561
 
275
- // AFAIK, we can't do both insert and update in one query, and that's probably
276
- // why append is 2x faster than insert. Prepend also has to update parents, but
277
- // it's constantly fast. Insert degrades for reversed (yet LIMIT X magically
278
- // makes it much faster) but it's OK for append. It's probably because it's the
279
- // most complicated SQL, but I believe it can be simplified. If not, we can
280
- // optimize prepending by reversing the incoming timestamps if we detect that
281
- // they will prepend. They are always sorted in ascending order by the
282
- // Protocol.
562
+ /**
563
+ * Determines the insertion strategy for a timestamp based on its position
564
+ * relative to the current first and last timestamps.
565
+ *
566
+ * Returns a tuple with the strategy and updated timestamp bounds.
567
+ */
568
+ export const getTimestampInsertStrategy = (
569
+ timestamp: TimestampBytes,
570
+ firstTimestamp: TimestampBytes,
571
+ lastTimestamp: TimestampBytes,
572
+ ): [
573
+ strategy: StorageInsertTimestampStrategy,
574
+ firstTimestamp: TimestampBytes,
575
+ lastTimestamp: TimestampBytes,
576
+ ] => {
577
+ if (orderTimestampBytes(timestamp, lastTimestamp) === 1) {
578
+ return ["append", firstTimestamp, timestamp];
579
+ }
580
+ if (orderTimestampBytes(timestamp, firstTimestamp) === -1) {
581
+ return ["prepend", timestamp, lastTimestamp];
582
+ }
583
+ return ["insert", firstTimestamp, lastTimestamp];
584
+ };
585
+
586
+ /**
587
+ * AFAIK, we can't do both insert and update in one query, and that's probably
588
+ * why append is 2x faster than insert. Prepend also has to update parents, but
589
+ * it's constantly fast. Insert degrades for reversed (yet LIMIT X magically
590
+ * fixes that) but it's OK for append.
591
+ *
592
+ * Note: SQL operations are idempotent (using `on conflict do nothing` and
593
+ * `changes() > 0`), but this is no longer required here since we use
594
+ * {@link BaseSqliteStorage.getExistingTimestamps} to filter out duplicates
595
+ * before insertion, which we need for quota checks anyway.
596
+ *
597
+ * TODO: Remove idempotency (`on conflict do nothing` and `changes() > 0`) since
598
+ * duplicates are now filtered before insertion.
599
+ */
283
600
  const insertTimestamp =
284
601
  (deps: SqliteDep) =>
285
602
  (
286
- ownerId: BinaryOwnerId,
287
- timestamp: BinaryTimestamp,
603
+ ownerId: OwnerIdBytes,
604
+ timestamp: TimestampBytes,
288
605
  level: PositiveInt,
289
- strategy: InsertTimestampStrategy,
606
+ strategy: StorageInsertTimestampStrategy,
290
607
  ): Result<void, SqliteError> => {
291
608
  const [h1, h2] = fingerprintToSqliteFingerprint(
292
- binaryTimestampToFingerprint(timestamp),
609
+ timestampBytesToFingerprint(timestamp),
293
610
  );
294
611
 
295
612
  let queries: Array<ReturnType<typeof sql.prepared>> = [];
@@ -807,6 +1124,13 @@ const insertTimestamp =
807
1124
  return ok();
808
1125
  };
809
1126
 
1127
+ export const timestampBytesToFingerprint = (
1128
+ timestamp: TimestampBytes,
1129
+ ): Fingerprint => {
1130
+ const hash = sha256(timestamp).slice(0, fingerprintSize);
1131
+ return hash as Fingerprint;
1132
+ };
1133
+
810
1134
  /**
811
1135
  * Generates a random skiplist level in the range [1, skiplistMaxLevel].
812
1136
  * Probabilistic approach avoids the need for explicit tree balancing.
@@ -819,7 +1143,7 @@ const randomSkiplistLevel = (deps: RandomDep): PositiveInt => {
819
1143
  ) {
820
1144
  level += 1;
821
1145
  }
822
- return level as PositiveInt;
1146
+ return PositiveInt.orThrow(level);
823
1147
  };
824
1148
 
825
1149
  /**
@@ -887,7 +1211,7 @@ const sqliteFingerprintToFingerprint = ([
887
1211
 
888
1212
  const getSize =
889
1213
  (deps: SqliteDep) =>
890
- (ownerId: BinaryOwnerId): Result<NonNegativeInt, SqliteError> => {
1214
+ (ownerId: OwnerIdBytes): Result<NonNegativeInt, SqliteError> => {
891
1215
  const result = deps.sqlite.exec<{ size: NonNegativeInt }>(sql.prepared`
892
1216
  with
893
1217
  ml(ml) as (
@@ -930,7 +1254,7 @@ const getSize =
930
1254
  const findLowerBound =
931
1255
  (deps: SqliteDep) =>
932
1256
  (
933
- ownerId: BinaryOwnerId,
1257
+ ownerId: OwnerIdBytes,
934
1258
  begin: NonNegativeInt,
935
1259
  end: NonNegativeInt,
936
1260
  upperBound: RangeUpperBound,
@@ -942,7 +1266,7 @@ const findLowerBound =
942
1266
  }
943
1267
 
944
1268
  const result = deps.sqlite.exec<{
945
- t: BinaryTimestamp;
1269
+ t: TimestampBytes;
946
1270
  }>(sql.prepared`
947
1271
  select t
948
1272
  from evolu_timestamp
@@ -960,14 +1284,14 @@ const findLowerBound =
960
1284
  if (!count.ok) return count;
961
1285
 
962
1286
  // `decrement` converts a count to an index.
963
- return ok(decrement(count.value) as NonNegativeInt);
1287
+ return ok(NonNegativeInt.orThrow(decrement(count.value)));
964
1288
  };
965
1289
 
966
1290
  const getTimestampCount =
967
1291
  (deps: SqliteDep) =>
968
1292
  (
969
- ownerId: BinaryOwnerId,
970
- timestamp: BinaryTimestamp,
1293
+ ownerId: OwnerIdBytes,
1294
+ timestamp: TimestampBytes,
971
1295
  ): Result<PositiveInt, SqliteError> => {
972
1296
  const result = deps.sqlite.exec<{
973
1297
  count: PositiveInt;
@@ -1024,7 +1348,7 @@ const getTimestampCount =
1024
1348
  const fingerprint =
1025
1349
  (deps: SqliteDep) =>
1026
1350
  (
1027
- ownerId: BinaryOwnerId,
1351
+ ownerId: OwnerIdBytes,
1028
1352
  begin: NonNegativeInt,
1029
1353
  end: NonNegativeInt,
1030
1354
  ): Result<Fingerprint, SqliteError> => {
@@ -1056,14 +1380,14 @@ const fingerprint =
1056
1380
  const fingerprintRanges =
1057
1381
  (deps: SqliteDep) =>
1058
1382
  (
1059
- ownerId: BinaryOwnerId,
1383
+ ownerId: OwnerIdBytes,
1060
1384
  buckets: ReadonlyArray<NonNegativeInt>,
1061
1385
  upperBound: RangeUpperBound = InfiniteUpperBound,
1062
1386
  ): Result<ReadonlyArray<FingerprintRange>, SqliteError> => {
1063
1387
  const bucketsJson = JSON.stringify(buckets);
1064
1388
 
1065
1389
  const result = deps.sqlite.exec<{
1066
- b: BinaryTimestamp | null;
1390
+ b: TimestampBytes | null;
1067
1391
  h1: Int64String;
1068
1392
  h2: Int64String;
1069
1393
  }>(sql.prepared`
@@ -1199,11 +1523,11 @@ const x = (a: string, b: string) => sql.raw(`(${a} | ${b}) - (${a} & ${b})`);
1199
1523
  export const getTimestampByIndex =
1200
1524
  (deps: SqliteDep) =>
1201
1525
  (
1202
- ownerId: BinaryOwnerId,
1526
+ ownerId: OwnerIdBytes,
1203
1527
  index: NonNegativeInt,
1204
- ): Result<BinaryTimestamp, SqliteError> => {
1528
+ ): Result<TimestampBytes, SqliteError> => {
1205
1529
  const result = deps.sqlite.exec<{
1206
- readonly pt: BinaryTimestamp;
1530
+ readonly pt: TimestampBytes;
1207
1531
  }>(sql.prepared`
1208
1532
  with
1209
1533
  fi(b, cl, ic, pt, mt, nt, nc) as (
@@ -1279,3 +1603,76 @@ export const getTimestampByIndex =
1279
1603
  if (!result.ok) return result;
1280
1604
  return ok(result.value.rows[0].pt);
1281
1605
  };
1606
+
1607
+ /** Retrieves usage information for an owner from the evolu_usage table. */
1608
+ export const getOwnerUsage =
1609
+ (deps: SqliteDep) =>
1610
+ (
1611
+ ownerIdBytes: OwnerIdBytes,
1612
+ initialTimestamp: TimestampBytes,
1613
+ ): Result<
1614
+ {
1615
+ storedBytes: NonNegativeInt | null;
1616
+ firstTimestamp: TimestampBytes;
1617
+ lastTimestamp: TimestampBytes;
1618
+ },
1619
+ SqliteError
1620
+ > => {
1621
+ const result = deps.sqlite.exec<{
1622
+ storedBytes: NonNegativeInt;
1623
+ firstTimestamp: TimestampBytes | null;
1624
+ lastTimestamp: TimestampBytes | null;
1625
+ }>(sql`
1626
+ select storedBytes, firstTimestamp, lastTimestamp
1627
+ from evolu_usage
1628
+ where ownerId = ${ownerIdBytes};
1629
+ `);
1630
+ if (!result.ok) return result;
1631
+
1632
+ if (!isNonEmptyReadonlyArray(result.value.rows)) {
1633
+ return ok({
1634
+ storedBytes: null,
1635
+ firstTimestamp: initialTimestamp,
1636
+ lastTimestamp: initialTimestamp,
1637
+ });
1638
+ }
1639
+
1640
+ const row = firstInArray(result.value.rows);
1641
+ assert(row.firstTimestamp, "not null");
1642
+ assert(row.lastTimestamp, "not null");
1643
+
1644
+ return ok({
1645
+ storedBytes: row.storedBytes,
1646
+ firstTimestamp: row.firstTimestamp,
1647
+ lastTimestamp: row.lastTimestamp,
1648
+ });
1649
+ };
1650
+
1651
+ /**
1652
+ * Updates timestamp bounds in evolu_usage table.
1653
+ *
1654
+ * Used by both relay and client to maintain firstTimestamp/lastTimestamp after
1655
+ * processing messages.
1656
+ */
1657
+ export const updateOwnerUsage =
1658
+ (deps: SqliteDep) =>
1659
+ (
1660
+ ownerIdBytes: OwnerIdBytes,
1661
+ storedBytes: PositiveInt,
1662
+ firstTimestamp: TimestampBytes,
1663
+ lastTimestamp: TimestampBytes,
1664
+ ): Result<void, SqliteError> => {
1665
+ const result = deps.sqlite.exec(sql`
1666
+ insert into evolu_usage
1667
+ ("ownerId", "storedBytes", "firstTimestamp", "lastTimestamp")
1668
+ values
1669
+ (${ownerIdBytes}, ${storedBytes}, ${firstTimestamp}, ${lastTimestamp})
1670
+ on conflict (ownerId) do update
1671
+ set
1672
+ storedBytes = ${storedBytes},
1673
+ firstTimestamp = ${firstTimestamp},
1674
+ lastTimestamp = ${lastTimestamp};
1675
+ `);
1676
+ if (!result.ok) return result;
1677
+ return ok();
1678
+ };