sliftutils 1.8.20 → 1.9.1
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/.cursor/rules/coding-styles.mdc +0 -1
- package/.cursor/rules/styling-css.mdc +1 -1
- package/CLAUDE.md +1 -1
- package/index.d.ts +23 -0
- package/misc/helpers.d.ts +1 -0
- package/misc/helpers.ts +6 -0
- package/package.json +1 -1
- package/render-utils/drag.d.ts +15 -0
- package/render-utils/drag.ts +161 -0
- package/storage/backblaze.d.ts +3 -0
- package/storage/backblaze.ts +29 -3
- package/storage/remoteStorage/ArchivesDelayed.ts +7 -1
|
@@ -14,7 +14,6 @@ alwaysApply: true
|
|
|
14
14
|
- When functions have more than one primitive parameter, such that you could confuse them (even something as simple as start time and end time), you should put them inside of an object and call that object `config`, so the function only has a single parameter.
|
|
15
15
|
- Never use return codes, always prefer to throw on error. Include a lot of context information in the error. If any values might be extremely large, such as parsing a file, limit them (ex, to 500 characters).
|
|
16
16
|
- Use double quotes, not single quotes.
|
|
17
|
-
- Never use the ternary operator. Instead, do this: `x ? y : z` => `x && y || z`.
|
|
18
17
|
- Never use non-null assertion operator, instead check the value, and if necessary (because it is accessed in nested functions), use a `const` variable to preserve the type.
|
|
19
18
|
- Errors should almost always use a template string to include the expected value, and the actual value, as in: `throw new Error(\`Expected X, was \${Y}\`);`
|
|
20
19
|
- Don't use switch statements. Use if/else statements instead.
|
|
@@ -34,7 +34,7 @@ className={css
|
|
|
34
34
|
+ (conditionalExample && css.opacity(0.5))
|
|
35
35
|
}
|
|
36
36
|
```
|
|
37
|
-
Specifically, it should be a value that you check for, and then the value. Don't
|
|
37
|
+
Specifically, it should be a value that you check for, and then the value. Don't do `|| ""`. If you have multiple values, chain them with `||` and `&&`. Keep the CSS simple, don't add too much, because it's easier to add than to remove.
|
|
38
38
|
|
|
39
39
|
## Aliases and Helpers
|
|
40
40
|
|
package/CLAUDE.md
CHANGED
package/index.d.ts
CHANGED
|
@@ -60,6 +60,7 @@ declare module "sliftutils/misc/getSecret" {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
declare module "sliftutils/misc/helpers" {
|
|
63
|
+
export declare function throwDetached(error: unknown): void;
|
|
63
64
|
export declare function waitForDiskCollectionFlush(): Promise<void>;
|
|
64
65
|
|
|
65
66
|
}
|
|
@@ -851,6 +852,25 @@ declare module "sliftutils/render-utils/colors" {
|
|
|
851
852
|
|
|
852
853
|
}
|
|
853
854
|
|
|
855
|
+
declare module "sliftutils/render-utils/drag" {
|
|
856
|
+
export declare let dragCount: number;
|
|
857
|
+
type DragOffset = {
|
|
858
|
+
x: number;
|
|
859
|
+
y: number;
|
|
860
|
+
};
|
|
861
|
+
export declare function performDrag(e: MouseEvent, onMove: (offset: DragOffset) => void, onDone?: (offset: DragOffset) => void): void;
|
|
862
|
+
export declare function performDrag2(config: {
|
|
863
|
+
e: MouseEvent;
|
|
864
|
+
onMove?: (offset: DragOffset) => void;
|
|
865
|
+
onDone?: (offset: DragOffset) => void;
|
|
866
|
+
onFinally?: (passedSlop: boolean) => void;
|
|
867
|
+
slop?: number;
|
|
868
|
+
}): void;
|
|
869
|
+
export declare function cancelDrag(): void;
|
|
870
|
+
export {};
|
|
871
|
+
|
|
872
|
+
}
|
|
873
|
+
|
|
854
874
|
declare module "sliftutils/render-utils/mobxTyped" {
|
|
855
875
|
import { observable, runInAction, computed, autorun, onBecomeObserved, onBecomeUnobserved, Reaction } from "mobx";
|
|
856
876
|
export { observable, runInAction, computed, autorun, onBecomeObserved, onBecomeUnobserved, Reaction };
|
|
@@ -3023,6 +3043,9 @@ declare module "sliftutils/storage/backblaze" {
|
|
|
3023
3043
|
private getBucketAPI;
|
|
3024
3044
|
private currentReset;
|
|
3025
3045
|
private last503Reset;
|
|
3046
|
+
private lastAPIReset;
|
|
3047
|
+
private consecutiveFailures;
|
|
3048
|
+
private resetAPIs;
|
|
3026
3049
|
private apiRetryLogic;
|
|
3027
3050
|
get(fileName: string, config?: GetConfig): Promise<Buffer | undefined>;
|
|
3028
3051
|
get2(fileName: string, config?: GetConfig): Promise<{
|
package/misc/helpers.d.ts
CHANGED
package/misc/helpers.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { delay } from "socket-function/src/batching";
|
|
2
2
|
import { hasPending } from "../storage/PendingManager";
|
|
3
3
|
|
|
4
|
+
export function throwDetached(error: unknown): void {
|
|
5
|
+
void Promise.resolve().then(() => {
|
|
6
|
+
throw error;
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
|
|
4
10
|
export async function waitForDiskCollectionFlush() {
|
|
5
11
|
await delay(2000);
|
|
6
12
|
while (hasPending()) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare let dragCount: number;
|
|
2
|
+
type DragOffset = {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
};
|
|
6
|
+
export declare function performDrag(e: MouseEvent, onMove: (offset: DragOffset) => void, onDone?: (offset: DragOffset) => void): void;
|
|
7
|
+
export declare function performDrag2(config: {
|
|
8
|
+
e: MouseEvent;
|
|
9
|
+
onMove?: (offset: DragOffset) => void;
|
|
10
|
+
onDone?: (offset: DragOffset) => void;
|
|
11
|
+
onFinally?: (passedSlop: boolean) => void;
|
|
12
|
+
slop?: number;
|
|
13
|
+
}): void;
|
|
14
|
+
export declare function cancelDrag(): void;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { MaybePromise } from "socket-function/src/types";
|
|
2
|
+
import { runInAction } from "./mobxTyped";
|
|
3
|
+
|
|
4
|
+
export let dragCount = 0;
|
|
5
|
+
|
|
6
|
+
type DragOffset = { x: number; y: number };
|
|
7
|
+
|
|
8
|
+
function throttleFunctionFast<Args extends unknown[]>(
|
|
9
|
+
fnc: (...args: Args) => MaybePromise<void>
|
|
10
|
+
): (...args: Args) => void {
|
|
11
|
+
let pendingCall: { args: Args } | undefined;
|
|
12
|
+
let inCall = false;
|
|
13
|
+
return async (...args: Args) => {
|
|
14
|
+
if (inCall) {
|
|
15
|
+
pendingCall = { args };
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
inCall = true;
|
|
19
|
+
try {
|
|
20
|
+
await fnc(...args);
|
|
21
|
+
} finally {
|
|
22
|
+
inCall = false;
|
|
23
|
+
if (pendingCall) {
|
|
24
|
+
let call = pendingCall;
|
|
25
|
+
pendingCall = undefined;
|
|
26
|
+
void fnc(...call.args);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function inAction<Args extends unknown[]>(fnc: (...args: Args) => void): (...args: Args) => void {
|
|
33
|
+
return (...args: Args) => runInAction(() => fnc(...args));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function performDrag(
|
|
37
|
+
e: MouseEvent,
|
|
38
|
+
onMove: (offset: DragOffset) => void,
|
|
39
|
+
onDone?: (offset: DragOffset) => void,
|
|
40
|
+
) {
|
|
41
|
+
return performDrag2({ e, onMove, onDone });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function performDrag2(
|
|
45
|
+
config: {
|
|
46
|
+
e: MouseEvent;
|
|
47
|
+
onMove?: (offset: DragOffset) => void;
|
|
48
|
+
onDone?: (offset: DragOffset) => void;
|
|
49
|
+
onFinally?: (passedSlop: boolean) => void;
|
|
50
|
+
slop?: number;
|
|
51
|
+
}
|
|
52
|
+
) {
|
|
53
|
+
let { e, slop } = config;
|
|
54
|
+
let onMove = inAction(config.onMove ?? (() => { }));
|
|
55
|
+
let onDone = config.onDone && inAction(config.onDone);
|
|
56
|
+
let onFinally = config.onFinally && inAction(config.onFinally);
|
|
57
|
+
dragCount++;
|
|
58
|
+
|
|
59
|
+
let startMouseX = e.clientX;
|
|
60
|
+
let startMouseY = e.clientY;
|
|
61
|
+
let lastMouseX = e.clientX;
|
|
62
|
+
let lastMouseY = e.clientY;
|
|
63
|
+
|
|
64
|
+
let passedSlop = !slop;
|
|
65
|
+
|
|
66
|
+
function cancel() {
|
|
67
|
+
lastMouseX = startMouseX;
|
|
68
|
+
lastMouseY = startMouseY;
|
|
69
|
+
finish();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let finished = false;
|
|
73
|
+
let disposed = false;
|
|
74
|
+
|
|
75
|
+
const triggerMove = throttleFunctionFast(async () => {
|
|
76
|
+
if (disposed) return;
|
|
77
|
+
let deltaX = lastMouseX - startMouseX;
|
|
78
|
+
let deltaY = lastMouseY - startMouseY;
|
|
79
|
+
if (slop) {
|
|
80
|
+
let dist = Math.sqrt(deltaX ** 2 + deltaY ** 2);
|
|
81
|
+
if (dist > slop) passedSlop = true;
|
|
82
|
+
if (!passedSlop) return;
|
|
83
|
+
}
|
|
84
|
+
onMove({ x: deltaX, y: deltaY });
|
|
85
|
+
await new Promise(r => requestAnimationFrame(r));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const onMouseMove = (e: MouseEvent) => {
|
|
89
|
+
if (finished) return;
|
|
90
|
+
if (e.buttons === 0) {
|
|
91
|
+
finish();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (lastMouseX === e.clientX && lastMouseY === e.clientY) return;
|
|
95
|
+
lastMouseX = e.clientX;
|
|
96
|
+
lastMouseY = e.clientY;
|
|
97
|
+
triggerMove();
|
|
98
|
+
};
|
|
99
|
+
let finish = () => {
|
|
100
|
+
selfCancelCallback.delete(cancel);
|
|
101
|
+
try {
|
|
102
|
+
if (finished) return;
|
|
103
|
+
finished = true;
|
|
104
|
+
dragCount--;
|
|
105
|
+
window.removeEventListener("mousemove", onMouseMove);
|
|
106
|
+
window.removeEventListener("mouseup", finish, { capture: true });
|
|
107
|
+
window.removeEventListener("blur", onBlur);
|
|
108
|
+
document.removeEventListener("keydown", keyDown);
|
|
109
|
+
triggerMove();
|
|
110
|
+
if (onDone) {
|
|
111
|
+
let deltaX = lastMouseX - startMouseX;
|
|
112
|
+
let deltaY = lastMouseY - startMouseY;
|
|
113
|
+
if (slop) {
|
|
114
|
+
let dist = Math.sqrt(deltaX ** 2 + deltaY ** 2);
|
|
115
|
+
if (dist > slop) passedSlop = true;
|
|
116
|
+
if (!passedSlop) return;
|
|
117
|
+
}
|
|
118
|
+
onDone({ x: deltaX, y: deltaY });
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
setTimeout(() => {
|
|
122
|
+
onFinally?.(passedSlop);
|
|
123
|
+
}, 0);
|
|
124
|
+
}
|
|
125
|
+
disposed = true;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const keyDown = (e: KeyboardEvent) => {
|
|
129
|
+
if (finished) return;
|
|
130
|
+
if (e.code === "Escape") {
|
|
131
|
+
cancel();
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const onBlur = () => {
|
|
136
|
+
if (finished) return;
|
|
137
|
+
cancel();
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
window.addEventListener("mousemove", onMouseMove);
|
|
141
|
+
window.addEventListener("mouseup", finish, { capture: true });
|
|
142
|
+
window.addEventListener("blur", onBlur);
|
|
143
|
+
document.addEventListener("keydown", keyDown);
|
|
144
|
+
selfCancelCallback.add(cancel);
|
|
145
|
+
|
|
146
|
+
if (!slop) {
|
|
147
|
+
onMove({ x: 0, y: 0 });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let selfCancelCallback = new Set<() => void>();
|
|
152
|
+
export function cancelDrag() {
|
|
153
|
+
for (let callback of selfCancelCallback) {
|
|
154
|
+
try {
|
|
155
|
+
callback();
|
|
156
|
+
} catch (e) {
|
|
157
|
+
console.error(`Error in drag cancel callback: ${(e as Error).stack ?? e}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
selfCancelCallback.clear();
|
|
161
|
+
}
|
package/storage/backblaze.d.ts
CHANGED
|
@@ -21,6 +21,9 @@ export declare class ArchivesBackblaze implements IArchives {
|
|
|
21
21
|
private getBucketAPI;
|
|
22
22
|
private currentReset;
|
|
23
23
|
private last503Reset;
|
|
24
|
+
private lastAPIReset;
|
|
25
|
+
private consecutiveFailures;
|
|
26
|
+
private resetAPIs;
|
|
24
27
|
private apiRetryLogic;
|
|
25
28
|
get(fileName: string, config?: GetConfig): Promise<Buffer | undefined>;
|
|
26
29
|
get2(fileName: string, config?: GetConfig): Promise<{
|
package/storage/backblaze.ts
CHANGED
|
@@ -29,6 +29,9 @@ let backblazeCreds = lazy(async (): Promise<BackblazeCreds> => {
|
|
|
29
29
|
const MIN_BUCKET_CACHE_TIME = 60 * 1000;
|
|
30
30
|
// Backblaze requires 5MB chunks for large files, but larger is more efficient for us (and their "last part" size check misbehaves on retries, which chunks this big sidestep - see setLargeFile)
|
|
31
31
|
const LARGE_FILE_MIN_CHUNK_SIZE = 32 * 1024 * 1024;
|
|
32
|
+
const CONSECUTIVE_FAILURES_BEFORE_RESET = 10;
|
|
33
|
+
const MIN_API_RESET_INTERVAL = 1000;
|
|
34
|
+
const CONNECTION_DEAD_MARKERS = ["TLS", "ECONNRESET", "ECONNREFUSED", "EPIPE", "socket hang up", "socket disconnected"];
|
|
32
35
|
|
|
33
36
|
// A B2 download/HEAD response's headers carry the file's metadata. content-range's total wins over content-length for ranged responses (which only report the slice's length).
|
|
34
37
|
function parseFileMetadataHeaders(response: HttpsResponseInfo): { size: number; writeTime: number } {
|
|
@@ -562,6 +565,17 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
562
565
|
|
|
563
566
|
// Keep track of when we last reset because of a 503
|
|
564
567
|
private last503Reset = 0;
|
|
568
|
+
private lastAPIReset = 0;
|
|
569
|
+
private consecutiveFailures = 0;
|
|
570
|
+
|
|
571
|
+
private resetAPIs(context: string, reason: string, err: Error) {
|
|
572
|
+
if (Date.now() - this.lastAPIReset < MIN_API_RESET_INTERVAL) return;
|
|
573
|
+
this.lastAPIReset = Date.now();
|
|
574
|
+
console.warn(`[${context}] Resetting getAPI and getBucketAPI for ${this.getDebugName()} (${reason}, ${this.consecutiveFailures} consecutive failures): ${err.message}`);
|
|
575
|
+
getAPI.reset();
|
|
576
|
+
this.getBucketAPI.reset();
|
|
577
|
+
this.consecutiveFailures = 0;
|
|
578
|
+
}
|
|
565
579
|
// IMPORTANT! We must always CATCH AROUND the apiRetryLogic, NEVER inside of fnc. Otherwise we won't be able to recreate the auth token. `context` is a short label (verb + file path) included in every retry/error log so a stuck silent-retry loop is identifiable from the logs.
|
|
566
580
|
private async apiRetryLogic<T>(
|
|
567
581
|
context: string,
|
|
@@ -571,10 +585,10 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
571
585
|
let api: B2Api | undefined;
|
|
572
586
|
try {
|
|
573
587
|
api = await this.getBucketAPI();
|
|
574
|
-
|
|
588
|
+
let result = await fnc(api);
|
|
589
|
+
this.consecutiveFailures = 0;
|
|
590
|
+
return result;
|
|
575
591
|
} catch (err: any) {
|
|
576
|
-
if (retries <= 0) throw err;
|
|
577
|
-
|
|
578
592
|
// A file that does not exist is an answer, not a failure. It is the one error here that
|
|
579
593
|
// cannot become anything else by asking again, so it is thrown at once instead of being
|
|
580
594
|
// slept on twice - callers turn it into undefined, which is what they were asking.
|
|
@@ -583,9 +597,21 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
583
597
|
|| err.stack.includes(`file_not_found`)
|
|
584
598
|
|| err.stack.includes(`"status": 404`)
|
|
585
599
|
) {
|
|
600
|
+
this.consecutiveFailures = 0;
|
|
586
601
|
throw err;
|
|
587
602
|
}
|
|
588
603
|
|
|
604
|
+
this.consecutiveFailures++;
|
|
605
|
+
if (!api) {
|
|
606
|
+
this.resetAPIs(context, "acquiring the api failed, so the lazy holds a rejected promise", err);
|
|
607
|
+
} else if (CONNECTION_DEAD_MARKERS.some(marker => err.stack.includes(marker))) {
|
|
608
|
+
this.resetAPIs(context, "connection level error", err);
|
|
609
|
+
} else if (this.consecutiveFailures >= CONSECUTIVE_FAILURES_BEFORE_RESET) {
|
|
610
|
+
this.resetAPIs(context, `${CONSECUTIVE_FAILURES_BEFORE_RESET} calls in a row failed`, err);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (retries <= 0) throw err;
|
|
614
|
+
|
|
589
615
|
// If it's a 503 and it's been a minute since we last reset, then Wait and reset.
|
|
590
616
|
if (
|
|
591
617
|
(err.stack.includes(`"status": 503`)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runInfinitePoll } from "socket-function/src/batching";
|
|
2
|
+
import { throwDetached } from "../../misc/helpers";
|
|
2
3
|
import {
|
|
3
4
|
IArchives, ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, DelConfig,
|
|
4
5
|
FindConfig, GetConfig, GetInfoConfig, SetConfig, SetLargeFileConfig, SourceConfig,
|
|
@@ -105,8 +106,13 @@ export class ArchivesDelayed implements IArchives {
|
|
|
105
106
|
let deadlineReached = force || this.deadlinePassed();
|
|
106
107
|
for (let [fileName, entry] of [...this.pending]) {
|
|
107
108
|
if (!deadlineReached && entry.dueAt > now) continue;
|
|
109
|
+
try {
|
|
110
|
+
await this.write(fileName, entry);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
throwDetached(new Error(`Flushing ${JSON.stringify(fileName)} (${entry.data.length} bytes, writeTime ${entry.writeTime}) to ${this.inner.getDebugName()} failed, it stays pending: ${(e as Error).stack ?? e}`));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
108
115
|
// Only drop it if it wasn't overwritten while we were writing
|
|
109
|
-
await this.write(fileName, entry);
|
|
110
116
|
if (this.pending.get(fileName) === entry) {
|
|
111
117
|
this.pending.delete(fileName);
|
|
112
118
|
}
|