@evolu/common 6.0.1-preview.9 → 7.1.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 (195) hide show
  1. package/README.md +27 -29
  2. package/dist/src/Array.d.ts +317 -12
  3. package/dist/src/Array.d.ts.map +1 -1
  4. package/dist/src/Array.js +219 -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 +39 -0
  15. package/dist/src/Cache.d.ts.map +1 -0
  16. package/dist/src/Cache.js +47 -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 +32 -7
  21. package/dist/src/Console.d.ts.map +1 -1
  22. package/dist/src/Console.js +73 -10
  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 +45 -54
  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 +213 -133
  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 +2 -4
  36. package/dist/src/Evolu/Internal.d.ts.map +1 -1
  37. package/dist/src/Evolu/Internal.js +2 -4
  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 +595 -447
  49. package/dist/src/Evolu/Public.d.ts +7 -9
  50. package/dist/src/Evolu/Public.d.ts.map +1 -1
  51. package/dist/src/Evolu/Public.js +3 -4
  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/Order.d.ts +0 -5
  85. package/dist/src/Order.d.ts.map +1 -1
  86. package/dist/src/Order.js +0 -5
  87. package/dist/src/Platform.d.ts +15 -0
  88. package/dist/src/Platform.d.ts.map +1 -0
  89. package/dist/src/Platform.js +17 -0
  90. package/dist/src/Random.d.ts +3 -7
  91. package/dist/src/Random.d.ts.map +1 -1
  92. package/dist/src/Random.js +0 -5
  93. package/dist/src/Ref.d.ts.map +1 -1
  94. package/dist/src/Relation.d.ts +79 -0
  95. package/dist/src/Relation.d.ts.map +1 -0
  96. package/dist/src/Relation.js +127 -0
  97. package/dist/src/Resources.d.ts +118 -0
  98. package/dist/src/Resources.d.ts.map +1 -0
  99. package/dist/src/Resources.js +197 -0
  100. package/dist/src/Result.d.ts +189 -59
  101. package/dist/src/Result.d.ts.map +1 -1
  102. package/dist/src/Result.js +30 -241
  103. package/dist/src/Skiplist.js +2 -1
  104. package/dist/src/Sqlite.d.ts +89 -5
  105. package/dist/src/Sqlite.d.ts.map +1 -1
  106. package/dist/src/Sqlite.js +136 -9
  107. package/dist/src/Store.d.ts +0 -5
  108. package/dist/src/Store.d.ts.map +1 -1
  109. package/dist/src/Store.js +0 -5
  110. package/dist/src/Task.d.ts +580 -0
  111. package/dist/src/Task.d.ts.map +1 -0
  112. package/dist/src/Task.js +464 -0
  113. package/dist/src/Time.d.ts +66 -6
  114. package/dist/src/Time.d.ts.map +1 -1
  115. package/dist/src/Time.js +96 -7
  116. package/dist/src/Type.d.ts +677 -344
  117. package/dist/src/Type.d.ts.map +1 -1
  118. package/dist/src/Type.js +718 -467
  119. package/dist/src/Types.d.ts +38 -75
  120. package/dist/src/Types.d.ts.map +1 -1
  121. package/dist/src/WebSocket.d.ts +5 -7
  122. package/dist/src/WebSocket.d.ts.map +1 -1
  123. package/dist/src/WebSocket.js +12 -23
  124. package/dist/src/Worker.d.ts +39 -16
  125. package/dist/src/Worker.d.ts.map +1 -1
  126. package/dist/src/Worker.js +22 -9
  127. package/dist/src/index.d.ts +8 -3
  128. package/dist/src/index.d.ts.map +1 -1
  129. package/dist/src/index.js +8 -3
  130. package/package.json +14 -13
  131. package/src/Array.ts +396 -19
  132. package/src/Assert.ts +0 -35
  133. package/src/Brand.ts +75 -0
  134. package/src/Buffer.ts +7 -7
  135. package/src/Cache.ts +79 -0
  136. package/src/Callbacks.ts +62 -22
  137. package/src/Console.ts +92 -12
  138. package/src/Crypto.ts +99 -91
  139. package/src/Evolu/Db.ts +519 -1026
  140. package/src/Evolu/Diff.ts +7 -5
  141. package/src/Evolu/Evolu.ts +476 -360
  142. package/src/Evolu/Internal.ts +2 -4
  143. package/src/Evolu/LocalAuth.ts +463 -0
  144. package/src/Evolu/Owner.ts +355 -228
  145. package/src/Evolu/Platform.ts +9 -9
  146. package/src/Evolu/Protocol.ts +846 -742
  147. package/src/Evolu/Public.ts +8 -15
  148. package/src/Evolu/PublicKysely.ts +4 -5
  149. package/src/Evolu/Query.ts +3 -2
  150. package/src/Evolu/Relay.ts +406 -103
  151. package/src/Evolu/Schema.ts +323 -91
  152. package/src/Evolu/Storage.ts +559 -137
  153. package/src/Evolu/Sync.ts +819 -36
  154. package/src/Evolu/Timestamp.ts +90 -58
  155. package/src/Identicon.ts +197 -0
  156. package/src/Instances.ts +90 -0
  157. package/src/Number.ts +6 -10
  158. package/src/Object.ts +13 -5
  159. package/src/Order.ts +0 -6
  160. package/src/Platform.ts +20 -0
  161. package/src/Random.ts +3 -8
  162. package/src/Ref.ts +0 -5
  163. package/src/Relation.ts +234 -0
  164. package/src/Resources.ts +367 -0
  165. package/src/Result.ts +196 -61
  166. package/src/Skiplist.ts +1 -1
  167. package/src/Sqlite.ts +152 -17
  168. package/src/Store.ts +0 -5
  169. package/src/Task.ts +894 -0
  170. package/src/Time.ts +179 -10
  171. package/src/Type.ts +1136 -731
  172. package/src/Types.ts +43 -77
  173. package/src/WebSocket.ts +27 -31
  174. package/src/Worker.ts +72 -29
  175. package/src/index.ts +8 -3
  176. package/dist/src/Evolu/Config.d.ts +0 -69
  177. package/dist/src/Evolu/Config.d.ts.map +0 -1
  178. package/dist/src/Evolu/Config.js +0 -9
  179. package/dist/src/Evolu/Kysely.d.ts +0 -6
  180. package/dist/src/Evolu/Kysely.d.ts.map +0 -1
  181. package/dist/src/Evolu/Kysely.js +0 -21
  182. package/dist/src/ManyToManyMap.d.ts +0 -26
  183. package/dist/src/ManyToManyMap.d.ts.map +0 -1
  184. package/dist/src/ManyToManyMap.js +0 -92
  185. package/dist/src/NanoId.d.ts +0 -27
  186. package/dist/src/NanoId.d.ts.map +0 -1
  187. package/dist/src/NanoId.js +0 -6
  188. package/dist/src/Promise.d.ts +0 -180
  189. package/dist/src/Promise.d.ts.map +0 -1
  190. package/dist/src/Promise.js +0 -176
  191. package/src/Evolu/Config.ts +0 -83
  192. package/src/Evolu/Kysely.ts +0 -38
  193. package/src/ManyToManyMap.ts +0 -140
  194. package/src/NanoId.ts +0 -39
  195. package/src/Promise.ts +0 -295
