folio package - github.com/jpl-au/folio - Go Packages

folio

package module
v0.1.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 1, 2026 License: MIT Imports: 28 Imported by: 0

README

Folio

From the Latin folium — a leaf or page of a manuscript.

A JSONL document store where the file is the interface. One .folio file holds your data as plain text — readable by grep, jq, any JSONL-capable tool, or an LLM, without the engine running. The Go library adds binary search, concurrent access, and automatic versioning on top of the same file.

The format is designed so every access path returns correct results: current content is plaintext in _d and grep-searchable, old versions are compressed in _h and invisible to text search, and record types are filterable by a single field (_r).

# These work without Go, without a server, without anything
grep '"_d":".*TODO' docs.folio               # search content
grep -o '"_l":"[^"]*"' docs.folio | sort -u  # list documents
jq -r 'select(._r == 2) | ._d' docs.folio  # extract all content
// Or use the Go library for structured access
db, _ := folio.Open("docs.folio", folio.Config{})
db.Set("my-doc", "Hello, World!")
content, _ := db.Get("my-doc")

Install

go get github.com/jpl-au/folio

Quick Start

package main

import (
    "fmt"
    "log"
    "github.com/jpl-au/folio"
)

func main() {
    db, err := folio.Open("data/docs.folio", folio.Config{})
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    if err := db.Set("my-doc", "Hello, World!"); err != nil {
        log.Fatal(err)
    }

    content, err := db.Get("my-doc")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(content) // "Hello, World!"

    // Iterate over labels, search results, and history
    for label, err := range db.List() {
        if err != nil { log.Fatal(err) }
        fmt.Println(label)
    }

    for match, err := range db.Search("Hello", folio.SearchOptions{}) {
        if err != nil { log.Fatal(err) }
        fmt.Println(match.Label)
    }

    for version, err := range db.History("my-doc") {
        if err != nil { log.Fatal(err) }
        fmt.Println(version.Data)
    }
}

File Format

Every .folio file is valid JSONL. The first line is a fixed-size header; subsequent lines are records distinguished by the _r field:

{"_v":1,"_e":0,"_alg":1,"_ts":1706000000000,"_s":[0,0,0,0,0,0]}        <- Header (128 bytes, space-padded)
{"_r":2,"_id":"a1b2c3d4e5f6g7h8","_ts":1706000000000,"_l":"my-doc","_d":"Hello!","_h":"..."} <- Data record
{"_r":3,"_id":"a1b2c3d4e5f6g7h8","_ts":1706000000000,"_l":"my-doc","_d":"","_h":"..."}       <- History record
{"_r":1,"_id":"a1b2c3d4e5f6g7h8","_ts":1706000000000,"_o":128,"_l":"my-doc"}                 <- Index record

Current content lives in _d and is plaintext — grep-searchable directly. Previous versions are Zstd-compressed and Ascii85-encoded in the _h field, retrievable through the History API or any language with Zstd and Ascii85 support.

See USAGE.md for command-line examples and PORTING.md for the full format specification.

API

Core Operations
db.Set(label, content string) error          // Create or update
db.Batch(docs ...Document) error             // Batch create or update
db.Get(label string) (string, error)         // Retrieve content by label
db.Delete(label string) error                // Soft delete (preserves history)
db.Exists(label string) (bool, error)        // Check existence
db.Rename(old, new string) error             // Change a document's label
db.Count() int                               // Document count (no I/O, lock-free)
Iterators

All, Search, List, MatchLabel, and History return iter.Seq2 iterators. Results stream lazily — break from the range loop to stop early without scanning the rest of the file.

db.All() iter.Seq2[Document, error]                                     // All label–content pairs
db.List() iter.Seq2[string, error]                                      // All labels
db.Search(pattern string, opts SearchOptions) iter.Seq2[Match, error]   // Pattern match on content
db.MatchLabel(pattern string) iter.Seq2[Match, error]                   // Regex on labels
db.History(label string) iter.Seq2[Version, error]                      // All versions

Search uses a literal fast path for patterns without regex metacharacters: the query is JSON-escaped and matched with bytes.Contains against the raw file content, avoiding both regex overhead and per-record JSON unescaping. Patterns containing regex metacharacters (.*+?()[]{}|\^$) fall back to regexp.Match. The fast path is transparent — callers don't need to know which path runs.

