Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com> Signed-off-by: eurfelux <eurfelux@gmail.com>
94 KiB
FileManager Architecture
SoT scope — this document owns: FileEntry / FileRef data models, physical storage layout, version detection & concurrency control (OCC), atomic writes, recycle bin, reference cleanup, DirectoryWatcher internals, on-demand orphan sweep, DanglingCache state machine, and AI SDK integration design. Module-level concerns (type system, IPC / DataApi contracts, layered architecture, business-service integration, lifecycle assignment) live in
architecture.md. In case of conflict, the layer ownership above decides: positioning / contract → the module-level doc, implementation → this document.When a section describes a behavior (dispatch, OCC, atomic writes, orphan sweep, etc.), read it as the specification the implementation must satisfy. Sections explicitly tagged "(deferred)" describe planned architecture that is not yet implemented.
1. Core Concepts
1.0 Management Principle
FileManager provides file management capabilities for two origins; callers choose based on their own needs:
internal: Cherry owns the file content, stored at{userData}/Data/Files/{id}.{ext}. The caller hands the source content to FileManager, which copies it and takes over the lifecycle.external: Cherry only records an absolute path reference on the user's side; does not copy content. File availability and content changes are determined by the user side.
The caller decides the origin; FileManager makes no assumptions about the business layer.
Best-effort semantics for external: an external entry is a persistent record that "the caller expressed intent to reference this path at some point in time"—no guarantee the file remains stable, no guarantee content matches the reference-time content. Cherry does no bidirectional DB-FS sync, doesn't track external rename/move; external changes naturally surface as "reading new content next time" or "dangling".
Data categories that do not enter FileManager (auto-derived data, logs, Agent workspace, OCR intermediates, MCP config, files self-managed by FS-first modules, etc.) are detailed in architecture.md §1.3.
1.1 FileEntry
Each FileEntry corresponds to a file the user uploaded/saved. FileEntry is a flat individual record—no directory tree, no parent-child relationship, no mount concept.
FileEntry
├── id: UUID (v7 for v2-native entries; v4 preserved from v1 Dexie migration)
├── origin: 'internal' | 'external'
├── name: filename (without extension)
├── ext: extension (without leading dot), nullable
├── size: bytes
├── externalPath: absolute path, non-null only when origin='external'
├── deletedAt: ms epoch | null
├── createdAt / updatedAt
1.2 Origin: internal vs external
The origin field of each FileEntry defines content ownership:
| origin | Physical location | Ownership | Mutability |
|---|---|---|---|
internal |
{userData}/Data/Files/{id}.{ext} |
Fully owned by Cherry | Read-write |
external |
Absolute path pointed to by externalPath |
Owned by user, referenced by Cherry | Changeable by explicit user action (write / rename / permanentDelete apply, delegated to the FS primitives); Cherry does no automatic/watcher-driven modifications; does not track external rename/move—external changes cause the entry to naturally go dangling |
Path uniqueness: at most one entry can exist whose externalPath agrees with another under case folding. Implemented via SQLite functional unique index:
CREATE UNIQUE INDEX fe_external_path_lower_unique_idx
ON file_entry (lower(external_path));
fe_external_path_idx (plain index on the raw external_path) backs byte-exact lookups (findByExternalPath, rename re-finds, path-resolution call sites). The functional index simultaneously serves the case-insensitive lookup path (WHERE lower(externalPath) = lower(?)) used by findCaseInsensitivePeers and enforces the uniqueness invariant — ensureExternalEntry MUST resolve case-collisions at the application layer before INSERT (see "Duplicate-entry detection on insert" below) because a DB-level rejection would otherwise surface as an opaque SQLITE_CONSTRAINT. Internal rows (externalPath = NULL) are exempt — SQLite treats multiple NULLs as distinct in a UNIQUE index.
Canonical invariant of externalPath: SQLite performs byte-level comparison on the raw externalPath column and cannot natively detect NFC ≡ NFD (Unicode). The functional index above handles case folding via lower() but does not apply Unicode normalization, so externalPath must be normalized via canonicalizeExternalPath(raw) before persistence—this is an application-layer invariant, with ensureExternalEntry and fileEntryService.findByExternalPath as mandatory call sites.
Compile-time enforcement via CanonicalExternalPath brand: canonicalizeExternalPath() returns a branded CanonicalExternalPath (TS phantom type, zero runtime cost; see src/shared/data/types/file/fileEntry.ts). Every DB read/write surface that filters by externalPath — today findByExternalPath, and any future DataApi endpoint or repository method — MUST accept this type, not a plain string. The type system then guarantees callers routed their input through the normalization function, eliminating the "forgot to canonicalize" class of bug that would silently miss all matches.
| Source | Natively canonical | Relies on normalization to disambiguate |
|---|---|---|
Electron showOpenDialog |
✅ (OS returns the on-disk true case) | None |
| Drag-drop from Finder/Explorer | ✅ (OS drag source) | None |
User-typed @/path/... / clipboard paste |
❌ | Risk of case / NFD/NFC |
| External URL scheme / shell integration | ❌ | Same as above |
| v1 migration (inherits Dexie stored values) | ❌ (inherits legacy value quality) | Canonicalize once during migration |
Normalization scope (synchronous, no FS IO):
- Null-byte rejection —
raw.includes('\0')→ throw, so poisoned paths never reach DB persistence (reject at the earliest boundary, not at use-time insideresolvePhysicalPath) path.resolve(raw)→ absolutize + eliminate./../.normalize('NFC')→ Unicode normalization (closes the NFD/NFC window for macOS CJK)- Trailing separator trimming
Intentionally omitted (deferred until concrete user feedback warrants the cost):
fs.realpathas a step insidecanonicalizeExternalPathitself (would require async FS IO at every canonicalization call site and a file-existence precondition).fs.realpathIS used on theensureExternalEntrycollision path described below — that is a per-collision probe, not a per-canonicalize step.- Symlink target merging at canonicalize time
- Windows 8.3 short-name resolution
See the JSDoc for canonicalizeExternalPath in src/main/services/file/utils/pathResolver.ts for the detailed contract.
Rule evolution discipline
Because the canonical form is application-layer logic, not DB schema, any change to canonicalizeExternalPath's normalization steps desynchronizes historical rows (written under the old rule) from new queries (running under the new rule). This produces a silent failure mode: byte-compare misses, the user sees "my file is in the library but the app says it isn't", and ensureExternalEntry inserts a duplicate.
Rule: modifying canonicalizeExternalPath ≡ ship a paired Drizzle migration that re-canonicalizes every existing file_entry row with origin='external' in the same PR. No exceptions — even if the new rule is claimed "strictly more permissive", the byte-compare will still miss.
When a rule change additionally collapses previously-distinct strings to the same canonical form (e.g. adding fs.realpath merges APFS case-insensitive duplicates), the migration MUST also merge the colliding rows. The rules below are prescriptive; follow them exactly rather than improvising per-migration.
Winner selection when merging rows:
- Oldest
createdAtwins (preserves user-visible history — a 3-year-old entry's creation timestamp is more valuable than a 3-day-old one's). - Tiebreaker: highest ref count (keeps the entry that more of the user's data already points at).
- Final tiebreaker: smallest
idby lexicographic order (deterministic, no FS-state dependency).
Losers' dependents (executed in the same Drizzle transaction as the merge):
- Association rows with
fileEntryId = loser.id→ update towinner.id. No deduplication inside each table'sUNIQUE(fileEntryId, sourceId, role)constraint is expected because each(sourceId, role)pair originally referenced only one entry; if violations occur, the update conflicts and the migration fails loudly (do not silentlyON CONFLICT DO NOTHING— investigate). file_entry.id = loser.id→ delete.- Any downstream consumer of
loser.id(futurefile_upload.fileEntryId, business-service caches keyed by entryId) MUST be enumerated and updated in the same migration. If you add a new table that referencesfile_entry.id, the canonicalization migration procedure expands — document the expansion alongside the table's schema.
Atomicity: the entire re-canonicalize + merge operation runs in one Drizzle migration transaction. On failure the DB rolls back to the pre-migration state and the next startup re-attempts; partial progress is not possible.
Renderer-side cache invalidation: after the migration runs, some React Query caches keyed by the loser's id may be stale. Because migrations execute before the renderer boots, this is self-healing on the first query — no special coordination required.
Duplicate-entry detection on insert
Case-insensitive uniqueness on externalPath is enforced at both layers: the functional UNIQUE index fe_external_path_lower_unique_idx (DB) and ensureExternalEntry's pre-INSERT collision check (application). The two-layer scheme keeps the DB-level guarantee unbreakable while letting the application disambiguate the FS-correct interpretation case-by-case.
// Inside ensureExternalEntry, AFTER canonicalize, AFTER findByExternalPath miss,
// AFTER fs.stat verifies the new path exists, BEFORE INSERT:
const peers = await fileEntryService.findCaseInsensitivePeers(canonicalPath)
if (peers.length > 0) {
// `fs.realpath` is the platform-correct probe for "are these the same FS
// entity": on case-insensitive volumes (macOS APFS default, Windows NTFS
// default) the FS folds case to its on-disk canonical form, so two case-
// different inputs resolve to the same string. On case-sensitive volumes
// (Linux ext4, case-sensitive APFS) they resolve to distinct strings.
const reusable = await resolveCaseCollisionPeer(canonicalPath, peers)
if (reusable) return reusable // same FS entity → reuse existing entry
// No peer is the same FS entity. The DB unique constraint will reject the
// INSERT, but we throw early with a descriptive error and full peer detail
// so the caller can decide (rename one of the colliding paths, or surface
// the conflict to the user) instead of seeing an opaque SQLITE_CONSTRAINT.
throw new Error(`ensureExternal: case-collision with existing entries…`)
}
// No peers → safe to INSERT; DB unique constraint is now a redundant safety net.
Behavioral matrix (/foo/A.txt already an entry; user invokes ensureExternalEntry('/foo/a.txt')):
| Filesystem class | fs.realpath('/foo/A.txt') |
fs.realpath('/foo/a.txt') |
Outcome |
|---|---|---|---|
| Case-insensitive (macOS APFS default, NTFS default) | /foo/A.txt |
/foo/A.txt (FS folds) |
Same string → reuse existing entry |
| Case-sensitive (Linux ext4, case-sensitive APFS) | /foo/A.txt |
/foo/a.txt |
Distinct strings → throw case-collision |
Dangling peer (/foo/A.txt missing on disk) |
ENOENT |
/foo/a.txt |
Cannot disambiguate → throw case-collision (caller must permanentDelete the dangling row first) |
Scope:
- Runs only on the insert branch; reuse / update / read branches never invoke peer detection.
- The lookup is O(log N) (index-backed), so the previous "best-effort, skip above 10k rows" heuristic is removed —
findCaseInsensitivePeersnow runs unconditionally regardless of table size. - The
fs.realpathcall resolves symlinks too, which is the right semantic for "same logical file"; symlink targets are intentionally NOT canonicalized at storage time (see "Intentionally omitted" above), so two symlinks pointing at the same target each get their own entry, but a case-different reference to one of those entries reuses it.
This subsumes the fs.realpath upgrade that earlier revisions of this section described as "deferred until user feedback" — the same probe is applied at exactly the moment it matters (collision resolution) without paying the FS IO cost on every canonicalize call.
Invariants:
| Field | origin='internal' | origin='external' |
|---|---|---|
name |
SoT (user can rename actively) | Pure projection of externalPath (basename) |
ext |
SoT | Pure projection of externalPath (extname) |
size |
SoT (non-null, ≥ 0) | Always null — no DB snapshot; live value via getMetadata |
externalPath |
NULL | Absolute path (the authoritative identity of external) |
For external entries the row stores only identity + stable projections. name / ext do not drift because externalPath is fixed for the lifetime of the entry (external rename by the user surfaces as a dangling entry, not an in-place rewrite of name). size / mtime are served live by File IPC getMetadata(id) on demand — see §3 External Entry Liveness Model.
1.3 FileRef (Business Reference)
Business objects associate with FileEntry through source-owned ref tables plus a shared FileRef projection:
chat_message_file_ref / painting_file_ref / ...
├── fileEntryId → FileEntry (FK, CASCADE delete)
├── sourceId → owning source row (FK, CASCADE delete)
├── role: business-semantic reference role (defined by the source module)
└── UNIQUE(fileEntryId, sourceId, role)
FileRefService aggregates these source-owned tables into the shared FileRef discriminated union for DataApi reads, ref counts, and sweep reporting. It does not own persistent ref writes. The only mutable refs stored by FileRefService are temp_session refs, backed by main-process CacheService memory.
When a persistent business object is deleted, SQLite FK cascade removes its association rows. Relationship replacement (for example, replacing a painting's complete file set) is handled directly by the owning business service.
1.4 FileHandle / FileInfo — see architecture.md §2
FileHandle (polymorphic reference crossing IPC), FileInfo (path-indexed data shape), and the full reference-vs-data-shape symmetry are defined at the module-level architecture document, not here. This document concerns FileManager's internal implementation only.
FileHandle(tagged union / factories / dispatch):architecture.md §2.2FileEntryvsFileInfo(semantic comparison / field invariants / projection rules):architecture.md §2.3- Signature selection guide & anti-patterns:
architecture.md §2.4
Method applicability inside FileManager:
| Category | Methods |
|---|---|
Accept FileHandle (entry + path branches via IPC dispatch) |
read / getMetadata / getVersion / getContentHash / write / writeIfUnchanged / rename / permanentDelete / copy / open / showInFolder |
Accept FileEntryId only (entry-identity operations; no path-handle counterpart) |
trash / restore / createInternalEntry / ensureExternalEntry / withTempCopy |
1.5 FileUpload (AI Provider Upload Cache) — deferred
AI SDK SharedV4ProviderReference integration and the file_upload table are deferred until the Vercel AI SDK Files API stabilises. The module-level DataApi surface (ensureUploaded / buildProviderReference / invalidate) is outlined in architecture.md §3.5; the detailed schema and FileUploadService API are retained here in §9 AI SDK Integration for the eventual landing PR.
1.6 FileManager Implementation Layout (Facade + Private Internals)
FileManager is the sole public entry point of the FileEntry management system but is not a 30-method God class. The implementation uses a facade + private pure-function modules pattern. (Note: "sole public entry" is scoped to FileEntry — the file module also hosts DirectoryTreeBuilder as a parallel top-level primitive for in-memory tree mirrors; see architecture.md §1.2 and directory-tree.md.)
1.6.1 Why It Can Be Split
A method-by-method audit of FileManager's public API for "does it depend on class instance state" concludes: the vast majority of methods do not depend on instance state.
| State | Users | Ownership |
|---|---|---|
versionCache (LRU) |
write / writeIfUnchanged / getVersion |
class private field (held by FileManager instance) |
fileEntryService / fileRefService |
All DB operations | container singleton (application.get(...)) |
danglingCache |
External-related methods | file-module singleton (module import) |
@main/utils/file/* |
All FS operations | pure functions, stateless |
| IPC handler registration handles | lifecycle | managed by onInit / onStop |
Only versionCache and lifecycle artifacts are truly bound to the FileManager instance; business methods themselves are stateless.
1.6.2 Module Layout
src/main/services/file/
├── index.ts ← barrel: exports only FileManager + public types
├── FileManager.ts ← facade class; lifecycle + IPC + versionCache + inline getMetadata
├── internal/ ← private implementation (not re-exported by index.ts; external imports forbidden)
│ ├── deps.ts — FileManagerDeps type
│ ├── dispatch.ts — FileHandle.kind dispatch helper (entry vs path adapter)
│ ├── entry/
│ │ ├── create.ts — createInternal / ensureExternal
│ │ ├── lifecycle.ts — trash / restore / permanentDelete + batches
│ │ ├── rename.ts
│ │ └── copy.ts
│ ├── content/
│ │ ├── read.ts — read / createReadStream (including `readByPath` variants)
│ │ ├── write.ts — write / writeIfUnchanged / createWriteStream
│ │ └── hash.ts — getContentHash / getVersion
│ ├── system/
│ │ ├── shell.ts — open / showInFolder
│ │ └── tempCopy.ts — withTempCopy
│ └── orphanSweep.ts — temp-session ref prune + FS-level orphan sweep
└── versionCache.ts ← LRU type definition
getMetadata is the one entry-level read that does NOT live under
internal/entry/ — it is implemented inline on the FileManager class
because it is a thin wrapper around fs.stat with no entry-flow logic
of its own. Adding a future entry-flow concern (e.g. presence event
emission on success) would justify extracting it, but until then the
inline definition keeps the facade's stat path single-hop.
1.6.3 Dependency Passing Convention
Each internal/* pure function explicitly receives FileManagerDeps:
// internal/deps.ts (illustrative — see src/main/services/file/internal/deps.ts for the authoritative definition)
export interface FileManagerDeps {
readonly fileEntryService: FileEntryService
readonly fileRefService: FileRefService
readonly danglingCache: DanglingCache
readonly versionCache: VersionCache
}
// internal/entry/create.ts — two APIs, corresponding to two public methods on the FileManager facade
// Note: CreateInternalEntryParams is a source-discriminated union
// (source: 'path' | 'url' | 'base64' | 'bytes'); each branch only exposes content
// that name/ext cannot be derived from. Full matrix in `src/shared/file/types/ipc.ts`.
export async function createInternalEntry(
deps: FileManagerDeps,
params: CreateInternalEntryParams
): Promise<FileEntry> {
// Extract { name, ext, bytes } by source branch → write physical file → DB insert; always produces a new entry
}
export async function ensureExternalEntry(
deps: FileManagerDeps,
params: EnsureExternalEntryParams
): Promise<FileEntry> {
// Upsert by externalPath: reuse the existing row or insert a new one
// (external entries cannot be trashed, so there is no restore branch)
}
1.6.4 Thin-Delegation Facade
// FileManager.ts (illustrative — see src/main/services/file/FileManager.ts for the authoritative wiring)
@Injectable('FileManager')
@ServicePhase(Phase.WhenReady)
export class FileManager extends BaseService implements IFileManager {
private readonly _versionCache = createVersionCacheImpl(2000)
private readonly deps: FileManagerDeps = {
fileEntryService,
fileRefService,
danglingCache,
versionCache: this._versionCache
}
// Public API: thin delegates. Internal modules export entry-flavoured
// functions directly (no `*ByEntry` suffix — see §1.6.5); `*ByPath`
// siblings exist only on the path branch and are NOT exposed here.
createInternalEntry(params) { return createInternal(this.deps, params) }
ensureExternalEntry(params) { return ensureExternal(this.deps, params) }
read(id, opts?) { return read(this.deps, id, opts) }
trash(id) { return trash(this.deps, id) }
// ... one line per method
protected async onInit() {
await this.deps.danglingCache.initFromDb()
this.registerIpcHandlers()
// No auto-sweep at startup; an explicit cleanup UI/caller triggers `runSweep` via IPC.
}
}
1.6.5 FileHandle Dispatch Convention (Adapter Responsibility at the IPC Boundary)
Dispatch location: FileHandle.kind dispatch stays at the IPC handler registration site. Rationale:
FileHandleis the input shape at the IPC serialization layer—the renderer sends a{ kind, ... }tagged union, and post-deserialization kind-dispatch is a "request interpretation" concern—it is the proper responsibility of the IPC adapter layer- FileManager's public API remains entry-native (accepts only
FileEntryId); main-side business service calls are intuitive without needing acreateFileEntryHandle(id)wrapper - The
FilePathHandlebranch only needs the IPC handler; main-side business services hold FileEntries—they have no arbitrary-path scenario
Internal module convention: each action file exposes consistently named variants by kind:
// internal/content/read.ts
export async function read(deps, entryId, opts): Promise<ReadResult<T>> // serves FileManager public API (entry-flavoured)
export async function readByPath(deps, path, opts): Promise<ReadResult<T>> // serves the path-handle branch of the IPC handler
// future: export async function readVirtual(deps, handle, opts)
Naming convention (per the shipped exports): entry-flavoured variants
use the bare verb (read, createInternal, ensureExternal, trash,
copy, rename, …); path-flavoured siblings carry the *ByPath suffix.
The bare entry variant is what FileManager's public method delegates to;
*ByPath (and future *Virtual) do not flow through FileManager's
public methods — they serve the path-handle branch of the IPC handler
only. The previous draft of this section used a *ByEntry suffix on the
entry variants, but no shipped export follows that pattern; the docs are
updated to match the code, not the other way around.
Unified style for dispatch helper: to prevent "every IPC method writing its own if-else" noise, FileManager provides a small internal helper:
// FileManager.ts (private)
private dispatchHandle<T>(
handle: FileHandle,
byEntry: (entryId: FileEntryId) => Promise<T>,
byPath: (path: FilePath) => Promise<T>
): Promise<T> {
switch (handle.kind) {
case 'entry': return byEntry(handle.entryId)
case 'path': return byPath(handle.path)
}
}
private registerIpcHandlers() {
this.ipcHandle('file.read', (handle, opts) =>
this.dispatchHandle(handle,
id => this.read(id, opts),
path => contentRead.readByPath(this.deps, path, opts)
)
)
this.ipcHandle('file.write', (handle, data) =>
this.dispatchHandle(handle,
id => this.write(id, data),
path => contentWrite.writeByPath(this.deps, path, data)
)
)
// ... other IPC methods that accept FileHandle
// IPC methods that accept only FileEntryId pass through directly
this.ipcHandle('file.trash', ({ id }) => this.trash(id))
this.ipcHandle('file.createInternalEntry', params => this.createInternalEntry(params))
this.ipcHandle('file.ensureExternalEntry', params => this.ensureExternalEntry(params))
}
Impact of adding a new handle kind (e.g., virtual pointing into archive members, remote pointing to an S3 URI):
src/shared/file/types/handle.ts— add variant to handle union- Relevant
internal/*/*.ts— add corresponding*Virtual/*Remotepure functions FileManager.ts— add a callback parameter to thedispatchHandlesignature; each IPC handler explicitly handles that kind (or throws "unsupported")
The extension surface is concentrated in a single file, FileManager.ts—it's immediately obvious which kinds each IPC method supports, which aids auditing. This is lighter than introducing a separate FileAccessor class while achieving the same "extension convergence".
1.6.6 External Access Constraints
| Location | May import | Forbidden to import |
|---|---|---|
| Main-side business service (KnowledgeService, MessageService, etc.) | @main/services/file (gets FileManager) / @main/utils/file/{fs,path,metadata,search,shell} / @main/services/file/watcher |
@main/services/file/internal/** |
Inside the file module itself (internal/*, watcher/*) |
May reference each other as needed; may also import @main/utils/file/* primitives |
Except FileManager, must not import internal/* |
| External Node/renderer | N/A (file-module is main-side) | — |
Boundary enforcement: the src/main/services/file/index.ts barrel re-exports only public types + the FileManager class; internal/ symbols cannot be reached via @main/services/file. If violations surface, add an ESLint no-restricted-imports rule.
1.6.7 Design Trade-offs
| Option | Adopted? | Rationale |
|---|---|---|
| Split business methods into 5 lifecycle services | ❌ | Overkill—lifecycle registration, dependency ordering, and test mocking costs all 5×, in exchange only for "methods split across files" |
FileManager as facade + internal/* pure functions |
✅ | Only 1 lifecycle node; pure functions can be unit-tested with stub deps directly; external API surface remains stable |
FileAccessor as a standalone class handling FileHandle dispatch |
❌ | Dispatch itself is a proper responsibility of the IPC adapter layer; converging into the dispatchHandle helper inside FileManager suffices; splitting off another layer adds pure complexity |
| FileManager public API switched to handle-native | ❌ | IPC and Main-side call contracts need not share shape; main-side business services using entry-native directly is more intuitive, without needing a createFileEntryHandle wrapper |
| Extract versionCache as a module singleton | ❌ | As a FileManager private field, it naturally supports test isolation (new instance = fresh cache) |
1.6.8 Event Emission & Broadcast (deferred)
FileManager exposes three typed Event<T> on its instance surface and forwards each to every live renderer window. The public contract and queryKey invalidation table live in architecture.md §3.6; this section pins down the emission mechanics that the FileManager implementation must satisfy.
class FileManager extends BaseService {
readonly onEntryRowChanged: Event<EntryRowChangedEvent>
readonly onEntryContentChanged: Event<EntryContentChangedEvent>
readonly onDanglingStateChanged: Event<DanglingStateChangedEvent>
private readonly _entryRow = new Emitter<EntryRowChangedEvent>()
private readonly _entryContent = new Emitter<EntryContentChangedEvent>()
// onDanglingStateChanged is re-exposed from DanglingCache — see §11.7
constructor(private readonly windowManager: WindowManager) { super() }
protected override onInit(): void {
this.registerIpcHandlers()
this.initVersionCache()
danglingCache.initFromDb()
// Wire internal events → renderer broadcast. Each disposable auto-cleans on stop.
this.registerDisposable(this.onEntryRowChanged((e) =>
this.windowManager.broadcast('file-manager-event', { type: 'entry-row', ...e })))
this.registerDisposable(this.onEntryContentChanged((e) =>
this.windowManager.broadcast('file-manager-event', { type: 'entry-content', ...e })))
this.registerDisposable(this.onDanglingStateChanged((e) =>
this.windowManager.broadcast('file-manager-event', { type: 'dangling-state', ...e })))
void this.runOrphanSweep().catch((err) => logger.error('Orphan sweep failed', err))
}
}
Emission point per mutation (all emits happen after the DB transaction commits, never inside):
| Method | Emit |
|---|---|
createInternalEntry / batch |
onEntryRowChanged { kind: 'created' } per newly-inserted row |
ensureExternalEntry / batch |
Insert branch → onEntryRowChanged { kind: 'created' }. Reuse branch → no emit (no state change). |
write / writeIfUnchanged |
After atomic rename + (internal-only) DB size update commits → onEntryContentChanged { id, version }. StaleVersionError → no emit. |
createWriteStream |
On stream 'finish' → emit. On 'abort' / 'error' / .destroy() → no emit. |
rename |
After DB commit (and FS rename for external) → onEntryRowChanged { kind: 'updated' }. |
trash / restore / batch |
After DB update commits → onEntryRowChanged { kind: 'updated' } per affected id. |
permanentDelete / batch |
After DB delete commits (internal: FS unlink runs first; external: FS untouched per §1.2) → onEntryRowChanged { kind: 'deleted' }. CASCADE-dropped association rows emit no extra events — the renderer invalidates ['fileManager', 'entries'] and refetches. |
copy |
Creates a new internal entry → emit onEntryRowChanged { kind: 'created' } for the new id only (source is untouched). |
Atomicity & crash semantics: emits are plain Emitter.fire() calls, not part of the DB transaction. A process crash between commit and fire loses the event. This is acceptable because:
- The committed DB state is authoritative — user data is durable
- The renderer's
staleTimecontract (seearchitecture.md §4.1.1) refreshes the query on its natural cadence - Next renderer query loads the latest state — no permanent desync
Events are accelerators, not authoritative notifications. This is an explicit design choice documented in architecture.md §3.6.
Multi-window fan-out: windowManager.broadcast iterates every live webContents in the window pool and sends the IPC payload. Backgrounded / minimized windows still receive the IPC (Electron delivers to paused webContents); they process it on resume via React Query's standard refresh-on-focus behavior, and any brief overlap with the renderer's first query after resume is resolved idempotently by queryClient.invalidateQueries. Windows whose renderer bundle has not finished loading miss the event entirely, but their first query hits a fresh state with no ghost data.
Testing contract: unit tests of mutation methods must not depend on broadcast — windowManager.broadcast is mocked in test fixtures. The emission-point table above is the contract tests should assert against (e.g. "ensureExternalEntry reuse branch does not emit").
2. Storage Architecture
2.1 Physical Path Rules
Physical paths are not persisted; resolved at runtime based on origin:
function resolvePhysicalPath(entry: FileEntry): string {
if (entry.origin === 'internal') {
return application.getPath('feature.files.data', `${entry.id}${entry.ext ? '.' + entry.ext : ''}`)
}
return entry.externalPath!
}
internal physical paths are always flat: {userData}/Data/Files/{uuid}.{ext}, and do not change with the FileEntry's name. UUID naming makes internal files invisible and not manually organizable by the user—this is an intentional design choice.
external physical paths are entirely determined by the user; Cherry does not touch them.
2.2 Physical Directory Structure
{userData}/Data/Files/
├── {uuid-1}.pdf
├── {uuid-2}.png
├── ...
└── {uuid-n}.tmp-{uuid} ← Temporary files for atomic writes (abnormal residues cleaned by `runSweep`)
Cherry creates no subdirectories under {userData}/Data/Files/. All internal files are stored flat.
2.3 Temporary File Handling
Transient processing files (OCR intermediates, PDF pagination, archive extraction, etc.) do not create FileEntry and use @main/utils/file/fs primitives directly under {userData}/temp/ (or process-level os.tmpdir()). After processing, the business side cleans up or relies on OS mechanisms.
3. External Entry Liveness Model
3.1 Design: No DB Snapshot for Drift-Prone Fields
The external file can be modified or moved by the user at any time. Rather than carrying a DB snapshot that silently drifts (and then chasing it with "refresh" paths), file_module stores only the fields that cannot drift while the entry exists:
Field on file_entry (external) |
Source of truth | Drift possible? |
|---|---|---|
id, origin, createdAt, updatedAt |
DB row | No |
externalPath |
User intent at registration time | No (user-explicit changes go through ensureExternalEntry(newPath)) |
name / ext |
Pure projection of externalPath (path.basename / path.extname) |
No (stable as long as externalPath is stable) |
size |
Not stored — always null (enforced by fe_size_internal_only CHECK) |
N/A |
Live size / mtime for an external entry are obtained via File IPC getMetadata(id) (fs.stat on demand). This makes the freshness cost explicit at the call site rather than hiding a stale snapshot behind the FileEntry.size field.
3.2 Why Size Is Not Stored
The classic "DB snapshot + refresh paths" design produces two symmetric defect classes:
- Stale reads — callers consume
FileEntry.sizeassuming freshness, missing the part of the doc that says "snapshot may be stale". - Bookkeeping bugs — every write / read / hash path has to remember to UPDATE the snapshot; forgetting one leaves the snapshot behind.
Making size unavailable on the row eliminates both: the renderer cannot read a stale value (there is nothing to read), and the main-side code has no snapshot to maintain. The cost — one extra fs.stat per external row when size is actually needed — is localized and observable.
Paths that would otherwise need to refresh a snapshot: read / getVersion / getContentHash on external still run fs.stat as part of their own work (and update DanglingCache as a side effect), but they do not write to the DB row — no size column exists to refresh.
Cherry does not track external rename: after a user mv/rename outside of Cherry, the corresponding entry goes dangling. The user must re-@ inside Cherry to establish a new reference at the new path via ensureExternalEntry(newPath).
3.3 Dangling Model
When an external file does not exist on disk (or is inaccessible), the corresponding entry is called dangling. Dangling state is maintained by DanglingCache (a file_module singleton); see §11 for details.
Three states:
| State | Meaning |
|---|---|
'present' |
The file was recently observed to exist (watcher event / stat success / ops operation observation) |
'missing' |
The file was recently observed to be absent (watcher unlink / stat ENOENT) |
'unknown' |
No watcher coverage, no prior stat (or cache was actively cleared) |
Detection timing:
- Passive (pull): File IPC
getDanglingState/batchGetDanglingStatesquery →danglingCache.check(entry)(synchronous on cache hit, singlefs.staton cold miss). DataApi never reads this cache. - Active push: when a business module creates a watcher via
createDirectoryWatcher(), the factory auto-wires add/unlink events into DanglingCache - Side effect: FileManager's own read/stat/write operations also update the cache on success/failure
UI semantics: dangling entries show a failed style in the UI (grayscale, icon marker), but are not auto-cleaned—the ref association chain is preserved; the user can explicitly "Remove from library" or attempt to re-point.
4. Version Detection and Concurrency Control
4.1 FileVersion
interface FileVersion {
mtime: number // ms epoch
size: number
}
Used as a fast signal for detecting external changes. Two tiers of usage:
- Fast path:
statVersion(path)(microsecond-level, covers 99% of cases) - Deep path:
contentHash(path)→ xxhash-h64 (millisecond-to-second level, used when mtime/size match but further confirmation is needed)
Rationale for mtime + size as a signature:
- Six scenarios where mtime alone fails—multiple writes within the same ms, clock rewind, backup preserving mtime, user touch, low-precision FS (FAT32), in-place 1-byte edit—are covered by size or hash as fallbacks
4.2 Read API
interface ReadResult<T> {
content: T
mime: string
version: FileVersion
}
read(id, opts?: { encoding?: 'text' }): Promise<ReadResult<string>>
read(id, opts: { encoding: 'base64' }): Promise<ReadResult<string>>
read(id, opts: { encoding: 'binary' }): Promise<ReadResult<Uint8Array>>
read uniformly returns { content, mime, version }. Returning version comes at near-zero cost—stat is already required on the read path.
4.3 Write vs WriteIfUnchanged
write(id, data): Promise<FileVersion>
writeIfUnchanged(id, data, expectedVersion: FileVersion): Promise<FileVersion>
Two independent methods that force the caller to explicitly choose whether conflict detection is needed:
| Caller | Which to use | Reason |
|---|---|---|
| First-time write, overwrite, migration, preprocessing | write |
No concurrency semantics |
| Editor save (Notes, Markdown, and other potential future consumers) | writeIfUnchanged |
Must detect external changes |
On conflict, writeIfUnchanged throws StaleVersionError, and the caller decides on UX after catching (dialog, three-way merge, keep both versions, etc.).
Behavior on external: write / writeIfUnchanged / createWriteStream / rename / permanentDelete all apply—Cherry supports user-explicitly-triggered external file modifications (editor save, UI rename, user-confirmed delete), delegated to the FS primitives at @main/utils/file/fs (atomic write / rename / remove). Cherry does not perform automatic / watcher-driven external file modifications.
4.4 LRU Version Cache
FileManager maintains Map<FileEntryId, CachedVersion> internally (LRU, ~2000 entries):
| Trigger | Action | Phase |
|---|---|---|
write / writeIfUnchanged completes |
set(id, new version) |
Phase 1 (shipped) — see internal/content/write.ts:42, 68, 83 |
| Critical path detects external change | set(id, new version) |
Deferred — paired with the change-detector that would observe "external change" outside the watcher path |
| Startup reconcile completes | clear() |
Deferred — no startup reconcile pass exists yet |
Trust boundary: the cache only accelerates getVersion queries and is not used for critical decisions. writeIfUnchanged's concurrency comparison must re-stat; it does not trust the cache.
5. Atomic Writes
5.1 tmp + fsync + rename Flow
All writes (entry/internal to userData, entry/external to externalPath, path-handle to any path) follow the POSIX atomic flow:
1. Create {target}.tmp-{uuid} in the same directory
2. Write data to the tmp fd
3. fsync(tmp fd) ← data flushed to disk
4. rename(tmp, target) ← atomic replacement (POSIX guarantee)
5. fsync(dir fd) ← rename metadata flushed to disk
Key rules:
- fsync on by default. Cherry's write frequency is user-action level, and fsync on SSD costs < 10ms
- tmp must be in the same directory as target. Cross-filesystem rename is not atomic
- tmp naming:
{target}.tmp-{uuidv7}—UUID avoids concurrent-write conflicts - Crash residue: FileManager's on-demand orphan sweep cleans up by
^.+\.tmp-<uuidv7>$ - 2× disk usage is an inherent cost of POSIX rename semantics, unavoidable
5.2 Stream Variant
createWriteStream(id): Promise<AtomicWriteStream>
Stream writes also follow tmp + rename. The returned AtomicWriteStream extends Writable; .close() triggers fsync + rename + fsync(dir); .abort() cancels and unlinks the tmp.
5.3 FS Primitive Access Policy
The atomicWriteFile / atomicWriteIfUnchanged / createAtomicWriteStream primitives exported by @main/utils/file/fs are open to modules outside the file module. BootConfig, MCP oauth storage, and any other main-process service that needs a safe atomic write imports them directly; scattered ad-hoc tmp+rename implementations are not introduced.
6. Deletion and Recycle Bin
6.1 deletedAt Model
All soft deletes are implemented via the deletedAt timestamp, without physically moving files:
| Operation | Physical impact (internal) | Physical impact (external) |
|---|---|---|
trash(id) |
None | N/A (fe_external_no_delete CHECK rejects; external rows cannot be trashed) |
restore(id) |
None | N/A (no trashed external rows to restore) |
permanentDelete(id) |
DB delete + best-effort remove(physicalPath) (@main/utils/file/fs) |
DB delete only — user's file is never modified (matches architecture.md §3.4) |
trash / restore are internal-only. External entries cannot be trashed by definition (fe_external_no_delete CHECK enforces this); the trash semantics make sense only for files Cherry owns.
permanentDelete on internal: DB row is removed first, then the physical file at {userData}/Data/Files/{id}.{ext} is best-effort unlinked. Unlink failures (ENOENT, insufficient permissions, etc.) are logged but do not block — the DB-row-gone outcome is what callers observe; any orphaned blob is later cleaned by the next user-triggered orphan sweep (§10).
permanentDelete on external: DB row is removed; the user's file at externalPath is never modified — Cherry only owns the reference, not the content. This is the only safe contract: silently deleting user files from inside the app would violate the "best-effort external reference" semantics (§1.0.2 in architecture.md). Users who actually want the underlying file gone do so through their OS file manager.
6.2 Auto Expiry (deferred — lands in Phase 2)
Status: design only. Phase 1 ships no expiry timer service, no Preferences key, and no
WHERE deletedAt < now() - retentionMsquery. Trashed entries persist until the user runs an explicitpermanentDelete(or the next user-triggered orphan sweep collects an already-deleted entry's residual blob). The 30-day window below is the proposed retention; the actual default and configurability land with the timer service.
By default trashed entries are cleaned up after 30 days (lifecycle service timer); the user may configure the days or disable it in Preferences.
Query: WHERE deletedAt < now() - retentionMs → batch permanentDelete.
6.3 Edge Cases
| Scenario | Handling |
|---|---|
| unlink fails on permanentDelete internal (file already missing, permission issue) | Log warn; the DB row is already gone, so the failure surfaces only as an orphan blob that the next user-triggered orphan sweep will reclaim |
| permanentDelete on external | DB-only by design; the user's file at externalPath is never touched — Cherry owns only the reference |
ensureExternalEntry(path) when an entry for the same path already exists |
Entry point first calls canonicalizeExternalPath(raw); upsert returns the existing row. External entries cannot be trashed, so there is no "restore" branch. |
| Two entries for the same file due to case / NFC differences (macOS APFS, Windows NTFS, or NFD ↔ NFC input) | NFC closed by canonicalizeExternalPath; case-collision rejected at INSERT by the DB functional unique index plus the fs.realpath-based reuse-or-throw decision in ensureExternalEntry (see §1.2 "Duplicate-entry detection on insert"). |
| External file at original path externally replaced with a different file | Cherry does not check content consistency (best-effort). name / ext on the row are derived from externalPath and do not change; size is always served live by getMetadata. DanglingCache flips to 'present' on the next stat, so the UI just renders the new file under the existing reference. |
| A trashed entry is permanently externally deleted and then restored | Appears dangling (DanglingCache returns missing on next check), UI shows failed style |
| External write with permission error / disk full on target path | Throw without polluting DB; caller decides retry or user notification |
7. Reference Cleanup Mechanism
Three layers of protection, with each layer as a fallback for the next:
+-------------------------------------------------------+
| Layer 1: fileEntryId CASCADE |
| FileEntry deleted -> ref rows auto-cascaded |
| file_upload auto-cascaded |
| (DB FK constraint, zero app code) |
+-------------------------------------------------------+
| Layer 2: source FK cascade / relationship replacement |
| business entity deleted -> source-FK cascade |
| relationship replaced -> explicit cleanup+insert |
+-------------------------------------------------------+
| Layer 3: on-demand DB orphan sweep |
| prune temp-session refs whose file_entry is missing |
| report active file_entry rows with zero refs |
+-------------------------------------------------------+
Layer 3 is not a generic persistent-source reconciler. Persistent association rows are FK-constrained and should disappear through Layer 1 / Layer 2 cascades; the sweep only handles the non-persistent temp_session cache and reporting.
7.1 No-Reference Entry Policy
The default stance — FileEntry is preserved even when no business refs point at it — is chosen so the user never loses a file they (or Cherry) bothered to track merely because the original consumer got deleted. A UI surface may show an "unreferenced" marker for user-triggered cleanup.
There are no automatic deletion exceptions. Even an external entry that is currently missing and has zero refs is still a user-visible library record: it may represent a temporarily unmounted drive, a file the user wants to re-link later, or simply a stale record the user should remove explicitly. The file module may report these rows, but it must not delete them without an explicit user/caller action.
Policy matrix by (origin, dangling state, refs):
| origin | dangling state | refs | Policy |
|---|---|---|---|
internal |
n/a (always 'present') |
any | Preserve — user may re-link via UI; only user-initiated cleanup |
external |
'present' |
any | Preserve — file still exists, fully re-attachable |
external |
'unknown' |
any | Preserve — not yet observed; treated as still-live until proven otherwise |
external |
'missing' |
>0 | Preserve — business objects still reference this entry. Automatic deletion would CASCADE-drop association rows and silently mutate user data (messages' attachment count drops, UI state shifts). The business service owning those refs is the right layer to decide replacement / removal policy, not the file module. Reference-oriented UI surfaces (§3.4 UI convention) show these as "file missing" so the user can act. |
external |
'missing' |
0 | Preserve + report — no refs remain, but the row is still user-visible library state. FilesPage / cleanup UI may show "missing" and offer "Remove from library"; no time-based auto-delete. |
7.2 No Automatic Dangling-External Cleanup
Dangling external entries are never deleted automatically by a scheduler, startup task, or runSweep() policy pass. Cleanup is explicit:
- User action: FilesPage or a cleanup UI calls the external-entry deletion path (labelled "Remove from library") for selected rows.
- Business action: a business service that owns a reference may decide how to handle a missing file in its own workflow (prompt, re-link, remove ref, etc.).
- Sweep reporting:
runDbSweepmay report unreferenced entries by origin so a UI can surface candidates, but it does not delete FileEntry rows based on dangling state or ref count.
Rationale:
- External paths are volatile (USB/NAS/network mounts, permission changes, moved files). A cached or freshly observed
'missing'state is still not sufficient authority to delete a user-visible library record. - Automatic deletion would make file rows disappear without a visible initiating action, which is surprising even when
refs = 0. - Explicit removal keeps product copy accurate: external-entry deletion is "Remove from library" and never claims to delete the user's physical file.
Consequences:
- No persisted "missing since" timestamp or time-based cleanup query.
- No cleanup-verification bypass around DanglingCache TTL.
- No cleanup-specific observability event.
- No
('external', 'missing', 0)automatic deletion branch in Layer 3. Layer 3 remains temp-session ref pruning plus orphan-entry reporting.
8. DirectoryWatcher
8.1 Positioning
DirectoryWatcher is a non-lifecycle general FS primitive (not a service), available for business modules to new themselves. It is merely a chokidar wrapper and binds no business semantics.
Placed in src/main/services/file/watcher/, as a dedicated submodule of the file module distinct from the pure FS primitives at @main/utils/file/*. Rationale for the split:
| Aspect | @main/utils/file/* primitives |
watcher/ |
|---|---|---|
| Paradigm | Pure functions (stateless) | Stateful class |
| Lifecycle | None (completes upon call) | Has one (start → running → dispose) |
| Resource holding | None | FSWatcher instance + pending queues + timers |
| Consumption contract | const x = await read(path) |
const w = new DirectoryWatcher(...); ... w.dispose() |
Grouping a stateful class with pure-function primitives would break the primitives' stateless contract. This mirrors the layering between Node.js official fs.readFile (function) and fs.watch returning an FSWatcher instance (class): functionally related but fundamentally different consumption shapes, so they live in separate submodules.
8.2 API
Shipped surface mirrors src/main/services/file/watcher/index.ts —
a single onEvent(listener) subscriber over a normalized event union.
Earlier drafts proposed seven separate event channels (onAdd /
onAddDir / onUnlink / onUnlinkDir / onRename / onReady /
onError) with file vs directory split and built-in rename detection;
the watcher module ships a flat union instead because no current
consumer needs the dir-event split, and rename detection is deferred
to the same change that lands the first onRename consumer (see §8.3).
export type WatcherEvent =
| { readonly kind: 'add'; readonly path: FilePath }
| { readonly kind: 'addDir'; readonly path: FilePath }
| { readonly kind: 'unlink'; readonly path: FilePath }
| { readonly kind: 'unlinkDir'; readonly path: FilePath }
| { readonly kind: 'change'; readonly path: FilePath }
| { readonly kind: 'ready' }
| { readonly kind: 'error'; readonly error: Error }
export type WatcherListener = (event: WatcherEvent) => void
export interface DirectoryWatcher {
/** Subscribe to normalized FS events. Returns an unsubscribe function. */
onEvent(listener: WatcherListener): () => void
/** Stop watching and release OS-level resources. Idempotent. */
close(): Promise<void>
}
export interface CreateDirectoryWatcherOptions {
/** Recurse into subdirectories. Default: true. */
readonly recursive?: boolean
/** Custom ignore predicate. Built-in OS-junk ignores always apply. */
readonly ignore?: (path: FilePath) => boolean
/** Stability window for `awaitWriteFinish` (ms). Default: 200. Set to 0 to disable. */
readonly stabilityThresholdMs?: number
}
export function createDirectoryWatcher(
path: FilePath,
opts?: CreateDirectoryWatcherOptions
): Promise<DirectoryWatcher>
Adding new event kinds: the flat union is additive — extending
WatcherEvent with a new kind is non-breaking to existing
subscribers (they observe and skip), so the dir-split / rename
channels can be reintroduced without a watcher-rewrite if a real
consumer surfaces.
8.3 Rename Detection Semantics
When enabled, unlink/add events are delayed windowMs to attempt pairing as a rename:
- Successful match → only
onRenameis emitted (the matched unlink/add are suppressed) - No match → after timeout, unlink/add are emitted normally
Key guarantee: when enabled, onUnlink/onAdd and onRename do not fire simultaneously, so consumer semantics are clear.
Platform precision:
- Unix (macOS/Linux): prefers inode matching, falls back to size
- Windows: size only (NTFS ino is unstable), precision degraded and documented as acceptable
Only file rename is handled. Directory renames are not specially recognized; consumers combine sub-file events themselves.
8.4 Built-in Ignore Rules
OS garbage files are ignored by default (not disable-able):
{ basename: '.DS_Store' }{ basename: '.localized' }{ basename: 'Thumbs.db' }{ basename: 'desktop.ini' }
Consumers may append ignored; merged after the default rules.
8.5 Usage Pattern
Business modules new + dispose themselves as needed:
// Illustrative (non-file_module implementation)
const watcher = new DirectoryWatcher({
path: source.basePath,
renameDetection: { enabled: true }
})
watcher.onAdd(...)
watcher.onRename(...)
await watcher.start()
// ...
watcher.dispose()
file_module starts no watcher instances. Whether to monitor external directories is the business module's decision.
9. AI SDK Integration (FileUploadService) — Deferred
⚠️ This section is a design record; the corresponding implementation is deferred. Vercel AI SDK's Files Upload API (
FilesV4,SharedV4ProviderReference) is still pre-release, and the corresponding dependency is unstable. FileUploadService, thefile_uploadtable, and related IPC methods are all deferred to a separate PR after the SDK reaches stable. This section preserves the design intent for direct landing in the future.
9.1 Motivation
Cherry needs to integrate with the Vercel AI SDK's file upload API. The SDK's SharedV4ProviderReference models "the same logical file may be uploaded to N providers, each with its own fileId".
When it lands, a dedicated file_upload table tracks these uploads, decoupled from fileEntry.
9.2 Schema
CREATE TABLE file_upload (
id TEXT PRIMARY KEY,
file_entry_id TEXT NOT NULL REFERENCES file_entry(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
remote_id TEXT NOT NULL,
content_version TEXT NOT NULL, -- xxhash-h64 at upload time
uploaded_at INTEGER NOT NULL,
expires_at INTEGER,
status TEXT NOT NULL, -- 'active' | 'expired' | 'failed'
metadata TEXT, -- JSON, provider-specific
UNIQUE(file_entry_id, provider)
);
9.3 Service API
interface IFileUploadService {
ensureUploaded(fileEntryId: FileEntryId, provider: string): Promise<FileUpload>
buildProviderReference(fileEntryId: FileEntryId): Promise<SharedV4ProviderReference>
invalidate(fileEntryId: FileEntryId): Promise<void>
}
ensureUploaded logic:
- Look up (entryId, provider) in
file_upload - Compute current contentHash (internal may trust versionCache; external must recompute)
- If contentVersion matches + not expired → reuse
- Otherwise: read content → call
provider.files().uploadFile()→ upsertfile_upload
buildProviderReference assembles all active uploads into Record<provider, remoteId>.
9.4 Invalidation and Re-upload
- Content change (triggered by FileEntry write) → mark all file_upload as stale (or delete)
- Provider expiry (expires_at < now) → treat as missing on next use; re-upload
- Manually deleted on the provider side → upstream error on send, catch →
invalidate+ re-upload
10. On-Demand Orphan Sweep (User-Triggered)
10.1 Positioning
Orphan sweep is explicitly triggered via the File_RunSweep IPC channel — there is no startup auto-run. FileManager exposes a single runSweep() method for cleanup UI/caller-initiated flows; it runs both the FS-level pass (§10) and the DB-level pass (§7 Layer 3) concurrently and returns a single OrphanReport once both settle.
protected override async onInit(): Promise<void> {
// DanglingCache reverse index built from DB before any IPC accepts
// a dangling query, so a renderer cannot race the first call.
await this.deps.danglingCache.initFromDb()
// IPC handlers, including `File_RunSweep`, are registered here.
this.registerIpcHandlers()
}
async runSweep(): Promise<OrphanReport> {
// Two concurrent passes:
// 1. FS-level file sweep (§10): scan {userData}/Data/Files/* for
// orphans not present in the file_entry snapshot.
// 2. DB-level temp-session ref prune + entry report (§7 Layer 3):
// prune cache refs whose file_entry is missing, then report
// unreferenced active entries.
// Each branch settles independently with its own error capture. A DB
// failure dominates as `failed`; FS-side partial/aborted/failed outcomes
// degrade the umbrella report to `partial` via `fsSweepIssue`.
}
Rationale for user-triggered (vs. startup auto-run):
- Cleanup is a user-domain concern. The user opening the cleanup UI is the trigger; running it implicitly at boot consumes resources for an action the user did not request.
- The earlier startup variant existed in part to suppress noise during the v1→v2 transition window (when consumer migrators Batches A-E had not yet wired their file association rows). That noise was scaffolding for a one-time event — once Batch A-E land the noise self-resolves, and outside the transition window the sweep's findings are exactly the signal the cleanup UI wants to surface.
- No persistent state machine. Each invocation runs end-to-end and returns its own report; FileManager no longer holds
lastDbSweepReport/lastDbSweepRanAt. UIs that want "last scan" timing should hold the previously-returnedOrphanReport.lastRunAtthemselves.
A note on initVersionCache: an earlier draft of this section bundled a synchronous initVersionCache() call into onInit. It didn't survive implementation — version cache is per-FileManager-instance and constructs at field-init time (no boot step), so there is no separate init call to make. registerIpcHandlers() did survive and is the convention used across lifecycle services for the same reason it surfaces in lifecycle-migration-guide.md: keeps onInit a narrow init→register sequence and gives a single spot for Phase 2 channels to land.
10.2 Scan Strategy
The sweep uses a single snapshot of file_entry.id taken at sweep start, not per-file DB lookups:
SELECT id FROM file_entry -- one query, held in an in-memory Set for the duration of the sweep
It then diffs readdir({userData}/Data/Files/) against the snapshot in memory. Chosen properties:
- Simple and fast: one SQL round-trip vs N round-trips; for <10k internal entries the cost is sub-10ms.
- Race window is bounded and explicit: entries inserted after the snapshot but before the sweep reaches their file appear as "not in DB". The
mtime > 5minfilter in §10.3 is the only thing that protects them — the snapshot strategy makes this reliance explicit rather than hiding it behind DB isolation levels.
Implementations MUST NOT silently switch to per-file DB lookups — the snapshot strategy, the mtime > 5min filter (§10.3), and the safety threshold (§10.4) are co-designed; any change to one requires re-evaluating the other two.
10.3 Heuristic Rationale & FS Prerequisites
The mtime > 5min filter is an engineering heuristic, not a formal guarantee. It is sufficient under the following assumptions:
createInternalEntry's "FS write UUID file → DB insert" window is << 5min in practice (microseconds to seconds)atomicWriteFile's "open tmp → write → fsync → rename" window is << 5min, with streaming writes refreshing mtime on eachwrite(2)userDataresides on a local POSIX-compliant filesystem with sub-minute mtime resolution (APFS / ext4 / NTFS local)- System clock is not discontinuously advanced by > 5min while a write is in flight
Known breakage modes (documented, accepted as residual risk):
| Scenario | Consequence |
|---|---|
| Very slow write (huge file + slow disk/fsync) exceeds 5min between FS write and DB insert | Newly-written internal file may be unlinked on the next user-triggered sweep |
| Process frozen / suspended > 5min mid-write; then a subsequent sweep runs | Same as above |
| System clock jumps forward > 5min after file creation | Recent residue gets mis-aged; usually harmless — those files were orphans anyway |
| System clock jumps backward | Filter becomes permissive (now < mtime disqualifies the file); cleanup delayed to the next sweep run (safe) |
userData on FAT32 / exFAT / SMB / NFS (second-precision or offset-prone mtime) |
Filter still works at coarse granularity; extreme clock skew between client and server can mis-age files |
userData on tmpfs / CoW FS with unusual mtime semantics |
Out of contract; user responsibility |
The sweep does not defend against deliberately hostile mtime manipulation. These are known limits, not bugs.
10.4 Safety Threshold & Abort
To bound the blast radius of application-layer regressions (e.g. a faulty migration that truncates file_entry, a regression in the sweep predicate, a developer running the app against a stale DB), the sweep MUST evaluate its plan before executing and abort on suspicious scale:
interface SweepPlan {
orphanFiles: { path: string; bytes: number }[]
tmpFiles: { path: string; bytes: number }[]
}
function shouldAbort(
plan: SweepPlan,
totalFilesOnDisk: number,
totalBytesOnDisk: number
): boolean {
const toDelete = plan.orphanFiles.length + plan.tmpFiles.length
const toDeleteBytes = sumBytes(plan.orphanFiles) + sumBytes(plan.tmpFiles)
// Normal crash residue: always proceed.
if (toDelete < 20 && toDeleteBytes < 10 * 1024 * 1024) return false
// Otherwise check proportion. If the sweep would erase a large fraction of
// the on-disk UUID population, something upstream is wrong — refuse and
// warn; the next sweep run re-evaluates after the bug is fixed.
const countFraction = toDelete / Math.max(1, totalFilesOnDisk)
const byteFraction = toDeleteBytes / Math.max(1, totalBytesOnDisk)
return countFraction > 0.5 || byteFraction > 0.5
}
Contract:
- On abort, no files are unlinked.
- Emits a
warn-level structured log (see §10.5) so developers / on-call can diagnose. - The service remains available — abort is a controlled outcome, not a failure; no
Erroris thrown into the.catch()handler. - The next sweep run re-evaluates the plan after the upstream issue is resolved.
Scope note: the threshold defends against internal bugs, not user-side manipulation of {userData}/Data/Files/. Users are not expected or encouraged to edit the storage directory (all file operations should go through the in-app entry system). The threshold's job is to ensure "nothing Cherry itself does, internally, silently deletes the bulk of a user's library".
10.5 Observability
Every sweep run emits one structured log record through loggerService — info on normal completion, warn on partial / aborted outcomes, error on unexpected failure caught by .catch():
{
event: 'orphan-file-sweep', // disambiguates from the DB-side 'orphan-sweep' (§7 Layer 3)
outcome: 'completed' | 'partial' | 'aborted' | 'failed',
entriesInDb: number,
direntsScanned: number, // total readdir entries (informational)
filesOnDisk: number, // UUID files + tmp residue candidates only
bytesOnDisk: number, // bytes of candidates (drives the abort fraction math)
plannedDeleteCount: number,
plannedDeleteBytes: number,
actualDeleteCount: number, // 0 on aborted / failed
actualDeleteBytes: number,
oldestDeletedMtime?: number, // ms epoch of the oldest file unlinked this run
statFailedCount: number, // non-ENOENT stat errors during planning
scanDurationMs: number,
// outcome-specific fields (discriminated union):
// 'partial': failedDeleteCount: number, failedSamples: readonly string[] (capped at 5)
// 'aborted': abortReason: 'count-fraction' | 'byte-fraction'
// 'failed': errorMessage: string
}
The DB-side sweep emits a parallel record under event: 'orphan-sweep'. Its current outcomes are completed or failed: it prunes temp-session refs whose file_entry is missing, then reports active entries with zero refs. The shared partial wire branch remains for compatibility, but there is no generic per-source checker pass.
These two records are the single source of truth for post-hoc diagnosis. No separate metrics pipeline is needed — at most two records per user-triggered sweep run is a trivial volume for log aggregation.
10.6 DanglingCache Initialization
The reverse index of DanglingCache (Map<path, Set<entryId>>) is built via a single synchronous DB query:
SELECT id, externalPath FROM file_entry
WHERE origin = 'external' AND deletedAt IS NULL
No stat performed—the state field (Map<entryId, DanglingState>) is initially empty; lazy stat on query (see §11).
10.7 Why There Is No Dangling Probe
The old version batch-stat'd all external entries at startup to build the dangling set. The new version cut this step:
- Dangling is a pull-only IPC query (
getDanglingState/batchGetDanglingStates); most query scenarios don't need it, so it's never computed eagerly - Lazy + Promise.all is fast enough: on the first dangling query, N stats run in parallel, typically <100ms
- Watcher-covered paths have zero IO — where coverage happens to exist. In practice, watcher coverage is emergent and typically sparse (see §11.1): only business modules with their own directory-monitoring needs (NoteService watching the notes directory, etc.) create watchers. Ad-hoc user-
@-ed external paths — the main source of external entries — are almost never covered by any watcher. For those paths, the TTL-based cache (§11.6) and ops observations are the dominant freshness mechanisms, not watchers. Do not read this bullet as "watchers cover most paths"; they don't.
10.8 Concurrency Safety
| Concurrency scenario | Result |
|---|---|
| createInternalEntry creates a new internal file during sweep | The mtime > 5min filter (§10.3) prevents the new file from being mistakenly deleted; the snapshot strategy (§10.2) makes this reliance explicit |
| FileManager.read/write on existing entries during sweep | No mutual exclusion; read/write follow different code paths and are unaffected |
| Upstream bug causes bulk deletion plan | Safety threshold (§10.4) aborts the sweep without unlinking |
| app exits during sweep | No persistent side effect; user can rerun via the cleanup UI on next launch |
10.9 Crash Consistency
file_module's crash window is very narrow:
| Operation | Order | Crash mid-operation | Recovery |
|---|---|---|---|
| createInternalEntry | FS write UUID file → DB insert | Orphan file | Orphan sweep |
| write (internal) | atomic tmp+rename + DB update | One of new/old files preserved | Naturally consistent |
| trash / restore / rename | DB only | None | None |
| permanentDelete (internal) | DB delete → best-effort FS unlink | Crash after DB delete leaves an orphan blob | Orphan sweep |
| copy (internal) | FS copy → DB insert | Orphan file | Orphan sweep |
| ensureExternalEntry | DB insert / reuse (doesn't touch user file) | None | None |
| permanentDelete (external) | DB delete only | None — user's file at externalPath is never modified |
None needed |
No WAL / pending_fs_ops table needed. Orphan sweep covers the internal crash residue; the external side naturally doesn't need it (delete failure just leaves it on disk).
11. DanglingCache (External Presence Tracker)
11.1 Positioning
DanglingCache is a singleton in file_module (not a lifecycle service) that maintains the "latest known on-disk state" for external entries.
// src/main/services/file/danglingCache.ts
export const danglingCache = new DanglingCache()
Role:
- Provides a fast query interface for File IPC
getDanglingState/batchGetDanglingStates. Cache hit within TTL (§11.2, 30 min) returns synchronously; TTL-expired or cold miss runs a singlefs.statand refreshes the cache. DataApi never reads this cache — DataApi is pure SQL. - Consumes add/unlink/rename events from watchers that happen to cover an entry's path (auto-wired via the factory). Watcher coverage is sparse in practice: it exists only where a business module has created a watcher for its own reasons (NoteService watching the notes directory, a future Obsidian-like module, etc.). Arbitrary user-
@-ed external paths — which are the bulk of external entries — are typically NOT watcher-covered. Do not assume event-driven updates as the primary freshness mechanism; the TTL (§11.6) is. - Consumes observation results from FileManager's own ops (read/stat/write success/failure) — updates cache opportunistically whenever a mutation or lookup touches a path.
11.2 State Model
DanglingCache exposes lazy, query-driven presence checks only. There is no cleanup-only recheck path because dangling external entries are not auto-deleted (§7.2). If a future explicit user workflow needs a strict re-stat escape hatch, add it with that concrete caller and document the user-visible action.
type DanglingState = 'present' | 'missing' | 'unknown'
interface CachedState {
state: 'present' | 'missing'
/** ms epoch of last observation — drives TTL expiry in `check` */
observedAt: number
/** Where this observation came from (for diagnostics / log context). */
source: 'watcher' | 'ops' | 'stat'
}
class DanglingCache {
/** TTL for cached observations. 30min — external file path moves are rare,
* and freshness is bounded by TTL for any entry the user actually queries. */
private static readonly TTL_MS = 30 * 60 * 1000
private byEntryId: Map<FileEntryId, CachedState>
private pathToEntryIds: Map<string, Set<FileEntryId>> // reverse index
/** Public event: fires on every genuine state transition (see §11.7). */
readonly onDanglingStateChanged: Event<DanglingStateChangedEvent>
// Query (TTL-aware; re-stats when cache entry is stale)
async check(entry: FileEntry): Promise<DanglingState>
// Event entry (for watcher factory + FileManager ops) — resets observedAt
onFsEvent(path: string, state: 'present' | 'missing'): void
// Index maintenance (for FileManager entry CRUD)
addEntry(entryId: FileEntryId, externalPath: string): void
removeEntry(entryId: FileEntryId, externalPath: string): void
// Startup init
initFromDb(): void
}
Query strategy for check — lazy TTL expiration:
async check(entry: FileEntry): Promise<DanglingState> {
if (entry.origin === 'internal') return 'present'
// L1: cache hit AND still within TTL → return cached
const cached = this.byEntryId.get(entry.id)
if (cached && Date.now() - cached.observedAt < DanglingCache.TTL_MS) {
return cached.state
}
// L2: no cache OR TTL expired → re-stat and update
return this.doStatAndUpdate(entry, 'stat')
}
private async doStatAndUpdate(
entry: FileEntry,
source: CachedState['source']
): Promise<DanglingState> {
const state = await statToState(entry.externalPath!)
const prev = this.byEntryId.get(entry.id)
this.byEntryId.set(entry.id, { state, observedAt: Date.now(), source })
if (!prev || prev.state !== state) {
this._onDanglingStateChanged.fire({ id: entry.id, state })
}
return state
}
Key design points:
- Lazy expiration only, no periodic background sweep. FS IO cost scales with query frequency, not total entry count — heavy-user populations (10k+ external entries) consume zero IO when no UI is querying.
- Watcher events / ops observations reset
observedAttoDate.now()— a path with active watcher coverage stays fresh indefinitely and never triggers TTL-driven re-stat. - TTL = 30 min: external file path moves are rare in practice (files accumulate, rarely move); a 30-minute worst-case staleness window is acceptable for background UI state, while keeping TTL ≫ React Query's renderer-side
staleTime ≤ 5minmeans most renderer refetches hit cache (desired: the cache adds value). - No background or cleanup-only recheck path: presence is refreshed only when a caller queries or an observed operation/watch event supplies a new state. This keeps IO proportional to use and avoids hidden deletion authority.
11.3 Watcher Auto-Wiring
Business modules need not be directly aware of DanglingCache. All watchers must be created via the createDirectoryWatcher() factory; the factory subscribes to its own event stream and mirrors presence transitions into DanglingCache before re-emitting the raw event to external subscribers:
add→ cache markspresentunlink→ cache marksmissingchange→ cache untouched (file is still present; mtime drift is not tracked here)
The cache feed is keyed by canonical (NFC) form so it lines up with the reverse index populated by ensureExternalEntry; the path forwarded to subscribers is the raw OS form chokidar saw, so a subscriber that opens the file with that string stays coherent with what the FS actually has.
Note: watcher rename events do not auto-update an external entry's externalPath—Cherry does not track external rename. After a rename, the original entry goes dangling; the user must re-@ to establish a new reference.
11.4 Reverse Index Maintenance
Timing for changes to pathToEntryIds (fully self-governed inside file_module, no DB-FS sync):
| Event | Action |
|---|---|
Startup initFromDb() |
SELECT id, externalPath FROM file_entry WHERE origin='external' AND deletedAt IS NULL → batch add |
ensureExternalEntry creates new |
addEntry(id, path) |
ensureExternalEntry reuses (upsert hit) |
No change (path already indexed) |
permanentDelete(external) |
removeEntry(id, path) |
rename(external) (explicit user action) |
removeEntry(id, oldPath) + addEntry(id, newPath) |
External entries cannot be trashed (fe_external_no_delete CHECK enforces this
at the schema level; trash / restore throw at the entry layer before
reaching the reverse-index update). Earlier drafts listed restore(external)
and trash(external) rows here — they were dead branches and have been
removed.
11.5 Handler-Side Parallelization
The File IPC batchGetDanglingStates handler fans out over the requested ids in parallel:
async function batchGetDanglingStates(ids: FileEntryId[]): Promise<Record<FileEntryId, DanglingState>> {
const entries = await fileEntryService.batchGetById(ids)
const pairs = await Promise.all(
entries.map(async (e) => [e.id, await danglingCache.check(e)] as const)
)
return Object.fromEntries(pairs)
}
- Cache-hit entries return synchronously (microtask)
- Only cache-miss external entries go through stat, all in parallel
- 1000 entries cold-start typically <100ms (libuv threadpool parallel stat)
- Handler lives behind File IPC, not DataApi — the FS side effect is contained to the IPC channel where side effects are expected
11.6 State Invalidation Policy
TTL-based lazy expiration. A cached entry is considered fresh while now - observedAt < TTL_MS (30 min); once stale, the next check() call re-stats and updates the cache. observedAt is refreshed by any of:
- Watcher add/unlink/rename events (where coverage exists — see §11.1 caveat)
- Observation side effects of FileManager ops (stat ENOENT → missing; create / ensureExternal / rename / write success → explicit
'present'commit throughonFsEvent(..., 'ops')). Read / hash / getMetadata / getVersion do not flip the cache to'present'on success — they only commit'missing'on ENOENT through theobserveExternalAccesschokepoint. The watcher-led design deliberately keeps presence learning out of the passive-read path; seeinternal/observe.tsfor the contract. - Cold-path or TTL-driven
fs.statfromcheck()/getMetadata/getDanglingState
Freshness guarantee: for any path the caller queries, cached state is never older than the TTL. Paths that are never queried may stay stale indefinitely — but by construction, no consumer is looking at them, so the staleness has no user-visible impact.
Why no background sweep: a periodic background re-validation across all cached entries was considered and rejected. See §12 Key Design Decisions. The short version: FS IO cost would scale with total entry count instead of query frequency, and dangling entries are never auto-deleted, so stale presence state should be corrected at use/query time rather than by a hidden global scanner.
Known residual case — stale 'present' with refs > 0: if an external file is deleted outside Cherry, without any watcher or ops observation to signal it, and no UI ever queries getDanglingState for that entry, the cache stays 'present' past TTL boundaries (first query after TTL will re-stat and fix). Business services that depend on referenced files MUST re-validate at use time (read will surface ENOENT anyway); DanglingCache is a UI/presence helper, not a correctness boundary.
11.7 Reactivity — Event Emission (deferred)
DanglingCache exposes onDanglingStateChanged: Event<DanglingStateChangedEvent> fired on every genuine state transition (watcher event, cold-path fs.stat observation after a cache miss, explicit ops observation):
export interface DanglingStateChangedEvent {
id: FileEntryId
state: 'present' | 'missing'
// Note: 'unknown' is never broadcast — transitions FROM unknown TO concrete
// fire; 'unknown' itself is the default pre-observation state, not a signal.
}
FileManager subscribes in onInit (see §1.6.8) and fans the event out to all renderer windows via the shared file-manager-event IPC channel, closing the "main-side FS observation → renderer React Query invalidation" loop. The public queryKey invalidation target is ['fileManager', 'dangling', id]; see architecture.md §3.6 for the full dispatch table.
Emission rules:
- Transitions only — if
onFsEvent(path, 'present')arrives and the cached state is already'present', no emit. Prevents broadcast floods on watcher-chatty filesystems. - Fan-out via reverse index — when multiple entries share the same
externalPath(allowed by schema — one row per path per non-trashed state, but historical / edge cases may produce more), §11.4's reverse index yields all affected ids. Emit one event per id. Renderer invalidates['fileManager', 'dangling', id]per id. - Internal-origin entries never fire — they are always
'present'by construction (§3.3); DanglingCache never tracks them.
Staleness backstop retained: the staleTime ≤ 5min contract from architecture.md §4.1.1 still applies. Events accelerate refresh; a lost event is bounded by React Query's natural refetch cadence. DanglingCache's emission is therefore an optimization over pure pull, not a replacement for the pull path.
11.8 Observability (deferred)
DanglingCache emits a structured info-level log record at a fixed cadence (every 10 minutes, driven by a simple timer in onInit) summarizing its recent activity. This mirrors the orphan sweep observability contract (§10.5) — one periodic record plus opportunistic warn / error on anomalies, no separate metrics pipeline.
{
event: 'dangling-cache-snapshot',
cachedEntries: number, // total entries currently in byEntryId
pathIndexSize: number, // total keys in pathToEntryIds
// counters since the last snapshot record (reset each emit)
checkCalls: number, // total check() invocations
checkCacheHits: number, // returned cached within TTL
checkTtlExpiredReStats: number, // re-stat triggered by TTL
checkColdStats: number, // re-stat triggered by cache miss
watcherEvents: number, // onFsEvent calls from watcher factory
opsObservations: number, // onFsEvent calls from FileManager ops
transitionsFired: number, // onDanglingStateChanged fires
statErrors: number, // fs.stat threw (permission, I/O error, etc.)
windowMs: number, // interval this snapshot covers (≈ 600_000)
}
Emission cadence: every 10 minutes while the service is active. Upon onStop, one final snapshot flushes any outstanding counters. Snapshot volume at steady state is 6 records/hour × 24 = 144 records/day through loggerService — trivial for log aggregation even on long-running installs.
Anomaly triggers (emitted out-of-band at warn level, independent of the snapshot cadence):
statErrors / checkCalls > 0.1sustained across two consecutive snapshot windows → likely a systemic FS issue (unmounted drive, permission regression)cachedEntries > 50_000→ memory-budget anomaly; suggests either a runaway caller or a bug inremoveEntrycleanuptransitionsFired > 1000within one 10-minute window → likely a watcher feedback loop or mass unmount event
These thresholds are heuristic starting points — tune based on real-world telemetry once available. IPC latency is not instrumented here; loggerService is a log pipeline, not a metrics system, and Cherry has no telemetry backend. Per-IPC latency concerns should be diagnosed ad-hoc via performance.now() in the affected handler during investigation, not baked into a permanent counter.
12. Key Design Decisions
| Decision | Conclusion | Core rationale |
|---|---|---|
| Tree vs flat | Flat | FileEntry manages "user-submitted independent files"; directory organization is not a file_module responsibility |
| Mount abstraction | Removed | All internal files live flat under {userData}/Data/Files/ (via the feature.files.data path key); external is reached directly via externalPath; no mount needed |
| Origin two-state | internal/external | Express "Cherry-owned" and "user-owned, Cherry-referenced" respectively; clear semantics |
| External read/write permissions | Explicit user ops may change; Cherry doesn't auto-change | VS Code-style behavior model—change when told to; don't modify behind the scenes |
| External operation symmetry | write/rename/permanentDelete all delegate to the FS primitives and take effect; trash/restore touch DB only | Soft delete preserves reversibility (doesn't touch FS); hard delete is the terminal action (really deletes FS) |
| External identity | externalPath unique(where not trashed) | At most one active entry at a time for the same path; ensureExternalEntry upserts by path |
| Cherry tracks external rename | Not tracked | Best-effort semantics; external rename → dangling → user re-@ |
| Snapshot vs realtime stat | External row stores only identity + stable projections (name / ext from externalPath); live size / mtime via getMetadata on demand |
Eliminates stale-snapshot bug class at the type level; cost of the extra fs.stat is explicit at the call site instead of hidden behind a DB field |
| Dangling state carrier | In-memory singleton DanglingCache | Not in DB (avoids bidirectional DB-FS sync); three states present/missing/unknown; TTL-based lazy expiration (§11.6, 30 min); refreshed on query / FS observation / watcher; no periodic background sweep — IO cost scales with query frequency, not entry count |
| Dangling exposure method | File IPC getDanglingState / batchGetDanglingStates (never DataApi) |
DataApi is pure SQL; FS probe lives in IPC where side effects are expected; zero cost by default; parallel stat on demand |
| Watcher → DanglingCache wiring | Factory auto-wires | Business modules unaware of DanglingCache; a single watcher instance serves business events + dangling tracking |
| Content hash algorithm | xxhash-h64 | Optimal cost-performance for non-cryptographic scenarios (~20GB/s). 64-bit collision space is sufficient for distinguishing successive versions within a single file's write history — the xxhash-wasm package shipped in this version exposes only h32 / h64, and h64 is the strongest variant available; revisit if a 128-bit variant becomes a dependency-cost tradeoff worth taking. |
| Does write carry version | Split into write / writeIfUnchanged | Force the caller to explicitly choose; avoid silent degradation to blind write when version is forgotten |
| Atomic write fsync | On by default | Correctness guarantee takes precedence over performance; Cherry is not a high-throughput scenario |
| Trash model | deletedAt timestamp | parentId unchanged; naturally supports expiry; no system_trash entries |
| pending_fs_ops | Removed | After extreme simplification, orphan sweep suffices to cover crashes |
| Startup dangling probe | Removed | Changed to lazy + Promise.all; stat only when an IPC caller explicitly requests dangling state |
| Is Watcher a lifecycle service | No | DirectoryWatcher is a primitive; business modules new it via the factory; file_module doesn't actively watch |
| Directory import / bidirectional sync | Moved out of file_module | Business modules (Knowledge, etc.) implement this with DirectoryWatcher + their own mapping tables |
| AI SDK upload cache | Standalone file_upload table (deferred) | Decoupled from mount / remote; naturally aligns with SharedV4ProviderReference |
| Notes | File tree is an independent domain, not mirrored to FileEntry | If other modules need to reference Notes files, they use the origin of their choice via the corresponding path |
| CacheService integration for DanglingCache / versionCache | Not integrated; both stay bespoke | CacheService (src/main/data/CacheService.ts) is a general TTL KV + cross-window sync primitive. DanglingCache needs a path → Set<entryId> reverse index (§11.4), transition-aware event emission (§11.7 — fire only on genuine state change by comparing old vs new), and observedAt-based "TTL expired → re-stat then update" semantics (§11.6 — CacheService's TTL is "expired → deleted", which would destroy the prev-state comparison needed for transition detection). versionCache needs size-bounded LRU (§4.4), not TTL — a fundamentally different eviction policy; and lives as a per-FileManager-instance field for test isolation, not as a BeforeReady singleton. Wrapping either in CacheService would flatten the value schema, bolt on the secondary structures separately, and bypass the TTL layer — no logic shed, only domain expression lost. CacheService remains the right tool for future scenarios that genuinely match "simple per-id TTL cache" or "cross-window cache" shape (e.g. a short-lived batchGetMetadata result cache, a future FileUploadService provider-upload cache). |
13. Adding a New origin Variant — Developer Checklist
The origin field ('internal' | 'external' today) is the single most cross-cutting axis in the file module: its value implicitly drives storage layout, DB constraints, mutability policy, dangling semantics, UX language, and cleanup scope. Adding a new variant (e.g. 'archived', 'shared', 'synced') requires coordinated changes across the layers below. Missing any row silently breaks an invariant — TypeScript catches discriminated-union exhaustiveness but not CHECK constraints, not ad-hoc policy branches, and not documentation tables.
This checklist is the canonical addition procedure. A PR introducing a new origin MUST tick every row it touches (or explicitly justify a skip).
13.1 Type & Schema Layer
| Location | Change required |
|---|---|
src/shared/data/types/file/fileEntry.ts → FileEntryOriginSchema |
Add the new enum value |
Same file → new XxxEntrySchema |
Define the row shape for the new variant (which columns are nullable / required / branded) |
Same file → FileEntrySchema discriminated union |
Add the new schema as a union member |
Same file → any type guard helpers (isInternalEntry, etc.) |
Add isXxxEntry helper if code needs to narrow |
13.2 DB Schema Layer
| Location | Change required |
|---|---|
src/main/data/db/schemas/file.ts |
Review every CHECK constraint naming origin — fe_origin_consistency, fe_size_internal_only, fe_external_no_delete, fe_external_path_unique, etc. — and decide whether the new variant honors / violates / is exempt from each |
| Drizzle migration | Ship the constraint updates in the same migration as the enum expansion. Partial unique indexes on externalPath may need a new branch |
| Existing rows | No migration should run for existing rows unless the new variant has a natural subset mapping (unlikely) |
13.3 Path Resolution Layer
| Location | Change required |
|---|---|
src/main/services/file/utils/pathResolver.ts → resolvePhysicalPath |
Add the new entry.origin branch; decide storage layout |
Same file → canonicalizeExternalPath |
If the new variant is path-based and distinct from 'external', decide whether it shares the canonical form or needs its own normalization + brand |
13.4 Behavior Policy Matrix
Every ad-hoc if (entry.origin === 'internal') / === 'external' in the codebase is a policy decision that must be re-evaluated. Grep for both and review:
| Policy | Location |
|---|---|
| Trash-ability (who can soft-delete) | trash / restore in FileManager; DB CHECK fe_external_no_delete |
| Size snapshot storage | write / writeIfUnchanged internal-DB-update branch; toFileInfo projection |
| Name / ext as SoT vs projection | rename mutation; toFileInfo projection; FileEntrySchema field docs |
| DanglingCache participation | DanglingCache.check returns 'present' for internal; consider where the new variant falls on the present/missing/unknown axis |
permanentDelete semantics |
Does it touch physical files? Just DB? Refer to §6 and architecture.md §3.4 |
| Orphan sweep scope | §10 scans origin='internal' UUID files; does the new variant have a sweepable disk presence? |
| Explicit cleanup semantics | §7.2 forbids automatic dangling-entry deletion; decide whether the new origin is preserved, reported, or removable only through an explicit user/caller action |
| IPC dispatch applicability | architecture.md §3.3 tables per method — does each method make sense for the new variant? |
13.5 UX Layer
| Location | Change required |
|---|---|
| architecture.md §3.4 UX labeling convention table | Add a row for the new origin's permanentDelete user-facing label + confirmation copy |
| Product-side component copy | Concrete button labels / menu items that branch on origin |
13.6 Event & Observability Layer
| Location | Change required |
|---|---|
| architecture.md §3.6 event payloads | onEntryRowChanged.origin field value domain expands — TS catches via discriminated-union narrowing in the renderer binding |
| Observability logs | dangling-cache-snapshot and orphan-sweep records may need per-origin breakdowns if the new variant is material to diagnostics |
13.7 Documentation Layer
| Location | Change required |
|---|---|
architecture.md §1.0.1 Semantics of Origin |
Add a paragraph describing the new variant |
file-manager-architecture.md §1.2 Origin table |
Add the row — physical location, ownership, mutability |
file-manager-architecture.md §1.2 Invariants table |
Add the column — name / ext / size / externalPath behavior per-origin |
file-manager-architecture.md §12 Key Design Decisions |
If the addition surfaces a notable new trade-off, record it |
13.8 Gate
PR description MUST list each ticked row with a one-line justification, and each explicitly-skipped row with a reason. A reviewer MUST spot-check §13.2 (DB CHECK constraints) and §13.4 (ad-hoc policy grep) before approval — these are the two layers where silent bugs most often hide.