main
ts 120 lines 4.99 KB
Raw
1 import { KvStorage } from '@rejetto/kvstorage'
2 import { promisify } from 'util'
3 import { access } from 'fs/promises'
4 import { onlyTruthy, try_, tryJson } from './cross'
5 import { onProcessExit } from './first'
6 import { utimes } from 'node:fs/promises'
7 import { statWithTimeout } from './util-files'
8 import { IS_WINDOWS } from './const'
9 import { isAbsolute, join, relative } from 'path'
10
11 const fsx = try_(() => {
12 const lib = require('fs-x-attributes')
13 return { set: promisify(lib.set), get: promisify(lib.get) }
14 }, () => console.warn('fs-x-attributes not available'))
15
16 const FN = 'file-attr.kv'
17 export const fileAttrDb = new KvStorage({ defaultPutDelay: 1000, maxPutDelay: 5000 })
18 onProcessExit(() => fileAttrDb.close())
19 fileAttrDb.open(FN).catch(e =>
20 console.error(String(e)))
21 const FILE_ATTR_PREFIX = 'user.hfs.' // user. prefix to be linux compatible
22 const FILE_ATTR_KEY_SEPARATOR = '|'
23
24 /* @param v must be JSON-able or undefined */
25 export async function storeFileAttr(path: string, k: string, v: any) {
26 const s = await statWithTimeout(path).catch(() => null)
27 // since we don't have fsx.remove, we simulate it with an empty string
28 if (s && await fsx?.set(path, FILE_ATTR_PREFIX + k, v === undefined ? '' : JSON.stringify(v)).then(() => 1, () => 0)) {
29 if (IS_WINDOWS) utimes(path, s.atime, s.mtime) // restore timestamps, necessary only on Windows
30 return true
31 }
32 // fallback to our kv-storage
33 return await fileAttrDb.put(fileAttrKey(path, k), v)?.catch((e: any) => {
34 console.error("Couldn't store metadata on", path, String(e.message || e))
35 return false
36 }) ?? true // if put is undefined, the value was already there
37 }
38
39 export async function loadFileAttr(path: string, k: string) {
40 return await fsx?.get(path, FILE_ATTR_PREFIX + k)
41 .then((x: any) => x === '' ? undefined : tryJson(String(x)),
42 () => fileAttrDb.isOpen() ? fileAttrDb.get(fileAttrKey(path, k)).catch(console.error) : null)
43 ?? undefined // normalize, as we get null instead of undefined on windows
44 }
45
46 // remove file-attr for files that don't exist anymore
47 export async function purgeFileAttr() {
48 let n = 0
49 await Promise.all(Array.from(fileAttrDb.keys()).map(k => {
50 const fn = splitFileAttrKey(k)?.filePath
51 return fn && access(fn).catch(() => {
52 n++
53 return fileAttrDb.del(k)
54 })
55 }))
56 if (n)
57 await fileAttrDb.rewrite()
58 console.log(`Removed ${n} entrie(s)`)
59 }
60
61 export async function moveStoredFileAttrs(fromPath: string, toPath: string) {
62 try {
63 if (fromPath === toPath || !fileAttrDb.isOpen())
64 return
65 const entries = storedFileAttrEntries()
66 const affectedEntries = entries.filter(x => isSameOrInside(fromPath, x.filePath))
67 if (!affectedEntries.length)
68 return
69 const affectedWithValues = await Promise.all(affectedEntries.map(async x => ({
70 ...x,
71 value: await fileAttrDb.get(x.key)
72 })))
73 const oldDestinationKeys = entries.filter(x => isSameOrInside(toPath, x.filePath)).map(x => x.key)
74 // destination attrs must be cleared first because a replaced file may not have all attrs owned by the source
75 await Promise.all(oldDestinationKeys.map(k => fileAttrDb.del(k)))
76 await Promise.all(affectedWithValues.map(async ({ key, filePath, attr, value }) => {
77 const rel = relative(fromPath, filePath)
78 // physical path keys the fallback DB, so filesystem moves must carry descendant entries explicitly
79 await fileAttrDb.put(fileAttrKey(join(toPath, rel), attr), value)
80 await fileAttrDb.del(key)
81 }))
82 }
83 // metadata sync runs after the filesystem mutation, so it must not report the completed file operation as failed
84 catch(e: any) { console.error("Couldn't move metadata in file-attr DB", fromPath, toPath, String(e.message || e)) }
85 }
86
87 export async function deleteStoredFileAttrs(path: string) {
88 try {
89 if (!fileAttrDb.isOpen())
90 return
91 const keys = storedFileAttrEntries().filter(x => isSameOrInside(path, x.filePath)).map(x => x.key)
92 await Promise.all(keys.map(k => fileAttrDb.del(k)))
93 }
94 // metadata cleanup runs after deletion, so surfacing this would leave clients seeing a false delete failure
95 catch(e: any) { console.error("Couldn't delete metadata from file-attr DB", path, String(e.message || e)) }
96 }
97
98 function storedFileAttrEntries() {
99 return onlyTruthy(Array.from(fileAttrDb.keys()).map(splitFileAttrKey))
100 }
101
102 function splitFileAttrKey(key: string) {
103 const i = key.lastIndexOf(FILE_ATTR_KEY_SEPARATOR)
104 if (i < 0)
105 return
106 return {
107 key,
108 filePath: key.slice(0, i),
109 attr: key.slice(i + 1)
110 }
111 }
112
113 function fileAttrKey(path: string, attr: string) {
114 return path + FILE_ATTR_KEY_SEPARATOR + attr
115 }
116
117 function isSameOrInside(parent: string, path: string) {
118 const rel = relative(parent, path)
119 return rel === '' || Boolean(rel) && !rel.startsWith('..') && !isAbsolute(rel)
120 }