Maintenance
db.Compact() error                        // Sort and reclaim space, keep history
db.Purge() error                          // Sort and reclaim space, remove all history
db.Rehash(alg) error                      // Migrate to a different hash algorithm
db.Repair(opts *CompactOptions) error     // Rebuild from a corrupted file

Configuration

db, err := folio.Open("data/docs.folio", folio.Config{
    HashAlgorithm: folio.AlgXXHash3,  // default; also AlgFNV1a, AlgBlake2b
    ReadBuffer:    64 * 1024,         // scanner buffer size (default 64KB)
    MaxRecordSize: 16 * 1024 * 1024,  // largest record allowed (default 16MB)
    SyncWrites:    false,             // fsync after every write
    BloomFilter:   true,              // in-memory filter for sparse region
    AutoCompact:   50,                // compact every 50 writes (0 = disabled)
    MMap:          true,              // memory-map for reads (unix only)
    Index:         true,              // in-memory index for O(1) lookups
})
Bloom Filter

By default, folio scans the sparse region linearly for every lookup that misses the sorted index. Enabling BloomFilter builds a small (~12KB) in-memory filter at Open that tracks which IDs exist in the sparse region. Lookups for absent documents skip the linear scan entirely.

Memory-Mapped I/O

Enabling MMap memory-maps the database file for reads using mmap(2). Reads are served directly from the OS page cache without syscall overhead, which benefits read-heavy workloads where the database is populated once and queried many times. Point lookups (Get after Compact) are ~7x faster with mmap enabled. Bulk scans (List, Search, All) see modest improvement since they are dominated by JSON parsing rather than I/O.

The mapping is read-only (PROT_READ | MAP_SHARED) and remapped automatically after writes. Writes always go through the file descriptor. Unix only — on other platforms, Open silently ignores the option.

In-Memory Index

Enabling Index builds a map[string]int64 at Open that maps each document's hex ID to the byte offset of its index record. Get and Exists become O(1) lookups with a single file read instead of binary search plus sparse scan. The index is maintained automatically across Set, Delete, and Rename, and rebuilt after Compact and Rehash.

The trade-off is memory: one map entry (~80 bytes) per document. For databases with millions of documents this may be significant. The bloom filter is a lighter alternative that only accelerates negative lookups.

Documentation

  • AGENTS.md - Quick orientation for LLM agents and tool integrations
  • USAGE.md - Command-line usage and grep examples
  • PORTING.md - Format specification and implementation guide

Design

Folio is optimised for short-lived processes — a CLI tool or script that opens a file, reads or writes, and closes. All state lives on disk: no in-memory indexes survive between invocations, no background threads, no caches beyond an optional bloom filter built fresh at Open. Every operation works by streaming the file or seeking to known byte positions.

This is deliberate. Features you might expect from a long-running database — event systems, subscription channels, persistent in-memory indexes, write-behind caches — are absent because the current design target does not benefit from them. A process that opens a file for one lookup and closes it would pay the cost of building these structures without ever recouping the investment.

The roadmap has three phases:

  1. Short-lived processes (current) — disk I/O is the critical path. Open, operate, close. No persistent memory structures.
  2. Bridging (in progress) — features useful to both short-lived and long-running processes: batch writes and memory-mapped I/O.
  3. Long-running processes — memory-oriented features where a process holds the database open for an extended period: cached statistics, event hooks, watch/subscribe.

License

MIT License - see LICENSE

Documentation

Overview

Full document enumeration in a single pass.

All scans data records (_r=2) across the heap and sparse regions, extracting label and content by byte scanning. Unlike the List+Get pattern, it never follows index pointers — content is read directly from the data record, avoiding per-document seek overhead.

The scan uses the same sorted-region awareness as Search: only the heap and sparse regions are visited, skipping the index section entirely. Records retired by Set or Delete have their type byte patched from 2 to 3 (history), so the type check at TypePos naturally excludes them.

Database teardown.

Public entry points for the two common Repair modes.

Compression for inline history snapshots.

Each record's _h field stores the document content at the time of write. The content is Zstd-compressed for size, then Ascii85-encoded to produce a printable string that can be embedded directly in a JSON value without escaping. This avoids the 33% overhead of base64 while remaining newline-free (critical for the line-delimited format).

Core type definitions and structural helpers.

