sliftutils 1.4.21 → 1.4.22
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/bin/autohost.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// Same as filehoster, but also compacts the hosted bulk databases on startup and every 3h, so remote
|
|
4
|
+
// clients (which skip compaction by default) don't have to.
|
|
5
|
+
require("typenode");
|
|
6
|
+
process.argv.push("--autocompact");
|
|
7
|
+
require("../storage/remoteFileServer").runFileHoster();
|
package/index.d.ts
CHANGED
|
@@ -903,6 +903,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
|
|
|
903
903
|
|
|
904
904
|
declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
905
905
|
import type { FileStorage } from "../FileFolderAPI";
|
|
906
|
+
export declare const BULK_ROOT_FOLDER = "bulkDatabases2";
|
|
906
907
|
export declare const bulkDatabase2Timing: {
|
|
907
908
|
streamSealAgeMs: number;
|
|
908
909
|
mergeCheckIntervalMs: number;
|
|
@@ -954,6 +955,8 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
954
955
|
private loadedFileSet;
|
|
955
956
|
private readerEpoch;
|
|
956
957
|
private fileSetPollTimer;
|
|
958
|
+
private bulkReaderCache;
|
|
959
|
+
private streamReaderCache;
|
|
957
960
|
private dataGen;
|
|
958
961
|
private pendingSignals;
|
|
959
962
|
private triggerTimer;
|
|
@@ -1007,6 +1010,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
1007
1010
|
merge(timeLo: number, timeHi: number): Promise<void>;
|
|
1008
1011
|
private makeRawGetRange;
|
|
1009
1012
|
private loadFileReader;
|
|
1013
|
+
private pruneFileCaches;
|
|
1010
1014
|
private readBulkHeader;
|
|
1011
1015
|
private fileLogicalSize;
|
|
1012
1016
|
private handleUnreadableFile;
|
|
@@ -1893,6 +1897,7 @@ declare module "sliftutils/storage/remoteFileServer" {
|
|
|
1893
1897
|
close: () => Promise<void>;
|
|
1894
1898
|
};
|
|
1895
1899
|
export declare function startRemoteFileServer(options: RemoteFileServerOptions): Promise<RemoteFileServerHandle>;
|
|
1900
|
+
export declare function autocompactBulkDatabases(root: string): Promise<void>;
|
|
1896
1901
|
export declare function runFileHoster(): Promise<void>;
|
|
1897
1902
|
|
|
1898
1903
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sliftutils",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.22",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
@@ -27,10 +27,12 @@
|
|
|
27
27
|
"watch-electron": "node ./builders/watchRun.js --port 9879 \"electron/*.ts\" \"electron/*.tsx\" \"yarn build-electron\"",
|
|
28
28
|
"notes": "mobx, preact, socket-function, typenode SHOULD be peerDependencies. But we want to use yarn (better dependency deduplication), so we can't use peerDependencies (as they aren't installed by default, which makes them a nightmare to use). If you want to override the versions, feel free to use overrides/resolutions.",
|
|
29
29
|
"test": "typenode ./test.ts",
|
|
30
|
-
"filehoster": "node ./bin/filehoster.js"
|
|
30
|
+
"filehoster": "node ./bin/filehoster.js",
|
|
31
|
+
"autohost": "node ./bin/autohost.js"
|
|
31
32
|
},
|
|
32
33
|
"bin": {
|
|
33
34
|
"filehoster": "./bin/filehoster.js",
|
|
35
|
+
"autohost": "./bin/autohost.js",
|
|
34
36
|
"build-nodejs": "./builders/nodeJSBuildRun.js",
|
|
35
37
|
"buildnodejs": "./builders/nodeJSBuildRun.js",
|
|
36
38
|
"build-extension": "./builders/extensionBuildRun.js",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FileStorage } from "../FileFolderAPI";
|
|
2
|
+
export declare const BULK_ROOT_FOLDER = "bulkDatabases2";
|
|
2
3
|
export declare const bulkDatabase2Timing: {
|
|
3
4
|
streamSealAgeMs: number;
|
|
4
5
|
mergeCheckIntervalMs: number;
|
|
@@ -50,6 +51,8 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
50
51
|
private loadedFileSet;
|
|
51
52
|
private readerEpoch;
|
|
52
53
|
private fileSetPollTimer;
|
|
54
|
+
private bulkReaderCache;
|
|
55
|
+
private streamReaderCache;
|
|
53
56
|
private dataGen;
|
|
54
57
|
private pendingSignals;
|
|
55
58
|
private triggerTimer;
|
|
@@ -103,6 +106,7 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
103
106
|
merge(timeLo: number, timeHi: number): Promise<void>;
|
|
104
107
|
private makeRawGetRange;
|
|
105
108
|
private loadFileReader;
|
|
109
|
+
private pruneFileCaches;
|
|
106
110
|
private readBulkHeader;
|
|
107
111
|
private fileLogicalSize;
|
|
108
112
|
private handleUnreadableFile;
|
|
@@ -26,8 +26,8 @@ import type { FileStorage } from "../FileFolderAPI";
|
|
|
26
26
|
// ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
27
27
|
|
|
28
28
|
// BulkDatabase2's compressed-block format is not compatible with BulkDatabase, so it uses its own
|
|
29
|
-
// folder rather than sharing bulkDatabases/.
|
|
30
|
-
const BULK_ROOT_FOLDER = "bulkDatabases2";
|
|
29
|
+
// folder rather than sharing bulkDatabases/. Exported so a server-side compactor can find collections.
|
|
30
|
+
export const BULK_ROOT_FOLDER = "bulkDatabases2";
|
|
31
31
|
const FILE_EXTENSION = ".bulk";
|
|
32
32
|
// A single writeBatch that already exceeds these limits skips the tier-0 stream and folds straight
|
|
33
33
|
// into a bulk file (streaming thousands of rows one frame at a time would be pointless).
|
|
@@ -260,8 +260,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
260
260
|
private currentFlushDelay = 0;
|
|
261
261
|
private lastWriteTime = 0;
|
|
262
262
|
|
|
263
|
-
// Block range cache is global and immutable-file-safe; clear it to simulate a cold page load
|
|
264
|
-
//
|
|
263
|
+
// Block range cache is global and immutable-file-safe; clear it to simulate a cold page load (e.g.
|
|
264
|
+
// between an untimed prep step and the timed benchmark). The per-instance sub-reader caches need no
|
|
265
|
+
// clearing here — a cold load is a fresh instance, which starts with empty caches.
|
|
265
266
|
public static clearCache() {
|
|
266
267
|
blockCache.clear();
|
|
267
268
|
}
|
|
@@ -328,6 +329,17 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
328
329
|
// (the rest see the bumped epoch, skip their own reload, and just await the in-flight one).
|
|
329
330
|
private readerEpoch = 0;
|
|
330
331
|
private fileSetPollTimer: ReturnType<typeof setInterval> | undefined;
|
|
332
|
+
|
|
333
|
+
// Per-file decoded sub-reader caches (keyed by fileName), so reloading the index re-reads only the
|
|
334
|
+
// files that actually changed — the stitch (join) still runs, but unchanged files skip re-decoding.
|
|
335
|
+
// Per-INSTANCE (not global), because a cached reader's getRange is bound to THIS instance's storage
|
|
336
|
+
// backend; in production a collection has one backend, and a "fresh client" is a new process with empty
|
|
337
|
+
// caches anyway. Bulk files are immutable, so a name maps to fixed content (cache until the file is
|
|
338
|
+
// gone). Stream files are append-only, so size is the version: reuse on a size match, else parse only
|
|
339
|
+
// the appended suffix. Pruned per build for files a merge removed (pruneFileCaches); survive the reader
|
|
340
|
+
// reset (that's the point — a reload reuses them).
|
|
341
|
+
private bulkReaderCache = new Map<string, BaseBulkDatabaseReader>();
|
|
342
|
+
private streamReaderCache = new Map<string, { readSize: number; parsedPos: number; entries: StreamEntry[] }>();
|
|
331
343
|
// Bumped on every overlay mutation / reader reset. An async column build captures it before its awaits
|
|
332
344
|
// and only caches its result if it hasn't changed since — so a write or reset mid-build (which clears
|
|
333
345
|
// the cache and may swap the reader) can never leave a stale entry behind.
|
|
@@ -493,6 +505,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
493
505
|
this.readerKeys = new Set(joined.keys);
|
|
494
506
|
// The files this index was built from, so the poll can detect a concurrent merge changing the set.
|
|
495
507
|
this.loadedFileSet = new Set([...bulkFiles.map(f => f.fileName), ...streamFiles.map(f => f.fileName)]);
|
|
508
|
+
// Evict cached sub-readers for files a merge removed, so the caches track the live file set.
|
|
509
|
+
this.pruneFileCaches(bulkFiles, streamFiles);
|
|
496
510
|
|
|
497
511
|
let time = Date.now() - start;
|
|
498
512
|
if (time > 50) {
|
|
@@ -786,17 +800,38 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
786
800
|
if (!streamFiles.length) return { entries: [], totalBytes: 0, missing: false, sizes };
|
|
787
801
|
const storage = await this.storage();
|
|
788
802
|
let missing = false;
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
//
|
|
803
|
+
// Per-file parse, reusing streamCache when the file hasn't grown. Stream files are append-only, so
|
|
804
|
+
// size is the version: same size ⇒ same parsed entries; a larger size ⇒ parse only the appended
|
|
805
|
+
// suffix and tack it on. We read a bounded prefix [0, size) — a foreign writer may be appending, and
|
|
806
|
+
// storage.get() errors past the stat'd size; parseStream stops at the last complete frame, so a
|
|
792
807
|
// later read picks up the rest. A file removed out from under us (a merge) sets `missing`.
|
|
793
|
-
const
|
|
808
|
+
const perFile = await Promise.all(streamFiles.map(async (f): Promise<{ fileName: string; size: number; entries: StreamEntry[] } | undefined> => {
|
|
794
809
|
try {
|
|
795
810
|
const info = await storage.getInfo(f.fileName);
|
|
796
811
|
if (!info) { missing = true; return undefined; }
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
812
|
+
const size = info.size;
|
|
813
|
+
sizes.set(f.fileName, size);
|
|
814
|
+
const cached = this.streamReaderCache.get(f.fileName);
|
|
815
|
+
if (cached && cached.readSize === size) return { fileName: f.fileName, size, entries: cached.entries };
|
|
816
|
+
if (size === 0) { this.streamReaderCache.set(f.fileName, { readSize: 0, parsedPos: 0, entries: [] }); return { fileName: f.fileName, size: 0, entries: [] }; }
|
|
817
|
+
if (cached && size > cached.readSize) {
|
|
818
|
+
// Grew: parse only the appended bytes from where we last stopped (a frame boundary).
|
|
819
|
+
const suffix = await storage.getRange(f.fileName, { start: cached.parsedPos, end: size });
|
|
820
|
+
if (!suffix) { missing = true; return undefined; }
|
|
821
|
+
const parsed = parseStream(suffix);
|
|
822
|
+
if (parsed.badBytes > 0) console.warn(`${this.name} stream file ${f.fileName} had ${parsed.badBytes} trailing bad/incomplete bytes (stopped reading there)`);
|
|
823
|
+
const entries = parsed.entries.length ? cached.entries.concat(parsed.entries) : cached.entries;
|
|
824
|
+
const parsedPos = cached.parsedPos + (suffix.length - parsed.badBytes);
|
|
825
|
+
this.streamReaderCache.set(f.fileName, { readSize: size, parsedPos, entries });
|
|
826
|
+
return { fileName: f.fileName, size, entries };
|
|
827
|
+
}
|
|
828
|
+
// Cold (or the rare shrink/rewrite): full read from the start.
|
|
829
|
+
const buffer = await storage.getRange(f.fileName, { start: 0, end: size });
|
|
830
|
+
if (!buffer) { missing = true; return undefined; }
|
|
831
|
+
const parsed = parseStream(buffer);
|
|
832
|
+
if (parsed.badBytes > 0) console.warn(`${this.name} stream file ${f.fileName} had ${parsed.badBytes} trailing bad/incomplete bytes (stopped reading there)`);
|
|
833
|
+
this.streamReaderCache.set(f.fileName, { readSize: size, parsedPos: size - parsed.badBytes, entries: parsed.entries });
|
|
834
|
+
return { fileName: f.fileName, size, entries: parsed.entries };
|
|
800
835
|
} catch {
|
|
801
836
|
missing = true;
|
|
802
837
|
return undefined;
|
|
@@ -804,17 +839,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
804
839
|
}));
|
|
805
840
|
const entries: { time: number; fileName: string; entry: StreamEntry }[] = [];
|
|
806
841
|
let totalBytes = 0;
|
|
807
|
-
for (
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
const parsed = parseStream(buffer);
|
|
812
|
-
if (parsed.badBytes > 0) {
|
|
813
|
-
console.warn(`${this.name} stream file ${streamFiles[i].fileName} had ${parsed.badBytes} trailing bad/incomplete bytes (stopped reading there)`);
|
|
814
|
-
}
|
|
815
|
-
for (const entry of parsed.entries) {
|
|
816
|
-
entries.push({ time: entry.time, fileName: streamFiles[i].fileName, entry });
|
|
817
|
-
}
|
|
842
|
+
for (const pf of perFile) {
|
|
843
|
+
if (!pf) continue;
|
|
844
|
+
totalBytes += pf.size;
|
|
845
|
+
for (const entry of pf.entries) entries.push({ time: entry.time, fileName: pf.fileName, entry });
|
|
818
846
|
}
|
|
819
847
|
return { entries, totalBytes, missing, sizes };
|
|
820
848
|
}
|
|
@@ -913,13 +941,30 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
913
941
|
}
|
|
914
942
|
|
|
915
943
|
private async loadFileReader(fileName: string): Promise<BaseBulkDatabaseReader> {
|
|
944
|
+
// Bulk files are immutable, so a decoded sub-reader (keys + keyTimes + columns) is valid until the
|
|
945
|
+
// file is gone — reuse it so an index reload doesn't re-decode unchanged files. (We skip the getInfo
|
|
946
|
+
// existence check on a hit; if the file was merged away mid-build the deferred read fails and
|
|
947
|
+
// readWithReload recovers — and buildReader won't ask for files that aren't currently listed.)
|
|
948
|
+
const cached = this.bulkReaderCache.get(fileName);
|
|
949
|
+
if (cached) return cached;
|
|
916
950
|
const raw = await this.makeRawGetRange(fileName);
|
|
917
951
|
const fileId = nullJoin(this.name, fileName);
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
952
|
+
// Stored as compressed blocks; replace getRange with a block-cached, decompressing version (same
|
|
953
|
+
// interface) and read the logical (uncompressed) size from its index. open() validates the file
|
|
954
|
+
// size against the index and throws if it's truncated/corrupt.
|
|
921
955
|
const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
|
|
922
|
-
|
|
956
|
+
const reader = await loadBulkDatabase({ totalBytes: opened.uncompressedSize, getRange: opened.getRange });
|
|
957
|
+
this.bulkReaderCache.set(fileName, reader);
|
|
958
|
+
return reader;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Drop cached sub-readers for files that no longer exist (a merge replaced them), so the caches track
|
|
962
|
+
// the live file set instead of growing with churn. Called after each successful build.
|
|
963
|
+
private pruneFileCaches(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[]) {
|
|
964
|
+
const liveBulk = new Set(bulkFiles.map(f => f.fileName));
|
|
965
|
+
const liveStream = new Set(streamFiles.map(f => f.fileName));
|
|
966
|
+
for (const name of this.bulkReaderCache.keys()) if (!liveBulk.has(name)) this.bulkReaderCache.delete(name);
|
|
967
|
+
for (const name of this.streamReaderCache.keys()) if (!liveStream.has(name)) this.streamReaderCache.delete(name);
|
|
923
968
|
}
|
|
924
969
|
|
|
925
970
|
// Reads only a bulk file's header (row count, time range, key range) — no column data — for merge
|
|
@@ -13,4 +13,5 @@ export type RemoteFileServerHandle = {
|
|
|
13
13
|
close: () => Promise<void>;
|
|
14
14
|
};
|
|
15
15
|
export declare function startRemoteFileServer(options: RemoteFileServerOptions): Promise<RemoteFileServerHandle>;
|
|
16
|
+
export declare function autocompactBulkDatabases(root: string): Promise<void>;
|
|
16
17
|
export declare function runFileHoster(): Promise<void>;
|
|
@@ -7,7 +7,10 @@ import os from "os";
|
|
|
7
7
|
import { execFileSync } from "child_process";
|
|
8
8
|
import { getExternalIP } from "socket-function/src/networking";
|
|
9
9
|
import { forwardPort } from "socket-function/src/forwardPort";
|
|
10
|
+
import { runInfinitePollCallAtStart } from "socket-function/src/batching";
|
|
10
11
|
import { WebSocketServer, WebSocket } from "ws";
|
|
12
|
+
import { BulkDatabaseBase, bulkDatabase2Timing, noopReactiveDeps, BULK_ROOT_FOLDER } from "./BulkDatabase2/BulkDatabaseBase";
|
|
13
|
+
import { wrapHandle, NodeJSDirectoryHandleWrapper, DirectoryWrapper } from "./FileFolderAPI";
|
|
11
14
|
|
|
12
15
|
// Remote file server for sliftutils getRemoteFileStorage / BulkDatabase2. Serves one folder on disk over
|
|
13
16
|
// self-signed HTTPS, authenticated with an auto-generated 6-word password (sent as
|
|
@@ -567,6 +570,54 @@ export function startRemoteFileServer(options: RemoteFileServerOptions): Promise
|
|
|
567
570
|
});
|
|
568
571
|
}
|
|
569
572
|
|
|
573
|
+
// ── server-side compaction (--autocompact) ──
|
|
574
|
+
// Remote clients (e.g. a TV) skip compaction by default — it's expensive to read/rewrite whole files over
|
|
575
|
+
// the network. So the host can do it locally instead: load each bulk database's index and run one merge
|
|
576
|
+
// pass, on startup and every 3 hours. Far more efficient (local disk, no network), and the data still gets
|
|
577
|
+
// compacted eventually.
|
|
578
|
+
const AUTOCOMPACT_INTERVAL_MS = 3 * 60 * 60 * 1000;
|
|
579
|
+
// One reused compactor instance per collection (keyed by baseDir\0name), so we don't leak the per-instance
|
|
580
|
+
// timers/caches by recreating instances each loop.
|
|
581
|
+
const compactors = new Map<string, BulkDatabaseBase<{ key: string }>>();
|
|
582
|
+
function getCompactor(baseDir: string, name: string): BulkDatabaseBase<{ key: string }> {
|
|
583
|
+
const key = baseDir + "\0" + name;
|
|
584
|
+
let db = compactors.get(key);
|
|
585
|
+
if (!db) {
|
|
586
|
+
// BulkDatabaseBase asks its factory for `bulkDatabases2/<name>`, so root the factory at baseDir.
|
|
587
|
+
const factory = async (p: string) => {
|
|
588
|
+
let base: DirectoryWrapper = new NodeJSDirectoryHandleWrapper(baseDir);
|
|
589
|
+
for (const part of p.split("/")) { if (part) base = await base.getDirectoryHandle(part, { create: true }); }
|
|
590
|
+
return wrapHandle(base);
|
|
591
|
+
};
|
|
592
|
+
db = new BulkDatabaseBase<{ key: string }>(name, noopReactiveDeps, factory);
|
|
593
|
+
compactors.set(key, db);
|
|
594
|
+
}
|
|
595
|
+
return db;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// One full pass: rescan the disk for collections (new ones may have appeared) and run a merge on each,
|
|
599
|
+
// strictly one after another (never in parallel).
|
|
600
|
+
export async function autocompactBulkDatabases(root: string): Promise<void> {
|
|
601
|
+
// Collections live under <baseDir>/bulkDatabases2/<name>/. The app may nest its data under a "data"
|
|
602
|
+
// subfolder (see getFileStorageNested2's heuristic), so look in both <root> and <root>/data.
|
|
603
|
+
for (const baseDir of [root, path.join(root, "data")]) {
|
|
604
|
+
let names: string[];
|
|
605
|
+
try {
|
|
606
|
+
names = fs.readdirSync(path.join(baseDir, BULK_ROOT_FOLDER), { withFileTypes: true })
|
|
607
|
+
.filter(d => d.isDirectory()).map(d => d.name);
|
|
608
|
+
} catch { continue; } // no bulkDatabases2 here
|
|
609
|
+
for (const name of names) {
|
|
610
|
+
try {
|
|
611
|
+
const t = Date.now();
|
|
612
|
+
const res = await getCompactor(baseDir, name).tryMergeNow();
|
|
613
|
+
if (res.merged) console.log(` [autocompact] ${name}: merged (${Date.now() - t}ms)`);
|
|
614
|
+
} catch (e) {
|
|
615
|
+
console.warn(` [autocompact] ${name}: failed — ${(e as Error).message}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
570
621
|
// CLI entry (invoked by bin/filehoster.js or `yarn filehoster <folder>`). Starts the server, logs the
|
|
571
622
|
// password + local/public URLs + batched access, and keeps the UPnP port mapping alive.
|
|
572
623
|
export async function runFileHoster(): Promise<void> {
|
|
@@ -607,4 +658,14 @@ export async function runFileHoster(): Promise<void> {
|
|
|
607
658
|
setInterval(refresh, 30 * 60 * 1000);
|
|
608
659
|
|
|
609
660
|
console.log(" [access] request logging on (batched every 5s)\n");
|
|
661
|
+
|
|
662
|
+
if (args.includes("--autocompact")) {
|
|
663
|
+
// No UI to protect on a host, so don't space merges out — compact promptly each pass.
|
|
664
|
+
bulkDatabase2Timing.mergeSpacingMs = 0;
|
|
665
|
+
console.log(" [autocompact] compacting bulk databases on startup, then every 3h (serial)\n");
|
|
666
|
+
// Fire-and-forget: runs the first pass now and re-runs every 3h. Not awaited, so it doesn't block
|
|
667
|
+
// startup; each pass rescans the disk for new collections and merges them one at a time.
|
|
668
|
+
void runInfinitePollCallAtStart(AUTOCOMPACT_INTERVAL_MS, () => autocompactBulkDatabases(root))
|
|
669
|
+
.catch(e => console.error(" [autocompact] poll error:", (e as Error).stack));
|
|
670
|
+
}
|
|
610
671
|
}
|