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

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 +58 -5
  2. package/dist/src/Array.d.ts.map +1 -1
  3. package/dist/src/Array.js +53 -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 +158 -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 +264 -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 +243 -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 +212 -26
  61. package/dist/src/Evolu/Storage.d.ts.map +1 -1
  62. package/dist/src/Evolu/Storage.js +137 -79
  63. package/dist/src/Evolu/Sync.d.ts +68 -13
  64. package/dist/src/Evolu/Sync.d.ts.map +1 -1
  65. package/dist/src/Evolu/Sync.js +422 -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 +621 -340
  103. package/dist/src/Type.d.ts.map +1 -1
  104. package/dist/src/Type.js +665 -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 +76 -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 +514 -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 +369 -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 +420 -92
  137. package/src/Evolu/Schema.ts +391 -191
  138. package/src/Evolu/Storage.ts +451 -118
  139. package/src/Evolu/Sync.ts +720 -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 +1083 -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,279 @@
1
+ import { sha256 } from "@noble/hashes/sha2.js";
2
+ import { NonEmptyReadonlyArray } from "../Array.js";
3
+ import { assert } from "../Assert.js";
4
+ import { Brand } from "../Brand.js";
5
+ import { concatBytes } from "../Buffer.js";
6
+ import { decrement } from "../Number.js";
7
+ import { RandomDep } from "../Random.js";
8
+ import { ok, Result } from "../Result.js";
9
+ import { sql, SqliteDep, SqliteError, SqliteValue } from "../Sqlite.js";
10
+ import { MaybeAsync } from "../Task.js";
11
+ import {
12
+ Id,
13
+ Int64String,
14
+ NonNegativeInt,
15
+ object,
16
+ PositiveInt,
17
+ record,
18
+ String,
19
+ } from "../Type.js";
20
+ import {
21
+ BaseOwnerError,
22
+ Owner,
23
+ OwnerId,
24
+ OwnerIdBytes,
25
+ ownerIdBytesToOwnerId,
26
+ OwnerWriteKey,
27
+ } from "./Owner.js";
28
+ import { orderTimestampBytes, Timestamp, TimestampBytes } from "./Timestamp.js";
29
+
30
+ export interface StorageConfig {
31
+ /**
32
+ * Callback called before an attempt to write, to check if an {@link OwnerId}
33
+ * has sufficient quota for the write.
34
+ *
35
+ * The callback receives the {@link OwnerId} and the number of bytes required
36
+ * for the write, and returns a {@link MaybeAsync} boolean: `true` to allow the
37
+ * write, or `false` to deny it due to quota limits.
38
+ *
39
+ * The callback can be synchronous (for SQLite or in-memory checks) or
40
+ * asynchronous (for calling remote APIs).
41
+ *
42
+ * The callback returns a boolean rather than an error type because error
43
+ * handling and logging are the responsibility of the callback
44
+ * implementation.
45
+ *
46
+ * ### Example
47
+ *
48
+ * ```ts
49
+ * // Client
50
+ * // evolu.subscribeError
51
+ *
52
+ * // Relay
53
+ * isOwnerWithinQuota: (ownerId, requiredBytes) => {
54
+ * console.log(ownerId, requiredBytes);
55
+ * // Check error via evolu.subscribeError
56
+ * return true;
57
+ * };
58
+ * ```
59
+ */
60
+ readonly isOwnerWithinQuota: (
61
+ ownerId: OwnerId,
62
+ requiredBytes: PositiveInt,
63
+ ) => MaybeAsync<boolean>;
64
+ }
65
+
66
+ /**
67
+ * Evolu Storage
68
+ *
69
+ * Evolu protocol using Storage is agnostic to storage implementation
70
+ * details—any storage can be plugged in, as long as it implements this
71
+ * interface. Implementations must handle their own errors; return values only
72
+ * indicate overall success or failure.
73
+ *
74
+ * The Storage API is synchronous because SQLite's synchronous API is the
75
+ * fastest way to use SQLite. Synchronous bindings (like better-sqlite3) call
76
+ * SQLite's C API directly with no context switching between the event loop and
77
+ * native code, and no promise microtasks or await overhead.
78
+ *
79
+ * The only exception is {@link Storage#writeMessages}, which is async to allow
80
+ * for async validation logic before writing to storage. The write operation
81
+ * itself remains synchronous.
82
+ */
83
+ export interface Storage {
84
+ readonly getSize: (ownerId: OwnerIdBytes) => NonNegativeInt | null;
85
+
86
+ readonly fingerprint: (
87
+ ownerId: OwnerIdBytes,
88
+ begin: NonNegativeInt,
89
+ end: NonNegativeInt,
90
+ ) => Fingerprint | null;
91
+
92
+ /**
93
+ * Computes fingerprints with their upper bounds in one call.
94
+ *
95
+ * This function can be replaced with many fingerprint/findLowerBound calls,
96
+ * but implementations can leverage it for batching and more efficient
97
+ * fingerprint computation.
98
+ */
99
+ readonly fingerprintRanges: (
100
+ ownerId: OwnerIdBytes,
101
+ buckets: ReadonlyArray<NonNegativeInt>,
102
+ upperBound?: RangeUpperBound,
103
+ ) => ReadonlyArray<FingerprintRange> | null;
104
+
105
+ readonly findLowerBound: (
106
+ ownerId: OwnerIdBytes,
107
+ begin: NonNegativeInt,
108
+ end: NonNegativeInt,
109
+ upperBound: RangeUpperBound,
110
+ ) => NonNegativeInt | null;
111
+
112
+ readonly iterate: (
113
+ ownerId: OwnerIdBytes,
114
+ begin: NonNegativeInt,
115
+ end: NonNegativeInt,
116
+ callback: (timestamp: TimestampBytes, index: NonNegativeInt) => boolean,
117
+ ) => void;
118
+
119
+ /**
120
+ * Validates the {@link OwnerWriteKey} for the given {@link Owner}.
121
+ *
122
+ * Returns `true` if the write key is valid, `false` otherwise.
123
+ */
124
+ readonly validateWriteKey: (
125
+ ownerId: OwnerIdBytes,
126
+ writeKey: OwnerWriteKey,
127
+ ) => boolean;
128
+
129
+ /** Sets the {@link OwnerWriteKey} for the given {@link Owner}. */
130
+ readonly setWriteKey: (
131
+ ownerId: OwnerIdBytes,
132
+ writeKey: OwnerWriteKey,
133
+ ) => boolean;
134
+
135
+ /**
136
+ * Write encrypted {@link CrdtMessage}s to storage.
137
+ *
138
+ * Must use a mutex per ownerId to ensure sequential processing and proper
139
+ * protocol logic handling during sync operations.
140
+ *
141
+ * TODO: Use MaybeAsync
142
+ */
143
+ readonly writeMessages: (
144
+ ownerIdBytes: OwnerIdBytes,
145
+ messages: NonEmptyReadonlyArray<EncryptedCrdtMessage>,
146
+ ) => MaybeAsync<Result<void, StorageWriteError | StorageQuotaError>>;
147
+
148
+ /** Read encrypted {@link DbChange}s from storage. */
149
+ readonly readDbChange: (
150
+ ownerId: OwnerIdBytes,
151
+ timestamp: TimestampBytes,
152
+ ) => EncryptedDbChange | null;
153
+
154
+ /**
155
+ * Delete all data for the given {@link Owner}.
156
+ *
157
+ * Returns `true` on success, `false` on failure.
158
+ */
159
+ readonly deleteOwner: (ownerId: OwnerIdBytes) => boolean;
160
+ }
161
+
162
+ export interface StorageDep {
163
+ readonly storage: Storage;
164
+ }
165
+
166
+ /** Error indicating a serious write failure. */
167
+ export interface StorageWriteError extends BaseOwnerError {
168
+ readonly type: "StorageWriteError";
169
+ }
170
+
171
+ /** Error when storage or billing quota is exceeded. */
172
+ export interface StorageQuotaError extends BaseOwnerError {
173
+ readonly type: "StorageQuotaError";
174
+ }
175
+
1
176
  /**
2
- * Evolu Storage for SQLite
177
+ * A cryptographic hash used for efficiently comparing collections of
178
+ * {@link TimestampBytes}s.
179
+ *
180
+ * It consists of the first {@link fingerprintSize} bytes of the SHA-256 hash of
181
+ * one or more timestamps.
182
+ */
183
+ export type Fingerprint = Uint8Array & Brand<"Fingerprint">;
184
+
185
+ export const fingerprintSize = NonNegativeInt.orThrow(12);
186
+
187
+ /** A fingerprint of an empty range. */
188
+ export const zeroFingerprint = new Uint8Array(fingerprintSize) as Fingerprint;
189
+
190
+ export interface BaseRange {
191
+ readonly upperBound: RangeUpperBound;
192
+ }
193
+
194
+ /**
195
+ * Union type for Range's upperBound: either a {@link TimestampBytes} or
196
+ * {@link InfiniteUpperBound}.
197
+ */
198
+ export type RangeUpperBound = TimestampBytes | InfiniteUpperBound;
199
+
200
+ export const InfiniteUpperBound = Symbol("InfiniteUpperBound");
201
+ export type InfiniteUpperBound = typeof InfiniteUpperBound;
202
+
203
+ export const RangeType = {
204
+ Fingerprint: 1,
205
+ Skip: 0,
206
+ Timestamps: 2,
207
+ } as const;
208
+
209
+ export type RangeType = (typeof RangeType)[keyof typeof RangeType];
210
+
211
+ export interface SkipRange extends BaseRange {
212
+ readonly type: typeof RangeType.Skip;
213
+ }
214
+
215
+ export interface FingerprintRange extends BaseRange {
216
+ readonly type: typeof RangeType.Fingerprint;
217
+ readonly fingerprint: Fingerprint;
218
+ }
219
+
220
+ export interface TimestampsRange extends BaseRange {
221
+ readonly type: typeof RangeType.Timestamps;
222
+ readonly timestamps: ReadonlyArray<TimestampBytes>;
223
+ }
224
+
225
+ export type Range = SkipRange | FingerprintRange | TimestampsRange;
226
+
227
+ /** An encrypted {@link CrdtMessage}. */
228
+ export interface EncryptedCrdtMessage {
229
+ readonly timestamp: Timestamp;
230
+ readonly change: EncryptedDbChange;
231
+ }
232
+
233
+ /** Encrypted DbChange */
234
+ export type EncryptedDbChange = Uint8Array & Brand<"EncryptedDbChange">;
235
+
236
+ /**
237
+ * A CRDT message combining a unique {@link Timestamp} with a {@link DbChange}.
238
+ *
239
+ * Used in Evolu's sync protocol to replicate data changes across devices. Evolu
240
+ * operates as a durable queue, providing exactly-once delivery guarantees for
241
+ * reliable synchronization across application restarts and network failures.
242
+ */
243
+ export interface CrdtMessage {
244
+ readonly timestamp: Timestamp;
245
+ readonly change: DbChange;
246
+ }
247
+
248
+ /**
249
+ * A DbChange is a change to a table row. Together with a unique
250
+ * {@link Timestamp}, it forms a {@link CrdtMessage}.
251
+ */
252
+ export const DbChange = object({
253
+ table: String,
254
+ id: Id,
255
+ values: record(String, SqliteValue),
256
+ });
257
+ export type DbChange = typeof DbChange.Type;
258
+
259
+ /**
260
+ * Common interface for both client and relay SQLite storages.
3
261
  *
4
262
  * Evolu uses a Skiplist, which leverages SQLite indexes. The core logic is
5
263
  * implemented in SQL, so it doesn't have to make roundtrips to the DB.
6
264
  *
7
- * The ideal storage for a Relay should use a similar architecture to
265
+ * While the SQL implementation may look sophisticated, it's conceptually simple
266
+ * and LLMs can explain how it works. The Skiplist data structure is well
267
+ * explained in [this Stack Overflow
268
+ * answer](https://stackoverflow.com/questions/61944198/what-is-a-zip-tree-and-how-does-it-work).
269
+ * The logic resembles [Negentropy's C++
270
+ * storage](https://github.com/hoytech/negentropy), except we use a Skiplist to
271
+ * leverage SQLite indexes, which makes the code simpler.
272
+ *
273
+ * Note: A paid review by the SQLite team is planned, as they use the same
274
+ * algorithm for their rsync tool.
275
+ *
276
+ * The ideal storage for a Relay should use an architecture like
8
277
  * [strfry](https://github.com/hoytech/strfry) (a KV storage), but with Skiplist
9
278
  * to ensure that insertion order doesn't matter (local-first apps can often
10
279
  * write in the past.)
@@ -19,116 +288,66 @@
19
288
  * each other, if necessary. One relay should handle hundreds of thousands of
20
289
  * users, and when it goes down, nothing happens, because it will be
21
290
  * synchronized later.
22
- *
23
- * @module
24
291
  */
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 {
292
+ export interface BaseSqliteStorage
293
+ extends Pick<
294
+ Storage,
295
+ | "getSize"
296
+ | "fingerprint"
297
+ | "fingerprintRanges"
298
+ | "findLowerBound"
299
+ | "iterate"
300
+ | "deleteOwner"
301
+ > {
302
+ /**
303
+ * Inserts a timestamp for an owner into the skiplist-based storage.
304
+ *
305
+ * Must be idempotent - inserting the same timestamp multiple times has no
306
+ * effect after the first insertion. This is crucial for sync reliability as
307
+ * messages may be received and processed multiple times.
308
+ */
50
309
  readonly insertTimestamp: (
51
- ownerId: BinaryOwnerId,
52
- timestamp: BinaryTimestamp,
310
+ ownerId: OwnerIdBytes,
311
+ timestamp: TimestampBytes,
53
312
  ) => Result<void, SqliteError>;
54
313
 
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"];
314
+ /**
315
+ * Efficiently checks which timestamps already exist in the database using a
316
+ * single CTE query instead of N individual queries.
317
+ */
318
+ readonly getExistingTimestamps: (
319
+ ownerIdBytes: OwnerIdBytes,
320
+ timestampsBytes: NonEmptyReadonlyArray<TimestampBytes>,
321
+ ) => Result<ReadonlyArray<TimestampBytes>, SqliteError>;
60
322
  }
61
323
 
62
- export interface SqliteStorageBaseDep {
63
- readonly storage: SqliteStorageBase;
324
+ export interface BaseSqliteStorageDep {
325
+ readonly storage: BaseSqliteStorage;
64
326
  }
65
327
 
66
- export type SqliteStorageDeps = SqliteDep & RandomDep;
328
+ export type SqliteStorageDeps = RandomDep & SqliteDep;
67
329
 
68
- export interface CreateSqliteStorageBaseOptions {
330
+ export interface CreateBaseSqliteStorageConfig extends StorageConfig {
69
331
  onStorageError: (error: SqliteError) => void;
70
332
  }
71
333
 
72
- export const createSqliteStorageBase =
334
+ export const createBaseSqliteStorage =
73
335
  (deps: SqliteStorageDeps) =>
74
- (
75
- options: CreateSqliteStorageBaseOptions,
76
- ): Result<SqliteStorageBase, SqliteError> => {
77
- const createTablesResult = createTables(deps);
78
- if (!createTablesResult.ok) return createTablesResult;
79
-
336
+ (config: CreateBaseSqliteStorageConfig): BaseSqliteStorage => {
337
+ // TODO: Use evolu_usage table.
80
338
  const ownerStats = new Map<
81
339
  OwnerId,
82
340
  {
83
- minT: BinaryTimestamp;
84
- maxT: BinaryTimestamp;
341
+ minT: TimestampBytes;
342
+ maxT: TimestampBytes;
85
343
  }
86
344
  >();
87
345
 
88
- return ok({
89
- insertTimestamp: (ownerId: BinaryOwnerId, timestamp: BinaryTimestamp) => {
90
- const ownerIdString = binaryOwnerIdToOwnerId(ownerId);
91
- const level = randomSkiplistLevel(deps);
92
-
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;
114
-
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
- }
124
-
125
- return insertTimestamp(deps)(ownerId, timestamp, level, strategy);
126
- },
127
-
346
+ return {
128
347
  getSize: (ownerId) => {
129
348
  const size = getSize(deps)(ownerId);
130
349
  if (!size.ok) {
131
- options.onStorageError(size.error);
350
+ config.onStorageError(size.error);
132
351
  return null;
133
352
  }
134
353
  return size.value;
@@ -138,7 +357,7 @@ export const createSqliteStorageBase =
138
357
  assertBeginEnd(begin, end);
139
358
  const result = fingerprint(deps)(ownerId, begin, end);
140
359
  if (!result.ok) {
141
- options.onStorageError(result.error);
360
+ config.onStorageError(result.error);
142
361
  return null;
143
362
  }
144
363
  return result.value;
@@ -147,7 +366,7 @@ export const createSqliteStorageBase =
147
366
  fingerprintRanges: (ownerId, buckets, upperBound) => {
148
367
  const ranges = fingerprintRanges(deps)(ownerId, buckets, upperBound);
149
368
  if (!ranges.ok) {
150
- options.onStorageError(ranges.error);
369
+ config.onStorageError(ranges.error);
151
370
  return null;
152
371
  }
153
372
  return ranges.value;
@@ -161,7 +380,7 @@ export const createSqliteStorageBase =
161
380
  upperBound,
162
381
  );
163
382
  if (!lowerBound.ok) {
164
- options.onStorageError(lowerBound.error);
383
+ config.onStorageError(lowerBound.error);
165
384
  return null;
166
385
  }
167
386
  return lowerBound.value;
@@ -175,7 +394,7 @@ export const createSqliteStorageBase =
175
394
  // This is much faster than SQL limit with offset.
176
395
  const first = getTimestampByIndex(deps)(ownerId, begin);
177
396
  if (!first.ok) {
178
- options.onStorageError(first.error);
397
+ config.onStorageError(first.error);
179
398
  return;
180
399
  }
181
400
 
@@ -192,7 +411,7 @@ export const createSqliteStorageBase =
192
411
  * implementing chunking, be sure to run performance tests (including
193
412
  * fetching one by one).
194
413
  */
195
- const result = deps.sqlite.exec<{ t: BinaryTimestamp }>(sql`
414
+ const result = deps.sqlite.exec<{ t: TimestampBytes }>(sql`
196
415
  select t
197
416
  from evolu_timestamp
198
417
  where ownerId = ${ownerId} and t > ${first.value}
@@ -200,23 +419,105 @@ export const createSqliteStorageBase =
200
419
  limit ${length - 1};
201
420
  `);
202
421
  if (!result.ok) {
203
- options.onStorageError(result.error);
422
+ config.onStorageError(result.error);
204
423
  return;
205
424
  }
206
425
 
207
426
  for (let i = 0; i < result.value.rows.length; i++) {
208
- const index = (begin + 1 + i) as NonNegativeInt;
427
+ const index = NonNegativeInt.orThrow(begin + 1 + i);
209
428
  if (!callback(result.value.rows[i].t, index)) return;
210
429
  }
211
430
  },
212
- });
431
+
432
+ deleteOwner: (ownerId) => {
433
+ const result = deps.sqlite.exec(sql`
434
+ delete from evolu_timestamp where ownerId = ${ownerId};
435
+ `);
436
+ if (!result.ok) {
437
+ config.onStorageError(result.error);
438
+ return false;
439
+ }
440
+ return true;
441
+ },
442
+
443
+ insertTimestamp: (ownerId: OwnerIdBytes, timestamp: TimestampBytes) => {
444
+ const ownerIdString = ownerIdBytesToOwnerId(ownerId);
445
+ const level = randomSkiplistLevel(deps);
446
+
447
+ let stats = ownerStats.get(ownerIdString);
448
+
449
+ if (!stats) {
450
+ const result = deps.sqlite.exec<{
451
+ maxT: TimestampBytes | null;
452
+ minT: TimestampBytes | null;
453
+ }>(sql.prepared`
454
+ select min(t) as minT, max(t) as maxT
455
+ from evolu_timestamp
456
+ where ownerId = ${ownerId};
457
+ `);
458
+ if (!result.ok) return result;
459
+
460
+ stats = {
461
+ minT: result.value.rows[0].minT ?? timestamp,
462
+ maxT: result.value.rows[0].maxT ?? timestamp,
463
+ };
464
+ ownerStats.set(ownerIdString, stats);
465
+ }
466
+
467
+ let strategy: InsertTimestampStrategy;
468
+
469
+ if (orderTimestampBytes(timestamp, stats.maxT) === 1) {
470
+ strategy = "append";
471
+ stats.maxT = timestamp;
472
+ } else if (orderTimestampBytes(timestamp, stats.minT) === -1) {
473
+ strategy = "prepend";
474
+ stats.minT = timestamp;
475
+ } else {
476
+ strategy = "insert";
477
+ }
478
+
479
+ return insertTimestamp(deps)(ownerId, timestamp, level, strategy);
480
+ },
481
+
482
+ getExistingTimestamps: (ownerIdBytes, timestampsBytes) => {
483
+ const concatenatedTimestamps = concatBytes(...timestampsBytes);
484
+
485
+ const result = deps.sqlite.exec<{
486
+ timestampBytes: TimestampBytes;
487
+ }>(sql`
488
+ with recursive
489
+ split_timestamps(timestampBytes, pos) as (
490
+ select
491
+ substr(${concatenatedTimestamps}, 1, 16),
492
+ 17 as pos
493
+ union all
494
+ select
495
+ substr(${concatenatedTimestamps}, pos, 16),
496
+ pos + 16
497
+ from split_timestamps
498
+ where pos <= length(${concatenatedTimestamps})
499
+ )
500
+ select s.timestampBytes
501
+ from
502
+ split_timestamps s
503
+ join evolu_timestamp t
504
+ on t.ownerId = ${ownerIdBytes} and s.timestampBytes = t.t;
505
+ `);
506
+
507
+ if (!result.ok) return result;
508
+
509
+ return ok(result.value.rows.map((row) => row.timestampBytes));
510
+ },
511
+ };
213
512
  };
214
513
 
215
514
  const assertBeginEnd = (begin: NonNegativeInt, end: NonNegativeInt) => {
216
515
  assert(begin <= end, "invalid begin or end");
217
516
  };
218
517
 
219
- const createTables = (deps: SqliteDep): Result<void, SqliteError> => {
518
+ export const createBaseSqliteStorageTables = (
519
+ deps: SqliteDep,
520
+ ): Result<void, SqliteError> => {
220
521
  for (const query of [
221
522
  /**
222
523
  * Creates the `evolu_timestamp` table for storing timestamps of multiple
@@ -229,7 +530,7 @@ const createTables = (deps: SqliteDep): Result<void, SqliteError> => {
229
530
  *
230
531
  * Columns:
231
532
  *
232
- * - `t` – globally unique binary timestamp
533
+ * - `t` – TimestampBytes
233
534
  * - `h1`/`h2` – 12-byte fingerprint split into two integers for fast XOR
234
535
  * - `c` – incremental count
235
536
  * - `l` – Skiplist level (1 to 32)
@@ -241,7 +542,7 @@ const createTables = (deps: SqliteDep): Result<void, SqliteError> => {
241
542
  * enough even without it.
242
543
  */
243
544
  sql`
244
- create table if not exists evolu_timestamp (
545
+ create table evolu_timestamp (
245
546
  "ownerId" blob not null,
246
547
  "t" blob not null,
247
548
  "h1" integer,
@@ -254,7 +555,7 @@ const createTables = (deps: SqliteDep): Result<void, SqliteError> => {
254
555
  `,
255
556
 
256
557
  sql`
257
- create index if not exists evolu_timestamp_index on evolu_timestamp (
558
+ create index evolu_timestamp_index on evolu_timestamp (
258
559
  "ownerId",
259
560
  "l",
260
561
  "t",
@@ -263,6 +564,31 @@ const createTables = (deps: SqliteDep): Result<void, SqliteError> => {
263
564
  "c"
264
565
  );
265
566
  `,
567
+
568
+ /**
569
+ * Creates the `evolu_usage` table for tracking data consumption per owner.
570
+ *
571
+ * Columns:
572
+ *
573
+ * - `ownerId` – OwnerIdBytes (primary key)
574
+ * - `storedBytes` – total bytes stored in database
575
+ * - `receivedBytes` – TODO: Decide how to use
576
+ * - `sentBytes` – TODO: Decide how to use
577
+ * - `firstTimestamp` – TODO: Decide how to use (nullable)
578
+ * - `lastTimestamp` – TODO: Decide how to use (nullable)
579
+ */
580
+ sql`
581
+ create table evolu_usage (
582
+ "ownerId" blob primary key,
583
+ "storedBytes" integer not null
584
+ -- TODO: Decide how to use receivedBytes, sentBytes, firstTimestamp, lastTimestamp
585
+ -- "receivedBytes" integer not null,
586
+ -- "sentBytes" integer not null,
587
+ -- "firstTimestamp" blob,
588
+ -- "lastTimestamp" blob
589
+ )
590
+ strict;
591
+ `,
266
592
  ]) {
267
593
  const result = deps.sqlite.exec(query);
268
594
  if (!result.ok) return result;
@@ -283,13 +609,13 @@ type InsertTimestampStrategy = "append" | "prepend" | "insert";
283
609
  const insertTimestamp =
284
610
  (deps: SqliteDep) =>
285
611
  (
286
- ownerId: BinaryOwnerId,
287
- timestamp: BinaryTimestamp,
612
+ ownerId: OwnerIdBytes,
613
+ timestamp: TimestampBytes,
288
614
  level: PositiveInt,
289
615
  strategy: InsertTimestampStrategy,
290
616
  ): Result<void, SqliteError> => {
291
617
  const [h1, h2] = fingerprintToSqliteFingerprint(
292
- binaryTimestampToFingerprint(timestamp),
618
+ timestampBytesToFingerprint(timestamp),
293
619
  );
294
620
 
295
621
  let queries: Array<ReturnType<typeof sql.prepared>> = [];
@@ -807,6 +1133,13 @@ const insertTimestamp =
807
1133
  return ok();
808
1134
  };
809
1135
 
1136
+ export const timestampBytesToFingerprint = (
1137
+ timestamp: TimestampBytes,
1138
+ ): Fingerprint => {
1139
+ const hash = sha256(timestamp).slice(0, fingerprintSize);
1140
+ return hash as Fingerprint;
1141
+ };
1142
+
810
1143
  /**
811
1144
  * Generates a random skiplist level in the range [1, skiplistMaxLevel].
812
1145
  * Probabilistic approach avoids the need for explicit tree balancing.
@@ -819,7 +1152,7 @@ const randomSkiplistLevel = (deps: RandomDep): PositiveInt => {
819
1152
  ) {
820
1153
  level += 1;
821
1154
  }
822
- return level as PositiveInt;
1155
+ return PositiveInt.orThrow(level);
823
1156
  };
824
1157
 
825
1158
  /**
@@ -887,7 +1220,7 @@ const sqliteFingerprintToFingerprint = ([
887
1220
 
888
1221
  const getSize =
889
1222
  (deps: SqliteDep) =>
890
- (ownerId: BinaryOwnerId): Result<NonNegativeInt, SqliteError> => {
1223
+ (ownerId: OwnerIdBytes): Result<NonNegativeInt, SqliteError> => {
891
1224
  const result = deps.sqlite.exec<{ size: NonNegativeInt }>(sql.prepared`
892
1225
  with
893
1226
  ml(ml) as (
@@ -930,7 +1263,7 @@ const getSize =
930
1263
  const findLowerBound =
931
1264
  (deps: SqliteDep) =>
932
1265
  (
933
- ownerId: BinaryOwnerId,
1266
+ ownerId: OwnerIdBytes,
934
1267
  begin: NonNegativeInt,
935
1268
  end: NonNegativeInt,
936
1269
  upperBound: RangeUpperBound,
@@ -942,7 +1275,7 @@ const findLowerBound =
942
1275
  }
943
1276
 
944
1277
  const result = deps.sqlite.exec<{
945
- t: BinaryTimestamp;
1278
+ t: TimestampBytes;
946
1279
  }>(sql.prepared`
947
1280
  select t
948
1281
  from evolu_timestamp
@@ -960,14 +1293,14 @@ const findLowerBound =
960
1293
  if (!count.ok) return count;
961
1294
 
962
1295
  // `decrement` converts a count to an index.
963
- return ok(decrement(count.value) as NonNegativeInt);
1296
+ return ok(NonNegativeInt.orThrow(decrement(count.value)));
964
1297
  };
965
1298
 
966
1299
  const getTimestampCount =
967
1300
  (deps: SqliteDep) =>
968
1301
  (
969
- ownerId: BinaryOwnerId,
970
- timestamp: BinaryTimestamp,
1302
+ ownerId: OwnerIdBytes,
1303
+ timestamp: TimestampBytes,
971
1304
  ): Result<PositiveInt, SqliteError> => {
972
1305
  const result = deps.sqlite.exec<{
973
1306
  count: PositiveInt;
@@ -1024,7 +1357,7 @@ const getTimestampCount =
1024
1357
  const fingerprint =
1025
1358
  (deps: SqliteDep) =>
1026
1359
  (
1027
- ownerId: BinaryOwnerId,
1360
+ ownerId: OwnerIdBytes,
1028
1361
  begin: NonNegativeInt,
1029
1362
  end: NonNegativeInt,
1030
1363
  ): Result<Fingerprint, SqliteError> => {
@@ -1056,14 +1389,14 @@ const fingerprint =
1056
1389
  const fingerprintRanges =
1057
1390
  (deps: SqliteDep) =>
1058
1391
  (
1059
- ownerId: BinaryOwnerId,
1392
+ ownerId: OwnerIdBytes,
1060
1393
  buckets: ReadonlyArray<NonNegativeInt>,
1061
1394
  upperBound: RangeUpperBound = InfiniteUpperBound,
1062
1395
  ): Result<ReadonlyArray<FingerprintRange>, SqliteError> => {
1063
1396
  const bucketsJson = JSON.stringify(buckets);
1064
1397
 
1065
1398
  const result = deps.sqlite.exec<{
1066
- b: BinaryTimestamp | null;
1399
+ b: TimestampBytes | null;
1067
1400
  h1: Int64String;
1068
1401
  h2: Int64String;
1069
1402
  }>(sql.prepared`
@@ -1199,11 +1532,11 @@ const x = (a: string, b: string) => sql.raw(`(${a} | ${b}) - (${a} & ${b})`);
1199
1532
  export const getTimestampByIndex =
1200
1533
  (deps: SqliteDep) =>
1201
1534
  (
1202
- ownerId: BinaryOwnerId,
1535
+ ownerId: OwnerIdBytes,
1203
1536
  index: NonNegativeInt,
1204
- ): Result<BinaryTimestamp, SqliteError> => {
1537
+ ): Result<TimestampBytes, SqliteError> => {
1205
1538
  const result = deps.sqlite.exec<{
1206
- readonly pt: BinaryTimestamp;
1539
+ readonly pt: TimestampBytes;
1207
1540
  }>(sql.prepared`
1208
1541
  with
1209
1542
  fi(b, cl, ic, pt, mt, nt, nc) as (