Concurrency is managed in three layers, each serving a different scope:

  1. Atomic state machine (db.state): gates whether new operations are allowed at all. Transitions: StateAll → StateRead → StateNone → StateClosed. Checked at the top of every public method.

  2. sync.RWMutex (db.mu): coordinates in-process readers and writers. Readers hold RLock; writers and Repair hold Lock.

  3. OS file lock (db.lock): coordinates across processes via flock(2) or LockFileEx. See internal/flock for lifetime management.

When an operation starts, it waits (via db.cond) until the state allows it, then acquires the appropriate lock level at layers 2 and 3.

Soft deletion — the record is converted to history so its compressed snapshot survives for version retrieval, but it no longer appears in lookups or listings because its index is erased.

Package folio provides an append-only document database backed by a single JSONL file. Documents are stored as newline-delimited JSON records with automatic versioning — every update preserves the previous content as a compressed history snapshot.

The file is divided into a heap, index, and sparse region. The heap co-locates data and history records sorted by ID then timestamp, so all versions of a document are contiguous. Indexes follow the heap in sorted order. The sparse region collects new writes and is scanned linearly. Compaction merges the sparse region back into the heap. This design keeps all state on disk without requiring in-memory indexes, though an optional bloom filter can accelerate negative lookups in the sparse region.

Concurrency gating for the three-layer lock protocol.

blockWrite and blockRead acquire all three concurrency layers (state check → OS flock → RWMutex) before allowing an operation to proceed. On return the caller holds db.mu (Lock or RLock) and db.lock; both must be released in the defer of the calling method.

Document retrieval using the two-region lookup strategy.

Lookups check the sorted index section first (binary search, O(log n)), then fall back to the sparse region (linear scan) for records written since the last compaction. The optional bloom filter can skip the sparse scan entirely when an ID is definitively absent.

Hash algorithms for deriving the _id field from a document label.

_id is always 16 hex characters (64 bits). This fixed width is what allows scanm to extract IDs at a known byte offset without parsing. The algorithm is stored in the header so all records in a file use the same one; Rehash can migrate between algorithms in place because the output width is identical across all three.

xxHash3 is the default because it has the best throughput for short strings (document labels) and excellent distribution. FNV-1a exists as a stdlib-only fallback for environments that cannot use cgo or external dependencies. Blake2b is offered for users who want cryptographic-quality distribution to minimise collision probability, at the cost of ~10x slower hashing — relevant only for very large databases where birthday-bound collisions on 64-bit hashes become a concern.

The header occupies the first 128 bytes of the database file. It stores section boundaries, document count, and a dirty flag for crash recovery. The fixed size allows the dirty flag to be toggled with a single-byte write at a known offset, avoiding a full header rewrite on every mutation.

Version history retrieval from compressed _h snapshots.

Both current Records (_r=2) and retired History records (_r=3) carry a compressed snapshot in _h. History collects all of them for a given label, decompresses each, and yields them in chronological write order.

After compaction, all versions of a document are contiguous in the heap (sorted by ID then timestamp). History uses group() to binary-search the heap for the ID group, then linearly scans the sparse region for any records appended since the last compaction.

Because results must be sorted by file offset (the ground truth for write order), all versions are collected and sorted before yielding. The iterator API provides consistency with Search, MatchLabel, and List even though this method buffers internally.

Label enumeration across the entire file.

Memory-mapped file I/O for Unix platforms.

mmapFile maps a file read-only with MAP_SHARED so that in-place patches made through the writer fd are visible immediately (both fds share the kernel page cache). Only appends that extend the file beyond the mapped region require a remap.

Database creation and opening.

Low-level read primitives for the newline-delimited record format.

Every record is a single JSON line terminated by '\n'. These functions read individual lines and find record boundaries via io.ReaderAt so that concurrent readers do not interfere with each other's offsets.

All read functions accept a source (an io.ReaderAt with a known size) rather than *os.File directly. When memory-mapping is enabled the source wraps the mmap'd region; otherwise it wraps the read-only fd.

Record format and type definitions.

