| title | Development |
|---|---|
| description | Build, test, lint, and code conventions. |
The macOS and Linux builds require Go 1.27.3+, Bun 1.4.4+, Node.js (22.23.4+ on
22.x, 24.22.1+ on 24.x, or 26.12.1+), and a C/C++ compiler. The Make targets install
the pinned browser dependencies when web/package.json or web/bun.lock
changes, then embed the production UI in the Go binary; Node runs the embed
validator in that path. On Debian/Ubuntu also install libsqlite3-dev, which
provides the sqlite3.h header needed to compile the default sqlite_vec
extension.
# Debug build
make build
# Release build (optimized, stripped)
make build-release
# Install to ~/.local/bin or GOPATH
make install
# Homebrew formula build into $BINDIR (default bin/)
make homebrew VERSION=1.2.3make homebrew is the normal build with DISTRIBUTION=homebrew, so the binary
refuses msgvault update and points at brew upgrade msgvault. It adds the
duckdb_use_lib tag, which links a system libduckdb; set CGO_LDFLAGS (and
CGO_CFLAGS for headers) when that library is outside the default search path.
Make targets compile Go with -trimpath, so binaries record module-relative
source paths instead of absolute ones. Without it, Go caches every package
separately for each checkout directory, and each git worktree grows the build
cache by another full copy. With it, identical source in any checkout reuses
the same cache entries.
- Run
make <target> GOFLAGS=-trimpath=falseto opt out for one command. - Plain
gocommands outside Make do not add the flag. Rungo env -w GOFLAGS=-trimpathto make it your default; Make keeps any value saved that way.
Debuggers need a source mapping for trimmed binaries:
dlv debuganddlv testinheritGOFLAGSand cannot find source files. Pass--build-flags=-trimpath=false. Debug builds disable optimizations, so they never shared cache entries anyway.- For
dlv execordlv attachon a trimmed binary, start Delve from the repository root and runconfig substitute-path -guess(Delve 1.24 or later). It maps the module path to your checkout, the standard library toGOROOT, and dependencies to the module cache.
Use the PowerShell helper from the repository root to compile the Go binary.
It selects the host architecture automatically and embeds assets already in
internal/web/dist; it does not build the browser application.
For a binary with the Web UI, first run make web-embed in an MSYS2 shell
with GNU Make, Bun, and Node.js available. This builds and validates the browser
assets. Then run the PowerShell helper:
# Debug Go build
.\scripts\build.ps1
# Optimized, stripped Go build
.\scripts\build.ps1 -ReleaseGo and MSYS2 are required because msgvault uses CGO. Install the compiler for your Windows architecture:
# Windows AMD64 (run from PowerShell)
C:\msys64\usr\bin\pacman.exe -S --needed mingw-w64-x86_64-toolchainFor Windows ARM64, install CMake and Ninja from an MSYS2 CLANGARM64 shell:
pacman -S --needed mingw-w64-clang-aarch64-cmake \
mingw-w64-clang-aarch64-ninjaThe first ARM64 build downloads a checksum-verified LLVM-MinGW toolchain,
compiles the pinned DuckDB native library, and caches both under
%LOCALAPPDATA%\msgvault\build-cache; subsequent builds reuse them. Set
MSGVAULT_BUILD_CACHE to use another cache location, or pass -RebuildDuckDB
to rebuild the cached library.
The repository's Dockerfile builds the embedded Web UI and the msgvault
binary. To build a local image and run its runtime checks:
docker buildx build --load --tag msgvault:dev .
scripts/smoke-container.sh msgvault:devFor portable image archives, Docker Bake provides an oci target:
docker buildx bake ociIt exports dist/amd64.oci.tar and dist/arm64.oci.tar for Linux AMD64 and
ARM64. The builder needs support for both target architectures, either through
native workers or emulation. Override OCI_OUTPUT_DIR to change the output
directory. VERSION, COMMIT, and BUILD_DATE set the binary metadata;
OCI_VERSION and REVISION set the image's version and source-revision labels.
Defaults identify a development build.
Image builds run checks against a temporary empty archive: database
initialization, a DuckDB query, and the embedded Web UI and its JavaScript
asset. The runtime smoke also starts from environment variables without a
config file and verifies that its minted daemon key survives a restart.
scripts/smoke-container.sh repeats these checks in the loaded image
with networking disabled. These commands build and check local artifacts; they
do not publish them.
Repository-owned release-publishing workflows and the old release/tagging scripts have been removed. Pushing a tag no longer invokes those publishers. The Docker inputs, local build commands, installers, and ordinary CI remain available.
# Run all tests
make test
# Verbose output
make test-v
# Build the CLI and exercise search and thread reading on a synthetic archive
make test-cli-e2etest-cli-e2e uses an isolated archive and a fixed synthetic embedding provider.
Linux CI runs it against the built release binary. To reuse a binary locally,
set MSGVAULT_E2E_BINARY to its absolute path.
All Go test runs need -tags "fts5 sqlite_vec"; the Make targets supply these
automatically. Use assert and require from testify, with expected values
first. See AGENTS.md
for repository testing rules.
Use testing/synctest bubbles for work owned by the test process, including
goroutines, channels, timers, tickers, and fakes. Advance virtual time with
synctest.Sleep and wait for durable state with synctest.Wait.
Keep real waits for PostgreSQL and SQLite locks, database clocks, network
requests, subprocesses, DuckDB, and operating-system events.
Call Kit's stacktest.Dump(t) before failing a timeout while waiting on goroutines.
make lint and make lint-ci build a pinned golangci-lint with Kit's
kennlint plugin and run its sleeptest and deadlinetest checks. Outside a
synctest.Test bubble, sleeptest rejects time.Sleep and deadlinetest
rejects sub-second context.WithTimeout, context.WithDeadline, time.After,
time.NewTimer, time.AfterFunc, and testify Eventually budgets, plus zero
or negative Never windows. It also checks duration fields and same-module
helper parameters whose camelCase or snake_case names contain a timeout,
deadline, budget, or wait word, including plurals. Standalone
time.Now().Add deadlines are checked too. A kept real wait carries
//nolint:kennlint // sleeptest: <what it waits for> on the flagged line.
Use deadlinetest: for a deadline, only when its expiry is the asserted result
or a select shows an event does not happen.
MSGVAULT_TEST_DB=postgres://... runs PostgreSQL-backed
tests. pgvector tests require a PostgreSQL instance with the vector
extension and the pgvector build tag.
MSGVAULT_TEST_DB=postgres://... make test-pg-nocgo checks the PostgreSQL-only
library profile. It builds the store, query engine, Slack and Discord importers,
and HTTP API with CGO_ENABLED=0, then tests archive writes, full-text search,
and reads against PostgreSQL. It deliberately omits the fts5 and sqlite_vec
build tags. SQLite archives, local SQLite imports, and DuckDB/Parquet analytics
require the normal CGO build; this profile does not build the standalone CLI.
The PostgreSQL deadlock tests also require permission to set
deadlock_timeout. They defer the blocker transaction's deadlock detector
so the write under test is the deadlock victim. For a non-superuser test
role, have the database administrator run:
GRANT SET ON PARAMETER deadlock_timeout TO test_role;Replace test_role with the role in MSGVAULT_TEST_DB. This parameter grant
is sufficient; the test role does not need superuser access.
There are two PostgreSQL configurations to cover: the pgvector build
(make test-pg) and the shipped build, which has no pgvector tag
(make test-pg-shipped). Run make test-pg-both rather than both of those —
the tag changes the test binary of only the packages listed in
PG_SHIPPED_ONLY_PKGS in the Makefile, so the second full run would reprove
the first. Each test binary builds the schema once into a template — a
SQLite file copied per test, or a PostgreSQL template database cloned per
test with CREATE DATABASE ... TEMPLATE (internal/testutil/sqlite_template.go,
internal/testutil/pg_template.go) — instead of replaying InitSchema() for
every fixture. Nothing runs in the background, so no fixture ever issues a
schema statement while a test body is running. Each database is still private
to its test, still produced by the same InitSchema() path, and still dropped
on cleanup. A PostgreSQL template is owned through a session advisory lock the
server releases when the binary exits, so the next binary reclaims whatever an
earlier one left behind; a role without CREATEDB falls back to a private
schema in the configured database.
make test automatically overlaps the CLI, store, API, and query package shards
with the remaining SQLite packages when at least 32 CPUs and 64 GiB of available
memory are detected. The planner reserves four test-process slots for the
unsharded remainder, then divides the remaining slots across the sharded
packages, up to 16 shards each. Each slot budgets two Go execution threads
(GOMAXPROCS=2) and a 2 GiB memory allowance. For four sharded packages, a
32-CPU budget permits three shards per package; 128 CPUs permits fifteen,
provided memory also permits them. This changes scheduling, not test coverage.
The planner emits the per-process and remainder settings with its shard count,
so execution uses the same aggregate budget. Shard builds use go -p=1; the
remainder uses go test -p=4. Memory allowances guide scheduling and do not
enforce per-process limits, including native allocations.
Detection accounts for CPU affinity and GOMAXPROCS. On Linux it also accounts
for visible cgroup v2 ancestor CPU quotas and remaining memory under both hard
and soft limits. macOS uses available system memory. Smaller budgets, cgroup v1,
other platforms, and unreadable limits retain the standard schedule. Detection
is a snapshot, not a reservation of resources against other workloads.
Use TEST_PROFILE=standard to disable automatic scaling. Setting TEST_SHARDS
explicitly also retains sequential package jobs with the requested shard count.
The PostgreSQL targets keep their existing connection-oriented concurrency
limits; setting MSGVAULT_TEST_DB disables automatic scaling in make test too.
CI's explicit test-unsharded and package-shard jobs keep their existing layout.
Successful Bash shards and the Windows slow-packages job print named passing and skipped test outcomes, including subtests, followed by the package result. Failed runs print the full verbose test output for diagnosis.
# Format code
make fmt
# Run linter (builds the pinned golangci-lint with Kit's plugin; needs git)
make lint
# Check for issues
go vet ./...Inspect the Server-Timing headers on successful /api/v1/explore responses in
browser developer tools. Failed requests may contain only the phases completed
before the error. Durations are milliseconds:
| Metric | Work measured |
|---|---|
candidates |
Resolve the search candidate pool, including the search phases below |
lexical |
Count full-text matches and fetch bounded, ranked message IDs |
embedding |
Embed the semantic or hybrid query |
retrieval |
Retrieve vector or fused results; desc names the accelerator path |
projection |
Build result rows from the analytical cache |
identities |
Hydrate identity matches for the returned rows |
The candidates duration includes lexical, embedding, and retrieval work;
do not add those subphases to it. A reused semantic candidate snapshot skips
embedding and retrieval, so those metrics are absent. Group requests to
/api/v1/explore/groups report grouping for the analytical aggregation.
Run the synthetic HTTP benchmark to measure candidate resolution through the real SQLite full-text index and DuckDB result projection:
go test -tags 'fts5 sqlite_vec' ./internal/api -run '^$' \
-bench '^BenchmarkExploreFullText$' -benchtime=3x -count=3 -benchmemIt creates an isolated 20,000-message archive and measures rare, common, and candidate-limited queries. Setup and a warm-up request are excluded. Compare the same fixture and machine before and after a change; these timings do not predict latency on a larger archive.
For analytical grouping and domain drill-downs, build the larger synthetic cache once in an empty scratch directory, then reuse it:
explore_bench_root=$(mktemp -d)
go test -tags 'fts5 sqlite_vec' ./internal/query -run '^$' \
-bench '^BenchmarkExploreScaleBuild$' -benchtime=1x \
-args -relationship-bench-root="$explore_bench_root"
go test -tags 'fts5 sqlite_vec' ./internal/query -run '^$' \
-bench '^BenchmarkExploreScaleQueries$' -benchtime=4x \
-args -relationship-bench-root="$explore_bench_root"This fixture has 2,562,000 messages and 71,486 people. The query benchmark
reports the first request and the median of later requests separately. It uses
the default interactive memory limit; DuckDB can spill work to temporary disk.
Add -relationship-bench-memory=8GB for diagnostic comparisons with older
queries that exceed that limit. Add
-relationship-bench-profile to write DuckDB operator profiles into the scratch
directory. Run timing comparisons without other heavy workloads, and retain
the same memory limit and thread settings. This fixture uses uniform senders
and one chat roster; it does not model real participant skew or attachments.
To separate local vector retrieval from embedding-provider latency, run:
go test -tags 'fts5 sqlite_vec' ./internal/vector/sqlitevec -run '^$' \
-bench '^BenchmarkVectorRetrieval$' -benchtime=3x -count=3 -benchmemThis benchmark uses 100,000 synthetic messages with one 64-dimensional vector each. It measures exact semantic retrieval and rare/common hybrid retrieval, without an accelerator, embedding calls, or HTTP result projection. Use the request timing headers to determine which phase needs attention on a real archive before comparing it with this narrower benchmark.
Compare indexed candidates with the historical scan on one generated 20,000-message archive:
go test -tags 'fts5 sqlite_vec' ./internal/query -run '^$' \
-bench '^BenchmarkMetadataSubstring$' -benchtime=3x -count=3 -benchmemSetup and index maintenance are excluded. The benchmark covers rare subject, participant, and recipient-name matches, common terms, short terms, and common-term narrow-date counts. The scoped case keeps the existing date query path. Use the same machine and corpus for comparisons; these numbers do not predict latency on a larger archive.
The built-in fuzz target compares ordered pages, exact counts, and stats with the original substring predicate over generated metadata:
go test -tags 'fts5 sqlite_vec' ./internal/query -run '^$' \
-fuzz '^FuzzMetadataSearchParity$' -fuzztime=30s -parallel=2Use generated metadata to compare the Directory verifier and bounded filename suggestions without a private archive:
go test -tags 'fts5 sqlite_vec' ./internal/store \
-run '^TestFilenameDiscoverySyntheticRecall$' -count=1
go test -tags 'fts5 sqlite_vec' ./internal/namematch -run '^$' \
-bench '^BenchmarkDirectoryVerifier$' -benchmem -count=3
go test -tags 'fts5 sqlite_vec sqlite_dbstat' ./internal/store -run '^$' \
-bench '^BenchmarkFilenameDiscovery$' -benchtime=3x -benchmem -count=3The existing native-driver sqlite_dbstat diagnostic tag enables physical
projection table and index page-byte measurement. Ordinary correctness and
shipped builds retain their usual tags. The filename benchmark uses durable
SQLite at 1k, 10k and 100k generated names plus one 512-rune filename, with 64
synthetic identities bound to one person. Exact, typo, short-prefix, no-hit,
dense-collision, restrictive-empty, maximum-name and 64-person cases share the
same fixture and policy.
Indexed timings cover in-process HTTP dispatch and response decoding. The exhaustive baseline scans the same fixture's filename metadata in memory, applies the same policy and sorts results; it is test-only and excludes HTTP overhead. Setup and warmup are outside request timings. Separate metrics report durable bootstrap, one journaled rename, one idle maintenance batch, physical index bytes, posting rows, admitted/verified IDs, returned rows, recall, and missing matches for limited results. Repeated short samples characterize this synthetic workload; they do not predict every archive or impose latency thresholds on correctness tests. Complete results must equal the exhaustive oracle within the requested output limit; limited results report their missing output and candidate-pool matches explicitly.
Run the recall test with MSGVAULT_TEST_DB set to a disposable PostgreSQL test
database for backend parity. Existing native query-plan tests exercise indexed
posting seeks and bounded authority/provenance reads on both backends.
TestMCPMemory guards the retained Go heap of a msgvault mcp process. It
samples two fresh child processes, because the package caches tool catalogs and
resolved SDK schemas for reuse:
startup: package initialization, before a server exists.initialized: a server afterinitializeandtools/listover OS pipes, with every backend a daemon-backed stdio proxy can enable. The backends are empty stand-ins; neither sample opens an archive or calls a tool.
Each sample must keep less than 64 MiB of Go heap after garbage collection.
The budget catches eager construction of tool catalogs, which retained over
300 MiB in v0.20.0, without setting a startup-time target. If you add a catalog
capability, enable it in daemonProxyMemoryOptions; the test fails until you do.
Run the guard and the allocation benchmarks:
go test -tags 'fts5 sqlite_vec' ./internal/mcp -run '^TestMCPMemory$' -count=1 -v
go test -tags 'fts5 sqlite_vec' ./internal/mcp -run '^$' \
-bench '^BenchmarkOperationCatalogMemory$' -benchtime=3x -benchmemThe cold benchmark builds one catalog, cached looks it up again, and
server constructs a server with warmed caches, as the stateless HTTP listener
does for each request. Benchmark bytes are allocations, not retained heap.
To profile one phase, run the child test directly so the profile covers the measured process rather than the parent:
MSGVAULT_MCP_MEMORY_PHASE=initialized go test -tags 'fts5 sqlite_vec' ./internal/mcp \
-run '^TestMCPMemoryChild$' -count=1 -v -memprofile=/tmp/mcp.heap
go tool pprof -top -inuse_space /tmp/mcp.heapHeapAlloc is live Go heap; TotalAlloc counts cumulative allocation. Neither
includes native allocations or executable mappings. Linux RSS and the macOS
top MEM column (physical footprint, which includes compressed memory) measure
different things, so do not compare them directly with Go heap figures.
For several simultaneous clients, see transport selection.
Use msgvault eval to compare keyword, semantic, and
hybrid results against queries and relevance ratings you supply. Keep the
archive, topics, and ratings the same when comparing runs. The command reports
ranking quality and query timings; it does not create the ratings for you.
The lossless vCard 2.1/3.0/4.0 codec vendors the IANA vCard Elements registry
under internal/vcard/registry/data. Registry checks are deliberate networked
maintenance commands, not ordinary CI steps:
# Report whether the upstream registry differs from the vendored snapshot.
make vcard-registry-check
# Fetch, validate, and atomically update the snapshot for review.
make vcard-registry-updateThe codec preserves ordered properties, source spelling, parameter quoting, unknown extensions, and raw values. Keep new registry elements covered by an explicit handling declaration so an upstream addition cannot be silently ignored.
internal/store/schema_version.go owns store.SchemaVersion, the monotonic
integer for required main archive schema work. Increase it whenever the main
archive schema changes, including indexes, or a required data migration changes.
Embedded readers require an exact version match. Once an archive is migrated to
a higher version, applications using an older pkg/archive must upgrade too,
even if the schema change only added an index.
Filename suggestions require archive schema version 6 and API schema 3.15, newer than the latest release. Version 6 creates empty projection tables; background maintenance establishes readiness separately. Schema migration does not scan attachments or populate the index, and suggestion requests never perform setup or backfill. Embedded readers require completed version-6 setup.
TestSchemaVersionContract hashes the schema and migration ledger a fresh archive
gets on each backend, plus the legacy column statements only older archives run,
and fails until the version moves; its message gives the digest to append.
Append entries for the new version to both schemaContractDigests and
schemaContractPostgresDigests; keep earlier entries unchanged. The SQLite lane
also requires a PostgreSQL entry. Obtain that digest by running the store tests
with MSGVAULT_TEST_DB set to a PostgreSQL URL. Without local PostgreSQL, use the digest from the CI
PostgreSQL lane's failure log, append it, and rerun CI to verify it.
A data-only repair that runs outside the migration ledger leaves both digests
alone, so bump the version by hand for one. SQLite and PostgreSQL use the
schema_version key in archive_metadata. InitSchemaContext refuses future
versions before DDL and stamps this version only after its required steps succeed,
using the caller's context. Keep optional and best-effort maintenance behavior separate from that
completion guarantee.
The initial contract is new on main, after v0.21.0. Existing archives start at
0; successful initialization upgrades them to the binary's expected version.
The CLI reference owns the probe contract,
and Setup owns the operator
upgrade procedure.
- Web UI: Svelte with TypeScript, generated OpenAPI types, and components from the shared UI toolkit
- TUI: Bubble Tea for model/update, lipgloss for styling
- Database: All DB operations through the
Storestruct (internal/store) - Error handling: Return
error, wrap with context viafmt.Errorf - Tests: Table-driven tests
- Cancellation: Context-based cancellation for long operations
- Encoding: Charset detection via
gogs/chardet, conversion viagolang.org/x/text/encoding
- Never use
SELECT DISTINCTwith JOINs: useEXISTSsubqueries instead (semi-joins) EXISTSis faster (stops at first match) and avoids duplicates at the source
Instead of:
SELECT DISTINCT m.id FROM messages m
JOIN message_recipients mr ON mr.message_id = m.id
WHERE mr.recipient_type = 'from' AND ...Use:
SELECT m.id FROM messages m
WHERE EXISTS (
SELECT 1 FROM message_recipients mr
WHERE mr.message_id = m.id
AND mr.recipient_type = 'from' AND ...
)| Library | Purpose |
|---|---|
svelte |
Embedded analytical Web UI |
orval |
Typed browser API client generation |
cobra |
CLI framework |
charmbracelet/bubbletea |
TUI framework |
charmbracelet/lipgloss |
TUI styling |
mattn/go-sqlite3 |
SQLite with CGO (FTS5) |
duckdb/duckdb-go/v2 |
DuckDB driver for Parquet |
gogs/chardet |
Character set detection |
golang.org/x/text |
Text encoding conversion |
The site has three reading levels: the product overview at /, the archive
lifecycle at /guide/, and task guides and references at /docs/. Keep
technical setup and command details in the documentation tier.
From the repository root:
make docs-install
make docs-build
make docs-checkmake docs-check validates Markdown, builds the real site with its asset
branches, and checks pages, links, metadata, and redirects. Use make docs-serve
to inspect the complete site at http://127.0.0.1:8000.
See the documentation contributor guide for content ownership, historical designs, and media maintenance.
Join the msgvault Discord server to discuss development, ask questions, or share feedback.