<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Building Gravity]]></title><description><![CDATA[Building Gravity]]></description><link>https://gravitynotes.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 14 Nov 2026 18:25:55 GMT</lastBuildDate><atom:link href="https://gravitynotes.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How I took Gravity's search from 0% to 99%]]></title><description><![CDATA[How I rebuilt Gravity's notes search against a 200-case eval: from 0 of 200 to 198, with a 17 MB embedding model and the same typing speed.]]></description><link>https://gravitynotes.hashnode.dev/how-i-took-gravity-s-search-from-0-to-99</link><guid isPermaLink="true">https://gravitynotes.hashnode.dev/how-i-took-gravity-s-search-from-0-to-99</guid><category><![CDATA[search]]></category><category><![CDATA[SQLite]]></category><category><![CDATA[Rust]]></category><category><![CDATA[Machine Learning]]></category><dc:creator><![CDATA[Tristan Manchester]]></dc:creator><pubDate>Sat, 14 Nov 2026 12:21:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af84ffce96debdd04601398/8c185347-047e-4415-9ee0-45b7e719ae13.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Gravity has a new search on iPhone, iPad, Android and Mac, in the command line tool, and in the connector that Claude and ChatGPT use to read your notes. I rebuilt it against a test of 200 searches that the old search failed every time. The new one finds the right note in the top 10 for 198 of them. On a second set of 200 that was written blind, it goes from 17 to 198. Typing a search is as fast as before, and the slowest keystroke in a 100,000-note library went from 149 ms to 14 ms.</p>
<p>This post covers how I built the test, what was wrong, what the new search does, and the four searches it still gets wrong.</p>
<h2>Search that worked until you misremembered</h2>
<p>The old search was SQLite's FTS5 full-text index with the <code>unicode61</code> tokenizer. Typed words matched the beginnings of words in your notes, and if that found nothing, a trigram index matched words of three letters or more anywhere inside longer words. Results were ordered by BM25. A first word ending in a colon, like <code>todo:</code>, became a tag and showed only notes starting with it.</p>
<p>That works whenever you search with the words you wrote. Search for <code>dentist</code> and you get the note with "dentist" in it. The trouble starts when you remember a note differently from how you typed it, and with a notes app you mostly do. You wrote "Mum's birthday" on one phone and search <code>mums</code> on another that has no apostrophe on the first keyboard screen. You wrote "Wi-Fi" and search <code>wifi</code>. You wrote "couch" and search <code>sofa</code>. You wrote "Thurs" and search <code>thursday</code>. None of those found anything.</p>
<h2>A library built to break search</h2>
<p>Testing search needs a library, and I wasn't going to use anyone's real notes. So I wrote a seeded generator that makes one person's library: 40,000 notes from six years of capture across 12 parts of life (work, family, health, money, food, the house, travel, reading, code, ideas, a journal and odds and ends), about 680,000 words. Around 20% of the notes are in 18 other languages, 27% are archived, 2% are in the trash and 6% have been rescued at least once, roughly what a long-running Gravity library looks like.</p>
<p>Then I went looking for failures. I wrote 314 probes aimed at likely weak spots and ran them through the real search code. 246 failed. I grouped the failures by cause, kept 20 distinct causes, and wrote 10 cases for each.</p>
<p>Every case has three parts: a note, a search a person would reasonably type to find it, and a control search using the note's own words. A case passes when its note shows up in the top 10, which is a phone screen and a short scroll. Each case's note is added to the 40,000 through Gravity's own note-saving code.</p>
<p>The controls are there so a zero means something. All 200 controls passed, so every note was indexed and findable. The 200 real searches scored 0 of 200 in the top 10, and only 7 appeared anywhere in the first 200 results, at ranks 57 to 137.</p>
<p>I ran the same cases through the real <code>gravity</code> binary, through the phone app's own search code on the SQLite version the phone uses, and through the plainer trigram index that Macs on macOS 15 fall back to. Every one gave the same ranks.</p>
<img src="https://www.gravitynotes.app/figures/search-eval/still.png" alt="An isometric drawing of a test fixture: a bed of 200 pins in 20 columns of ten, one column for each reason search failed, with a probe on a gantry above it, a slide switch for old and new search, and a dial gauge counting raised pins" style="display:block;margin:0 auto" />