Every line in the database file is a JSON object beginning with {"_r":N where N identifies the record type. This fixed prefix allows type detection and ID extraction at known byte offsets without JSON parsing — critical for binary search and compaction where millions of records may be scanned.

Three types coexist in the file:

  • Index (_r=1): maps a label's hash to the byte offset of its data record.
  • Record (_r=2): the current content of a document.
  • History (_r=3): a previous version with compressed content in _h.

On update, the old Record is retyped to History (byte patch from 2→3) and its _d field is blanked. This preserves the compressed snapshot in _h for version retrieval while making the record invisible to data scans.

In-place hash algorithm migration.

All three algorithms produce a 16 hex character (8 byte) _id, and the _id field sits at a fixed byte offset in every record. This means Rehash can overwrite each _id in place without moving or resizing any records — no temp file, no rewrite, just a linear scan with targeted byte patches.

The dirty flag is set before any patches begin and cleared after the header is updated. A crash mid-rehash leaves the flag set, so the next Open triggers automatic Repair — which rebuilds all IDs from labels, restoring consistency regardless of how many patches completed.

Label renaming with in-place patching when possible.

When old and new labels have the same byte length, Rename patches _id and _l directly in the data record and index record — no new version is created and no history entry is added. When lengths differ, it falls back to appending a new record+index and blanking the old ones (equivalent to Set+Delete but under a single lock hold).

History records are not patched in either path: they retain the old ID and become unreachable via History(newLabel). This matches the behaviour callers would get from the manual Get+Set+Delete approach.

Repair rebuilds the database file with all records in sorted order.

Over time, appends accumulate in the sparse region and lookups degrade toward linear scans. Repair reads every record, sorts by ID, and writes a new file with a contiguous heap (data + history sorted by ID then timestamp) followed by sorted indexes — restoring O(log n) binary search. It also serves as crash recovery: on Open, if a .tmp file or dirty flag is found, Repair is run automatically to restore consistency.

A temporary file (.tmp) is used instead of rewriting in place because in-place rewrite risks total data loss on crash: if the process dies mid-rewrite, both the old and new data are gone. Writing to a temp file, syncing, then atomically renaming means the original file is intact until the rename succeeds. A crash during the write phase at worst orphans the .tmp file, which is cleaned up on next Open.

The operation proceeds in two phases to minimise the time readers are blocked:

  • Phase 1 (read lock): scan the old file and write the new .tmp file. Concurrent readers continue using the old file.
  • Phase 2 (write lock): swap file handles from the old file to the new one. This is a brief exclusive lock for the atomic rename.

When called for crash recovery (BlockReaders=true), a write lock is held for the entire operation since the file may be inconsistent.

Scan strategies for the two-region file layout.

After compaction the file contains a sorted heap (data + history interleaved by ID then timestamp) followed by sorted indexes. New writes are appended after the indexes into a sparse (unsorted) region. Lookups therefore need two strategies:

  • scan: binary search over a sorted section. O(log n) seeks. Pass recordType=0 to match any type (type-agnostic).
  • sparse: linear scan over the unsorted region. O(n) but bounded to records written since the last compaction.

scanm is a compaction-only variant that extracts metadata at fixed byte positions without JSON parsing, since compaction must touch every record but only needs ID, type, timestamp, and label.

Search over document content and labels.

Search scans data records (_r=2) and matches against the _d field. After compaction the file has three sections: heap (data + history), index, and sparse. Search only needs the heap and sparse regions — the index section contains no _d fields. The scan skips the index section entirely by reading [HeaderSize..heapEnd) then [sparseStart..EOF). Before any compaction both boundaries are zero, so the first range is empty and the second covers the whole file — identical to a full scan.

Literal patterns (no regex metacharacters) take a fast path: the query is JSON-escaped and matched with bytes.Contains, avoiding both regex overhead and the need to unescape record content. Patterns containing regex metacharacters fall back to regexp.Match with optional decode.

The literal path works by escaping the search term into the same JSON representation used on disk (via json.Marshal), then matching raw bytes directly. This avoids per-record unescape overhead entirely. However, it assumes the on-disk encoding matches what json.Marshal produces. If Decode is set, the caller explicitly wants unescape-then-match semantics (e.g. to handle non-standard encodings like \u0041 for 'A'), so the literal path is bypassed to guarantee equivalent results.

Case-insensitive literal search uses bytes.ToLower on both needle and content. This allocates a copy of the _d slice per record. A zero-alloc alternative (sliding bytes.EqualFold) would trade O(n) for O(n*m) but eliminate GC pressure. We keep ToLower for now because search terms are typically short and the allocation is bounded to the _d field, not the full record line. Revisit if profiling shows GC pressure from search.

MatchLabel scans index records (_r=1) and matches against _l. It scans only the index section and sparse region, skipping the heap entirely.

Both stream through the file line-by-line to avoid loading it into memory. Callers consume results lazily via range and can break early to stop the scan without reading the rest of the file.

Document creation and update using append-then-blank.

A Set always appends a new Record+Index pair at the tail. If an older version exists, it is then patched in place: its type byte is changed from Record (2) to History (3), its _d content is blanked with spaces, and its index line is overwritten with spaces. The compressed snapshot in _h is preserved so History can still retrieve the old content. This approach avoids rewriting the file on every update while keeping the latest version immediately accessible via the newest index.

Batch amortises lock acquisition across multiple documents. All inputs are validated before any writes begin — if validation fails, no documents are written.

Write primitives for the append-only file.

New records are always appended at db.tail (the current end of file). The dirty flag is set on the first write of a session so that an unclean shutdown can be detected on next Open and trigger automatic repair. It is cleared during Close once all data has been flushed.

Example
package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	"github.com/jpl-au/folio"
)

