@evolu/common 8.0.0-next.4 → 8.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 (237) hide show
  1. package/dist/src/Array.d.ts +279 -173
  2. package/dist/src/Array.d.ts.map +1 -1
  3. package/dist/src/Array.js +105 -101
  4. package/dist/src/Assert.d.ts +44 -44
  5. package/dist/src/Assert.d.ts.map +1 -1
  6. package/dist/src/Assert.js +44 -32
  7. package/dist/src/BigInt.d.ts +6 -3
  8. package/dist/src/BigInt.d.ts.map +1 -1
  9. package/dist/src/BigInt.js +5 -2
  10. package/dist/src/Brand.d.ts +27 -40
  11. package/dist/src/Brand.d.ts.map +1 -1
  12. package/dist/src/Buffer.d.ts +27 -11
  13. package/dist/src/Buffer.d.ts.map +1 -1
  14. package/dist/src/Cache.d.ts +9 -4
  15. package/dist/src/Cache.d.ts.map +1 -1
  16. package/dist/src/Cache.js +8 -3
  17. package/dist/src/Callbacks.d.ts +30 -19
  18. package/dist/src/Callbacks.d.ts.map +1 -1
  19. package/dist/src/Console.d.ts +145 -66
  20. package/dist/src/Console.d.ts.map +1 -1
  21. package/dist/src/Console.js +91 -43
  22. package/dist/src/Crypto.d.ts +85 -42
  23. package/dist/src/Crypto.d.ts.map +1 -1
  24. package/dist/src/Crypto.js +44 -20
  25. package/dist/src/Eq.d.ts +87 -21
  26. package/dist/src/Eq.d.ts.map +1 -1
  27. package/dist/src/Eq.js +67 -19
  28. package/dist/src/Error.d.ts +5 -3
  29. package/dist/src/Error.d.ts.map +1 -1
  30. package/dist/src/Error.js +16 -0
  31. package/dist/src/Function.d.ts +119 -58
  32. package/dist/src/Function.d.ts.map +1 -1
  33. package/dist/src/Function.js +86 -43
  34. package/dist/src/Http.d.ts +394 -0
  35. package/dist/src/Http.d.ts.map +1 -0
  36. package/dist/src/Http.js +102 -0
  37. package/dist/src/Identicon.d.ts +16 -7
  38. package/dist/src/Identicon.d.ts.map +1 -1
  39. package/dist/src/Identicon.js +14 -5
  40. package/dist/src/LeakDetector.d.ts +88 -0
  41. package/dist/src/LeakDetector.d.ts.map +1 -0
  42. package/dist/src/LeakDetector.js +86 -0
  43. package/dist/src/LockManager.d.ts +18 -9
  44. package/dist/src/LockManager.d.ts.map +1 -1
  45. package/dist/src/LockManager.js +7 -8
  46. package/dist/src/Lookup.d.ts +43 -13
  47. package/dist/src/Lookup.d.ts.map +1 -1
  48. package/dist/src/Lookup.js +28 -8
  49. package/dist/src/Microtask.d.ts +1 -1
  50. package/dist/src/Microtask.d.ts.map +1 -1
  51. package/dist/src/Microtask.js +3 -7
  52. package/dist/src/Number.d.ts +49 -13
  53. package/dist/src/Number.d.ts.map +1 -1
  54. package/dist/src/Number.js +34 -5
  55. package/dist/src/Object.d.ts +160 -40
  56. package/dist/src/Object.d.ts.map +1 -1
  57. package/dist/src/Object.js +92 -46
  58. package/dist/src/Option.d.ts +10 -7
  59. package/dist/src/Option.d.ts.map +1 -1
  60. package/dist/src/Option.js +9 -6
  61. package/dist/src/Order.d.ts +21 -21
  62. package/dist/src/Order.d.ts.map +1 -1
  63. package/dist/src/Order.js +18 -21
  64. package/dist/src/Platform.d.ts +31 -0
  65. package/dist/src/Platform.d.ts.map +1 -1
  66. package/dist/src/Platform.js +98 -0
  67. package/dist/src/Random.d.ts +32 -17
  68. package/dist/src/Random.d.ts.map +1 -1
  69. package/dist/src/Random.js +12 -8
  70. package/dist/src/Redacted.d.ts +43 -29
  71. package/dist/src/Redacted.d.ts.map +1 -1
  72. package/dist/src/Redacted.js +14 -3
  73. package/dist/src/Ref.d.ts +11 -3
  74. package/dist/src/Ref.d.ts.map +1 -1
  75. package/dist/src/Ref.js +2 -2
  76. package/dist/src/RefCount.d.ts +2 -6
  77. package/dist/src/RefCount.d.ts.map +1 -1
  78. package/dist/src/RefCount.js +33 -90
  79. package/dist/src/Relation.d.ts +70 -5
  80. package/dist/src/Relation.d.ts.map +1 -1
  81. package/dist/src/Relation.js +77 -2
  82. package/dist/src/Resource.d.ts +590 -155
  83. package/dist/src/Resource.d.ts.map +1 -1
  84. package/dist/src/Resource.js +592 -343
  85. package/dist/src/Result.d.ts +838 -191
  86. package/dist/src/Result.d.ts.map +1 -1
  87. package/dist/src/Result.js +163 -102
  88. package/dist/src/Schedule.d.ts +684 -231
  89. package/dist/src/Schedule.d.ts.map +1 -1
  90. package/dist/src/Schedule.js +766 -295
  91. package/dist/src/Set.d.ts +89 -40
  92. package/dist/src/Set.d.ts.map +1 -1
  93. package/dist/src/Set.js +50 -29
  94. package/dist/src/Sqlite.d.ts +29 -72
  95. package/dist/src/Sqlite.d.ts.map +1 -1
  96. package/dist/src/Sqlite.js +41 -46
  97. package/dist/src/StackTrace.d.ts +40 -0
  98. package/dist/src/StackTrace.d.ts.map +1 -0
  99. package/dist/src/StackTrace.js +140 -0
  100. package/dist/src/Store.d.ts +5 -4
  101. package/dist/src/Store.d.ts.map +1 -1
  102. package/dist/src/Store.js +5 -4
  103. package/dist/src/String.d.ts +7 -1
  104. package/dist/src/String.d.ts.map +1 -1
  105. package/dist/src/String.js +20 -7
  106. package/dist/src/Task.d.ts +3572 -1770
  107. package/dist/src/Task.d.ts.map +1 -1
  108. package/dist/src/Task.js +1879 -1274
  109. package/dist/src/Test.d.ts +17 -125
  110. package/dist/src/Test.d.ts.map +1 -1
  111. package/dist/src/Test.js +17 -68
  112. package/dist/src/Time.d.ts +108 -49
  113. package/dist/src/Time.d.ts.map +1 -1
  114. package/dist/src/Time.js +172 -73
  115. package/dist/src/Type.d.ts +3542 -1926
  116. package/dist/src/Type.d.ts.map +1 -1
  117. package/dist/src/Type.js +3070 -1784
  118. package/dist/src/Types.d.ts +200 -108
  119. package/dist/src/Types.d.ts.map +1 -1
  120. package/dist/src/Types.js +39 -8
  121. package/dist/src/WebSocket.d.ts +48 -23
  122. package/dist/src/WebSocket.d.ts.map +1 -1
  123. package/dist/src/WebSocket.js +15 -6
  124. package/dist/src/Worker.d.ts +94 -36
  125. package/dist/src/Worker.d.ts.map +1 -1
  126. package/dist/src/Worker.js +58 -24
  127. package/dist/src/index.d.ts +55 -52
  128. package/dist/src/index.d.ts.map +1 -1
  129. package/dist/src/index.js +3 -1
  130. package/dist/src/intl/cs.d.ts +94 -0
  131. package/dist/src/intl/cs.d.ts.map +1 -0
  132. package/dist/src/intl/cs.js +220 -0
  133. package/dist/src/intl/index.d.ts +7 -0
  134. package/dist/src/intl/index.d.ts.map +1 -0
  135. package/dist/src/intl/index.js +6 -0
  136. package/dist/src/local-first/Db.d.ts +12 -8
  137. package/dist/src/local-first/Db.d.ts.map +1 -1
  138. package/dist/src/local-first/Db.js +109 -132
  139. package/dist/src/local-first/Error.d.ts +4 -4
  140. package/dist/src/local-first/Evolu.d.ts +338 -92
  141. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  142. package/dist/src/local-first/Evolu.js +47 -55
  143. package/dist/src/local-first/LocalAuth.d.ts +4 -4
  144. package/dist/src/local-first/LocalAuth.d.ts.map +1 -1
  145. package/dist/src/local-first/Owner.d.ts +61 -24
  146. package/dist/src/local-first/Owner.d.ts.map +1 -1
  147. package/dist/src/local-first/Owner.js +45 -8
  148. package/dist/src/local-first/Protocol.d.ts +44 -31
  149. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  150. package/dist/src/local-first/Protocol.js +36 -13
  151. package/dist/src/local-first/Query.d.ts +147 -62
  152. package/dist/src/local-first/Query.d.ts.map +1 -1
  153. package/dist/src/local-first/Query.js +112 -58
  154. package/dist/src/local-first/Relay.d.ts +44 -14
  155. package/dist/src/local-first/Relay.d.ts.map +1 -1
  156. package/dist/src/local-first/Schema.d.ts +92 -41
  157. package/dist/src/local-first/Schema.d.ts.map +1 -1
  158. package/dist/src/local-first/Schema.js +25 -8
  159. package/dist/src/local-first/Shared.d.ts +23 -21
  160. package/dist/src/local-first/Shared.d.ts.map +1 -1
  161. package/dist/src/local-first/Shared.js +100 -57
  162. package/dist/src/local-first/Storage.d.ts +53 -32
  163. package/dist/src/local-first/Storage.d.ts.map +1 -1
  164. package/dist/src/local-first/Storage.js +194 -234
  165. package/dist/src/local-first/Timestamp.d.ts +21 -21
  166. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  167. package/dist/src/local-first/Timestamp.js +2 -2
  168. package/dist/src/local-first/index.d.ts +11 -11
  169. package/package.json +14 -13
  170. package/src/Array.ts +306 -205
  171. package/src/Assert.ts +54 -69
  172. package/src/BigInt.ts +6 -3
  173. package/src/Brand.ts +27 -40
  174. package/src/Buffer.ts +28 -12
  175. package/src/Cache.ts +9 -4
  176. package/src/Callbacks.ts +30 -19
  177. package/src/Console.ts +156 -84
  178. package/src/Crypto.ts +83 -41
  179. package/src/Eq.ts +95 -22
  180. package/src/Error.ts +8 -4
  181. package/src/Function.ts +167 -59
  182. package/src/Http.ts +549 -0
  183. package/src/Identicon.ts +16 -7
  184. package/src/LeakDetector.ts +182 -0
  185. package/src/LockManager.ts +43 -39
  186. package/src/Lookup.ts +62 -28
  187. package/src/Microtask.ts +19 -23
  188. package/src/Number.ts +64 -12
  189. package/src/Object.ts +173 -49
  190. package/src/Option.ts +10 -7
  191. package/src/Order.ts +23 -21
  192. package/src/Platform.ts +136 -0
  193. package/src/Polyfills.ts +6 -12
  194. package/src/Random.ts +32 -19
  195. package/src/Redacted.ts +44 -30
  196. package/src/Ref.ts +14 -6
  197. package/src/RefCount.ts +38 -80
  198. package/src/Relation.ts +202 -10
  199. package/src/Resource.ts +1199 -591
  200. package/src/Result.ts +930 -271
  201. package/src/Schedule.ts +859 -343
  202. package/src/Set.ts +89 -40
  203. package/src/Sqlite.ts +144 -138
  204. package/src/StackTrace.ts +230 -0
  205. package/src/Store.ts +8 -7
  206. package/src/String.ts +22 -5
  207. package/src/Task.ts +5101 -2832
  208. package/src/Test.ts +20 -168
  209. package/src/Time.ts +293 -90
  210. package/src/Type.ts +10034 -4117
  211. package/src/Types.ts +227 -110
  212. package/src/WebSocket.ts +64 -36
  213. package/src/Worker.ts +180 -72
  214. package/src/index.ts +55 -52
  215. package/src/intl/cs.ts +388 -0
  216. package/src/intl/index.ts +7 -0
  217. package/src/local-first/Db.ts +152 -179
  218. package/src/local-first/Error.ts +4 -4
  219. package/src/local-first/Evolu.ts +495 -250
  220. package/src/local-first/LocalAuth.ts +6 -12
  221. package/src/local-first/Owner.ts +58 -21
  222. package/src/local-first/Protocol.ts +49 -31
  223. package/src/local-first/Query.ts +146 -61
  224. package/src/local-first/Relay.ts +51 -21
  225. package/src/local-first/Schema.ts +109 -51
  226. package/src/local-first/Shared.ts +266 -226
  227. package/src/local-first/Storage.ts +266 -264
  228. package/src/local-first/Timestamp.ts +25 -18
  229. package/src/local-first/index.ts +11 -11
  230. package/dist/src/Skiplist.d.ts +0 -28
  231. package/dist/src/Skiplist.d.ts.map +0 -1
  232. package/dist/src/Skiplist.js +0 -64
  233. package/dist/src/Tracer.d.ts +0 -48
  234. package/dist/src/Tracer.d.ts.map +0 -1
  235. package/dist/src/Tracer.js +0 -6
  236. package/src/Skiplist.ts +0 -108
  237. package/src/Tracer.ts +0 -54