<p><em>Fig 1. Each pin is one search from the eval, in 20 columns of ten by the reason the old search failed. A pin rises when its note lands in the top 10. With the old search two pins are up, counting notes that answer the search as well as the one it was written for. With the new one, 198 are.</em> <a href="https://www.gravitynotes.app/figures/search-eval/index.html"><em>Open the interactive version</em></a><em>.</em></p>
<h2>The 20 ways it failed</h2>
<table>
<thead>
<tr>
<th>Failure</th>
<th>The note says</th>
<th>The search</th>
</tr>
</thead>
<tbody><tr>
<td>A colon in the search turned it into a tag</td>
<td><code>Cottage wifi password: bluebell42</code></td>
<td><code>password: bluebell42</code></td>
</tr>
<tr>
<td>Short tags found nothing</td>
<td><code>w: the kitchen tap washer size</code></td>
<td><code>w:</code></td>
</tr>
<tr>
<td>Tags compared accents exactly</td>
<td><code>Café: new place near the station</code></td>
<td><code>cafe: flat whites</code></td>
</tr>
<tr>
<td>One short word switched off substring search for every word</td>
<td><code>Haftpflichtversicherung zu teuer</code></td>
<td><code>versicherung zu teuer</code></td>
</tr>
<tr>
<td>Some Latin letters didn't fold</td>
<td><code>Họp ở Hà Nội thứ hai</code></td>
<td><code>ha noi</code></td>
</tr>
<tr>
<td>Accents in other scripts</td>
<td><code>Купить ёлку</code></td>
<td><code>елку</code></td>
</tr>
<tr>
<td>Languages written without spaces</td>
<td><code>今天下午去咖啡店见客户</code></td>
<td><code>咖啡</code></td>
</tr>
<tr>
<td>Emoji</td>
<td><code>🎂 Mum's cake order</code></td>
<td><code>🎂</code></td>
</tr>
<tr>
<td>Pasted characters</td>
<td><code>ﬁnal ﬁgures</code> (a ligature)</td>
<td><code>final figures</code></td>
</tr>
<tr>
<td>Apostrophes</td>
<td><code>Mum's birthday dinner</code></td>
<td><code>mums birthday dinner</code></td>
</tr>
<tr>
<td>Joined and split spellings</td>
<td><code>Cottage Wi-Fi password</code></td>
<td><code>wifi password cottage</code></td>
</tr>
<tr>
<td>Word forms</td>
<td><code>Need new battery</code></td>
<td><code>batteries smoke alarm</code></td>
</tr>
<tr>
<td>Phone numbers and codes with spaces</td>
<td><code>Plumber Dave 07700 900123</code></td>
<td><code>07700900123</code></td>
</tr>
<tr>
<td>Numbers and units</td>
<td><code>Paid £1,240</code></td>
<td><code>1240 flue</code></td>
</tr>
<tr>
<td>Dates</td>
<td><code>Passport expires 14/03/2027</code></td>
<td><code>passport 14 march 2027</code></td>
</tr>
<tr>
<td>Typos</td>
<td><code>the prescription from Boots</code></td>
<td><code>perscription boots</code></td>
</tr>
<tr>
<td>Abbreviations</td>
<td><code>Bins go out Thurs night</code></td>
<td><code>bins thursday recycling</code></td>
</tr>
<tr>
<td>Different words, same meaning</td>
<td><code>The couch delivery</code></td>
<td><code>sofa delivery dunelm</code></td>
</tr>
<tr>
<td>Questions</td>
<td><code>Spare key is under the blue plant pot</code></td>
<td><code>where is the spare key</code></td>
</tr>
<tr>
<td>Ranking</td>
<td>the newest dentist note, rescued twice</td>
<td><code>dentist</code> (it ranked 137th)</td>
</tr>
</tbody></table>
<p>The first four were plain bugs. Any word ending in a colon became a strict tag, even one copied from the middle of a note, so <code>password: bluebell42</code> looked for notes tagged <code>password:</code> and found none. A short tag like <code>w:</code> was a prefix query on <code>w</code>, which also matched every <code>work:</code> and <code>wedding:</code> note, and the scan stopped after 1,000 matches before it reached a real <code>w:</code> note. Tags compared exactly, though the website says accents don't matter. And a single search word under three letters, like <code>zu</code>, switched off the substring stage for every other word in the search.</p>
<p>The rest were limits of matching letters. Keyword search can't know that "Thurs" is Thursday or that a couch is a sofa. A question like <code>where is the spare key</code> required every word, including <code>where</code>, <code>is</code> and <code>the</code>. And BM25 alone decided the order, so for a common word like <code>dentist</code>, a long old note full of the word beat the one you wrote yesterday and rescued twice.</p>
<h2>The bug the eval didn't catch</h2>
<p>Speed was a separate benchmark, and it found the worst bug of the lot. The phone app uses op-sqlite, which bundles SQLite 3.51.3. On the phone's schema, that version's query planner started the search query from the index on <code>deleted_at</code>, then ran the full-text match once per note. In other words, every search on a phone read every note.</p>
<p>I compiled op-sqlite's own <code>sqlite3.c</code> with the app's iOS flags and ran the phone's query on my Mac against 40,000 notes. <code>dentist</code> took 817 ms. A one-letter search, <code>t</code>, took 288 seconds. The CLI and Mac app use newer or different SQLite builds that start from the full-text index, so they never showed it. Writing the condition as <code>+n.deleted_at IS NULL</code> stops SQLite using that index, and <code>dentist</code> dropped to 1.7 ms.</p>
<h2>Keeping myself honest</h2>
<p>Hill-climbing a test of 200 cases is easy to fake. You can add each failing word to a list until the score is 100% and the search is no better for anyone else. I set four guards before changing any code.</p>
<ul>
<li>A held-out set of 200 more cases in the same 20 classes, written by a separate agent that never saw the implementation. I reported it next to the main score and never tuned on it. The old search scored 16 of 200 there, 10 of them on the short-tag bug.</li>
<li>The 200 controls had to stay at 200 of 200.</li>
<li>Every word list (abbreviations, irregular plurals, number words) had to be a general list. None could be the eval's own words.</li>
<li>Speed was measured against a frozen copy of the old search code, run back to back on the same machine.</li>
</ul>
<p>There was one more problem to sort out. Some searches have more than one right answer. If the library holds three notes about topping up the boiler pressure, <code>boiler pressure</code> can fairly return any of them. So I had an independent model judge every failing case's top 10 without seeing the ranks, and it marked notes that answer the search as well as the target does. It found such notes for 12 cases. I report both numbers: the original target only, and counting judged answers. Counting judged answers moves the old search from 0 to 2 on the main set and from 16 to 17 on the held-out set.</p>
<h2>What search v2 does</h2>
<h3>One index, built in app code</h3>
<p>The new index lives next to your notes in the same SQLite file and replaces both old full-text tables.</p>
<ul>
<li><code>notes_search(rowid, body, extra)</code> holds a search document for each note.</li>
<li>An FTS5 table over <code>body</code> and <code>extra</code> with prefix indexes, and a trigram table over <code>body</code> for matches inside words.</li>
<li>An <code>fts5vocab</code> table, so search can ask which words exist in your library and how many notes use each.</li>
<li>A <code>search_dirty</code> queue. Triggers on <code>notes</code> add a note's row to the queue when it's created or its text changes.</li>
</ul>
<p>SQL triggers can't build the search document, because op-sqlite on the phone has no custom SQL functions, and the folding rules are too involved for SQL anyway. So each app drains the queue in its own code: before each search, and in the background after writes. Draining a note computes its search document and its meaning vector and upserts them into the index.</p>
<h3>Folding</h3>
<p><code>body</code> is the note folded into one canonical form. Text is normalised (NFKC), invisible formatting characters are removed, case is lowered and accent marks are stripped. Letters that don't decompose are spelled out (ß becomes ss, ø becomes o, ł becomes l, þ becomes th), Greek final sigma becomes σ, and katakana becomes hiragana. Words broken across lines with a hyphen are joined. Each emoji becomes a token like <code>emo1f382</code>, so <code>🎂</code> is a word you can search for.</p>
<p>The same fold runs on the search. That fixed the accent, script, pasted-character and emoji failures in one place.</p>
<h3>Alternate forms</h3>
<p><code>extra</code> holds other ways of writing what's in the note:</p>
<ul>
<li>Joined forms of words with apostrophes, dots and hyphens: <code>mums</code>, <code>wifi</code>, <code>phd</code>.</li>
<li>Numbers without thousands commas, <code>15k</code> as <code>15000</code>, <code>5 km</code> joined as <code>5km</code> and <code>5km</code> split as <code>5 km</code>.</li>
<li>Codes. Runs of letter and digit groups like <code>07700 900123</code>, <code>SW1A 1AA</code> or <code>+44 7700 900123</code> are stored joined, and a <code>+44</code> number also gets its <code>0</code> form.</li>
<li>Number words both ways (<code>six</code> and <code>6</code>), abbreviation expansions (<code>thurs</code> to <code>thursday</code>, <code>appt</code> to <code>appointment</code>, <code>xmas</code> to <code>christmas</code>), and English stems using Snowball's Porter2, after mapping irregular forms like <code>mice</code> to <code>mouse</code>.</li>
<li>Dates and times as canonical tokens. <code>14/03/2027</code>, <code>14 March 2027</code> and <code>March 14th, 2027</code> all produce <code>md0314</code> and <code>y2027</code>, and <code>7.30pm</code> produces <code>t1930</code>. Dates are read day first.</li>
<li>Overlapping two-character pieces for Chinese, Japanese, Korean and Thai, so <code>咖啡</code> matches inside <code>今天下午去咖啡店见客户</code>.</li>
</ul>
<h3>Searches become concepts</h3>
<p>The search is folded the same way and split into concepts. A word concept matches the word as a prefix, its stem, its number form and its expansion. Words of three letters or more also match as a substring. A typed date becomes the same tokens the note produced. Question words like <code>where</code>, <code>is</code> and <code>the</code> become concepts that are never required.</p>
<p>Some expansions use the library itself. A word of five letters or more that no word in your notes starts with gets up to three typo variants from the vocabulary table, by edit distance with the first letter fixed, so <code>perscription</code> finds <code>prescription</code>. A closed word like <code>wifi</code> can match a note that says <code>wi fi</code>, and two typed words whose join is a word in your notes, like <code>pass word</code>, become one.</p>
<p>A tag is still a tag when tagged notes exist. When none do, <code>password:</code> becomes the plain word <code>password</code>. Tags compare folded, so <code>cafe:</code> finds <code>Café:</code>, and a short tag like <code>w:</code> asks the index for notes whose first word is exactly <code>w</code>, so it doesn't scan through every <code>work:</code> note first.</p>
<h3>Meaning from a 17 MB model</h3>
<p>Some searches share no letters with their note: <code>sofa</code> and couch, <code>lawyer</code> and solicitor. For those, search v2 uses a static embedding model. A static model is a table with one vector per word piece. A text's vector is the sum of its pieces' rows, which takes microseconds with no neural network to run, so it works offline on a phone.</p>
<p>I tested the best current static models on the eval by meaning alone, with no keyword matching at all. On the main set, Sentence Transformers' multilingual <code>static-similarity-mrl-multilingual-v1</code> found 146 of 200, <code>static-retrieval-mrl-en-v1</code> found 138, and Minish Lab's <code>potion-retrieval-32M</code> found 137. The multilingual model looked best on paper and covers 50 languages in 27 MB. It lost on the searches keyword matching can't rescue: for <code>lawyer</code>, it ranked the solicitor note 1,155th, where <code>potion-retrieval-32M</code> ranked it 2nd. So Gravity ships <code>potion-retrieval-32M</code> (MIT licence), cut to its first 256 dimensions and stored as int8 with one scale per token. The file is 17.3 MB.</p>
<p>Every platform has to produce the same results, so the arithmetic is integers wherever it can be. Each note's vector is scaled so its largest component is ±127, rounded half away from zero and stored as 256 bytes. Similarity is an integer dot product over the product of integer norms. Rust, TypeScript and Swift get the same numbers bit for bit.</p>
<h3>Scoring</h3>
<p>Every candidate gets one score:</p>
<ul>
<li>14 × coverage, the share of required concepts it matches</li>
<li>2 × the share matched as whole words</li>
<li>5 if it carries the searched tag</li>
<li>3 × its BM25 over the best BM25 in the results</li>
<li>3 × 0.5 ^ (age / 30 days), with age counted back from your newest note</li>
<li>1.5 × rescues, counting up to 3, over 3</li>
<li>6 × meaning similarity</li>
<li>2 × proximity, how close together the matched words sit</li>
</ul>
<p>Notes that match every concept are kept, as are notes that match at least half of them and notes with a similarity of at least 0.40. Coverage dominates, so a note with all your words beats one with some of them. Among notes that match equally, recent and rescued notes rise, which fixed the <code>dentist</code> case. The recency half-life counts from your newest note so a library you haven't touched in a year doesn't rank as if everything had gone stale.</p>
<h2>Climbing</h2>
<p>I hill-climbed the reference implementation first, in Rust in <code>gravity-core</code>, running the eval after every change and checking the held-out set beside it. The held-out score tracked the main one the whole way, which told me the changes would hold for searches I hadn't written. With the model switched off, keyword matching alone now finds 192 of 200 on the main set and 190 held out. The model adds the rest.</p>
<p>Then I made it fast again, because the first working version wasn't:</p>
<ul>
<li>Finding the newest note's sort key scanned the table for 4 to 11 ms. An <code>INDEXED BY</code> hint made it instant.</li>
<li>The word stage takes the best 250 notes by BM25 plus the newest 50. When more than 2,000 notes match, as with a single letter, it takes the newest 250 and skips BM25.</li>
<li>The meaning stage runs for searches of two or more words, or when words found nothing. A single word that matched skips it.</li>
<li>Vectors moved to their own table, so loading them reads 256 bytes a note. Cold loading dropped from 358 ms to 23 ms.</li>
<li>Folding plain ASCII takes a fast path.</li>
<li>A setup step that ran on every search, costing 6 ms, now runs once.</li>
<li>Result excerpts are built in app code. FTS5's <code>snippet()</code> was quadratic on long notes.</li>
</ul>
<h2>Before and after</h2>
<p>All on the 40,400-note library, with the note in the top 10:</p>
<table>
<thead>
<tr>
<th>Set</th>
<th align="right">Before</th>
<th align="right">After</th>
</tr>
</thead>
<tbody><tr>
<td>Main, original target only</td>
<td align="right">0</td>
<td align="right">190</td>
</tr>
<tr>
<td>Main, counting judged answers</td>
<td align="right">2</td>
<td align="right">198</td>
</tr>
<tr>
<td>Held-out, original target only</td>
<td align="right">16</td>
<td align="right">194</td>
</tr>
<tr>
<td>Held-out, counting judged answers</td>
<td align="right">17</td>
<td align="right">198</td>
</tr>
<tr>
<td>Controls, both sets</td>
<td align="right">400</td>
<td align="right">400</td>
</tr>
</tbody></table>
<p>Speed is every keystroke of 400 typed searches, 2,329 searches in all, on an M3 Pro with a warm cache, as the apps run it:</p>
<table>
<thead>
<tr>
<th align="right">Notes</th>
<th align="right">Median before</th>
<th align="right">Median after</th>
<th align="right">99th percentile before</th>
<th align="right">99th percentile after</th>
<th align="right">Slowest before</th>
<th align="right">Slowest after</th>
</tr>
</thead>
<tbody><tr>
<td align="right">1,000</td>
<td align="right">0.33 ms</td>
<td align="right">0.29 ms</td>
<td align="right">1.80 ms</td>
<td align="right">2.06 ms</td>
<td align="right">9.6 ms</td>
<td align="right">3.1 ms</td>
</tr>
<tr>
<td align="right">10,000</td>
<td align="right">0.73 ms</td>
<td align="right">0.78 ms</td>
<td align="right">5.70 ms</td>
<td align="right">3.27 ms</td>
<td align="right">16.3 ms</td>
<td align="right">7.2 ms</td>
</tr>
<tr>
<td align="right">40,000</td>
<td align="right">1.58 ms</td>
<td align="right">1.88 ms</td>
<td align="right">15.3 ms</td>
<td align="right">8.33 ms</td>
<td align="right">48.6 ms</td>
<td align="right">29.5 ms</td>
</tr>
<tr>
<td align="right">100,000</td>
<td align="right">2.88 ms</td>
<td align="right">2.40 ms</td>
<td align="right">35.4 ms</td>
<td align="right">9.58 ms</td>
<td align="right">149 ms</td>
<td align="right">14.1 ms</td>
</tr>
</tbody></table>
<p>Medians stay about the same, the slow tail is much shorter, and the short-tag bug went from 36 ms with no results to 6 ms with the right note. I set out to make no search slower, and that didn't fully hold. Fifteen of 196 fixed searches got slower by 0.5 to 3 ms, all of them searches by meaning. All 15 are searches where meaning now runs: rare words, words that match nothing, and searches of several words. The worst is <code>bluebell</code> at 40,000 notes, from 0.44 ms to 3.43 ms. I think 3 ms is a fair price for finding the note.</p>
<p>On the phone, the planner fix matters more than anything else in this post. With the new SQL on 40,400 notes, a one-word search takes 7 ms in Node and 32 ms with the JIT off, which is close to how Hermes runs on a phone. A 20-word paste takes 17 and 174 ms. Indexing a library that size the first time takes about 33 seconds with the JIT off, in the background, a chunk at a time. The Mac app indexes 2,000 notes in 387 ms and then searches in 1 to 4 ms.</p>
<p>The command line tool starts a new process for every search and loads the vectors each time, so it's the slowest path. At 40,000 notes a meaning search like <code>where is the spare key</code> takes 19 ms warm and about 150 ms cold. At 100,000 notes, cold meaning searches take 320 to 380 ms, over my 250 ms target. The apps keep vectors in memory, so they don't pay that.</p>
<h2>One contract, three implementations</h2>
<p>Gravity's apps don't share code. The CLI is Rust, the phone app is TypeScript in React Native, and the Mac app is Swift. They share a written contract instead: section 6 of the note contract specifies the index schema, the fold character by character, every alternate form, the model's file format and arithmetic, the query rules and the scoring formula. Beside it sit golden files, search documents, vectors and ranked results for fixed inputs, which every app must reproduce exactly.</p>
<p>I wrote the Rust reference first, then ported it to TypeScript and Swift. Both ports reproduce every golden file. The ports also found two bugs in the reference. The queue trigger used <code>INSERT OR IGNORE</code>, and an upsert on a note already in the queue failed, because an upsert's conflict clause overrides <code>OR IGNORE</code>. It's <code>ON CONFLICT DO NOTHING</code> now. And a wide character just before a word broken across lines crashed the indexer. Both fixes went into the contract, so all three apps got them.</p>
<h2>Search in Claude and ChatGPT</h2>
<p>Gravity has an <a href="https://www.gravitynotes.app/connect-ai-apps">MCP server</a> that lets Claude, ChatGPT and other AI apps save and find notes in your synced library. Its <code>search</code> and <code>list_notes</code> tools had their own search, on the sync server, and it was the old design again. On the eval it scored 2 of 200 on the main set and 17 of 200 held out, the same as the old app search.</p>
<p>The sync server is a Cloudflare Worker with one SQLite-backed Durable Object per library, which I wrote about in <a href="https://www.gravitynotes.app/blog/how-gravity-sync-keeps-both-versions">how Gravity Sync keeps both versions</a>. I moved the TypeScript port into a shared package that the phone app and the Worker both import. The Durable Object runs the same search v2 over its own copy of your notes.</p>
<p>The model ships with the Worker as a static asset behind the <code>ASSETS</code> binding, which the Worker reads and never serves over HTTP. Workers have tight CPU limits per request, so the Durable Object indexes in bounded chunks: up to 200 notes before a read, the rest on alarms alongside the nightly backup. If the model ever fails to load, search falls back to words only.</p>
<table>
<thead>
<tr>
<th>MCP search</th>
<th align="right">Main</th>
<th align="right">Held-out</th>
<th align="right">Per search</th>
</tr>
</thead>
<tbody><tr>
<td>Before</td>
<td align="right">2/200</td>
<td align="right">17/200</td>
<td align="right">~1 ms</td>
</tr>
<tr>
<td>Words only</td>
<td align="right">192/200</td>
<td align="right">190/200</td>
<td align="right">2.4 ms</td>
</tr>
<tr>
<td>With the model</td>
<td align="right">198/200</td>
<td align="right">198/200</td>
<td align="right">6 ms</td>
</tr>
</tbody></table>
<p>Those times come from running the backend's own code on its schema in Node. In workerd, Cloudflare's runtime, 40,000 notes take about 8 seconds of CPU to index once, and a search takes 7.5 ms. When you ask Claude to find the note about the spare key, it now finds the same note your phone would.</p>
<h2>What it still gets wrong</h2>
<p>Four searches still fail, two in each set:</p>
<table>
<thead>
<tr>
<th>Search</th>
<th>What it needed</th>
</tr>
</thead>
<tbody><tr>
<td><code>heating broken</code></td>
<td>That a note saying "boiler keeps cutting out" is about broken heating</td>
</tr>
<tr>
<td><code>baby supplies tesco</code></td>
<td>That nappies and Calpol are baby supplies</td>
</tr>
<tr>
<td><code>what time does the tip open</code></td>
<td>That in Britain the tip is the recycling centre</td>
</tr>
<tr>
<td><code>how do i top up the boiler pressure</code></td>
<td>To rank this note above duplicate notes saying "top up if low"</td>
</tr>
</tbody></table>
<p>The first three need knowledge about the world that a static word model doesn't have. I tried larger static models, combining two models, and expanding searches with the model's nearest words. None fixed them without breaking something else. The fourth is a ranking call between near-duplicates, and the judge didn't count the others as equally good. A small language model could probably fix all four, but it would be hundreds of megabytes and far slower than 3 ms, and that isn't the trade I want for a notes app that opens ready to type.</p>
<p>The eval, the held-out set, the benchmark and the golden files all stay in the repository. A future change to search has to beat 198 without breaking the controls or the speed table, and the four misses are the cases to beat.</p>
<p>Search v2 is in the latest Gravity for iPhone, iPad, Android and Mac, and your library indexes itself in the background the first time you open it. If you search for something you know is there and Gravity doesn't find it, tell me what you typed at the <a href="https://www.gravitynotes.app/contact">contact page</a>. Each miss becomes a new case in the eval.</p>
<hr />
<p>Gravity is a notes app for iPhone, iPad, Android and Mac. It's at <a href="https://www.gravitynotes.app">gravitynotes.app</a>.</p>
]]></content:encoded></item><item><title><![CDATA[How Gravity Sync keeps both versions of your note]]></title><description><![CDATA[How Gravity Sync merges notes across iPhone, Android and Mac without losing text: one SQLite database per library, and operations instead of states.]]></description><link>https://gravitynotes.hashnode.dev/how-gravity-sync-keeps-both-versions-of-your-note</link><guid isPermaLink="true">https://gravitynotes.hashnode.dev/how-gravity-sync-keeps-both-versions-of-your-note</guid><category><![CDATA[SQLite]]></category><category><![CDATA[cloudflare]]></category><category><![CDATA[local first]]></category><category><![CDATA[architecture]]></category><dc:creator><![CDATA[Tristan Manchester]]></dc:creator><pubDate>Sat, 14 Nov 2026 12:19:20 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af84ffce96debdd04601398/c08796f1-4e11-4c3b-84a0-408cb070a65d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Gravity Sync is out now as a free beta in Gravity for iPhone, iPad, Android and Mac. It puts one list of notes on every device you sign in on. This post is about the part that took longest: what the server does when two devices change the same note while neither can see the other.</p>
<p>The rule I built everything around is that text you wrote never disappears because of sync. A note can change, sink, get archived or get deleted on purpose. It can't vanish because two phones disagreed about what happened while one was in a tunnel.</p>
<h2>Why not last writer wins</h2>
<p>Most sync engines settle conflicts by timestamp. Each device sends its version of a record, the server keeps the newest, and every device converges on the same answer. Converging is easy. The trouble is what converging throws away.</p>
<p>Say you fix a typo in a note on your Mac while your phone is offline, and on the phone you add a paragraph to the same note. Last writer wins keeps whichever arrives later. If that's the typo fix, your paragraph is gone, and nothing on either screen tells you. Phone clocks drift too, so "later" can mean the device that's set five minutes fast.</p>
<p>Line-level merging does better until both devices touch the same line, and then it picks a winner as well. A notes app gets plenty of those, because most notes are one line.</p>
<p>So Gravity Sync gives up a little tidiness for safety. When two edits to the same text can't both be the current version, it keeps both: one stays as the note and the other sits next to it as a copy marked Conflict. You merge them by hand, which takes ten seconds, and you never have to wonder where a paragraph went.</p>
<h2>One database per library</h2>
<p>The server is a Cloudflare Worker with one SQLite-backed Durable Object per library. A Durable Object is a small single-threaded server with its own storage, so every change to your library goes through one queue and one database, one at a time. Two phones pushing at the same moment get an order, and each change sees exactly the state the one before it left behind.</p>
<p>The plan was bigger at first. In September I designed sync around PowerSync and a Neon Postgres database. Partway through I replaced both with this, because a single SQLite database per person maps onto the problem directly. Nobody shares a library, there's no cross-library query to make fast, and transactions are local and synchronous. Accounts live in D1, sign-in is Clerk, and each library is backed up to R2 every night.</p>
<h2>Operations, not states</h2>
<p>Devices don't upload "here's the note now". They upload what the person did. There are nine kinds of operation:</p>
<ul>
<li>create, with the note's first text</li>
<li>text, with the full new text and the revision it was edited from</li>
<li>bump and rescue, which move a note to the top</li>
<li>archive and unarchive</li>
<li>trash, restore and purge (delete for good)</li>
</ul>
<p>Sending intent instead of state lets the server merge by meaning. If you archive a note on your phone while you edit it on your Mac, those don't conflict, because one changes where the note lives and the other changes what it says, and both apply. If you rescue the same note on two devices, its rescue count goes up by two, because you rescued it twice.</p>
<p>A text operation always carries the whole note and the revision it started from, never a patch. When it arrives, the server compares that base revision with the note's current one:</p>
<ul>
<li>If they match, nobody else got there first. The new text becomes the note and the revision moves on.</li>
<li>If they don't, another device changed the note in the meantime. Its text stays as the note, and the arriving text becomes a copy labelled Conflict.</li>
<li>If the note was purged, a device that saw the deletion can't bring the text back, and a device that wrote something new before it heard about the deletion keeps that text as a copy marked "deleted on another device".</li>
</ul>
<img src="https://www.gravitynotes.app/figures/merge-sorter/still.png" alt="An isometric drawing of a card sorter: a hopper feeds edit cards one at a time onto a carriage on two rails; each card has a pin at the revision it was edited from, and a comb over the track has one tooth raised at the note's current revision; cards that fit land on the note stack, stale ones go to a copy tray, and retries go onto a receipt spike" style="display:block;margin:0 auto" />

