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