TLDR: A Home is the per-owner object that holds an owner's databases and domain services (
apps/api/src/lib/home/). Drive stores files through Mounts (apps/api/src/lib/mount/). A Mount is one storage root of an owner's Drive, with its own settings in the home'ssettings.json: a user has a default one, and a team has one per team drive. It is ametadata.dbpaths table over one storage backend,local,local-keyors3(apps/api/src/lib/storage/). Four things are not obvious from the code. Onlocala storage key is the name path, so every key-derived write holds the tree lock, one reader/writer lock over the mount's whole tree, and every rename takes it exclusively. Every storage read has a 30 s idle deadline, and a gone object answers 410 where an outage answers 503. Containers name users by email only, so they survive copy and restore anywhere. Folder sizes are a lazy cache, so a plain listing can write.
Drive is the file tree under every app. The Drive app shows it, WebDAV serves it to desktop clients (WEBDAV.md), and every doc, sheet, deck, drawing, stickies board and chat lives in it as a container: a folder named like a file, such as Notes.eigendoc, that holds the document's databases and media (ARCHITECTURE.md § Eigen file types). A request reaches a file through four layers: the route, SharedDrive, which checks access when the caller is not the owner (ACL.md), Drive, which spans the owner's mounts, and the Mount (ARCHITECTURE.md § Drive Architecture).
The idea underneath is that the mount's paths table is the tree and the backend holds only bytes. A row gives a file its name, its parent, its type and its sharing. The storage key is derived from the row: the name path on local, the id on the other two backends (§ A mount is a paths table).
getHome(ownerId) (apps/api/src/lib/home/get-home.ts) builds the Home on first use and caches it. A Home closes when nothing holds it and its last request is longer ago than its idle time (5 min for a user, 30 for a team). A request is a getHome, and the Home keeps its time (Home.touch). A hold pauses the closing, and a release only checks the rule again (Home.closeWhenIdle): it never starts a fresh idle, so a holder that comes and goes, such as an upload that retries on a failing bucket, never keeps a Home open. So when the last tab closes, its stream lets go, and a Home whose last request is long ago closes at once. A socket's document lingers a minute first and holds the Home meanwhile, so that Home closes when the linger ends. A tab that reconnects calls getHome, which is a request. openHome is getHome without the request, for background work such as a backup, which holds the Home while it captures like any other holder: a Home nobody requested closes when the capture lets go. Reading a part such as home.drive is no request either, so code that keeps a Home across long awaits, such as the pulls of a new-user reconciliation, holds it (withHomeHeld). Whatever may outlast the idle calls hold(): a backup capture, an open event stream (SSE.md), a collab socket (COLLAB.md), a collab document while it lingers (COLLAB.md), a live WebDAV lock (WEBDAV.md), an s3 upload on the wire (SYNC.md) and a long request. A long request is one that switches off the server's idle timeout, such as a Drive copy, convert, export or import, a calendar, contacts or mail import and a WebDAV COPY, or a Drive upload or a WebDAV PUT, whose body streams in. It runs inside withHomeHeld (on Home, with forwarders on Drive and SharedDrive), which holds every Home it works in from its access check until it answers, the target Home of a copy into another owner's drive included. An upload or a PUT checks write access first, so a user who may not write never holds another owner's Home. An import from another owner's file holds that owner's Home while it reads the file. Without the hold, a request that outlasts the idle with nothing else touching that Home finds its databases closed under it, a 500. A holder that must stop when the Home closes anyway, such as a stream, passes a callback that the close runs before the parts close. A closing Home refuses a new hold with a 503, because that holder would never hear the close. A team home gets the longer idle (TEAM_HOME_IDLE_MS overrides it), because no event stream holds it, only a socket on one of its documents. evictHome closes one on purpose (user deletion, backup restore), shutdownAllHomes all of them at exit, and while it runs openHome refuses every home with a 503, so nothing builds one again during the shutdown.
| Home | Folder | Services |
|---|---|---|
UserHome |
data/home/{userId}/ |
Drive, Mail, Contacts, Calendar, Notifications |
TeamHome |
data/team/{teamId}/ |
Drive, Calendar (ORGANISATIONS-AND-TEAMS.md) |
OrgHome |
data/org/{orgId}/ |
Only its filesystem |
GuestHome |
data/guest/{userId}/ |
Drive, Notifications (GUEST-ACCESS.md) |
A getter for a part the Home lacks, such as mail on a guest's or notifications on a team's, throws ApiError(404), so a route that reaches for it answers 404 instead of crashing. Code that may meet any kind of home checks first, through hasCalendar or instanceof TeamHome. A notice goes through Home.notify, which drops it on a home without a notification center. The home relay hands a team's notices to its members instead.
A Home serves its own owner. Another owner's data goes through home-relay.ts (SCALABILITY.md).
data/home/{userId}/
├── settings.json
├── mounts/
│ ├── shared.db paths others shared into this home (ACL.md)
│ └── {mountId}/ metadata.db, data/, thumbs/, tmp/, staging/ (s3 only)
├── eigen.mail/ mail.db, Maildir/
├── eigen.contacts/ contacts.db, avatars/
├── eigen.calendar/ calendar.db
└── eigen.notifications/ notifications.db
The names come from PATHS in apps/api/src/lib/config/paths.ts. Mail, Contacts and Calendar reach their folders through home.fs, a LocalFilesystem with the atomic and durable write family those domains need. Contacts and calendar events live as BLOBs in their databases, with columns projected from them (CONTACTS.md, CALENDAR.md). staging/ sits outside tmp/ so the stale-temp sweep can never purge an upload the bucket has not acknowledged (SYNC.md). A JSON file such as settings.json is written through writeAtomic, which stages a dotted .tmp- copy beside it and renames it over. A crash between the two leaves the copy, so Home.init sweeps the home's root of copies older than a day (STALE_TEMP_MAX_AGE_MS) before it loads the settings; a younger one may be a write still in flight.
A container (.eigendoc, .eigenchat and their siblings) is the portable unit. It is copied, moved and version-restored as a self-contained set of files. So its databases reference users by email only: chat authors, comment authors, assignees and mentions. Home-level databases (metadata.db, contacts.db, calendar.db, notifications.db) sit beside their owner and may hold user ids. Because containers carry no ids, a copy or a backup restore keeps every user reference intact on any server and in any id space.
Every Drive row lives in the mount's paths table (apps/api/src/lib/mount/schema.ts). The bytes live in a StorageBackend (apps/api/src/lib/storage/types.ts):
| Type | Where a file's bytes live |
|---|---|
local |
data/ under the file's name path, so the tree on disk mirrors Drive |
local-key |
data/{id}.{ext}, flat |
s3 |
{prefix}/{id}.{ext} in a bucket: a plain file is PUT in its request, a container database is written behind by the upload queue (SYNC.md) |
An id key never moves, so on local-key and s3 a rename, a move or a trash changes only the row. On local the same operations rename files and directories on disk. That difference drives the tree lock and the .trash/ directory (SOFT-DELETE.md).
read() returns a StorageFile, a lazy BunFile or S3File that holds no bytes yet. A local file goes into a Response as is, which keeps serving zero-copy. An S3File goes in as file.stream(), since it takes no ResponseInit options. Both local backends resolve every key through resolveWithinBase (apps/api/src/lib/core/path-utils.ts), and S3Storage validates key segments, so no key escapes its base.
A write to an existing row runs under Mount.withPathLock(pathId): an overwrite, a rename or move, a trash, a restore or a permanent delete, a version snapshot on the container, the chat restore. The path locks and the tree lock below belong to the mount's folder, not to a Mount object, so a backup's own Mount of a disabled mount and the one an enable or a storage re-point builds wait for each other. A backup holds a file's path lock while it copies the file, because LocalStorage.write rewrites a local file in place, so a read beside an overwrite would end short (BACKUP.md). A create has no row to lock yet. On local it takes a path lock on its folder and name instead (withCreateLock), because two creates of one name would write to one name path: the second waits, finds the first one's row and answers 409 before it writes. Off local each create writes to its own id key, and the partial unique index on (parentId, LOWER(name)) over untrashed rows closes the race between two creates of one name. The loser gets a 409 and removes the object it wrote. Off local no lock keeps a folder's permanent delete out of a create, so the foreign key on parentId refuses a row under a deleted folder: that create answers 404 and removes its object too. A move or a restore into a folder deleted meanwhile answers the same 404 (rethrowPathWriteError). So does one into a folder trashed meanwhile, which that folder's permanent delete would take with it: the move or restore checks every folder from its target up to the root in the transaction of its row write (targetFolderErrorInTx). A rename or move on local checks its target name again under the exclusive tree lock, and what is on disk there (SOFT-DELETE.md), before its disk rename, which would replace a sibling's bytes at the target. A create checks its name under the shared tree lock, right before its write, and on local what is on disk at its key, so one queued behind a rename, move or restore that took the name answers 409 and writes nothing.
LocalStorage.write recreates any missing directory (createPath: true). So a save that resolved its key before a folder rename would rebuild the old folder and orphan the save. A local mount therefore has one reader/writer lock over its whole tree (Mount.withTreeShared / withTreeExclusive, apps/api/src/utils/rw-lock.ts). On s3 and local-key both methods pass straight through.
- Shared: every write whose key comes from the paths table, from the key resolution through the storage call and the row write, so the row and the bytes agree. That covers file writes and creates, folder creates, a file delete and a managed database's sync.
- Exclusive: every storage rename of a file or a directory and every directory removal: rename and move, trash, restore and folder delete.
Waiters are served in arrival order. A rename queued behind a stream of saves runs after the saves in flight and before the saves that arrive after it. The lock order is path lock, then document-db slot, then tree lock. A path's acl: chain (Drive.runInOrder) comes before all three. A trash holds its item's chain while it takes its mount locks, and it takes the chain of each path below the item, top-down, only after it released them. A rename and a restore take their item's chain only after they released their mount locks (ACL.md). A create's lock on its name is taken right before the tree lock, and its holder takes no slot, so a version snapshot at a tick or a close takes one while it holds the slot: it waits on the create lock of its versions/ folder or its snapshot file. That is the only path lock a slot holder waits on. The snapshot only try-locks its container, so a slot holder never waits on a row's path lock. A tree-lock holder takes no further lock, because a shared region inside a shared region deadlocks once an exclusive is queued. A reader that holds a container database takes no path lock while it holds, because a close under that lock waits for it (DATABASE.md). Most reads take no lock, and one that races a move answers a transient 404, a document-db open a 503. A Drive copy and the download of a document or a version into a temp file read under the shared tree lock. A backup's copy of a file holds the shared tree lock and the file's path lock. apps/api/src/test/storage/overwrite-ancestor-move.test.ts pins the races.
Bun's S3Client takes no timeout and no signal, and gives up on a silent request only after about 360 s. Eigen's own bound is STORAGE_TIMEOUT_MS (30 s, apps/api/src/lib/storage/deadline.ts). S3Storage races exists, size, list and delete against it. An exists or size that times out or fails answers 503, and only a missing key is false or null. A timed-out delete returns false like any failed delete.
Every storage read the server consumes itself runs through streamStorageFile, the storage form of the one stream loop consumeStream. A read that delivers no byte for 30 s is cancelled with a 503.
On a warm local file consumeStream never gives up the event loop, so a read that does not hold the file's path lock ends before an overwrite that arrives during it can start. That matters because a local write goes into the same file, not into a temp file renamed over it, so a reader of a live file and an overwrite must not interleave. The cost is that such a read holds the API for its length (ROADMAP.md). Only the backup passes yields, which gives a turn every 2 MB: it holds the path lock of each drive file it copies, and nothing rewrites its own staged copies.
Most reads also pass the mount's downloads signal. closeAllDatabases and Drive.destruct abort it first, so no teardown waits on a stalled download or extraction read. Copy and version snapshots read without it, because a close-time snapshot runs after that abort.
A file served to a client (/download, /embed, WebDAV GET) is the exception. Its stream goes straight into the Response, so a stalled body there is bounded only by the server's 200 s idleTimeout. It holds no lock and no Home while it waits.
Hetzner Object Storage sheds load with a 503 SlowDown, and the same request a second later succeeds. Bun's S3Client never retries a read (its retry covers only multipart parts), so one throttled HEAD failed a whole docx export. retryStorageRead (apps/api/src/lib/storage/deadline.ts) gives exists, size, list and every streamStorageFile GET three attempts, about 200 ms and then 800 ms apart, with jitter. It retries a throttle, a 5xx, a refused or closed connection, and Bun's UnknownError, which is all a HEAD reports for any status but 200 and 404, a 403 included. A missing key and a definite refusal (NoSuchKey, AccessDenied in a GET body) fail at once. Writes are not retried: a whole-object PUT could be replayed, but the upload queue has its own backoff and orphan accounting, and on a versioned bucket each replay adds a version. Retrying the direct PUTs is in ROADMAP.md.
A HEAD and a list share one deadline over all their attempts: storageRead runs them inside the 30 s of withStorageDeadline, whose signal stops a wait when it fires. Each GET attempt has its own idle deadline, and a GET that stalled into it is not retried, so a GET whose attempts each fail late can take up to three deadlines. A GET retries only while no byte has reached the caller, since a second GET would hand it the first bytes again, and the mount's downloads signal stops its wait. The last failure keeps its 503. apps/api/src/test/storage/s3-read-retry.test.ts pins this. The test preload shrinks the waits to 1 ms (setRetryWaitsMs), and that file puts the real ones back where it times them.
Only the GET body tells a missing object from a sick bucket: NoSuchKey on S3, ENOENT on disk (isMissingObjectCause). Mount.downloadKeyToTemp answers that with 410 (storageGone), every other storage failure with 503, and a local one (the tmp/ write or rename) with 500, so a full disk does not read as an outage. A 410 tells the client to stop retrying. The collab tab retries a 503 or a 500 (COLLAB.md), an HTTP client only a 503.
A container database's open reads the freshest copy first: the crash temp, then the staged copy of an unacknowledged upload, then the stored object (SYNC.md). So a 410 means no copy exists anywhere, and a version restore is the way back. On a local-key mount the open does not GET but stats data.db, and answers 410 only on ENOENT.
Drive.create (apps/api/src/lib/drive/drive.ts) creates the container folder, then provisions it (ChatRoom.create or CollabDocument.create, plus the comment row a card chat seeds). When provisioning throws, mount.deletePath removes the row and the error propagates. That delete sends no SSE, because the row was never announced. On a remote mount it cancels the container's queued uploads, so a staged PUT cannot bring the object back. The name is free again, so an immediate retry with the same name starts clean. A rollback that itself fails is logged and the container stays: a row nobody has seen, which holds the name and fails every open. No scan finds it: the orphaned-container scan is open work in the "Data integrity + verified backups" row of ROADMAP.md.
The stem passes the mount's name rule (validateName, apps/api/src/lib/mount/names.ts) before the extension goes on, so an empty stem cannot become a nameless .eigendoc. Names are stored NFC. Every dedup (copy, upload, chat dedupeName, trash restore) compares in NFC, and getUniqueFileName trims the stem so the (n) suffix still fits the 255-byte limit.
Slow storage can make a create look failed when it is not: the request times out or answers 503 while the server keeps writing, and the row lands seconds later. So the two create hooks (useCreateDriveItem and useCreateChatRoom) post through createWithReconcile (packages/lib/src/core/drive/reconcile-create.ts) with a 15 s timeout.
Before posting, the hook lists the folder once and keeps the ids it sees. After an abort, a network error or a 5xx it polls that listing for the name it sent. A row matches when it has that name and an id the snapshot did not hold. That means created by this request or by a concurrent create of the same name, with no clock on either side. A same-name sibling that existed before is in the snapshot, so it never passes for ours. A match resolves the mutation as a success, and a miss throws CreateUnconfirmedError, whose message is the toast.
- A 4xx is never reconciled: it is the server's definitive no (409 duplicate name).
- A failed snapshot skips reconcile, since there is no honest anchor.
- The chat wizard can create without a parent. The route then resolves the lazily created
chatsfolder, which no client endpoint names, so the hook polls the mount's chat listing instead. - A chat create through
dedupeNamelets the server suffix the name (Name (2)), so no poll could find it. It passes noexpectedNameand runs no polls. An indeterminate failure still becomesCreateUnconfirmedError, so a chat that may well exist reads as slow storage rather than a raw timeout.
packages/lib/src/test/core/drive/reconcile-create.test.ts pins the contract.
A folder row caches its recursive size in paths.size, and NULL means stale. A mutation does not recompute. It NULLs the whole ancestor chain (invalidateSizesFrom, or invalidateAncestorsOf for a content write). That runs once the mutation has committed, so a failure there is logged and leaves a stale total, never a 500 for a write that happened. The next read that hydrates the folder (toDrivePath) recomputes the subtree bottom-up in one transaction and writes the totals back, reusing every descendant total still cached.
So a GET can write, which matters for any read-replica idea. The first listing after a large move or delete pays the recompute, and every later read is a column read. A trashed item hangs under the mount root and is left out of the totals (SOFT-DELETE.md).
A container that opts in (collab documents and chats, the snapshot key in their database config) keeps file-level snapshots of its data.db in <container>/versions/<iso-ts>.db (apps/api/src/lib/versioning/). ManagedDatabase takes one every 100 writes and on close, and each snapshot prunes by the retention policy. A database adopted from a crash temp (SYNC.md) owes its close a snapshot even with no new write, because the fresh connection counts no change for the recovered tail.
- A manual save and the pre-restore snapshot block on the container's path lock, because an explicit user action must never skip. They read the live db, or with none open the crash temp an unclean shutdown left, then the staged copy, then the stored object (
stageManagedDbCopy). - The timer and close path try-locks and skips when the lock is held (
trySnapshotContainerDataDb). It runs inside a close that a lock holder may be waiting on, and a skip loses one history entry, never bytes. - A restore first copies the chosen snapshot to a temp file, since the pre-restore snapshot prunes and could delete it. It then replays the snapshot into the live Y.Doc for a collab document (COLLAB.md) or overwrites the chat's
data.dbbytes under the same row (replaceContainerDataDb): one slot call waits for a chat request still reading the db, closes it, removes its crash temp and writes the bytes, through the upload queue ons3and as a fresh file onlocal-key. No lock is held across the steps.
A version keeps the media it names: as long as a saved version names a file, a restore can bring it back, so nothing deletes a file while versions exist. Only Delete history and a copy run the sweep that frees the rest (MEDIA-REFERENCES.md).
Delete history (DELETE /drive/:ownerId/:mountId/file/:pathId/versions, Drive.deleteVersions) deletes the versions/ folder of the document and of each comment thread in its chat/, each under the path lock its snapshots take, so no version lands in a folder while it goes. Only an owner of the drive may (requireDriveOwner), as for a delete forever in the trash, because nothing brings a version back: a shared editor gets 403. The document's history gets a versions-deleted entry, as a restore gets version-restored (FILE-HISTORY.md). Then it sweeps the document at once, open or not, so what only the versions named goes in the same request, except the media and threads of the last 72 hours (MEDIA-REFERENCES.md). A document only ever shrinks this way: no sweep runs on its own.
A document's parts, such as its data.db and comments.db, its versions/ folder and each version in it, its comment threads and the files in its media/, change only with the whole document. A database renamed or trashed on its own breaks the document, a version moved out leaves a gap in its history, and a thread trashed on its own would leave the sweep a thread that is neither live nor gone. So a person's trash, rename or move of anything inside a document answers 400, the owner's request included (Drive.refuseInsideDocument, in deletePath, renamePath and movePath). The check runs when the call names a user, which WebDAV, SharedDrive and the routes do. Server code names none, such as ChatRoom.deleteMessage, which still trashes a deleted message's attachments, because the file system holds the truth of what a message still has. One route names none either: creating a chat (routes/chat.ts) rolls back a new chat whose sharing failed with deletePath and no user.
A write that puts bytes in a document has a narrower check (Drive.refuseDocumentPart). New content for a database or a version is refused, and so is any create, upload, copy or move into versions/, because a file put there would list and restore as a version. Every lookup of versions/ ignores case, as Mount.getChildByName does, so the check folds the name (isVersionsFolder). A document has no versions/ before its first saved version or after a Delete history, so no create, copy or rename gives a child of a document a name that folds to versions (refuseVersionsName). The databases need no such check: a whole document always has its data.db, and a collab document its comments.db, so a twin of either is a name taken (409). That leaves the databases free for a copy to another drive, which makes the new container first and then writes its databases into it through the target's Drive methods. The rest of a document stays writable, so a writer still uploads into its media/ or starts a comment thread. WebDAV refuses every write inside a document with a 423 before it gets that far. apps/api/src/test/drive/container-write-guard.test.ts pins both checks.
Every copy goes through Drive.copyPath, from the copy route and from WebDAV COPY alike, and one walk does it (apps/api/src/lib/mount/copy.ts). It lists the whole source tree first (listCopy) and writes only that list (writeCopy). A walk that listed each folder as it reached it would copy its own output: a move of the target into the source while the copy runs, or two copies in opposite directions between two drives, would feed it new folders until the disk filled. A copy within one owner and mount writes through Mount.copyPath. Any other copy writes through copyPathAcross (apps/api/src/lib/drive/copy-across.ts), which re-uploads each file and recreates each container typed through the target's own Drive methods, so the target checks every write. A byte copy of a container is a valid independent document, because its internal children reference each other by name, not by path id. The listing flushes each document's open data.db and comments.db first, so the copy has the latest edits and comments, and leaves versions/ behind, so the copy starts with a clean history. A copied file keeps the source's media facts and thumbnail. originalName and the WebDAV dead properties stay with the source.
A copy writes every part of a document as bytes, and then sweeps each document it made with no grace (sweepCopy). So the copy holds what its state names and nothing else, such as no image only a version named and no thread of a deleted comment, while the source keeps everything (MEDIA-REFERENCES.md). The sweep deletes only a part the copy wrote, matched by name against the listing the copy wrote from. A copy to another drive shows in the target's tabs as each piece lands, so an image, a comment or a document someone adds to it before the sweep runs stays. That copy sweeps through the target's Drive. A sweep that fails only logs and leaves the copy whole, as the copy itself succeeded.
The copy route (POST /drive/:ownerId/:mountId/path/:pathId/copy) dedups the destination name itself, not in Drive.copyPath, so WebDAV COPY keeps its overwrite and 409 behavior. A file copied to another mount is uploaded there, so the upload picks its name. Every copy needs read on the target folder, wherever its source lives.
Every free name picked before a create (a copy, an upload or a WebDAV PUT, a trash restore, a chat the wizard names) goes through withFreeName (apps/api/src/lib/drive/naming.ts). In one step with no await in it, it reads the folder's names, skips the names of the creates still running in that folder, and reserves the name it picks in one map keyed by owner, mount and folder id. The step can be one because the mount reads its SQLite table synchronously (Mount.hasLiveChild, Mount.liveChildNames), so no other create lands between the read and the reservation. The reservation lasts until the create ends, and by then its row is in the folder for the next read to see. So creates of one name into one folder at once each get their own name, however many there are, and a name trashed, moved or deleted is free again at once, even while other creates run. Nothing retries and nothing waits: a reservation is a set entry, not a lock, so uploads and copies into one folder all run in parallel. A create that fails gives its name back.
One map is enough because the API is one process. It holds the data dir's instance lock (holdInstanceLock), so every upload and copy on the server passes through that map. A writer that names its item itself, such as a new folder, a WebDAV MKCOL, a rename or a move, reserves nothing and answers 409 when the name is taken (nameTakenError). It can still take a name a create reserved, and then the reserving create gets that 409 instead. The losing create leaves no bytes: on local its create lock makes it fail before the write, and elsewhere it fails before the write or removes the object it wrote. Drive.copyPath refuses to copy a folder into its own subtree for both callers with a 409, as RFC 4918 asks. A move makes the same check inside Mount.updatePath, in one synchronous transaction with the row write, so two opposite moves cannot form a cycle between check and write. A cycle would spin every recursive walk over the tree.
Drive.copyPath checks that list against the target's limits before it writes anything, so a REST copy and a WebDAV COPY meet the same ones. Every copy must fit the target's free space (enforceMountQuota, 507), counted by copySize over the listed tree, so without the versions/ of each document inside, since the copy leaves those behind. A file must also fit the per-file upload cap (enforceMaxUploadSize, 413), as an upload must (QUOTA.md). A copy to another drive checks write access on the target folder before it lists the source, so a user who may not write there flushes no document and reads no limit of the target's owner.
A folder or document copy creates its top folder first and then copies the children, so a child that fails, such as one whose bytes are gone, would leave a half-built tree, and every retry another. So a copy that throws removes its top folder through Drive.deleteFailedCopy before the error goes on, in one mount and across drives alike. Nothing is kept first, because the source is untouched. Others can put their own paths in the half-built folder while the copy runs: across drives the target announces each folder as it lands, and in one mount a listing of the target shows it. The walk records every path it creates, and deleteFailedCopy deletes the folder for good only when everything in it is one of those and nobody shared any of it since. It skips the trash then, because trashed bytes still count against the target's quota and a writer copying into another owner's folder cannot empty that owner's trash, so every failed retry would fill a stranger's drive. Anything else in the folder sends it to the trash like any delete, so a file someone moved or uploaded there can be restored. The check and the delete are not one step, so a create that passed its folder check before the cleanup began can still land and be deleted for good with the folder (ROADMAP.md). Either way the cleanup takes the lock, WebDAV-lock and document handling of Drive.deletePath, and across drives it needs write access on the folder. A crash mid-copy still leaves the partial tree (ROADMAP.md).
The copy route and WebDAV COPY send nothing while a deep tree copies, so both lift the server's 200 s idleTimeout for their request (server.timeout(request, 0)). Otherwise the client would see an empty reply and retry into a duplicate tree.
The client copies a batch with one route call per item, all at once (copyEach in packages/lib/src/core/drive/hooks/writes.ts). When only part of it copies, useCopyPaths and useCopyToFolder throw PartialCopyError with the copies that landed and the sources that failed, and a dialog retries only the failed ones. The route gives each copy a free name, so a source sent again would land a second time. useCopyToMediaFolder, for a document's or a chat's media, shows that error's toast and returns the copies that landed, so the editor still inserts them. The error's message ends with the first failure's reason. When nothing copies, every hook throws the first server error instead, so the toast says why and a card's Save stops.
A move never crosses mounts. It would change the row's owner, mount and id, and that breaks shares, links and history.
time() (apps/api/src/utils/timing.ts) logs a [timing] line for a wrapped operation past 100 ms, and a ManagedDatabase logs Synced after each push to storage. Both stay on in production, by the owner's choice: they are how a slow request or a stuck sync shows in the logs. Don't gate them behind an environment check.
- SOFT-DELETE.md: trash and restore
- FILE-HISTORY.md: the event log and watches in
metadata.db - SYNC.md: the upload queue, staged copies and freshest-first reads on
s3 - DATABASE.md: schemas and migrations
- ACL.md: permissions and
shared.db - PREVIEWS.md: thumbnails and cached previews in
thumbs/andtmp/previews/ - QUOTA.md: what counts toward a mount's size