Skip to content

Commit cba158f

Browse files
committed
fix(storage): write atomically via temp+rename and report write failures
1 parent ed8a66d commit cba158f

3 files changed

Lines changed: 78 additions & 2 deletions

File tree

packages/devframe/src/node/__tests__/storage.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,67 @@ describe('createStorage', () => {
4040
fs.rmSync(dir, { recursive: true, force: true })
4141
}
4242
})
43+
44+
it('writes atomically via temp file + rename', async () => {
45+
const dir = fs.mkdtempSync(join(os.tmpdir(), 'devframe-storage-'))
46+
const filepath = join(dir, 'state.json')
47+
48+
try {
49+
const state = createStorage({
50+
filepath,
51+
initialValue: { count: 1 },
52+
debounce: 0,
53+
})
54+
55+
state.mutate((draft) => {
56+
draft.count = 2
57+
})
58+
59+
await wait(20)
60+
61+
// no leftover temp file
62+
const entries = fs.readdirSync(dir)
63+
expect(entries).toEqual(['state.json'])
64+
65+
const saved = JSON.parse(fs.readFileSync(filepath, 'utf-8'))
66+
expect(saved).toEqual({ count: 2 })
67+
}
68+
finally {
69+
fs.rmSync(dir, { recursive: true, force: true })
70+
}
71+
})
72+
73+
it('reports a write failure instead of throwing', async () => {
74+
const dir = fs.mkdtempSync(join(os.tmpdir(), 'devframe-storage-'))
75+
const filepath = join(dir, 'state.json')
76+
77+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
78+
const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation(() => {
79+
throw new Error('EACCES: permission denied')
80+
})
81+
82+
try {
83+
const state = createStorage({
84+
filepath,
85+
initialValue: { count: 1 },
86+
debounce: 0,
87+
})
88+
89+
expect(() => {
90+
state.mutate((draft) => {
91+
draft.count = 2
92+
})
93+
}).not.toThrow()
94+
95+
await wait(20)
96+
97+
expect(errorSpy).toHaveBeenCalled()
98+
expect(fs.existsSync(filepath)).toBe(false)
99+
}
100+
finally {
101+
renameSpy.mockRestore()
102+
errorSpy.mockRestore()
103+
fs.rmSync(dir, { recursive: true, force: true })
104+
}
105+
})
43106
})

packages/devframe/src/node/diagnostics.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,9 @@ export const diagnostics = defineDiagnostics({
6464
`Scoped RPC registration for namespace "${p.namespace}" received an already-namespaced function name "${p.name}".`,
6565
fix: 'A scoped context auto-namespaces ids. Pass a bare name without a ":" separator (e.g. `register({ name: "get-cwd" })`), or use the unscoped `ctx.base.rpc.register` for a fully-qualified name.',
6666
},
67+
DF0035: {
68+
why: (p: { filepath: string }) => `Failed to persist storage file: ${p.filepath}`,
69+
fix: 'Check that the storage directory is writable and has free space.',
70+
},
6771
},
6872
})

packages/devframe/src/node/storage.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from 'node:fs'
2+
import process from 'node:process'
23
import { destr } from 'destr'
34
import { createSharedState } from 'devframe/utils/shared-state'
45
import { dirname } from 'pathe'
@@ -39,8 +40,16 @@ export function createStorage<T extends object>(options: CreateStorageOptions<T>
3940
state.on(
4041
'updated',
4142
debounce((newState) => {
42-
fs.mkdirSync(dirname(options.filepath), { recursive: true })
43-
fs.writeFileSync(options.filepath, `${JSON.stringify(newState, null, 2)}\n`)
43+
try {
44+
const dir = dirname(options.filepath)
45+
fs.mkdirSync(dir, { recursive: true })
46+
const tmp = `${options.filepath}.${process.pid}.tmp`
47+
fs.writeFileSync(tmp, `${JSON.stringify(newState, null, 2)}\n`)
48+
fs.renameSync(tmp, options.filepath) // atomic replace on same filesystem
49+
}
50+
catch (error) {
51+
diagnostics.DF0035({ filepath: options.filepath, cause: error }, { method: 'error' })
52+
}
4453
}, debounceTime),
4554
)
4655

0 commit comments

Comments
 (0)