func main() {
	dir, _ := os.MkdirTemp("", "folio-example")
	defer os.RemoveAll(dir)

	// Open or create a database
	db, err := folio.Open(filepath.Join(dir, "myapp.folio"), folio.Config{})
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// Store a document
	db.Set("readme", "# My App\n\nWelcome to my application.")

	// Retrieve it
	content, _ := db.Get("readme")
	fmt.Println(content)
}
Output:
# My App

Welcome to my application.

Index

Examples

Constants

View Source
const (
	StateAll    = 0 // reads and writes permitted
	StateRead   = 1 // reads only (Compact in progress, Phase 1)
	StateNone   = 2 // nothing permitted (Rehash / crash recovery)
	StateClosed = 3 // terminal
)

State machine values. Transitions are monotonic during shutdown (All→Closed) but cycle during maintenance (All→Read→All for Compact, All→None→All for Rehash). blockRead/blockWrite wait on db.cond until the state allows their operation.

View Source
const (
	AlgXXHash3 = 1 // default — fastest, good distribution
	AlgFNV1a   = 2 // stdlib only, no external dependencies
	AlgBlake2b = 3 // cryptographic quality distribution
)
View Source
const (
	TypeIndex   = 1
	TypeRecord  = 2
	TypeHistory = 3
)

