# Sync flow
How `openFile`, `syncCurrentEditor`, `syncTextsWithServer`, and `syncLocalFileWithServer` hand off work, and how the server knows what to send or receive.
## Triggers and who calls whom
```mermaid
flowchart TD
U[User: sidebar click, link click, popstate] -->|openFile| OF[openFile]
Timer1[setInterval 1000ms saver] -->|syncCurrentEditor| SCE[syncCurrentEditor]
Focus[focusin / focus event] -->|syncCurrentEditor| SCE
Timer2[setInterval syncTextsWithServer + syncMediaFiles] -->|syncTextsWithServer| STS[syncTextsWithServer]
OF -->|save previous editor before swapping| SCE
SCE -->|switchAwayEditor=false: end of fn| SLF[syncLocalFileWithServer]
STS -->|per-file path| POST1[POST /syncFile per file]
SLF --> POST2[POST /syncFile]
STS -->|batch modified/deleted| POST3[POST /syncFilenames]
```
The red node is the drift-seal line (files.js:1028). If `currentEditor` was rotated by anything during the yellow await above, this write lands on the wrong editor instance.
## syncCurrentEditor - both flag branches
```mermaid
flowchart TD
Enter([syncCurrentEditor switchAwayEditor]) --> G1{files undef, debug,
or path undefined?}
G1 -->|yes| Ret1([return])
G1 -->|no| G2{isSaving OR
isMessingWithCurrentEditor?}
G2 -->|yes| Ret2([return])
G2 -->|no| SetFlag[isMessingWithCurrentEditor = true;
path = currentEditor.path]
SetFlag --> IsInbox{path == INBOX_PATH?}
IsInbox -->|yes| InboxBranch[chat sync logic]
InboxBranch --> Ret3([return])
IsInbox -->|no| RenameCheck{firstLine's filename
!= current filename?}
RenameCheck -->|yes| Rename[await remove path
await getFileHandle newPath
await writeIfContentIsDifferent
await renderSidebar]
Rename --> Ret4([return])
RenameCheck -->|no| DiffCheck[await isContentEqual
path, getCurrentContent]
DiffCheck --> SameGuard{isCurrentEditorSame?}
SameGuard -->|no| Ret5([return])
SameGuard -->|yes| ModCheck{contentWasModifiedLocally
AND editor.isClean?}
ModCheck -->|yes| SwitchGate{switchAwayEditor?}
SwitchGate -->|true: skip reload| Clear[isMessingWithCurrentEditor = false]
SwitchGate -->|false: reload from disk| Reload[await openFile path, false]
Reload --> Clear
ModCheck -->|editor dirty| Save[write editor content to disk;
editor.markClean]
Save --> Clear
ModCheck -->|neither| Clear
Clear --> ServerGate{switchAwayEditor?}
ServerGate -->|true: skip server sync| Done([return])
ServerGate -->|false| PushServer[await syncLocalFileWithServer path]
PushServer --> Done
style Rename fill:#faa,stroke:#900,color:#000
style Reload fill:#fca,stroke:#c80,color:#000
style SwitchGate fill:#cfc,stroke:#070,color:#000
style ServerGate fill:#cfc,stroke:#070,color:#000
```
The two green gates are the `switchAwayEditor` branches. The orange `Reload` is the one we neutralised (it used to recurse into `openFile` without an `el` arg and clobber the main editor). The red `Rename` block is the executioner that actually deletes and creates files on disk - still live, fires whenever first-line header disagrees with filename.
## Sync with the server - batch vs per-file
```mermaid
sequenceDiagram
participant Client
participant Server
Note over Client: syncTextsWithServer fires
Client->>Client: collect modified and deleted files (skip editor and editor2 paths)
Client->>Server: POST /syncFilenames with modified, deleted, timestamps
Server-->>Client: files, timestamps, renames
Client->>Client: write non-current files to disk and update server.files snapshot
Client->>Client: advance per-dir timestamp pointers
Note over Client: syncCurrentEditor finishes the switchAwayEditor=false branch
Client->>Client: syncLocalFileWithServer for the active editor
Client->>Server: POST /syncFile with path, lastModified, clientLastModified, clientLastSynced, content
alt notModified
Server-->>Client: notModified
Client->>Client: advance lastClientModified only
else updatedOnServer
Server-->>Client: updatedOnServer with new lastModified
Client->>Client: record the server lastModified, no disk write
else merged or ok
Server-->>Client: content and lastModified
Client->>Client: writeIfContentIsDifferent, then openFile if path matches editor.path
end
```
### How the server knows there's something to sync
Two mechanisms, running in parallel:
1. **Batch: `syncTextsWithServer` → `POST /syncFilenames`.** The client sends:
- `modified`: files whose disk `lastModified` is newer than the `lastClientSynced` pointer recorded in `server.files` for that path.
- `deleted`: files present in the client's `server.files` snapshot but no longer on disk.
- `timestamps`: a per-directory pointer telling the server "everything I've seen up to here." The server replies with files newer than each directory's pointer. **The two currently-open editor files are skipped on both send and receive** (`files.js:230` and `files.js:577`) - they're handled by the per-file path instead, to avoid racing with the user's active edits.
2. **Per-file: `syncLocalFileWithServer` → `POST /syncFile`.** Called at the end of each `syncCurrentEditor` (when `switchAwayEditor=false`). Sends the single file's content plus its `lastModified` + `clientLastModified` + `clientLastSynced`. The server compares timestamps and responds with one of four statuses that the client maps to either "advance pointers only" or "write this content to disk."
The client's `server.files` object holds the triple `(content, lastModified, lastClientModified)` per path - this is the client's view of what the server thinks the world looks like, and the basis for deciding which files to include in the next `modified`/`deleted` lists. Persisted to `localStorage` under `SERVER_STORAGE_KEY`.
### Auth gate: `lastServerOk`
The auth token lives in an HttpOnly cookie, so JS can't see it directly. Instead, every successful response from the server stamps `localStorage.lastServerOk` with `Date.now()` via `markServerOk()` (files.js). `hasLastServerOk()` returns true if that key exists - which it only can if the server has previously accepted us. Use this as the gate before kicking off sync work: no stamp ⇒ no token ⇒ skip the request entirely. The flag is set in:
- `app.js` after the `/issuePermanentToken` exchange returns 200
- `post()` after a 2xx response (covers all `/syncFilenames`, `/syncFile`, `/syncMediaFilenames`, `/syncMediaFile` upload calls now that they go through this helper)
- `syncMediaFiles` directly after the raw `POST /syncMediaFile` download (binary blob, can't share `post()`)
If the server later 401s, the stamp stays - but the request will simply fail and no sync state advances, so we don't need to clear it.
## File deletion propogation across clients
A delete on one device has to travel through the server and reach every other device that still holds the file. The mechanism is an append-only `fslog` on disk: every server-side `userFS.Del` writes a ` del ` row, and every `/syncFilenames` response carries the deletes a given client hasn't seen yet.
### Why we need this log at all
Without it, the server only knows what currently exists on disk - it has no memory of what *used* to exist. Sync responses only list present files. So Client B, which still holds the deleted file locally, would see "this path is on my disk but not in the server response" and conclude it's a *new* local file → it would re-upload `foo.md` and the file resurrects. The fslog gives the server a memory of deletions, so it can tell B "yes, this used to exist, but it was deleted at time T - drop your stale copy."
```mermaid
sequenceDiagram
autonumber
participant A as Client A
participant S as Server
participant L as fslog
(append-only file)
participant B as Client B
Note over A,B: Steady state: both clients hold foo.md locally,
server has foo.md on disk
rect rgb(245, 240, 230)
Note over A: User deletes foo.md in the PWA
A->>A: moveFile("/foo.md", "/archive/foo.md")
(local FS only)
A->>S: POST /syncFilenames
{ deleted: ["/foo.md"],
modified: [{path:"/archive/foo.md", ...}],
serverTime: }
S->>S: userFS.Del("foo.md")
removes from disk
S->>L: append " del /app/storage//foo.md"
S->>S: deletes = DeletesLog(uid, req.serverTime+1)
→ {"foo.md": }
S->>S: suppress echo: drop entries that
match request.Deleted
S->>S: write /archive/foo.md
S-->>A: response.deleted = {} (A's own delete
was filtered)
response.files = [...]
end
Note over A,B: ...time passes, B opens app or hits sync interval...
rect rgb(230, 240, 245)
B->>S: POST /syncFilenames
{ deleted: [], modified: [...],
serverTime: }
S->>L: scan fslog for this user
S->>S: deletes = DeletesLog(uid, req.serverTime+1)
→ {"foo.md": }
(no suppression: B didn't delete it)
S-->>B: response.deleted = {"foo.md": }
response.files = [archive/foo.md, ...]
B->>B: for each (path, deletedAt) in response.deleted:
local = getMemFile(path)
if local && local.lastModified ≤ deletedAt:
await remove(path); removeServerFile(path)
B->>B: write archive/foo.md from response.files
end
Note over A,B: Both clients converged:
foo.md gone, archive/foo.md present
```