@steve02081504/fount-p2p 0.0.18 → 0.0.20
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/dag/storage.mjs +4 -48
- package/files/chunk_fetch.mjs +30 -13
- package/files/manifest_fetch.mjs +38 -23
- package/package.json +1 -1
- package/utils/atomic_fs.mjs +87 -0
- package/utils/inflight_table.mjs +95 -0
- package/utils/json_io.mjs +8 -4
package/dag/storage.mjs
CHANGED
|
@@ -1,62 +1,18 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer'
|
|
2
|
-
import { randomUUID } from 'node:crypto'
|
|
3
2
|
import { createReadStream, createWriteStream } from 'node:fs'
|
|
4
|
-
import { appendFile, mkdir, open, readFile,
|
|
3
|
+
import { appendFile, mkdir, open, readFile, writeFile } from 'node:fs/promises'
|
|
5
4
|
import { dirname } from 'node:path'
|
|
6
5
|
import { createInterface } from 'node:readline'
|
|
7
6
|
import { Readable } from 'node:stream'
|
|
8
7
|
import { pipeline } from 'node:stream/promises'
|
|
9
|
-
import { setTimeout as sleep } from 'node:timers/promises'
|
|
10
8
|
|
|
9
|
+
import { atomicTemporaryPath, finalizeAtomicRename } from '../utils/atomic_fs.mjs'
|
|
11
10
|
import { withAsyncMutex } from '../utils/async_mutex.mjs'
|
|
12
11
|
|
|
12
|
+
export { finalizeAtomicRename } from '../utils/atomic_fs.mjs'
|
|
13
|
+
|
|
13
14
|
/** 流式重写 JSONL 时分块写入的行数上限 */
|
|
14
15
|
const WRITE_JSONL_CHUNK_LINES = 1000
|
|
15
|
-
/** Windows 上 rename 可能被短暂占用,做几次短退避重试。 */
|
|
16
|
-
const ATOMIC_RENAME_RETRY_DELAYS_MS = [0, 10, 25, 50, 100]
|
|
17
|
-
const ATOMIC_RENAME_RETRY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES'])
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* @param {string} filePath 目标路径
|
|
21
|
-
* @returns {string} 唯一临时文件路径
|
|
22
|
-
*/
|
|
23
|
-
function atomicTemporaryPath(filePath) {
|
|
24
|
-
return `${filePath}.tmp.${process.pid}.${randomUUID()}`
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* @param {string} temporaryPath 临时文件路径
|
|
29
|
-
* @returns {Promise<void>}
|
|
30
|
-
*/
|
|
31
|
-
async function cleanupAtomicTemporary(temporaryPath) {
|
|
32
|
-
try { await unlink(temporaryPath) } catch { /* ok */ }
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* 完成原子写的最终 rename;若目标目录已在 cleanup 中消失,则清理残余临时文件后静默返回。
|
|
37
|
-
* @param {string} temporaryPath 临时文件路径
|
|
38
|
-
* @param {string} filePath 最终目标路径
|
|
39
|
-
* @returns {Promise<boolean>} 是否已成功落到目标路径
|
|
40
|
-
*/
|
|
41
|
-
export async function finalizeAtomicRename(temporaryPath, filePath) {
|
|
42
|
-
/** @type {NodeJS.ErrnoException | undefined} */
|
|
43
|
-
let lastError
|
|
44
|
-
for (const delayMs of ATOMIC_RENAME_RETRY_DELAYS_MS) {
|
|
45
|
-
if (delayMs) await sleep(delayMs)
|
|
46
|
-
try {
|
|
47
|
-
await rename(temporaryPath, filePath)
|
|
48
|
-
return true
|
|
49
|
-
}
|
|
50
|
-
catch (error) {
|
|
51
|
-
lastError = error
|
|
52
|
-
if (ATOMIC_RENAME_RETRY_CODES.has(error?.code)) continue
|
|
53
|
-
break
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
await cleanupAtomicTemporary(temporaryPath)
|
|
57
|
-
if (lastError?.code === 'ENOENT') return false
|
|
58
|
-
throw lastError
|
|
59
|
-
}
|
|
60
16
|
|
|
61
17
|
/**
|
|
62
18
|
* @param {string} line JSONL 单行
|
package/files/chunk_fetch.mjs
CHANGED
|
@@ -3,15 +3,24 @@ import { randomUUID } from 'node:crypto'
|
|
|
3
3
|
import { bytesToBase64 } from '../core/bytes_codec.mjs'
|
|
4
4
|
import {
|
|
5
5
|
MAX_PENDING_CHUNK_FETCHES,
|
|
6
|
-
pendingChunkFetches,
|
|
7
6
|
registerChunkFetchWait,
|
|
8
7
|
} from '../federation/chunk_fetch_pending.mjs'
|
|
8
|
+
import { ms } from '../utils/duration.mjs'
|
|
9
|
+
import { createInflightTable } from '../utils/inflight_table.mjs'
|
|
9
10
|
|
|
10
11
|
import { verifiedChunkBytes } from './chunk_fetch_verify.mjs'
|
|
11
12
|
import { fetchFederationChunk, resolveNodeHash } from './chunk_provider_registry.mjs'
|
|
12
13
|
import { getChunk, hasChunk, putChunk } from './chunk_store.mjs'
|
|
13
14
|
import { fanoutFedFetch } from './fetch_fanout.mjs'
|
|
14
15
|
|
|
16
|
+
const DEFAULT_CHUNK_FETCH_TIMEOUT_MS = ms('8s')
|
|
17
|
+
|
|
18
|
+
/** 同 username+hash 共享一次 fanout;队满只丢已超基础超时的队首。 */
|
|
19
|
+
const chunkInflight = createInflightTable({
|
|
20
|
+
maxSize: MAX_PENDING_CHUNK_FETCHES,
|
|
21
|
+
baseTimeoutMs: DEFAULT_CHUNK_FETCH_TIMEOUT_MS,
|
|
22
|
+
})
|
|
23
|
+
|
|
15
24
|
/**
|
|
16
25
|
* @typedef {{
|
|
17
26
|
* username: string,
|
|
@@ -42,19 +51,27 @@ export async function fetchChunk(context) {
|
|
|
42
51
|
}
|
|
43
52
|
}
|
|
44
53
|
|
|
45
|
-
|
|
54
|
+
const inflightKey = `${username}\0${hash}`
|
|
55
|
+
const shared = chunkInflight.acquire(inflightKey, () => {
|
|
56
|
+
const requestId = randomUUID()
|
|
57
|
+
const wait = registerChunkFetchWait(requestId, hash, DEFAULT_CHUNK_FETCH_TIMEOUT_MS)
|
|
58
|
+
void (async () => {
|
|
59
|
+
try {
|
|
60
|
+
const { nodeHash } = await resolveNodeHash(username)
|
|
61
|
+
await fanoutFedFetch(username, 'fed_chunk_get', {
|
|
62
|
+
requestId,
|
|
63
|
+
nodeHash,
|
|
64
|
+
chunkHash: hash,
|
|
65
|
+
ownerEntityHash: context.ownerEntityHash,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
catch { /* pending wait 超时/cancel 负责 settle */ }
|
|
69
|
+
})()
|
|
70
|
+
return { done: wait.done, cancel: wait.cancel }
|
|
71
|
+
})
|
|
72
|
+
if (!shared) return null
|
|
46
73
|
|
|
47
|
-
const
|
|
48
|
-
const { done } = registerChunkFetchWait(requestId, hash, 8000)
|
|
49
|
-
const { nodeHash } = await resolveNodeHash(username)
|
|
50
|
-
const payload = {
|
|
51
|
-
requestId,
|
|
52
|
-
nodeHash,
|
|
53
|
-
chunkHash: hash,
|
|
54
|
-
ownerEntityHash: context.ownerEntityHash,
|
|
55
|
-
}
|
|
56
|
-
await fanoutFedFetch(username, 'fed_chunk_get', payload)
|
|
57
|
-
const result = await done
|
|
74
|
+
const result = await shared
|
|
58
75
|
const verified = verifiedChunkBytes(hash, result)
|
|
59
76
|
if (verified) {
|
|
60
77
|
await putChunk(hash, verified)
|
package/files/manifest_fetch.mjs
CHANGED
|
@@ -3,11 +3,12 @@ import { randomUUID } from 'node:crypto'
|
|
|
3
3
|
import {
|
|
4
4
|
manifestFetchExpectedKey,
|
|
5
5
|
MAX_PENDING_MANIFEST_FETCHES,
|
|
6
|
-
pendingManifestFetches,
|
|
7
6
|
registerManifestFetchWait,
|
|
8
7
|
} from '../federation/manifest_fetch_pending.mjs'
|
|
9
8
|
import { isWritableLocalEntity } from '../node/identity.mjs'
|
|
10
9
|
import { getEntityStore } from '../node/instance.mjs'
|
|
10
|
+
import { ms } from '../utils/duration.mjs'
|
|
11
|
+
import { createInflightTable } from '../utils/inflight_table.mjs'
|
|
11
12
|
|
|
12
13
|
import { resolveNodeHash } from './chunk_provider_registry.mjs'
|
|
13
14
|
import { loadFileManifest, saveFileManifest } from './evfs.mjs'
|
|
@@ -15,11 +16,18 @@ import { fanoutFedFetch } from './fetch_fanout.mjs'
|
|
|
15
16
|
import { normalizeFileManifest } from './manifest.mjs'
|
|
16
17
|
import { shouldPreferIncomingPublicManifest } from './public_manifest.mjs'
|
|
17
18
|
|
|
18
|
-
const DEFAULT_MANIFEST_FETCH_TIMEOUT_MS =
|
|
19
|
+
const DEFAULT_MANIFEST_FETCH_TIMEOUT_MS = ms('8s')
|
|
20
|
+
|
|
21
|
+
/** 同 username+owner+path 共享一次 fanout;队满只丢已超基础超时的队首。 */
|
|
22
|
+
const manifestInflight = createInflightTable({
|
|
23
|
+
maxSize: MAX_PENDING_MANIFEST_FETCHES,
|
|
24
|
+
baseTimeoutMs: DEFAULT_MANIFEST_FETCH_TIMEOUT_MS,
|
|
25
|
+
})
|
|
19
26
|
|
|
20
27
|
/**
|
|
21
28
|
* 拉取公开 manifest;默认不写盘。`cache: true` 或 `cachePublicManifest` 才缓存。
|
|
22
29
|
* 本地已有 publicSig 时仍会 fanout 再校验,按 publishedAt 择新;超时则回退本地。
|
|
30
|
+
* 同 key in-flight 去重;调用方外层超时不 abort,后台继续填缓存。
|
|
23
31
|
* @param {{ username: string, ownerEntityHash: string, logicalPath: string, cache?: boolean, timeoutMs?: number }} context - 拉取上下文
|
|
24
32
|
* @returns {Promise<import('./manifest.mjs').FileManifest | null>} 验签后的 manifest,失败为 null
|
|
25
33
|
*/
|
|
@@ -29,30 +37,37 @@ export async function fetchPublicManifest(context) {
|
|
|
29
37
|
const { username } = context
|
|
30
38
|
if (!ownerEntityHash || !logicalPath || !username) return null
|
|
31
39
|
|
|
32
|
-
const local = await loadFileManifest(ownerEntityHash, logicalPath)
|
|
33
|
-
const hasLocalPublic = local?.transferKeyDescriptor?.type === 'public' && !!local?.meta?.publicSig
|
|
34
|
-
|
|
35
|
-
if (pendingManifestFetches.size >= MAX_PENDING_MANIFEST_FETCHES)
|
|
36
|
-
return hasLocalPublic ? local : null
|
|
37
|
-
|
|
38
40
|
const timeoutMs = Number(context.timeoutMs) > 0
|
|
39
41
|
? Number(context.timeoutMs)
|
|
40
42
|
: DEFAULT_MANIFEST_FETCH_TIMEOUT_MS
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
43
|
+
const expectedKey = manifestFetchExpectedKey(ownerEntityHash, logicalPath)
|
|
44
|
+
const inflightKey = `${username}\0${expectedKey}`
|
|
45
|
+
|
|
46
|
+
// 先挂 in-flight(同步),再读本地 — 避免并发调用在 await 间隙各自 start
|
|
47
|
+
const localPromise = loadFileManifest(ownerEntityHash, logicalPath)
|
|
48
|
+
const shared = manifestInflight.acquire(inflightKey, () => {
|
|
49
|
+
const requestId = randomUUID()
|
|
50
|
+
const wait = registerManifestFetchWait(requestId, expectedKey, timeoutMs)
|
|
51
|
+
void (async () => {
|
|
52
|
+
try {
|
|
53
|
+
const { nodeHash } = await resolveNodeHash(username)
|
|
54
|
+
await fanoutFedFetch(username, 'fed_manifest_get', {
|
|
55
|
+
requestId,
|
|
56
|
+
nodeHash,
|
|
57
|
+
ownerEntityHash,
|
|
58
|
+
logicalPath,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
catch { /* pending wait 超时/cancel 负责 settle */ }
|
|
62
|
+
})()
|
|
63
|
+
return { done: wait.done, cancel: wait.cancel }
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const local = await localPromise
|
|
67
|
+
const hasLocalPublic = local?.transferKeyDescriptor?.type === 'public' && !!local?.meta?.publicSig
|
|
68
|
+
if (!shared) return hasLocalPublic ? local : null
|
|
69
|
+
|
|
70
|
+
const result = await shared
|
|
56
71
|
|
|
57
72
|
if (result && (!hasLocalPublic || shouldPreferIncomingPublicManifest(local, result))) {
|
|
58
73
|
if (context.cache === true)
|
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 原子落盘:唯一临时路径 + rename(Windows 短暂占用时短退避重试)。
|
|
3
|
+
*/
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import { rename, unlink } from 'node:fs/promises'
|
|
7
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
8
|
+
|
|
9
|
+
/** Windows 上 rename 可能被短暂占用,做几次短退避重试。 */
|
|
10
|
+
const ATOMIC_RENAME_RETRY_DELAYS_MS = [0, 10, 25, 50, 100]
|
|
11
|
+
const ATOMIC_RENAME_RETRY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES'])
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} filePath 目标路径
|
|
15
|
+
* @returns {string} 唯一临时文件路径
|
|
16
|
+
*/
|
|
17
|
+
export function atomicTemporaryPath(filePath) {
|
|
18
|
+
return `${filePath}.tmp.${process.pid}.${randomUUID()}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} temporaryPath 临时文件路径
|
|
23
|
+
* @returns {Promise<void>}
|
|
24
|
+
*/
|
|
25
|
+
async function cleanupAtomicTemporary(temporaryPath) {
|
|
26
|
+
try { await unlink(temporaryPath) } catch { /* ok */ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} temporaryPath 临时文件路径
|
|
31
|
+
* @returns {void}
|
|
32
|
+
*/
|
|
33
|
+
function cleanupAtomicTemporarySync(temporaryPath) {
|
|
34
|
+
try { fs.unlinkSync(temporaryPath) } catch { /* ok */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 完成原子写的最终 rename;若目标目录已在 cleanup 中消失,则清理残余临时文件后静默返回。
|
|
39
|
+
* @param {string} temporaryPath 临时文件路径
|
|
40
|
+
* @param {string} filePath 最终目标路径
|
|
41
|
+
* @returns {Promise<boolean>} 是否已成功落到目标路径
|
|
42
|
+
*/
|
|
43
|
+
export async function finalizeAtomicRename(temporaryPath, filePath) {
|
|
44
|
+
/** @type {NodeJS.ErrnoException | undefined} */
|
|
45
|
+
let lastError
|
|
46
|
+
for (const delayMs of ATOMIC_RENAME_RETRY_DELAYS_MS) {
|
|
47
|
+
if (delayMs) await sleep(delayMs)
|
|
48
|
+
try {
|
|
49
|
+
await rename(temporaryPath, filePath)
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
lastError = error
|
|
54
|
+
if (ATOMIC_RENAME_RETRY_CODES.has(error?.code)) continue
|
|
55
|
+
break
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
await cleanupAtomicTemporary(temporaryPath)
|
|
59
|
+
if (lastError?.code === 'ENOENT') return false
|
|
60
|
+
throw lastError
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `finalizeAtomicRename` 的同步版。
|
|
65
|
+
* @param {string} temporaryPath 临时文件路径
|
|
66
|
+
* @param {string} filePath 最终目标路径
|
|
67
|
+
* @returns {boolean} 是否已成功落到目标路径
|
|
68
|
+
*/
|
|
69
|
+
export function finalizeAtomicRenameSync(temporaryPath, filePath) {
|
|
70
|
+
/** @type {NodeJS.ErrnoException | undefined} */
|
|
71
|
+
let lastError
|
|
72
|
+
for (const delayMs of ATOMIC_RENAME_RETRY_DELAYS_MS) {
|
|
73
|
+
if (delayMs) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs)
|
|
74
|
+
try {
|
|
75
|
+
fs.renameSync(temporaryPath, filePath)
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
lastError = error
|
|
80
|
+
if (ATOMIC_RENAME_RETRY_CODES.has(error?.code)) continue
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
cleanupAtomicTemporarySync(temporaryPath)
|
|
85
|
+
if (lastError?.code === 'ENOENT') return false
|
|
86
|
+
throw lastError
|
|
87
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-flight 去重表:同 key 复用并 touch 到队尾;队满时仅淘汰「已超过 baseTimeout」的队首。
|
|
3
|
+
* 双窗口:size >= maxSize 且 entry 年龄 >= baseTimeoutMs 才 cancel。
|
|
4
|
+
*
|
|
5
|
+
* @template T
|
|
6
|
+
* @param {{ maxSize: number, baseTimeoutMs: number, now?: () => number }} options 容量与基础超时
|
|
7
|
+
* @returns {{
|
|
8
|
+
* size: () => number,
|
|
9
|
+
* has: (key: string) => boolean,
|
|
10
|
+
* acquire: (key: string, start: () => { done: Promise<T>, cancel: () => void }) => Promise<T> | null,
|
|
11
|
+
* clear: () => void,
|
|
12
|
+
* }} 表句柄
|
|
13
|
+
*/
|
|
14
|
+
export function createInflightTable(options) {
|
|
15
|
+
const maxSize = Math.max(1, Math.floor(Number(options.maxSize) || 1))
|
|
16
|
+
const baseTimeoutMs = Math.max(0, Number(options.baseTimeoutMs) || 0)
|
|
17
|
+
const now = options.now || Date.now
|
|
18
|
+
|
|
19
|
+
/** @type {Map<string, { done: Promise<T>, cancel: () => void, startedAt: number }>} */
|
|
20
|
+
const map = new Map()
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 队满时从队首取消已超时项。
|
|
24
|
+
* @returns {void}
|
|
25
|
+
*/
|
|
26
|
+
function pruneAgedOverCap() {
|
|
27
|
+
const t = now()
|
|
28
|
+
while (map.size >= maxSize) {
|
|
29
|
+
const oldestKey = map.keys().next().value
|
|
30
|
+
const entry = map.get(oldestKey)
|
|
31
|
+
if (!entry || t - entry.startedAt < baseTimeoutMs) break
|
|
32
|
+
map.delete(oldestKey)
|
|
33
|
+
entry.cancel()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} key 逻辑键
|
|
39
|
+
* @param {{ done: Promise<T>, cancel: () => void, startedAt: number }} entry 条目
|
|
40
|
+
* @returns {void}
|
|
41
|
+
*/
|
|
42
|
+
function track(key, entry) {
|
|
43
|
+
entry.done.finally(() => {
|
|
44
|
+
if (map.get(key) === entry) map.delete(key)
|
|
45
|
+
})
|
|
46
|
+
map.set(key, entry)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
/**
|
|
51
|
+
* @returns {number} 当前 in-flight 数
|
|
52
|
+
*/
|
|
53
|
+
size: () => map.size,
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} key 逻辑键
|
|
56
|
+
* @returns {boolean} 是否在飞
|
|
57
|
+
*/
|
|
58
|
+
has: key => map.has(key),
|
|
59
|
+
/**
|
|
60
|
+
* 复用或启动;队满且无法淘汰超时项时返回 null(拒绝新开)。
|
|
61
|
+
* @param {string} key 逻辑键
|
|
62
|
+
* @param {() => { done: Promise<T>, cancel: () => void }} start 仅在未命中时调用
|
|
63
|
+
* @returns {Promise<T> | null} 共享 Promise,或拒绝新开
|
|
64
|
+
*/
|
|
65
|
+
acquire(key, start) {
|
|
66
|
+
const existing = map.get(key)
|
|
67
|
+
if (existing) {
|
|
68
|
+
map.delete(key)
|
|
69
|
+
map.set(key, existing)
|
|
70
|
+
pruneAgedOverCap()
|
|
71
|
+
return existing.done
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pruneAgedOverCap()
|
|
75
|
+
if (map.size >= maxSize) return null
|
|
76
|
+
|
|
77
|
+
const started = start()
|
|
78
|
+
const entry = {
|
|
79
|
+
done: started.done,
|
|
80
|
+
cancel: started.cancel,
|
|
81
|
+
startedAt: now(),
|
|
82
|
+
}
|
|
83
|
+
track(key, entry)
|
|
84
|
+
return entry.done
|
|
85
|
+
},
|
|
86
|
+
/**
|
|
87
|
+
* 取消全部并清空(测试用)。
|
|
88
|
+
* @returns {void}
|
|
89
|
+
*/
|
|
90
|
+
clear() {
|
|
91
|
+
for (const entry of map.values()) entry.cancel()
|
|
92
|
+
map.clear()
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
package/utils/json_io.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import fs from 'node:fs'
|
|
|
2
2
|
import fsp from 'node:fs/promises'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
|
|
5
|
+
import { atomicTemporaryPath, finalizeAtomicRename, finalizeAtomicRenameSync } from './atomic_fs.mjs'
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* @param {string} filePath 绝对路径
|
|
7
9
|
* @returns {Promise<object | null>} JSON 或 null
|
|
@@ -24,9 +26,10 @@ export async function readJsonFile(filePath) {
|
|
|
24
26
|
*/
|
|
25
27
|
export async function writeJsonFile(filePath, data) {
|
|
26
28
|
await fsp.mkdir(path.dirname(filePath), { recursive: true })
|
|
27
|
-
const temporaryPath =
|
|
29
|
+
const temporaryPath = atomicTemporaryPath(filePath)
|
|
28
30
|
await fsp.writeFile(temporaryPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
|
|
29
|
-
await
|
|
31
|
+
if (!await finalizeAtomicRename(temporaryPath, filePath))
|
|
32
|
+
throw Object.assign(new Error(`ENOENT: atomic rename failed for ${filePath}`), { code: 'ENOENT' })
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
/**
|
|
@@ -51,7 +54,8 @@ export function readJsonFileSync(filePath) {
|
|
|
51
54
|
*/
|
|
52
55
|
export function writeJsonFileSync(filePath, data) {
|
|
53
56
|
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
54
|
-
const temporaryPath =
|
|
57
|
+
const temporaryPath = atomicTemporaryPath(filePath)
|
|
55
58
|
fs.writeFileSync(temporaryPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
|
|
56
|
-
|
|
59
|
+
if (!finalizeAtomicRenameSync(temporaryPath, filePath))
|
|
60
|
+
throw Object.assign(new Error(`ENOENT: atomic rename failed for ${filePath}`), { code: 'ENOENT' })
|
|
57
61
|
}
|