@@ -0,0 +1,464 @@
1
+ import { isNonEmptyArray, shiftArray } from "./Array.js";
2
+ import { err, ok } from "./Result.js";
3
+ import { durationToNonNegativeInt } from "./Time.js";
4
+ import { NonNegativeInt, PositiveInt } from "./Type.js";
5
+ /** Narrower check to detect AbortError objects at runtime. */
6
+ const isAbortError = (error) => typeof error === "object" &&
7
+ error !== null &&
8
+ error.type === "AbortError";
9
+ // For React Native
10
+ if (typeof AbortSignal.any !== "function") {
11
+ AbortSignal.any = function (signals) {
12
+ const controller = new AbortController();
13
+ const onAbort = (event) => {
14
+ controller.abort(event.target.reason);
15
+ cleanup();
16
+ };
17
+ const cleanup = () => {
18
+ for (const s of signals)
19
+ s.removeEventListener("abort", onAbort);
20
+ };
21
+ for (const s of signals) {
22
+ if (s.aborted) {
23
+ controller.abort(s.reason);
24
+ return controller.signal;
25
+ }
26
+ s.addEventListener("abort", onAbort);
27
+ }
28
+ return controller.signal;
29
+ };
30
+ }
31
+ /**
32
+ * Combines user signal from context with an internal signal.
33
+ *
34
+ * If the context has a signal, combines both signals using AbortSignal.any().
35
+ * Otherwise, returns just the internal signal.
36
+ */
37
+ const combineSignal = (context, internalSignal) => context?.signal
38
+ ? AbortSignal.any([context.signal, internalSignal])
39
+ : internalSignal;
40
+ /**
41
+ * Converts async function returning {@link Result} to a {@link Task}.
42
+ *
43
+ * ### Example
44
+ *
45
+ * ```ts
46
+ * interface FetchError {
47
+ * readonly type: "FetchError";
48
+ * readonly error: unknown;
49
+ * }
50
+ *
51
+ * // Task version of fetch with proper error handling and cancellation support.
52
+ * const fetch = (url: string) =>
53
+ * toTask((context) =>
54
+ * tryAsync(
55
+ * () => globalThis.fetch(url, { signal: context?.signal ?? null }),
56
+ * (error): FetchError => ({ type: "FetchError", error }),
57
+ * ),
58
+ * );
59
+ *
60
+ * // `satisfies` shows the expected type signature.
61
+ * fetch satisfies (url: string) => Task<Response, FetchError>;
62
+ *
63
+ * const result1 = await fetch("https://api.example.com/data")();
64
+ * result1 satisfies Result<Response, FetchError>;
65
+ *
66
+ * // With AbortController
67
+ * const controller = new AbortController();
68
+ * const result2 = await fetch("https://api.example.com/data")(controller);
69
+ * result2 satisfies Result<Response, FetchError | AbortError>;
70
+ * ```
71
+ */
72
+ export const toTask = (fn) =>
73
+ // Note: Not using async to avoid Promise wrapper overhead in fast path
74
+ ((context) => {
75
+ const signal = context?.signal;
76
+ // Fast path when no signal – return promise directly
77
+ if (!signal) {
78
+ // Preserve future context fields (e.g., tracing) even without a signal
79
+ return fn(context);
80
+ }
81
+ if (signal.aborted) {
82
+ return Promise.resolve(err({ type: "AbortError", reason: signal.reason }));
83
+ }
84
+ // Use Promise.withResolvers for clean abort handling and cleanup
85
+ const { promise: abortPromise, resolve: resolveAbort } = Promise.withResolvers();
86
+ const handleAbort = () => {
87
+ resolveAbort(err({ type: "AbortError", reason: signal.reason }));
88
+ };
89
+ signal.addEventListener("abort", handleAbort, { once: true });
90
+ // No finally: we expect no throws in normal flow; Result path removes listener.
91
+ // Unexpected throws indicate a bug and are allowed to crash (no recovery here).
92
+ return Promise.race([
93
+ abortPromise,
94
+ fn(context).then((result) => {
95
+ signal.removeEventListener("abort", handleAbort);
96
+ return result;
97
+ }),
98
+ ]);
99
+ });
100
+ // For React Native
101
+ if (typeof AbortSignal.timeout !== "function") {
102
+ AbortSignal.timeout = function (ms) {
103
+ const controller = new AbortController();
104
+ const id = setTimeout(() => {
105
+ controller.abort();
106
+ }, ms);
107
+ // clear timeout if aborted early
108
+ controller.signal.addEventListener("abort", () => {
109
+ clearTimeout(id);
110
+ });
111
+ return controller.signal;
112
+ };
113
+ }
114
+ /**
115
+ * Creates a {@link Task} that waits for the specified duration.
116
+ *
117
+ * ### Example
118
+ *
119
+ * ```ts
120
+ * const result1 = await wait("10ms")();
121
+ * result1 satisfies Result<void, never>;
122
+ *
123
+ * // With AbortController
124
+ * const controller = new AbortController();
125
+ * const result2 = await wait("10ms")(controller);
126
+ * result2 satisfies Result<void, AbortError>;
127
+ * ```
128
+ */
129
+ export const wait = (duration) => toTask((context) => new Promise((resolve) => {
130
+ const ms = durationToNonNegativeInt(duration);
131
+ const timeoutSignal = AbortSignal.timeout(ms);
132
+ const signal = combineSignal(context, timeoutSignal);
133
+ // Listen for abort - either from timeout completion or external abort
134
+ signal.addEventListener("abort", () => {
135
+ resolve(ok());
136
+ }, { once: true });
137
+ }));
138
+ /**
139
+ * Adds timeout behavior to a {@link Task}.
140
+ *
141
+ * ### Example
142
+ *
143
+ * ```ts
144
+ * interface FetchError {
145
+ * readonly type: "FetchError";
146
+ * readonly error: unknown;
147
+ * }
148
+ *
149
+ * // Task version of fetch with proper error handling and cancellation support.
150
+ * const fetch = (url: string) =>
151
+ * toTask((context) =>
152
+ * tryAsync(
153
+ * () => globalThis.fetch(url, { signal: context?.signal ?? null }),
154
+ * (error): FetchError => ({ type: "FetchError", error }),
155
+ * ),
156
+ * );
157
+ *
158
+ * // `satisfies` shows the expected type signature.
159
+ * fetch satisfies (url: string) => Task<Response, FetchError>;
160
+ *
161
+ * const fetchWithTimeout = (url: string) => timeout("2m", fetch(url));
162
+ *
163
+ * const result1 = await fetchWithTimeout("https://api.example.com/data")();
164
+ * result1 satisfies Result<Response, FetchError | TimeoutError>;
165
+ *
166
+ * // With AbortController
167
+ * const controller = new AbortController();
168
+ * const result2 = await fetchWithTimeout("https://api.example.com/data")(
169
+ * controller,
170
+ * );
171
+ * result2 satisfies Result<
172
+ * Response,
173
+ * FetchError | TimeoutError | AbortError
174
+ * >;
175
+ * ```
176
+ */
177
+ export const timeout = (duration, task) => toTask(async (context) => {
178
+ const timeoutMs = durationToNonNegativeInt(duration);
179
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
180
+ const signal = combineSignal(context, timeoutSignal);
181
+ const result = await task({ signal });
182
+ if (timeoutSignal.aborted) {
183
+ return err({ type: "TimeoutError", timeoutMs });
184
+ }
185
+ return result;
186
+ });
187
+ /**
188
+ * Adds retry logic with exponential backoff and jitter to a {@link Task}.
189
+ *
190
+ * ### Example
191
+ *
192
+ * ```ts
193
+ * interface FetchError {
194
+ * readonly type: "FetchError";
195
+ * readonly error: unknown;
196
+ * }
197
+ *
198
+ * // Task version of fetch with proper error handling and cancellation support.
199
+ * const fetch = (url: string) =>
200
+ * toTask((context) =>
201
+ * tryAsync(
202
+ * () => globalThis.fetch(url, { signal: context?.signal ?? null }),
203
+ * (error): FetchError => ({ type: "FetchError", error }),
204
+ * ),
205
+ * );
206
+ *
207
+ * // `satisfies` shows the expected type signature.
208
+ * fetch satisfies (url: string) => Task<Response, FetchError>;
209
+ *
210
+ * const fetchWithRetry = (url: string) =>
211
+ * retry({ retries: PositiveInt.orThrow(3) }, fetch(url));
212
+ *
213
+ * const result1 = await fetchWithRetry("https://api.example.com/data")();
214
+ * result1 satisfies Result<Response, FetchError | RetryError<FetchError>>;
215
+ *
216
+ * // With AbortController
217
+ * const controller = new AbortController();
218
+ * const result2 = await fetchWithRetry("https://api.example.com/data")(
219
+ * controller,
220
+ * );
221
+ * result2 satisfies Result<
222
+ * Response,
223
+ * FetchError | RetryError<FetchError> | AbortError
224
+ * >;
225
+ * ```
226
+ */
227
+ export const retry = ({ retries, initialDelay = "1s", maxDelay = "30s", factor = 2, jitter = 0.5, retryable = (error) => !isAbortError(error), onRetry, }, task) => toTask(async (context) => {
228
+ const initialDelayMs = durationToNonNegativeInt(initialDelay);
229
+ const maxDelayMs = durationToNonNegativeInt(maxDelay);
230
+ const maxRetries = PositiveInt.orThrow(retries);
231
+ let attempt = 0;
232
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
233
+ while (true) {
234
+ const result = await task(context);
235
+ if (result.ok) {
236
+ return result;
237
+ }
238
+ // Never retry on AbortError; propagate it directly
239
+ if (isAbortError(result.error)) {
240
+ return err(result.error);
241
+ }
242
+ attempt += 1;
243
+ if (attempt > maxRetries || !retryable(result.error)) {
244
+ return err({
245
+ type: "RetryError",
246
+ cause: result.error,
247
+ attempts: attempt,
248
+ });
249
+ }
250
+ // Calculate delay with exponential backoff
251
+ const exponentialDelay = initialDelayMs * Math.pow(factor, attempt - 1);
252
+ const cappedDelay = Math.min(exponentialDelay, maxDelayMs);
253
+ // Apply jitter to prevent thundering herd problem
254
+ const randomFactor = 1 - jitter + Math.random() * jitter * 2;
255
+ const delay = Math.floor(cappedDelay * randomFactor);
256
+ if (onRetry) {
257
+ onRetry(result.error, attempt, delay);
258
+ }
259
+ // Wait before retry
260
+ const delayResult = await wait(NonNegativeInt.orThrow(delay))(context);
261
+ if (!delayResult.ok) {
262
+ // If delay was aborted, return AbortError (will be handled by toTask)
263
+ return delayResult;
264
+ }
265
+ }
266
+ });
267
+ /**
268
+ * Creates a semaphore that limits concurrent async Tasks to the specified
269
+ * count.
270
+ *
271
+ * A semaphore controls access to a resource by maintaining a count of available
272
+ * permits. Tasks acquire a permit before executing and release it when
273
+ * complete.
274
+ *
275
+ * For mutual exclusion (exactly one Task at a time), consider using
276
+ * {@link createMutex} instead.
277
+ *
278
+ * ### Example
279
+ *
280
+ * ```ts
281
+ * // Allow maximum 3 concurrent Tasks
282
+ * const semaphore = createSemaphore(PositiveInt.orThrow(3));
283
+ *
284
+ * let currentConcurrent = 0;
285
+ * const events: Array<string> = [];
286
+ *
287
+ * const fetchData = (id: number) =>
288
+ * toTask<number, never>(async (context) => {
289
+ * currentConcurrent++;
290
+ * events.push(`start ${id} (concurrent: ${currentConcurrent})`);
291
+ *
292
+ * await wait("10ms")(context);
293
+ *
294
+ * currentConcurrent--;
295
+ * events.push(`end ${id} (concurrent: ${currentConcurrent})`);
296
+ * return ok(id * 10);
297
+ * });
298
+ *
299
+ * // These will execute with at most 3 running concurrently
300
+ * const results = await Promise.all([
301
+ * semaphore.withPermit(fetchData(1))(),
302
+ * semaphore.withPermit(fetchData(2))(),
303
+ * semaphore.withPermit(fetchData(3))(),
304
+ * semaphore.withPermit(fetchData(4))(), // waits for one above to complete
305
+ * semaphore.withPermit(fetchData(5))(), // waits for permit
306
+ * ]);
307
+ *
308
+ * expect(results.map(getOrThrow)).toEqual([10, 20, 30, 40, 50]);
309
+ * expect(events).toMatchInlineSnapshot(`
310
+ * [
311
+ * "start 1 (concurrent: 1)",
312
+ * "start 2 (concurrent: 2)",
313
+ * "start 3 (concurrent: 3)",
314
+ * "end 1 (concurrent: 2)",
315
+ * "start 4 (concurrent: 3)",
316
+ * "end 2 (concurrent: 2)",
317
+ * "start 5 (concurrent: 3)",
318
+ * "end 3 (concurrent: 2)",
319
+ * "end 4 (concurrent: 1)",
320
+ * "end 5 (concurrent: 0)",
321
+ * ]
322
+ * `);
323
+ * ```
324
+ */
325
+ export const createSemaphore = (maxConcurrent) => {
326
+ let isDisposed = false;
327
+ let availablePermits = maxConcurrent;
328
+ const waitingQueue = [];
329
+ const semaphoreController = new AbortController();
330
+ const acquire = () => {
331
+ if (availablePermits > 0) {
332
+ availablePermits--;
333
+ return Promise.resolve();
334
+ }
335
+ return new Promise((resolve) => {
336
+ waitingQueue.push(resolve);
337
+ });
338
+ };
339
+ const release = () => {
340
+ if (isNonEmptyArray(waitingQueue)) {
341
+ shiftArray(waitingQueue)();
342
+ }
343
+ else {
344
+ availablePermits++;
345
+ }
346
+ };
347
+ return {
348
+ withPermit: (task) => toTask(async (context) => {
349
+ await acquire();
350
+ // Check if semaphore was disposed while waiting
351
+ if (isDisposed) {
352
+ return err({
353
+ type: "AbortError",
354
+ reason: "Semaphore disposed",
355
+ });
356
+ }
357
+ const signal = combineSignal(context, semaphoreController.signal);
358
+ const result = await task({ signal });
359
+ release();
360
+ return result;
361
+ }),
362
+ [Symbol.dispose]: () => {
363
+ if (isDisposed)
364
+ return;
365
+ isDisposed = true;
366
+ // Cancel all running and waiting tasks
367
+ semaphoreController.abort("Semaphore disposed");
368
+ // Release all waiting tasks so they can continue and check isDisposed
369
+ while (isNonEmptyArray(waitingQueue)) {
370
+ shiftArray(waitingQueue)();
371
+ }
372
+ },
373
+ };
374
+ };
375
+ /**
376
+ * Creates a new mutex for ensuring mutual exclusion.
377
+ *
378
+ * A mutex is a {@link createSemaphore} with exactly one permit, ensuring that
379
+ * only one Task can execute at a time.
380
+ *
381
+ * ### Example
382
+ *
383
+ * ```ts
384
+ * const mutex = createMutex();
385
+ *
386
+ * const updateTask = (id: number) =>
387
+ * toTask((context) =>
388
+ * tryAsync(
389
+ * () => updateSharedResource(id, context),
390
+ * (error): UpdateError => ({ type: "UpdateError", error }),
391
+ * ),
392
+ * );
393
+ *
394
+ * // These Tasks will execute one at a time
395
+ * const results = await Promise.all([
396
+ * mutex.withLock(updateTask(1))(),
397
+ * mutex.withLock(updateTask(2))(),
398
+ * mutex.withLock(updateTask(3))(),
399
+ * ]);
400
+ * ```
401
+ */
402
+ export const createMutex = () => {
403
+ const mutex = createSemaphore(PositiveInt.orThrow(1));
404
+ return {
405
+ withLock: mutex.withPermit,
406
+ [Symbol.dispose]: mutex[Symbol.dispose],
407
+ };
408
+ };
409
+ /**
410
+ * Schedule a task to run after all interactions (animations, gestures,
411
+ * navigation) have completed.
412
+ *
413
+ * This uses `requestIdleCallback` when available, otherwise falls back to
414
+ * `setTimeout(0)` for cross-platform compatibility.
415
+ *
416
+ * ### Example
417
+ *
418
+ * ```ts
419
+ * const processDataTask: Task<void, ProcessError> = toTask(async () => {
420
+ * // Heavy processing work
421
+ * return ok();
422
+ * });
423
+ *
424
+ * // Schedule the task to run when idle
425
+ * void requestIdleTask(processDataTask)();
426
+ * ```
427
+ */
428
+ export const requestIdleTask = (task) => toTask(async (context) => new Promise((resolve) => {
429
+ idleCallback(() => {
430
+ void task(context).then(resolve);
431
+ });
432
+ }));
433
+ const idleCallback = typeof globalThis.requestIdleCallback === "function"
434
+ ? globalThis.requestIdleCallback
435
+ : (callback) => setTimeout(callback, 0);
436
+ /**
437
+ * Type guard to check if a {@link MaybeAsync} value is async (a promise).
438
+ *
439
+ * This function narrows the type of a {@link MaybeAsync} value, allowing you to
440
+ * conditionally `await` only when necessary.
441
+ *
442
+ * ### Example
443
+ *
444
+ * ```ts
445
+ * const getData = (id: string): MaybeAsync<Data> => {
446
+ * const cached = cache.get(id);
447
+ * if (cached) return cached; // Sync path
448
+ * return fetchData(id); // Async path
449
+ * };
450
+ *
451
+ * const result = getData(id);
452
+ * const data = isAsync(result) ? await result : result;
453
+ * // No microtask overhead when cached!
454
+ * ```
455
+ */
456
+ export const isAsync = (value) =>
457
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
458
+ typeof value?.then === "function";
459
+ // TODO: Add tracing support
460
+ // - Extend TaskContext with optional tracing field
461
+ // - Add traced(name, task) helper that wraps Task execution
462
+ // - Collect span data (name, timing, parent-child relationships, status)
463
+ // - Support OpenTelemetry export format with proper traceId/spanId generation
464
+ // - Automatic parent-child span relationships through context propagation
@@ -1,20 +1,80 @@
1
- /**
2
- * ⏳
3
- *
4
- * @module
5
- */
1
+ import { DateIso, NonNegativeInt } from "./Type.js";
6
2
  /** Retrieves the current time in milliseconds, similar to `Date.now()`. */
