pinokiod 8.0.39 → 8.0.40
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/kernel/api/hf/index.js +2 -2
- package/kernel/connect/index.js +2 -2
- package/kernel/connect/providers/huggingface/index.js +14 -5
- package/kernel/index.js +0 -12
- package/kernel/shell.js +1 -1
- package/kernel/shells.js +6 -0
- package/package.json +1 -1
- package/server/index.js +0 -120
- package/server/views/connect/huggingface.ejs +7 -9
- package/server/views/partials/main_sidebar.ejs +0 -1
- package/test/hf-api.test.js +4 -2
- package/test/huggingface-connect.test.js +7 -11
- package/test/huggingface-token-validity.test.js +135 -0
- package/kernel/vault/hash_worker.js +0 -24
- package/kernel/vault/index.js +0 -820
- package/kernel/vault/registry.js +0 -169
- package/kernel/vault/sweeper.js +0 -470
- package/server/views/vault.ejs +0 -1556
- package/test/vault-engine.test.js +0 -360
- package/test/vault-sweep.test.js +0 -336
- package/test/vault-ui.test.js +0 -324
package/kernel/vault/index.js
DELETED
|
@@ -1,820 +0,0 @@
|
|
|
1
|
-
const fs = require('fs')
|
|
2
|
-
const os = require('os')
|
|
3
|
-
const path = require('path')
|
|
4
|
-
const crypto = require('crypto')
|
|
5
|
-
const { Worker } = require('worker_threads')
|
|
6
|
-
const Registry = require('./registry')
|
|
7
|
-
const Sweeper = require('./sweeper')
|
|
8
|
-
|
|
9
|
-
// Shared model store engine (spec/requirements/shared-model-store.md).
|
|
10
|
-
// Store + registry + volume probe + hashing + adopt/convert/verify, plus the
|
|
11
|
-
// user-facing operations behind the vault dashboard (detach, undo, reclaim).
|
|
12
|
-
// Nothing here runs automatically except startup verify; discovery happens
|
|
13
|
-
// only through the user's manual scan.
|
|
14
|
-
|
|
15
|
-
const SIZE_THRESHOLD = 100 * 1024 * 1024
|
|
16
|
-
const TMP_SUFFIX = ".pinokio-dedup-tmp"
|
|
17
|
-
const NO_LINK_CODES = new Set(["EXDEV", "ENOTSUP", "EPERM", "ENOSYS"])
|
|
18
|
-
const LOCK_CODES = new Set(["EBUSY", "EPERM", "EACCES"])
|
|
19
|
-
const STAT_CONCURRENCY = 32
|
|
20
|
-
|
|
21
|
-
const isPathWithin = (root, target) => {
|
|
22
|
-
const rel = path.relative(path.resolve(root), path.resolve(target))
|
|
23
|
-
return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel))
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const samePath = (left, right) => {
|
|
27
|
-
const a = path.resolve(left)
|
|
28
|
-
const b = path.resolve(right)
|
|
29
|
-
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const sourceId = (kind, name) => `${kind}:${encodeURIComponent(name)}`
|
|
33
|
-
|
|
34
|
-
const statMany = async (paths, concurrency = STAT_CONCURRENCY) => {
|
|
35
|
-
const results = new Array(paths.length)
|
|
36
|
-
let cursor = 0
|
|
37
|
-
const workers = Array.from({ length: Math.min(concurrency, paths.length) }, async () => {
|
|
38
|
-
while (cursor < paths.length) {
|
|
39
|
-
const index = cursor++
|
|
40
|
-
try {
|
|
41
|
-
results[index] = await fs.promises.stat(paths[index])
|
|
42
|
-
} catch (error) {
|
|
43
|
-
results[index] = null
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
})
|
|
47
|
-
await Promise.all(workers)
|
|
48
|
-
return results
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
class Vault {
|
|
52
|
-
constructor(kernel) {
|
|
53
|
-
this.kernel = kernel
|
|
54
|
-
this.enabled = false
|
|
55
|
-
this.initialized = false
|
|
56
|
-
this.mode = null // 'link' | 'copy' for the vault's own volume
|
|
57
|
-
this.volumeModes = new Map() // dev -> 'link' | 'copy'
|
|
58
|
-
this.registry = null
|
|
59
|
-
this.worker = null
|
|
60
|
-
this.workerJobs = new Map()
|
|
61
|
-
this.workerSeq = 0
|
|
62
|
-
this.workerStarts = 0
|
|
63
|
-
this.workerIdleTimer = null
|
|
64
|
-
this.workerIdleMs = 750
|
|
65
|
-
this.statConcurrency = STAT_CONCURRENCY
|
|
66
|
-
this.sizeThreshold = SIZE_THRESHOLD
|
|
67
|
-
this._sources = []
|
|
68
|
-
}
|
|
69
|
-
get root() {
|
|
70
|
-
return path.resolve(this.kernel.homedir, "vault")
|
|
71
|
-
}
|
|
72
|
-
get blobRoot() {
|
|
73
|
-
return path.resolve(this.root, "sha256")
|
|
74
|
-
}
|
|
75
|
-
storePathFor(hash) {
|
|
76
|
-
return path.resolve(this.blobRoot, hash.slice(0, 2), hash)
|
|
77
|
-
}
|
|
78
|
-
async refreshSources() {
|
|
79
|
-
const home = path.resolve(this.kernel.homedir)
|
|
80
|
-
const apiRoot = path.resolve(home, "api")
|
|
81
|
-
const sources = [
|
|
82
|
-
{ id: "pinokio", kind: "pinokio", label: "Pinokio", root: home, parent_id: null },
|
|
83
|
-
{ id: "apps", kind: "virtual", label: "Apps", root: apiRoot, parent_id: "pinokio" },
|
|
84
|
-
{ id: "external", kind: "virtual", label: "External folders", root: null, parent_id: null }
|
|
85
|
-
]
|
|
86
|
-
let storeDev = null
|
|
87
|
-
try { storeDev = (await fs.promises.stat(this.root)).dev } catch (e) {}
|
|
88
|
-
const decorate = async (source) => {
|
|
89
|
-
if (!source.root) return source
|
|
90
|
-
try {
|
|
91
|
-
const st = await fs.promises.stat(source.root)
|
|
92
|
-
source.dev = st.dev
|
|
93
|
-
source.available = st.isDirectory()
|
|
94
|
-
source.shareable = source.available && storeDev !== null && st.dev === storeDev && this.mode === "link"
|
|
95
|
-
} catch (e) {
|
|
96
|
-
source.available = false
|
|
97
|
-
source.shareable = false
|
|
98
|
-
}
|
|
99
|
-
return source
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
let apiEntries = []
|
|
103
|
-
try { apiEntries = await fs.promises.readdir(apiRoot, { withFileTypes: true }) } catch (e) {}
|
|
104
|
-
const seenExternalRoots = new Set()
|
|
105
|
-
for (const entry of apiEntries) {
|
|
106
|
-
const mountPath = path.resolve(apiRoot, entry.name)
|
|
107
|
-
if (entry.isSymbolicLink()) {
|
|
108
|
-
try {
|
|
109
|
-
const root = await fs.promises.realpath(mountPath)
|
|
110
|
-
const st = await fs.promises.stat(root)
|
|
111
|
-
if (!st.isDirectory()) continue
|
|
112
|
-
const canonical = path.resolve(root)
|
|
113
|
-
if (seenExternalRoots.has(canonical)) continue
|
|
114
|
-
seenExternalRoots.add(canonical)
|
|
115
|
-
sources.push(await decorate({
|
|
116
|
-
id: sourceId("external", entry.name), kind: "external", label: entry.name,
|
|
117
|
-
root: canonical, mount_path: mountPath, parent_id: "external"
|
|
118
|
-
}))
|
|
119
|
-
} catch (e) {}
|
|
120
|
-
} else if (entry.isDirectory()) {
|
|
121
|
-
sources.push(await decorate({
|
|
122
|
-
id: sourceId("app", entry.name), kind: "app", label: entry.name,
|
|
123
|
-
app: entry.name, root: mountPath, parent_id: "apps"
|
|
124
|
-
}))
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
let homeEntries = []
|
|
129
|
-
try { homeEntries = await fs.promises.readdir(home, { withFileTypes: true }) } catch (e) {}
|
|
130
|
-
for (const entry of homeEntries) {
|
|
131
|
-
if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name === "api" || entry.name === "vault") continue
|
|
132
|
-
sources.push(await decorate({
|
|
133
|
-
id: sourceId("folder", entry.name), kind: "folder", label: entry.name,
|
|
134
|
-
root: path.resolve(home, entry.name), parent_id: "pinokio"
|
|
135
|
-
}))
|
|
136
|
-
}
|
|
137
|
-
const homeSource = sources.find((source) => source.id === "pinokio")
|
|
138
|
-
if (homeSource) await decorate(homeSource)
|
|
139
|
-
this._sources = sources
|
|
140
|
-
return sources
|
|
141
|
-
}
|
|
142
|
-
sources() {
|
|
143
|
-
return this._sources
|
|
144
|
-
}
|
|
145
|
-
async addExternalSource(folderPath) {
|
|
146
|
-
if (typeof folderPath !== "string" || !path.isAbsolute(folderPath.trim())) {
|
|
147
|
-
throw new Error("Choose a valid folder.")
|
|
148
|
-
}
|
|
149
|
-
const requested = folderPath.trim()
|
|
150
|
-
let canonical
|
|
151
|
-
let stats
|
|
152
|
-
try {
|
|
153
|
-
canonical = path.resolve(await fs.promises.realpath(requested))
|
|
154
|
-
stats = await fs.promises.stat(canonical)
|
|
155
|
-
} catch (error) {
|
|
156
|
-
throw new Error("That folder is no longer available.")
|
|
157
|
-
}
|
|
158
|
-
if (!stats.isDirectory()) throw new Error("Choose a folder, not a file.")
|
|
159
|
-
|
|
160
|
-
const home = path.resolve(this.kernel.homedir)
|
|
161
|
-
let canonicalHome = home
|
|
162
|
-
try { canonicalHome = path.resolve(await fs.promises.realpath(home)) } catch (error) {}
|
|
163
|
-
if (isPathWithin(canonicalHome, canonical) || isPathWithin(canonical, canonicalHome)) {
|
|
164
|
-
throw new Error("That folder is already inside Pinokio and is included in scans.")
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
await this.refreshSources()
|
|
168
|
-
const existing = this._sources.find((source) => source.kind === "external" && source.root && samePath(source.root, canonical))
|
|
169
|
-
if (existing) {
|
|
170
|
-
return { created: false, source: existing }
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const apiRoot = path.resolve(home, "api")
|
|
174
|
-
await fs.promises.mkdir(apiRoot, { recursive: true })
|
|
175
|
-
const baseLabel = path.basename(canonical) || "external-folder"
|
|
176
|
-
let mountPath = null
|
|
177
|
-
for (let index = 1; index < 10000; index++) {
|
|
178
|
-
const label = index === 1 ? baseLabel : `${baseLabel}-${index}`
|
|
179
|
-
const candidate = path.resolve(apiRoot, label)
|
|
180
|
-
try {
|
|
181
|
-
await fs.promises.symlink(canonical, candidate, this.kernel.platform === "win32" ? "junction" : "dir")
|
|
182
|
-
mountPath = candidate
|
|
183
|
-
break
|
|
184
|
-
} catch (error) {
|
|
185
|
-
if (error && error.code === "EEXIST") continue
|
|
186
|
-
if (error && (error.code === "EACCES" || error.code === "EPERM")) {
|
|
187
|
-
throw new Error("Pinokio does not have permission to add that folder.")
|
|
188
|
-
}
|
|
189
|
-
throw new Error("Pinokio could not add that folder.")
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
if (!mountPath) throw new Error("Pinokio could not create a unique name for that folder.")
|
|
193
|
-
|
|
194
|
-
await this.refreshSources()
|
|
195
|
-
const source = this._sources.find((item) => item.kind === "external" && item.mount_path && samePath(item.mount_path, mountPath))
|
|
196
|
-
if (!source) throw new Error("The folder was added but could not be loaded. Restart Pinokio and try again.")
|
|
197
|
-
return { created: true, source }
|
|
198
|
-
}
|
|
199
|
-
sourceForPath(filePath, preferredId) {
|
|
200
|
-
const sources = this._sources || []
|
|
201
|
-
if (preferredId) {
|
|
202
|
-
const preferred = sources.find((source) => source.id === preferredId)
|
|
203
|
-
if (preferred) return preferred
|
|
204
|
-
}
|
|
205
|
-
const absolute = path.resolve(filePath)
|
|
206
|
-
const concrete = sources.filter((source) => source.root && source.kind !== "virtual")
|
|
207
|
-
.filter((source) => isPathWithin(source.root, absolute) || (source.mount_path && isPathWithin(source.mount_path, absolute)))
|
|
208
|
-
.sort((a, b) => Math.max((b.root || "").length, (b.mount_path || "").length) -
|
|
209
|
-
Math.max((a.root || "").length, (a.mount_path || "").length))
|
|
210
|
-
return concrete[0] || sources.find((source) => source.id === "pinokio") || null
|
|
211
|
-
}
|
|
212
|
-
locationForPath(filePath, preferredId) {
|
|
213
|
-
const source = this.sourceForPath(filePath, preferredId)
|
|
214
|
-
if (!source) return { source_id: null, relative_path: path.basename(filePath) }
|
|
215
|
-
const absolute = path.resolve(filePath)
|
|
216
|
-
let base = source.root
|
|
217
|
-
if (source.mount_path && isPathWithin(source.mount_path, absolute)) base = source.mount_path
|
|
218
|
-
const relative = path.relative(base, absolute).split(path.sep).join("/") || path.basename(absolute)
|
|
219
|
-
return {
|
|
220
|
-
source_id: source.id,
|
|
221
|
-
source_kind: source.kind,
|
|
222
|
-
source_label: source.label,
|
|
223
|
-
relative_path: relative
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
scanRoots() {
|
|
227
|
-
const home = path.resolve(this.kernel.homedir)
|
|
228
|
-
const roots = [{ root: home, source_id: "pinokio" }]
|
|
229
|
-
const seen = new Set([home])
|
|
230
|
-
for (const source of this._sources) {
|
|
231
|
-
if (source.kind !== "external" || !source.available || !source.root) continue
|
|
232
|
-
const canonical = path.resolve(source.root)
|
|
233
|
-
if (isPathWithin(home, canonical) || isPathWithin(canonical, home) || seen.has(canonical)) continue
|
|
234
|
-
seen.add(canonical)
|
|
235
|
-
roots.push({ root: canonical, source_id: source.id })
|
|
236
|
-
}
|
|
237
|
-
return roots
|
|
238
|
-
}
|
|
239
|
-
// Kill switch: system ENVIRONMENT variable PINOKIO_VAULT, default unset =
|
|
240
|
-
// enabled. Read directly (single key) to avoid loading the environment
|
|
241
|
-
// module chain before the kernel is fully up.
|
|
242
|
-
async isEnabled() {
|
|
243
|
-
let value = process.env.PINOKIO_VAULT
|
|
244
|
-
if (value === undefined && this.kernel.homedir) {
|
|
245
|
-
try {
|
|
246
|
-
const raw = await fs.promises.readFile(path.resolve(this.kernel.homedir, "ENVIRONMENT"), "utf8")
|
|
247
|
-
for (const line of raw.split("\n")) {
|
|
248
|
-
const m = line.match(/^\s*PINOKIO_VAULT\s*=\s*(.*)\s*$/)
|
|
249
|
-
if (m) value = m[1].trim()
|
|
250
|
-
}
|
|
251
|
-
} catch (e) {}
|
|
252
|
-
}
|
|
253
|
-
return String(value).toLowerCase() !== "false"
|
|
254
|
-
}
|
|
255
|
-
// Disabled ⇒ do nothing observable: no directory, no registry, no probes.
|
|
256
|
-
async init() {
|
|
257
|
-
this.enabled = await this.isEnabled()
|
|
258
|
-
if (!this.enabled) return { enabled: false }
|
|
259
|
-
await fs.promises.mkdir(this.blobRoot, { recursive: true })
|
|
260
|
-
this.registry = new Registry(this.root)
|
|
261
|
-
this.mode = await this.probe(this.root)
|
|
262
|
-
await this.refreshSources()
|
|
263
|
-
const loaded = await this.registry.load()
|
|
264
|
-
if (loaded.corrupt) {
|
|
265
|
-
await this.rebuild(undefined, { preserveState: false })
|
|
266
|
-
}
|
|
267
|
-
this.sweeper = new Sweeper(this)
|
|
268
|
-
this.initialized = true
|
|
269
|
-
return { enabled: true, mode: this.mode, corrupt_recovered: !!loaded.corrupt }
|
|
270
|
-
}
|
|
271
|
-
// Capability probe: temp file + node:fs.link + nlink check. Once per volume.
|
|
272
|
-
async probe(dir) {
|
|
273
|
-
let dev
|
|
274
|
-
try {
|
|
275
|
-
dev = (await fs.promises.stat(dir)).dev
|
|
276
|
-
if (this.volumeModes.has(dev)) return this.volumeModes.get(dev)
|
|
277
|
-
} catch (e) {
|
|
278
|
-
return "copy"
|
|
279
|
-
}
|
|
280
|
-
const a = path.resolve(dir, `.pinokio-probe-${crypto.randomBytes(6).toString("hex")}`)
|
|
281
|
-
const b = a + "-link"
|
|
282
|
-
let mode = "copy"
|
|
283
|
-
try {
|
|
284
|
-
await fs.promises.writeFile(a, "probe")
|
|
285
|
-
await fs.promises.link(a, b)
|
|
286
|
-
const st = await fs.promises.stat(b)
|
|
287
|
-
if (st.nlink === 2) mode = "link"
|
|
288
|
-
} catch (e) {
|
|
289
|
-
} finally {
|
|
290
|
-
await fs.promises.unlink(a).catch(() => {})
|
|
291
|
-
await fs.promises.unlink(b).catch(() => {})
|
|
292
|
-
}
|
|
293
|
-
this.volumeModes.set(dev, mode)
|
|
294
|
-
return mode
|
|
295
|
-
}
|
|
296
|
-
async hashFile(filePath) {
|
|
297
|
-
if (this.workerIdleTimer) {
|
|
298
|
-
clearTimeout(this.workerIdleTimer)
|
|
299
|
-
this.workerIdleTimer = null
|
|
300
|
-
}
|
|
301
|
-
if (!this.worker) {
|
|
302
|
-
const worker = new Worker(path.resolve(__dirname, "hash_worker.js"))
|
|
303
|
-
this.worker = worker
|
|
304
|
-
this.workerStarts += 1
|
|
305
|
-
worker.unref()
|
|
306
|
-
worker.on("message", ({ id, hash, size, error }) => {
|
|
307
|
-
const job = this.workerJobs.get(id)
|
|
308
|
-
if (!job) return
|
|
309
|
-
this.workerJobs.delete(id)
|
|
310
|
-
if (error) job.reject(new Error(error))
|
|
311
|
-
else job.resolve({ hash, size })
|
|
312
|
-
// Keep the worker warm across the scan queue. Starting one worker per
|
|
313
|
-
// file costs ~30ms and adds up quickly on a first scan.
|
|
314
|
-
if (this.workerJobs.size === 0 && this.worker === worker) {
|
|
315
|
-
this.workerIdleTimer = setTimeout(() => {
|
|
316
|
-
this.workerIdleTimer = null
|
|
317
|
-
if (this.workerJobs.size === 0 && this.worker === worker) {
|
|
318
|
-
this.worker = null
|
|
319
|
-
worker.terminate().catch(() => {})
|
|
320
|
-
}
|
|
321
|
-
}, this.workerIdleMs)
|
|
322
|
-
if (this.workerIdleTimer.unref) this.workerIdleTimer.unref()
|
|
323
|
-
}
|
|
324
|
-
})
|
|
325
|
-
worker.on("error", (error) => {
|
|
326
|
-
if (this.worker !== worker) return
|
|
327
|
-
if (this.workerIdleTimer) clearTimeout(this.workerIdleTimer)
|
|
328
|
-
this.workerIdleTimer = null
|
|
329
|
-
for (const job of this.workerJobs.values()) job.reject(error)
|
|
330
|
-
this.workerJobs.clear()
|
|
331
|
-
this.worker = null
|
|
332
|
-
})
|
|
333
|
-
worker.on("exit", (code) => {
|
|
334
|
-
if (this.worker !== worker) return
|
|
335
|
-
if (this.workerIdleTimer) clearTimeout(this.workerIdleTimer)
|
|
336
|
-
this.workerIdleTimer = null
|
|
337
|
-
for (const job of this.workerJobs.values()) {
|
|
338
|
-
job.reject(new Error(`hash worker exited with code ${code}`))
|
|
339
|
-
}
|
|
340
|
-
this.workerJobs.clear()
|
|
341
|
-
this.worker = null
|
|
342
|
-
})
|
|
343
|
-
}
|
|
344
|
-
const id = ++this.workerSeq
|
|
345
|
-
return new Promise((resolve, reject) => {
|
|
346
|
-
this.workerJobs.set(id, { resolve, reject })
|
|
347
|
-
this.worker.postMessage({ id, filePath })
|
|
348
|
-
})
|
|
349
|
-
}
|
|
350
|
-
// adopt: give an existing file a store name. Metadata-only, never copies.
|
|
351
|
-
async adopt(filePath, hash, meta = {}) {
|
|
352
|
-
if (!this.enabled) return { status: "disabled" }
|
|
353
|
-
const storePath = this.storePathFor(hash)
|
|
354
|
-
const st = await fs.promises.stat(filePath)
|
|
355
|
-
const linkEntry = {
|
|
356
|
-
hash,
|
|
357
|
-
app: meta.app || null,
|
|
358
|
-
source_id: meta.source_id || null,
|
|
359
|
-
dev: st.dev,
|
|
360
|
-
ino: st.ino,
|
|
361
|
-
mode: "link"
|
|
362
|
-
}
|
|
363
|
-
let storeStat = null
|
|
364
|
-
try {
|
|
365
|
-
storeStat = await fs.promises.stat(storePath)
|
|
366
|
-
} catch (e) {}
|
|
367
|
-
if (storeStat) {
|
|
368
|
-
if (storeStat.dev === st.dev && storeStat.ino === st.ino) {
|
|
369
|
-
this.registry.addBlob(hash, { size: st.size, source_urls: meta.source_urls || [] })
|
|
370
|
-
this.registry.addLink(filePath, linkEntry)
|
|
371
|
-
return { status: "already" }
|
|
372
|
-
}
|
|
373
|
-
return { status: "duplicate", storePath }
|
|
374
|
-
}
|
|
375
|
-
try {
|
|
376
|
-
await fs.promises.mkdir(path.dirname(storePath), { recursive: true })
|
|
377
|
-
await fs.promises.link(filePath, storePath)
|
|
378
|
-
} catch (e) {
|
|
379
|
-
if (NO_LINK_CODES.has(e.code)) {
|
|
380
|
-
linkEntry.mode = "copy"
|
|
381
|
-
this.registry.addBlob(hash, { size: st.size, source_urls: meta.source_urls || [] })
|
|
382
|
-
this.registry.addLink(filePath, linkEntry)
|
|
383
|
-
await this.registry.appendEvent({ kind: "adopt", hash, path: filePath, app: meta.app || null, source_id: meta.source_id || null, bytes_saved: 0, mode: "copy" })
|
|
384
|
-
return { status: "copy-mode" }
|
|
385
|
-
}
|
|
386
|
-
throw e
|
|
387
|
-
}
|
|
388
|
-
this.registry.addBlob(hash, { size: st.size, source_urls: meta.source_urls || [] })
|
|
389
|
-
this.registry.addLink(filePath, linkEntry)
|
|
390
|
-
await this.registry.appendEvent({ kind: "adopt", hash, path: filePath, app: meta.app || null, source_id: meta.source_id || null, bytes_saved: 0 })
|
|
391
|
-
return { status: "adopted" }
|
|
392
|
-
}
|
|
393
|
-
// convert: replace a byte-identical duplicate with a link to an existing
|
|
394
|
-
// blob. Atomic link+rename; target is never missing at any instant.
|
|
395
|
-
async convert(targetPath, hash, meta = {}) {
|
|
396
|
-
if (!this.enabled) return { status: "disabled" }
|
|
397
|
-
const storePath = this.storePathFor(hash)
|
|
398
|
-
let storeStat
|
|
399
|
-
try {
|
|
400
|
-
storeStat = await fs.promises.stat(storePath)
|
|
401
|
-
} catch (e) {
|
|
402
|
-
return { status: "no-blob" }
|
|
403
|
-
}
|
|
404
|
-
const targetStat = await fs.promises.stat(targetPath)
|
|
405
|
-
if (storeStat.dev === targetStat.dev && storeStat.ino === targetStat.ino) {
|
|
406
|
-
this.registry.addLink(targetPath, { hash, app: meta.app || null, source_id: meta.source_id || null, dev: storeStat.dev, ino: storeStat.ino, mode: "link" })
|
|
407
|
-
return { status: "already" }
|
|
408
|
-
}
|
|
409
|
-
if (storeStat.size !== targetStat.size) {
|
|
410
|
-
return { status: "size-mismatch" }
|
|
411
|
-
}
|
|
412
|
-
const tmp = targetPath + TMP_SUFFIX
|
|
413
|
-
try {
|
|
414
|
-
try {
|
|
415
|
-
await fs.promises.link(storePath, tmp)
|
|
416
|
-
} catch (e) {
|
|
417
|
-
if (e.code === "EEXIST") {
|
|
418
|
-
await fs.promises.unlink(tmp)
|
|
419
|
-
await fs.promises.link(storePath, tmp)
|
|
420
|
-
} else {
|
|
421
|
-
throw e
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
await fs.promises.rename(tmp, targetPath)
|
|
425
|
-
} catch (e) {
|
|
426
|
-
await fs.promises.unlink(tmp).catch(() => {})
|
|
427
|
-
if (LOCK_CODES.has(e.code)) {
|
|
428
|
-
return { status: "locked" }
|
|
429
|
-
}
|
|
430
|
-
if (NO_LINK_CODES.has(e.code)) {
|
|
431
|
-
return { status: "unavailable", code: e.code }
|
|
432
|
-
}
|
|
433
|
-
throw e
|
|
434
|
-
}
|
|
435
|
-
const st = await fs.promises.stat(targetPath)
|
|
436
|
-
this.registry.addLink(targetPath, { hash, app: meta.app || null, source_id: meta.source_id || null, dev: st.dev, ino: st.ino, mode: "link" })
|
|
437
|
-
this.registry.addSaved(storeStat.size)
|
|
438
|
-
await this.registry.appendEvent({
|
|
439
|
-
kind: "convert", hash, path: targetPath, app: meta.app || null, source_id: meta.source_id || null,
|
|
440
|
-
auto: !!meta.auto, bytes_saved: storeStat.size, batch_id: meta.batch_id || null
|
|
441
|
-
})
|
|
442
|
-
return { status: "converted", bytes_saved: storeStat.size }
|
|
443
|
-
}
|
|
444
|
-
// reclaim: delete an orphan's store name. Refuses when any app name remains.
|
|
445
|
-
async reclaim(hash) {
|
|
446
|
-
if (!this.enabled) return { status: "disabled" }
|
|
447
|
-
const storePath = this.storePathFor(hash)
|
|
448
|
-
let st
|
|
449
|
-
try {
|
|
450
|
-
st = await fs.promises.stat(storePath)
|
|
451
|
-
} catch (e) {
|
|
452
|
-
this.registry.removeBlob(hash)
|
|
453
|
-
return { status: "gone" }
|
|
454
|
-
}
|
|
455
|
-
if (st.nlink > 1) return { status: "in-use" }
|
|
456
|
-
await fs.promises.unlink(storePath)
|
|
457
|
-
this.registry.removeBlob(hash)
|
|
458
|
-
await this.registry.appendEvent({ kind: "reclaim", hash, bytes_saved: st.size })
|
|
459
|
-
return { status: "reclaimed", bytes_freed: st.size }
|
|
460
|
-
}
|
|
461
|
-
// Startup registry verification (trigger 5): bounded by registry size,
|
|
462
|
-
// never a discovery walk. Prunes dead links, flags orphans, removes stray
|
|
463
|
-
// conversion tmp files, re-adopts blobs whose store name was deleted.
|
|
464
|
-
async verify() {
|
|
465
|
-
if (!this.enabled || !this.registry) return
|
|
466
|
-
for (const [linkPath, entry] of [...this.registry.links]) {
|
|
467
|
-
await fs.promises.unlink(linkPath + TMP_SUFFIX).catch(() => {})
|
|
468
|
-
let st = null
|
|
469
|
-
try {
|
|
470
|
-
st = await fs.promises.stat(linkPath)
|
|
471
|
-
} catch (e) {}
|
|
472
|
-
if (!st || st.ino !== entry.ino || st.dev !== entry.dev) {
|
|
473
|
-
this.registry.removeLink(linkPath)
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
for (const [dupPath] of [...this.registry.duplicates]) {
|
|
477
|
-
try {
|
|
478
|
-
await fs.promises.stat(dupPath)
|
|
479
|
-
} catch (e) {
|
|
480
|
-
this.registry.duplicates.delete(dupPath)
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
for (const [hash, blob] of [...this.registry.blobs]) {
|
|
484
|
-
const storePath = this.storePathFor(hash)
|
|
485
|
-
let st = null
|
|
486
|
-
try {
|
|
487
|
-
st = await fs.promises.stat(storePath)
|
|
488
|
-
} catch (e) {}
|
|
489
|
-
if (!st) {
|
|
490
|
-
// Store name gone; re-adopt from a surviving app name if one exists.
|
|
491
|
-
let readopted = false
|
|
492
|
-
for (const [linkPath, entry] of this.registry.links) {
|
|
493
|
-
if (entry.hash !== hash || entry.mode !== "link") continue
|
|
494
|
-
try {
|
|
495
|
-
await fs.promises.mkdir(path.dirname(storePath), { recursive: true })
|
|
496
|
-
await fs.promises.link(linkPath, storePath)
|
|
497
|
-
readopted = true
|
|
498
|
-
break
|
|
499
|
-
} catch (e) {}
|
|
500
|
-
}
|
|
501
|
-
if (!readopted) this.registry.removeBlob(hash)
|
|
502
|
-
continue
|
|
503
|
-
}
|
|
504
|
-
blob.orphan = st.nlink === 1
|
|
505
|
-
blob.verified_at = Date.now()
|
|
506
|
-
}
|
|
507
|
-
this.registry.schedulePersist()
|
|
508
|
-
}
|
|
509
|
-
// Rebuild derived state from disk: store filenames are hashes, stat gives
|
|
510
|
-
// nlink, and (dev, ino) matching re-associates app paths. The replacement
|
|
511
|
-
// maps are assembled off to the side and swapped in only after the walk so
|
|
512
|
-
// a long repair never persists a half-reset registry.
|
|
513
|
-
async rebuild(roots, options = {}) {
|
|
514
|
-
if (!this.enabled) return
|
|
515
|
-
const registry = this.registry
|
|
516
|
-
const preserveState = options.preserveState !== false
|
|
517
|
-
const preserved = preserveState ? {
|
|
518
|
-
excluded: new Map(registry.excluded),
|
|
519
|
-
totals: Object.assign({}, registry.totals),
|
|
520
|
-
lastScan: registry.lastScan ? Object.assign({}, registry.lastScan) : null,
|
|
521
|
-
duplicates: new Map(registry.duplicates)
|
|
522
|
-
} : null
|
|
523
|
-
const rebuiltBlobs = new Map()
|
|
524
|
-
const rebuiltLinks = new Map()
|
|
525
|
-
const storeInoToHash = new Map()
|
|
526
|
-
let shards = []
|
|
527
|
-
try {
|
|
528
|
-
shards = await fs.promises.readdir(this.blobRoot)
|
|
529
|
-
} catch (e) {}
|
|
530
|
-
for (const shard of shards) {
|
|
531
|
-
let names = []
|
|
532
|
-
try {
|
|
533
|
-
names = await fs.promises.readdir(path.resolve(this.blobRoot, shard))
|
|
534
|
-
} catch (e) { continue }
|
|
535
|
-
for (const name of names) {
|
|
536
|
-
if (!/^[0-9a-f]{64}$/.test(name)) continue
|
|
537
|
-
try {
|
|
538
|
-
const st = await fs.promises.stat(path.resolve(this.blobRoot, shard, name))
|
|
539
|
-
const previous = registry.blobs.get(name)
|
|
540
|
-
rebuiltBlobs.set(name, {
|
|
541
|
-
size: st.size,
|
|
542
|
-
first_seen: previous && previous.first_seen ? previous.first_seen : Date.now(),
|
|
543
|
-
source_urls: previous && Array.isArray(previous.source_urls) ? [...previous.source_urls] : [],
|
|
544
|
-
verified_at: Date.now(),
|
|
545
|
-
orphan: st.nlink === 1
|
|
546
|
-
})
|
|
547
|
-
storeInoToHash.set(`${st.dev}:${st.ino}`, name)
|
|
548
|
-
} catch (e) {}
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
await this.refreshSources()
|
|
552
|
-
const walkRoots = roots
|
|
553
|
-
? roots.map((root) => typeof root === "string" ? { root, source_id: null } : root)
|
|
554
|
-
: this.scanRoots()
|
|
555
|
-
for (const entry of walkRoots) {
|
|
556
|
-
await this.walkForInoMatch(
|
|
557
|
-
entry.root,
|
|
558
|
-
storeInoToHash,
|
|
559
|
-
entry.source_id === "pinokio" ? null : entry.source_id,
|
|
560
|
-
rebuiltLinks
|
|
561
|
-
)
|
|
562
|
-
}
|
|
563
|
-
const rebuiltDuplicates = new Map()
|
|
564
|
-
if (preserved) {
|
|
565
|
-
for (const [duplicatePath, duplicate] of preserved.duplicates) {
|
|
566
|
-
if (preserved.excluded.has(duplicatePath) || rebuiltLinks.has(duplicatePath) || !rebuiltBlobs.has(duplicate.hash)) continue
|
|
567
|
-
try {
|
|
568
|
-
const st = await fs.promises.stat(duplicatePath)
|
|
569
|
-
if (st.isFile() && (!duplicate.size || duplicate.size === st.size)) {
|
|
570
|
-
rebuiltDuplicates.set(duplicatePath, Object.assign({}, duplicate, { size: st.size }))
|
|
571
|
-
}
|
|
572
|
-
} catch (error) {}
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
const rebuiltByIno = new Map()
|
|
576
|
-
for (const entry of rebuiltLinks.values()) {
|
|
577
|
-
rebuiltByIno.set(registry.inoKey(entry.dev, entry.ino), entry.hash)
|
|
578
|
-
}
|
|
579
|
-
registry.blobs = rebuiltBlobs
|
|
580
|
-
registry.links = rebuiltLinks
|
|
581
|
-
registry.scanIndex = new Map()
|
|
582
|
-
registry.duplicates = rebuiltDuplicates
|
|
583
|
-
registry.excluded = preserved ? preserved.excluded : new Map()
|
|
584
|
-
registry.lastScan = preserved ? preserved.lastScan : null
|
|
585
|
-
registry.totals = preserved ? preserved.totals : { lifetime_bytes_saved: 0 }
|
|
586
|
-
registry.byIno = rebuiltByIno
|
|
587
|
-
await registry.flush()
|
|
588
|
-
}
|
|
589
|
-
async walkForInoMatch(root, storeInoToHash, preferredSourceId = null, outputLinks = null) {
|
|
590
|
-
const stack = [root]
|
|
591
|
-
const vaultRoot = path.resolve(this.root)
|
|
592
|
-
while (stack.length) {
|
|
593
|
-
const dir = stack.pop()
|
|
594
|
-
let entries = []
|
|
595
|
-
try {
|
|
596
|
-
entries = await fs.promises.readdir(dir, { withFileTypes: true })
|
|
597
|
-
} catch (error) { continue }
|
|
598
|
-
const files = []
|
|
599
|
-
for (const entry of entries) {
|
|
600
|
-
const full = path.resolve(dir, entry.name)
|
|
601
|
-
if (entry.isSymbolicLink()) continue
|
|
602
|
-
if (entry.isDirectory()) {
|
|
603
|
-
if (full === vaultRoot) continue
|
|
604
|
-
stack.push(full)
|
|
605
|
-
}
|
|
606
|
-
else if (entry.isFile()) files.push(full)
|
|
607
|
-
}
|
|
608
|
-
const stats = await statMany(files, this.statConcurrency)
|
|
609
|
-
for (let index = 0; index < files.length; index++) {
|
|
610
|
-
const st = stats[index]
|
|
611
|
-
if (!st || st.size < this.sizeThreshold) continue
|
|
612
|
-
const hash = storeInoToHash.get(`${st.dev}:${st.ino}`)
|
|
613
|
-
if (hash) {
|
|
614
|
-
const source = this.sourceForPath(files[index], preferredSourceId)
|
|
615
|
-
const link = {
|
|
616
|
-
hash, app: source && source.kind === "app" ? source.app : null,
|
|
617
|
-
source_id: source ? source.id : null, dev: st.dev, ino: st.ino,
|
|
618
|
-
mode: "link", created: Date.now()
|
|
619
|
-
}
|
|
620
|
-
if (outputLinks) outputLinks.set(files[index], link)
|
|
621
|
-
else this.registry.addLink(files[index], link)
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
appNameFor(filePath) {
|
|
627
|
-
const source = this.sourceForPath(filePath)
|
|
628
|
-
return source && source.kind === "app" ? source.app : null
|
|
629
|
-
}
|
|
630
|
-
// Global state for the vault page and the health endpoint. Answered from
|
|
631
|
-
// memory + one stat per blob; never a walk.
|
|
632
|
-
async status() {
|
|
633
|
-
if (!this.enabled || !this.registry) return { enabled: false }
|
|
634
|
-
const registry = this.registry
|
|
635
|
-
const blobs = []
|
|
636
|
-
const blobByHash = new Map()
|
|
637
|
-
const storeStats = new Map()
|
|
638
|
-
const namesByHash = new Map()
|
|
639
|
-
for (const [linkPath, entry] of registry.links) {
|
|
640
|
-
if (!namesByHash.has(entry.hash)) namesByHash.set(entry.hash, [])
|
|
641
|
-
const location = this.locationForPath(linkPath, entry.source_id)
|
|
642
|
-
namesByHash.get(entry.hash).push(Object.assign({
|
|
643
|
-
path: linkPath, app: entry.app || null, mode: entry.mode
|
|
644
|
-
}, location))
|
|
645
|
-
}
|
|
646
|
-
let bytesOnDisk = 0
|
|
647
|
-
let wouldBe = 0
|
|
648
|
-
for (const [hash, blob] of registry.blobs) {
|
|
649
|
-
const names = namesByHash.get(hash) || []
|
|
650
|
-
const apps = new Set(names.map((name) => name.app).filter(Boolean))
|
|
651
|
-
let nlink = null
|
|
652
|
-
let storeStat = null
|
|
653
|
-
try {
|
|
654
|
-
storeStat = await fs.promises.stat(this.storePathFor(hash))
|
|
655
|
-
nlink = storeStat.nlink
|
|
656
|
-
} catch (e) {}
|
|
657
|
-
storeStats.set(hash, storeStat)
|
|
658
|
-
const size = blob.size || 0
|
|
659
|
-
const physicalCopies = Math.max(1, (storeStat ? 1 : 0) + names.filter((name) => name.mode === "copy").length)
|
|
660
|
-
bytesOnDisk += size * physicalCopies
|
|
661
|
-
wouldBe += size * Math.max(physicalCopies, names.length)
|
|
662
|
-
const publicBlob = {
|
|
663
|
-
hash, size: blob.size || 0, orphan: !!blob.orphan, nlink,
|
|
664
|
-
names, apps: [...apps], source_urls: blob.source_urls || []
|
|
665
|
-
}
|
|
666
|
-
blobs.push(publicBlob)
|
|
667
|
-
blobByHash.set(hash, publicBlob)
|
|
668
|
-
}
|
|
669
|
-
const duplicates = []
|
|
670
|
-
for (const [p, entry] of registry.duplicates) {
|
|
671
|
-
const location = this.locationForPath(p, entry.source_id)
|
|
672
|
-
const source = this.sourceForPath(p, location.source_id)
|
|
673
|
-
const index = registry.scanIndex.get(p)
|
|
674
|
-
const storeStat = storeStats.get(entry.hash)
|
|
675
|
-
const shareable = !!(source && source.shareable && storeStat && (!index || index.dev === undefined || index.dev === storeStat.dev))
|
|
676
|
-
const blob = blobByHash.get(entry.hash)
|
|
677
|
-
duplicates.push(Object.assign({
|
|
678
|
-
path: p, hash: entry.hash, size: entry.size || 0, app: entry.app || null,
|
|
679
|
-
shareable, matches: blob ? blob.names : []
|
|
680
|
-
}, location))
|
|
681
|
-
}
|
|
682
|
-
const events = (await registry.readEvents()).slice(-100).reverse()
|
|
683
|
-
const scan = this.scanStatus()
|
|
684
|
-
const excluded = []
|
|
685
|
-
for (const [p, meta] of registry.excluded) {
|
|
686
|
-
let size = Number(meta.size) || 0
|
|
687
|
-
if (!size) {
|
|
688
|
-
try { size = (await fs.promises.stat(p)).size } catch (error) {}
|
|
689
|
-
}
|
|
690
|
-
excluded.push(Object.assign({ path: p, ts: meta.ts || null, size }, this.locationForPath(p, meta.source_id)))
|
|
691
|
-
}
|
|
692
|
-
const publicSources = this._sources.map((source) => ({
|
|
693
|
-
id: source.id, kind: source.kind, label: source.label, root: source.root,
|
|
694
|
-
display_path: source.kind === "pinokio" ? source.root : (source.mount_path || source.root),
|
|
695
|
-
target_path: source.kind === "external" ? source.root : null,
|
|
696
|
-
parent_id: source.parent_id, app: source.app || null,
|
|
697
|
-
available: source.available !== false, shareable: source.kind === "virtual" ? null : !!source.shareable
|
|
698
|
-
}))
|
|
699
|
-
return {
|
|
700
|
-
enabled: true,
|
|
701
|
-
mode: this.mode,
|
|
702
|
-
scan,
|
|
703
|
-
last_scan: registry.lastScan,
|
|
704
|
-
bytes_on_disk: bytesOnDisk,
|
|
705
|
-
bytes_without_sharing: wouldBe,
|
|
706
|
-
saved_by_sharing: wouldBe - bytesOnDisk,
|
|
707
|
-
lifetime_bytes_saved: registry.totals.lifetime_bytes_saved,
|
|
708
|
-
reclaimable: blobs.filter((b) => b.orphan).reduce((sum, b) => sum + b.size, 0),
|
|
709
|
-
pending_bytes: duplicates.filter((d) => d.shareable).reduce((sum, d) => sum + d.size, 0),
|
|
710
|
-
unavailable_bytes: duplicates.filter((d) => !d.shareable).reduce((sum, d) => sum + d.size, 0),
|
|
711
|
-
sources: publicSources, blobs, duplicates, excluded, events
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
scanStatus() {
|
|
715
|
-
const sweeper = this.sweeper
|
|
716
|
-
return sweeper ? Object.assign({}, sweeper.state, {
|
|
717
|
-
current_file: sweeper.currentHash ? path.basename(sweeper.currentHash.path) : null
|
|
718
|
-
}) : null
|
|
719
|
-
}
|
|
720
|
-
progressStatus() {
|
|
721
|
-
return {
|
|
722
|
-
enabled: !!this.enabled,
|
|
723
|
-
scan: this.scanStatus(),
|
|
724
|
-
last_scan: this.registry ? this.registry.lastScan : null
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
// detach: make one name an independent copy again ("go back"), and pin it
|
|
728
|
-
// so future scans neither re-list nor re-share it. Works on linked names
|
|
729
|
-
// (copies bytes out) and on pending duplicates (just ignores them).
|
|
730
|
-
async detach(filePath) {
|
|
731
|
-
if (!this.enabled) return { status: "disabled" }
|
|
732
|
-
const entry = this.registry.links.get(filePath)
|
|
733
|
-
if (entry) {
|
|
734
|
-
const tmp = filePath + TMP_SUFFIX
|
|
735
|
-
await fs.promises.copyFile(filePath, tmp)
|
|
736
|
-
await fs.promises.rename(tmp, filePath)
|
|
737
|
-
const st = await fs.promises.stat(filePath)
|
|
738
|
-
this.registry.removeLink(filePath)
|
|
739
|
-
this.registry.excluded.set(filePath, { ts: Date.now(), source_id: entry.source_id || null, size: st.size })
|
|
740
|
-
this.registry.schedulePersist()
|
|
741
|
-
await this.registry.appendEvent({ kind: "detach", hash: entry.hash, path: filePath, app: entry.app || null, source_id: entry.source_id || null, size: st.size })
|
|
742
|
-
return { status: "detached" }
|
|
743
|
-
}
|
|
744
|
-
if (this.registry.duplicates.has(filePath)) {
|
|
745
|
-
const dup = this.registry.duplicates.get(filePath)
|
|
746
|
-
this.registry.duplicates.delete(filePath)
|
|
747
|
-
this.registry.excluded.set(filePath, { ts: Date.now(), source_id: dup.source_id || null, size: dup.size || 0 })
|
|
748
|
-
this.registry.schedulePersist()
|
|
749
|
-
await this.registry.appendEvent({ kind: "detach", hash: dup.hash, path: filePath, app: dup.app || null, source_id: dup.source_id || null, size: dup.size || 0 })
|
|
750
|
-
return { status: "ignored" }
|
|
751
|
-
}
|
|
752
|
-
return { status: "not-found" }
|
|
753
|
-
}
|
|
754
|
-
// reshare: un-pin a detached path; the next scan may list it again.
|
|
755
|
-
async reshare(filePath) {
|
|
756
|
-
if (!this.enabled) return { status: "disabled" }
|
|
757
|
-
const excluded = this.registry.excluded.get(filePath)
|
|
758
|
-
if (this.registry.excluded.delete(filePath)) {
|
|
759
|
-
this.registry.schedulePersist()
|
|
760
|
-
await this.registry.appendEvent({ kind: "reshare", path: filePath, source_id: excluded && excluded.source_id ? excluded.source_id : null })
|
|
761
|
-
return { status: "resharable" }
|
|
762
|
-
}
|
|
763
|
-
return { status: "not-found" }
|
|
764
|
-
}
|
|
765
|
-
// Undo a conversion batch: replace each converted name with an independent
|
|
766
|
-
// copy of the bytes (spec, UX contract surface 3).
|
|
767
|
-
async undoBatch(batchId) {
|
|
768
|
-
if (!this.enabled || !batchId) return { undone: 0 }
|
|
769
|
-
const events = await this.registry.readEvents()
|
|
770
|
-
const summary = { undone: 0, bytes: 0 }
|
|
771
|
-
for (const event of events) {
|
|
772
|
-
if (event.kind !== "convert" || event.batch_id !== batchId || !event.path) continue
|
|
773
|
-
const entry = this.registry.links.get(event.path)
|
|
774
|
-
if (!entry || entry.hash !== event.hash) continue
|
|
775
|
-
const storePath = this.storePathFor(event.hash)
|
|
776
|
-
try {
|
|
777
|
-
const st = await fs.promises.stat(event.path)
|
|
778
|
-
const storeStat = await fs.promises.stat(storePath)
|
|
779
|
-
if (st.ino !== storeStat.ino || st.dev !== storeStat.dev) continue
|
|
780
|
-
const tmp = event.path + TMP_SUFFIX
|
|
781
|
-
await fs.promises.copyFile(storePath, tmp)
|
|
782
|
-
await fs.promises.rename(tmp, event.path)
|
|
783
|
-
this.registry.removeLink(event.path)
|
|
784
|
-
this.registry.duplicates.set(event.path, {
|
|
785
|
-
hash: event.hash, size: storeStat.size, app: entry.app || null,
|
|
786
|
-
source_id: entry.source_id || event.source_id || null, discovered: Date.now()
|
|
787
|
-
})
|
|
788
|
-
this.registry.totals.lifetime_bytes_saved = Math.max(0,
|
|
789
|
-
this.registry.totals.lifetime_bytes_saved - (event.bytes_saved || 0))
|
|
790
|
-
await this.registry.appendEvent({
|
|
791
|
-
kind: "undo", hash: event.hash, path: event.path,
|
|
792
|
-
source_id: entry.source_id || event.source_id || null, batch_id: batchId,
|
|
793
|
-
bytes_saved: event.bytes_saved || storeStat.size
|
|
794
|
-
})
|
|
795
|
-
summary.undone += 1
|
|
796
|
-
summary.bytes += storeStat.size
|
|
797
|
-
} catch (e) {}
|
|
798
|
-
}
|
|
799
|
-
this.registry.schedulePersist()
|
|
800
|
-
return summary
|
|
801
|
-
}
|
|
802
|
-
async reclaimAll() {
|
|
803
|
-
if (!this.enabled) return { reclaimed: 0, bytes_freed: 0 }
|
|
804
|
-
const summary = { reclaimed: 0, bytes_freed: 0 }
|
|
805
|
-
for (const [hash, blob] of [...this.registry.blobs]) {
|
|
806
|
-
if (!blob.orphan) continue
|
|
807
|
-
const result = await this.reclaim(hash)
|
|
808
|
-
if (result.status === "reclaimed") {
|
|
809
|
-
summary.reclaimed += 1
|
|
810
|
-
summary.bytes_freed += result.bytes_freed || 0
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
return summary
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
Vault.SIZE_THRESHOLD = SIZE_THRESHOLD
|
|
818
|
-
Vault.TMP_SUFFIX = TMP_SUFFIX
|
|
819
|
-
|
|
820
|
-
module.exports = Vault
|