Architecture
This page explains how GraphDB is built internally. Understanding the architecture helps you make informed decisions about data modeling, indexing, and when to reach for specific APIs.
High-level overview
+---------------------------------------------------+| GraphDB() || (factory function) || || Returns GraphDBType: thin wrapper holding || Map<string, Collection> || || +---------------------------------------------+ || | createCollection<T>(options?) | || | | || | +----------+ +---------+ +-------------+ | || | | Storage | | Indexes | | Listeners | | || | | Map< | | Map< | | Map<Event, | | || | | id, | | field, | | Set<fn>> | | || | | Doc<T>> | | Map< | | | | || | | | | value,| | Map<docId, | | || | | | | Set< | | Set<fn>> | | || | | | | id>>> | +-------------+ | || | +----------+ +---------+ | || | | || | +----------+ +-----------+ | || | | Syncers | | Utilities | | || | | (optional| | - where | | || | | async | | checker | | || | | hooks) | | - sort | | || | +----------+ | - isEmpty | | || | +-----------+ | || +---------------------------------------------+ |+---------------------------------------------------+Closure-based design
GraphDB uses closure-based encapsulation rather than classes. The createCollection() function declares all internal state as local variables and returns a plain object whose methods close over that state. This means:
- Private by default. Internal structures like the document
Map, indexMap, and listenerSetinstances are not accessible from outside. There is nothisto leak, no prototype to monkey-patch, and no need for#privatefields orWeakMaptricks. - No
thisbinding issues. Every returned method is a plain closure. You can destructure, pass callbacks around, or assign methods to variables without worrying about lost context. - Simpler testing. The returned object is a plain JavaScript object with predictable behavior.
// Simplified mental model of createCollectionfunction createCollection<T>(options?) { // Private state -- not reachable from outside const documents = new Map<string, Doc<T>>(); const indexes = new Map<keyof T, Map<unknown, Set<string>>>(); const onListeners = new Map<EventType, Set<(payload: any) => void>>(); const docListeners = new Map<string, Set<(payload: ListenerPayload<T>) => void>>();
// Public API -- closes over private state return { create(doc) { /* ... */ }, read(id) { /* ... */ }, update(id, patch) { /* ... */ }, remove(id) { /* ... */ }, query(where, options) { /* ... */ }, // ... };}Document storage
Documents are stored in a Map<string, Doc<T>>. The key is the document’s _id, a string generated via crypto.randomUUID() at creation time. Each document is stored as a Doc<T>, which extends your type T with system fields:
type Doc<T> = T & { _id: string; _createdAt: number; // epoch milliseconds _updatedAt: number; // epoch milliseconds};Timestamps are epoch milliseconds (number), not Date objects. This makes comparisons trivial and avoids serialization issues.
Using a Map provides:
- O(1)
read(id)viaMap.get() - O(1)
exists(id)viaMap.has() - O(1)
count()(no filter) viaMap.size - Insertion-order iteration when scanning
Index structure
Indexes follow a three-level Map structure:
indexes: Map<field, Map<value, Set<docId>>>For example, given a collection of users indexed on age:
indexes = Map { "age" => Map { 25 => Set { "uuid-1", "uuid-4" }, 30 => Set { "uuid-2" }, 35 => Set { "uuid-3", "uuid-5", "uuid-7" } }}When you create a collection with indexes: ['age'], GraphDB initializes an empty Map for the age field. As documents are created, updated, or removed, three internal helpers maintain index consistency:
indexAdd(doc)— iterates each indexed field, gets the field’s value from the document, and adds the document’s_idto the correspondingSet. Creates theSetif it does not exist yet.indexRemove(doc)— reverse ofindexAdd. Removes the_idfrom theSetand cleans up emptySetentries to avoid memory leaks.indexUpdate(before, after)— for each indexed field, compares old and new values. If unchanged, skips the field. Otherwise, removes from the old bucket and adds to the new one.
Query planner
The query pipeline follows this order: filter -> sort -> skip -> limit.
The filter phase uses a function called getCandidateIds() that determines whether any where clause fields have indexes. The algorithm works as follows:
- Check for indexed fields. For each field in the
whereclause, check if an index exists for that field. - Equality lookups. If the where value is a primitive (string, number, boolean) or uses the
eqoperator, look up the exact value in the index to get aSet<docId>. This is O(1). inoperator lookups. If the where value uses theinoperator with an array, perform one lookup per array element and union the results. This is O(m) where m is the array length.- Set intersection (smallest-first). When multiple indexed fields match, sort the candidate sets by size (smallest first) and intersect them. This minimizes the number of comparisons because the smallest set bounds the maximum result size.
- Evaluate remaining clauses. Non-indexed fields and operators that cannot use indexes (like
gt,lt,contains,regex) are evaluated on the candidate documents only, not the full collection.
If no indexed fields match the query, the planner falls through to a full scan of all documents.
Query: { age: 25, name: "Alice" }Indexes: ["age"]
1. "age" is indexed -> look up value 25 -> Set { "uuid-1", "uuid-4" }2. "name" is NOT indexed -> skip index phase3. Candidate set: { "uuid-1", "uuid-4" } (2 docs, not full collection)4. Evaluate "name === Alice" only on those 2 candidatesListener system
GraphDB provides two listener mechanisms, both built on Map<key, Set<handler>>:
Collection-level events (on)
onListeners: Map<EventType, Set<handler>>Events include created, updated, removed, and syncError. When an event fires, GraphDB iterates the Set for that event type and calls each handler.
Per-document listeners (listen)
docListeners: Map<docId, Set<handler>>listen(id, callback) attaches a handler that fires whenever the specified document is updated or removed. This enables fine-grained reactivity — you can watch a single document without receiving events for the entire collection.
O(1) unsubscribe
Both on() and listen() return a cancel function. Internally, this cancel function calls Set.delete(handler), which is O(1). There is no array scanning or splicing involved:
const cancel = users.on('created', (payload) => { console.log('New user:', payload.doc);});
// Later: O(1) unsubscribecancel();Syncer system
Syncers enable optimistic writes. The pattern is:
- Apply the write immediately to the in-memory store (create, update, or remove).
- Call the async syncer function and await its result.
- If the syncer returns
true, the write stands. Nothing more happens. - If the syncer returns
falseor throws, GraphDB reverts the write automatically:- For
create: removes the newly created document. - For
update: restores the previous version of the document. - For
remove: re-inserts the removed document.
- For
- Emit a
syncErrorevent so centralized error handlers can react. - Throw an error so the caller’s
catchblock can handle it.
This design means the UI always sees an immediate response, and corrections happen asynchronously if the backend rejects the write.
Timeline:
create(doc) called | v [Doc written to Map] <-- UI sees new doc immediately | v [syncer.create(doc) called] | +-- success --> done | +-- failure --> [Doc removed from Map] [syncError event emitted] [Error thrown]Utility functions
whereChecker(doc, field, clause)
Evaluates a single where clause against one field of a document. Handles:
- Primitive values: direct equality check (
doc[field] === clause) - RegExp values: tests the field value against the regular expression
- Operator objects: evaluates operators like
eq,ne,gt,gte,lt,lte,in,nin,contains, andregex
sortDocuments(docs, sort)
Multi-field sort supporting ASC and DESC directions. Uses String.prototype.localeCompare() for string fields and numeric subtraction for numbers. Fields are evaluated in order — the first field is the primary sort key, the second is the tiebreaker, and so on.
isEmptyObject(obj)
A fast empty-object check using a for...in loop that returns false on the first property found. Faster than Object.keys(obj).length === 0 because it avoids allocating an array.