Architecture in diagrams

August 16, 2026 · View on GitHub

The same material as architecture.md, drawn. Read that one for the invariants and the prose; read this one to see the shapes.

The diagrams are Mermaid, which GitHub renders inline, as do most Markdown viewers with Mermaid support.

The stack

Each layer knows only about the one below it.

flowchart TD
    subgraph app["Your program"]
        A1["tinysql.ParseSQL / Execute"]
        A2["tinysql builder"]
        A3["database/sql + driver"]
    end

    subgraph drv["internal/driver"]
        D1["connector: owns one storage.DB"]
        D2["conn: transactions, placeholder binding"]
        D3["server: reader/writer gating, persist"]
    end

    subgraph eng["internal/engine"]
        E1["lexer + parser to Statement"]
        E2["executeStatement: locks, rollback, WAL, audit"]
        E3["execStmt: dispatch per statement type"]
        E4["fast paths over stored rows"]
        E5["general evaluator over row maps"]
    end

    subgraph sto["internal/storage"]
        S1["DB: tenants, tables"]
        S2["CatalogManager: views, triggers, jobs, RBAC"]
        S3["WALManager / AdvancedWAL"]
        S4["StorageBackend: disk, json, hybrid, index, paged"]
    end

    A1 --> E1
    A2 --> E1
    A3 --> D2
    D2 --> D3
    D1 --> S1
    D2 --> E2
    E1 --> E2
    E2 --> E3
    E3 --> E4
    E3 --> E5
    E4 --> S1
    E5 --> S1
    E2 --> S3
    E3 --> S2
    S1 --> S4

The engine touches the filesystem in exactly one place: the SQL-callable file() and http() functions in io_functions.go. That is a real capability to be aware of, not an oversight — it gives any statement the process's own filesystem and network reach.

Core types

classDiagram
    class DB {
        tenants
        catalog
        wal
        backend
        shadow
        +Get(tenant, name)
        +Put(tenant, table)
        +SnapshotForTx()
        +StatementWAL()
        +copyRuntimeState()
    }
    class Table {
        Name
        Cols
        Rows
        Version
        dirtyFrom
        dirtyRows
        +MarkRowUpdated(idx)
        +MarkDirtyFrom(idx)
        +DirtyRows()
    }
    class Column {
        Name
        Type
        Affinity
        NotNull
        Constraint
        ForeignKey
    }
    class CatalogManager {
        revision
        views
        triggers
        jobs
        rbac
        +Revision()
    }
    class WALManager {
        nextSeq
        checkpointWatermark
        +LogTransaction(changes)
        +Checkpoint(db)
    }
    class StorageBackend {
        <<interface>>
        +LoadTable()
        +SaveTable()
        +Sync()
    }
    class ResultSet {
        Cols
        Rows
    }

    DB "1" --> "many" Table : per tenant
    DB "1" --> "1" CatalogManager
    DB "1" --> "0..1" WALManager
    DB "1" --> "0..1" StorageBackend
    Table "1" --> "many" Column
    Table ..> ResultSet : queried into

A Table stores rows as [][]any — no per-row struct, no page layout. Values stay native Go values from storage through evaluation into the ResultSet, so a read needs no unmarshalling. That single choice explains most of tinySQL's read performance and most of its memory profile.

An autocommit write, end to end

sequenceDiagram
    autonumber
    participant App as Application
    participant Conn as driver.conn
    participant Srv as driver.server
    participant Eng as engine
    participant DB as storage.DB
    participant WAL as WALManager
    participant Disk as disk

    App->>Conn: Exec("UPDATE t SET x = ? WHERE id = ?")
    Conn->>Conn: bind placeholders, parse
    Conn->>Srv: acquire writer slot
    Conn->>Eng: Execute(ctx, live DB, tenant, stmt)
    Eng->>DB: permission check
    Eng->>DB: lock content for write
    Eng->>DB: MetaSnapshot as WAL pre-image
    Eng->>DB: rollback snapshot
    Eng->>DB: apply the change, MarkRowUpdated per row
    Eng->>WAL: LogTransaction(diff of pre-image)
    WAL->>Disk: append records, fsync
    Disk-->>WAL: durable
    alt append failed
        Eng->>DB: restore rollback snapshot
        Eng-->>Conn: error
    else append succeeded
        Eng-->>Conn: result
        Conn->>Srv: persist to backend
        Srv-->>Conn: error propagated on failure
    end
    Conn-->>App: rows affected, or error

Two properties are visible here. The log is written before the statement is acknowledged, and a failed append restores the rollback point so memory never holds a change the log does not. And a persistence failure becomes the statement's error — acknowledging a write that is not durable is the one outcome an application cannot detect for itself.

A transaction

