@pistonite/pure 0.0.12

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.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * **I once had a fancy error object with TypeScript magic that tries
3
+ * to reduce allocation while maintaining Result-safety. It turns out
4
+ * that was slower than allocating plain objects for every return, because
5
+ * of how V8 optimizes things.**
6
+ *
7
+ * Don't even use `isErr()` helper functions to abstract. They are slower than
8
+ * directly property access in my testing.
9
+ *
10
+ * ## Function that can fail
11
+ * Instead of having functions `throw`, make it `return` instead.
12
+ * ```typescript
13
+ * // Instead of
14
+ * function doSomethingCanFail() {
15
+ * if (Math.random() < 0.5) {
16
+ * return 42;
17
+ * }
18
+ * throw "oops";
19
+ * }
20
+ * // Do this
21
+ * import type { Result } from "pure/result";
22
+ *
23
+ * function doSomethingCanFail(): Result<number, string> {
24
+ * if (Math.random() < 0.5) {
25
+ * return { val: 42 };
26
+ * }
27
+ * return { err: "oops" };
28
+ * }
29
+ * ```
30
+ * This is similar to Rust:
31
+ * ```rust
32
+ * fn do_something_can_fail() -> Result<u32, String> {
33
+ * if ... {
34
+ * return Ok(42);
35
+ * }
36
+ *
37
+ * Err("oops".to_string())
38
+ * }
39
+ * ```
40
+ *
41
+ * ## Calling function that can fail
42
+ * The recommended pattern is
43
+ * ```typescript
44
+ * const x = doTheCall(); // x is Result<T, E>;
45
+ * if (x.err) {
46
+ * // x.err is E, handle it
47
+ * return ...
48
+ * }
49
+ * // x.val is T
50
+ * // ...
51
+ * ```
52
+ * If your `E` type covers falsy values that are valid, use `"err" in x` instead of `x.err`.
53
+ * A well-known case is `Result<T, unknown>`. `if(r.err)` cannot narrow the else case to `Ok`,
54
+ * but `if("err" in r)` can.
55
+ *
56
+ * A full example:
57
+ * ```typescript
58
+ * function getParam(name: string): Result<number, Error> {
59
+ * if (name === "a") {
60
+ * return { val: 13 };
61
+ * }
62
+ * if (name === "b") {
63
+ * return { val: 42 };
64
+ * }
65
+ * return { err: new Error("bad name") };
66
+ * }
67
+ *
68
+ * function multiplyFormat(
69
+ * name1: string,
70
+ * name2: string,
71
+ * prefix: string
72
+ * ): Result<string, Error> {
73
+ * const v1 = getParam(name1);
74
+ * if (v1.err) {
75
+ * console.error(v1.err);
76
+ * return v1;
77
+ * }
78
+ * const v2 = getParam(name1);
79
+ * if (v2.err) {
80
+ * console.error(v2.err);
81
+ * return v2;
82
+ * }
83
+ *
84
+ * const formatted = `${prefix}${v1.val * v2.val}`;
85
+ * return { val: formatted };
86
+ * }
87
+ * ```
88
+ *
89
+ * ## Interop with throwing functions
90
+ * This library also has `tryCatch` to interop with throwing functions,
91
+ * and `tryAsync` for async functions.
92
+ *
93
+ * ```typescript
94
+ * import { tryCatch, tryAsync } from "pure/result";
95
+ *
96
+ * // synchronous
97
+ * const result1: Result<MyData, unknown> = tryCatch(() => JSON.parse<MyData>(...));
98
+ * // or you can specify the error type:
99
+ * const result2 = tryCatch<MyData, SyntaxError>(() => JSON.parse(...));
100
+ *
101
+ * // asynchronous
102
+ * async function doSomethingCanFail() {
103
+ * if (Math.random() < 0.5) {
104
+ * return 42;
105
+ * }
106
+ * throw "oops";
107
+ * }
108
+ * const result = await tryAsync<number, string>(() => doStuff);
109
+ * ```
110
+ *
111
+ * ## Returning void
112
+ * Use `Void<E>` as the return type if the function returns `void` on success
113
+ * ```typescript
114
+ * const x = doSomethingThatVoidsOnSuccess();
115
+ * if (x.err) {
116
+ * return x;
117
+ * }
118
+ * // type of x is Record<string, never>, i.e. empty object
119
+ * ```
120
+ *
121
+ * ## Why is there no `match`/`map`/`mapErr`, etc?
122
+ *
123
+ * If you are thinking this is a great idea:
124
+ * ```typescript
125
+ * const result = foo(bar);
126
+ * match(result,
127
+ * (okValue) => {
128
+ * // handle ok case
129
+ * },
130
+ * (errValue) => {
131
+ * // handle err case
132
+ * },
133
+ * );
134
+ * ```
135
+ * The vanilla `if` doesn't allocate the closures, and has less code, and you can
136
+ * control the flow properly inside the blocks with `return`/`break`/`continue`
137
+ * ```typescript
138
+ * const result = foo(bar);
139
+ * if (result.err) {
140
+ * // handle err case
141
+ * } else {
142
+ * // handle ok case
143
+ * }
144
+ * ```
145
+ *
146
+ * As for the other utility functions from Rust's Result type, they really only benefit
147
+ * because you can early return with `?` AND those abstractions are zero-cost in Rust.
148
+ * Neither is true in JavaScript.
149
+ *
150
+ * You can also easily write them yourself if you really want to.
151
+ *
152
+ * @module
153
+ */
154
+
155
+ /**
156
+ * A value that either a success (Ok) or an error (Err)
157
+ *
158
+ * Construct a success with { val: ... } and an error with { err: ... }
159
+ */
160
+ export type Result<T, E> = Ok<T> | Err<E>;
161
+
162
+ // If these look weird, it's because TypeScript is weird
163
+ // This is to get type narrowing to work most of the time
164
+
165
+ /** A success value */
166
+ export type Ok<T> = { val: T; err?: never };
167
+ /** An error value */
168
+ export type Err<E> = { err: E; val?: never };
169
+
170
+ /**
171
+ * A value that is either `void` or an error
172
+ *
173
+ * Construct success with `{}` and an error with `{ err: ... }`
174
+ */
175
+ export type Void<E> = { val?: never; err?: never } | { err: E };
176
+ /** A value that is a success `void` */
177
+ export type VoidOk = Record<string, never>;
178
+
179
+ /** Wrap a function with try-catch and return a Result. */
180
+ export function tryCatch<T, E = unknown>(fn: () => T): Result<T, E> {
181
+ try {
182
+ return { val: fn() };
183
+ } catch (e) {
184
+ return { err: e as E };
185
+ }
186
+ }
187
+
188
+ /** Wrap an async function with try-catch and return a Promise<Result>. */
189
+ export async function tryAsync<T, E = unknown>(
190
+ fn: () => Promise<T>,
191
+ ): Promise<Result<T, E>> {
192
+ try {
193
+ return { val: await fn() };
194
+ } catch (e) {
195
+ return { err: e as E };
196
+ }
197
+ }
198
+
199
+ /** Try best effort converting an error to a string */
200
+ export function errstr(e: unknown): string {
201
+ if (typeof e === "string") {
202
+ return e;
203
+ }
204
+ if (e) {
205
+ if (typeof e === "object" && "message" in e) {
206
+ if (typeof e.message === "string") {
207
+ return e.message;
208
+ }
209
+ }
210
+ if (typeof e === "object" && "toString" in e) {
211
+ return e.toString();
212
+ }
213
+ }
214
+ return `${e}`;
215
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Debounce executes an action after a certain delay. Any
3
+ * call to the action during this delay resets the timer.
4
+ *
5
+ * ## Warning
6
+ * The implementation is naive and does not handle the case
7
+ * where the action keeps getting called before the delay,
8
+ * in which case the action will never be executed. This
9
+ * will be improved in the future...
10
+ */
11
+ export class Debounce<TResult> {
12
+ private timer: number | undefined;
13
+ constructor(
14
+ private fn: () => Promise<TResult>,
15
+ private delay: number,
16
+ ) {
17
+ this.timer = undefined;
18
+ }
19
+
20
+ public execute(): Promise<TResult> {
21
+ if (this.timer !== undefined) {
22
+ clearTimeout(this.timer);
23
+ }
24
+ return new Promise<TResult>((resolve, reject) => {
25
+ this.timer = setTimeout(async () => {
26
+ this.timer = undefined;
27
+ try {
28
+ resolve(await this.fn());
29
+ } catch (error) {
30
+ reject(error);
31
+ }
32
+ }, this.delay);
33
+ });
34
+ }
35
+ }
@@ -0,0 +1,75 @@
1
+ import type { Result } from "../result/index.ts";
2
+
3
+ /**
4
+ * Latest is a synchronization utility to allow
5
+ * only one async operation to be executed at a time,
6
+ * and any call will only return the result of the latest
7
+ * operation.
8
+ *
9
+ * ## Example
10
+ * In the example below, both call will return the result
11
+ * of the second call (2)
12
+ * ```typescript
13
+ * import { Latest } from "@pistonite/pure/sync";
14
+ *
15
+ * let counter = 0;
16
+ *
17
+ * const operation = async () => {
18
+ * counter++;
19
+ * await new Promise((resolve) => setTimeout(() => {
20
+ * resolve(counter);
21
+ * }, 1000));
22
+ * }
23
+ *
24
+ * const call = new Latest(operation);
25
+ *
26
+ * const result1 = call.execute();
27
+ * const result2 = call.execute();
28
+ * console.log(await result1); // 2
29
+ * console.log(await result2); // 2
30
+ * ```
31
+ */
32
+ export class Latest<TResult> {
33
+ private isRunning = false;
34
+ private pending: {
35
+ resolve: (result: TResult) => void;
36
+ reject: (error: unknown) => void;
37
+ }[] = [];
38
+
39
+ constructor(private fn: () => Promise<TResult>) {}
40
+
41
+ public async execute(): Promise<TResult> {
42
+ if (this.isRunning) {
43
+ return new Promise<TResult>((resolve, reject) => {
44
+ this.pending.push({ resolve, reject });
45
+ });
46
+ }
47
+ this.isRunning = true;
48
+ const alreadyPending = [];
49
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
+ let result: Result<TResult, unknown> = { err: "not executed" };
51
+ // eslint-disable-next-line no-constant-condition
52
+ while (true) {
53
+ try {
54
+ const fn = this.fn;
55
+ result = { val: await fn() };
56
+ } catch (error) {
57
+ result = { err: error };
58
+ }
59
+ if (this.pending.length) {
60
+ alreadyPending.push(...this.pending);
61
+ this.pending = [];
62
+ continue;
63
+ }
64
+ break;
65
+ }
66
+ this.isRunning = false;
67
+ if ("err" in result) {
68
+ alreadyPending.forEach(({ reject }) => reject(result.err));
69
+ throw result.err;
70
+ } else {
71
+ alreadyPending.forEach(({ resolve }) => resolve(result.val));
72
+ return result.val;
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,95 @@
1
+ import Deque from "denque";
2
+
3
+ /**
4
+ * Ensure you have exclusive access in concurrent code
5
+ *
6
+ * Only guaranteed if no one else has reference to the inner object
7
+ *
8
+ * It can take a second type parameter to specify interface with write methods
9
+ */
10
+ export class RwLock<TRead, TWrite extends TRead = TRead> {
11
+ /**
12
+ * This is public so inner object can be accessed directly
13
+ * ONLY SAFE in sync context
14
+ */
15
+ public inner: TWrite;
16
+
17
+ private readers: number = 0;
18
+ private isWriting: boolean = false;
19
+ private readWaiters: Deque<() => void> = new Deque();
20
+ private writeWaiters: Deque<() => void> = new Deque();
21
+
22
+ constructor(t: TWrite) {
23
+ this.inner = t;
24
+ }
25
+
26
+ /** Acquire a read (shared) lock and call fn with the value. Release the lock when fn returns or throws. */
27
+ public async scopedRead<R>(fn: (t: TRead) => Promise<R>): Promise<R> {
28
+ if (this.isWriting) {
29
+ await new Promise<void>((resolve) => {
30
+ // need to check again to make sure it's not already done
31
+ if (this.isWriting) {
32
+ this.readWaiters.push(resolve);
33
+ return;
34
+ }
35
+ resolve();
36
+ });
37
+ }
38
+ // acquired
39
+ this.readers++;
40
+ try {
41
+ return await fn(this.inner);
42
+ } finally {
43
+ this.readers--;
44
+ if (this.writeWaiters.length > 0) {
45
+ if (this.readers === 0) {
46
+ // notify one writer
47
+ this.writeWaiters.shift()!();
48
+ }
49
+ // don't notify anyone if there are still readers
50
+ } else {
51
+ // notify all readers
52
+ while (this.readWaiters.length > 0) {
53
+ this.readWaiters.shift()!();
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Acquire a write (exclusive) lock and call fn with the value. Release the lock when fn returns or throws.
61
+ *
62
+ * fn takes a setter function as second parameter, which you can use to update the value like `x = set(newX)`
63
+ */
64
+ public async scopedWrite<R>(
65
+ fn: (t: TWrite, setter: (t: TWrite) => TWrite) => Promise<R>,
66
+ ): Promise<R> {
67
+ if (this.isWriting || this.readers > 0) {
68
+ await new Promise<void>((resolve) => {
69
+ // need to check again to make sure it's not already done
70
+ if (this.isWriting || this.readers > 0) {
71
+ this.writeWaiters.push(resolve);
72
+ return;
73
+ }
74
+ resolve();
75
+ });
76
+ }
77
+ // acquired
78
+ this.isWriting = true;
79
+ try {
80
+ return await fn(this.inner, (t: TWrite) => {
81
+ this.inner = t;
82
+ return t;
83
+ });
84
+ } finally {
85
+ this.isWriting = false;
86
+ if (this.readWaiters.length > 0) {
87
+ // notify one reader
88
+ this.readWaiters.shift()!();
89
+ } else if (this.writeWaiters.length > 0) {
90
+ // notify one writer
91
+ this.writeWaiters.shift()!();
92
+ }
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,170 @@
1
+ import type { Void, Err, VoidOk } from "../result/index.ts";
2
+
3
+ /**
4
+ * An async event that can be cancelled when a new one starts
5
+ *
6
+ * ## Example
7
+ *
8
+ * ```typescript
9
+ * import { Serial } from "@pistonite/pure/sync";
10
+ *
11
+ * // helper function to simulate async work
12
+ * const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
13
+ *
14
+ * // Create the event
15
+ * const event = new Serial();
16
+ *
17
+ * // The cancellation system uses the Result type
18
+ * // and returns an error when it is cancelled
19
+ * const promise1 = event.run(async (shouldCancel) => {
20
+ * for (let i = 0; i < 10; i++) {
21
+ * await wait(1000);
22
+ * const cancelResult = shouldCancel();
23
+ * if (cancelResult.err) {
24
+ * return cancelResult;
25
+ * }
26
+ * }
27
+ * return { val: 42 };
28
+ * });
29
+ *
30
+ * await wait(3000);
31
+ *
32
+ * // calling event.run a second time will cause `shouldCancel` to return false
33
+ * // the next time it's called by the first event
34
+ * const promise2 = event.run(async (shouldCancel) => {
35
+ * for (let i = 0; i < 10; i++) {
36
+ * await wait(1000);
37
+ * const cancelResult = shouldCancel();
38
+ * if (cancelResult.err) {
39
+ * return cancelResult;
40
+ * }
41
+ * }
42
+ * return { val: 43 };
43
+ * });
44
+ *
45
+ * console.log(await promise1); // { err: "cancel" }
46
+ * console.log(await promise2); // { val: 43 }
47
+ * ```
48
+ *
49
+ * ## Getting the current serial number
50
+ * The serial number has type `bigint` and is incremented every time `run` is called.
51
+ *
52
+ * The callback function receives the current serial number as the second argument, if you need it
53
+ * ```typescript
54
+ * import { Serial } from "@pistonite/pure/sync";
55
+ *
56
+ * const event = new Serial();
57
+ * const promise = event.run(async (shouldCancel, serial) => {
58
+ * console.log(serial);
59
+ * return {};
60
+ * });
61
+ * ```
62
+ *
63
+ * ## Checking for cancel
64
+ * It's the event handler's responsibility to check if the event is cancelled by
65
+ * calling the `shouldCancel` function. This function returns an `Err` if it should be cancelled.
66
+ *
67
+ * ```typescript
68
+ * import { Serial } from "@pistonite/pure/sync";
69
+ *
70
+ * const event = new Serial();
71
+ * await event.run(async (shouldCancel, serial) => {
72
+ * // do some operations
73
+ * ...
74
+ *
75
+ * const cancelResult = shouldCancel();
76
+ * if (cancelResult.err) {
77
+ * return cancelResult;
78
+ * }
79
+ *
80
+ * // not cancelled, continue
81
+ * ...
82
+ * });
83
+ * ```
84
+ * It's possible the operation is cheap enough that an outdated event should probably be let finish.
85
+ * It's ok in that case to not call `shouldCancel`. The `Serial` class checks it one
86
+ * last time before returning the result after the callback finishes.
87
+ *
88
+ * ## Handling cancelled event
89
+ * To check if an event is completed or cancelled, simply `await`
90
+ * on the promise returned by `event.run` and check the `err`
91
+ * ```typescript
92
+ * import { Serial } from "@pistonite/pure/sync";
93
+ *
94
+ * const event = new Serial();
95
+ * const result = await event.run(async (shouldCancel) => {
96
+ * // your code here ...
97
+ * );
98
+ * if (result.err === "cancel") {
99
+ * console.log("event was cancelled");
100
+ * } else {
101
+ * console.log("event completed");
102
+ * }
103
+ * ```
104
+ *
105
+ * You can also pass in a callback to the constructor, which will be called
106
+ * when the event is cancelled. This event is guaranteed to fire at most once per run
107
+ * ```typescript
108
+ * import { Serial } from "@pistonite/pure/sync";
109
+ *
110
+ * const event = new Serial((current, latest) => {
111
+ * console.log(`Event with serial ${current} is cancelled because the latest serial is ${latest}`);
112
+ * });
113
+ * ```
114
+ *
115
+ *
116
+ */
117
+ export class Serial {
118
+ private serial: bigint;
119
+ private onCancel: SerialEventCancelCallback;
120
+
121
+ constructor(onCancel?: SerialEventCancelCallback) {
122
+ this.serial = 0n;
123
+ if (onCancel) {
124
+ this.onCancel = onCancel;
125
+ } else {
126
+ this.onCancel = () => {};
127
+ }
128
+ }
129
+
130
+ public async run<T = VoidOk>(
131
+ callback: SerialEventCallback<T>,
132
+ ): Promise<T | Err<SerialEventCancelToken>> {
133
+ let cancelled = false;
134
+ const currentSerial = ++this.serial;
135
+ const shouldCancel = () => {
136
+ if (currentSerial !== this.serial) {
137
+ if (!cancelled) {
138
+ cancelled = true;
139
+ this.onCancel(currentSerial, this.serial);
140
+ }
141
+ return { err: "cancel" as const };
142
+ }
143
+ return {};
144
+ };
145
+ const result = await callback(shouldCancel, currentSerial);
146
+ const cancelResult = shouldCancel();
147
+ if (cancelResult.err) {
148
+ return cancelResult;
149
+ }
150
+ return result;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * The callback type passed to SerialEvent constructor to be called
156
+ * when the event is cancelled
157
+ */
158
+ export type SerialEventCancelCallback = (
159
+ current: bigint,
160
+ latest: bigint,
161
+ ) => void;
162
+
163
+ /** The callback type passed to SerialEvent.run */
164
+ export type SerialEventCallback<T = VoidOk> = (
165
+ shouldCancel: () => Void<SerialEventCancelToken>,
166
+ current: bigint,
167
+ ) => Promise<T | Err<SerialEventCancelToken>>;
168
+
169
+ /** The error type received by caller when an event is cancelled */
170
+ export type SerialEventCancelToken = "cancel";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * # pure/sync
3
+ * JS synchronization utilities
4
+ *
5
+ * @module
6
+ */
7
+ export { RwLock } from "./RwLock.ts";
8
+ export { Serial } from "./Serial.ts";
9
+
10
+ // unstable
11
+ export { Latest } from "./Latest.ts";
12
+ export { Debounce } from "./Debounce.ts";