Three changes that go together:
1. Reconcile cli/plans/2026-04-24-file-event-store-{overview,nips}.md
with shipped reality: code lives in quartz/jvmMain/, not commons/;
data dir is <data-dir>/events-store/, not <root>/events/.
2. New StoreCommands wired as `amy store …`:
- stat → events count, kind histogram, disk bytes,
oldest/newest createdAt. Pure read, no Context
(skips identity check).
- sweep-expired → wraps store.deleteExpiredEvents(); reports
{swept, remaining}.
- scrub → wraps store.scrub() to rebuild idx/ from
canonicals.
- compact → wraps store.compact() to drop dangling idx/.
All four open the FsEventStore directly (no Context, no identity
needed) — they're store-only operations.
3. New e2e harness at cli/tests/cache/cache-headless.sh that boots a
local nostr-rs-relay, two amy identities (A + B), and asserts:
T1 — store stat reports non-empty store after publish-lists +
profile edit, with kind:0 and kind:10002 present.
T2 — A's `profile show` is `source: "cache"` by default.
T3 — `--refresh` forces `source: "relays"`.
T4 — B's first `profile show <A_NPUB>` is a relay miss; second is
a cache hit (proves drain populates the local store and
subsequent reads serve from disk).
T5 — `relay list` reads URLs back from the local kind:10002 /
10050 / 10051 events.
T6 — relays.json no longer exists in either data-dir.
T7 — store stat / sweep-expired / scrub / compact all run
without an identity present.
Same pattern as cli/tests/dm/dm-interop-headless.sh — reuses the
nostr-rs-relay infrastructure from cli/tests/marmot/setup.sh.
12 KiB
File-backed event store for amy — Part 1: Overview & Layout
Status: plan · Date: 2026-04-24 · Part 1 of 3
(see 2026-04-24-file-event-store-pipelines.md,
2026-04-24-file-event-store-nips.md)
A filesystem-native IEventStore for the cli/ module that matches
every feature of the SQLite store
(quartz/.../nip01Core/store/sqlite/) while tolerating the fact that
files may be created or deleted by the user between runs.
Goals
- Full feature parity with
SQLiteEventStore— sameIEventStorecontract, same NIP semantics (01 replaceable/addressable, 09 deletion, 40 expiration, 45 count, 50 search, 62 vanish, 91 multi- tag AND). - Human-inspectable. Every event is a JSON file on disk.
ls,cat,jq,grep,rsync,git, backup tools — all work. - Tolerates user edits. If the user deletes an event file, the
store converges: dangling index entries become no-ops, tombstones
stop enforcing, replaceable slots get rebuilt from
events/. - JVM-only, no JNI. No SQLite, no native deps beyond what the CLI already carries. Single-user, single-host.
- Small-to-medium scale. Tens of thousands of events. Not a full Amethyst cache replacement.
Non-goals
- Beating SQLite on throughput for 100k+ event workloads.
- Network/replication (rsync is good enough).
- Multi-tenant; concurrent writers across machines.
- Incremental migration tooling from SQLite (not needed —
amyhas no existing persisted store).
Guiding principles
- Filesystem primitives enforce invariants. Directory-entry
uniqueness =
UNIQUEconstraints.rename(2)= atomic commit. Hardlink refcount = cascade delete.chmod 444= immutable tables.flock= transaction serialization. - Hardlinks for all indexes. An index entry is a second name for the canonical event file, never a copy. Deleting any name drops the refcount; when it hits zero the kernel reclaims.
- Derived state is rebuildable.
idx/,replaceable/,addressable/,tombstones/can be regenerated by walkingevents/.amy store scrubdoes this. events/is the source of truth. If a file is there, it is part of the store. If it is gone, it is gone — tombstones aside.- No business logic. Lives next to the SQLite reference store
in
quartz/, sibling pattern. No Nostr-protocol decisions live incli/.
Module placement
Source: quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/
- Sibling to the existing
quartz/src/commonMain/kotlin/.../nip01Core/store/sqlite/reference implementation. The plan originally proposedcommons/but the shipped placement isquartz/jvmMain/, where every other event- store concern already lives — and quartz already has ajvmTestsource set so we get JVM-specific tests for free. - JVM-only (uses
java.nio.file,FileChannel.lock,Files.createLink). - Consumed by
cli/. Android keepsSQLiteEventStore. Desktop can opt in later if useful. - Tests under
quartz/src/jvmTest/.../store/fs/.
Directory layout
Root: configurable. The CLI uses <data-dir>/events-store/ (where
<data-dir> is --data-dir PATH, $AMETHYST_CLI_DATA, or ./amy).
The plan originally said events/; the actual DataDir.eventsDir
field is events-store/ to leave the bare name events/ available
for the canonical-events subdirectory inside the store.
<root>/
├── .lock # flock target for write serialization
├── .seed # random Long for hash salting; write-once
├── .version # schema version for future migrations
│
├── events/<aa>/<bb>/<id>.json # canonical. <aa><bb> = first 4 hex of id
│
├── idx/
│ ├── kind/<kind>/<created_at>-<id> # hardlink → events/.../<id>.json
│ ├── author/<pubkey>/<created_at>-<id> # hardlink
│ ├── owner/<owner_hash>/<created_at>-<id> # hardlink; gift-wrap recipient
│ ├── tag/<name>/<hash>/<created_at>-<id> # hardlink; single-letter tags
│ └── fts/<token>/<id> # hardlink; inverted index
│
├── replaceable/<kind>/<pubkey>.json # hardlink to current winner
├── addressable/<kind>/<pubkey>/<sha256(d)>.json # hardlink to current winner
│
└── tombstones/
├── id/<id> # hardlink to the kind-5 event
├── addr/<kind>/<pubkey>/<sha256(d)> # hardlink to kind-5
└── vanish/<pubkey_hash> # hardlink to kind-62
Filename conventions
- Event files:
<id>.json, where<id>is the 64-hex event id. Content is the raw NIP-01 JSON (same bytes the signer produced). - Timestamp prefix: zero-padded 10-digit unix seconds
(
0001713960000) so lexicographic sort = chronological sort. - Hashes: SHA-256 of the canonical bytes, hex-lowercased. Used for d-tags and arbitrary tag values (sanitizes user content for filesystem safety).
- Owner hash:
TagNameValueHasher.hash(ownerPubkey)(MurmurHash3 salted by.seed). Matches SQLite'spubkey_owner_hashsemantics — for GiftWraps it is the recipient's p-tag, not the event pubkey. (EventIndexesModule.kt:161-166)
Sharding
events/<aa>/<bb>/= 65 536 leaf directories max. Keeps any leaf under ~a few hundred files for 10 M events. Piggybacks on ext4 htree / APFS B-tree / NTFS index.idx/*/…/no sharding; query paths pick one index tree and walk its subdirectory directly (idx/kind/1/,idx/author/<pk>/).- Long pubkeys / ids are already 64 hex (32 bytes). No length issues.
File modes
- Event files:
444(read-only). Matches SQLite's "immutable tables" invariant: triggers blockUPDATE(EventIndexesModule.kt:105-111). We achieve the same at the OS layer. - Directories:
755. User can stillrm(write on parent dir only). - Tombstones:
444(they are event files via hardlink).
Feature parity matrix
| SQLite feature | File-store mechanism | Enforced by |
|---|---|---|
event_headers |
events/<aa>/<bb>/<id>.json |
Write-then-rename atomicity |
event_tags |
idx/tag/<n>/<h>/<ts>-<id> hardlinks |
Set on insert; orphans tolerated |
event_fts |
idx/fts/<token>/<id> hardlinks |
Tokenize SearchableEvent.indexableContent() |
event_expirations |
idx/expires_at/<ts>-<id> hardlink |
Swept by deleteExpiredEvents() |
event_vanish |
tombstones/vanish/<owner_hash> hardlink |
Checked on insert |
seeds |
.seed file; 8 random bytes |
Written once on create |
UNIQUE(kind,pubkey) for replaceable |
replaceable/<k>/<pk>.json slot |
Directory-entry uniqueness + atomic rename |
UNIQUE(kind,pubkey,d) for addressable |
addressable/<k>/<pk>/<h>.json slot |
Same |
| Reject ephemeral | Code guard before any file write | if (event.kind.isEphemeral()) return |
| Reject expired on insert | Code guard before any file write | if (event.isExpired()) throw |
| NIP-09 delete by id | Unlink events/.../<id>.json + all hardlinks |
Tombstone blocks re-insert |
| NIP-09 delete by address | Unlink addressable slot + canonical | Tombstone blocks re-insert |
| NIP-09 gift-wrap by p-tag | idx/owner/<owner_hash>/ walk |
Owner hash matches recipient for GiftWrap |
| NIP-62 vanish cascade | Walk idx/owner/<owner_hash>/ and unlink |
AFTER-insert cascade |
| NIP-62 block future | Check tombstones/vanish/<owner_hash> |
Before every insert |
| NIP-40 deleteExpired sweep | Walk idx/expires_at/ until now, unlink |
Called by CLI cron |
| NIP-45 count | Same planner as query, count results | Streaming |
| NIP-50 search | Intersect idx/fts/<token>/ sets |
Tokenizer matches Quartz |
| NIP-91 tag AND | Sorted-list intersection of idx/tag/<n>/<h>/ |
Streaming comm -12 equivalent |
transaction {} |
flock(.lock) + stage + commit + unlock |
All writes to staging dir, single rename |
vacuum() |
amy store scrub — walks events/, rebuilds idx/ etc. |
Offline maintenance |
analyse() |
No-op; FS already maintains its own indexes | — |
Public API
Implements com.vitorpamplona.quartz.nip01Core.store.IEventStore
(quartz/.../store/IEventStore.kt:26-60) in full.
class FsEventStore(
root: Path,
relayUrl: String, // matches SQLiteEventStore ctor; used for NIP-62 scoping
indexingStrategy: IndexingStrategy = DefaultIndexingStrategy,
clock: Clock = Clock.systemUTC(), // injectable for tests
) : IEventStore {
// IEventStore
override fun insert(event: Event)
override fun transaction(body: IEventStore.ITransaction.() -> Unit)
override fun <T : Event> query(filter: Filter): List<T>
override fun <T : Event> query(filters: List<Filter>): List<T>
override fun <T : Event> query(filter: Filter, onEach: (T) -> Unit)
override fun <T : Event> query(filters: List<Filter>, onEach: (T) -> Unit)
override fun count(filter: Filter): Int
override fun count(filters: List<Filter>): Int
override fun delete(filter: Filter)
override fun delete(filters: List<Filter>)
override fun deleteExpiredEvents()
override fun close()
// FsEventStore extras (parity with SQLiteEventStore)
fun delete(id: HexKey): Int
fun rawQuery(filter: Filter): List<RawEvent> // parses JSON to RawEvent
fun rawQuery(filters: List<Filter>): List<RawEvent>
fun planQuery(filter: Filter): String // returns human-readable plan
fun planQuery(filters: List<Filter>): String
// Maintenance
suspend fun scrub() // rebuild all derived state
suspend fun compact() // equivalent to vacuum — drops orphans
}
Signer/event conveniences mirror SQLite's — no behavioural drift.
File-backed counterparts to SQLite pragmas
| SQLite pragma | File-store analog |
|---|---|
journal_mode=WAL |
Staging dir .staging/<uuid>/ + atomic rename on commit |
synchronous=OFF |
Default = no fsync per write; AMY_FSYNC=1 env opts in |
cache_size=-32000 |
None — rely on OS page cache |
foreign_keys=ON |
Implicit: hardlinks are the FK; refcount is the cascade |
BEGIN IMMEDIATE |
FileChannel.lock(.lock) exclusive advisory lock |
CLI commands (later; out of scope for this plan)
Anticipated but not implemented here:
amy store import <path> # from relay / file / stdin
amy store export [filter] # to stdout NDJSON
amy store query <filter-json> # debug
amy store scrub # rebuild derived state
amy store compact # drop orphans
amy store stat # counts, disk usage
Added in a follow-up roadmap row once the store itself is in place.
Open questions (flagged for review before coding)
- Tag index granularity. SQLite hashes
(name, value)into oneLong. We could keep the same hash scheme and use the hex of the Murmur hash as the directory name, giving byte-for-byte parity with SQLite's index semantics. Proposed: yes — reuseTagNameValueHasher. - Filename collisions on d-tag. SHA-256 is safe; Murmur is not. Use SHA-256 for d-tag slots specifically.
- Relay URL in constructor.
SQLiteEventStoretakes arelayUrlIdentifierfor NIP-62 scoping (shouldVanishFrom). We mirror that; same semantics. - Empty tombstone semantics. If a tombstone hardlink is deleted
by the user, enforcement stops for that target. Scrub does not
re-create tombstones from lingering kind-5 events in
events/— removing the tombstone is treated as an explicit "un-forget" action. Confirm with user.
Next: pipelines
(2026-04-24-file-event-store-pipelines.md) — insert, query, delete,
transaction, concurrency.