sequenceDiagram
    autonumber
    participant App as Application
    participant Conn as driver.conn
    participant Live as live DB
    participant Shadow as shadow DB
    participant WAL as WALManager

    App->>Conn: BeginTx
    Conn->>Live: SnapshotForTx
    Live-->>Conn: base (versions only) + shadow (deep copy)
    Note over Shadow: marked as a shadow, so StatementWAL<br/>returns nil and statements cannot log
    Conn->>Conn: record catalog revision and contents

    App->>Conn: Exec("INSERT ...")
    Conn->>Shadow: execute
    Note over Shadow: applied in the shadow only,<br/>nothing written to the log

    App->>Conn: Exec("CREATE VIEW ...")
    Conn->>Shadow: execute against the shadow's own catalog copy

    alt COMMIT
        App->>Conn: Commit
        Conn->>Conn: CollectWALChanges(base, shadow)
        Conn->>Conn: catalog changed? revision gate, then compare contents
        Conn->>Live: detectTxConflicts on changed tables
        alt conflict
            Conn-->>App: ErrTransactionConflict (retryable)
        else no conflict
            Conn->>WAL: LogTransaction(all changes) once
            Conn->>Live: ApplyWALChanges
            Conn->>Live: AdoptCatalog if the catalog changed
            Conn-->>App: ok, or the persistence error
        end
    else ROLLBACK or abandoned
        App->>Conn: Rollback, ResetSession, or Close
        Conn->>Shadow: discard
        Note over WAL: nothing to retract, because<br/>the block was never logged
    end

The shadow is why a rolled-back transaction leaves nothing behind. When statements inside a transaction did log as they ran, each one landed on disk looking committed, and recovery resurrected rows a ROLLBACK had discarded.

ModeAdvancedWAL records row operations as they happen, so it cannot simply stay silent. It instead joins one ambient WAL transaction per SQL transaction, which recovery replays only if a matching commit record exists.

Connection transaction state

stateDiagram-v2
    [*] --> Idle
    Idle --> InTx : BeginTx
    Idle --> InTx : Exec("BEGIN")
    InTx --> InTx : Exec writes to the shadow
    InTx --> Idle : Commit, merged into the live DB
    InTx --> Idle : Rollback, shadow discarded
    InTx --> Idle : ResetSession or Close, shadow discarded
    Idle --> [*] : Close

    note right of InTx
        A transaction started by Exec("BEGIN")
        is invisible to database/sql, so the pool
        can recycle the connection while it is open.
        ResetSession rolls it back; without that the
        connection stayed here forever and silently
        discarded every later write.
    end note

Query execution: fast path or general path

flowchart TD
    Q["SELECT statement"] --> ELIG{"single table,<br/>no CTE reference,<br/>supported shape?"}
    ELIG -- no --> GEN
    ELIG -- yes --> PLAN["compile a plan:<br/>projections, filter, order keys"]
    PLAN --> FILT{"can the WHERE clause<br/>compile to a row filter?"}
    FILT -- "no (nil filter)" --> GEN
    FILT -- yes --> ORD{"can every ORDER BY term<br/>resolve to an output column<br/>or a source column?"}
    ORD -- no --> GEN
    ORD -- yes --> IDX{"does a predicate match<br/>a seekable index,<br/>and is seeking cheaper?"}
    IDX -- yes --> SEEK["index seek,<br/>then evaluate the residual"]
    IDX -- no --> SCAN["scan stored rows,<br/>evaluate the compiled filter"]
    SEEK --> RAW["project from the stored row"]
    SCAN --> RAW
    RAW --> RES["ResultSet"]

    GEN["general path:<br/>build a Row map per row,<br/>recursive evaluator"] --> RES

    style GEN fill:#f5f0e8,stroke:#8a7a5c
    style RAW fill:#e8f0f5,stroke:#5c7a8a

A fast path must decline, never guess. Every builder returns a plan plus an ok, or a nil filter, and anything it does not fully understand falls through to the general path. A fast path that returns a subtly wrong answer is the one failure mode a test cannot catch unless it already knows the right answer.

Write-ahead log records

One statement or one transaction becomes a BEGIN, one record per changed table, and a COMMIT. Recovery applies a transaction's records only when it reaches that commit record.

flowchart LR
    B["BEGIN<br/>seq, txID"] --> R1
    B --> R2
    B --> R3
    B --> R4
    subgraph per["exactly one of these per changed table"]
        R1["APPEND_ROWS<br/>only the new rows"]
        R2["UPDATE_ROWS<br/>changed rows and their positions"]
        R3["APPLY_TABLE<br/>the whole table"]
        R4["DROP_TABLE"]
    end
    R1 --> C["COMMIT<br/>then fsync"]
    R2 --> C
    R3 --> C
    R4 --> C

Which record a change becomes is decided by the dirty tracking on the table:

