@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,18 +1,21 @@
1
- import { assert } from "../Assert.js";
2
- import { NanoIdLibDep } from "../NanoId.js";
1
+ import { bytesToHex } from "../Buffer.js";
2
+ import { RandomBytesDep } from "../Crypto.js";
3
+ import { createEqObject, eqNumber, eqString } from "../Eq.js";
3
4
  import { increment } from "../Number.js";
4
5
  import { Order, orderUint8Array } from "../Order.js";
5
- import { err, getOrThrow, ok, Result } from "../Result.js";
6
+ import { err, ok, Result } from "../Result.js";
6
7
  import { TimeDep } from "../Time.js";
7
8
  import {
8
9
  brand,
10
+ DateIso,
11
+ InferType,
9
12
  lessThanOrEqualTo,
10
13
  NonNegativeInt,
11
14
  object,
12
15
  regex,
13
16
  String,
17
+ Uint8Array,
14
18
  } from "../Type.js";
15
- import { Brand } from "../Types.js";
16
19
 
17
20
  export interface TimestampConfig {
18
21
  /**
@@ -30,7 +33,6 @@ export interface TimestampConfigDep {
30
33
  export type TimestampError =
31
34
  | TimestampDriftError
32
35
  | TimestampCounterOverflowError
33
- | TimestampDuplicateNodeError
34
36
  | TimestampTimeOutOfRangeError;
35
37
 
36
38
  export interface TimestampDriftError {
@@ -43,11 +45,6 @@ export interface TimestampCounterOverflowError {
43
45
  readonly type: "TimestampCounterOverflowError";
44
46
  }
45
47
 
46
- export interface TimestampDuplicateNodeError {
47
- readonly type: "TimestampDuplicateNodeError";
48
- readonly nodeId: NodeId;
49
- }
50
-
51
48
  export interface TimestampTimeOutOfRangeError {
52
49
  readonly type: "TimestampTimeOutOfRangeError";
53
50
  }
@@ -95,14 +92,17 @@ export const maxCounter = 65535 as Counter;
95
92
  *
96
93
  * https://lemire.me/blog/2019/12/12/are-64-bit-random-identifiers-free-from-collision
97
94
  *
98
- * What will happen if a different device generates the same NodeId?
95
+ * What happens if different devices generate the same NodeId?
99
96
  *
100
- * If the device belongs to a different owner, nothing will happen because
101
- * different owner have different owner IDs. Timestamps are partitioned by
102
- * OwnerId.
97
+ * If devices with the same NodeId use different owners, no issues occur.
103
98
  *
104
- * If the device belongs to the same owner, the other device will return
105
- * {@link TimestampDuplicateNodeError}.
99
+ * If devices with the same NodeId use the same owner, problems only arise when
100
+ * they generate CRDT messages with identical timestamps (same millis, counter,
101
+ * and NodeId). In this case, the protocol sync algorithm treats them as the
102
+ * same message: the first will be synced with the relay, while the affected
103
+ * message will not be delivered. The affected devices will see different data
104
+ * yet they will think they are synced. This is extremely rare and can be
105
+ * resolved by resetting one device to generate a new NodeId.
106
106
  */
107
107
  export const NodeId = regex("NodeId", /^[a-f0-9]{16}$/)(String);
108
108
  export type NodeId = typeof NodeId.Type;
@@ -113,16 +113,72 @@ export const maxNodeId = "ffffffffffffffff" as NodeId;
113
113
  /**
114
114
  * Hybrid Logical Clock timestamp.
115
115
  *
116
+ * Timestamps serve as globally unique, causally ordered identifiers for CRDT
117
+ * messages in Evolu's sync protocol.
118
+ *
119
+ * ### Why Hybrid Logical Clocks
120
+ *
121
+ * Evolu uses Hybrid Logical Clocks (HLC), which combine physical time (millis)
122
+ * with a logical counter. This hybrid approach preserves causality like logical
123
+ * clocks while staying close to physical time for better human
124
+ * interpretability.
125
+ *
126
+ * The counter component ensures causality is maintained even when physical
127
+ * clocks are imperfect. When clocks drift or operations occur concurrently, the
128
+ * counter increments to establish a total order. This means Evolu achieves
129
+ * well-defined, eventually-consistent behavior regardless of physical clock
130
+ * accuracy.
131
+ *
132
+ * Vector clocks can accurately track causality and detect concurrent
133
+ * operations, but they require unbounded space in peer-to-peer systems and
134
+ * crucially, still don't solve our fundamental problem: when they detect
135
+ * operations as concurrent, we still need a deterministic way to choose a
136
+ * winner. Additionally, any deterministic conflict resolution can be gamed by
137
+ * malicious actors.
138
+ *
139
+ * HLC timestamps work well in practice because modern device clocks accurately
140
+ * reflect the order of sequential edits in the common case. Evolu's `maxDrift`
141
+ * configuration protects against buggy clocks and prevents problematic
142
+ * future-dated entries from propagating through the network.
143
+ *
144
+ * ### References
145
+ *
116
146
  * - https://muratbuffalo.blogspot.com/2014/07/hybrid-logical-clocks.html
117
147
  * - https://sergeiturukin.com/2017/06/26/hybrid-logical-clocks.html
118
148
  * - https://jaredforsyth.com/posts/hybrid-logical-clocks/
149
+ * - https://willowprotocol.org/more/timestamps_really/index.html
150
+ *
151
+ * ### Privacy Considerations
152
+ *
153
+ * Timestamps are metadata visible to relays and collaborators. While it can be
154
+ * considered a privacy leak, let us explain why it's necessary, and how to
155
+ * avoid it if maximum privacy is required.
156
+ *
157
+ * With real-time communication, participants always see activity (receiving
158
+ * bytes). We cannot trust anyone not to store that information, so explicitly
159
+ * exposing timestamps doesn't add additional risk.
160
+ *
161
+ * If we really want not to leak user activity, we can implement a local write
162
+ * queue:
163
+ *
164
+ * 1. Write changes immediately to a local-only table
165
+ * 2. Periodically and randomly flush messages to sync tables
166
+ *
167
+ * **Trade-off:** It breaks real-time collaboration.
119
168
  */
120
169
  export const Timestamp = object({
121
170
  millis: Millis,
122
171
  counter: Counter,
123
172
  nodeId: NodeId,
124
173
  });
125
- export type Timestamp = typeof Timestamp.Type;
174
+ export interface Timestamp extends InferType<typeof Timestamp> {}
175
+
176
+ /** Equality function for comparing {@link Timestamp}. */
177
+ export const eqTimestamp = createEqObject<Timestamp>({
178
+ millis: eqNumber,
179
+ counter: eqNumber,
180
+ nodeId: eqString,
181
+ });
126
182
 
127
183
  export const createTimestamp = ({
128
184
  millis = minMillis,
@@ -130,36 +186,11 @@ export const createTimestamp = ({
130
186
  nodeId = minNodeId,
131
187
  }: Partial<Timestamp> = {}): Timestamp => ({ millis, counter, nodeId });
132
188
 
133
- const hexAlphabet = "0123456789abcdef";
134
-
135
- export const createInitialTimestamp = (deps: NanoIdLibDep): Timestamp => {
136
- const nodeId = deps.nanoIdLib.customAlphabet(hexAlphabet, 16)() as NodeId;
189
+ export const createInitialTimestamp = (deps: RandomBytesDep): Timestamp => {
190
+ const nodeId = bytesToHex(deps.randomBytes.create(8)) as NodeId;
137
191
  return createTimestamp({ nodeId });
138
192
  };
139
193
 
140
- /** TimestampString is a sortable string version of {@link Timestamp}. */
141
- export type TimestampString = string & Brand<"TimestampString">;
142
-
143
- export const timestampToTimestampString = (t: Timestamp): TimestampString =>
144
- [
145
- new Date(t.millis).toISOString(),
146
- t.counter.toString(16).toUpperCase().padStart(4, "0"),
147
- t.nodeId,
148
- ].join("-") as TimestampString;
149
-
150
- export const timestampStringToTimestamp = (
151
- timestampString: TimestampString,
152
- ): Timestamp => {
153
- const array = timestampString.split("-");
154
- const timestamp = {
155
- millis: Date.parse(array.slice(0, 3).join("-")).valueOf(),
156
- counter: parseInt(array[3], 16),
157
- nodeId: array[4],
158
- };
159
- assert(Timestamp.is(timestamp), "timestampString is malformed");
160
- return timestamp;
161
- };
162
-
163
194
  const getNextMillis =
164
195
  (deps: TimeDep & TimestampConfigDep) =>
165
196
  (
@@ -222,12 +253,8 @@ export const receiveTimestamp =
222
253
  Timestamp,
223
254
  | TimestampDriftError
224
255
  | TimestampCounterOverflowError
225
- | TimestampDuplicateNodeError
226
256
  | TimestampTimeOutOfRangeError
227
257
  > => {
228
- if (local.nodeId === remote.nodeId) {
229
- return err({ type: "TimestampDuplicateNodeError", nodeId: local.nodeId });
230
- }
231
258
  const millis = getNextMillis(deps)([local.millis, remote.millis]);
232
259
  if (!millis.ok) return millis;
233
260
 
@@ -249,18 +276,19 @@ export const receiveTimestamp =
249
276
  });
250
277
  };
251
278
 
252
- /** BinaryTimestamp is a binary and sortable version of {@link Timestamp} for DB. */
253
- export type BinaryTimestamp = Uint8Array & Brand<"BinaryTimestamp">;
279
+ /** Sortable bytes representation of {@link Timestamp}. */
280
+ export const TimestampBytes = brand("TimestampBytes", Uint8Array);
281
+ export type TimestampBytes = typeof TimestampBytes.Type;
254
282
 
255
- export const binaryTimestampLength = getOrThrow(NonNegativeInt.from(16));
283
+ export const timestampBytesLength = NonNegativeInt.orThrow(16);
256
284
 
257
- export const timestampToBinaryTimestamp = (
285
+ export const timestampToTimestampBytes = (
258
286
  timestamp: Timestamp,
259
- ): BinaryTimestamp => {
287
+ ): TimestampBytes => {
260
288
  const { millis, counter, nodeId } = timestamp;
261
289
 
262
290
  // 6 bytes for millis, 2 bytes for counter, 8 bytes for nodeId.
263
- const value = new Uint8Array(16);
291
+ const value = new globalThis.Uint8Array(16);
264
292
 
265
293
  // Encode `millis` into the first 6 bytes.
266
294
  const millisBigInt = BigInt(millis);
@@ -281,11 +309,11 @@ export const timestampToBinaryTimestamp = (
281
309
  value[8 + i] = byte;
282
310
  }
283
311
 
284
- return value as BinaryTimestamp;
312
+ return value as TimestampBytes;
285
313
  };
286
314
 
287
- export const binaryTimestampToTimestamp = (
288
- timestamp: BinaryTimestamp,
315
+ export const timestampBytesToTimestamp = (
316
+ timestamp: TimestampBytes,
289
317
  ): Timestamp => {
290
318
  // Decode `millis` from the first 6 bytes.
291
319
  const millis =
@@ -308,4 +336,8 @@ export const binaryTimestampToTimestamp = (
308
336
  return { millis: Number(millis), counter, nodeId } as Timestamp;
309
337
  };
310
338
 
311
- export const orderBinaryTimestamp: Order<BinaryTimestamp> = orderUint8Array;
339
+ export const orderTimestampBytes: Order<TimestampBytes> = orderUint8Array;
340
+
341
+ export const timestampToDateIso = (timestamp: Timestamp): DateIso =>
342
+ // `as DateIso` is safe because the timestamp is always valid
343
+ new Date(timestamp.millis).toISOString() as DateIso;
@@ -0,0 +1,197 @@
1
+ import type { Brand } from "./Brand.js";
2
+ import { Id, idToIdBytes } from "./Type.js";
3
+ import { md5 } from "@noble/hashes/legacy.js";
4
+
5
+ /**
6
+ * SVG string representing a visual identicon for an {@link Id}, created with
7
+ * {@link createIdenticon}.
8
+ */
9
+ export type Identicon = string & Brand<"Identicon">;
10
+
11
+ /** {@link Identicon} style. */
12
+ export type IdenticonStyle = "github" | "quadrant" | "gradient" | "sutnar";
13
+
14
+ /**
15
+ * Creates a deterministic identicon SVG from an {@link Id}.
16
+ *
17
+ * Works with any {@link Id} including branded IDs like `OwnerId`, etc.
18
+ *
19
+ * Available styles:
20
+ *
21
+ * - `"github"` (default): 5x5 grid with horizontal mirroring (GitHub-style)
22
+ * - `"quadrant"`: 2x2 grid with direct RGB color mapping from bytes
23
+ * - `"gradient"`: Diagonal stripes with smooth color gradients
24
+ * - `"sutnar"`: Three compositional variants with adaptive colors
25
+ *
26
+ * ### Example
27
+ *
28
+ * ```ts
29
+ * const svg = createIdenticon(id);
30
+ * const quadrantStyle = createIdenticon(id, "quadrant");
31
+ * const gradientStyle = createIdenticon(id, "gradient");
32
+ * const sutnarStyle = createIdenticon(id, "sutnar");
33
+ *
34
+ * // Works with branded IDs
35
+ * const ownerSvg = createIdenticon(ownerId);
36
+ * ```
37
+ */
38
+ export const createIdenticon = (
39
+ id: Id,
40
+ style: IdenticonStyle = "github",
41
+ ): Identicon => {
42
+ const bytes = idToIdBytes(id);
43
+
44
+ switch (style) {
45
+ case "github": {
46
+ // GitHub-style identicon: MD5 hash the bytes first
47
+ const hashedBytes = md5(bytes);
48
+
49
+ // Map function for value ranges
50
+ const map = (
51
+ value: number,
52
+ inMin: number,
53
+ inMax: number,
54
+ outMin: number,
55
+ outMax: number,
56
+ ): number =>
57
+ ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
58
+
59
+ // Extract 12-bit hue from bytes[12] (lower 4 bits) + bytes[13]
60
+ const h = ((hashedBytes[12] & 0x0f) << 8) | hashedBytes[13];
61
+ const hue = map(h, 0, 4095, 0, 360);
62
+ const saturation = 65 - map(hashedBytes[14], 0, 255, 0, 20);
63
+ const lightness = 75 - map(hashedBytes[15], 0, 255, 0, 20);
64
+
65
+ const fgColor = `hsl(${hue},${saturation}%,${lightness}%)`;
66
+ const bgColor = `hsl(${hue},${saturation}%,90%)`;
67
+
68
+ let rects = `<rect width="5" height="5" fill="${bgColor}"/>`;
69
+
70
+ // Extract nibbles and generate pattern
71
+ let nibbleIndex = 0;
72
+ for (let x = 2; x >= 0; x--) {
73
+ for (let y = 0; y < 5; y++) {
74
+ const byte = hashedBytes[Math.floor(nibbleIndex / 2)];
75
+ const nibble = nibbleIndex % 2 === 0 ? byte >> 4 : byte & 0x0f;
76
+ const paint = nibble % 2 === 0;
77
+ nibbleIndex++;
78
+
79
+ if (paint) {
80
+ rects += `<rect x="${x}" y="${y}" width="1" height="1" fill="${fgColor}"/>`;
81
+ const mx = 4 - x;
82
+ if (mx !== x) {
83
+ rects += `<rect x="${mx}" y="${y}" width="1" height="1" fill="${fgColor}"/>`;
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 5" shape-rendering="crispEdges">${rects}</svg>` as Identicon;
90
+ }
91
+
92
+ case "quadrant": {
93
+ const toHex = (b: number): string => b.toString(16).padStart(2, "0");
94
+ let rects = "";
95
+ for (let i = 0; i < 4; i++) {
96
+ const x = i % 2;
97
+ const y = Math.floor(i / 2);
98
+ const r = bytes[i * 3];
99
+ const g = bytes[i * 3 + 1];
100
+ const b = bytes[i * 3 + 2];
101
+ const color = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
102
+ rects += `<rect x="${x}" y="${y}" width="1" height="1" fill="${color}"/>`;
103
+ }
104
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 2">${rects}</svg>` as Identicon;
105
+ }
106
+
107
+ case "gradient": {
108
+ // Smooth color gradients with diagonal stripes.
109
+ const toHex = (b: number): string => b.toString(16).padStart(2, "0");
110
+
111
+ // Generate colors from bytes.
112
+ const color1 = `#${toHex(bytes[0])}${toHex(bytes[1])}${toHex(bytes[2])}`;
113
+ const color2 = `#${toHex(bytes[3])}${toHex(bytes[4])}${toHex(bytes[5])}`;
114
+ const color3 = `#${toHex(bytes[6])}${toHex(bytes[7])}${toHex(bytes[8])}`;
115
+
116
+ let defs = "";
117
+ let shapes = "";
118
+
119
+ // Diagonal stripes with gradient.
120
+ defs += `<linearGradient id="grad1-${id}" x1="0%" y1="0%" x2="0%" y2="100%">`;
121
+ defs += `<stop offset="0%" style="stop-color:${color1};stop-opacity:1" />`;
122
+ defs += `<stop offset="100%" style="stop-color:${color2};stop-opacity:1" />`;
123
+ defs += `</linearGradient>`;
124
+
125
+ defs += `<linearGradient id="grad2-${id}" x1="0%" y1="0%" x2="0%" y2="100%">`;
126
+ defs += `<stop offset="0%" style="stop-color:${color2};stop-opacity:1" />`;
127
+ defs += `<stop offset="100%" style="stop-color:${color3};stop-opacity:1" />`;
128
+ defs += `</linearGradient>`;
129
+
130
+ shapes += `<rect width="100" height="100" fill="url(#grad1-${id})"/>`;
131
+
132
+ const stripeWidth = 15 + (bytes[9] / 255) * 20;
133
+ const angle = 30 + (bytes[10] / 255) * 60;
134
+
135
+ shapes += `<rect x="20" y="-50" width="${stripeWidth}" height="200" fill="url(#grad2-${id})" transform="rotate(${angle} 50 50)" opacity="0.7"/>`;
136
+ shapes += `<rect x="60" y="-50" width="${stripeWidth}" height="200" fill="url(#grad2-${id})" transform="rotate(${angle} 50 50)" opacity="0.5"/>`;
137
+
138
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs>${defs}</defs>${shapes}</svg>` as Identicon;
139
+ }
140
+
141
+ case "sutnar": {
142
+ // Three compositional variants with adaptive colors.
143
+ const hue = (bytes[0] / 255) * 360;
144
+ const saturation = 50 + (bytes[1] / 255) * 30;
145
+ const lightness = 50 + (bytes[2] / 255) * 20;
146
+
147
+ // Generate palette from base hue with variations
148
+ const toHsl = (h: number, s: number, l: number) =>
149
+ `hsl(${h},${s}%,${l}%)`;
150
+
151
+ const color1 = toHsl(hue, saturation, lightness);
152
+ const color2 = toHsl((hue + 120) % 360, saturation, lightness);
153
+ const color3 = toHsl((hue + 240) % 360, saturation, lightness);
154
+ const color4 = toHsl(hue, saturation * 0.3, lightness * 0.5);
155
+ const color5 = toHsl(
156
+ hue,
157
+ saturation * 0.5,
158
+ Math.min(lightness * 1.3, 90),
159
+ );
160
+
161
+ const palette = [color1, color2, color3, color4, color5] as const;
162
+
163
+ // Layout variant based on first byte.
164
+ const variant = bytes[3] % 3;
165
+
166
+ let shapes = "";
167
+
168
+ // Almost white background with subtle tint.
169
+ shapes += `<rect width="100" height="100" fill="${toHsl(hue, 10, 95)}"/>`;
170
+
171
+ if (variant === 0) {
172
+ // Composition A: Circle + horizontal bar.
173
+ const circleColor = palette[bytes[4] % palette.length];
174
+ const barColor = palette[(bytes[4] + 1) % palette.length];
175
+
176
+ shapes += `<circle cx="30" cy="50" r="22" fill="${circleColor}"/>`;
177
+ shapes += `<rect x="60" y="40" width="35" height="20" fill="${barColor}"/>`;
178
+ } else if (variant === 1) {
179
+ // Composition B: Vertical bar + circle.
180
+ const barColor = palette[bytes[5] % palette.length];
181
+ const circleColor = palette[(bytes[5] + 1) % palette.length];
182
+
183
+ shapes += `<rect x="15" y="10" width="18" height="80" fill="${barColor}"/>`;
184
+ shapes += `<circle cx="70" cy="50" r="15" fill="${circleColor}"/>`;
185
+ } else {
186
+ // Composition C: Square + circle.
187
+ const squareColor = palette[bytes[6] % palette.length];
188
+ const circleColor = palette[(bytes[6] + 1) % palette.length];
189
+
190
+ shapes += `<rect x="20" y="20" width="30" height="30" fill="${squareColor}"/>`;
191
+ shapes += `<circle cx="70" cy="70" r="18" fill="${circleColor}"/>`;
192
+ }
193
+
194
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${shapes}</svg>` as Identicon;
195
+ }
196
+ }
197
+ };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Manages disposable instances by key, ensuring exactly one instance per key.
3
+ *
4
+ * Use cases:
5
+ *
6
+ * - One mutex per key to prevent concurrent writes
7
+ * - Preserving state during hot module reloading
8
+ *
9
+ * **Important:** Do not use this as global shared state. Use it locally or pass
10
+ * it as a dependency instead. The only exception is for hot reloading support,
11
+ * where Evolu uses it to ensure only one instance exists across module reloads
12
+ * (having two Evolu instances with the same name would mean two SQLite
13
+ * connections to the same file, which could corrupt data).
14
+ */
15
+ export interface Instances<K extends string, T extends Disposable>
16
+ extends Disposable {
17
+ /**
18
+ * Ensures an instance exists for the given key, creating it if necessary. If
19
+ * the instance already exists, the optional `onCacheHit` callback is invoked
20
+ * to update the existing instance.
21
+ */
22
+ readonly ensure: (
23
+ key: K,
24
+ create: () => T,
25
+ onCacheHit?: (instance: T) => void,
26
+ ) => T;
27
+
28
+ /** Gets an instance by key, or returns `null` if it doesn't exist. */
29
+ readonly get: (key: K) => T | null;
30
+
31
+ /** Checks if an instance exists for the given key. */
32
+ readonly has: (key: K) => boolean;
33
+
34
+ /**
35
+ * Deletes and disposes an instance by key. Returns `true` if the instance
36
+ * existed and was deleted, `false` otherwise.
37
+ */
38
+ readonly delete: (key: K) => boolean;
39
+ }
40
+
41
+ /** Creates an {@link Instances}. */
42
+ export const createInstances = <
43
+ K extends string,
44
+ T extends Disposable,
45
+ >(): Instances<K, T> => {
46
+ const instances = new Map<K, T>();
47
+
48
+ return {
49
+ ensure: (key, create, onCacheHit) => {
50
+ let instance = instances.get(key);
51
+
52
+ if (instance == null) {
53
+ instance = create();
54
+ instances.set(key, instance);
55
+ } else if (onCacheHit) {
56
+ onCacheHit(instance);
57
+ }
58
+
59
+ return instance;
60
+ },
61
+
62
+ get: (key) => instances.get(key) ?? null,
63
+
64
+ has: (key) => instances.has(key),
65
+
66
+ delete: (key) => {
67
+ const instance = instances.get(key);
68
+ if (instance == null) return false;
69
+ instances.delete(key);
70
+ instance[Symbol.dispose]();
71
+ return true;
72
+ },
73
+
74
+ [Symbol.dispose]: () => {
75
+ const errors: Array<unknown> = [];
76
+ for (const instance of instances.values()) {
77
+ try {
78
+ instance[Symbol.dispose]();
79
+ } catch (error) {
80
+ errors.push(error);
81
+ }
82
+ }
83
+ instances.clear();
84
+ if (errors.length === 1) throw errors[0];
85
+ if (errors.length > 1) {
86
+ throw new AggregateError(errors, "Multiple disposal errors occurred");
87
+ }
88
+ },
89
+ };
90
+ };
package/src/Number.ts CHANGED
@@ -2,12 +2,8 @@ import { NonEmptyReadonlyArray } from "./Array.js";
2
2
  import { assertNonEmptyReadonlyArray } from "./Assert.js";
3
3
  import { err, ok, Result } from "./Result.js";
4
4
  import { NonNegativeInt, PositiveInt } from "./Type.js";
5
- import {
6
- IntentionalNever,
7
- IsBranded,
8
- Predicate,
9
- WidenLiteral,
10
- } from "./Types.js";
5
+ import { IntentionalNever, Predicate, WidenLiteral } from "./Types.js";
6
+ import { IsBranded } from "./Brand.js";
11
7
 
12
8
  export const increment = (n: number): number => n + 1;
13
9
 
@@ -63,15 +59,15 @@ export const computeBalancedBuckets = (
63
59
  numberOfItems: NonNegativeInt,
64
60
 
65
61
  /** Default: 16 */
66
- numberOfBuckets = 16 as PositiveInt,
62
+ numberOfBuckets = PositiveInt.orThrow(16),
67
63
 
68
64
  /** Default: 2 */
69
- minNumberOfItemsPerBucket = 2 as PositiveInt,
65
+ minNumberOfItemsPerBucket = PositiveInt.orThrow(2),
70
66
  ): Result<NonEmptyReadonlyArray<PositiveInt>, PositiveInt> => {
71
67
  const minRequiredItems = numberOfBuckets * minNumberOfItemsPerBucket;
72
68
 
73
69
  if (numberOfItems < minRequiredItems)
74
- return err(minRequiredItems as PositiveInt);
70
+ return err(PositiveInt.orThrow(minRequiredItems));
75
71
 
76
72
  const indexes: Array<PositiveInt> = [];
77
73
  const itemsPerBucket = Math.floor(numberOfItems / numberOfBuckets);
@@ -82,7 +78,7 @@ export const computeBalancedBuckets = (
82
78
  const hasExtraItem = i < extraItems;
83
79
  const itemsInThisBucket = itemsPerBucket + (hasExtraItem ? 1 : 0);
84
80
  bucketBoundary += itemsInThisBucket;
85
- indexes.push(bucketBoundary as PositiveInt);
81
+ indexes.push(PositiveInt.orThrow(bucketBoundary));
86
82
  }
87
83
 
88
84
  assertNonEmptyReadonlyArray(indexes);
package/src/Object.ts CHANGED
@@ -25,14 +25,22 @@ export type ReadonlyRecord<K extends keyof any, V> = Readonly<Record<K, V>>;
25
25
  type StringKeyOf<T> = Extract<keyof T, string>;
26
26
 
27
27
  /**
28
- * Converts a record to entries, preserving branded string key types (e.g.,
29
- * `type Id = 'id' & string`) via `StringKeyOf<T>`, unlike `Object.entries`
30
- * which widens keys to `string`.
28
+ * Like `Object.entries` but preserves branded keys.
29
+ *
30
+ * ### Example
31
+ *
32
+ * ```ts
33
+ * type UserId = string & { readonly __brand: "UserId" };
34
+ * const users: Record<UserId, string> = {};
35
+ * const entries = objectToEntries(users); // [UserId, string][]
36
+ * ```
31
37
  */
32
38
  export const objectToEntries = <T extends Record<string, any>>(
33
39
  record: T,
34
- ): Array<[StringKeyOf<T>, T[StringKeyOf<T>]]> =>
35
- Object.entries(record) as Array<[StringKeyOf<T>, T[StringKeyOf<T>]]>;
40
+ ): ReadonlyArray<[StringKeyOf<T>, T[StringKeyOf<T>]]> =>
41
+ Object.entries(record) as Array<
42
+ [StringKeyOf<T>, T[StringKeyOf<T>]]
43
+ > as ReadonlyArray<[StringKeyOf<T>, T[StringKeyOf<T>]]>;
36
44
 
37
45
  /**
38
46
  * Maps a `ReadonlyRecord<K, V>` to a new `ReadonlyRecord<K, U>`, preserving
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Platform detection utilities for Evolu.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ /** Detects if the code is running in React Native environment. */
8
+ export const isReactNative =
9
+ typeof navigator !== "undefined" &&
10
+ "product" in navigator &&
11
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
12
+ (navigator as any).product === "ReactNative";
13
+
14
+ /**
15
+ * Detects if Node.js Buffer is available and should be used.
16
+ *
17
+ * React Native apps often polyfill Node.js APIs like Buffer, but we want to use
18
+ * native methods when available for better performance.
19
+ *
20
+ * Returns false in React Native even if Buffer is polyfilled, as we prefer
21
+ * native methods in that environment.
22
+ *
23
+ * @see https://github.com/craftzdog/react-native-quick-base64#installation
24
+ */
25
+ export const hasNodeBuffer =
26
+ !isReactNative && typeof globalThis.Buffer !== "undefined";
package/src/Random.ts CHANGED
@@ -7,8 +7,9 @@
7
7
  import { Random as RandomLib } from "random";
8
8
 
9
9
  /**
10
- * A simple wrapper around Math.random(). Most apps need only this. For more
11
- * complex needs check {@link RandomLibDep}.
10
+ * A simple wrapper around Math.random().
11
+ *
12
+ * For more complex needs check {@link RandomLibDep}.
12
13
  *
13
14
  * ### Example
14
15
  *