@pistonite/pure 0.0.13 → 0.0.14

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,179 @@
1
+ import { type AwaitRet, makePromise } from "./util.ts";
2
+
3
+ /**
4
+ * An async event wrapper that is guaranteed to:
5
+ * - Not re-fire in a minimal interval after it's initialially fired.
6
+ * - All calls will eventually fire
7
+ *
8
+ * The caller will get a promise that resolves the next time the event is fired
9
+ * and resolved.
10
+ *
11
+ * Unlike the naive implementation with a setTimeout, this implementation
12
+ * will not starve the event. If it's constantly being called,
13
+ * it will keep firing the event at at least the minimum interval (might
14
+ * take longer if the underlying function takes longer to execute
15
+ *
16
+ * ## Simple Example
17
+ *
18
+ * Multiple calls will be debounced to the minimum interval
19
+ * ```typescript
20
+ * import { debounce } from "@pistonite/pure/sync";
21
+ *
22
+ * const execute = debounce({
23
+ * fn: () => {
24
+ * console.log("called");
25
+ * }
26
+ * interval: 100,
27
+ * });
28
+ * await execute(); // resolved immediately
29
+ * await execute(); // resolved after 100ms
30
+ * ```
31
+ *
32
+ * ## Discarding extra calls
33
+ * When making multiple calls, if the call is currently being debounced
34
+ * (i.e. executed and the minimum interval hasn't passed), new calls
35
+ * will replace the previous call.
36
+ *
37
+ * If you want to the in-between calls to be preserved,
38
+ * use `batch` instead.
39
+ *
40
+ * ```typescript
41
+ * import { debounce } from "@pistonite/pure/sync";
42
+ *
43
+ * const execute = debounce({
44
+ * fn: (n: number) => {
45
+ * console.log(n);
46
+ * }
47
+ * interval: 100,
48
+ * });
49
+ * await execute(1); // logs 1 immediately
50
+ * const p1 = execute(2); // will be resolved at 100ms
51
+ * await new Promise((resolve) => setTimeout(resolve, 50));
52
+ * await Promise.all[p1, execute(3)]; // will be resolved at 100ms, discarding the 2nd call
53
+ * // 1, 3 will be logged
54
+ * ```
55
+ *
56
+ * ## Slow function
57
+ * By default, the debouncer takes into account the time
58
+ * it takes for the underlying function to execute. It starts
59
+ * the next cycle as soon as both the minimul interval has passed
60
+ * and the function has finished executing. This ensures only
61
+ * 1 call is being executed at a time.
62
+ *
63
+ * However, if you want the debouncer to always debounce at the set interval,
64
+ * regardless of if the previous call has finished, set `disregardExecutionTime`
65
+ * to true.
66
+ *
67
+ * ```typescript
68
+ * import { debounce } from "@pistonite/pure/sync";
69
+ *
70
+ * const execute = debounce({
71
+ * fn: async (n: number) => {
72
+ * await new Promise((resolve) => setTimeout(resolve, 150));
73
+ * console.log(n);
74
+ * },
75
+ * interval: 100,
76
+ * // without this, will debounce at the interval of 150ms
77
+ * disregardExecutionTime: true,
78
+ * });
79
+ * ```
80
+ */
81
+ export function debounce<TFn extends (...args: any[]) => any>({
82
+ fn,
83
+ interval,
84
+ disregardExecutionTime,
85
+ }: DebounceConstructor<TFn>) {
86
+ const impl = new DebounceImpl(fn, interval, !!disregardExecutionTime);
87
+ return (...args: Parameters<TFn>) => impl.invoke(...args);
88
+ }
89
+
90
+ /**
91
+ * Options for `debounce` function
92
+ */
93
+ export type DebounceConstructor<TFn> = {
94
+ /** Function to be debounced */
95
+ fn: TFn;
96
+ /**
97
+ * Minimum interval between each call
98
+ *
99
+ * Setting this to <= 0 will make the debounce function
100
+ * a pure pass-through, not actually debouncing the function
101
+ */
102
+ interval: number;
103
+
104
+ /**
105
+ * By default, the debouncer takes in account the time
106
+ * the underlying function executes. i.e. the actual debounce
107
+ * interval is `max(interval, executionTime)`. This default
108
+ * behavior guanrantees that no 2 calls will be executed concurrently.
109
+ *
110
+ * If you want the debouncer to always debounce at the set interval,
111
+ * set this to true.
112
+ */
113
+ disregardExecutionTime?: boolean;
114
+ };
115
+
116
+ class DebounceImpl<TFn extends (...args: any[]) => any> {
117
+ private idle: boolean;
118
+ private next?: {
119
+ args: Parameters<TFn>;
120
+ promise: Promise<AwaitRet<TFn>>;
121
+ resolve: (result: AwaitRet<TFn>) => void;
122
+ reject: (error: any) => void;
123
+ };
124
+ constructor(
125
+ private fn: TFn,
126
+ private interval: number,
127
+ private disregardExecutionTime: boolean,
128
+ ) {
129
+ this.idle = true;
130
+ }
131
+
132
+ public invoke(...args: Parameters<TFn>): Promise<AwaitRet<TFn>> {
133
+ if (this.idle) {
134
+ this.idle = false;
135
+ return this.execute(...args);
136
+ }
137
+ if (!this.next) {
138
+ this.next = { args, ...makePromise<AwaitRet<TFn>>() };
139
+ } else {
140
+ this.next.args = args;
141
+ }
142
+ return this.next.promise;
143
+ }
144
+
145
+ private scheduleNext() {
146
+ const next = this.next;
147
+ if (next) {
148
+ this.next = undefined;
149
+ const { args, resolve, reject } = next;
150
+ void this.execute(...args).then(resolve, reject);
151
+ return;
152
+ }
153
+ this.idle = true;
154
+ }
155
+
156
+ private async execute(...args: Parameters<TFn>): Promise<AwaitRet<TFn>> {
157
+ const fn = this.fn;
158
+ let done = this.disregardExecutionTime;
159
+ setTimeout(() => {
160
+ if (done) {
161
+ this.scheduleNext();
162
+ } else {
163
+ done = true;
164
+ }
165
+ }, this.interval);
166
+ try {
167
+ return await fn(...args);
168
+ } finally {
169
+ if (!this.disregardExecutionTime) {
170
+ if (done) {
171
+ // interval already passed, we need to call it
172
+ this.scheduleNext();
173
+ } else {
174
+ done = true;
175
+ }
176
+ }
177
+ }
178
+ }
179
+ }
package/src/sync/index.ts CHANGED
@@ -4,9 +4,12 @@
4
4
  *
