Shardly · technical manual · edition 01
A document store with full-text search, built from nothing
Two maps
do all the
work.
Shardly stores JSON documents on disk and searches them by relevance. No database, no Lucene, no search library. Around 2,200 lines of TypeScript, and this page walks through all of it.
Where a document's bytes live. Why a crash cannot lose an acknowledged write. How a word becomes a posting list. What BM25 is really asking. And every place the design knowingly stops short.
How to read this
Top to bottom is the path a document takes. Sections 01 to 08 follow it onto the disk and prove it survives a crash. Sections 09 to 13 follow it into the index and back out as a ranked result. Sections 14 to 18 are the edges: how documents get in, what the API promises, what everything costs, and where the design stops.
Six sections carry a lab you can drive. They run the real tokenizer and the real BM25 in your browser, ported without changes, so nothing here shows a number the engine would not produce.
Everything below follows from one claim
You can find the bytes of any document without reading any other document. You can find every document containing a word without reading any document at all.
Two maps do the work. OffsetIndex takes a document id and gives back a segment file, a byte offset, and a byte length. InvertedIndex takes a term and gives back the list of documents containing it, with how often each one uses it.
Both maps live in memory. Neither is the source of truth. The segment files on disk are, and both maps can be rebuilt from them. Everything else in Shardly is the machinery that keeps the maps honest when the power goes out.
A read is one fs.readSync call at a known offset. A search walks posting lists and does not open a segment file until it already knows which ten documents to return. If you understand those two sentences, the rest of this page is detail.
The two maps
- A · offset
- Map<string, { segment, byteOffset, length, deleted }>
- B · inverted
- Map<string, Array<{ docId, termFrequency }>>
- Truth
- Neither. The segments are.
- On restart
- Load a snapshot, or rebuild from segments.
What a document actually is once it lands
One line of JSON in a file called segment-0000.log, and four numbers in memory that say where to find it.
The length stops at the closing brace
Records are separated by newlines, so the file is valid NDJSON and you can read it with tail. But length counts only the JSON itself. That way the buffer handed to JSON.parse holds exactly one object and nothing else, with no trimming step that could go wrong on the last record in a file.
Many readers, one writer
Storage keeps a read-only file descriptor per segment in this.readFds, so a hot read never pays for open. It keeps exactly one write descriptor, on the newest segment, in append mode. That asymmetry removes the need for any lock: writes only ever touch the end of one file, and reads only ever touch bytes that are already final.
An id carries no order, and that is a real cost
Every id comes from randomUUID(). Random ids spread evenly and never collide, which is what you want. They also mean two documents written a second apart sort next to each other by pure accident. There is no way to ask for "every document after this one" without a full pass, and liveDocIds() returns insertion order because that is what a JavaScript Map happens to preserve, not because anything guarantees it. A monotonic id would buy range scans and cursor pagination. Shardly does not need them, so it does not have them.
// src/storage/storage.ts // The whole read path. read(docId: string): Document | null { const entry = this.offsetIndex.get(docId); if (!entry || entry.deleted) return null; const fd = this.readFd(entry.segment); const buf = Buffer.allocUnsafe(entry.length); fs.readSync(fd, buf, 0, entry.length, entry.byteOffset); return (JSON.parse(buf.toString("utf8")) as StoredDocument).doc; }
Lab 02 / resolve an id to bytes
Pick a document
Returned
{ "title": "Write-ahead logging", … }
Not one neighbouring record was opened, parsed, or even touched. The cost is identical whether the file holds five records or five million.
Appending is the only write that is cheap and safe at once
An in-place update has to fit, or move. Both paths need the old state and the new state to be consistent if the power fails between them.
Why 64 MiB, and not any other number
SEGMENT_MAX_BYTES caps each file at 64 MiB. Nothing in the read path cares how large a segment is, because reads seek directly to an offset. The cap is not a performance knob.
It exists so compaction has units to work with, and so a corrupted file loses a bounded amount of data instead of everything. Pick 4 GiB and one bad sector takes the corpus. Pick 1 MiB and you drown in file descriptors.
The file is the authority on its own size
openCurrentSegment() sets currentSegmentSize from fs.statSync(p).size, never from the snapshot. This looks like a detail and is not.
Recovery can restore a write the snapshot never saw. If the next append trusted the snapshot, it would place a new record on top of a live one and silently destroy it. Asking the filesystem costs one syscall at startup and removes the entire class of bug.
The bill for appending
Space is never reclaimed until you compact. A store that deletes as much as it writes grows without bound.
That is a real cost, paid in disk, on a schedule you choose. The alternative was a cost paid in correctness, on a schedule the power company chooses.
A single write to disk is not atomic
The power can fail with half a JSON object on disk, and nothing about those bytes says they are half. That is the entire problem the write-ahead log solves.
// Storage.append(), in order. // 1 · Say where the record is about // to go, before any of it exists. wal.logPending({ docId, segment, byteOffset, length }); fsync(walFd); // 2 · Put it there. fs.writeSync(segFd, record + "\n"); fsync(segFd); // 3 · Reflect it, then prove it. offsetIndex.set(docId, entry); wal.logCommitted({ docId, segment, byteOffset, length }); fsync(walFd); // Only now does write() return.
Two records per write looks wasteful. It is not.
Ask what a single record would tell you. A lone "I intend to write 231 bytes at offset 4096" leaves recovery unable to separate a finished write from a torn one. A lone "I wrote 231 bytes at offset 4096" is a lie if the process dies before the segment fsync returns, because the log entry is durable and the data is not.
You need the pair. The pending record bounds where the damage can be. The committed record proves the damage is not there. Neither one is enough alone.
What Shardly actually promises
write() returns to the caller only after step 3. An acknowledged write survives any crash, full stop. A write still in flight might survive and might not, and both outcomes are correct, because nobody was told it succeeded.
That is the whole contract. It is narrower than "nothing is ever lost", and it is the only kind of promise a single machine can actually keep.
Lab 01 / kill the process mid-write
Click a kill point
Verdict
This is the case the whole protocol exists for. Without the pending record, nothing would know those bytes were suspect.
Recovery, and the case it deliberately drops
Two passes over the log. The first trusts, the second interrogates.
Why a tombstone is committed from birth
Delete records are written as committed straight away, with no pending phase. A tombstone is a flag in memory, and the WAL record is the only durable part of it. There is no second file to keep in step, so there is nothing to be half-done.
Where the honesty is
The four checks confirm structure. They cannot confirm meaning. A record that is byte-complete, parses as JSON, and carries the right id can still have a flipped bit inside a string value, and every check will pass it. Detecting that needs a checksum per record, and Shardly does not have one.
Adding a CRC to the record format is the single most valuable change left in this file. I am saying so here rather than letting you discover it.
A torn tail should not condemn the file
readAll() swallows parse errors on individual lines. That is narrow and intentional. The last line of the log is the one most likely to be torn by the exact crash being recovered from, and one bad tail should not make the whole log unreadable.
Crash harness · npx tsx scripts/crash-test.ts 6
Spawn a writer, SIGKILL it after a random 150 to 550 ms, reopen the store, verify every id ever acknowledged.
The bug that made the test lie
The writer acknowledges each id over a pipe. fs.writeSync on a non-blocking pipe throws EAGAIN once the parent's buffer fills.
A dropped acknowledgement does not fail the test. It quietly shrinks the set under test, which is worse, because it looks exactly like success. scripts/crash-writer.ts retries on EAGAIN instead of dropping the line. A harness you have not tried to break is a harness you are trusting on faith.
fsync costs the same for 200 bytes and 200 kilobytes
Which means the number of fsync calls, not the number of bytes, sets the ingest ceiling.
The fiddly part is the arithmetic, not the protocol
writeBatch() builds the whole NDJSON blob in memory first, assigning ids and computing offsets as it goes, rotating segments where the 64 MiB cap demands. Every offset is measured against this.currentSegmentSize plus Buffer.byteLength(chunk, "utf8"), which is the committed size plus the bytes buffered but not yet flushed.
It has to be Buffer.byteLength and not String.length. One multi-byte character puts every later offset in the batch off by the difference. That bug is invisible on ASCII test data and corrupts every read on real text, which is the worst combination a bug can have.
npx tsx scripts/seed.ts 20000 /tmp/shardly-demo
The ordering guarantee never changed. The invariant was never "one document at a time", it was "intent durable before data, data durable before commit".
Compaction is four steps, and only one of them commits
Deleting a document sets a flag. The bytes stay. Compaction is how the disk finds out.
New segments are numbered past the current one
They start at currentSegmentIndex + 1, so a fresh file can never collide with one that live offsets point at. The old files are readable through the entire operation. Nothing is unlinked until the new snapshot is durable.
Leaving the orphan is a decision, not an oversight
A crash before step 3 leaves a partial new segment that nothing references. No startup sweep removes it, and that is a choice rather than an omission.
A sweep that unlinks unreferenced segments is exactly the code that deletes real data the day a snapshot goes missing for an unrelated reason. Leaving the orphan costs disk. The next compaction reclaims it anyway, because it reuses the same number and opens with "w". Even if a normal rotation reaches that number first, it opens with "a" and sizes itself from the file, so new records land after the dead bytes and every offset stays correct.
Wasted space, never corruption. I will take that trade every time.
Measured · 20,000 seeded documents, half deleted
- Trigger
- Manual, through
POST /compact. No background thread decides for you. - Blocking
- Yes. All I/O in Shardly is synchronous, so a compaction stalls every concurrent request for its duration.
- Commit point
- snapshot(), step 3 of 4
Counting documents cannot tell you a snapshot is current
Delete one document, add another, and the count matches while the contents do not. That case is not hypothetical, it is what a normal workload does all day.
Renaming a file is atomic, and that is not the same as durable
writeJsonAtomic() writes to ${target}.tmp, calls fsync on it, renames it over the target, and then calls fsync on the parent directory. That last step is easy to skip and it matters here more than usual.
A rename is atomic with respect to readers, but the directory entry is not durable until the directory itself is flushed. snapshot() truncates the write-ahead log immediately afterward. Without the directory fsync, a power failure in that gap loses the snapshot and the log that would have rebuilt it. Two durable-looking writes, zero durable state.
Why the two snapshots are not written atomically together
The offset snapshot and the inverted-index snapshot are written in sequence. A crash between them leaves a fresh offset file next to a stale index file.
The version check catches that on the next start and rebuilds. Making the pair atomic would need a two-phase write across two files for no gain, because the inverted index is derived data. Rebuilding it is always safe and never wrong. Spending complexity to protect something you can always regenerate is how storage layers get hard to read.
The rule
SNAPSHOT_EVERY_N_WRITES is 500. Anything other than an exact version match rebuilds the index from the segments. Equal counts no longer pass for equal state.
A tokenizer is a controlled loss of information
Four stages, and every one of them throws something away on purpose. The useful question is not whether it loses information. It is which information, and what that costs you.
The character class is ASCII-only
/[^a-z0-9\s]+/g runs after toLowerCase(), so every character outside a-z0-9 becomes a separator. tokenize("Zürich café") gives ["z", "rich", "caf"]. tokenize("東京 検索") gives an empty array.
Non-English text is not ranked poorly. It is unindexable. For the bundled English corpus this never shows up, which is exactly why it is worth writing down.
The stemmer over-stems and under-stems
Six suffix rules, not Porter. stem("cares") returns "car", colliding with the actual word car, so a query for cars matches documents about caring. That is over-stemming.
Meanwhile stem("running") gives "runn" and stem("runs") gives "run", so two forms of one verb never meet. That is under-stemming, and it is a plain defect rather than a trade-off. Porter handles it with a doubled-consonant rule.
Why keep it anyway
It is a dozen lines you can read in full and reason about, and swapping in Porter is a contained change to one function that already has tests around it.
The reason to write the failures down is simpler. A stemmer nobody has measured is a stemmer everyone trusts too much.
Symmetry saves it
Query text and document text go through the identical function. A collision costs precision on both sides evenly instead of breaking recall in one direction, which is the difference between a blunt tool and a broken one.
Lab 03 / the tokenizer, ported verbatim
Type anything
| Input word | Lowercased | After the ASCII split | Rule that fired | Token emitted |
|---|---|---|---|---|
| Zürich | zürich | z · rich | len ≤ 3, untouched · no rule matched | z · rich |
| cafés | cafés | caf · s | len ≤ 3, untouched · len ≤ 3, untouched | caf · s |
| are | are | are | stopword | — |
| running | running | running | -ing, len > 5 | runn |
| distributed | distributed | distributed | -ed, len > 4 | distribut |
| queries | queries | queries | -ies → y | query |
Output · 7 tokens, duplicates kept because term frequency needs them
The complete stopword list · 51 surface forms, checked before stemming
Because the check runs before stemming, have and has are dropped while having stems to hav and survives.
Four maps, and the interesting one exists only for delete
index, docFreq, and docLengths are what BM25 needs. docTerms is what keeps deletion from scaling with the size of the corpus.
Removing a document means removing it from every posting list it appears in. The obvious way is to walk the whole index and filter each list. It is correct, it passes every unit test, and it costs O(vocabulary) per delete while allocating a replacement array for every term in the corpus, whether or not the document touched it.
docTerms maps a document id to the distinct terms it contributed. The loop becomes O(terms in this document). The gap widens as the vocabulary grows, which is the direction real corpora go.
docTerms is not in the snapshot format. load() rebuilds it by walking the postings once, which costs one pass over data already being parsed and keeps old snapshots readable. Deriving beats storing when the derivation is cheaper than the format change.
The memory cost is one array of string references per document. The strings are the same objects already used as keys in index, so this is pointers, not text.
The four maps
- index
- term → postings. The index itself.
- docFreq
- term → document count. IDF's n.
- docLengths
- docId → token count. BM25's |D|.
- docTerms
- docId → its distinct terms. Delete only.
- totalTokens
- A running sum, so averageDocumentLength() is O(1) instead of a scan.
addDocument() calls removeDocument() first when the id is already known. Without that, re-adding an id would leave two postings for one document and double its score.
Lab 04 / a five-document index, live
Real tokenizer, real BM25
The corpus · every document that exists
D1 · 19 tokens
Crash recovery. A write-ahead log records intent before data. On restart the log replays and verifies each pending record against the segment bytes.
D2 · 18 tokens
Segment storage. Segments are append-only files. Storage never rewrites a record in place, so a crash can only damage the tail of the newest segment.
D3 · 17 tokens
Ranking with BM25. BM25 scores a document by term frequency, inverse document frequency, and length. Rare terms weigh more than common terms.
D4 · 15 tokens
Index deletion cost. Deleting a document from an inverted index means visiting every posting list that document contributed a term to.
D5 · 15 tokens
Compaction. Compaction rewrites live records into fresh segments and drops the tombstoned ones. The offset snapshot is the commit point.
Posting lists · 62 terms, showing the 36 most common
record
D1×2 D2×1 D5×1
segment
D1×1 D2×3 D5×1
crash
D1×1 D2×1
document
D3×2 D4×2
rewrit
D2×1 D5×1
term
D3×3 D4×1
against
D1×1
ahead
D1×1
append
D2×1
before
D1×1
bm25
D3×2
byt
D1×1
can
D2×1
commit
D5×1
common
D3×1
compaction
D5×2
contribut
D4×1
cost
D4×1
damage
D2×1
data
D1×1
delet
D4×1
deletion
D4×1
drop
D5×1
each
D1×1
every
D4×1
fil
D2×1
frequency
D3×2
fresh
D5×1
index
D4×2
intent
D1×1
into
D5×1
inverse
D3×1
invert
D4×1
length
D3×1
list
D4×1
live
D5×1
What the engine did
- Query terms
- segment · crash · recovery
- Lists unioned
- 3
- Candidates
- 3 of 5
- Docs read
- 3 · only the winners
- Docs scanned
- 0
Ranked, with the score broken down term by term
recovery tf=1 idf=1.39 → 1.309 crash tf=1 idf=0.88 → 0.827 segment tf=1 idf=0.54 → 0.509
segment tf=3 idf=0.54 → 0.883 crash tf=1 idf=0.88 → 0.848
segment tf=1 idf=0.54 → 0.566
BM25 asks three questions and multiplies the answers
How surprising is this word? Do repeats keep helping? Is this document long, or is it thorough? Every part of the formula is one of those three.
Lab 05 / one term, one document
N fixed at 20,000 documents
Contribution to the score
8.838
idf 4.708 × tf-factor 1.877
A · Repeats saturate
Raw frequency would be a straight line climbing forever. k1 bends it flat. Set k1 to 0 and every repeat past the first counts for nothing.
B · Length is discounted
At b = 0 the line is flat and a 10,000-word page wins on volume. At b = 1 depth is punished as padding. 0.75 sits between them.
C · Rarity is everything · why the + 1 inside the logarithm is not decoration
Drag n past 10,000 and watch the dashed line cross zero. Under that version, a document would lose points for containing a common query word. The + 1 keeps every contribution positive.
The test that pins the middle setting
A ranker that punishes length is as broken as one that rewards it. tests/rank.test.ts covers both ends: a short document beats an over-long one at equal term frequency, and a genuinely more relevant long document still wins when it earns it. The second test is the one that catches an over-eager b.
Why every hit carries a breakdown
Each result includes a TermScore[] with the term, its frequency in that document, its IDF, and its contribution, sorted by contribution. A test asserts the contributions sum to the total.
That is what lets an interface answer "why did this rank here" instead of showing a number nobody can check. A relevance score you cannot decompose is a relevance score you cannot debug.
Constants
- k1
- 1.5
- b
- 0.75
- Overridable
- Yes, through RankOptions, so tests can pin them.
Score in one pass, explain in another
Building a result object for every candidate would allocate for every document that matched any term. The answer is ten of them.
The version that was replaced
The first implementation looked up each winner's term frequency by searching that term's posting list, once per winner. On a term appearing in 20,000 documents with a limit of 10, it re-scanned 20,000 postings ten times to recover ten numbers.
It now walks each term's postings once, filling a Map<docId, Map<term, tf>> keyed by a Set of winner ids. The work drops to O(terms × postings) no matter what the limit is.
total counts matches, not results
total reports every document matching at least one query term, which is scores.size, not the size of the returned page. Reporting the page size would make every search look like it found exactly ten things, and pagination would have nothing to paginate.
When the heap steps aside
If limit is Infinity or larger than the candidate count, selectTopN() sorts everything instead. A heap cannot beat a sort when you want the whole list, and pretending otherwise is how you end up with a slower "optimized" path.
A search engine that cannot show its own speedup is a claim, not a result
So Shardly ships the slow path too, runs both on the same corpus with the same constants, and checks that they agree on the winner.
Lab 06 / the same query, both ways
npm run bench -- /tmp/shardly-demo · warm-up discarded, five runs averaged
Read the second bar for what it is
naiveSearch() re-reads and re-tokenizes every document from disk on every call. The comparison is no index at all against index already built, not linear scan against index lookup. Building the index is paid once at ingest and appears in neither column. A pre-tokenized linear scan would land somewhere between the two.
What the comparison is worth
Both sides compute BM25 over the same documents with the same k1 and b, and topHitsMatch verifies the fast path returns the same answer as the slow one. A speedup with a different answer is a bug report, not a benchmark.
Where the number is soft
speedup divides by Math.max(indexed.tookMs, 1e-6). On a small corpus where the indexed path finishes in microseconds, that ratio is measuring timer resolution as much as anything. Treat the ordering as real and the exact multiplier as approximate.
These are local measurements on one machine, not a performance guarantee. Run npm run seed and npm run bench on your own hardware for numbers that mean something to you.
What each column contains
- Indexed
- Tokenize the query, union the posting lists, score the candidates, seek the top ten. The index is already built.
- Unindexed
- Read every document from disk, tokenize it, compute document frequencies, score the corpus. Every single call.
- Not measured
- Index build time. It is paid once at ingest and appears in neither column.
- Missing third column
- A linear scan over pre-tokenized data. It would land between the two, and it is the honest comparison nobody publishes.
Three sources, one function signature
Every adapter returns plain Document objects and hands them to Engine.addDocuments(). None of them knows segments, the log, or the index exist.
Detecting binary before decoding it
Files with an unrecognized extension get checked first. isProbablyBinary() reads the first 8 KiB and returns true on any NUL byte. Otherwise it flags files where more than 30% of bytes are C0 control characters outside tab, newline, and carriage return.
A NUL byte is conclusive. The 30% threshold is a heuristic, tuned to let odd-but-real text through while rejecting compiled output. Extensions on the TEXT_EXTENSIONS list skip the check entirely, because a .ts file is text even if it opens with something strange.
A PDF with no text is reported, not indexed
PDFs go through unpdf. If extraction yields nothing, the file is returned as skipped with a reason rather than stored as an empty document.
A scanned page that silently produces zero tokens is precisely the thing you want to see in the response. Indexing it as an empty document hides the problem and pollutes avgdl at the same time.
Malformed NDJSON lines are skipped, not fatal
One bad line in a 200,000-line dump should not reject the dump. Same reasoning as the torn WAL tail.
Politeness is a feature of the GitHub adapter
Concurrency runs through mapLimit(), an eighteen-line worker pool. Promise.all over every file would open hundreds of sockets and trip the rate limiter on the first repository. Caps on repositories, files per repository, total files, and file size are parameters with defaults, so the HTTP route holds a fixed policy while tests pick their own.
Progress is streamed, not awaited
POST /corpus/wikipedia/index streams NDJSON progress events as it works. A request that returns after 2,386 documents tells the user nothing for the whole duration. The streaming version is about twenty lines.
The HTTP layer stays thin so the engine stays testable
routes.ts validates input, shapes output, and calls Engine. Engine has no idea Fastify exists.
| Method | Route | Purpose |
|---|---|---|
| GET | /health | Liveness check |
| GET | /stats | Live document count |
| POST | /documents | Add one JSON document |
| POST | /documents/bulk | Add an array of documents through writeBatch() |
| POST | /documents/upload | Multipart text, code, JSON, NDJSON, and PDF |
| POST | /ingest/github | Index a user or repository, optionally deep |
| GET | /corpus/wikipedia/status | Bundled count against indexed count |
| POST | /corpus/wikipedia/index | Stream indexing progress as NDJSON |
| POST | /corpus/wikipedia/deindex | Remove the articles, keep the file |
| GET | /documents | Paginated list with truncated previews |
| GET | /documents/:id | Full document, never truncated |
| DELETE | /documents/:id | Tombstone and de-index |
| GET | /search | Ranked hits with snippets and BM25 breakdowns |
| GET | /benchmark | Indexed and naive timings plus top-hit agreement |
| POST | /compact | Rewrite segments, drop tombstoned records |
| POST | /reset | Clear the store and the index |
previewDoc() had to learn to recurse
GET /documents and GET /search trim string values to 280 characters and set _truncated. The first version only walked top-level keys.
A nested {content: {body: "…"}} from a deep GitHub index shipped whole, so responses ran to megabytes while _truncated was absent, which is the worst possible combination: wrong data and a flag saying it is fine. It now recurses through objects and arrays. GET /documents/:id still returns the full document, because that is the detail view.
parseLimit() clamps rather than trying
Unparseable or non-positive values fall back to the default, and everything is capped. A client asking for limit=100000000 gets the cap, not an attempt and a heap crash.
buildServer() returns the engine too
Tests need the handle. Production needs the onClose hook that snapshots and closes descriptors. Both SIGINT and SIGTERM route through app.close(), so a container stop is a clean shutdown rather than a recovery on next boot.
The cost of everything
One table for time, one for memory. Both are honest about what dominates in practice, which is rarely the term in the big-O.
Time · n is candidate count, k is the result limit
| Operation | Cost | What actually dominates |
|---|---|---|
write(doc) | O(1) work, 3 fsync | fsync latency, nothing else |
writeBatch(docs) | O(k) work, 3 fsync total | Disk bandwidth |
read(id) | O(1), one readSync | OS page cache |
delete(id) | O(terms in that document) | Document size, not corpus size |
search(q, limit) | O(Σ postings of query terms + n log k) | How common the query words are |
naiveSearch(q) | O(corpus bytes), re-tokenized every call | CPU, then disk |
compact() | O(live records) | Disk bandwidth |
startup, snapshot valid | O(offset entries), JSON.parse | Parsing JSON |
startup, snapshot stale | O(corpus), full re-tokenize | CPU |
Memory · rough, and rough on purpose
V8 does not promise an object layout, so these are estimates from typical heap snapshots rather than guarantees. They are still the right order of magnitude, and the order of magnitude is the point.
- Offset entry
- A 36-character UUID key plus a four-field object. Call it 180 bytes per document.
- 1M documents
- Roughly 180 MB of heap before a single posting exists.
- One posting
{ docId, termFrequency }is about 50 bytes, counting the shared string pointer.- A 200-token doc
- Around 120 distinct terms, so roughly 6 KB of postings.
- Therefore
- A million such documents is several gigabytes of postings alone. This is the ceiling, and it is a heap ceiling, not a disk one.
Lucene stores the same information as delta-encoded integers in memory-mapped blocks. That is not an optimization detail, it is the difference between a corpus that fits and one that does not.
What a real engine does that this one does not
Shardly and Lucene solve the same problem with the same shapes. The difference is everywhere in the second column, and naming it is more useful than pretending the gap is small.
| Concern | Shardly | Lucene, and Elasticsearch on top of it |
|---|---|---|
| Posting storage | Plain JS objects on the heap | Delta and variable-byte encoded blocks on disk |
| Skipping | None. Walk the whole list. | Skip lists over posting blocks |
| Term dictionary | A JavaScript Map | A finite state transducer, prefix-compressed, memory-mapped |
| Positions | Not stored | Positions, offsets, and payloads per posting |
| Query language | Bag of words | Boolean, phrase, span, fuzzy, wildcard, function score |
| Segments | Append log plus manual compaction | Immutable segments plus a tiered merge policy |
| Deletes | A flag in the offset map | A deleted-docs bitset per segment |
| Durability | WAL, fsync per write or per batch | Translog, with refresh and flush phases |
| Concurrency | One process, synchronous I/O | Lock-free readers over immutable segments |
| Scale-out | None | Shards, replicas, a coordinator, a cluster state |
The structures are the same, which is the whole point
An inverted index is an inverted index whether the postings are JavaScript objects or variable-byte integers in a mapped file. BM25 is BM25 whether you compute it in TypeScript or in a JIT that has been tuned for twenty years. Segments are immutable and merged in both systems, and both put a durable log in front of the data.
Every row in that table is an engineering answer to a scale Shardly does not operate at. Skip lists matter when a posting list has ten million entries. A finite state transducer for the term dictionary matters when the vocabulary does not fit in memory. Positions matter the moment somebody types a phrase in quotes.
What you get for reading this one instead
You can read all of Shardly in an afternoon and hold it in your head afterwards. Every constant is in one file. Every fsync is on a line you can point at. When a search returns the wrong document, you can decompose the score term by term and find out why.
That is not a substitute for Lucene and is not trying to be. It is the thing you build once, so that Lucene stops being magic.
Said out loud, before you find them
Seven limits, and the order I would fix them in. Effort and payoff both matter, and they rarely point the same way.
No checksums
Recovery validates structure, not content. A flipped bit inside a string value survives JSON.parse and the id check, and every one of the four recovery tests passes it. A CRC per record is the fix, it is contained to the record format, and it closes the only silent-corruption hole in the design.
Fix first. Small change, removes a whole failure class.
All I/O is synchronous
fs.readSync and fs.writeSync block the Node event loop, so one slow read stalls every concurrent request. At a scale where the page cache holds the working set this is fine, and it keeps the storage code linear and readable. At a larger scale it is the first thing to change, and it is not a small change.
Second. Large diff, and the readability cost is real.
Search is a bag of words
No phrase queries, no boolean operators, no field weighting, no fuzzy matching. Position data has to go into the postings before any of that becomes possible, which means the posting format changes and the snapshot format changes with it.
Third. Biggest jump in what the product can do.
The stemmer is six rules
Porter's algorithm fixes the doubled-consonant case and most of the over-stemming. It is a self-contained swap into one function that already has tests around it.
Cheap. Do it any time.
The inverted index is memory-resident
Postings are JavaScript objects, nothing spills to disk, and the corpus has to fit in the heap. Real engines use skip lists and delta-encoded posting blocks on disk. See the memory table above for where the wall is.
Only when the wall is actually hit.
One writer, one process
Nothing coordinates two processes against one data directory. There is no lock file. Opening the same directory twice will corrupt it. A lock file is twenty lines and would at least turn corruption into an error message.
Twenty lines. Worth doing on principle.
No authentication
POST /reset destroys the store and DELETE /documents/:id removes a document, both unauthenticated, with CORS set to origin: true. Correct for a local demo, unacceptable the moment it is reachable from anywhere else.
Blocking, if this is ever deployed.
Also true, and less fixable
Snapshots are JSON. Human-readable and slow to parse. A binary format would load faster and diff worse. For a system whose entire point is being readable, JSON wins, and I would make the same call again.
The tokenizer cannot index non-English text. Not ranks it poorly. Cannot index it. Fixing that means a Unicode-aware character class and a per-language stemmer, at which point you are building a real analysis chain and should probably use one.
Compaction blocks. Everything blocks. See limit 02.
A limit you have written down is a decision. A limit you have not is a surprise waiting for someone else.