the .part-then-rename rule was applied at two upload routes yesterday and by this morning the platform had a dozen other writes putting files in the same folders — an OCR result beside its image, a TTS clip, a download landing, a save from the editor — every one straight to the final name. a guarantee that holds for two of fourteen paths is not a guarantee, and the next person adding a fifteenth had nothing to copy. so it is a function now, and every durable write goes through it: the file browser, sendoff, task definitions, skill and tool files, bug reports, scraped attachments, the chat provider config. the failure it prevents is silent rather than loud. a truncated m4a decodes fine, it is simply short — a commander transcribing recordings as they land would produce a clean transcript of the first few seconds of a two-minute memo and report success. two things learned writing it. Bun.write creates the parent chain itself, so the mkdir option i added was dead weight and several callers were doing it twice. and Parameters<typeof Bun.write>[1] resolves to the first overload only — BunFile — which rejects the string and Buffer every caller actually passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
82 lines
2.8 KiB
TypeScript
82 lines
2.8 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { writeAtomic } from './write-atomic';
|
|
|
|
/*
|
|
The property: the final name never holds a partial file.
|
|
|
|
A test cannot observe the instant between two syscalls, so what is pinned here is the shape that makes
|
|
the property true — the data goes to `<name>.part` first, the final name only ever appears complete,
|
|
and a failure leaves no fragment under either name.
|
|
*/
|
|
|
|
const dir = () => mkdtempSync(join(tmpdir(), 'zz-atomic-'));
|
|
|
|
describe('writeAtomic', () => {
|
|
test('the file ends up at the final name, complete', async () => {
|
|
const d = dir();
|
|
try {
|
|
const path = join(d, 'memo.md');
|
|
await writeAtomic(path, 'hello');
|
|
expect(readFileSync(path, 'utf8')).toBe('hello');
|
|
expect(existsSync(`${path}.part`)).toBe(false);
|
|
} finally {
|
|
rmSync(d, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('it overwrites an existing file rather than refusing', async () => {
|
|
const d = dir();
|
|
try {
|
|
const path = join(d, 'memo.md');
|
|
writeFileSync(path, 'old');
|
|
await writeAtomic(path, 'new');
|
|
expect(readFileSync(path, 'utf8')).toBe('new');
|
|
} finally {
|
|
rmSync(d, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// A failed write must not leave a `.part` behind — only an interrupted PROCESS should, which is the
|
|
// case worth being able to see in the folder.
|
|
test('a failed write leaves nothing under either name', async () => {
|
|
const d = dir();
|
|
try {
|
|
// The parent is a FILE, so neither the .part write nor the rename can succeed.
|
|
writeFileSync(join(d, 'blocker'), 'x');
|
|
const path = join(d, 'blocker', 'memo.md');
|
|
await expect(writeAtomic(path, 'x')).rejects.toThrow();
|
|
expect(existsSync(`${path}.part`)).toBe(false);
|
|
expect(existsSync(path)).toBe(false);
|
|
} finally {
|
|
rmSync(d, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Bun.write creates the parent chain itself, which is why this takes no mkdir option and why the
|
|
// callers it replaced could drop theirs.
|
|
test('a missing directory is created, not an error', async () => {
|
|
const d = dir();
|
|
try {
|
|
const path = join(d, 'nested', 'deep', 'memo.md');
|
|
await writeAtomic(path, 'x');
|
|
expect(readFileSync(path, 'utf8')).toBe('x');
|
|
} finally {
|
|
rmSync(d, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('a Blob writes the same way as a string', async () => {
|
|
const d = dir();
|
|
try {
|
|
const path = join(d, 'blob.bin');
|
|
await writeAtomic(path, new Blob([new Uint8Array([1, 2, 3])]));
|
|
expect([...readFileSync(path)]).toEqual([1, 2, 3]);
|
|
} finally {
|
|
rmSync(d, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|