5
5
  * @module
6
6
  */
7
- export { RwLock } from "./RwLock.ts";
8
- export { Serial } from "./Serial.ts";
7
+ export { serial, type SerialConstructor } from "./serial.ts";
8
+ export { latest, type LatestConstructor } from "./latest.ts";
9
+ export { debounce, type DebounceConstructor } from "./debounce.ts";
10
+ export { batch, type BatchConstructor } from "./batch.ts";
11
+ export { cell, type CellConstructor, type Cell } from "./cell.ts";
12
+ export { persist, type PersistConstructor, type Persist } from "./persist.ts";
9
13
 
10
14
  // unstable
11
- export { Latest } from "./Latest.ts";
12
- export { Debounce } from "./Debounce.ts";
15
+ export { RwLock } from "./RwLock.ts";
@@ -0,0 +1,85 @@
1
+ import { makePromise } from "./util.ts";
2
+
3
+ /**
4
+ * An async event wrapper that always resolve to the result of the latest
5
+ * call
6
+ *
7
+ * ## Example
8
+ * In the example below, both call will return the result
9
+ * of the second call (2)
10
+ * ```typescript
11
+ * import { latest } from "@pistonite/pure/sync";
12
+ *
13
+ * let counter = 0;
14
+ *
15
+ * const execute = latest({
16
+ * fn: async () => {
17
+ * counter++;
18
+ * await new Promise((resolve) => setTimeout(() => {
19
+ * resolve(counter);
20
+ * }, 1000));
21
+ * }
22
+ * });
23
+ *
24
+ * const result1 = execute();
25
+ * const result2 = execute();
26
+ * console.log(await result1); // 2
27
+ * console.log(await result2); // 2
28
+ * ```
29
+ */
30
+ export function latest<TFn extends (...args: any[]) => any>({
31
+ fn,
32
+ }: LatestConstructor<TFn>) {
33
+ const impl = new LatestImpl(fn);
34
+ return (...args: Parameters<TFn>) => impl.invoke(...args);
35
+ }
36
+
37
+ export type LatestConstructor<TFn> = {
38
+ /** Function to be wrapped */
39
+ fn: TFn;
40
+ };
41
+ export class LatestImpl<TFn extends (...args: any[]) => any> {
42
+ private hasNewer: boolean;
43
+ private pending?: {
44
+ promise: Promise<Awaited<ReturnType<TFn>>>;
45
+ resolve: (result: Awaited<ReturnType<TFn>>) => void;
46
+ reject: (error: unknown) => void;
47
+ };
48
+
49
+ constructor(private fn: TFn) {
50
+ this.hasNewer = false;
51
+ }
52
+
53
+ public async invoke(
54
+ ...args: Parameters<TFn>
55
+ ): Promise<Awaited<ReturnType<TFn>>> {
56
+ if (this.pending) {
57
+ this.hasNewer = true;
58
+ return this.pending.promise;
59
+ }
60
+ this.pending = makePromise<Awaited<ReturnType<TFn>>>();
61
+ let error = undefined;
62
+ let result;
63
+ while (true) {
64
+ this.hasNewer = false;
65
+ try {
66
+ const fn = this.fn;
67
+ result = await fn(...args);
68
+ } catch (e) {
69
+ error = e;
70
+ }
71
+ if (!this.hasNewer) {
72
+ break;
73
+ }
74
+ }
75
+ const pending = this.pending;
76
+ this.pending = undefined;
77
+ if (error) {
78
+ pending.reject(error);
79
+ throw error;
80
+ } else {
81
+ pending.resolve(result);
82
+ return result;
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,122 @@
1
+ import { cell, type Cell, type CellConstructor } from "./cell.ts";
2
+
3
+ /**
4
+ * Create a cell that persists its value to a web storage
5
+ */
6
+ export function persist<T>({
7
+ storage,
8
+ key,
9
+ serialize = JSON.stringify,
10
+ deserialize,
11
+ initial,
12
+ }: PersistConstructor<T>): Persist<T> {
13
+ const deser =
14
+ deserialize ??
15
+ ((value: string) => {
16
+ try {
17
+ return JSON.parse(value);
18
+ } catch {
19
+ return null;
20
+ }
21
+ });
22
+ return new PersistImpl(storage, key, serialize, deser, initial);
23
+ }
24
+
25
+ export type PersistConstructor<T> = CellConstructor<T> & {
26
+ /** The web storage to use */
27
+ storage: Storage;
28
+
29
+ /** The key to use in the storage */
30
+ key: string;
31
+
32
+ /**
33
+ * Serialize the value to store in the storage
34
+ *
35
+ * By default, it will use `JSON.stringify`
36
+ */
37
+ serialize?(value: T): string;
38
+ /**
39
+ * Deserialize the value from the storage
40
+ *
41
+ * By default, it will use `JSON.parse` wrapped with try-catch
42
+ */
43
+ deserialize?(value: string): T | null;
44
+ };
45
+
46
+ export type Persist<T> = Cell<T> & {
47
+ /**
48
+ * Load the value initially, and notify all the current subscribers
49
+ *
50
+ * Optionally, you can pass an initial value to override the current value
51
+ */
52
+ init(initial?: T): T;
53
+ /** Clear the value from the storage */
54
+ clear(): void;
55
+ /** Clera the value and disable the persistence */
56
+ disable(): void;
57
+ };
58
+
59
+ class PersistImpl<T> implements Persist<T> {
60
+ private cell: Cell<T>;
61
+ private unsubscribe: () => void;
62
+
63
+ constructor(
64
+ private storage: Storage,
65
+ private key: string,
66
+ private serialize: (value: T) => string,
67
+ private deserialize: (value: string) => T | null,
68
+ initial: T,
69
+ ) {
70
+ this.cell = cell({ initial });
71
+ this.unsubscribe = () => {};
72
+ }
73
+
74
+ public init(initial?: T): T {
75
+ const serialize = this.serialize;
76
+ const deserialize = this.deserialize;
77
+ let value: T;
78
+ if (initial !== undefined) {
79
+ value = initial;
80
+ } else {
81
+ value = this.cell.get();
82
+ }
83
+ try {
84
+ const data = this.storage.getItem(this.key);
85
+ if (data !== null) {
86
+ const loadedValue = deserialize(data);
87
+ if (loadedValue !== null) {
88
+ value = loadedValue;
89
+ this.cell.set(value);
90
+ }
91
+ }
92
+ } catch {}
93
+ this.unsubscribe = this.cell.subscribe((value) => {
94
+ this.storage.setItem(this.key, serialize(value));
95
+ });
96
+ return value;
97
+ }
98
+
99
+ public get(): T {
100
+ return this.cell.get();
101
+ }
102
+
103
+ public set(value: T): void {
104
+ this.cell.set(value);
105
+ }
106
+
107
+ public subscribe(
108
+ callback: (value: T) => void,
109
+ notifyImmediately?: boolean,
110
+ ): () => void {
111
+ return this.cell.subscribe(callback, notifyImmediately);
112
+ }
113
+
114
+ public clear() {
115
+ this.storage.removeItem(this.key);
116
+ }
117
+
118
+ public disable() {
119
+ this.clear();
120
+ this.unsubscribe();
121
+ }
122
+ }
@@ -0,0 +1,220 @@
1
+ import type { Result } from "../result/index.ts";
2
+
3
+ /**
4
+ * An async event wrapper that is cancelled when a new one starts.
5
+ * When a new event is started, the previous caller will receive a
6
+ * cancellation error, instead of being hung up indefinitely.
7
+ *
8
+ * If you want every caller to receive the latest result
9
+ * instead of a cancellation error, use `latest` instead.
10
+ *
11
+ * ## Example
12
+ *
13
+ * ```typescript
14
+ * import { serial } from "@pistonite/pure/sync";
15
+ *
16
+ * // helper function to simulate async work
17
+ * const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
18
+ *
19
+ * // Create the wrapped function
20
+ * const execute = serial({
21
+ * // This has to be curried for type inferrence
22
+ * fn: (checkCancel) => async () => {
23
+ * for (let i = 0; i < 10; i++) {
24
+ * await wait(1000);
25
+ * // The cancellation mechanism throws an error if is cancelled
26
+ * checkCancel();
27
+ * }
28
+ * return 42;
29
+ * }
30
+ * });
31
+ *
32
+ * // execute it the first time
33
+ * const promise1 = execute();
34
+ * await wait(3000);
35
+ *
36
+ * // calling event.run a second time will cause `checkCancel` to return false
37
+ * // the next time it's called by the first event
38
+ * const promise2 = execute();
39
+ *
40
+ * console.log(await promise1); // { err: "cancel" }
41
+ * console.log(await promise2); // { val: 42 }
42
+ * ```
43
+ *
44
+ * ## Passing in arguments
45
+ * TypeScript magic is used to ensure full type-safety when passing in arguments.
46
+ *
47
+ * ```typescript
48
+ * import { serial, type SerialCancelToken } from "@pistonite/pure/sync";
49
+ * import type { Result } from "@pistonite/pure/result";
50
+ *
51
+ * const execute = serial({
52
+ * fn: (checkCancel) => async (arg1: number, arg2: string) => {
53
+ *
54
+ * // do something with arg1 and arg2
55
+ * console.log(arg1, arg2);
56
+ *
57
+ * // ...
58
+ * }
59
+ * });
60
+ *
61
+ * expectTypeOf(execute)
62
+ * .toEqualTypeOf<
63
+ * (arg1: number, arg2: string) => Promise<Result<void, SerialCancelToken>>
64
+ * >();
65
+ *
66
+ * await execute(42, "hello"); // no type error!
67
+ * ```
68
+ *
69
+ * ## Getting the current serial number
70
+ * The serial number has type `bigint` and is incremented every time `run` is called.
71
+ *
72
+ * You can have an extra argument after `checkCancel`, that will receive the current serial number,
73
+ * if you need it for some reason.
74
+ * ```typescript
75
+ * import { serial } from "@pistonite/pure/sync";
76
+ *
77
+ * const execute = serial({
78
+ * fn: (checkCancel, serial) => () => { console.log(serial); }
79
+ * });
80
+ *
81
+ * await execute(); // 1n
82
+ * await execute(); // 2n
83
+ * ```
84
+ *
85
+ * ## Checking for cancel
86
+ * It's the event handler's responsibility to check if the event is cancelled by
87
+ * calling the `checkCancel` function. This function will throw if the event
88
+ * is cancelled, and the error will be caught by the wrapper and returned as an `Err`
89
+ *
90
+ * Note that even if you don't check it, there is one final check before the result is returned.
91
+ * So you will never get a result from a cancelled event. Also note that you only need to check
92
+ * after any `await` calls. If there's no `await`, everything is executed synchronously,
93
+ * and it's theoretically impossible to cancel the event. However, this depends on
94
+ * the runtime's implementation of promises.
95
+ *
96
+ * ## Handling cancelled event
97
+ * To check if an event is completed or cancelled, simply `await`
98
+ * on the promise check the `err`
99
+ * ```typescript
100
+ * import { serial } from "@pistonite/pure/sync";
101
+ *
102
+ * const execute = serial({
103
+ * fn: (checkCancel) => async () => {
104
+ * // your code here ...
105
+ * }
106
+ * });
107
+ * const result = await execute();
108
+ * if (result.err === "cancel") {
109
+ * console.log("event was cancelled");
110
+ * } else {
111
+ * console.log("event completed");
112
+ * }
113
+ * ```
114
+ *
115
+ * You can also pass in a callback to the constructor, which will be called
116
+ * when the event is cancelled. The cancel callback is guaranteed to only fire at most once per run
117
+ * ```typescript
118
+ * import { serial } from "@pistonite/pure/sync";
119
+ *
120
+ * const onCancel = (current: bigint, latest: bigint) => {
121
+ * console.log(`Event with serial ${current} is cancelled because the latest serial is ${latest}`);
122
+ * };
123
+ *
124
+ * const execute = new Serial({
125
+ * fn: ...,
126
+ * onCancel,
127
+ * });
128
+ * ```
129
+ *
130
+ * ## Exception handling
131
+ *
132
+ * If the underlying function throws, the exception will be re-thrown to the caller.
133
+ */
134
+
135
+ export function serial<TFn extends (...args: any[]) => any>({
136
+ fn,
137
+ onCancel,
138
+ }: SerialConstructor<TFn>) {
139
+ const impl = new SerialImpl(fn, onCancel);
140
+ return (...args: Parameters<TFn>) => impl.invoke(...args);
141
+ }
142
+
143
+ /**
144
+ * Options for `serial` function
145
+ */
146
+ export type SerialConstructor<TFn> = {
147
+ /**
148
+ * Function creator that returns the async function to be wrapped
149
+ */
150
+ fn: (checkCancel: CheckCancelFn, current: SerialId) => TFn;
151
+ /**
152
+ * Optional callback to be called when the event is cancelled
153
+ *
154
+ * This is guaranteed to be only called at most once per execution
155
+ */
156
+ onCancel?: SerialEventCancelCallback;
157
+ };
158
+
159
+ class SerialImpl<TFn extends (...args: any[]) => any> {
160
+ private serial: SerialId;
161
+ private fn: SerialFnCreator<TFn>;
162
+ private onCancel: SerialEventCancelCallback;
163
+
164
+ constructor(
165
+ fn: SerialFnCreator<TFn>,
166
+ onCancel?: SerialEventCancelCallback,
167
+ ) {
168
+ this.fn = fn;
169
+ this.serial = 0n;
170
+ if (onCancel) {
171
+ this.onCancel = onCancel;
172
+ } else {
173
+ this.onCancel = () => {};
174
+ }
175
+ }
176
+
177
+ public async invoke(
178
+ ...args: Parameters<TFn>
179
+ ): Promise<Result<Awaited<ReturnType<TFn>>, SerialCancelToken>> {
180
+ let cancelled = false;
181
+ const currentSerial = ++this.serial;
182
+ const checkCancel = () => {
183
+ if (currentSerial !== this.serial) {
184
+ if (!cancelled) {
185
+ cancelled = true;
186
+ this.onCancel(currentSerial, this.serial);
187
+ }
188
+ throw new Error("cancelled");
189
+ }
190
+ };
191
+ const fn = this.fn;
192
+ // note: no typechecking for "result"
193
+ try {
194
+ const result = await fn(checkCancel, currentSerial)(...args);
195
+ checkCancel();
196
+ return { val: result };
197
+ } catch (e) {
198
+ if (currentSerial !== this.serial) {
199
+ return { err: "cancel" };
200
+ }
201
+ throw e;
202
+ }
203
+ }
204
+ }
205
+
206
+ type SerialId = bigint;
207
+ type CheckCancelFn = () => void;
208
+ type SerialFnCreator<T> = (checkCancel: CheckCancelFn, serial: SerialId) => T;
209
+
210
+ /**
211
+ * The callback type passed to SerialEvent constructor to be called
212
+ * when the event is cancelled
213
+ */
214
+ export type SerialEventCancelCallback = (
215
+ current: SerialId,
216
+ latest: SerialId,
217
+ ) => void;
218
+
219
+ /** The error type received by caller when an event is cancelled */
220
+ export type SerialCancelToken = "cancel";
@@ -0,0 +1,23 @@
1
+ export const makePromise = <T>() => {
2
+ let resolve;
3
+ let reject;
4
+ const promise = new Promise<T>((res, rej) => {
5
+ resolve = res;
6
+ reject = rej;
7
+ });
8
+ if (!resolve || !reject) {
9
+ throw new Error(
10
+ "Promise callbacks not set. This is a bug in the JS engine!",
11
+ );
12
+ }
13
+ return {
14
+ promise,
15
+ resolve,
16
+ reject,
17
+ };
18
+ };
19
+
20
+ /** Shorthand for Awaited<ReturnType<T>> */
21
+ export type AwaitRet<T> = T extends (...args: any[]) => infer R
22
+ ? Awaited<R>
23
+ : never;
@@ -1,35 +0,0 @@
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
- }