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.
@@ -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.21",
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
- // (e.g. between an untimed prep step and the timed benchmark).
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
- // Read a bounded prefix [0, size) rather than the whole file: a foreign writer may be appending
790
- // concurrently, and storage.get() errors when the file grows past the size it stat'd. Reading a
791
- // prefix is tolerant parseStream stops at the last complete frame, the file stays valid, and a
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 buffers = await Promise.all(streamFiles.map(async f => {
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
- sizes.set(f.fileName, info.size);
798
- if (info.size === 0) return undefined;
799
- return await storage.getRange(f.fileName, { start: 0, end: info.size });
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 (let i = 0; i < streamFiles.length; i++) {
808
- const buffer = buffers[i];
809
- if (!buffer) continue;
810
- totalBytes += buffer.length;
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
- // Files are immutable and stored as compressed blocks; replace getRange with a block-cached,
919
- // decompressing version (same interface) and read the logical (uncompressed) size from its
920
- // index. open() validates the file size against the index and throws if it's truncated/corrupt.
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
- return loadBulkDatabase({ totalBytes: opened.uncompressedSize, getRange: opened.getRange });
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
  }