package/src/Http.ts ADDED
@@ -0,0 +1,549 @@
1
+ /**
2
+ * Task-aware HTTP helpers.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import { err, ok, tryAsync, type Err, type Result } from "./Result.ts";
8
+ import type { AbortError, retry, Task, timeout } from "./Task.ts";
9
+ import type { Typed } from "./Type.ts";
10
+ import type { Awaitable } from "./Types.ts";
11
+
12
+ /**
13
+ * WHATWG-fetch-compatible function used by {@link fetch}.
14
+ *
15
+ * A default dependency, replaceable at the composition root — for a
16
+ * platform-optimized fetch such as React Native's, a configured undici
17
+ * instance, or a test double. Implementations must be pre-bound; unbound
18
+ * `globalThis.fetch` throws in browsers.
19
+ *
20
+ * @group Fetch
21
+ */
22
+ export type NativeFetch = typeof globalThis.fetch;
23
+
24
+ /**
25
+ * Dependency wrapper for {@link NativeFetch}.
26
+ *
27
+ * @group Fetch
28
+ */
29
+ export interface NativeFetchDep {
30
+ readonly nativeFetch: NativeFetch;
31
+ }
32
+
33
+ /**
34
+ * Body mode for {@link fetch}.
35
+ *
36
+ * A mode selects a built-in consumer that reads the {@link Response} inside the
37
+ * fetch Task:
38
+ *
39
+ * - `"bytes"` — reads the body as a `Uint8Array<ArrayBuffer>`.
40
+ * - `"headers"` — cancels the body and returns {@link FetchResponse}. Pair it with
41
+ * `{ method: "HEAD" }` when the server should not send a body.
42
+ * - `"json"` — parses the body as JSON and returns `unknown`. Narrow or decode at
43
+ * the call site; there is deliberately no generic to cast through.
44
+ * - `"text"` — reads the body as a string.
45
+ *
46
+ * Body modes (`"bytes"`, `"json"`, `"text"`) return {@link FetchStatusError} for
47
+ * non-2xx responses. `"headers"` does not judge status, because the status is
48
+ * usually the value being asked for.
49
+ *
50
+ * @group Fetch
51
+ */
52
+ export type FetchMode = "text" | "json" | "bytes" | "headers";
53
+
54
+ /**
55
+ * Body-free {@link Response} view safe to return from {@link fetch}.
56
+ *
57
+ * {@link fetch} returns this view only after the native body has been drained,
58
+ * errored, or cancelled, so none of its usable members depends on the request
59
+ * signal. Body-reading members and `Response.clone` are omitted because they
60
+ * would be invalid after the fetch Task settles.
61
+ *
62
+ * This is a TypeScript contract, not a runtime security boundary: the runtime
63
+ * value can still be a native Response, and casts can access omitted members.
64
+ *
65
+ * @group Fetch
66
+ */
67
+ export type FetchResponse = Omit<
68
+ Response,
69
+ | "arrayBuffer"
70
+ | "blob"
71
+ | "body"
72
+ | "bodyUsed"
73
+ | "bytes"
74
+ | "clone"
75
+ | "formData"
76
+ | "json"
77
+ | "text"
78
+ >;
79
+
80
+ /**
81
+ * Error returned by {@link fetch} body modes.
82
+ *
83
+ * The union distinguishes native request failures, non-2xx responses, and body
84
+ * read failures. Narrow on `type` when handling a specific case. The
85
+ * distinction matters for retries: {@link FetchBodyError} means a 2xx response
86
+ * failed during reading, so the server may have committed the request.
87
+ *
88
+ * @group Fetch
89
+ */
90
+ export type FetchError =
91
+ FetchTransportError | FetchStatusError | FetchBodyError;
92
+
93
+ /**
94
+ * Error returned when the native fetch request fails before a response exists.
95
+ *
96
+ * @group Fetch
97
+ */
98
+ export interface FetchTransportError extends Typed<"FetchTransportError"> {
99
+ readonly error: unknown;
100
+ }
101
+
102
+ /**
103
+ * Error returned when a body mode receives a non-2xx response.
104
+ *
105
+ * The response body is drained as text before this error is returned. Draining
106
+ * buffers the whole error body; error responses are expected to be small. If
107
+ * the drain itself fails, `body` contains the read error.
108
+ *
109
+ * @group Fetch
110
+ */
111
+ export interface FetchStatusError extends Typed<"FetchStatusError"> {
112
+ readonly response: FetchResponse;
113
+ readonly body: Result<string, unknown>;
114
+ }
115
+
116
+ /**
117
+ * Error returned when a body mode cannot read or decode a 2xx response.
118
+ *
119
+ * This includes mid-stream network failures, decoding failures, and — for the
120
+ * `"json"` mode — empty bodies such as 204 responses, which are not JSON. Do
121
+ * not use `"json"` for endpoints that return no content.
122
+ *
123
+ * @group Fetch
124
+ */
125
+ export interface FetchBodyError extends Typed<"FetchBodyError"> {
126
+ readonly response: FetchResponse;
127
+ readonly error: unknown;
128
+ }
129
+
130
+ /**
131
+ * Consumes a native {@link Response} before {@link fetch} settles.
132
+ *
133
+ * The callback runs inside the fetch Task, while the request signal is still
134
+ * alive. Return a Result error for expected domain failures. Throwing or
135
+ * rejecting with a non-abort error is a defect, like any other Task body.
136
+ *
137
+ * Consumers take values, not dependencies; close over anything they need. A
138
+ * consumer that needs its own timeout, retries, or child Tasks has outgrown
139
+ * being a consumer: fetch the body with a mode and compose Tasks on the plain
140
+ * value, or write a Task that owns the whole request.
141
+ *
142
+ * Consumers do not have to normalize abort. {@link fetch} rethrows Evolu
143
+ * {@link AbortError}, normalizes host abort errors after the Run aborts, and
144
+ * treats an Err returned after abort as abort control flow — so a consumer's
145
+ * `try`/`catch` around a body read cannot accidentally turn an abort into a
146
+ * domain error. An Ok returned after abort is kept; only errors are presumed to
147
+ * be abort in disguise.
148
+ *
149
+ * The Response and anything derived from its live body, such as a reader, must
150
+ * not escape the consumer. Consume to plain values before returning.
151
+ *
152
+ * @group Fetch
153
+ */
154
+ export type FetchConsume<T, E = never> = (
155
+ response: Response,
156
+ ) => Awaitable<Result<T, E>>;
157
+
158
+ /**
159
+ * Fetches a resource and consumes the {@link Response} inside the Task, so the
160
+ * body is read while the request signal is still alive.
161
+ *
162
+ * The request runs through {@link NativeFetchDep | run.deps.nativeFetch}.
163
+ * Because native fetch is a default dependency, platforms and tests can replace
164
+ * it without changing call sites.
165
+ *
166
+ * With a {@link FetchMode}, non-2xx responses return {@link FetchStatusError}
167
+ * (except `"headers"`, which reports status as a value) and unreadable bodies
168
+ * return {@link FetchBodyError}. With a {@link FetchConsume} callback, native
169
+ * status semantics apply: HTTP error statuses resolve, and the consumer decides
170
+ * how to interpret the status and body.
171
+ *
172
+ * `signal` is not accepted in init because abort is controlled by the current
173
+ * Run.
174
+ *
175
+ * Aborting the Run aborts the underlying request, any response that arrives
176
+ * after abort, and any in-progress body read. Abort is represented as
177
+ * {@link AbortError}, not FetchError: `run(fetch(...))` rejects with AbortError,
178
+ * and `run.abortable(fetch(...))` returns it as an {@link Err}.
179
+ *
180
+ * Some runtimes reject aborted fetches with their own error instead of
181
+ * `signal.reason`. This wrapper normalizes abort rejections from native fetch,
182
+ * built-in body reads, and consumer callbacks back to the Run's AbortError.
183
+ *
184
+ * `fetch` owns request lifetime and Response containment. It does not transform
185
+ * requests or interpret app protocols beyond the built-in modes. Use Task
186
+ * helpers for resilience, app helpers for app conventions, a replacement
187
+ * {@link NativeFetch} for request-wide behavior (base URLs, auth, logging), and
188
+ * consumers for response interpretation.
189
+ *
190
+ * ### Composing fetch
191
+ *
192
+ * Resilience is ordinary Task composition: wrap `fetch(url, "json")` in
193
+ * {@link timeout}, then in {@link retry}.
194
+ *
195
+ * ```ts
196
+ * import {
197
+ * createRun,
198
+ * exponential,
199
+ * fetch,
200
+ * retry,
201
+ * take,
202
+ * timeout,
203
+ * type NativeFetch,
204
+ * } from "@evolu/common";
205
+ *
206
+ * const fetchWithRetry = (url: string) =>
207
+ * retry(
208
+ * timeout(fetch(url, "json"), "30s"),
209
+ * take(2)(exponential("100ms")),
210
+ * );
211
+ *
212
+ * let requestCount = 0;
213
+ * const nativeFetch: NativeFetch = () => {
214
+ * requestCount++;
215
+ * return Promise.resolve(
216
+ * requestCount === 1
217
+ * ? new Response("Try again", { status: 503 })
218
+ * : new Response('{"name":"Ada"}'),
219
+ * );
220
+ * };
221
+ * await using run = createRun({ nativeFetch });
222
+ *
223
+ * expectOk(await run(fetchWithRetry("/api/user")), { name: "Ada" });
224
+ * ```
225
+ *
226
+ * App conventions belong in small app-owned helpers. For example, posting JSON
227
+ * is native `init` plus two conventions worth centralizing — the content-type
228
+ * header and the stringify:
229
+ *
230
+ * ```ts
231
+ * import {
232
+ * createRun,
233
+ * fetch,
234
+ * type FetchError,
235
+ * type NativeFetch,
236
+ * type Task,
237
+ * } from "@evolu/common";
238
+ *
239
+ * const postJson = (
240
+ * url: string,
241
+ * data: unknown,
242
+ * ): Task<unknown, FetchError> =>
243
+ * fetch(url, "json", {
244
+ * method: "POST",
245
+ * headers: { "content-type": "application/json" },
246
+ * body: JSON.stringify(data),
247
+ * });
248
+ *
249
+ * const nativeFetch: NativeFetch = () =>
250
+ * Promise.resolve(new Response('{"id":"user-1"}'));
251
+ * await using run = createRun({ nativeFetch });
252
+ *
253
+ * expectOk(await run(postJson("/api/users", { name: "Ada" })), {
254
+ * id: "user-1",
255
+ * });
256
+ * ```
257
+ *
258
+ * Your app's version will grow your conventions — auth, envelopes, error
259
+ * mapping — which is why it belongs to the app, not to `fetch`.
260
+ *
261
+ * ### Intercepting requests
262
+ *
263
+ * Request-wide behavior belongs to a replacement {@link NativeFetch} installed
264
+ * at the composition root. This is the equivalent of interceptors or hooks in
265
+ * libraries that expose client instances.
266
+ *
267
+ * ```ts
268
+ * import { createRun, fetch, type NativeFetch } from "@evolu/common";
269
+ *
270
+ * const token = "secret-token";
271
+ * const baseUrl = "https://api.example.com/v1/";
272
+ * let interceptedRequest: Request | undefined;
273
+ * const baseFetch: NativeFetch = (input, init) => {
274
+ * interceptedRequest = new Request(input, init);
275
+ * return Promise.resolve(new Response("ok"));
276
+ * };
277
+ *
278
+ * const nativeFetch: NativeFetch = (input, init) => {
279
+ * const headers = new Headers(init?.headers);
280
+ * headers.set("authorization", `Bearer ${token}`);
281
+ *
282
+ * // Only string inputs are resolved against the base URL; URL and Request
283
+ * // inputs are passed through unchanged.
284
+ * const url =
285
+ * typeof input === "string" ? new URL(input, baseUrl) : input;
286
+ * return baseFetch(url, { ...init, headers });
287
+ * };
288
+ *
289
+ * await using run = createRun({ nativeFetch });
290
+ * expectOk(await run(fetch("users", "text")), "ok");
291
+ * expect({
292
+ * url: interceptedRequest?.url,
293
+ * authorization: interceptedRequest?.headers.get("authorization"),
294
+ * }).toEqual({
295
+ * url: "https://api.example.com/v1/users",
296
+ * authorization: "Bearer secret-token",
297
+ * });
298
+ * ```
299
+ *
300
+ * ### Consuming responses
301
+ *
302
+ * Built-in modes handle common bodies. Specialized response interpretation
303
+ * belongs in a consumer. Typed decoders, response envelopes, streaming, and
304
+ * custom status semantics can be built on top without changing `fetch`.
305
+ *
306
+ * ```ts
307
+ * import {
308
+ * createRun,
309
+ * fetch,
310
+ * ok,
311
+ * type FetchTransportError,
312
+ * type NativeFetch,
313
+ * type Task,
314
+ * } from "@evolu/common";
315
+ *
316
+ * const nativeFetch: NativeFetch = (input) =>
317
+ * Promise.resolve(
318
+ * String(input).endsWith("/metadata")
319
+ * ? new Response(null, {
320
+ * status: 204,
321
+ * headers: { "cache-control": "max-age=60" },
322
+ * })
323
+ * : new Response('{"name":"Ada"}'),
324
+ * );
325
+ * await using run = createRun({ nativeFetch });
326
+ *
327
+ * const user = await run(fetch("/api/user", "json"));
328
+ * const metadata = fetch("/api/user/metadata", (response) =>
329
+ * ok({
330
+ * status: response.status,
331
+ * cache: response.headers.get("cache-control"),
332
+ * }),
333
+ * );
334
+ * expectTypeOf(metadata).toEqualTypeOf<
335
+ * Task<{ status: number; cache: string | null }, FetchTransportError>
336
+ * >();
337
+ * expectOk(user, { name: "Ada" });
338
+ * expectOk(await run(metadata), { status: 204, cache: "max-age=60" });
339
+ * ```
340
+ *
341
+ * ### Aborting fetch
342
+ *
343
+ * Abort follows the standard Task rules: a Fiber from `run(fetch(...))` rejects
344
+ * with {@link AbortError}, and `run.abortable(fetch(...))` returns it as a
345
+ * Result error.
346
+ *
347
+ * ```ts
348
+ * import {
349
+ * AbortError,
350
+ * createRun,
351
+ * fetch,
352
+ * type NativeFetch,
353
+ * } from "@evolu/common";
354
+ *
355
+ * const nativeFetch: NativeFetch = (_input, init) =>
356
+ * new Promise<Response>((_resolve, reject) => {
357
+ * const signal = init?.signal;
358
+ * if (!signal) throw new Error("Missing signal");
359
+ * signal.addEventListener("abort", () => reject(signal.reason), {
360
+ * once: true,
361
+ * });
362
+ * });
363
+ * await using run = createRun({ nativeFetch });
364
+ *
365
+ * const fiber = run.abortable(fetch("/api/user", "json"));
366
+ * fiber.abort();
367
+ * const result = await fiber;
368
+ *
369
+ * expect(!result.ok && AbortError.is(result.error)).toBe(true);
370
+ * ```
371
+ *
372
+ * @group Fetch
373
+ */
374
+ export function fetch(
375
+ input: RequestInfo | URL,
376
+ mode: "text",
377
+ init?: Omit<RequestInit, "signal">,
378
+ ): Task<string, FetchError>;
379
+
380
+ export function fetch(
381
+ input: RequestInfo | URL,
382
+ mode: "json",
383
+ init?: Omit<RequestInit, "signal">,
384
+ ): Task<unknown, FetchError>;
385
+
386
+ export function fetch(
387
+ input: RequestInfo | URL,
388
+ mode: "bytes",
389
+ init?: Omit<RequestInit, "signal">,
390
+ ): Task<Uint8Array<ArrayBuffer>, FetchError>;
391
+
392
+ export function fetch(
393
+ input: RequestInfo | URL,
394
+ mode: "headers",
395
+ init?: Omit<RequestInit, "signal">,
396
+ ): Task<FetchResponse, FetchTransportError>;
397
+
398
+ export function fetch<T, E = never>(
399
+ input: RequestInfo | URL,
400
+ consume: FetchConsume<T, E>,
401
+ init?: Omit<RequestInit, "signal">,
402
+ ): Task<T, FetchTransportError | E>;
403
+
404
+ export function fetch(
405
+ input: RequestInfo | URL,
406
+ modeOrConsume: FetchMode | FetchConsume<unknown, unknown>,
407
+ init?: Omit<RequestInit, "signal">,
408
+ ): Task<unknown, unknown> {
409
+ return async (run) => {
410
+ const { signal } = run;
411
+
412
+ // Abort control flow wins over step errors, normalizing host abort errors
413
+ // from native fetch, body reads, body cancel, and consumers.
414
+ const orAbort = (error: unknown): unknown => {
415
+ signal.throwIfAborted();
416
+ return error;
417
+ };
418
+
419
+ const fetched = await tryAsync(
420
+ () => run.deps.nativeFetch(input, { ...init, signal }),
421
+ orAbort,
422
+ );
423
+ let result: Result<unknown, unknown>;
424
+
425
+ if (!fetched.ok) {
426
+ result = err({ type: "FetchTransportError", error: fetched.error });
427
+ } else {
428
+ const response = fetched.value;
429
+
430
+ // A response that arrives after abort is not processed: cancel its body
431
+ // best-effort, then let abort win before any mode or consumer runs.
432
+ if (signal.aborted) {
433
+ await tryAsync(() => response.body?.cancel());
434
+ signal.throwIfAborted();
435
+ }
436
+
437
+ if (typeof modeOrConsume === "function") {
438
+ const consumed = await tryAsync(() => modeOrConsume(response), orAbort);
439
+ // A consumer throw is a defect once orAbort has ruled out abort.
440
+ if (!consumed.ok) throw consumed.error;
441
+ result = consumed.value;
442
+ } else if (modeOrConsume === "headers") {
443
+ // The head is already a value; body cancel failures are irrelevant.
444
+ await tryAsync(() => response.body?.cancel(), orAbort);
445
+ result = ok(response);
446
+ } else if (!response.ok) {
447
+ result = err({
448
+ type: "FetchStatusError",
449
+ response,
450
+ // Error bodies are diagnostics; text preserves proxy/HTML/plain responses across modes.
451
+ body: await tryAsync(() => response.text(), orAbort),
452
+ });
453
+ } else {
454
+ const body = await tryAsync(() => {
455
+ switch (modeOrConsume) {
456
+ case "text":
457
+ return response.text();
458
+ case "json":
459
+ return response.json() as Promise<unknown>;
460
+ case "bytes":
461
+ return response.bytes();
462
+ }
463
+ }, orAbort);
464
+ result = body.ok
465
+ ? body
466
+ : err({ type: "FetchBodyError", response, error: body.error });
467
+ }
468
+ }
469
+
470
+ // An Err produced after abort is treated as abort control flow.
471
+ if (!result.ok) signal.throwIfAborted();
472
+ return result;
473
+ };
474
+ }
475
+
476
+ /**
477
+ * Test {@link NativeFetch} that records calls and serves queued handlers.
478
+ *
479
+ * Each call shifts the next handler. Calling with an empty queue throws, so a
480
+ * test never silently reaches an unplanned request.
481
+ *
482
+ * @group Testing
483
+ */
484
+ export interface TestNativeFetch extends NativeFetch {
485
+ readonly calls: ReadonlyArray<TestNativeFetchCall>;
486
+ readonly handle: (handler: TestNativeFetchHandler) => void;
487
+ }
488
+
489
+ /**
490
+ * Recorded {@link TestNativeFetch} call.
491
+ *
492
+ * @group Testing
493
+ */
494
+ export interface TestNativeFetchCall {
495
+ readonly input: RequestInfo | URL;
496
+ readonly init: RequestInit | undefined;
497
+ }
498
+
499
+ /**
500
+ * Handler for one {@link TestNativeFetch} call.
501
+ *
502
+ * @group Testing
503
+ */
504
+ export type TestNativeFetchHandler = (
505
+ input: RequestInfo | URL,
506
+ init: RequestInit | undefined,
507
+ ) => Awaitable<Response>;
508
+
509
+ /**
510
+ * Creates {@link TestNativeFetch}.
511
+ *
512
+ * @group Testing
513
+ */
514
+ export const testCreateNativeFetch = (
515
+ ...handlers: ReadonlyArray<TestNativeFetchHandler>
516
+ ): TestNativeFetch => {
517
+ const calls: Array<TestNativeFetchCall> = [];
518
+ const handlerQueue = [...handlers];
519
+
520
+ return Object.assign(
521
+ (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
522
+ calls.push({ input, init });
523
+ const handler = handlerQueue.shift();
524
+ if (!handler) throw new Error("Unexpected NativeFetch call");
525
+ return Promise.resolve(handler(input, init));
526
+ },
527
+ {
528
+ calls,
529
+ handle: (handler: TestNativeFetchHandler): void => {
530
+ handlerQueue.push(handler);
531
+ },
532
+ },
533
+ );
534
+ };
535
+
536
+ /**
537
+ * Creates a Response body for {@link NativeFetch} tests that errors on first
538
+ * read.
539
+ *
540
+ * @group Testing
541
+ */
542
+ export const testCreateNativeFetchErroringBody = (
543
+ error: unknown = new Error("stream failed"),
544
+ ): ReadableStream<Uint8Array> =>
545
+ new ReadableStream<Uint8Array>({
546
+ pull: (controller) => {
547
+ controller.error(error);
548
+ },
549
+ });
package/src/Identicon.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  */
6
6
 