7
3
  export interface Time {
8
4
  readonly now: () => number;
5
+ readonly nowIso: () => DateIso;
9
6
  }
10
7
  export interface TimeDep {
11
8
  readonly time: Time;
12
9
  }
13
- /** Creates a {@link Time} using Date.now(). */
10
+ /**
11
+ * Creates a {@link Time} using Date.now().
12
+ *
13
+ * If the system clock is misconfigured (out of allowed range), the application
14
+ * will fail with an assertion error. This is intentional - there's no
15
+ * reasonable fallback when the system clock is fundamentally wrong.
16
+ */
14
17
  export declare const createTime: () => Time;
15
18
  /**
16
19
  * Creates a {@link Time} that returns a monotonically increasing number based on
17
20
  * a queueMicrotask.
18
21
  */
19
22
  export declare const createTestTime: () => Time;
23
+ /** Single digit 0-9. Used internally for {@link DurationString} validation. */
24
+ export type D = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
25
+ /**
26
+ * Minutes and seconds 1-59. Used internally for {@link DurationString}
27
+ * validation. Uses single digits for 1-9, full numbers for 10-59.
28
+ */
29
+ export type MmSs = Exclude<D, "0"> | `1${D}` | `2${D}` | `3${D}` | `4${D}` | `5${D}`;
30
+ /** Hours 1-23. Used internally for {@link DurationString} validation. */
31
+ export type Hours = Exclude<D, "0"> | `1${D}` | `2${"0" | "1" | "2" | "3"}`;
32
+ /** Days 1-99. Used internally for {@link DurationString} validation. */
33
+ export type Days = Exclude<D, "0"> | `${Exclude<D, "0">}${D}`;
34
+ /**
35
+ * Template literal type for compile-time validated duration strings.
36
+ *
37
+ * Allowed patterns: basic units (ms, s, m, h, d) and logical combinations
38
+ * (s+ms, m+s, h+m, d+h).
39
+ *
40
+ * Supported formats:
41
+ *
42
+ * - Milliseconds: `0ms`, `1ms`, `500ms`, `999ms`
43
+ * - Seconds: `5s`, `30s` (1-59, single digit for 1-9)
44
+ * - Minutes: `5m`, `30m` (1-59, single digit for 1-9)
45
+ * - Hours: `1h`, `12h`, `23h` (1-23)
46
+ * - Days: `1d`, `30d`, `99d` (1-99)
47
+ * - Combinations: `1s 250ms`, `30m 15s`, `2h 45m`, `7d 12h`
48
+ *
49
+ * Note: Duration strings are for developer experience only - they provide
50
+ * readable, compile-time validated expressions but should never be persisted or
51
+ * parsed from users as they are not localized. Always convert to NonNegativeInt
52
+ * (milliseconds) for storage and APIs.
53
+ */
54
+ export type DurationString = `${D}ms` | `${D}${D}ms` | `${D}${D}${D}ms` | `${MmSs}s` | `${MmSs}m` | `${Hours}h` | `${Days}d` | `${MmSs}s ${D}ms` | `${MmSs}s ${D}${D}ms` | `${MmSs}s ${D}${D}${D}ms` | `${MmSs}m ${MmSs}s` | `${Hours}h ${MmSs}m` | `${Days}d ${Hours}h`;
55
+ /**
56
+ * Duration can be either a {@link DurationString} or milliseconds as
57
+ * {@link NonNegativeInt}.
58
+ */
59
+ export type Duration = DurationString | NonNegativeInt;
60
+ /**
61
+ * Converts a duration to milliseconds.
62
+ *
63
+ * Accepts either a {@link DurationString} (e.g., "5m", "1h 30m") or milliseconds
64
+ * as {@link NonNegativeInt}.
65
+ *
66
+ * ### Example
67
+ *
68
+ * ```ts
69
+ * durationToNonNegativeInt("0ms"); // 0 ✅
70
+ * durationToNonNegativeInt("500ms"); // 500 ✅
71
+ * durationToNonNegativeInt("30s"); // 30000 ✅
72
+ * durationToNonNegativeInt("5m"); // 300000 ✅
73
+ * durationToNonNegativeInt("12h"); // 43200000 ✅
74
+ * durationToNonNegativeInt("7d"); // 604800000 ✅
75
+ * durationToNonNegativeInt("2h 45m"); // 9900000 ✅
76
+ * durationToNonNegativeInt(5000); // 5000 ✅ (already milliseconds)
77
+ * ```
78
+ */
79
+ export declare const durationToNonNegativeInt: (duration: Duration) => NonNegativeInt;
20
80
  //# sourceMappingURL=Time.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Time.d.ts","sourceRoot":"","sources":["../../src/Time.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,2EAA2E;AAC3E,MAAM,WAAW,IAAI;IACnB,QAAQ,CAAC,GAAG,EAAE,MAAM,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;CACrB;AAED,+CAA+C;AAC/C,eAAO,MAAM,UAAU,QAAO,IAE5B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,cAAc,QAAO,IAWjC,CAAC"}