<p><em>Fig 2. Edits go through one at a time. Each card's pin marks the revision it was edited from, and the comb has one tooth raised at the note's revision. A pin that fits passes and the card lands on the note. A stale card goes to the copy tray, and a retry of an edit already applied goes on the receipt spike.</em> <a href="https://www.gravitynotes.app/figures/merge-sorter/index.html"><em>Open the interactive version</em></a><em>.</em></p>
<h2>Saving and syncing are two different promises</h2>
<p>Gravity's whole point is that capture is instant, so sync has to stay out of the way of the editor. Every write on the device stores the note and its sync operation in the same SQLite transaction. Either both are on disk or neither is. If the app crashes between "you pressed Return" and "the phone found a network", the operation is still there when it starts again.</p>
<p>The status in Settings keeps the two promises apart. "Changes saved on this device" means your text is safe on this phone. "Up to date" means the server has it too. The app never shows the second while work is still queued.</p>
<h2>Retrying without doing things twice</h2>
<p>Phones lose responses all the time. The server commits your change, the reply dies in a lift, and the phone has no idea whether it worked. It has to retry, and the retry mustn't rescue the note a second time.</p>
<p>Every operation carries a mutation ID made on the device. The server stores a receipt for each one in the same transaction as the change itself, with a hash of the payload. A retry with the same ID and the same payload gets back the stored result without running again. The same ID with a different payload is refused, because something has gone wrong and guessing would be worse.</p>
<h2>Knowing text without keeping it</h2>
<p>Joining sync is where many systems duplicate everything. You've got 3,000 notes on your iPhone through iCloud and the same 3,000 on your Mac. Turn on Gravity Sync on the iPhone, then on the Mac, and a naive server ends up with 6,000.</p>
<p>Gravity's server checks whether it has seen a piece of text before, even text it no longer stores. For every revision a note has held, it keeps a keyed hash of the text, using HMAC-SHA256 with keys that never leave the server. When the Mac joins and offers a note, the server checks the hash. If the note has held that exact text at any point, now or earlier, the Mac's copy adds nothing. If the text is new, it becomes a Conflict copy. The server knows "I've had this text" without storing old versions of your notes.</p>
<p>Order comes along too. Each note keeps its creation time and its place in the list when it joins, capped at the server's clock, so the list on the new device looks the way you left it.</p>
<h2>Trying to break it</h2>
<p>A bug in merge code doesn't crash anything. It loses a sentence somebody wrote in March, and they notice in June. I wanted tests that went looking for that.</p>
<p>The merge core is a pure TypeScript module with no network, no clock and no randomness of its own. The server passes in the time, the ID generator and the hashes. That makes it cheap to run thousands of times:</p>
<ul>
<li>It runs against an in-memory store and against the real SQLite schema with its triggers, and both must agree.</li>
<li>21 recorded concurrency cases from early prototypes replay on every check.</li>
<li>A property-based simulator runs three devices doing all nine operations with offline stretches, reordered delivery, lost responses and retries. After every run it checks one rule: every text anyone committed still exists as a note, a copy or a queued operation, unless someone deliberately replaced or deleted it on a device that could see it.</li>
<li>The same runs with skewed clocks have to reach the same merge results.</li>
<li>Mutation testing switches off individual merge rules in a scratch copy and requires the tests to fail. A rule no test notices missing isn't really tested. That run takes about half an hour, and I run it after every change to the merge code.</li>
</ul>
<h2>What it costs</h2>
<p>Speed mattered as much as safety, because nobody keeps a sync feature that makes the app feel heavy. In my tests with a 10,500-note library, a fresh iPhone downloaded everything in 30 seconds and Android in 44. A new note written on an iPhone reached the server in about two and a half seconds. A single note can hold up to 1 MB of text, which is longer than most novels.</p>
<h2>The trade-offs I chose</h2>
<p>Gravity Sync isn't end-to-end encrypted. Notes are encrypted in transit and at rest, and the server can read them, which is what lets it merge by meaning and lets you sign in on a new phone with Google or Apple and get everything back with no recovery key. Losing a phone is the disaster most people actually have, so I chose easy recovery.</p>
<p>It also keeps no version history yet. Conflict copies cover the case where two devices disagree, but you can't scroll back through last month's edits. The revision IDs and hashes are already there, so history can come later without changing the protocol.</p>
<p>Gravity Sync is free during the beta. Turn it on in Settings on any device, sign in with Google, Apple or an email code, and your notes follow you, including between <a href="https://www.gravitynotes.app/apple-notes-for-android">an iPhone and an Android phone</a>. If you find a way to make it lose a note, I want to hear about it at the <a href="https://www.gravitynotes.app/contact">contact page</a>.</p>
<hr />
<p>Gravity is a notes app for iPhone, iPad, Android and Mac. It's at <a href="https://www.gravitynotes.app">gravitynotes.app</a>.</p>
]]></content:encoded></item></channel></rss>