querysub 0.642.0 → 0.644.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.642.0",
3
+ "version": "0.644.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -79,8 +79,8 @@
79
79
  "node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
80
80
  "pako": "^2.1.0",
81
81
  "peggy": "^5.0.6",
82
- "sliftutils": "^1.7.114",
83
- "socket-function": "^1.2.31",
82
+ "sliftutils": "^1.7.116",
83
+ "socket-function": "^1.2.33",
84
84
  "terser": "^5.31.0",
85
85
  "typenode": "^6.6.1",
86
86
  "typesafecss": "^0.32.0",
@@ -123,11 +123,14 @@ export function createArchiveLocker2(config: {
123
123
  // Delete all files we didn't just set (move to recycle bin)
124
124
  let moveCount = 0;
125
125
  async function moveFile(file: string) {
126
- moveCount++;
127
- if (correctFiles.has(file)) return;
126
+ if (correctFiles.has(file)) {
127
+ moveCount++;
128
+ return;
129
+ }
128
130
  let info = await archiveValues.getInfo(file);
129
131
  console.log(`Moving file ${file}, ${moveCount}/${valuePaths.size}, ${formatNumber(info?.size || 0)} bytes`);
130
132
  await moveArchiveFile({ from: archiveValues, to: archiveRecycleBin, path: file, noFallbacks: true });
133
+ moveCount++;
131
134
  console.log(`Moved file ${file}, ${moveCount}/${valuePaths.size}`);
132
135
  }
133
136
  let moveFileParallel = runInParallel({ parallelCount: 8 }, moveFile);
@@ -1,6 +1,6 @@
1
1
  import { sha256 } from "js-sha256";
2
2
  import { getAllNodeIds, getOwnThreadId } from "../../-f-node-discovery/NodeDiscovery";
3
- import { archives, pathValueArchives } from "../pathValueArchives";
3
+ import { archives, archivesRecycleBin, pathValueArchives } from "../pathValueArchives";
4
4
  import { ignoreErrors, logErrors, timeoutToUndefinedSilent } from "../../errors";
5
5
  import { green, magenta } from "socket-function/src/formatting/logColors";
6
6
  import { devDebugbreak, isPublic } from "../../config";
@@ -111,6 +111,83 @@ export async function getLiveSnapshotList(): Promise<ArchiveSnapshotList> {
111
111
  return { loadTime: Date.now() - startTime, snapshots: [liveOverview] };
112
112
  }
113
113
 
114
+ // Reading every stored snapshot at once is expensive, so we cap the concurrency rather than firing
115
+ // thousands of reads in parallel.
116
+ const SNAPSHOT_READ_PARALLEL = 32;
117
+
118
+ export type SnapshotFileCounts = {
119
+ live: number;
120
+ recycled: number;
121
+ missing: number;
122
+ };
123
+ export type RecycleBinCheck = {
124
+ // Snapshot file name (or "live") => how its files are distributed across live/recycled/missing.
125
+ counts: { [snapshotFile: string]: SnapshotFileCounts };
126
+ liveFileCount: number;
127
+ recycledFileCount: number;
128
+ snapshotCount: number;
129
+ loadTime: number;
130
+ };
131
+
132
+ // Classifies every file of every snapshot as live / recycled / missing, entirely server-side. This
133
+ // lists the whole live store, the whole recycle bin, and reads every snapshot's file list (all in
134
+ // parallel), then intersects - so the client gets only the per-snapshot counts, never the (large)
135
+ // underlying file listings. Expensive (can take minutes), so it's an explicit on-demand action.
136
+ export async function checkRecycleBin(): Promise<RecycleBinCheck> {
137
+ let startTime = Date.now();
138
+
139
+ async function getLiveFilePaths(): Promise<string[]> {
140
+ // Zero-byte files are deletes on new storage servers, matching how the archive locker filters them.
141
+ return (await archives().findInfo("")).filter(x => x.size > 0).map(x => x.path);
142
+ }
143
+ async function getRecycleBinFilePaths(): Promise<string[]> {
144
+ return (await archivesRecycleBin().findInfo("")).filter(x => x.size > 0).map(x => x.path);
145
+ }
146
+ async function getSnapshotFileLists(): Promise<{ file: string; files: string[] }[]> {
147
+ let snapshotFiles = await snapshots().find("");
148
+ let lists: { file: string; files: string[] }[] = [];
149
+ async function readOne(file: string): Promise<void> {
150
+ let data = await snapshots().get(file);
151
+ if (!data) return;
152
+ lists.push({ file, files: data.toString().split("\n") });
153
+ }
154
+ let readParallel = runInParallel({ parallelCount: SNAPSHOT_READ_PARALLEL }, readOne);
155
+ await Promise.all(snapshotFiles.map(readParallel));
156
+ return lists;
157
+ }
158
+
159
+ let [liveFiles, recycledFiles, snapshotLists] = await Promise.all([
160
+ getLiveFilePaths(),
161
+ getRecycleBinFilePaths(),
162
+ getSnapshotFileLists(),
163
+ ]);
164
+
165
+ let liveSet = new Set(liveFiles);
166
+ let recycledSet = new Set(recycledFiles);
167
+ let counts: { [snapshotFile: string]: SnapshotFileCounts } = {};
168
+ for (let snapshot of snapshotLists) {
169
+ let count: SnapshotFileCounts = { live: 0, recycled: 0, missing: 0 };
170
+ for (let file of snapshot.files) {
171
+ if (liveSet.has(file)) {
172
+ count.live++;
173
+ } else if (recycledSet.has(file)) {
174
+ count.recycled++;
175
+ } else {
176
+ count.missing++;
177
+ }
178
+ }
179
+ counts[snapshot.file] = count;
180
+ }
181
+
182
+ return {
183
+ counts,
184
+ liveFileCount: liveFiles.length,
185
+ recycledFileCount: recycledFiles.length,
186
+ snapshotCount: snapshotLists.length,
187
+ loadTime: Date.now() - startTime,
188
+ };
189
+ }
190
+
114
191
  export async function getSnapshot(snapshotFile: string | "live"): Promise<ArchiveSnapshotRead> {
115
192
  let snapshot = await getSnapshotBase(snapshotFile);
116
193
  let locker = await pathValueArchives.getArchiveLocker();
@@ -173,6 +173,7 @@ export function formatValue(value: unknown, formatter: JSXFormatter = "guess", c
173
173
  }
174
174
 
175
175
  export function toSpaceCase(text: string) {
176
+ if (typeof text !== "string") return String(text);
176
177
  if (text.startsWith("_")) return text;
177
178
  return text
178
179
  // "camelCase" => "camel Case"
@@ -1,7 +1,8 @@
1
1
  module.allowclient = true;
2
2
 
3
- import { ArchiveSnapshotList, ArchiveSnapshotOverview, ArchiveSnapshotRead, downloadSnapshot, getLiveSnapshotList, getSavedSnapshotList, getSnapshot, loadSnapshot, saveSnapshot, uploadSnapshot } from "../../0-path-value-core/archiveLocks/archiveSnapshots";
3
+ import { ArchiveSnapshotList, ArchiveSnapshotOverview, ArchiveSnapshotRead, checkRecycleBin as checkRecycleBinServer, downloadSnapshot, getLiveSnapshotList, getSavedSnapshotList, getSnapshot, loadSnapshot, RecycleBinCheck, saveSnapshot, uploadSnapshot } from "../../0-path-value-core/archiveLocks/archiveSnapshots";
4
4
  import { qreact } from "../../4-dom/qreact";
5
+ import preact from "preact";
5
6
  import { SocketFunction } from "socket-function/SocketFunction";
6
7
  import { getBrowserUrlNode } from "../../-f-node-discovery/NodeDiscovery";
7
8
  import { Table } from "../../5-diagnostics/Table";
@@ -34,6 +35,16 @@ export class SnapshotViewer extends qreact.Component {
34
35
  saving: t.boolean(false),
35
36
  uploading: t.boolean(false),
36
37
  downloading: t.lookup(t.boolean),
38
+ // Bulk recycle-bin check. The server classifies every snapshot's files as live/recycled/missing
39
+ // and returns just the counts; `active` stays true after it finishes so the columns keep showing.
40
+ recycleCheck: {
41
+ active: t.boolean(false),
42
+ running: t.boolean(false),
43
+ error: t.optional<string>(),
44
+ summary: t.optional<string>(),
45
+ // snapshot file name => classified counts (atomic, so we write the whole triple at once)
46
+ results: t.lookup(t.atomic<{ live: number; recycled: number; missing: number }>()),
47
+ },
37
48
  });
38
49
  render() {
39
50
  let controller = SnapshotViewerSynced(getBrowserUrlNode());
@@ -51,6 +62,20 @@ export class SnapshotViewer extends qreact.Component {
51
62
  );
52
63
  let file = selectedSnapshot.value;
53
64
  if (!file || typeof file !== "string") {
65
+ let recycleCheck = this.state.recycleCheck;
66
+ let rows: SnapshotListRow[] = snapshotList.map(snapshot => {
67
+ let counts = recycleCheck.results[snapshot.file];
68
+ // The live "snapshot" isn't a stored file, but by definition every file in it is live.
69
+ if (!counts && snapshot.file === "live" && recycleCheck.active) {
70
+ counts = { live: snapshot.fileCount, recycled: 0, missing: 0 };
71
+ }
72
+ return {
73
+ ...snapshot,
74
+ liveCount: counts?.live,
75
+ recycledCount: counts?.recycled,
76
+ missingCount: counts?.missing,
77
+ };
78
+ });
54
79
  return (
55
80
  <div class={css.pad2(10).vbox(10)}>
56
81
  {loadStatus}
@@ -59,14 +84,23 @@ export class SnapshotViewer extends qreact.Component {
59
84
  <Button onClick={() => triggerSnapshotUpload(this)}>
60
85
  {this.state.uploading && "Uploading..." || "Upload Snapshot"}
61
86
  </Button>
87
+ <Button onClick={() => checkRecycleBin(this)}>
88
+ {recycleCheck.running && "Checking Recycle Bin..." || "Check Recycle Bin"}
89
+ </Button>
62
90
  </div>
91
+ {recycleCheck.running && <div>Checking recycle bin (this can take a few minutes)...</div>}
92
+ {recycleCheck.summary && <div>{recycleCheck.summary}</div>}
93
+ {recycleCheck.error && <div class={css.color("red").whiteSpace("pre-wrap")}>{recycleCheck.error}</div>}
63
94
  <Table
64
- rows={snapshotList}
95
+ rows={rows}
65
96
  columns={{
66
97
  time: {},
67
98
  fileCount: {},
68
99
  valueCount: {},
69
100
  byteCount: {},
101
+ liveCount: { title: "Live", formatter: formatCountCell },
102
+ recycledCount: { title: "Recycled", formatter: formatCountCell },
103
+ missingCount: { title: "Missing", formatter: formatMissingCell },
70
104
  file: {
71
105
  title: "Actions",
72
106
  formatter: (file, context) => {
@@ -80,7 +114,7 @@ export class SnapshotViewer extends qreact.Component {
80
114
  try {
81
115
  await SnapshotViewerSynced(getBrowserUrlNode()).saveLiveSnapshot.promise();
82
116
  } finally {
83
- Querysub.localCommit(() => {
117
+ Querysub.commit(() => {
84
118
  this.state.saving = false;
85
119
  });
86
120
  }
@@ -202,9 +236,59 @@ function summarizeSnapshotByStatus(files: ArchiveSnapshotRead["files"]): Snapsho
202
236
  return rows;
203
237
  }
204
238
 
239
+ type SnapshotListRow = ArchiveSnapshotOverview & {
240
+ liveCount?: number;
241
+ recycledCount?: number;
242
+ missingCount?: number;
243
+ };
244
+
245
+ function formatCountCell(value: unknown): string {
246
+ if (typeof value !== "number") return "";
247
+ return formatNumber(value);
248
+ }
249
+ function formatMissingCell(value: unknown): preact.ComponentChild {
250
+ if (typeof value !== "number") return "";
251
+ if (value === 0) return "0";
252
+ // Missing files in a snapshot mean it can no longer be fully restored - call that out.
253
+ return <span class={css.color("red").fontWeight("bold")}>{formatNumber(value)}</span>;
254
+ }
255
+
256
+ async function checkRecycleBin(component: SnapshotViewer) {
257
+ if (component.state.recycleCheck.running) return;
258
+ const controller = SnapshotViewerController.nodes[getBrowserUrlNode()];
259
+ Querysub.commit(() => {
260
+ let rc = component.state.recycleCheck;
261
+ rc.active = true;
262
+ rc.running = true;
263
+ rc.error = undefined;
264
+ rc.summary = undefined;
265
+ for (let key of Object.keys(rc.results)) {
266
+ delete rc.results[key];
267
+ }
268
+ });
269
+ try {
270
+ // The server does all the listing/reading/classifying and returns only the per-snapshot counts.
271
+ let check = await controller.checkRecycleBin();
272
+ Querysub.commit(() => {
273
+ let rc = component.state.recycleCheck;
274
+ for (let [snapshotFile, counts] of Object.entries(check.counts)) {
275
+ rc.results[snapshotFile] = counts;
276
+ }
277
+ rc.summary = `Checked ${formatNumber(check.snapshotCount)} snapshots against ${formatNumber(check.liveFileCount)} live and ${formatNumber(check.recycledFileCount)} recycled files in ${formatTime(check.loadTime)}.`;
278
+ rc.running = false;
279
+ });
280
+ } catch (e: any) {
281
+ console.error(red(`Failed to check recycle bin: ${e.stack}`));
282
+ Querysub.commit(() => {
283
+ component.state.recycleCheck.error = e.stack || String(e);
284
+ component.state.recycleCheck.running = false;
285
+ });
286
+ }
287
+ }
288
+
205
289
  async function downloadSnapshotFile(component: SnapshotViewer, file: string) {
206
290
  if (component.state.downloading[file]) return;
207
- Querysub.localCommit(() => {
291
+ Querysub.commit(() => {
208
292
  component.state.downloading[file] = true;
209
293
  });
210
294
  try {
@@ -224,7 +308,7 @@ async function downloadSnapshotFile(component: SnapshotViewer, file: string) {
224
308
  } catch (e: any) {
225
309
  console.error(red(`Failed to download snapshot ${file}: ${e.stack}`));
226
310
  } finally {
227
- Querysub.localCommit(() => {
311
+ Querysub.commit(() => {
228
312
  delete component.state.downloading[file];
229
313
  });
230
314
  }
@@ -238,7 +322,7 @@ async function triggerSnapshotUpload(component: SnapshotViewer) {
238
322
  input.onchange = async () => {
239
323
  let selected = input.files?.[0];
240
324
  if (!selected) return;
241
- Querysub.localCommit(() => {
325
+ Querysub.commit(() => {
242
326
  component.state.uploading = true;
243
327
  });
244
328
  try {
@@ -249,7 +333,7 @@ async function triggerSnapshotUpload(component: SnapshotViewer) {
249
333
  } catch (e: any) {
250
334
  console.error(red(`Failed to upload snapshot: ${e.stack}`));
251
335
  } finally {
252
- Querysub.localCommit(() => {
336
+ Querysub.commit(() => {
253
337
  component.state.uploading = false;
254
338
  });
255
339
  }
@@ -316,6 +400,10 @@ class SnapshotViewerControllerBase {
316
400
  return await downloadSnapshot(snapshotFile);
317
401
  }
318
402
 
403
+ public async checkRecycleBin(): Promise<RecycleBinCheck> {
404
+ return await checkRecycleBinServer();
405
+ }
406
+
319
407
  public async uploadSnapshot(buffer: Buffer): Promise<ArchiveSnapshotOverview> {
320
408
  return await uploadSnapshot(buffer);
321
409
  }
@@ -332,6 +420,8 @@ export const SnapshotViewerController = SocketFunction.register(
332
420
  saveLiveSnapshot: {},
333
421
  downloadSnapshot: {},
334
422
  uploadSnapshot: {},
423
+ // Returns a per-snapshot count map (not the underlying listings), but there can be many snapshots.
424
+ checkRecycleBin: { responseLimit: 1024 * 1024 * 1024 },
335
425
  example: {}
336
426
  }),
337
427
  () => ({