1
+ {"version":3,"file":"Time.d.ts","sourceRoot":"","sources":["../../src/Time.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEpD,2EAA2E;AAC3E,MAAM,WAAW,IAAI;IACnB,QAAQ,CAAC,GAAG,EAAE,MAAM,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC;CAChC;AAED,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;CACrB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,UAAU,QAAO,IAa7B,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,cAAc,QAAO,IAcjC,CAAC;AAEF,+EAA+E;AAC/E,MAAM,MAAM,CAAC,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;AAE1E;;;GAGG;AACH,MAAM,MAAM,IAAI,GACZ,OAAO,CAAC,CAAC,EAAE,GAAG,CAAC,GACf,IAAI,CAAC,EAAE,GACP,IAAI,CAAC,EAAE,GACP,IAAI,CAAC,EAAE,GACP,IAAI,CAAC,EAAE,GACP,IAAI,CAAC,EAAE,CAAC;AAEZ,yEAAyE;AACzE,MAAM,MAAM,KAAK,GACb,OAAO,CAAC,CAAC,EAAE,GAAG,CAAC,GACf,IAAI,CAAC,EAAE,GACP,IAAI,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,EAAE,CAAC;AAEhC,wEAAwE;AACxE,MAAM,MAAM,IAAI,GAAG,OAAO,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,GAAG,OAAO,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;AAE9D;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,MAAM,cAAc,GACtB,GAAG,CAAC,IAAI,GACR,GAAG,CAAC,GAAG,CAAC,IAAI,GACZ,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,GAChB,GAAG,IAAI,GAAG,GACV,GAAG,IAAI,GAAG,GACV,GAAG,KAAK,GAAG,GACX,GAAG,IAAI,GAAG,GACV,GAAG,IAAI,KAAK,CAAC,IAAI,GACjB,GAAG,IAAI,KAAK,CAAC,GAAG,CAAC,IAAI,GACrB,GAAG,IAAI,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,GACzB,GAAG,IAAI,KAAK,IAAI,GAAG,GACnB,GAAG,KAAK,KAAK,IAAI,GAAG,GACpB,GAAG,IAAI,KAAK,KAAK,GAAG,CAAC;AAEzB;;;GAGG;AACH,MAAM,MAAM,QAAQ,GAAG,cAAc,GAAG,cAAc,CAAC;AAEvD;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,wBAAwB,GACnC,UAAU,QAAQ,KACjB,cA+DF,CAAC"}
package/dist/src/Time.js CHANGED
@@ -1,19 +1,33 @@
1
+ import { assert } from "./Assert.js";
2
+ import { DateIso, NonNegativeInt } from "./Type.js";
1
3
  /**
2
- *
4
+ * Creates a {@link Time} using Date.now().
3
5
  *
4
- * @module
6
+ * If the system clock is misconfigured (out of allowed range), the application
7
+ * will fail with an assertion error. This is intentional - there's no
8
+ * reasonable fallback when the system clock is fundamentally wrong.
5
9
  */
6
- /** Creates a {@link Time} using Date.now(). */
7
- export const createTime = () => ({
8
- now: () => Date.now(),
9
- });
10
+ export const createTime = () => {
11
+ const time = {
12
+ now: () => {
13
+ const iso = time.nowIso();
14
+ return new globalThis.Date(iso).getTime();
15
+ },
16
+ nowIso: () => {
17
+ const iso = new globalThis.Date().toISOString();
18
+ assert(DateIso.is(iso), "System clock returned invalid ISO date");
19
+ return iso;
20
+ },
21
+ };
22
+ return time;
23
+ };
10
24
  /**
11
25
  * Creates a {@link Time} that returns a monotonically increasing number based on
12
26
  * a queueMicrotask.
13
27
  */
14
28
  export const createTestTime = () => {
15
29
  let now = 0;
16
- return {
30
+ const time = {
17
31
  now: () => {
18
32
  const current = now;
19
33
  queueMicrotask(() => {
@@ -21,5 +35,80 @@ export const createTestTime = () => {
21
35
  });
22
36
  return current;
23
37
  },
38
+ nowIso: () => DateIso.orThrow(new globalThis.Date(time.now()).toISOString()),
39
+ };
40
+ return time;
41
+ };
42
+ /**
43
+ * Converts a duration to milliseconds.
44
+ *
45
+ * Accepts either a {@link DurationString} (e.g., "5m", "1h 30m") or milliseconds
46
+ * as {@link NonNegativeInt}.
47
+ *
48
+ * ### Example
49
+ *
50
+ * ```ts
51
+ * durationToNonNegativeInt("0ms"); // 0 ✅
52
+ * durationToNonNegativeInt("500ms"); // 500 ✅
53
+ * durationToNonNegativeInt("30s"); // 30000 ✅
54
+ * durationToNonNegativeInt("5m"); // 300000 ✅
55
+ * durationToNonNegativeInt("12h"); // 43200000 ✅
56
+ * durationToNonNegativeInt("7d"); // 604800000 ✅
57
+ * durationToNonNegativeInt("2h 45m"); // 9900000 ✅
58
+ * durationToNonNegativeInt(5000); // 5000 ✅ (already milliseconds)
59
+ * ```
60
+ */
61
+ export const durationToNonNegativeInt = (duration) => {
62
+ // If it's already a NonNegativeInt (milliseconds), return as-is
63
+ if (typeof duration === "number") {
64
+ return duration;
65
+ }
66
+ // Parse duration string without regex to avoid ReDoS vulnerabilities
67
+ const units = {
68
+ ms: 1,
69
+ s: 1000,
70
+ m: 60000,
71
+ h: 3600000,
72
+ d: 86400000, // 24 * 60 * 60 * 1000
24
73
  };
74
+ let total = 0;
75
+ let i = 0;
76
+ while (i < duration.length) {
77
+ // Skip whitespace
78
+ while (i < duration.length && duration[i] === " ") {
79
+ i++;
80
+ }
81
+ if (i >= duration.length)
82
+ break;
83
+ // Parse number
84
+ let numStr = "";
85
+ while (i < duration.length && duration[i] >= "0" && duration[i] <= "9") {
86
+ numStr += duration[i];
87
+ i++;
88
+ }
89
+ if (numStr === "")
90
+ break;
91
+ // Parse unit (ms or single char s/m/h/d)
92
+ let unit = "";
93
+ if (i < duration.length) {
94
+ if (duration[i] === "m" &&
95
+ i + 1 < duration.length &&
96
+ duration[i + 1] === "s") {
97
+ unit = "ms";
98
+ i += 2;
99
+ }
100
+ else if (duration[i] === "s" ||
101
+ duration[i] === "m" ||
102
+ duration[i] === "h" ||
103
+ duration[i] === "d") {
104
+ unit = duration[i];
105
+ i++;
106
+ }
107
+ }
108
+ if (unit === "")
109
+ break;
110
+ const value = parseInt(numStr, 10);
111
+ total += value * units[unit];
112
+ }
113
+ return NonNegativeInt.orThrow(total);
25
114
  };