Record type markers. These appear as the first value in every JSON line ({"_r":N) and are used for byte-level type checks during scan.

View Source
const (
	TypePos       = 6  // {"_r":N — type digit position
	IDStart       = 15 // first byte of the 16-char hex ID
	IDEnd         = 31 // one past the last byte of the ID
	TSStart       = 39 // first byte of the 13-digit timestamp
	TSEnd         = 52 // one past the last byte of the timestamp
	MinRecordSize = 52 // shortest valid line (must reach TSEnd)
)

Fixed byte positions within a record line. Every record starts with {"_r":N,"_id":"...","_ts":N and these fields are always serialised in the same order, so type, ID, and timestamp can be read at known offsets without JSON parsing.

View Source
const HeaderSize = 128

HeaderSize is fixed so the dirty flag can be patched at a known byte offset without rewriting the whole header.

View Source
const MaxLabelSize = 256 // bytes
View Source
const MaxRecordSize = 16 * 1024 * 1024 // 16MB, bounds scanner buffer allocation

Variables

View Source
var (
	ErrNotFound       = errors.New("document not found")
	ErrExists         = errors.New("document already exists")
	ErrLabelTooLong   = errors.New("label exceeds maximum size")
	ErrInvalidLabel   = errors.New("label contains invalid characters")
	ErrEmptyContent   = errors.New("content cannot be empty")
	ErrClosed         = errors.New("database is closed")
	ErrInvalidPattern = errors.New("invalid regex pattern")
	ErrCorruptHeader  = errors.New("corrupt header")
	ErrCorruptRecord  = errors.New("corrupt record")
	ErrCorruptIndex   = errors.New("corrupt index")
	ErrDecompress     = errors.New("decompression failed")
)

Sentinel errors for programmatic handling. Callers can use errors.Is to distinguish recoverable conditions (ErrNotFound) from corruption (ErrCorruptHeader, ErrCorruptRecord, ErrCorruptIndex, ErrDecompress).

Functions

This section is empty.

Types

type CompactOptions

type CompactOptions struct {
	BlockReaders bool // hold write lock for entire operation (crash recovery)
	PurgeHistory bool // drop history records from the output
}

type Config

type Config struct {
	HashAlgorithm int  // 1=xxHash3 (default), 2=FNV1a, 3=Blake2b
	ReadBuffer    int  // scanner buffer (default 64KB)
	MaxRecordSize int  // largest allowed record (default 16MB)
	SyncWrites    bool // fsync after every write (durability vs throughput)
	BloomFilter   bool // maintain bloom filter over the sparse region
	AutoCompact   int  // compact every N writes; persisted to header, 0 = leave stored value unchanged
	MMap          bool // memory-map the file for reads (unix only)
	Index         bool // maintain in-memory index for O(1) lookups
}

Config tunes the memory/disk trade-off. Zero values use safe defaults.

Example
package main

import (
	"os"
	"path/filepath"

	"github.com/jpl-au/folio"
)

func main() {
	dir, _ := os.MkdirTemp("", "folio-example")
	defer os.RemoveAll(dir)

	// Custom configuration
	cfg := folio.Config{
		HashAlgorithm: folio.AlgXXHash3, // Default, fastest
		SyncWrites:    true,             // fsync after each write
		ReadBuffer:    128 * 1024,       // 128KB read buffer
		MaxRecordSize: 32 * 1024 * 1024, // 32MB max record
	}

	db, _ := folio.Open(filepath.Join(dir, "custom.folio"), cfg)
	defer db.Close()
}

type DB

type DB struct {
	// contains filtered or unexported fields
}

DB is an open database handle. Two separate file descriptors are held so that concurrent reads never contend with writes. ReadAt on the O_RDONLY fd is position-independent and safe for concurrent readers; a single O_RDWR fd would have reads and appends fighting over the shared file position. Splitting eliminates that contention entirely.

func Open

func Open(path string, config Config) (*DB, error)

Open opens or creates a database at the given path. If a previous session crashed (dirty flag set, or .tmp file left behind), an automatic Repair is attempted under an exclusive lock to restore consistency before returning.

func (*DB) All added in v0.1.2

func (db *DB) All() iter.Seq2[Document, error]

All yields every current document as a label–content pair. It scans data records directly, avoiding the N+1 cost of List followed by Get for each label. Callers consume results lazily via range and can break early to stop the scan.

func (*DB) Batch added in v0.1.2

func (db *DB) Batch(docs ...Document) error

Batch creates or updates multiple documents under a single lock hold. All inputs are validated before any writes begin. Documents are processed in slice order.

func (*DB) Close

func (db *DB) Close() error

Close flushes state, clears the dirty flag if set, and releases all file handles. Any blocked operations wake up and receive ErrClosed.

func (*DB) Compact

func (db *DB) Compact() error

Compact merges the sparse region back into sorted order, restoring binary search performance. All history is preserved.

Example
package main

import (
	"fmt"
	"os"
	"path/filepath"

	"github.com/jpl-au/folio"
)

func main() {
	dir, _ := os.MkdirTemp("", "folio-example")
	defer os.RemoveAll(dir)

	db, _ := folio.Open(filepath.Join(dir, "example.folio"), folio.Config{})
	defer db.Close()

	// After many writes, compact reorganises for faster reads
	for i := range 100 {
		db.Set("counter", fmt.Sprintf("%d", i))
	}

	// Compact sorts data for binary search (preserves history)
	db.Compact()

	// Purge removes history, keeping only current versions
	db.Purge()
}

func (*DB) Count added in v0.1.2

func (db *DB) Count() int

Count returns the current document count. This is a best-guess value maintained incrementally by Set and Delete. It is corrected to an accurate count during Compact or Repair.

func (*DB) Delete

func (db *DB) Delete(label string) error

Delete soft-removes a document. The record's compressed history snapshot is preserved; only Purge permanently removes it.

Example
package main

import (
	"fmt"
	"os"
	"path/filepath"

	"github.com/jpl-au/folio"
)

func main() {
	dir, _ := os.MkdirTemp("", "folio-example")
	defer os.RemoveAll(dir)

	db, _ := folio.Open(filepath.Join(dir, "example.folio"), folio.Config{})
	defer db.Close()

	db.Set("temp", "Temporary data")

	// Delete removes from active documents but preserves history
	db.Delete("temp")

	_, err := db.Get("temp")
	fmt.Println(err == folio.ErrNotFound)
}
Output:
true

func (*DB) Exists

func (db *DB) Exists(label string) (bool, error)

Exists performs the same two-region lookup as Get but returns as soon as a matching index is found, without reading the data record.

func (*DB) Get

func (db *DB) Get(label string) (string, error)

Get returns the current content of a document identified by label. The lookup follows the index (not the data records directly) because the index is smaller and faster to binary search, then a single seek to the data record's offset retrieves the content.

Example
package main

import (
	"fmt"
	"os"
	"path/filepath"

	"github.com/jpl-au/folio"
)

func main() {
	dir, _ := os.MkdirTemp("", "folio-example")
	defer os.RemoveAll(dir)

	db, _ := folio.Open(filepath.Join(dir, "example.folio"), folio.Config{})
	defer db.Close()

	db.Set("greeting", "Hello, World!")

	content, err := db.Get("greeting")
	if err == folio.ErrNotFound {
		fmt.Println("Document not found")
		return
	}
	fmt.Println(content)
}
Output:
Hello, World!

func (*DB) History

func (db *DB) History(label string) iter.Seq2[Version, error]

History yields every version of a document in chronological order. It searches the heap via binary search (O(log n) + group size), then scans the sparse region for records appended since the last compaction.

Example
package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	"github.com/jpl-au/folio"
)

func main() {
	dir, _ := os.MkdirTemp("", "folio-example")
	defer os.RemoveAll(dir)

	db, _ := folio.Open(filepath.Join(dir, "example.folio"), folio.Config{})
	defer db.Close()

	// Create multiple versions
	db.Set("doc", "Version 1")
	db.Set("doc", "Version 2")
	db.Set("doc", "Version 3")

	// Retrieve all versions (oldest first)
	i := 0
	for v, err := range db.History("doc") {
		if err != nil {
			log.Fatal(err)
		}
		i++
		fmt.Printf("v%d: %s\n", i, v.Data)
	}
}
Output:
v1: Version 1
v2: Version 2
v3: Version 3

func (*DB) List

func (db *DB) List() iter.Seq2[string, error]

List yields labels for all current documents. It scans the entire file (both sorted and sparse regions) for index records because a document may only exist in the sparse region if it was created since the last compaction. Labels are deduplicated but not sorted. Callers consume results lazily via range and can break early to stop the scan.

Example
package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	"github.com/jpl-au/folio"
)

