Describe the bug
A checklist (or note) can be silently replaced by an empty stub containing only a freshly generated uuid — no title, no checklistType, no items. The list still exists in the UI, but it is empty, and the original content is gone from disk with no error logged.
This happened twice to one list on my instance. Both times it followed ticking an item off.
Root cause
Three behaviours combine:
1. serverWriteFile is not atomic — app/_server/actions/file/index.ts
export const serverWriteFile = async (filePath: string, content: string) => {
await ensureDir(path.dirname(filePath));
await fs.writeFile(filePath, content, "utf-8");
};
fs.writeFile opens with O_TRUNC, so the file is zero bytes from the truncate until the write completes.
2. serverReadFile converts every failure into an empty string — same file
export const serverReadFile = async (filePath, customReturn?): Promise<string> => {
try {
return (await fs.readFile(filePath, "utf-8")) || "";
} catch (error) {
return customReturn || "";
}
};
A read that lands in the truncation window is indistinguishable from a missing file.
3. The readers self-heal a missing uuid by writing it back — app/_server/actions/checklist/readers.ts (and note/readers.ts)
const content = await serverReadFile(filePath);
if (isRaw) {
const { metadata } = extractYamlMetadata(content);
let uuid = metadata.uuid;
if (!uuid) {
uuid = generateUuid();
const updatedContent = updateYamlMetadata(content, { uuid });
await serverWriteFile(filePath, updatedContent); // ← writes the empty read back
}
Given content === "", extractYamlMetadata returns {}, so there is no uuid, so a new one is minted and updateYamlMetadata("", { uuid }) produces ---\nuuid: <new>\n---\n — which is then written over the real file.
What closes the loop: updateItem calls broadcast({ type: "checklist", action: "updated", ... }) immediately after serverWriteFile. Every connected client re-reads the list at once, and queries.ts does that with isRaw: true — the exact branch that performs the self-heal write. Ticking a checkbox fires the write that races the reads it triggers. More clients or devices open on the list widens the window.
Evidence
The damaged file was exactly 51 bytes:
---
uuid: 90bebaec-330a-43ab-a96e-7247063a9757
---
Reconstructing what this path emits from an empty read gives 51 bytes, byte-for-byte identical.
listToMarkdown — the normal serializer — cannot produce that file:
metadata.title = list.title || "Untitled Checklist"; // always emits a title
...
if (list.items.length === 0) return frontmatter.trim(); // no trailing newline
It always writes a title: line, and it trims for an empty list, which would give 50 bytes with no trailing newline. The damaged file had the trailing newline and no title at all. Only the updateYamlMetadata("", { uuid }) path matches.
The uuid also changed, which an update path would never do — confirming the file was rewritten as a brand-new object rather than modified.
To Reproduce
Timing-dependent, so it is intermittent by nature:
- Open the same checklist in two or more clients (two browsers, or phone + desktop).
- Tick items off in fairly quick succession.
- Occasionally the write and the broadcast-triggered re-read overlap, and the list is replaced by a uuid-only stub.
Expected behavior
A read failure should never cause a write. A concurrent reader should never observe a partially written file.
Suggested fix
Two small changes, either of which alone prevents the loss; together they are belt and braces.
Make the write atomic — write to a temp file and rename(2) it into place. rename is atomic within a filesystem, so a reader sees either the old file or the new one:
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
let mode: number | undefined;
try { mode = (await fs.stat(filePath)).mode & 0o777; } catch { /* new file */ }
try {
await fs.writeFile(tmp, content, "utf-8");
if (mode !== undefined) await fs.chmod(tmp, mode);
await fs.rename(tmp, filePath);
} catch (error) {
try { await fs.unlink(tmp); } catch {}
throw error;
}
Guard the self-heal in both checklist/readers.ts and note/readers.ts:
if (!uuid && content.trim().length > 0) {
It may also be worth reconsidering serverReadFile swallowing real errors — "file is missing" and "the read failed" are very different facts, and collapsing them is what makes this reachable.
Version
Reproduced on 1.20.0. Verified still present on main at 1.27.0 — serverReadFile and serverWriteFile are unchanged, and the self-heal write is at checklist/readers.ts:245.
Additional context
I am running the two fixes above as a local patch and have had no recurrence. Happy to open a PR if useful.
Describe the bug
A checklist (or note) can be silently replaced by an empty stub containing only a freshly generated
uuid— notitle, nochecklistType, no items. The list still exists in the UI, but it is empty, and the original content is gone from disk with no error logged.This happened twice to one list on my instance. Both times it followed ticking an item off.
Root cause
Three behaviours combine:
1.
serverWriteFileis not atomic —app/_server/actions/file/index.tsfs.writeFileopens withO_TRUNC, so the file is zero bytes from the truncate until the write completes.2.
serverReadFileconverts every failure into an empty string — same fileA read that lands in the truncation window is indistinguishable from a missing file.
3. The readers self-heal a missing uuid by writing it back —
app/_server/actions/checklist/readers.ts(andnote/readers.ts)Given
content === "",extractYamlMetadatareturns{}, so there is no uuid, so a new one is minted andupdateYamlMetadata("", { uuid })produces---\nuuid: <new>\n---\n— which is then written over the real file.What closes the loop:
updateItemcallsbroadcast({ type: "checklist", action: "updated", ... })immediately afterserverWriteFile. Every connected client re-reads the list at once, andqueries.tsdoes that withisRaw: true— the exact branch that performs the self-heal write. Ticking a checkbox fires the write that races the reads it triggers. More clients or devices open on the list widens the window.Evidence
The damaged file was exactly 51 bytes:
Reconstructing what this path emits from an empty read gives 51 bytes, byte-for-byte identical.
listToMarkdown— the normal serializer — cannot produce that file:It always writes a
title:line, and it trims for an empty list, which would give 50 bytes with no trailing newline. The damaged file had the trailing newline and no title at all. Only theupdateYamlMetadata("", { uuid })path matches.The uuid also changed, which an update path would never do — confirming the file was rewritten as a brand-new object rather than modified.
To Reproduce
Timing-dependent, so it is intermittent by nature:
Expected behavior
A read failure should never cause a write. A concurrent reader should never observe a partially written file.
Suggested fix
Two small changes, either of which alone prevents the loss; together they are belt and braces.
Make the write atomic — write to a temp file and
rename(2)it into place.renameis atomic within a filesystem, so a reader sees either the old file or the new one:Guard the self-heal in both
checklist/readers.tsandnote/readers.ts:It may also be worth reconsidering
serverReadFileswallowing real errors — "file is missing" and "the read failed" are very different facts, and collapsing them is what makes this reachable.Version
Reproduced on 1.20.0. Verified still present on
mainat 1.27.0 —serverReadFileandserverWriteFileare unchanged, and the self-heal write is atchecklist/readers.ts:245.Additional context
I am running the two fixes above as a local patch and have had no recurrence. Happy to open a PR if useful.