@evolu/common 6.0.1-preview.8 → 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 +214 -134
  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 +600 -454
  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 +15 -14
  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 +477 -361
  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 +842 -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
@@ -1,10 +1,319 @@
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 { err, ok, Result } from "../Result.js";
13
+ import { sql, SqliteDep, SqliteError, SqliteValue } from "../Sqlite.js";
14
+ import { MaybeAsync } from "../Task.js";
15
+ import {
16
+ Boolean,
17
+ brand,
18
+ Id,
19
+ Int64String,
20
+ NonNegativeInt,
21
+ nullOr,
22
+ object,
23
+ PositiveInt,
24
+ record,
25
+ String,
26
+ TypeError,
27
+ } from "../Type.js";
28
+ import {
29
+ BaseOwnerError,
30
+ Owner,
31
+ OwnerId,
32
+ OwnerIdBytes,
33
+ OwnerWriteKey,
34
+ } from "./Owner.js";
35
+ import { systemColumns } from "./Schema.js";
36
+ import { orderTimestampBytes, Timestamp, TimestampBytes } from "./Timestamp.js";
37
+
38
+ export interface StorageConfig {
39
+ /**
40
+ * Callback called before an attempt to write, to check if an {@link OwnerId}
41
+ * has sufficient quota for the write.
42
+ *
43
+ * The callback receives the {@link OwnerId} and the total bytes that would be
44
+ * stored after the write (current stored bytes plus incoming bytes), and
45
+ * returns a {@link MaybeAsync} boolean: `true` to allow the write, or `false`
46
+ * to deny it due to quota limits.
47
+ *
48
+ * The callback can be synchronous (for SQLite or in-memory checks) or
49
+ * asynchronous (for calling remote APIs).
50
+ *
51
+ * The callback returns a boolean rather than an error because error handling
52
+ * and logging are the responsibility of the callback implementation.
53
+ *
54
+ * ### Example
55
+ *
56
+ * ```ts
57
+ * // Client
58
+ * // evolu.subscribeError
59
+ *
60
+ * // Relay
61
+ * isOwnerWithinQuota: (ownerId, requiredBytes) => {
62
+ * console.log(ownerId, requiredBytes);
63
+ * // Check error via evolu.subscribeError
64
+ * return true;
65
+ * };
66
+ * ```
67
+ */
68
+ readonly isOwnerWithinQuota: (
69
+ ownerId: OwnerId,
70
+ requiredBytes: PositiveInt,
71
+ ) => MaybeAsync<boolean>;
72
+ }
73
+
74
+ /**
75
+ * Evolu Storage
76
+ *
77
+ * Evolu protocol using Storage is agnostic to storage implementation
78
+ * details—any storage can be plugged in, as long as it implements this
79
+ * interface. Implementations must handle their own errors; return values only
80
+ * indicate overall success or failure.
81
+ *
82
+ * The Storage API is synchronous because SQLite's synchronous API is the
83
+ * fastest way to use SQLite. Synchronous bindings (like better-sqlite3) call
84
+ * SQLite's C API directly with no context switching between the event loop and
85
+ * native code, and no promise microtasks or await overhead.
86
+ *
87
+ * The only exception is {@link Storage#writeMessages}, which is async to allow
88
+ * for async validation logic before writing to storage. The write operation
89
+ * itself remains synchronous.
90
+ */
91
+ export interface Storage {
92
+ readonly getSize: (ownerId: OwnerIdBytes) => NonNegativeInt | null;
93
+
94
+ readonly fingerprint: (
95
+ ownerId: OwnerIdBytes,
96
+ begin: NonNegativeInt,
97
+ end: NonNegativeInt,
98
+ ) => Fingerprint | null;
99
+
100
+ /**
101
+ * Computes fingerprints with their upper bounds in one call.
102
+ *
103
+ * This function can be replaced with many fingerprint/findLowerBound calls,
104
+ * but implementations can leverage it for batching and more efficient
105
+ * fingerprint computation.
106
+ */
107
+ readonly fingerprintRanges: (
108
+ ownerId: OwnerIdBytes,
109
+ buckets: ReadonlyArray<NonNegativeInt>,
110
+ upperBound?: RangeUpperBound,
111
+ ) => ReadonlyArray<FingerprintRange> | null;
112
+
113
+ readonly findLowerBound: (
114
+ ownerId: OwnerIdBytes,
115
+ begin: NonNegativeInt,
116
+ end: NonNegativeInt,
117
+ upperBound: RangeUpperBound,
118
+ ) => NonNegativeInt | null;
119
+
120
+ readonly iterate: (
121
+ ownerId: OwnerIdBytes,
122
+ begin: NonNegativeInt,
123
+ end: NonNegativeInt,
124
+ callback: (timestamp: TimestampBytes, index: NonNegativeInt) => boolean,
125
+ ) => void;
126
+
127
+ /**
128
+ * Validates the {@link OwnerWriteKey} for the given {@link Owner}.
129
+ *
130
+ * Returns `true` if the write key is valid, `false` otherwise.
131
+ */
132
+ readonly validateWriteKey: (
133
+ ownerId: OwnerIdBytes,
134
+ writeKey: OwnerWriteKey,
135
+ ) => boolean;
136
+
137
+ /** Sets the {@link OwnerWriteKey} for the given {@link Owner}. */
138
+ readonly setWriteKey: (
139
+ ownerId: OwnerIdBytes,
140
+ writeKey: OwnerWriteKey,
141
+ ) => boolean;
142
+
143
+ /**
144
+ * Write encrypted {@link CrdtMessage}s to storage.
145
+ *
146
+ * Must use a mutex per ownerId to ensure sequential processing and proper
147
+ * protocol logic handling during sync operations.
148
+ *
149
+ * TODO: Use MaybeAsync
150
+ */
151
+ readonly writeMessages: (
152
+ ownerIdBytes: OwnerIdBytes,
153
+ messages: NonEmptyReadonlyArray<EncryptedCrdtMessage>,
154
+ ) => MaybeAsync<Result<void, StorageWriteError | StorageQuotaError>>;
155
+
156
+ /** Read encrypted {@link DbChange}s from storage. */
157
+ readonly readDbChange: (
158
+ ownerId: OwnerIdBytes,
159
+ timestamp: TimestampBytes,
160
+ ) => EncryptedDbChange | null;
161
+
162
+ /**
163
+ * Delete all data for the given {@link Owner}.
164
+ *
165
+ * Returns `true` on success, `false` on failure.
166
+ */
167
+ readonly deleteOwner: (ownerId: OwnerIdBytes) => boolean;
168
+ }
169
+
170
+ export interface StorageDep {
171
+ readonly storage: Storage;
172
+ }
173
+
174
+ /** Error indicating a serious write failure. */
175
+ export interface StorageWriteError extends BaseOwnerError {
176
+ readonly type: "StorageWriteError";
177
+ }
178
+
179
+ /** Error when storage or billing quota is exceeded. */
180
+ export interface StorageQuotaError extends BaseOwnerError {
181
+ readonly type: "StorageQuotaError";
182
+ }
183
+
1
184
  /**
2
- * Evolu Storage for SQLite
185
+ * A cryptographic hash used for efficiently comparing collections of
186
+ * {@link TimestampBytes}s.
187
+ *
188
+ * It consists of the first {@link fingerprintSize} bytes of the SHA-256 hash of
189
+ * one or more timestamps.
190
+ */
191
+ export type Fingerprint = Uint8Array & Brand<"Fingerprint">;
192
+
193
+ export const fingerprintSize = NonNegativeInt.orThrow(12);
194
+
195
+ /** A fingerprint of an empty range. */
196
+ export const zeroFingerprint = new Uint8Array(fingerprintSize) as Fingerprint;
197
+
198
+ export interface BaseRange {
199
+ readonly upperBound: RangeUpperBound;
200
+ }
201
+
202
+ /**
203
+ * Union type for Range's upperBound: either a {@link TimestampBytes} or
204
+ * {@link InfiniteUpperBound}.
205
+ */
206
+ export type RangeUpperBound = TimestampBytes | InfiniteUpperBound;
207
+
208
+ export const InfiniteUpperBound = Symbol("InfiniteUpperBound");
209
+ export type InfiniteUpperBound = typeof InfiniteUpperBound;
210
+
211
+ export const RangeType = {
212
+ Fingerprint: 1,
213
+ Skip: 0,
214
+ Timestamps: 2,
215
+ } as const;
216
+
217
+ export type RangeType = (typeof RangeType)[keyof typeof RangeType];
218
+
219
+ export interface SkipRange extends BaseRange {
220
+ readonly type: typeof RangeType.Skip;
221
+ }
222
+
223
+ export interface FingerprintRange extends BaseRange {
224
+ readonly type: typeof RangeType.Fingerprint;
225
+ readonly fingerprint: Fingerprint;
226
+ }
227
+
228
+ export interface TimestampsRange extends BaseRange {
229
+ readonly type: typeof RangeType.Timestamps;
230
+ readonly timestamps: ReadonlyArray<TimestampBytes>;
231
+ }
232
+
233
+ export type Range = SkipRange | FingerprintRange | TimestampsRange;
234
+
235
+ /** An encrypted {@link CrdtMessage}. */
236
+ export interface EncryptedCrdtMessage {
237
+ readonly timestamp: Timestamp;
238
+ readonly change: EncryptedDbChange;
239
+ }
240
+
241
+ /** Encrypted DbChange */
242
+ export type EncryptedDbChange = Uint8Array & Brand<"EncryptedDbChange">;
243
+
244
+ /**
245
+ * A CRDT message combining a unique {@link Timestamp} with a {@link DbChange}.
246
+ *
247
+ * Used in Evolu's sync protocol to replicate data changes across devices. Evolu
248
+ * operates as a durable queue, providing exactly-once delivery guarantees for
249
+ * reliable synchronization across application restarts and network failures.
250
+ */
251
+ export interface CrdtMessage {
252
+ readonly timestamp: Timestamp;
253
+ readonly change: DbChange;
254
+ }
255
+
256
+ export const DbChangeValues = record(String, SqliteValue);
257
+ export type DbChangeValues = typeof DbChangeValues.Type;
258
+
259
+ // "createdAt" and "updatedAt" are derived from Timestamp.
260
+ // "id" and "isDeleted" are encoded separately to save bytes
261
+ // "ownerId" is part of the protocol message
262
+ const forbiddenSystemColumns = systemColumns.concat("id");
263
+
264
+ export const ValidDbChangeValues = brand(
265
+ "ValidDbChangeValues",
266
+ DbChangeValues,
267
+ (value) => {
268
+ const invalidColumns = forbiddenSystemColumns.filter((key) => key in value);
269
+ if (invalidColumns.length > 0)
270
+ return err<ValidDbChangeValuesError>({
271
+ type: "ValidDbChangeValues",
272
+ value,
273
+ invalidColumns,
274
+ });
275
+
276
+ return ok(value);
277
+ },
278
+ );
279
+ export type ValidDbChangeValues = typeof ValidDbChangeValues.Type;
280
+
281
+ export interface ValidDbChangeValuesError
282
+ extends TypeError<"ValidDbChangeValues"> {
283
+ readonly invalidColumns: ReadonlyArray<string>;
284
+ }
285
+
286
+ /**
287
+ * A DbChange is a change to a table row. Together with a unique
288
+ * {@link Timestamp}, it forms a {@link CrdtMessage}.
289
+ */
290
+ export const DbChange = object({
291
+ table: String,
292
+ id: Id,
293
+ values: ValidDbChangeValues,
294
+ isInsert: Boolean,
295
+ isDelete: nullOr(Boolean),
296
+ });
297
+ export type DbChange = typeof DbChange.Type;
298
+
299
+ /**
300
+ * Common interface for both client and relay SQLite storages.
3
301
  *
4
302
  * Evolu uses a Skiplist, which leverages SQLite indexes. The core logic is
5
303
  * implemented in SQL, so it doesn't have to make roundtrips to the DB.
6
304
  *
7
- * The ideal storage for a Relay should use a similar architecture to
305
+ * While the SQL implementation may look sophisticated, it's conceptually simple
306
+ * and LLMs can explain how it works. The Skiplist data structure is well
307
+ * explained in [this Stack Overflow
308
+ * answer](https://stackoverflow.com/questions/61944198/what-is-a-zip-tree-and-how-does-it-work).
309
+ * The logic resembles [Negentropy's C++
310
+ * storage](https://github.com/hoytech/negentropy), except we use a Skiplist to
311
+ * leverage SQLite indexes, which makes the code simpler.
312
+ *
313
+ * Note: A paid review by the SQLite team is planned, as they use the same
314
+ * algorithm for their rsync tool.
315
+ *
316
+ * The ideal storage for a Relay should use an architecture like
8
317
  * [strfry](https://github.com/hoytech/strfry) (a KV storage), but with Skiplist
9
318
  * to ensure that insertion order doesn't matter (local-first apps can often
10
319
  * write in the past.)
@@ -19,117 +328,104 @@
19
328
  * each other, if necessary. One relay should handle hundreds of thousands of
20
329
  * users, and when it goes down, nothing happens, because it will be
21
330
  * synchronized later.
22
- *
23
- * @module
24
331
  */
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 {
332
+ export interface BaseSqliteStorage
333
+ extends Pick<
334
+ Storage,
335
+ | "getSize"
336
+ | "fingerprint"
337
+ | "fingerprintRanges"
338
+ | "findLowerBound"
339
+ | "iterate"
340
+ | "deleteOwner"
341
+ > {
342
+ /** Inserts a timestamp for an owner into the skiplist-based storage. */
50
343
  readonly insertTimestamp: (
51
- ownerId: BinaryOwnerId,
52
- timestamp: BinaryTimestamp,
344
+ ownerId: OwnerIdBytes,
345
+ timestamp: TimestampBytes,
346
+ strategy: StorageInsertTimestampStrategy,
53
347
  ) => Result<void, SqliteError>;
54
348
 
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"];
60
- readonly deleteOwner: Storage["deleteOwner"];
349
+ /**
350
+ * Efficiently checks which timestamps already exist in the database using a
351
+ * single CTE query instead of N individual queries.
352
+ */
353
+ readonly getExistingTimestamps: (
354
+ ownerIdBytes: OwnerIdBytes,
355
+ timestampsBytes: NonEmptyReadonlyArray<TimestampBytes>,
356
+ ) => Result<ReadonlyArray<TimestampBytes>, SqliteError>;
61
357
  }
62
358
 
63
- export interface SqliteStorageBaseDep {
64
- readonly storage: SqliteStorageBase;
359
+ export interface BaseSqliteStorageDep {
360
+ readonly storage: BaseSqliteStorage;
65
361
  }
66
362
 
67
- export type SqliteStorageDeps = SqliteDep & RandomDep;
363
+ export type SqliteStorageDeps = RandomDep & SqliteDep;
68
364
 
69
- export interface CreateSqliteStorageBaseOptions {
365
+ export interface CreateBaseSqliteStorageConfig extends StorageConfig {
70
366
  onStorageError: (error: SqliteError) => void;
71
367
  }
72
368
 
73
- export const createSqliteStorageBase =
369
+ /**
370
+ * Creates a {@link BaseSqliteStorage} implementation.
371
+ *
372
+ * # Stateless Design
373
+ *
374
+ * This implementation is fully stateless - it requires no in-memory state
375
+ * between invocations. All necessary metadata (timestamp bounds for insertion
376
+ * strategy optimization) is persisted in the evolu_usage table. This makes
377
+ * Evolu Relay suitable for stateless serverless environments like AWS Lambda,
378
+ * Cloudflare Workers with Durable Objects, and other platforms where memory
379
+ * doesn't persist between requests. While not extensively tested in all these
380
+ * environments yet, the stateless design should work well across them.
381
+ */
382
+ export const createBaseSqliteStorage =
74
383
  (deps: SqliteStorageDeps) =>
75
- (
76
- options: CreateSqliteStorageBaseOptions,
77
- ): Result<SqliteStorageBase, SqliteError> => {
78
- const createTablesResult = createTables(deps);
79
- if (!createTablesResult.ok) return createTablesResult;
80
-
81
- const ownerStats = new Map<
82
- OwnerId,
83
- {
84
- minT: BinaryTimestamp;
85
- maxT: BinaryTimestamp;
86
- }
87
- >();
88
-
89
- return ok({
90
- insertTimestamp: (ownerId: BinaryOwnerId, timestamp: BinaryTimestamp) => {
91
- const ownerIdString = binaryOwnerIdToOwnerId(ownerId);
384
+ (config: CreateBaseSqliteStorageConfig): BaseSqliteStorage => {
385
+ return {
386
+ insertTimestamp: (
387
+ ownerId: OwnerIdBytes,
388
+ timestamp: TimestampBytes,
389
+ strategy: StorageInsertTimestampStrategy,
390
+ ) => {
92
391
  const level = randomSkiplistLevel(deps);
392
+ return insertTimestamp(deps)(ownerId, timestamp, level, strategy);
393
+ },
93
394
 
94
- let stats = ownerStats.get(ownerIdString);
95
-
96
- if (!stats) {
97
- const result = deps.sqlite.exec<{
98
- maxT: BinaryTimestamp | null;
99
- minT: BinaryTimestamp | null;
100
- }>(sql.prepared`
101
- select min(t) as minT, max(t) as maxT
102
- from evolu_timestamp
103
- where ownerId = ${ownerId};
104
- `);
105
- if (!result.ok) return result;
106
-
107
- stats = {
108
- minT: result.value.rows[0].minT ?? timestamp,
109
- maxT: result.value.rows[0].maxT ?? timestamp,
110
- };
111
- ownerStats.set(ownerIdString, stats);
112
- }
113
-
114
- let strategy: InsertTimestampStrategy;
395
+ getExistingTimestamps: (ownerIdBytes, timestampsBytes) => {
396
+ const concatenatedTimestamps = concatBytes(...timestampsBytes);
397
+
398
+ const result = deps.sqlite.exec<{
399
+ timestampBytes: TimestampBytes;
400
+ }>(sql`
401
+ with recursive
402
+ split_timestamps(timestampBytes, pos) as (
403
+ select
404
+ substr(${concatenatedTimestamps}, 1, 16),
405
+ 17 as pos
406
+ union all
407
+ select
408
+ substr(${concatenatedTimestamps}, pos, 16),
409
+ pos + 16
410
+ from split_timestamps
411
+ where pos <= length(${concatenatedTimestamps})
412
+ )
413
+ select s.timestampBytes
414
+ from
415
+ split_timestamps s
416
+ join evolu_timestamp t
417
+ on t.ownerId = ${ownerIdBytes} and s.timestampBytes = t.t;
418
+ `);
115
419
 
116
- if (orderBinaryTimestamp(timestamp, stats.maxT) === 1) {
117
- strategy = "append";
118
- stats.maxT = timestamp;
119
- } else if (orderBinaryTimestamp(timestamp, stats.minT) === -1) {
120
- strategy = "prepend";
121
- stats.minT = timestamp;
122
- } else {
123
- strategy = "insert";
124
- }
420
+ if (!result.ok) return result;
125
421
 
126
- return insertTimestamp(deps)(ownerId, timestamp, level, strategy);
422
+ return ok(result.value.rows.map((row) => row.timestampBytes));
127
423
  },
128
424
 
129
425
  getSize: (ownerId) => {
130
426
  const size = getSize(deps)(ownerId);
131
427
  if (!size.ok) {
132
- options.onStorageError(size.error);
428
+ config.onStorageError(size.error);
133
429
  return null;
134
430
  }
135
431
  return size.value;
@@ -139,7 +435,7 @@ export const createSqliteStorageBase =
139
435
  assertBeginEnd(begin, end);
140
436
  const result = fingerprint(deps)(ownerId, begin, end);
141
437
  if (!result.ok) {
142
- options.onStorageError(result.error);
438
+ config.onStorageError(result.error);
143
439
  return null;
144
440
  }
145
441
  return result.value;
@@ -148,7 +444,7 @@ export const createSqliteStorageBase =
148
444
  fingerprintRanges: (ownerId, buckets, upperBound) => {
149
445
  const ranges = fingerprintRanges(deps)(ownerId, buckets, upperBound);
150
446
  if (!ranges.ok) {
151
- options.onStorageError(ranges.error);
447
+ config.onStorageError(ranges.error);
152
448
  return null;
153
449
  }
154
450
  return ranges.value;
@@ -162,7 +458,7 @@ export const createSqliteStorageBase =
162
458
  upperBound,
163
459
  );
164
460
  if (!lowerBound.ok) {
165
- options.onStorageError(lowerBound.error);
461
+ config.onStorageError(lowerBound.error);
166
462
  return null;
167
463
  }
168
464
  return lowerBound.value;
@@ -176,7 +472,7 @@ export const createSqliteStorageBase =
176
472
  // This is much faster than SQL limit with offset.
177
473
  const first = getTimestampByIndex(deps)(ownerId, begin);
178
474
  if (!first.ok) {
179
- options.onStorageError(first.error);
475
+ config.onStorageError(first.error);
180
476
  return;
181
477
  }
182
478
 
@@ -193,7 +489,7 @@ export const createSqliteStorageBase =
193
489
  * implementing chunking, be sure to run performance tests (including
194
490
  * fetching one by one).
195
491
  */
196
- const result = deps.sqlite.exec<{ t: BinaryTimestamp }>(sql`
492
+ const result = deps.sqlite.exec<{ t: TimestampBytes }>(sql`
197
493
  select t
198
494
  from evolu_timestamp
199
495
  where ownerId = ${ownerId} and t > ${first.value}
@@ -201,12 +497,12 @@ export const createSqliteStorageBase =
201
497
  limit ${length - 1};
202
498
  `);
203
499
  if (!result.ok) {
204
- options.onStorageError(result.error);
500
+ config.onStorageError(result.error);
205
501
  return;
206
502
  }
207
503
 
208
504
  for (let i = 0; i < result.value.rows.length; i++) {
209
- const index = (begin + 1 + i) as NonNegativeInt;
505
+ const index = NonNegativeInt.orThrow(begin + 1 + i);
210
506
  if (!callback(result.value.rows[i].t, index)) return;
211
507
  }
212
508
  },
@@ -216,19 +512,21 @@ export const createSqliteStorageBase =
216
512
  delete from evolu_timestamp where ownerId = ${ownerId};
217
513
  `);
218
514
  if (!result.ok) {
219
- options.onStorageError(result.error);
515
+ config.onStorageError(result.error);
220
516
  return false;
221
517
  }
222
518
  return true;
223
519
  },
224
- });
520
+ };
225
521
  };
226
522
 
227
523
  const assertBeginEnd = (begin: NonNegativeInt, end: NonNegativeInt) => {
228
524
  assert(begin <= end, "invalid begin or end");
229
525
  };
230
526
 
231
- const createTables = (deps: SqliteDep): Result<void, SqliteError> => {
527
+ export const createBaseSqliteStorageTables = (
528
+ deps: SqliteDep,
529
+ ): Result<void, SqliteError> => {
232
530
  for (const query of [
233
531
  /**
234
532
  * Creates the `evolu_timestamp` table for storing timestamps of multiple
@@ -241,19 +539,13 @@ const createTables = (deps: SqliteDep): Result<void, SqliteError> => {
241
539
  *
242
540
  * Columns:
243
541
  *
244
- * - `t` – globally unique binary timestamp
542
+ * - `t` – TimestampBytes
245
543
  * - `h1`/`h2` – 12-byte fingerprint split into two integers for fast XOR
246
544
  * - `c` – incremental count
247
- * - `l` – Skiplist level (1 to 32)
248
- *
249
- * For scaling or isolation, sharding is possible—each owner can have a
250
- * separate SQLite database.
251
- *
252
- * Maybe we could use an integer surrogate key for ownerId, but it's fast
253
- * enough even without it.
545
+ * - `l` – Skiplist level (1 to 10)
254
546
  */
255
547
  sql`
256
- create table if not exists evolu_timestamp (
548
+ create table evolu_timestamp (
257
549
  "ownerId" blob not null,
258
550
  "t" blob not null,
259
551
  "h1" integer,
@@ -266,7 +558,7 @@ const createTables = (deps: SqliteDep): Result<void, SqliteError> => {
266
558
  `,
267
559
 
268
560
  sql`
269
- create index if not exists evolu_timestamp_index on evolu_timestamp (
561
+ create index evolu_timestamp_index on evolu_timestamp (
270
562
  "ownerId",
271
563
  "l",
272
564
  "t",
@@ -275,6 +567,26 @@ const createTables = (deps: SqliteDep): Result<void, SqliteError> => {
275
567
  "c"
276
568
  );
277
569
  `,
570
+
571
+ /**
572
+ * Creates the `evolu_usage` table for tracking data consumption per owner.
573
+ *
574
+ * Columns:
575
+ *
576
+ * - `ownerId` – OwnerIdBytes (primary key)
577
+ * - `storedBytes` – total bytes stored in database
578
+ * - `firstTimestamp` – for timestamp insertion strategies
579
+ * - `lastTimestamp` – for timestamp insertion strategies
580
+ */
581
+ sql`
582
+ create table evolu_usage (
583
+ "ownerId" blob primary key,
584
+ "storedBytes" integer not null,
585
+ "firstTimestamp" blob,
586
+ "lastTimestamp" blob
587
+ )
588
+ strict;
589
+ `,
278
590
  ]) {
279
591
  const result = deps.sqlite.exec(query);
280
592
  if (!result.ok) return result;
@@ -282,26 +594,56 @@ const createTables = (deps: SqliteDep): Result<void, SqliteError> => {
282
594
  return ok();
283
595
  };
284
596
 
285
- type InsertTimestampStrategy = "append" | "prepend" | "insert";
597
+ export type StorageInsertTimestampStrategy = "append" | "prepend" | "insert";
598
+
599
+ /**
600
+ * Determines the insertion strategy for a timestamp based on its position
601
+ * relative to the current first and last timestamps.
602
+ *
603
+ * Returns a tuple with the strategy and updated timestamp bounds.
604
+ */
605
+ export const getTimestampInsertStrategy = (
606
+ timestamp: TimestampBytes,
607
+ firstTimestamp: TimestampBytes,
608
+ lastTimestamp: TimestampBytes,
609
+ ): [
610
+ strategy: StorageInsertTimestampStrategy,
611
+ firstTimestamp: TimestampBytes,
612
+ lastTimestamp: TimestampBytes,
613
+ ] => {
614
+ if (orderTimestampBytes(timestamp, lastTimestamp) === 1) {
615
+ return ["append", firstTimestamp, timestamp];
616
+ }
617
+ if (orderTimestampBytes(timestamp, firstTimestamp) === -1) {
618
+ return ["prepend", timestamp, lastTimestamp];
619
+ }
620
+ return ["insert", firstTimestamp, lastTimestamp];
621
+ };
286
622
 
287
- // AFAIK, we can't do both insert and update in one query, and that's probably
288
- // why append is 2x faster than insert. Prepend also has to update parents, but
289
- // it's constantly fast. Insert degrades for reversed (yet LIMIT X magically
290
- // makes it much faster) but it's OK for append. It's probably because it's the
291
- // most complicated SQL, but I believe it can be simplified. If not, we can
292
- // optimize prepending by reversing the incoming timestamps if we detect that
293
- // they will prepend. They are always sorted in ascending order by the
294
- // Protocol.
623
+ /**
624
+ * AFAIK, we can't do both insert and update in one query, and that's probably
625
+ * why append is 2x faster than insert. Prepend also has to update parents, but
626
+ * it's constantly fast. Insert degrades for reversed (yet LIMIT X magically
627
+ * fixes that) but it's OK for append.
628
+ *
629
+ * Note: SQL operations are idempotent (using `on conflict do nothing` and
630
+ * `changes() > 0`), but this is no longer required here since we use
631
+ * {@link BaseSqliteStorage.getExistingTimestamps} to filter out duplicates
632
+ * before insertion, which we need for quota checks anyway.
633
+ *
634
+ * TODO: Remove idempotency (`on conflict do nothing` and `changes() > 0`) since
635
+ * duplicates are now filtered before insertion.
636
+ */
295
637
  const insertTimestamp =
296
638
  (deps: SqliteDep) =>
297
639
  (
298
- ownerId: BinaryOwnerId,
299
- timestamp: BinaryTimestamp,
640
+ ownerId: OwnerIdBytes,
641
+ timestamp: TimestampBytes,
300
642
  level: PositiveInt,
301
- strategy: InsertTimestampStrategy,
643
+ strategy: StorageInsertTimestampStrategy,
302
644
  ): Result<void, SqliteError> => {
303
645
  const [h1, h2] = fingerprintToSqliteFingerprint(
304
- binaryTimestampToFingerprint(timestamp),
646
+ timestampBytesToFingerprint(timestamp),
305
647
  );
306
648
 
307
649
  let queries: Array<ReturnType<typeof sql.prepared>> = [];
@@ -819,6 +1161,13 @@ const insertTimestamp =
819
1161
  return ok();
820
1162
  };
821
1163
 
1164
+ export const timestampBytesToFingerprint = (
1165
+ timestamp: TimestampBytes,
1166
+ ): Fingerprint => {
1167
+ const hash = sha256(timestamp).slice(0, fingerprintSize);
1168
+ return hash as Fingerprint;
1169
+ };
1170
+
822
1171
  /**
823
1172
  * Generates a random skiplist level in the range [1, skiplistMaxLevel].
824
1173
  * Probabilistic approach avoids the need for explicit tree balancing.
@@ -831,7 +1180,7 @@ const randomSkiplistLevel = (deps: RandomDep): PositiveInt => {
831
1180
  ) {
832
1181
  level += 1;
833
1182
  }
834
- return level as PositiveInt;
1183
+ return PositiveInt.orThrow(level);
835
1184
  };
836
1185
 
837
1186
  /**
@@ -899,7 +1248,7 @@ const sqliteFingerprintToFingerprint = ([
899
1248
 
900
1249
  const getSize =
901
1250
  (deps: SqliteDep) =>
902
- (ownerId: BinaryOwnerId): Result<NonNegativeInt, SqliteError> => {
1251
+ (ownerId: OwnerIdBytes): Result<NonNegativeInt, SqliteError> => {
903
1252
  const result = deps.sqlite.exec<{ size: NonNegativeInt }>(sql.prepared`
904
1253
  with
905
1254
  ml(ml) as (
@@ -942,7 +1291,7 @@ const getSize =
942
1291
  const findLowerBound =
943
1292
  (deps: SqliteDep) =>
944
1293
  (
945
- ownerId: BinaryOwnerId,
1294
+ ownerId: OwnerIdBytes,
946
1295
  begin: NonNegativeInt,
947
1296
  end: NonNegativeInt,
948
1297
  upperBound: RangeUpperBound,
@@ -954,7 +1303,7 @@ const findLowerBound =
954
1303
  }
955
1304
 
956
1305
  const result = deps.sqlite.exec<{
957
- t: BinaryTimestamp;
1306
+ t: TimestampBytes;
958
1307
  }>(sql.prepared`
959
1308
  select t
960
1309
  from evolu_timestamp
@@ -972,14 +1321,14 @@ const findLowerBound =
972
1321
  if (!count.ok) return count;
973
1322
 
974
1323
  // `decrement` converts a count to an index.
975
- return ok(decrement(count.value) as NonNegativeInt);
1324
+ return ok(NonNegativeInt.orThrow(decrement(count.value)));
976
1325
  };
977
1326
 
978
1327
  const getTimestampCount =
979
1328
  (deps: SqliteDep) =>
980
1329
  (
981
- ownerId: BinaryOwnerId,
982
- timestamp: BinaryTimestamp,
1330
+ ownerId: OwnerIdBytes,
1331
+ timestamp: TimestampBytes,
983
1332
  ): Result<PositiveInt, SqliteError> => {
984
1333
  const result = deps.sqlite.exec<{
985
1334
  count: PositiveInt;
@@ -1036,7 +1385,7 @@ const getTimestampCount =
1036
1385
  const fingerprint =
1037
1386
  (deps: SqliteDep) =>
1038
1387
  (
1039
- ownerId: BinaryOwnerId,
1388
+ ownerId: OwnerIdBytes,
1040
1389
  begin: NonNegativeInt,
1041
1390
  end: NonNegativeInt,
1042
1391
  ): Result<Fingerprint, SqliteError> => {
@@ -1068,14 +1417,14 @@ const fingerprint =
1068
1417
  const fingerprintRanges =
1069
1418
  (deps: SqliteDep) =>
1070
1419
  (
1071
- ownerId: BinaryOwnerId,
1420
+ ownerId: OwnerIdBytes,
1072
1421
  buckets: ReadonlyArray<NonNegativeInt>,
1073
1422
  upperBound: RangeUpperBound = InfiniteUpperBound,
1074
1423
  ): Result<ReadonlyArray<FingerprintRange>, SqliteError> => {
1075
1424
  const bucketsJson = JSON.stringify(buckets);
1076
1425
 
1077
1426
  const result = deps.sqlite.exec<{
1078
- b: BinaryTimestamp | null;
1427
+ b: TimestampBytes | null;
1079
1428
  h1: Int64String;
1080
1429
  h2: Int64String;
1081
1430
  }>(sql.prepared`
@@ -1211,11 +1560,11 @@ const x = (a: string, b: string) => sql.raw(`(${a} | ${b}) - (${a} & ${b})`);
1211
1560
  export const getTimestampByIndex =
1212
1561
  (deps: SqliteDep) =>
1213
1562
  (
1214
- ownerId: BinaryOwnerId,
1563
+ ownerId: OwnerIdBytes,
1215
1564
  index: NonNegativeInt,
1216
- ): Result<BinaryTimestamp, SqliteError> => {
1565
+ ): Result<TimestampBytes, SqliteError> => {
1217
1566
  const result = deps.sqlite.exec<{
1218
- readonly pt: BinaryTimestamp;
1567
+ readonly pt: TimestampBytes;
1219
1568
  }>(sql.prepared`
1220
1569
  with
1221
1570
  fi(b, cl, ic, pt, mt, nt, nc) as (
@@ -1291,3 +1640,76 @@ export const getTimestampByIndex =
1291
1640
  if (!result.ok) return result;
1292
1641
  return ok(result.value.rows[0].pt);
1293
1642
  };
1643
+
1644
+ /** Retrieves usage information for an owner from the evolu_usage table. */
1645
+ export const getOwnerUsage =
1646
+ (deps: SqliteDep) =>
1647
+ (
1648
+ ownerIdBytes: OwnerIdBytes,
1649
+ initialTimestamp: TimestampBytes,
1650
+ ): Result<
1651
+ {
1652
+ storedBytes: NonNegativeInt | null;
1653
+ firstTimestamp: TimestampBytes;
1654
+ lastTimestamp: TimestampBytes;
1655
+ },
1656
+ SqliteError
1657
+ > => {
1658
+ const result = deps.sqlite.exec<{
1659
+ storedBytes: NonNegativeInt;
1660
+ firstTimestamp: TimestampBytes | null;
1661
+ lastTimestamp: TimestampBytes | null;
1662
+ }>(sql`
1663
+ select storedBytes, firstTimestamp, lastTimestamp
1664
+ from evolu_usage
1665
+ where ownerId = ${ownerIdBytes};
1666
+ `);
1667
+ if (!result.ok) return result;
1668
+
1669
+ if (!isNonEmptyReadonlyArray(result.value.rows)) {
1670
+ return ok({
1671
+ storedBytes: null,
1672
+ firstTimestamp: initialTimestamp,
1673
+ lastTimestamp: initialTimestamp,
1674
+ });
1675
+ }
1676
+
1677
+ const row = firstInArray(result.value.rows);
1678
+ assert(row.firstTimestamp, "not null");
1679
+ assert(row.lastTimestamp, "not null");
1680
+
1681
+ return ok({
1682
+ storedBytes: row.storedBytes,
1683
+ firstTimestamp: row.firstTimestamp,
1684
+ lastTimestamp: row.lastTimestamp,
1685
+ });
1686
+ };
1687
+
1688
+ /**
1689
+ * Updates timestamp bounds in evolu_usage table.
1690
+ *
1691
+ * Used by both relay and client to maintain firstTimestamp/lastTimestamp after
1692
+ * processing messages.
1693
+ */
1694
+ export const updateOwnerUsage =
1695
+ (deps: SqliteDep) =>
1696
+ (
1697
+ ownerIdBytes: OwnerIdBytes,
1698
+ storedBytes: PositiveInt,
1699
+ firstTimestamp: TimestampBytes,
1700
+ lastTimestamp: TimestampBytes,
1701
+ ): Result<void, SqliteError> => {
1702
+ const result = deps.sqlite.exec(sql`
1703
+ insert into evolu_usage
1704
+ ("ownerId", "storedBytes", "firstTimestamp", "lastTimestamp")
1705
+ values
1706
+ (${ownerIdBytes}, ${storedBytes}, ${firstTimestamp}, ${lastTimestamp})
1707
+ on conflict (ownerId) do update
1708
+ set
1709
+ storedBytes = ${storedBytes},
1710
+ firstTimestamp = ${firstTimestamp},
1711
+ lastTimestamp = ${lastTimestamp};
1712
+ `);
1713
+ if (!result.ok) return result;
1714
+ return ok();
1715
+ };