func main() {
	dir, _ := os.MkdirTemp("", "folio-example")
	defer os.RemoveAll(dir)

	db, _ := folio.Open(filepath.Join(dir, "example.folio"), folio.Config{})
	defer db.Close()

	db.Set("apple", "A fruit")
	db.Set("banana", "Another fruit")
	db.Set("carrot", "A vegetable")

	count := 0
	for _, err := range db.List() {
		if err != nil {
			log.Fatal(err)
		}
		count++
	}
	fmt.Printf("Documents: %d\n", count)
}
Output:
Documents: 3

func (*DB) MatchLabel

func (db *DB) MatchLabel(pattern string) iter.Seq2[Match, error]

MatchLabel matches a regex against the _l field of index records. Only index lines (_r=1) are checked, so the scan skips data records entirely using the type byte at TypePos. Results are yielded lazily.

func (*DB) Purge

func (db *DB) Purge() error

Purge does the same as Compact but also drops history records, permanently removing all previous versions of every document.

func (*DB) Rehash

func (db *DB) Rehash(newAlg int) error

Rehash migrates all records to a new hash algorithm. Blocks all readers and writers because every _id in the file is being rewritten.

func (*DB) Rename added in v0.1.2

func (db *DB) Rename(old, new string) error

Rename changes a document's label. Returns ErrNotFound if old does not exist, or ErrExists if new already exists.

func (*DB) Repair

func (db *DB) Repair(opts *CompactOptions) error

Repair rebuilds the file. See the package comment for phase details.

func (*DB) Search

func (db *DB) Search(pattern string, opts SearchOptions) iter.Seq2[Match, error]

Search matches a pattern against the _d field of current data records. Results are yielded lazily; break from the range loop to stop early.

Example
package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	"github.com/jpl-au/folio"
)

func main() {
	dir, _ := os.MkdirTemp("", "folio-example")
	defer os.RemoveAll(dir)

	db, _ := folio.Open(filepath.Join(dir, "example.folio"), folio.Config{})
	defer db.Close()

	db.Set("readme", "# Welcome\n\nThis is the README file.")
	db.Set("changelog", "# Changelog\n\n## v1.0\n- Initial release")

	// Search file content with regex
	count := 0
	for _, err := range db.Search("README", folio.SearchOptions{}) {
		if err != nil {
			log.Fatal(err)
		}
		count++
	}
	fmt.Printf("Matches: %d\n", count)
}