7
7
  import { md5 } from "@noble/hashes/legacy.js";
8
- import type { Brand } from "./Brand.js";
9
- import { Id, idToIdBytes } from "./Type.js";
8
+ import type { Brand } from "./Brand.ts";
9
+ import { Id, idToIdBytes } from "./Type.ts";
10
10
 
11
11
  /**
12
12
  * SVG string representing a visual identicon for an {@link Id}, created with
@@ -32,13 +32,22 @@ export type IdenticonStyle = "github" | "quadrant" | "gradient" | "sutnar";
32
32
  * ### Example
33
33
  *
34
34
  * ```ts
35
+ * import { createIdFromString, createIdenticon } from "@evolu/common";
36
+ *
37
+ * const id = createIdFromString("identicon-example");
35
38
  * const svg = createIdenticon(id);
36
- * const quadrantStyle = createIdenticon(id, "quadrant");
37
- * const gradientStyle = createIdenticon(id, "gradient");
38
- * const sutnarStyle = createIdenticon(id, "sutnar");
39
+ * const styles = ["quadrant", "gradient", "sutnar"] as const;
40
+ * const alternativeSvgs = styles.map((style) =>
41
+ * createIdenticon(id, style),
42
+ * );
43
+ *
44
+ * // Branded IDs work too.
45
+ * const todoId = createIdFromString<"Todo">("todo-1");
46
+ * const todoSvg = createIdenticon(todoId);
39
47
  *
40
- * // Works with branded IDs
41
- * const ownerSvg = createIdenticon(ownerId);
48
+ * expect(svg.startsWith("<svg")).toBe(true);
49
+ * expect(new Set([svg, ...alternativeSvgs]).size).toBe(4);
50
+ * expect(todoSvg.startsWith("<svg")).toBe(true);
42
51
  * ```
43
52
  */
44
53
  export const createIdenticon = (