@asaidimu/utils-sync 1.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.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Saidimu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,413 @@
1
+ # `@asaidimu/utils-sync`
2
+
3
+ > **Synchronization primitives for TypeScript/JavaScript** – Mutex, Once, and Serializer utilities with fine‑grained concurrency control.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@asaidimu/utils-sync.svg)](https://www.npmjs.com/package/@asaidimu/utils-sync)
6
+ [![license](https://img.shields.io/npm/l/@asaidimu/utils-sync.svg)](https://github.com/asaidimu/erp-utils/blob/main/LICENSE)
7
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue)](https://www.typescriptlang.org/)
8
+ [![vitest](https://img.shields.io/badge/tested%20with-vitest-6b9f3e)](https://vitest.dev/)
9
+
10
+ ## Table of Contents
11
+
12
+ - [Overview & Features](#overview--features)
13
+ - [Installation & Setup](#installation--setup)
14
+ - [Usage Documentation](#usage-documentation)
15
+ - [Mutex](#mutex)
16
+ - [Once](#once)
17
+ - [Serializer](#serializer)
18
+ - [Project Architecture](#project-architecture)
19
+ - [Development & Contributing](#development--contributing)
20
+ - [Additional Information](#additional-information)
21
+
22
+ ---
23
+
24
+ ## Overview & Features
25
+
26
+ Modern JavaScript applications often face subtle concurrency issues – race conditions, duplicate work, or unexpected interleaving of async operations. `@asaidimu/utils-sync` provides three battle‑tested primitives to tame asynchronous chaos:
27
+
28
+ - **`Mutex`** – A mutual exclusion lock that allows only one task at a time to access a shared resource. Configurable handoff scheduling (microtask vs macrotask) prevents microtask starvation under heavy contention.
29
+ - **`Once`** – Guarantees that a given asynchronous operation runs exactly once, even when called concurrently from many places. Ideal for lazy initialisation, cache population, or one‑time setup. Supports optional retry on failure and sync/async functions.
30
+ - **`Serializer`** – Processes queued tasks sequentially (FIFO) while maintaining the last successful result. Built‑in backpressure protection and the ability to permanently close the queue. Perfect for rate‑limited APIs, write serialisation, or sequential job processing.
31
+
32
+ All utilities are written in strict TypeScript, fully typed, and come with zero runtime dependencies.
33
+
34
+ ### Key Features
35
+
36
+ - **Mutual exclusion** – `Mutex` with optional timeout and queue capacity limits.
37
+ - **Configurable yield behaviour** – Choose `"macrotask"` (default, prevents starvation) or `"microtask"` (zero‑delay handoff) per instance.
38
+ - **Once‑only execution** – `Once` deduplicates concurrent calls, caches success/failure, and optionally retries on error.
39
+ - **Sequential task processing** – `Serializer` maintains order, provides last‑result peeking, and can be closed permanently.
40
+ - **Timeout support** – All operations accept a timeout parameter (lock acquisition + execution).
41
+ - **Backpressure** – Configurable queue size to prevent uncontrolled growth.
42
+ - **Tiny & focused** – No external dependencies, tree‑shakeable exports.
43
+ - **First‑class TypeScript** – Generics, error types, and accurate return types.
44
+
45
+ ---
46
+
47
+ ## Installation & Setup
48
+
49
+ ### Prerequisites
50
+
51
+ - **Node.js** 18+ (or any modern environment with `Promise`, `queueMicrotask`, and `setTimeout`)
52
+ - **TypeScript** 4.7+ (if using types, but not required)
53
+
54
+ ### Installation
55
+
56
+ ```bash
57
+ npm install @asaidimu/utils-sync
58
+ ```
59
+
60
+ ```bash
61
+ pnpm add @asaidimu/utils-sync
62
+ ```
63
+
64
+ ```bash
65
+ yarn add @asaidimu/utils-sync
66
+ ```
67
+
68
+ ### Verification
69
+
70
+ After installation, you can test that the library works correctly:
71
+
72
+ ```typescript
73
+ import { Mutex } from '@asaidimu/utils-sync';
74
+
75
+ const mutex = new Mutex();
76
+ console.log(mutex.locked()); // false
77
+ ```
78
+
79
+ If the import runs without errors, the package is ready.
80
+
81
+ ---
82
+
83
+ ## Usage Documentation
84
+
85
+ All examples assume ES module import syntax:
86
+
87
+ ```typescript
88
+ import { Mutex, Once, Serializer } from '@asaidimu/utils-sync';
89
+ ```
90
+
91
+ For CommonJS:
92
+
93
+ ```javascript
94
+ const { Mutex, Once, Serializer } = require('@asaidimu/utils-sync');
95
+ ```
96
+
97
+ ---
98
+
99
+ ### Mutex
100
+
101
+ A mutual exclusion lock. Use it to protect critical sections where only one async operation should run at a time.
102
+
103
+ #### Basic example
104
+
105
+ ```typescript
106
+ const mutex = new Mutex();
107
+
108
+ async function criticalSection() {
109
+ await mutex.lock();
110
+ try {
111
+ // Only one caller executes this block at a time
112
+ await doSomething();
113
+ } finally {
114
+ mutex.unlock();
115
+ }
116
+ }
117
+ ```
118
+
119
+ #### With timeout
120
+
121
+ ```typescript
122
+ try {
123
+ await mutex.lock(1000); // wait max 1 second
124
+ // ... work ...
125
+ mutex.unlock();
126
+ } catch (err) {
127
+ if (err instanceof TimeoutError) {
128
+ console.log('Could not acquire lock in time');
129
+ }
130
+ }
131
+ ```
132
+
133
+ #### Non‑blocking attempt
134
+
135
+ ```typescript
136
+ if (mutex.tryLock()) {
137
+ try {
138
+ // lock acquired immediately
139
+ } finally {
140
+ mutex.unlock();
141
+ }
142
+ } else {
143
+ // lock was already held – do something else
144
+ }
145
+ ```
146
+
147
+ #### Options
148
+
149
+ | Option | Type | Default | Description |
150
+ |--------------|--------------------------|---------------|-----------------------------------------------------------------------------------------------|
151
+ | `capacity` | `number` | `Infinity` | Max pending waiters. If exceeded, `lock()` throws an error. |
152
+ | `yieldMode` | `"macrotask"` \| `"microtask"` | `"macrotask"` | `"macrotask"` yields via `setTimeout(…,0)` (prevents starvation). `"microtask"` uses `queueMicrotask` for lower latency. |
153
+
154
+ #### API
155
+
156
+ | Method | Return type | Description |
157
+ |------------------------------|-----------------------|---------------------------------------------------------------------------------------------------------|
158
+ | `lock(timeout?: number)` | `Promise<void>` | Acquire lock, waiting if necessary. Throws `TimeoutError` if timeout elapses or queue is full. |
159
+ | `tryLock()` | `boolean` | Attempt to acquire lock without waiting. Returns `true` if acquired. |
160
+ | `unlock()` | `void` | Release the lock. Throws if not locked. Schedules next waiter according to `yieldMode`. |
161
+ | `locked()` | `boolean` | Returns `true` if the lock is currently held. |
162
+ | `pending()` | `number` | Number of tasks waiting for the lock. |
163
+
164
+ ---
165
+
166
+ ### Once
167
+
168
+ Guarantees a function runs exactly once, even when many callers invoke `do()` concurrently. The result (or error) is cached and returned to all future callers.
169
+
170
+ #### Basic example
171
+
172
+ ```typescript
173
+ const once = new Once<string>();
174
+
175
+ async function getConfig() {
176
+ const result = await once.do(async () => {
177
+ const res = await fetch('/api/config');
178
+ return res.json();
179
+ });
180
+ // result.value contains the config, or result.error if failed
181
+ return result.value;
182
+ }
183
+ ```
184
+
185
+ #### With retry on failure
186
+
187
+ ```typescript
188
+ const once = new Once<string>({ retry: true });
189
+
190
+ // If the first attempt fails, the next call will retry
191
+ await once.do(failingFn); // fails, but _done = false
192
+ await once.do(successFn); // runs again, succeeds, caches result
193
+ ```
194
+
195
+ #### Synchronous functions
196
+
197
+ ```typescript
198
+ const once = new Once<number>();
199
+ const result = await once.do(() => 42); // works with sync return
200
+ ```
201
+
202
+ #### Checking state without awaiting
203
+
204
+ ```typescript
205
+ if (once.isReady()) {
206
+ const { value, error } = once.peek();
207
+ // safely inspect cached result
208
+ }
209
+ ```
210
+
211
+ #### Options
212
+
213
+ | Option | Type | Default | Description |
214
+ |-----------|-----------|---------|----------------------------------------------------------------------------------|
215
+ | `retry` | `boolean` | `false` | If `true`, a failed execution does **not** mark the instance as done – next call will retry. |
216
+ | `throws` | `boolean` | `false` | If `true`, the `do()` method will **throw** the error instead of returning it in the result object. |
217
+
218
+ #### API
219
+
220
+ | Method | Return type | Description |
221
+ |-----------------------------------|-------------------------------------|---------------------------------------------------------------------------------------------------|
222
+ | `do(fn, timeout?)` | `Promise<OnceResult<T>>` | Executes `fn` once. Returns `{ value, error }` (unless `throws:true`). Timeout covers lock + execution. |
223
+ | `isReady()` | `boolean` | `true` if operation has completed (success or non‑retryable failure) and no execution is running. |
224
+ | `running()` | `boolean` | `true` if the operation is currently executing. |
225
+ | `peek()` | `OnceResult<T>` | Returns current cached `{ value, error }` without waiting. |
226
+ | `get()` | `T \| null` | Returns cached value if done, otherwise throws. Throws cached error if present. |
227
+ | `reset()` | `void` | Clears state – next `do()` will run again. |
228
+ | `done()` | `boolean` | `true` if finished (success or final failure). |
229
+ | `resolved()` | `Promise<OnceResult<T>> \| null` | Returns the underlying promise if running or done, otherwise `null`. |
230
+
231
+ ---
232
+
233
+ ### Serializer
234
+
235
+ Processes tasks sequentially (FIFO order). Each task runs only after all previous tasks have completed. Use it to serialise writes to a file, throttle API calls, or enforce ordering.
236
+
237
+ #### Basic example
238
+
239
+ ```typescript
240
+ const serializer = new Serializer<string>();
241
+
242
+ async function log(message: string) {
243
+ const result = await serializer.do(async () => {
244
+ await appendToFile('log.txt', message);
245
+ return message;
246
+ });
247
+ return result.value; // last successful result
248
+ }
249
+ ```
250
+
251
+ #### Handling failures
252
+
253
+ Even if a task fails, the serializer continues processing the next queued tasks:
254
+
255
+ ```typescript
256
+ await serializer.do(failingFn); // returns { error: ... }
257
+ await serializer.do(successfulFn); // still runs
258
+ ```
259
+
260
+ #### Peeking at the last result
261
+
262
+ ```typescript
263
+ const { value, error } = serializer.peek();
264
+ ```
265
+
266
+ #### Closing the serializer permanently
267
+
268
+ ```typescript
269
+ serializer.close();
270
+ const result = await serializer.do(anyFn);
271
+ // result.error instanceof SerializerExecutionDone
272
+ ```
273
+
274
+ #### Options
275
+
276
+ | Option | Type | Default | Description |
277
+ |--------------|--------------------------|------------|------------------------------------------------------------------|
278
+ | `capacity` | `number` | `1000` | Max pending tasks. When full, `do()` returns an error immediately. |
279
+ | `yieldMode` | `"macrotask"` \| `"microtask"` | `"macrotask"` | Handoff scheduling for the internal mutex. Default prevents microtask starvation. |
280
+
281
+ #### API
282
+
283
+ | Method | Return type | Description |
284
+ |-------------------------------|--------------------------------------|------------------------------------------------------------------------------------------------------|
285
+ | `do(fn, timeout?)` | `Promise<SerializerResult<T\|null>>` | Enqueues `fn`. Returns `{ value, error }`. If closed or queue full, error is `SerializerExecutionDone`. |
286
+ | `peek()` | `SerializerResult<T\|null>` | Returns the last successful result or last error. |
287
+ | `close()` | `void` | Permanently closes the serializer. All subsequent `do()` calls fail immediately. |
288
+ | `pending()` | `number` | Number of tasks waiting in the queue. |
289
+ | `running()` | `boolean` | `true` if a task is currently executing. |
290
+
291
+ ---
292
+
293
+ ## Project Architecture
294
+
295
+ The library is written in **TypeScript** and follows a simple, functional‑object design. Each class is independent and does not rely on shared global state.
296
+
297
+ ### Core Components
298
+
299
+ - **`Mutex`** – Implements the lock with a FIFO waiter queue. Handoff uses either `setTimeout` (macrotask) or `queueMicrotask` to give callers control over fairness vs. latency.
300
+ - **`Once`** – Built on top of `Mutex` with `microtask` yield mode for minimal overhead. Tracks execution state (`_done`, `_value`, `_error`) and returns a cached promise to concurrent callers.
301
+ - **`Serializer`** – Also uses `Mutex` (default `macrotask` yield) to serialise work. Maintains the last result and supports backpressure via `capacity`.
302
+
303
+ ### Data Flow
304
+
305
+ 1. **Mutex** – Callers invoke `lock()`. If unlocked, they acquire immediately. Otherwise they are added to `waiters`. When `unlock()` is called, the next waiter is scheduled according to `yieldMode`.
306
+ 2. **Once** – First caller acquires the mutex, runs the function, and stores the promise. Later callers see the existing promise and await it directly (no mutex contention). After completion, the promise is cleared and `_done` is set.
307
+ 3. **Serializer** – Each `do()` call attempts to lock the internal mutex. Only one task holds the lock at a time. When a task finishes (success or error), the lock is released, allowing the next queued task to run.
308
+
309
+ ### Extension Points
310
+
311
+ The library is designed to be used as‑is, but you can easily compose the primitives:
312
+
313
+ - Use `Mutex` to build your own synchronisation patterns (e.g., read‑write locks).
314
+ - Extend `Once` or `Serializer` by subclassing (both are standard ES6 classes).
315
+ - Replace the underlying promise scheduling by providing a custom `Mutex` with different `yieldMode` logic (though the built‑in modes cover most needs).
316
+
317
+ ---
318
+
319
+ ## Development & Contributing
320
+
321
+ ### Development Setup
322
+
323
+ ```bash
324
+ git clone https://github.com/asaidimu/erp-utils.git
325
+ cd erp-utils/src/sync
326
+ npm install
327
+ ```
328
+
329
+ ### Scripts
330
+
331
+ | Command | Description |
332
+ |----------------------|--------------------------------------------------|
333
+ | `npm test` | Run tests once (Vitest) |
334
+ | `npm run test:watch` | Run tests in watch mode |
335
+ | `npm run test:browser` | Run tests in a browser environment (Vitest) |
336
+
337
+ ### Testing
338
+
339
+ Tests are written with **Vitest** and cover:
340
+
341
+ - `Once` – deduplication, retry behaviour, state transitions, error handling.
342
+ - `Serializer` – FIFO ordering, backpressure, closing, error resilience.
343
+ - `Mutex` – locking, timeout, capacity, yield modes (implicitly tested via Serializer and Once).
344
+
345
+ To run the full suite:
346
+
347
+ ```bash
348
+ npm test
349
+ ```
350
+
351
+ ### Contributing Guidelines
352
+
353
+ 1. **Fork** the repository and create a feature branch.
354
+ 2. **Write tests** for any new functionality or bug fixes.
355
+ 3. **Ensure existing tests pass** (`npm test`).
356
+ 4. **Follow the existing code style** (Prettier / ESLint – see root of monorepo).
357
+ 5. **Commit messages** should follow [Conventional Commits](https://www.conventionalcommits.org/) (e.g., `feat: add timeout to Mutex`).
358
+ 6. **Open a Pull Request** against the `main` branch.
359
+
360
+ ### Issue Reporting
361
+
362
+ Report bugs or request features via [GitHub Issues](https://github.com/asaidimu/erp-utils/issues). Please include:
363
+
364
+ - A clear description of the problem.
365
+ - Minimal code to reproduce (if bug).
366
+ - Environment details (Node version, package manager, OS).
367
+
368
+ ---
369
+
370
+ ## Additional Information
371
+
372
+ ### Troubleshooting
373
+
374
+ | Problem | Possible solution |
375
+ |----------------------------------------------|-------------------------------------------------------------------------------------------------------|
376
+ | `Mutex` lock never resolves | Check that `unlock()` is always called (e.g., use `try/finally`). |
377
+ | `Serializer` tasks stop running | Did you call `close()`? Once closed, all new tasks fail immediately. |
378
+ | `Once` returns stale error even after retry | Ensure `retry: true` is set. Without it, a failure marks `_done = true` and never retries. |
379
+ | `TimeoutError` when queue seems small | Increase `capacity` in `Mutex` or `Serializer` options. |
380
+ | Microtask starvation in high‑contention code | Set `yieldMode: "macrotask"` (default for `Serializer` and `Mutex`). `Once` uses microtask by design. |
381
+
382
+ ### FAQ
383
+
384
+ **Q: Can I use `Once` with a synchronous function?**
385
+ Yes – `Once.do()` accepts both `() => Promise<T>` and `() => T`. Synchronous return values are automatically wrapped in a resolved promise.
386
+
387
+ **Q: What happens if `Once.do()` times out?**
388
+ The timeout applies to the entire operation (including waiting for the mutex and execution). If a timeout occurs, the `do()` call rejects with `TimeoutError`, but the background execution (if already started) continues. Future callers will receive the final result.
389
+
390
+ **Q: Is `Serializer` safe for long‑running tasks?**
391
+ Absolutely. Tasks run sequentially, so a long task will delay subsequent tasks. Use `timeout` if you need to enforce a maximum wait per task.
392
+
393
+ **Q: Can I reuse a `Once` instance after a non‑retryable failure?**
394
+ Yes – call `reset()` to clear the cached error and allow a fresh execution.
395
+
396
+ **Q: Does `Mutex` re‑entrant?**
397
+ No – attempting to `lock()` from the same execution context that already holds the lock will deadlock. Use a single lock acquisition per critical section.
398
+
399
+ ### Changelog & Roadmap
400
+
401
+ See the [CHANGELOG.md](https://github.com/asaidimu/erp-utils/blob/main/CHANGELOG.md) for version history. Future plans include:
402
+
403
+ - `Semaphore` implementation.
404
+ - `AsyncCondition` variable.
405
+ - `DebouncedSerializer` for coalescing rapid consecutive calls.
406
+
407
+ ### License
408
+
409
+ [MIT](https://github.com/asaidimu/erp-utils/blob/main/LICENSE) © Saidimu
410
+
411
+ ### Acknowledgments
412
+
413
+ Inspired by similar synchronisation primitives in Rust (`Mutex`, `OnceCell`), Go (`sync.Mutex`), and the classic async patterns of the JavaScript ecosystem. Built with ❤️ using TypeScript and Vitest.
package/index.d.mts ADDED
@@ -0,0 +1,177 @@
1
+ interface MutexOptions {
2
+ /**
3
+ * Maximum number of pending requests allowed in the queue.
4
+ * If exceeded, tryLock/lock will fail.
5
+ * @default Infinity
6
+ */
7
+ capacity?: number;
8
+ /**
9
+ * Controls how lock handoff is scheduled when a waiter is unblocked.
10
+ *
11
+ * - `"macrotask"` (default): Uses setTimeout(fn, 0) to yield to the event
12
+ * loop between handoffs. Prevents microtask starvation under heavy
13
+ * contention — I/O, rendering, and other macrotasks can run between
14
+ * lock acquisitions. Use for invalidationSerializer and other
15
+ * coarse-grained serializers.
16
+ *
17
+ * - `"microtask"`: Uses queueMicrotask(fn) for handoff. Near-zero latency
18
+ * between acquisitions — no macrotask delay. Safe when you need
19
+ * back-to-back operations to complete as fast as possible and starvation
20
+ * is not a concern (e.g. buildOnce, streamSerializer where builds are
21
+ * infrequent and latency matters more than fairness).
22
+ */
23
+ yieldMode?: "macrotask" | "microtask";
24
+ }
25
+ /**
26
+ * A mutual exclusion lock.
27
+ * Allows only one execution context to access a resource at a time.
28
+ *
29
+ * Yield mode is configurable per instance:
30
+ * - "macrotask" (default): yields between handoffs, preventing microtask starvation.
31
+ * - "microtask": zero-delay handoff for latency-sensitive paths.
32
+ */
33
+ declare class Mutex {
34
+ private _locked;
35
+ private _capacity;
36
+ private _yieldMode;
37
+ private waiters;
38
+ constructor(options?: MutexOptions);
39
+ /**
40
+ * Acquires the lock. If already held, waits until released or timeout reached.
41
+ *
42
+ * @param timeout - Optional maximum wait time in milliseconds.
43
+ * @throws {TimeoutError} If the lock cannot be acquired within the timeout.
44
+ * @throws {Error} If the wait queue is full (backpressure).
45
+ */
46
+ lock(timeout?: number): Promise<void>;
47
+ /**
48
+ * Attempts to acquire the lock without waiting.
49
+ * @returns `true` if the lock was acquired, `false` otherwise.
50
+ */
51
+ tryLock(): boolean;
52
+ /**
53
+ * Releases the lock, scheduling the next waiter according to yieldMode.
54
+ * @throws {Error} If the mutex is not currently locked.
55
+ */
56
+ unlock(): void;
57
+ /** Returns true if the mutex is currently locked. */
58
+ locked(): boolean;
59
+ /** Returns the number of operations waiting for the lock. */
60
+ pending(): number;
61
+ }
62
+ type OnceResult<T> = {
63
+ value: T | null;
64
+ error?: unknown;
65
+ };
66
+ /**
67
+ * Ensures a specific task is executed exactly once, regardless of concurrent callers.
68
+ * Handles race conditions, caching, and optional retries on failure.
69
+ *
70
+ * Uses microtask yield mode for its internal mutex — builds are latency-sensitive
71
+ * and starvation is not a concern (only one build runs at a time by design).
72
+ *
73
+ * @template T - The type of value returned by the execution.
74
+ */
75
+ declare class Once<T = void> {
76
+ private mutex;
77
+ private promise;
78
+ private _value;
79
+ private _error;
80
+ private _done;
81
+ private retry;
82
+ private throws;
83
+ constructor({ retry, throws }?: {
84
+ retry?: boolean;
85
+ throws?: boolean;
86
+ });
87
+ /**
88
+ * Execute the function if it hasn't been executed yet.
89
+ * Subsequent calls return the result of the first execution.
90
+ *
91
+ * @param fn - The function to execute.
92
+ * @param timeout - Max wait time in ms (includes lock wait + execution time).
93
+ */
94
+ do(fn: () => Promise<T> | T, timeout?: number): Promise<OnceResult<T>>;
95
+ /**
96
+ * Returns true if the operation has completed (success or non-retryable failure)
97
+ * and is not currently executing.
98
+ * Replaces the previous `done() && !running()` two-call pattern.
99
+ */
100
+ isReady(): boolean;
101
+ /**
102
+ * Returns true if the operation is currently executing.
103
+ */
104
+ running(): boolean;
105
+ /**
106
+ * Returns the current state without waiting.
107
+ */
108
+ peek(): OnceResult<T>;
109
+ /**
110
+ * Returns the stored value if successful, otherwise throws the stored error.
111
+ * Throws if the operation is not yet complete.
112
+ */
113
+ get(): T | null;
114
+ /**
115
+ * Resets the instance, allowing the action to run again on the next call.
116
+ */
117
+ reset(): void;
118
+ /**
119
+ * Returns the underlying promise if running or done, null otherwise.
120
+ */
121
+ resolved(): Promise<OnceResult<T>> | null;
122
+ /**
123
+ * Returns true if the operation has finished (success or final failure).
124
+ */
125
+ done(): boolean;
126
+ private _awaitWithTimeout;
127
+ }
128
+ type SerializerResult<T> = {
129
+ value: T | null;
130
+ error?: unknown;
131
+ };
132
+ interface SerializerOptions {
133
+ /**
134
+ * Max items in queue. If full, .do() returns an error immediately.
135
+ * @default 1000
136
+ */
137
+ capacity?: number;
138
+ /**
139
+ * Yield mode for the internal mutex. Defaults to "macrotask" for
140
+ * coarse-grained serializers (invalidationSerializer). Use "microtask"
141
+ * for fine-grained latency-sensitive serializers (streamSerializer).
142
+ */
143
+ yieldMode?: "macrotask" | "microtask";
144
+ }
145
+ /**
146
+ * Ensures tasks are executed sequentially (FIFO).
147
+ * Maintains the result of the last successful execution.
148
+ * Includes backpressure protection via configurable queue capacity.
149
+ */
150
+ declare class Serializer<T = void> {
151
+ private mutex;
152
+ private _done;
153
+ private _lastValue;
154
+ private _lastError;
155
+ constructor(options?: SerializerOptions);
156
+ /**
157
+ * Enqueue a function to be executed after all previous tasks complete.
158
+ *
159
+ * @param fn - The function to execute.
160
+ * @param timeout - Max time to wait to acquire the lock.
161
+ * @returns Object containing the value or error.
162
+ */
163
+ do(fn: () => Promise<T> | T, timeout?: number): Promise<SerializerResult<T | null>>;
164
+ /** Returns the result of the last successful execution. */
165
+ peek(): SerializerResult<T | null>;
166
+ /**
167
+ * Permanently closes the serializer.
168
+ * Subsequent calls to `do()` will fail immediately with SerializerExecutionDone.
169
+ */
170
+ close(): void;
171
+ /** Returns the number of tasks currently waiting. */
172
+ pending(): number;
173
+ /** Returns true if a task is currently executing. */
174
+ running(): boolean;
175
+ }
176
+
177
+ export { Mutex, type MutexOptions, Once, type OnceResult, Serializer, type SerializerOptions, type SerializerResult };
package/index.d.ts ADDED
@@ -0,0 +1,177 @@
1
+ interface MutexOptions {
2
+ /**
3
+ * Maximum number of pending requests allowed in the queue.
4
+ * If exceeded, tryLock/lock will fail.
5
+ * @default Infinity
6
+ */
7
+ capacity?: number;
8
+ /**
9
+ * Controls how lock handoff is scheduled when a waiter is unblocked.
10
+ *
11
+ * - `"macrotask"` (default): Uses setTimeout(fn, 0) to yield to the event
12
+ * loop between handoffs. Prevents microtask starvation under heavy
13
+ * contention — I/O, rendering, and other macrotasks can run between
14
+ * lock acquisitions. Use for invalidationSerializer and other
15
+ * coarse-grained serializers.
16
+ *
17
+ * - `"microtask"`: Uses queueMicrotask(fn) for handoff. Near-zero latency
18
+ * between acquisitions — no macrotask delay. Safe when you need
19
+ * back-to-back operations to complete as fast as possible and starvation
20
+ * is not a concern (e.g. buildOnce, streamSerializer where builds are
21
+ * infrequent and latency matters more than fairness).
22
+ */
23
+ yieldMode?: "macrotask" | "microtask";
24
+ }
25
+ /**
26
+ * A mutual exclusion lock.
27
+ * Allows only one execution context to access a resource at a time.
28
+ *
29
+ * Yield mode is configurable per instance:
30
+ * - "macrotask" (default): yields between handoffs, preventing microtask starvation.
31
+ * - "microtask": zero-delay handoff for latency-sensitive paths.
32
+ */
33
+ declare class Mutex {
34
+ private _locked;
35
+ private _capacity;
36
+ private _yieldMode;
37
+ private waiters;
38
+ constructor(options?: MutexOptions);
39
+ /**
40
+ * Acquires the lock. If already held, waits until released or timeout reached.
41
+ *
42
+ * @param timeout - Optional maximum wait time in milliseconds.
43
+ * @throws {TimeoutError} If the lock cannot be acquired within the timeout.
44
+ * @throws {Error} If the wait queue is full (backpressure).
45
+ */
46
+ lock(timeout?: number): Promise<void>;
47
+ /**
48
+ * Attempts to acquire the lock without waiting.
49
+ * @returns `true` if the lock was acquired, `false` otherwise.
50
+ */
51
+ tryLock(): boolean;
52
+ /**
53
+ * Releases the lock, scheduling the next waiter according to yieldMode.
54
+ * @throws {Error} If the mutex is not currently locked.
55
+ */
56
+ unlock(): void;
57
+ /** Returns true if the mutex is currently locked. */
58
+ locked(): boolean;
59
+ /** Returns the number of operations waiting for the lock. */
60
+ pending(): number;
61
+ }
62
+ type OnceResult<T> = {
63
+ value: T | null;
64
+ error?: unknown;
65
+ };
66
+ /**
67
+ * Ensures a specific task is executed exactly once, regardless of concurrent callers.
68
+ * Handles race conditions, caching, and optional retries on failure.
69
+ *
70
+ * Uses microtask yield mode for its internal mutex — builds are latency-sensitive
71
+ * and starvation is not a concern (only one build runs at a time by design).
72
+ *
73
+ * @template T - The type of value returned by the execution.
74
+ */
75
+ declare class Once<T = void> {
76
+ private mutex;
77
+ private promise;
78
+ private _value;
79
+ private _error;
80
+ private _done;
81
+ private retry;
82
+ private throws;
83
+ constructor({ retry, throws }?: {
84
+ retry?: boolean;
85
+ throws?: boolean;
86
+ });
87
+ /**
88
+ * Execute the function if it hasn't been executed yet.
89
+ * Subsequent calls return the result of the first execution.
90
+ *
91
+ * @param fn - The function to execute.
92
+ * @param timeout - Max wait time in ms (includes lock wait + execution time).
93
+ */
94
+ do(fn: () => Promise<T> | T, timeout?: number): Promise<OnceResult<T>>;
95
+ /**
96
+ * Returns true if the operation has completed (success or non-retryable failure)
97
+ * and is not currently executing.
98
+ * Replaces the previous `done() && !running()` two-call pattern.
99
+ */
100
+ isReady(): boolean;
101
+ /**
102
+ * Returns true if the operation is currently executing.
103
+ */
104
+ running(): boolean;
105
+ /**
106
+ * Returns the current state without waiting.
107
+ */
108
+ peek(): OnceResult<T>;
109
+ /**
110
+ * Returns the stored value if successful, otherwise throws the stored error.
111
+ * Throws if the operation is not yet complete.
112
+ */
113
+ get(): T | null;
114
+ /**
115
+ * Resets the instance, allowing the action to run again on the next call.
116
+ */
117
+ reset(): void;
118
+ /**
119
+ * Returns the underlying promise if running or done, null otherwise.
120
+ */
121
+ resolved(): Promise<OnceResult<T>> | null;
122
+ /**
123
+ * Returns true if the operation has finished (success or final failure).
124
+ */
125
+ done(): boolean;
126
+ private _awaitWithTimeout;
127
+ }
128
+ type SerializerResult<T> = {
129
+ value: T | null;
130
+ error?: unknown;
131
+ };
132
+ interface SerializerOptions {
133
+ /**
134
+ * Max items in queue. If full, .do() returns an error immediately.
135
+ * @default 1000
136
+ */
137
+ capacity?: number;
138
+ /**
139
+ * Yield mode for the internal mutex. Defaults to "macrotask" for
140
+ * coarse-grained serializers (invalidationSerializer). Use "microtask"
141
+ * for fine-grained latency-sensitive serializers (streamSerializer).
142
+ */
143
+ yieldMode?: "macrotask" | "microtask";
144
+ }
145
+ /**
146
+ * Ensures tasks are executed sequentially (FIFO).
147
+ * Maintains the result of the last successful execution.
148
+ * Includes backpressure protection via configurable queue capacity.
149
+ */
150
+ declare class Serializer<T = void> {
151
+ private mutex;
152
+ private _done;
153
+ private _lastValue;
154
+ private _lastError;
155
+ constructor(options?: SerializerOptions);
156
+ /**
157
+ * Enqueue a function to be executed after all previous tasks complete.
158
+ *
159
+ * @param fn - The function to execute.
160
+ * @param timeout - Max time to wait to acquire the lock.
161
+ * @returns Object containing the value or error.
162
+ */
163
+ do(fn: () => Promise<T> | T, timeout?: number): Promise<SerializerResult<T | null>>;
164
+ /** Returns the result of the last successful execution. */
165
+ peek(): SerializerResult<T | null>;
166
+ /**
167
+ * Permanently closes the serializer.
168
+ * Subsequent calls to `do()` will fail immediately with SerializerExecutionDone.
169
+ */
170
+ close(): void;
171
+ /** Returns the number of tasks currently waiting. */
172
+ pending(): number;
173
+ /** Returns true if a task is currently executing. */
174
+ running(): boolean;
175
+ }
176
+
177
+ export { Mutex, type MutexOptions, Once, type OnceResult, Serializer, type SerializerOptions, type SerializerResult };
package/index.js ADDED
@@ -0,0 +1 @@
1
+ "use strict";var t=class t extends Error{constructor(e,r){super(e,{cause:r}),this.name="SyncError",Object.setPrototypeOf(this,t.prototype)}},e=class extends t{constructor(t){super(`[ArtifactContainer] Operation timed out: ${t}`)}},r=class extends t{constructor(t){super("[Serializer] The serializer has been marked as done!",t)}},i=class{_locked=!1;_capacity;_yieldMode;waiters=[];constructor(t){this._capacity=t?.capacity??1/0,this._yieldMode=t?.yieldMode??"macrotask"}async lock(t){if(!this._locked)return void(this._locked=!0);if(this.waiters.length>=this._capacity)throw new Error(`Mutex queue is full (capacity: ${this._capacity})`);let r;const i=new Promise((t=>r=t));this.waiters.push(r),null!=t?await Promise.race([i,new Promise(((i,s)=>setTimeout((()=>{const t=this.waiters.indexOf(r);-1!==t&&this.waiters.splice(t,1),s(new e("Mutex lock timed out"))}),t)))]):await i}tryLock(){return!this._locked&&(this._locked=!0,!0)}unlock(){if(!this._locked)throw new Error("Mutex is not locked");const t=this.waiters.shift();t?"microtask"===this._yieldMode?queueMicrotask(t):setTimeout(t,0):this._locked=!1}locked(){return this._locked}pending(){return this.waiters.length}};exports.Mutex=i,exports.Once=class{mutex=new i({yieldMode:"microtask"});promise=null;_value=null;_error;_done=!1;retry;throws;constructor({retry:t,throws:e}={}){this.retry=Boolean(t),this.throws=Boolean(e)}async do(t,e){return this._done?this.peek():this.promise?this._awaitWithTimeout(this.promise,e,"Once do() timed out"):(await this.mutex.lock(),this.promise?(this.mutex.unlock(),this._awaitWithTimeout(this.promise,e,"Once do() timed out")):(this.promise=(async()=>{try{const e=await t();this._value=e,this._done=!0}catch(t){if(this._error=t,this.retry||(this._done=!0),this.throws)throw t}finally{this.promise=null}return this.peek()})(),this.mutex.unlock(),this._awaitWithTimeout(this.promise,e,"Once do() timed out")))}isReady(){return this._done&&null===this.promise}running(){return null!==this.promise&&!this._done}peek(){return{value:this._value,error:this._error}}get(){if(!this._done)throw new Error("Once operation is not yet complete");if(this._error)throw this._error;return this._value}reset(){this._done=!1,this.promise=null,this._value=null,this._error=void 0}resolved(){return this.promise}done(){return this._done}_awaitWithTimeout(t,r,i="Operation timed out"){return null==r?t:Promise.race([t,new Promise(((t,s)=>setTimeout((()=>s(new e(i))),r)))])}},exports.Serializer=class{mutex;_done=!1;_lastValue=null;_lastError=void 0;constructor(t){this.mutex=new i({capacity:t?.capacity??1e3,yieldMode:t?.yieldMode??"macrotask"})}async do(t,e){if(this._done)return{value:null,error:new r};try{await this.mutex.lock(e)}catch(t){return{value:null,error:t}}let i,s=null;try{if(this._done)throw new r;s=await t(),this._lastValue=s,this._lastError=void 0}catch(t){i=t,this._lastError=t}finally{this.mutex.unlock()}return{value:s,error:i}}peek(){return{value:this._lastValue,error:this._lastError}}close(){this._done=!0}pending(){return this.mutex.pending()}running(){return this.mutex.locked()}};
package/index.mjs ADDED
@@ -0,0 +1 @@
1
+ var t=class t extends Error{constructor(e,r){super(e,{cause:r}),this.name="SyncError",Object.setPrototypeOf(this,t.prototype)}},e=class extends t{constructor(t){super(`[ArtifactContainer] Operation timed out: ${t}`)}},r=class extends t{constructor(t){super("[Serializer] The serializer has been marked as done!",t)}},i=class{_locked=!1;_capacity;_yieldMode;waiters=[];constructor(t){this._capacity=t?.capacity??1/0,this._yieldMode=t?.yieldMode??"macrotask"}async lock(t){if(!this._locked)return void(this._locked=!0);if(this.waiters.length>=this._capacity)throw new Error(`Mutex queue is full (capacity: ${this._capacity})`);let r;const i=new Promise((t=>r=t));this.waiters.push(r),null!=t?await Promise.race([i,new Promise(((i,s)=>setTimeout((()=>{const t=this.waiters.indexOf(r);-1!==t&&this.waiters.splice(t,1),s(new e("Mutex lock timed out"))}),t)))]):await i}tryLock(){return!this._locked&&(this._locked=!0,!0)}unlock(){if(!this._locked)throw new Error("Mutex is not locked");const t=this.waiters.shift();t?"microtask"===this._yieldMode?queueMicrotask(t):setTimeout(t,0):this._locked=!1}locked(){return this._locked}pending(){return this.waiters.length}},s=class{mutex=new i({yieldMode:"microtask"});promise=null;_value=null;_error;_done=!1;retry;throws;constructor({retry:t,throws:e}={}){this.retry=Boolean(t),this.throws=Boolean(e)}async do(t,e){return this._done?this.peek():this.promise?this._awaitWithTimeout(this.promise,e,"Once do() timed out"):(await this.mutex.lock(),this.promise?(this.mutex.unlock(),this._awaitWithTimeout(this.promise,e,"Once do() timed out")):(this.promise=(async()=>{try{const e=await t();this._value=e,this._done=!0}catch(t){if(this._error=t,this.retry||(this._done=!0),this.throws)throw t}finally{this.promise=null}return this.peek()})(),this.mutex.unlock(),this._awaitWithTimeout(this.promise,e,"Once do() timed out")))}isReady(){return this._done&&null===this.promise}running(){return null!==this.promise&&!this._done}peek(){return{value:this._value,error:this._error}}get(){if(!this._done)throw new Error("Once operation is not yet complete");if(this._error)throw this._error;return this._value}reset(){this._done=!1,this.promise=null,this._value=null,this._error=void 0}resolved(){return this.promise}done(){return this._done}_awaitWithTimeout(t,r,i="Operation timed out"){return null==r?t:Promise.race([t,new Promise(((t,s)=>setTimeout((()=>s(new e(i))),r)))])}},o=class{mutex;_done=!1;_lastValue=null;_lastError=void 0;constructor(t){this.mutex=new i({capacity:t?.capacity??1e3,yieldMode:t?.yieldMode??"macrotask"})}async do(t,e){if(this._done)return{value:null,error:new r};try{await this.mutex.lock(e)}catch(t){return{value:null,error:t}}let i,s=null;try{if(this._done)throw new r;s=await t(),this._lastValue=s,this._lastError=void 0}catch(t){i=t,this._lastError=t}finally{this.mutex.unlock()}return{value:s,error:i}}peek(){return{value:this._lastValue,error:this._lastError}}close(){this._done=!0}pending(){return this.mutex.pending()}running(){return this.mutex.locked()}};export{i as Mutex,s as Once,o as Serializer};
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@asaidimu/utils-sync",
3
+ "version": "1.0.0",
4
+ "description": "A collection of sync utilities.",
5
+ "main": "index.js",
6
+ "module": "index.mjs",
7
+ "types": "index.d.ts",
8
+ "keywords": [
9
+ "typescript",
10
+ "utility"
11
+ ],
12
+ "author": "Saidimu <47994458+asaidimu@users.noreply.github.com>",
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/asaidimu/erp-utils.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/asaidimu/erp-utils/issues"
20
+ },
21
+ "homepage": "https://github.com/asaidimu/erp-utils/tree/main/src/sync#readme",
22
+ "files": [
23
+ "./*"
24
+ ],
25
+ "exports": {
26
+ ".": {
27
+ "import": {
28
+ "types": "./index.d.ts",
29
+ "default": "./index.mjs"
30
+ },
31
+ "require": {
32
+ "types": "./index.d.ts",
33
+ "default": "./index.js"
34
+ }
35
+ }
36
+ },
37
+ "dependencies": {},
38
+ "publishConfig": {
39
+ "registry": "https://registry.npmjs.org/",
40
+ "tag": "latest",
41
+ "access": "public"
42
+ },
43
+ "release": {
44
+ "plugins": [
45
+ [
46
+ "@semantic-release/npm",
47
+ {
48
+ "pkgRoot": "./dist"
49
+ }
50
+ ],
51
+ [
52
+ "@semantic-release/git",
53
+ {
54
+ "assets": [
55
+ "CHANGELOG.md",
56
+ "package.json"
57
+ ],
58
+ "message": "chore(release): Release @asaidimu/utils-sync v${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
59
+ }
60
+ ]
61
+ ]
62
+ }
63
+ }