func (*DB) Set

func (db *DB) Set(label, content string) error

Set creates or updates a document. See the package comment for the append-then-blank strategy.

Example
package main

import (
	"log"
	"os"
	"path/filepath"

	"github.com/jpl-au/folio"
)

func main() {
	dir, _ := os.MkdirTemp("", "folio-example")
	defer os.RemoveAll(dir)

	db, _ := folio.Open(filepath.Join(dir, "example.folio"), folio.Config{})
	defer db.Close()

	// Create a new document
	err := db.Set("config", "theme: dark\nlanguage: en")
	if err != nil {
		log.Fatal(err)
	}

	// Update overwrites the previous version (history preserved)
	db.Set("config", "theme: light\nlanguage: en")
}

type Document added in v0.1.2

type Document struct {
	Label string
	Data  string
}

Document is a label–content pair yielded by All.

type Entry

type Entry struct {
	ID     string
	TS     int64
	Type   int
	SrcOff int64 // position in the source file
	DstOff int64 // position in the compaction output (set during write)
	Length int
	Label  string // populated only for index entries
}

Entry holds lightweight metadata extracted by scanm. Full JSON parsing is skipped — fields are read at fixed byte positions. DstOff is zero until compaction fills it with the record's new position in the output.

type Header struct {
	Version   int       `json:"_v"`   // Format version: 1 = current
	Error     int       `json:"_e"`   // Dirty flag: 1 = unclean shutdown detected
	Algorithm int       `json:"_alg"` // Hash algorithm used to derive _id from label
	Timestamp int64     `json:"_ts"`  // Unix ms when this header was last written
	State     [6]uint64 `json:"_s"`   // Section boundaries, counts, compaction state
}

Header describes the file layout. The State array holds section boundaries and counters that divide the file into contiguous regions:

[0..128)                Header (this struct, space-padded, newline-terminated)
[128..State[stHeap])    Heap: data + history sorted by ID then timestamp
[State[stHeap]..State[stIndex])  Sorted index records
[State[stIndex]..EOF)   Sparse region (unsorted appends since last compaction)

Within each ID group in the heap, records are sorted oldest-first. History records (_r=3) precede the current data record (_r=2). A zero offset means that section is empty or not yet established.

type Index

type Index struct {
	Type      int    `json:"_r"`
	ID        string `json:"_id"`
	Timestamp int64  `json:"_ts"`
	Offset    int64  `json:"_o"` // byte position of the corresponding Record
	Label     string `json:"_l"`
}

Index maps a label's hashed ID to the byte offset of its data Record. During lookup, the index is found first (by binary or sparse scan on ID), then the data record is read at the offset the index points to.

type Match

type Match struct {
	Label  string
	Offset int64
}

Match is a single search result: a label and the byte offset of the matching record in the file.

type Record

type Record struct {
	Type      int    `json:"_r"`
	ID        string `json:"_id"` // 16 hex chars, hash of Label
	Timestamp int64  `json:"_ts"` // unix ms
	Label     string `json:"_l"`
	Data      string `json:"_d"` // current content (blank for history)
	History   string `json:"_h"` // zstd+ascii85 compressed snapshot
}

Record is a data or history line. When a document is updated, the old Record has its Type patched from 2→3 (becoming history) and _d blanked, so the compressed _h snapshot is the only way to recover prior content.

type Result

type Result struct {
	Offset int64
	Length int
	Data   []byte
	ID     string
}

Result carries a record's position and raw bytes from a scan. Callers use Offset to read or overwrite the record and Data for parsing.

type SearchOptions

type SearchOptions struct {
	CaseSensitive bool
	Decode        bool // unescape JSON string escapes in _d before matching; bypasses literal fast path
}

SearchOptions configures Search behaviour. Callers control result count by breaking out of the range loop — no Limit field is needed.

type Version

type Version struct {
	Data string
	TS   int64
}

Version is a single point-in-time snapshot of a document's content.

Directories

Path Synopsis
internal
bloom
Package bloom provides a probabilistic set membership filter.
Package bloom provides a probabilistic set membership filter.
flock
Package flock provides OS-level file locking for cross-process coordination.
Package flock provides OS-level file locking for cross-process coordination.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL