querysub 0.153.0 → 0.154.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/package.json +6 -6
  2. package/src/-b-authorities/cloudflareHelpers.ts +11 -2
  3. package/src/3-path-functions/PathFunctionRunner.ts +168 -97
  4. package/src/3-path-functions/PathFunctionRunnerMain.ts +8 -2
  5. package/src/3-path-functions/pathFunctionLoader.ts +11 -6
  6. package/src/3-path-functions/syncSchema.ts +10 -1
  7. package/src/4-deploy/edgeBootstrap.ts +10 -1
  8. package/src/4-querysub/Querysub.ts +77 -3
  9. package/src/4-querysub/QuerysubController.ts +22 -2
  10. package/src/4-querysub/permissions.ts +33 -2
  11. package/src/4-querysub/querysubPrediction.ts +52 -18
  12. package/src/archiveapps/archiveGCEntry.tsx +38 -0
  13. package/src/archiveapps/archiveJoinEntry.ts +121 -0
  14. package/src/archiveapps/archiveMergeEntry.tsx +47 -0
  15. package/src/archiveapps/compressTest.tsx +59 -0
  16. package/src/archiveapps/lockTest.ts +127 -0
  17. package/src/config.ts +5 -0
  18. package/src/diagnostics/managementPages.tsx +55 -0
  19. package/src/diagnostics/misc-pages/ArchiveInspect.tsx +325 -0
  20. package/src/diagnostics/misc-pages/ArchiveViewer.tsx +781 -0
  21. package/src/diagnostics/misc-pages/ArchiveViewerTable.tsx +156 -0
  22. package/src/diagnostics/misc-pages/ArchiveViewerTree.tsx +573 -0
  23. package/src/diagnostics/misc-pages/ComponentSyncStats.tsx +129 -0
  24. package/src/diagnostics/misc-pages/LocalWatchViewer.tsx +431 -0
  25. package/src/diagnostics/misc-pages/RequireAuditPage.tsx +218 -0
  26. package/src/diagnostics/misc-pages/SnapshotViewer.tsx +206 -0
  27. package/src/diagnostics/misc-pages/TimeRangeView.tsx +648 -0
  28. package/src/diagnostics/misc-pages/archiveViewerFilter.tsx +221 -0
  29. package/src/diagnostics/misc-pages/archiveViewerShared.tsx +76 -0
  30. package/src/email/postmark.tsx +40 -0
  31. package/src/email/sendgrid.tsx +44 -0
  32. package/src/functional/UndoWatch.tsx +133 -0
  33. package/src/functional/diff.ts +858 -0
  34. package/src/functional/promiseCache.ts +67 -0
  35. package/src/functional/random.ts +9 -0
  36. package/src/functional/runCommand.ts +42 -0
  37. package/src/functional/runOnce.ts +7 -0
  38. package/src/functional/stats.ts +61 -0
  39. package/src/functional/throttleRerender.tsx +80 -0
  40. package/src/library-components/AspectSizedComponent.tsx +88 -0
  41. package/src/library-components/Histogram.tsx +338 -0
  42. package/src/library-components/InlinePopup.tsx +67 -0
  43. package/src/library-components/Notifications.tsx +153 -0
  44. package/src/library-components/RenderIfVisible.tsx +80 -0
  45. package/src/library-components/SimpleNotification.tsx +133 -0
  46. package/src/library-components/TabbedUI.tsx +39 -0
  47. package/src/library-components/animateAnyElement.tsx +65 -0
  48. package/src/library-components/errorNotifications.tsx +81 -0
  49. package/src/library-components/placeholder.ts +18 -0
  50. package/src/misc/format2.ts +48 -0
  51. package/src/misc.ts +33 -0
  52. package/src/misc2.ts +5 -0
  53. package/src/server.ts +2 -1
  54. package/src/storage/diskCache.ts +227 -0
  55. package/src/storage/diskCache2.ts +122 -0
  56. package/src/storage/fileSystemPointer.ts +72 -0
  57. package/src/user-implementation/LoginPage.tsx +78 -0
  58. package/src/user-implementation/RequireAuditPage.tsx +219 -0
  59. package/src/user-implementation/SecurityPage.tsx +212 -0
  60. package/src/user-implementation/UserPage.tsx +320 -0
  61. package/src/user-implementation/addSuperUser.ts +21 -0
  62. package/src/user-implementation/canSeeSource.ts +41 -0
  63. package/src/user-implementation/loginEmail.tsx +159 -0
  64. package/src/user-implementation/setEmailKey.ts +20 -0
  65. package/src/user-implementation/userData.ts +974 -0
@@ -0,0 +1,133 @@
1
+ import preact from "preact";
2
+ import { css } from "../4-dom/css";
3
+ import { Icon } from "./icons";
4
+ import { qreact } from "../4-dom/qreact";
5
+ import { closeCurrentNotification, createCloseCurrentNotification } from "./Notifications";
6
+ import { nextId } from "socket-function/src/misc";
7
+ import { Querysub } from "../4-querysub/Querysub";
8
+
9
+ let nextSeqNum = 1;
10
+ export class SimpleNotification extends qreact.Component<{
11
+ type: "info" | "warn" | "error" | "success";
12
+ title: string;
13
+ usesClickEvents?: boolean;
14
+ timeout?: number;
15
+ tooltip?: string;
16
+ }> {
17
+ componentDidMount(): void {
18
+ let timeout = this.timeout();
19
+ if (timeout > 0) {
20
+ let closeNotification = createCloseCurrentNotification();
21
+ if (Querysub.isAllSynced()) {
22
+ setTimeout(() => closeNotification?.(), timeout);
23
+ }
24
+ }
25
+ }
26
+ timeout() {
27
+ return this.props.timeout ?? 10_000;
28
+ }
29
+ seqNum = nextSeqNum++;
30
+ render() {
31
+ let { type, title, children, usesClickEvents } = this.props;
32
+ let color = (
33
+ type === "info" && { h: 188, s: 52 }
34
+ || type === "warn" && { h: 50, s: 100 }
35
+ || type === "error" && { h: 3, s: 62 }
36
+ || type === "success" && { h: 155, s: 66 }
37
+ || { h: 0, s: 50 }
38
+ );
39
+ let animClassName = `SimpleNotification-${this.seqNum}`;
40
+ return (
41
+ <div
42
+ class={
43
+ css.color("white").pad(2)
44
+ .hsla(color.h, color.s, 50, 0.9)
45
+ .bord(2, `hsl(${color.h}, ${color.s}%, 60%)`)
46
+ .hbox(10)
47
+ .maxWidth("40vw")
48
+ .relative
49
+ .overflowHidden
50
+ + (!usesClickEvents ? css.button : "")
51
+ + " " + animClassName
52
+ }
53
+ onClick={() => {
54
+ void navigator.clipboard.writeText(title);
55
+ if (!usesClickEvents) {
56
+ closeCurrentNotification();
57
+ }
58
+ }}
59
+ title={this.props.tooltip}
60
+ >
61
+ <div
62
+ class={
63
+ css
64
+ .absolute.zIndex(-1).pos(0, 0).fillHeight
65
+ .hsla(color.h, color.s, 50, 0.9)
66
+ + " " + animClassName + "-bg"
67
+ }
68
+ />
69
+ <div class={css.pad(2, 4).vbox(2).fillWidth}>
70
+ <div class={css.ellipsis.fillWidth.fontWeight("bold")}>
71
+ {title}
72
+ </div>
73
+ <div>
74
+ {children}
75
+ </div>
76
+ </div>
77
+ <div class={css.margin("auto")} />
78
+ <div class={css.flexShrink0.alignSelf("start").button} onClick={() => {
79
+ if (usesClickEvents) {
80
+ closeCurrentNotification();
81
+ }
82
+ }}>
83
+ {Icon.close({ size: 20, stroke: "white", fill: "hsl(0, 0%, 70%)" })}
84
+ </div>
85
+ <style>
86
+ {`
87
+ @keyframes ${animClassName}-anim {
88
+ 0% { transform: translateY(-100%); }
89
+ 100% { transform: translateY(0); }
90
+ }
91
+ .${animClassName} {
92
+ animation: ${animClassName}-anim 0.3s;
93
+ }
94
+
95
+ @keyframes ${animClassName}-bg-anim {
96
+ 0% { width: 100% }
97
+ 100% { width: 0% }
98
+ }
99
+ .${animClassName}-bg {
100
+ animation: ${animClassName}-bg-anim ${this.timeout()}ms linear;
101
+ }
102
+ `}
103
+ </style>
104
+ </div>
105
+ );
106
+ }
107
+ }
108
+ /*
109
+ primary-50: hsla(188, 52%, 84%, 1);
110
+ primary-300: hsla(188, 46%, 69%, 1);
111
+ primary-500: hsla(188, 45%, 54%, 1);
112
+ primary-700: hsla(192, 89%, 29%, 1);
113
+ primary-900: hsla(192, 100%, 20%, 1);
114
+
115
+ success-50: hsla(155, 66%, 86%, 1);
116
+ success-300: hsla(155, 50%, 70%, 1);
117
+ success-500: hsla(155, 45%, 54%, 1);
118
+ success-700: hsla(155, 90%, 29%, 1);
119
+ success-900: hsla(155, 100%, 20%, 1);
120
+
121
+ warning-50: hsla(50, 100%, 88%, 1);
122
+ warning-75: hsla(50, 100%, 83%, 1);
123
+ warning-300: hsla(50, 79%, 76%, 1);
124
+ warning-500: hsla(50, 81%, 67%, 1);
125
+ warning-700: hsla(50, 100%, 61%, 1);
126
+ warning-900: hsla(50, 100%, 43%, 1);
127
+
128
+ error-50: hsla(3, 62%, 89%, 1);
129
+ error-300: hsla(3, 100%, 83%, 1);
130
+ error-500: hsla(3, 86%, 70%, 1);
131
+ error-700: hsla(3, 91%, 64%, 1);
132
+ error-900: hsla(3, 67%, 52%, 1);
133
+ */
@@ -0,0 +1,39 @@
1
+ import preact from "preact";
2
+ import { qreact } from "../4-dom/qreact";
3
+ import { css } from "../4-dom/css";
4
+ import { ATag } from "../library-components/ATag";
5
+ import { URLParam, createURLSync } from "../library-components/URLParam";
6
+
7
+ const tabURL = createURLSync<string>("tab", "");
8
+
9
+ export class TabbedUI extends qreact.Component<{
10
+ // Defaults Params.tab
11
+ tab?: URLParam;
12
+ /** The first tab is the default */
13
+ tabs: { value: string; title: preact.ComponentChild; contents: preact.ComponentChild; }[];
14
+ }> {
15
+ render() {
16
+ let tabs = this.props.tabs;
17
+ let tabPointer = this.props.tab ?? tabURL;
18
+ let currentTab = tabs.find(t => t.value === tabPointer.value) ?? tabs[0];
19
+ return (
20
+ <div class={css.vbox(0)}>
21
+ <div class={css.hbox(0).fontSize(16)}>
22
+ {tabs.map(tab => (
23
+ <ATag
24
+ values={[{ param: tabPointer, value: tab.value }]}
25
+ class={css.flex.pad(10).bord(0, { h: 210, s: 0, l: 25 }).borderLeftWidth(1)
26
+ + (tab.value === currentTab.value && css.hsl(210, 50, 40) || css.hsl(210, 0, 40))
27
+ }
28
+ >
29
+ {tab.title}
30
+ </ATag>
31
+ ))}
32
+ </div>
33
+ <div class={css.bord(2, { h: 210, s: 0, l: 25 }).alignSelf("stretch").pad(10).vbox(10)}>
34
+ {currentTab.contents}
35
+ </div>
36
+ </div>
37
+ );
38
+ }
39
+ }
@@ -0,0 +1,65 @@
1
+ import { delay } from "socket-function/src/batching";
2
+ import { cacheJSONArgsEqual } from "socket-function/src/caching";
3
+ import { isNode } from "typesafecss";
4
+
5
+ /**
6
+ Animates the element as it moves, even if it is just part of a regular layout,
7
+ which works no matter where it moves. A unique id must be passed in, to
8
+ ensure the right position is animated. It is encouraged to use a key,
9
+ to ensure your element isn't reused with unrelated elements.
10
+ - Sets transition and transform on the element, and so is incompatible
11
+ with elements that use those.
12
+
13
+
14
+ For example:
15
+ <div key={key} ref={createRefAnimation(key)}>
16
+ Test
17
+ </div>
18
+
19
+ */
20
+ export const createRefAnimation = cacheJSONArgsEqual((id: string, transitionTime = "200ms", transitionType = "ease-in-out") => {
21
+ let prevRect: DOMRect | undefined;
22
+ let prevScrollOffsetTop = 0;
23
+ let prevScrollOffsetLeft = 0;
24
+ return async (elem: HTMLElement | null) => {
25
+ if (!elem) return;
26
+ let newRect = elem.getBoundingClientRect();
27
+ // If we haven't even started the transition, skip
28
+ if (elem.style.transform) {
29
+ return;
30
+ }
31
+ // If we are still animating, skip. Stopping it causes issues, and it is smoother to just
32
+ // let it finish.
33
+ if (elem.getAnimations().length > 0) {
34
+ return;
35
+ }
36
+ let curPrevRect = prevRect;
37
+ prevRect = newRect;
38
+
39
+ let newScrollOffsetObj = getScrollOffsetSum(elem);
40
+ let scrollChangeTop = newScrollOffsetObj.top - prevScrollOffsetTop;
41
+ prevScrollOffsetTop = newScrollOffsetObj.top;
42
+ let scrollChangeLeft = newScrollOffsetObj.left - prevScrollOffsetLeft;
43
+ prevScrollOffsetLeft = newScrollOffsetObj.left;
44
+
45
+ if (!curPrevRect?.width || !newRect.width || curPrevRect.left === newRect.left) {
46
+ return;
47
+ }
48
+ elem.style.transform = `translate(${curPrevRect.left - newRect.left - scrollChangeLeft}px, ${curPrevRect.top - newRect.top - scrollChangeTop}px)`;
49
+ elem.style.transition = `transform 0s ${transitionType}`;
50
+ await delay("paintLoop");
51
+ elem.style.transform = "";
52
+ elem.style.transition = `transform ${transitionTime} ${transitionType}`;
53
+ };
54
+ }, 10000);
55
+
56
+ function getScrollOffsetSum(elem: HTMLElement | null) {
57
+ let top = 0;
58
+ let left = 0;
59
+ while (elem) {
60
+ top += elem.scrollTop;
61
+ left += elem.scrollLeft;
62
+ elem = elem.parentElement;
63
+ }
64
+ return { top, left };
65
+ }
@@ -0,0 +1,81 @@
1
+ import { isNode } from "socket-function/src/misc";
2
+ import { showNotification } from "./Notifications";
3
+ import { SimpleNotification } from "./SimpleNotification";
4
+ import { qreact } from "../4-dom/qreact";
5
+ import { Querysub } from "../4-querysub/Querysub";
6
+ import { css } from "../4-dom/css";
7
+
8
+ function onUncaught(...args: unknown[]) {
9
+ let error = args[4] as Error;
10
+ if (!error) {
11
+ error = (args[0] as any).reason || { message: args[0] };
12
+ }
13
+ // Ignore ResizeObserver errors, they are spurious
14
+ // - https://github.com/vercel/next.js/discussions/51551
15
+ if (error.message.startsWith("ResizeObserver loop")) return;
16
+
17
+ onMessage({
18
+ type: "error",
19
+ message: error.message,
20
+ stack: error.stack || error.message,
21
+ });
22
+ };
23
+
24
+ if (!isNode()) {
25
+ window.onerror = window.onunhandledrejection = onUncaught;
26
+ function shimLog(type: "warn" | "error") {
27
+ let base = console[type];
28
+ console[type] = function (message: string, ...args: unknown[]) {
29
+ onMessage({ type, message, stack: message + "\n" + args.join("\n") });
30
+ base.call(console, message, ...args);
31
+ };
32
+ }
33
+
34
+ shimLog("error");
35
+ // NOTE: We MIGHT turn warning back on, if we can add some kind of dev switch.
36
+ // But for normal users... warnings are useless, and caused by all sorts of things
37
+ // which are completely fine and require no action.
38
+ //shimLog("warn");
39
+ }
40
+
41
+ let lastUserWantsInfo = false;
42
+ export function enableErrorNotifications() {
43
+ lastUserWantsInfo = true;
44
+ }
45
+
46
+ function onMessage(config: {
47
+ type: "error" | "warn";
48
+ message: string;
49
+ stack: string;
50
+ }) {
51
+ if (!lastUserWantsInfo && config.type !== "error") return;
52
+
53
+ let title = stripConsoleRenderedColors(config.message).split("\n")[0];
54
+ if (title.startsWith("Error: ")) {
55
+ title = title.slice("Error: ".length);
56
+ }
57
+ showNotification({
58
+ pos: "topRight",
59
+ id: title,
60
+ notification: <SimpleNotification
61
+ type={config.type}
62
+ title={title}
63
+ tooltip={config.stack}
64
+ >
65
+ {/* NOTE: I've stopping showing this info. It's annoying, as they can just look in the console
66
+ (as most of these come from console.error anyways). */}
67
+ {/* {lastUserWantsInfo && <div class={config.stack.trim().includes("\n") && css.whiteSpace("pre-wrap") || ""}>
68
+ {stripConsoleRenderedColors(config.stack)}
69
+ </div>} */}
70
+ </SimpleNotification>
71
+ });
72
+ }
73
+
74
+ // \033[XXXm
75
+ // \033[31;42m
76
+ // \033[38;5;206m
77
+ // \033[38;2;<r>;<g>;<b>m
78
+
79
+ function stripConsoleRenderedColors(text: string) {
80
+ return String(text).replaceAll(/\033\[\d+(;\d+)*m/g, "");
81
+ }
@@ -0,0 +1,18 @@
1
+ import { cacheArgsEqual } from "socket-function/src/caching";
2
+
3
+ export const getPlaceholder = cacheArgsEqual((width, height) => {
4
+ let canvas = document.createElement("canvas");
5
+ canvas.width = width;
6
+ canvas.height = height;
7
+ let ctx = canvas.getContext("2d")!;
8
+ ctx.fillStyle = "hsl(0, 0%, 80%)";
9
+ ctx.fillRect(0, 0, width, height);
10
+ // Draw width x height in the middle of the canvas
11
+ ctx.fillStyle = "hsl(0, 0%, 60%)";
12
+ // Bold
13
+ ctx.font = "bold 40px sans-serif";
14
+ ctx.textAlign = "center";
15
+ ctx.textBaseline = "middle";
16
+ ctx.fillText(`${width} x ${height}`, width / 2, height / 2);
17
+ return canvas.toDataURL();
18
+ }, 10);
@@ -0,0 +1,48 @@
1
+ export function parseNice(text: string): unknown {
2
+ if (text === "undefined") return undefined;
3
+ try {
4
+ return JSON.parse(text);
5
+ } catch { }
6
+ return text;
7
+ }
8
+ export function stringifyNice(value: unknown): string {
9
+ if (value === undefined) return "undefined";
10
+ if (value === "") return JSON.stringify("");
11
+ let testStringParse = parseNice(String(value));
12
+ if (testStringParse === value) {
13
+ return String(value);
14
+ }
15
+ return JSON.stringify(value);
16
+ }
17
+
18
+ export function niceNumberStringify(valueIn: number) {
19
+ if (Math.abs(valueIn) < 0.0000000001) {
20
+ return "0";
21
+ }
22
+ let value = valueIn.toString();
23
+ // TODO: Do this MUCH better...
24
+ if (value.slice(0, -1).endsWith("00000000000")) {
25
+ value = value.slice(0, -1);
26
+ while (value.endsWith("0")) {
27
+ value = value.slice(0, -1);
28
+ }
29
+ if (value.endsWith(".")) {
30
+ value = value.slice(0, -1);
31
+ }
32
+ return value;
33
+ }
34
+ if (value.slice(0, -1).endsWith("9999999999")) {
35
+ value = value.slice(0, -1);
36
+ while (value.endsWith("9")) {
37
+ value = value.slice(0, -1);
38
+ }
39
+ if (value.endsWith(".")) {
40
+ value = value.slice(0, -1);
41
+ }
42
+ // NOTE: Interestingly enough... because we remove all trailing 9s, it means if the last number is not 9,
43
+ // so... we can do this hack to round up
44
+ value = value.slice(0, -1) + (parseInt(value.slice(-1)) + 1);
45
+ return value;
46
+ }
47
+ return value;
48
+ }
package/src/misc.ts CHANGED
@@ -16,4 +16,37 @@ export function isEmpty(obj: unknown): boolean {
16
16
 
17
17
  export function isDefined<T>(value: T | undefined | null): value is T {
18
18
  return value !== undefined && value !== null;
19
+ }
20
+
21
+ export function ObjectAssign<T extends { [key: string]: unknown }>(obj: T, values: Partial<T>) {
22
+ return Object.assign(obj, values);
23
+ }
24
+
25
+ export function ellipsis(text: string, max: number) {
26
+ if (text.length <= max) return text;
27
+ return text.slice(0, max - 3) + "...";
28
+ }
29
+ export function ellipsisMiddle(text: string, max: number) {
30
+ if (text.length <= max) return text;
31
+ let half = Math.floor((max - 3) / 2);
32
+ return text.slice(0, half) + "..." + text.slice(-half);
33
+ }
34
+ export function ellipsisStart(text: string, max: number) {
35
+ if (text.length <= max) return text;
36
+ return "..." + text.slice(-max + 3);
37
+ }
38
+
39
+ export function joinVNodes(vNodes: preact.ComponentChild[], delimitter: preact.ComponentChild) {
40
+ let output: preact.ComponentChild[] = [];
41
+ for (let i = 0; i < vNodes.length; i++) {
42
+ if (i !== 0) {
43
+ output.push(delimitter);
44
+ }
45
+ output.push(vNodes[i]);
46
+ }
47
+ return output;
48
+ }
49
+
50
+ export function clamp(value: number, min: number, max: number) {
51
+ return Math.max(min, Math.min(max, value));
19
52
  }
package/src/misc2.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { atomic } from "./2-proxy/PathValueProxyWatcher";
2
+
3
+ export function isStrSimilar(a: string | undefined, b: string | undefined) {
4
+ return atomic(a)?.toLowerCase().trim().replaceAll(" ", "") === atomic(b)?.toLowerCase().trim().replaceAll(" ", "");
5
+ }
package/src/server.ts CHANGED
@@ -24,10 +24,11 @@ import { formatTime } from "socket-function/src/formatting/format";
24
24
  import "./4-querysub/Querysub";
25
25
  import { getOwnThreadId } from "./-f-node-discovery/NodeDiscovery";
26
26
  import { getOurAuthorities } from "./config2";
27
- import { isPublic } from "./config";
27
+ import { devDebugbreak, isPublic } from "./config";
28
28
 
29
29
  let yargObj = isNodeTrue() && yargs(process.argv)
30
30
  .option("authority", { type: "string", desc: `Defines the base paths we are an authority on (the domain is prepended to them). Either a file path to a JSON(AuthorityPath[]), or a base64 representation of the JSON(AuthorityPath[]).` })
31
+ .option("verbose", { type: "boolean", desc: "Log all writes and reads" })
31
32
  .argv || {}
32
33
  ;
33
34
 
@@ -0,0 +1,227 @@
1
+ import { asBuffer } from "../buffers";
2
+ import { assert, assertValue, logErrors } from "../errors";
3
+ import { batchFunction } from "socket-function/src/batching";
4
+ import { lazy } from "socket-function/src/caching";
5
+ import { formatNumber, formatTime } from "socket-function/src/formatting/format";
6
+ import { isNode } from "socket-function/src/misc";
7
+ import { measureBlock, measureWrap } from "socket-function/src/profiling/measure";
8
+
9
+
10
+
11
+ let persist = lazy(() => {
12
+ if (!isNode()) {
13
+ logErrors(navigator.storage?.persist());
14
+ }
15
+ });
16
+
17
+ let existingDiskCaches = new Set<string>();
18
+ /** Faster at reading then DiskBackedLookup (up to 3X, depending on the case), BUT...
19
+ * synchronously blocks for up to 30ms per write, and gives errors on large transactions/writes.
20
+ */
21
+ export function diskCache(
22
+ config: {
23
+ uniqueName: string;
24
+ /** Default = 64MB */
25
+ inMemoryLimit?: number;
26
+ }
27
+ ): {
28
+ get(key: string[]): Promise<(Buffer | undefined)[]>;
29
+ getSingle(key: string): Promise<Buffer | undefined>;
30
+ set(key: string, value: Buffer): void;
31
+ } {
32
+ let { uniqueName } = config;
33
+ const inMemoryLimit = config.inMemoryLimit ?? 64 * 1024 * 1024;
34
+
35
+ if (existingDiskCaches.has(uniqueName)) {
36
+ throw new Error(`Duplicate diskCache ${uniqueName}`);
37
+ }
38
+ existingDiskCaches.add(uniqueName);
39
+ uniqueName = "diskCache3_" + uniqueName;
40
+
41
+ if (isNode() || !navigator.storage) {
42
+ return {
43
+ async get(keys: string[]) {
44
+ return keys.map(x => undefined);
45
+ },
46
+ async getSingle() {
47
+ return undefined;
48
+ },
49
+ set() { }
50
+ };
51
+ }
52
+
53
+ const objectStoreName = "buffers";
54
+ let getDB = lazy((async () => {
55
+ let db = indexedDB.open(uniqueName, 1);
56
+ db.addEventListener("upgradeneeded", () => {
57
+ db.result.createObjectStore(objectStoreName, {});
58
+ });
59
+ await new Promise(resolve => db.addEventListener("success", resolve));
60
+ return db.result;
61
+ }));
62
+ async function getTransaction() {
63
+ let database = await getDB();
64
+ return database.transaction(objectStoreName, "readwrite").objectStore(objectStoreName);
65
+ }
66
+
67
+ let totalBufferCacheSize = 0;
68
+ let bufferCache = new Map<string, Buffer>();
69
+
70
+ // TODO: Batch this better, as each request is taking ~10ms on my laptop (even if only 0B is loaded).
71
+ async function getBufferCachedBase(bufferIds: string[]): Promise<(Buffer | undefined | "retry")[]> {
72
+ let transaction = await getTransaction();
73
+
74
+ let time = Date.now();
75
+
76
+ let bufferPromises: Map<string, Promise<Buffer | undefined | "retry">> = new Map();
77
+ for (let bufferId of new Set(bufferIds)) {
78
+ let memoryCache = bufferCache.get(bufferId);
79
+ if (memoryCache) {
80
+ bufferPromises.set(bufferId, Promise.resolve(memoryCache));
81
+ } else {
82
+ let result = transaction.get(bufferId);
83
+ let dataPromise = new Promise<Buffer | undefined | "retry">((resolve, reject) => {
84
+ result.addEventListener("success", () => {
85
+ let data = result.result;
86
+ if (!data) {
87
+ resolve(undefined);
88
+ } else {
89
+ resolve(asBuffer(data));
90
+ }
91
+ });
92
+ result.addEventListener("error", e => {
93
+ let message = (e as any).target.error.message;
94
+ if (message.includes("The transaction was aborted")) {
95
+ resolve("retry");
96
+ } else {
97
+ console.log(`IndexedDB error, skipping file ${bufferId}`, message, e);
98
+ resolve(undefined);
99
+ }
100
+ });
101
+ });
102
+ bufferPromises.set(bufferId, dataPromise);
103
+ logErrors(dataPromise.then(data => {
104
+ if (data === "retry") return;
105
+ if (!data) return;
106
+ let prev = bufferCache.get(bufferId);
107
+ if (prev) {
108
+ totalBufferCacheSize -= prev.byteLength;
109
+ }
110
+ bufferCache.set(bufferId, data);
111
+ totalBufferCacheSize += data.byteLength;
112
+ while (totalBufferCacheSize > inMemoryLimit) {
113
+ let first = bufferCache.keys().next().value;
114
+ let firstBuffer = bufferCache.get(first);
115
+ if (!firstBuffer) {
116
+ throw new Error(`Expected buffer to exist for ${first}`);
117
+ }
118
+ bufferCache.delete(first);
119
+ totalBufferCacheSize -= firstBuffer.byteLength;
120
+ }
121
+ }));
122
+ }
123
+ }
124
+
125
+
126
+ return await Promise.all(bufferIds.map(x => bufferPromises.get(x)));
127
+ }
128
+ const batchGetBuffer = batchFunction<{
129
+ bufferId: string;
130
+ callback: (result: Buffer | undefined) => void;
131
+ }>({ delay: 50 }, async function getDiskCacheBuffersInner(values) {
132
+ // Break buffers into separate transactions, as apparently large transactions can fail
133
+ // (or certain files can get into a bad state, making the entire transaction fail).
134
+ const BATCH_SIZE = 1000;
135
+ let maxRetries = 100;
136
+ while (maxRetries-- > 0 && values.length > 0) {
137
+ let retry: { bufferId: string; callback: (result: Buffer | undefined) => void }[] = [];
138
+ for (let i = 0; i < values.length; i += BATCH_SIZE) {
139
+ let results = await getBufferCachedBase(values.map(x => x.bufferId).slice(i, i + BATCH_SIZE));
140
+ for (let j = 0; j < results.length; j++) {
141
+ let result = results[j];
142
+ if (result === "retry") {
143
+ retry.push(values[i + j]);
144
+ } else {
145
+ values[i + j].callback(result);
146
+ }
147
+ }
148
+ }
149
+ values = retry;
150
+ if (values.length > 0) {
151
+ console.log(`Retrying ${values.length} buffers`);
152
+ }
153
+ }
154
+
155
+ for (let { bufferId, callback } of values) {
156
+ callback(undefined);
157
+ }
158
+
159
+ });
160
+
161
+ const getDiskCacheBuffersOuter = measureWrap(async function getBufferCached(bufferIds: string[]): Promise<(Buffer | undefined)[]> {
162
+ let time = Date.now();
163
+ console.log(`Reading ${bufferIds.length} buffers from disk cache`);
164
+ let promises = bufferIds.map(x => new Promise<Buffer | undefined>((r, e) =>
165
+ batchGetBuffer({ bufferId: x, callback: r })
166
+ ));
167
+ let results = await Promise.all(promises);
168
+ time = Date.now() - time;
169
+ let size = results.map(x => x?.byteLength ?? 0).reduce((a, b) => a + b, 0);
170
+ let count = results.filter(x => x).length;
171
+ console.log(`Read from disk cache ${formatNumber(count)} /// ${formatNumber(size)}B in ${formatTime(time)}`);
172
+ return results;
173
+ });
174
+
175
+ function addBufferToCache(bufferId: string, buffer: Buffer) {
176
+ persist();
177
+ bufferCache.set(bufferId, buffer);
178
+ logErrors(batchedAddBufferToCache({ bufferId, buffer }));
179
+ }
180
+ const batchedAddBufferToCache = batchFunction<{
181
+ bufferId: string;
182
+ buffer: Buffer
183
+ }>(
184
+ { delay: 1000 },
185
+ async values => {
186
+ let promises: Promise<unknown>[] = [];
187
+ // De-duplicate buffers
188
+ {
189
+ let finalBuffer = new Map<string, Buffer>();
190
+ for (let { buffer, bufferId } of values) {
191
+ finalBuffer.set(bufferId, buffer);
192
+ }
193
+ values = Array.from(finalBuffer).map(([bufferId, buffer]) => ({ bufferId, buffer }));
194
+ }
195
+ const BATCH_SIZE = 100;
196
+ for (let i = 0; i < values.length; i += BATCH_SIZE) {
197
+ let batch = values.slice(i, i + BATCH_SIZE);
198
+ let time = Date.now();
199
+ await measureBlock(async function batchedAddBufferToCache() {
200
+ for (let { bufferId, buffer } of batch) {
201
+
202
+ let transaction = await getTransaction();
203
+ let result = transaction.put(buffer, bufferId);
204
+ // NOTE: Wait for it to complete, so our timing can be more accurate.
205
+ promises.push(new Promise((resolve, reject) => {
206
+ result.addEventListener("success", resolve);
207
+ result.addEventListener("error", reject);
208
+ }));
209
+ }
210
+ });
211
+ let total = batch.map(x => x.buffer.byteLength).reduce((a, b) => a + b, 0);
212
+ console.log(`Wrote ${formatNumber(total)} to disk cache in ${formatTime(Date.now() - time)}`);
213
+ }
214
+ debugger;
215
+ await Promise.all(promises);
216
+ }
217
+ );
218
+
219
+ return {
220
+ get: getDiskCacheBuffersOuter,
221
+ async getSingle(bufferId: string) {
222
+ let result = await getDiskCacheBuffersOuter([bufferId]);
223
+ return result[0];
224
+ },
225
+ set: addBufferToCache,
226
+ };
227
+ }