1# The data layer and sync
2
3OneNote 2010 got multi-user editing without a server. A notebook is a folder
4on a file share. Every client edits the section files in place, and the
5format's append-only revisions plus some careful file locking let clients
6merge each other's work. Snowbound joins that arrangement as one more peer. It
7has no service, no account and no sync protocol of its own. The share is the
8source of truth, and whatever OneNote can do to a file while Snowbound is
9using it, Snowbound has to handle.
10
11This essay follows an edit from the keyboard to the share, then covers what
12happens when the share is busy, changed or gone.
13
14## Three threads
15
16```text
17UI thread (winit, UIKit) section thread sync thread
18──────────────────────── ────────────────────────────── ──────────────────────────────
19editor emits Edit (ops) ───► apply to the parsed Section read the stamp (header + length)
20 and returns at once write each burst to SQLite unchanged: ask for a seal,
21page reads ◄──────────────── answered from the Section publish it
22events ◄──────────────────── Changed, Rejected, published changed: read once, ask
23 seal ◄────────────────────────── for a rebase
24 rebase onto the new image ◄───── acknowledge: base += transaction
25```
26
27- **The UI thread only emits ops.** The editor records the ops each change
28 lowers to as it makes the change. Submitting them to the session returns
29 immediately. Parsing, revision building and SQLite never run on a frame.
30- **The section thread** (`notebook::working`) is the only owner of the parsed
31 `onestore::Section`. It applies edits, writes each burst of them to the
32 replica in one durable commit, and answers page reads. A rebase runs on a
33 fresh thread that builds its own section while the old one keeps answering
34 reads, so opening a page never waits on the network.
35- **The sync thread** (`notebook::worker`, stepping `notebook::sync`) does all
36 network I/O. It reads the stamp when a local edit waits or the notebook's watch
37 reports the file (every two seconds where nothing watches), then publishes or
38 rereads. Idle, it reads nothing.
39
40`notebook::session` wraps this up for apps. `Notebook` covers discovery and
41structure, `Section` covers one section's pages, edits, events and conflict
42pages. Both desktop and iOS use exactly this surface.
43
44## The replica
45
46Each open section has a replica: a SQLite database in the app's cache, named by
47where the notebook is and the section's logical identity, so the same file
48reopens the same queue after a relaunch. It holds:
49
50- **the base**: the last remote image the queue applies to, stored in chunks so
51 that acknowledging a publication rewrites only the chunks the transaction
52 touched;
53- **batches of edits**: each edit's ops, serialized, grouped into the batch
54 that will publish together;
55- **payloads**: picture and attachment bytes, stored once each, keyed by hash.
56
57The working state is never stored. It is the base with each sealed batch
58replayed and the open batch applied, rebuilt on open.
59
60A password-protected section's replica holds nothing in the clear. Its base and sealed
61transactions are the file's own ciphertext, and its edits and payloads are sealed with
62AES-256-GCM under a key derived from the section's, so the queue opens, offline or after a
63relaunch, only once the section is unlocked. A locked section is never opened in the
64background; its edits wait until it is unlocked again. A replica of a section another device
65then protects is deleted the next time the notebook is read, unless edits wait in it; those
66wait for the key, then come back as copies of the pages they changed. The database runs in WAL
67mode with full synchronous commits, and every setting is read back to check it
68took.
69
70The location matters because OneNote lets a notebook folder be copied whole, a
71Finder duplicate or an iCloud `Name 2` conflict copy, and every section in the
72copy keeps its identity. Keyed by identity alone, the copy would open the
73original's queue and publish its edits into the wrong file. So replicas live in
74`cache/replicas/<hash of location>/<identity>.sqlite` (`notebook::location`),
75where a location is a canonical local path or `smb://server/share/root`, and a
76`location` file in each folder names it. A section renamed or moved inside its
77notebook keeps its replica; a copy of a section inside one notebook (`Cross
782.one` beside `Cross.one`, which OneNote opens as a section of its own) is keyed
79by its own path instead, the original being the one the folder's TOC lists.
80
81When a notebook moves, its queue follows by one of three roads:
82
83- **the app moved it** (iOS Rename, or Notebook Properties' "Rename the folder
84 too"): `location::moved` moves the folder's replicas to the new location, the
85 latter through `Notebook::rename_folder` once no other writer holds a file;
86- **something else moved it** (Finder): on open, a section with no replica takes
87 one from the folder of a local location that no longer exists, if the file
88 stands as that replica's base or has moved on from it (a higher header
89 generation). A replica whose base is newer than the file, or a different state
90 of the same generation, belongs to another copy and stays;
91- **an older install** named replicas by identity alone in the cache's top or
92 its `smb` folder: the first location to open the section takes it.
93
94An attached file is a payload like a picture: its bytes ride in the edit that
95inserts it and publish inside that edit's revision. No other file is written,
96so nothing needs to reach the share before the revision that names it. That
97ordering would matter only for `_onefiles` payloads, which OneNote 2010 never
98writes ([file format](file-format.md)). A large file makes a publication as
99large as itself.
100
101A keystroke that publishes immediately costs three commits: the edit, the seal
102(which also records the publication attempt), and the receipt. Each one is
103ordered against something outside the cache. The edit must be durable before
104`apply` answers. The attempt must be durable before any byte reaches the
105share, because an attempt that might have landed is never replayed blindly.
106The receipt follows the file's own commit. Merging any two of them would open a
107window where a crash forgets or duplicates work.
108
109## A sync step
110
111```text
112stamp = remote.stamp() one small, uncoordinated read
113if stamp == base.stamp:
114 publish the sealed batch (if any) Ok ─► acknowledge
115 NotCommitted ─► retry later
116 Unknown ─► attempted; confirm before anything else
117else:
118 image = remote.read() coordinated snapshot, only now
119 rebase the queue onto image merge, maybe conflict pages
120 base := image
121```
122
123A batch stays open while the sync thread is busy, so keystrokes that arrive
124during one round trip publish together in the next. A check costs a
125kilobyte-sized read. A publication costs one appended revision, not the
126section.
127
128Uncertainty is handled explicitly. A publication attempt is recorded before
129the first network byte. If the reply is lost, the attempt confirms only when
130the remote holds its revisions (or every page it changed, as it changed them).
131Otherwise it waits as `AwaitingConfirmation`, and `release` resolves it
132explicitly: it exports a recovery archive first, then republishes or abandons.
133
134## Merging
135
136When the stamp has moved, someone else committed. The queue replays on the new
137image one op at a time (`notebook::merge`):
138
139- an op whose objects the remote left alone applies as it is;
140- a text op on text the remote also changed shifts past the remote's changes,
141 provided its range stays clear of them;
142- a move, deletion or setting the remote already made is dropped as done;
143- anything else conflicts.
144
145Conflicts follow OneNote 2010 exactly, because the other clients are OneNote.
146The conflicting op is dropped, along with every later op that names what it
147named. The remote's version stays the page. The local version becomes a
148read-only **conflict page** under it, with the conflicting objects marked and
149the page labelled with its author, so OneNote and Snowbound both show the
150same bar, the same highlighted paragraphs and the same Delete Conflict Page. A
151conflict never blocks the queue: it becomes one more queued edit. Page-list
152edits merge the way OneNote merges page series (a page someone moved keeps
153their placement, and a page deleted remotely but edited locally comes back as a
154copy). Each rule was checked against a capture of two real OneNote clients
155doing it first (`corpus/conflict-page`).
156
157## Why an embedded SMB client
158
159OneNote doesn't use lock files. It coordinates through the section file
160itself, with share modes on open and one-byte locks far past the end of the
161data:
162
163| Role | Open | Byte locks |
164| --- | --- | --- |
165| reader | read, sharing read/write/delete | shared lock on the reader byte, held for the read |
166| writer | read/write, denying other writers | the reader byte shared, plus an exclusive writer byte |
167| maintenance (Optimize) | as a writer | an exclusive range that covers the reader byte |
168
169Maintenance rewrites the file under a new name and renames it into place, so a
170handle to the old file goes stale even though its contents still parse.
171
172Snowbound has to take exactly these locks, or OneNote will either trample it
173or be locked out. macOS's `smbfs` can't express them. POSIX byte-range locks
174are unsupported. `flock` of either kind becomes an exclusive lock over the
175whole file, which fails OneNote's reads. The only exclusive-byte primitive is
176private and has no shared form. On top of that, `smbfs` serves reads from its
177own lease-backed cache, defers closes, writes whole cached pages back on
178`fsync`, and leaves AppleDouble `._` files beside sections it writes. On iOS
179an app sees a share only through Files, with no control over locks.
180
181So `notebook::smb` speaks SMB2 itself (on the `smb2` crate's message layer)
182and takes OneNote's opens and bytes exactly, without leases. It never denies
183OneNote a read. The leases matter because a Windows client holding a lease
184keeps its byte locks local and only reveals them when another open breaks the
185lease. So a peer must open before it locks, and never hold handles between
186operations. A commit is a handful of compound requests: open; take the locks,
187check identity and stamp; write appended bytes and patches, then flush; write
188the header, the counter and the version, each flushed in order; close.
189
190A notebook on a share that macOS has mounted is opened through the embedded
191client with the account the system keeps for that mount. It falls back to the
192mount only when it can't sign in that way.
193
194A notebook can also be opened from its server's address, with no mount at all (File ▸
195Open Notebook from Server), which is how Mac OS X 10.6 reaches a server that no longer
196speaks SMB1, the only version its Finder has. The notebook is kept by that address
197(`smb://[domain;]user@server/share/folder`), never with a password; the password lives in
198the keychain (the Secret Service on Linux) when the user asks to remember it, and
199otherwise the sign-in asks again at the next launch.
200
201A file's identity is its root object space, not its server file ID, which
202changes when maintenance replaces the file. A check opens by path each time
203for the same reason.
204
205## Offline
206
207Offline is just a sync step that fails. Edits keep landing in the replica, and
208the sync thread retries with backoff and wakes on new local edits. A notebook's
209last listing lets it open while the server is unreachable, and a section opens
210from its replica. On reconnect the queue publishes, or rebases and publishes,
211through the same path as always. A failed directory read keeps the previous
212catalog, so an unreachable share never looks like an emptied notebook.
213Working offline on purpose, as OneNote's Work Offline does, is the same state
214chosen: the sync thread stops stepping until Sync Now or until the user works
215online again.
216
217## Sections that aren't open
218
219OneNote keeps every section of an open notebook in sync and on this computer, not just
220the one on screen. Watched in the lab with a notebook of 200 sections on Samba, OneNote
2212010:
222
223- reads every section file once when it first opens the notebook (all 200 within 15
224 seconds), so a section never shown before opens with the share gone; opening it again
225 with its cache warm, it only lists the folders and reads the files listed otherwise;
226- arms one CHANGE_NOTIFY on the notebook's folder, with WATCH_TREE and a filter of names,
227 attributes, size and last write, and otherwise sends nothing: 22 idle minutes put no SMB
228 request on the wire, not even an echo, and the section on screen waits on the same watch;
229- lists the folder once when the server reports a change, and reads only the section that
230 changed, some seconds later;
231- tries a section it could not reach again about every 31 seconds.
232
233Snowbound does the same. Opening a notebook lists its folders and reads only the files whose
234listed size or last write time changed since it last read them, keeping what it took from
235each, with the file's stamp, in the cache (`discover::Cache`). On a share, a changed section
236whose replica already holds it as it stands, as after its own edits published, costs just a
237stamp read. Each section it did read is handed on with its image, which makes the offline copy
238or rebases the replica while the file's stamp is still the image's, so a launch reads each file
239once, as OneNote does. Dot files (macOS's `.DS_Store` and AppleDouble `._` companions, the
240`.snowbound` folder) and Office's `~$` files are not the notebook's and are skipped.
241`session::Background` then keeps the sections in sync, one thread per notebook:
242
243```text
244connect: arm the watch, list every folder
245 listed as before: compare the known stamp with the replica, locally
246 listed otherwise: check, one section every 100 ms
247share ─ CHANGE_NOTIFY (tree) ─► the file it names is due in 1 s ─► stamp ─► moved: rebase
248 └► a folder it names is listed in 1 s ─► the files listed otherwise
249failing: again in 31 s otherwise: again in an hour, against a lost notification
250```
251
252A check costs one stamp read. Only a section whose replica has edits waiting, or whose file
253moved past the replica's base, has its replica opened for the usual sync steps, and it is
254closed again afterwards; a section without one gets its offline copy. The section open on
255screen is left to its session, whose worker no longer polls: the watch wakes it, and once the
256session lets go of the replica the background checks the file at once. A connection that drops
257takes its watch with it, so the next one lists every folder again, and so does working online
258again.
259
260A notebook in a folder on this computer has no copies and learns of changes from FSEvents or
261inotify. One on a network volume the system mounted keeps copies. A Mac's SMB mount reports
262another client's change to FSEvents only for a folder watched in its own right, and names just
263that folder, so every folder of the notebook is watched and a report lists it. A Linux SMB
264mount reports none of another client's changes to inotify, so there each section's stamp is
265read every 15 seconds. On iOS, a folder on the device (Snowbound's own, or On My iPhone) has no
266copies and learns of other apps' writes through file coordination (`NSFilePresenter`); one a
267file provider keeps elsewhere, as iCloud Drive does, keeps copies and is checked every 15
268seconds. Closing a notebook deletes its copies, except any with edits still waiting.
269
270## iCloud Drive
271
272A cloud drive syncs whole files with no lock and no compare-and-swap. The stamp check still runs
273before each append, but only against this device's copy; when two devices append to the same
274base, iCloud keeps one as the file and the other beside it as a **conflict version**
275(`NSFileVersion`), on every device. A conflict version is a stamp check that failed after the
276fact, and each sync step merges the versions it finds before anything else:
277
278```text
279for each version V the remote lists (Remote::versions)
280 C := the file, read coordinated
281 per space of V: a := its newest revision C holds, or holds merged
282 ancestor := V opened at those revisions (Section::open_at)
283 ops := lower_page(ancestor[S], V[S]) per page V changed, V's new pages imported under their
284 identities, its deletions, moves and conflict pages
285 replay ops on C through merge.rs (conflict pages where they clash), seal_as the named
286 revisions, publish on C's stamp; then retire V (resolved, removed)
287```
288
289Each revision the merge writes is named after the version's revision it merged (a hash of it),
290so "holds merged" is a lookup by revision identity. A version merged once, by this device or
291another, merges as nothing the next time; two devices merging one version at once write the
292same names, and the merge of their two results finds everything held. Any device merges any
293version at once; nothing waits for the device that wrote it. A version of another section, or
294one that cannot be read, is kept beside the file as `Name (Device).one` instead.
295
296On a cloud drive every read and write of a section file runs under `NSFileCoordinator`, so the
297iCloud daemon never swaps a file between the stamp check and the append. A file iCloud evicted
298(a `.Name.icloud` placeholder, or on macOS 14 and later a dataless file under its own name) lists
299as last read, or as downloading; the host asks for it, and reads the notebook again as files
300arrive. The sync thread never waits for one: the replica serves it meanwhile. Opening a section
301with no replica yet waits for its file. macOS and iOS offer no public way to keep a file
302downloaded, as the Finder's Keep Downloaded does; evicted files are asked for again whenever
303seen. Each publication is an upload and a
304chance to conflict, so edits wait for a 3 second pause in typing (at most 30 seconds,
305`Section::set_pause`), and publish at once when the app leaves the foreground, the screen locks,
306the Mac's window loses focus or the app quits. A folder presenter reports other devices'
307changes and conflict versions, which change no file, and every section is checked every 15
308seconds besides. Signing out of iCloud closes its notebooks; replicas holding edits stay.
309
310`crates/notebook/tests/cloud.rs` runs devices against a fake iCloud (uploads without
311compare-and-swap, either side of a race winning, late deliveries, offline spans) and checks that
312every device ends on the same file with each typed string once. OneNote 2010 cold-opens a
313merged section with its conflict page as its own (`corpus/icloud-merge`).
314
315## Live Share
316
317Live Share (`notebook::live::share`, beta) opens a notebook one Snowbound holds on others,
318with no service holding the notebook. The host serves its notebook's storage verbs to the peers
319in the share's room; a guest runs the replica, queue and merge it runs on an SMB share against
320them (`Notebook::open_hosted`, `Section::resume_hosted`, `Background::hosted`), so offline
321queueing, rebases and conflict pages behave as on a share, and the host's files are only ever
322written by its own storage, OneNote's locks included. A guest meets the host first in the
323room of a short code (`7KQ-4MZ-9XR`: Crockford base32, its room's number, 30 bits of
324secret and a check symbol; SPAKE2 on its secret and any password) through
325Snowbound's relay or by mDNS, and is welcomed with the share's room and its random secret;
326stopping or restarting a share retires the secret. A guest's commit is checked on the host's
327image before it is committed, and a guest can name nothing outside the notebook. Large reads
328and uploads travel 128 KiB at a time, at most 512 KiB unanswered, so a relay never holds much
329for a slow peer. A host that goes away leaves its guests working offline: the notebook opens
330from its last listing and edits publish when it is back. The relay is
331`crates/relay`.
332
333Live presence, on by default, shows who else has a notebook others reach too (on a server, in
334iCloud Drive, in another computer's folder, or by Live Share): their avatars, page and caret.
335Its room's secret is a random one in `.snowbound/live.json`, which Live Share never serves.
336
337## Notebook structure
338
339Sections, groups and the notebook's own colour live in the `.onetoc2` files,
340which are revision stores edited through the same transaction path. Moving or
341renaming a file also rewrites two header fields OneNote checks on open (the
342parent TOC's identity and a CRC of the file's name). Without them OneNote
343treats the file as a stranger and re-identifies it. Deleting sends sections and
344pages to `OneNote_RecycleBin`, as OneNote does, and opening a notebook empties
345what the bin has held unchanged for 60 days, as OneNote prunes it: judged by the
346content's last change, the pages in one revision, binned section files deleted
347with their TOC entries left (`corpus/recycle-purge`).
348
349What only Snowbound reads lives in the notebook's `.snowbound` folder, which carries the
350Windows hidden attribute so that OneNote never makes a section group of it. Its files are
351plain files, not revision stores: pictures named by their content, and small JSON
352mappings that every writer merges before replacing. The first is tag art: a tag keeps
353OneNote's definition and a fallback symbol on the page, and `tags.json` maps its name and
354symbol to a picture that Snowbound draws in the symbol's place. `live.json` holds the
355notebook's presence room secret.
356
357## Recovery and migration
358
359`export_recovery` snapshots a replica into a single read-only archive: base and
360remote images, queue, attempts, receipts and media. It is the evidence behind
361any decision that could lose work. Cache schema conversions check their own
362output: every converted page must equal the page the old cache held, or the
363conversion rolls back and names the archive it made first.
364
365The [notebook README](../crates/notebook/README.md) documents the API in detail.