flowchart TD
    CH["a changed table"] --> D1{"rows only appended?"}
    D1 -- yes --> AR["APPEND_ROWS"]
    D1 -- no --> D2{"exact list of rows<br/>replaced in place,<br/>smaller than the table?"}
    D2 -- yes --> UR["UPDATE_ROWS"]
    D2 -- no --> AT["APPLY_TABLE"]

    style AT fill:#f5e8e8,stroke:#8a5c5c

APPLY_TABLE is the fallback, and the fallback direction is deliberate. A mutation that cannot describe its shape gives up the hint, and the cost is one oversized record. Trusting a hint that is wrong would cost data instead. Before UPDATE_ROWS existed, changing one row of a 10,000-row table serialized and fsynced all 10,000 of them.

Recovery

flowchart TD
    OPEN["OpenDB in ModeWAL"] --> CP["load the GOB checkpoint"]
    CP --> WM["read the checkpoint watermark:<br/>the log position it already reflects"]
    WM --> LOOP{"decode the next record"}

    LOOP -- "clean EOF,<br/>whole file consumed" --> DONE["ready"]
    LOOP -- "seq at or below the watermark" --> SKIP["skip, already in the checkpoint"]
    SKIP --> LOOP
    LOOP -- "decode error,<br/>or EOF with bytes left over" --> TORN["truncate at the end of<br/>the last complete record,<br/>report Truncated"]
    TORN --> DONE
    LOOP -- "a record" --> PEND["hold it against its txID"]
    PEND --> ISC{"COMMIT record?"}
    ISC -- no --> LOOP
    ISC -- yes --> APPLY["apply that transaction's records in order"]
    APPLY --> LOOP

    style TORN fill:#f5f0e8,stroke:#8a7a5c
    style SKIP fill:#e8f0f5,stroke:#5c7a8a

Both shaded branches exist because of a specific failure. Without the watermark, a crash between writing a checkpoint and truncating the log replayed already-checkpointed deltas and duplicated committed rows. Without treating a damaged tail as recoverable, a single bad byte made the database permanently unopenable even though everything before it had recovered fine — and a trailing run of zero bytes reads as a clean EOF, so anything appended after it would have been invisible to every later recovery.

Choosing a storage mode

flowchart TD
    START{"must an acknowledged write<br/>survive a crash?"}
    START -- no --> MEM{"does the data have to<br/>fit in memory?"}
    MEM -- yes --> ModeMemory["ModeMemory<br/>fastest; save explicitly"]
    MEM -- no --> COLD{"what should bound memory?"}
    COLD -- "a byte budget" --> ModeHybrid["ModeHybrid<br/>LRU buffer pool"]
    COLD -- "schema only" --> ModeIndex["ModeIndex<br/>rows on disk"]
    COLD -- "per-table files" --> FMT{"must the files be readable<br/>by a text tool?"}
    FMT -- yes --> ModeJSON["ModeJSON"]
    FMT -- no --> ModeDisk["ModeDisk<br/>GOB per table"]

    START -- yes --> GRAN{"what granularity of<br/>recovery do you need?"}
    GRAN -- "per statement" --> ModeWAL["ModeWAL<br/>log fsynced per commit,<br/>periodic full checkpoint"]
    GRAN -- "per row, point in time" --> ModeAdvancedWAL["ModeAdvancedWAL<br/>row-level log"]

    START -.-> ModePagedIndex["ModePagedIndex<br/>a read-only artifact:<br/>rows and indexes in B+Trees,<br/>immutable"]

    style ModeWAL fill:#e8f5e8,stroke:#5c8a5c

ModeWAL is the mode to compare against SQLite when an acknowledged write must survive a crash. Compare equivalent flush tiers: wal_sync=normal performs an ordinary fsync per commit and corresponds to SQLite synchronous=FULL without fullfsync; the default wal_sync=full uses the strongest available OS flush (including F_FULLFSYNC on macOS) and must be compared with SQLite fullfsync=ON. BENCHMARKS.md contains both tiers.

Where a change belongs

mindmap
  root(("I want to change..."))
    SQL surface
      lexer.go
      parser.go
      parse_*.go
      ast.go
    what a statement does
      exec_dispatch.go
      exec_ddl_*.go
      exec_dml_*.go
      exec_select.go
    functions
      builtin_registry.go
      builtin_string.go
      builtin_math.go
      builtin_datetime.go
      builtin_hash.go
      eval_aggregate.go
      eval_window.go
    performance
      exec_plan.go
      exec_fastpath_*.go
      exec_raw_filter.go
    semantics
      value_semantics.go
      coerce.go
    durability
      wal_manager.go
      wal_advanced.go
      disk_format.go
    transactions
      driver/tx.go
      driver/conn.go
      storage/snapshot.go