7bbfb52d87
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.
501 lines
16 KiB
Markdown
501 lines
16 KiB
Markdown
# File-backed event store for `amy` — Part 3: NIPs, Enforcement, Scrub, Testing
|
||
|
||
**Status:** plan · **Date:** 2026-04-24 · **Part 3 of 3**
|
||
(see `2026-04-24-file-event-store-overview.md`,
|
||
`2026-04-24-file-event-store-pipelines.md`)
|
||
|
||
Per-NIP behavior, slot enforcement details, tag indexing, scrub, and
|
||
test plan.
|
||
|
||
---
|
||
|
||
## NIP-01 — Replaceable events (kinds 0, 3, 10000–19999)
|
||
|
||
### Enforcement
|
||
|
||
- Slot path: `replaceable/<kind>/<pubkey>.json`
|
||
- Slot is a **hardlink** into `events/<aa>/<bb>/<id>.json` — same
|
||
inode, two names.
|
||
- Directory-entry uniqueness is the `UNIQUE(kind, pubkey)` constraint
|
||
(parity with `ReplaceableModule.kt:25-60`).
|
||
|
||
### Insert flow (inside transaction T2/T5 from Part 2)
|
||
|
||
```
|
||
guard = ReplaceableGuard
|
||
if slot exists:
|
||
Told = read slot JSON → createdAt
|
||
if event.createdAt <= Told: REJECT # blocks older from reinsert
|
||
install (at T5):
|
||
slotTmp = <slot>.tmp.<rand>
|
||
ln events/<new_id>.json slotTmp
|
||
Files.move(slotTmp, slot, ATOMIC_MOVE, REPLACE_EXISTING)
|
||
# Old slot's inode loses one link. If it had a canonical hardlink too,
|
||
# drop it — mirror SQLite trigger that deletes the old row.
|
||
if oldId present:
|
||
runDeleteLinksFor(oldId) # same code path as delete()
|
||
```
|
||
|
||
### Reading the current version
|
||
|
||
```
|
||
cat replaceable/3/<pubkey>.json # latest kind-3 for pubkey
|
||
```
|
||
|
||
Direct path open. No scan, no MAX — the slot is the winner.
|
||
|
||
### Edge cases
|
||
|
||
- **Race between two newer events.** Both pass guard, both try to
|
||
install; last `rename` wins atomically. Losing event's canonical
|
||
exists orphaned until scrub or until someone queries by its id.
|
||
Semantically valid: both were newer than the previous slot.
|
||
- **Slot deleted by user.** Next insert of (kind, pubkey) has no
|
||
guard and wins. This matches "files come and go" contract.
|
||
- **Canonical deleted but slot survives.** Slot still resolves —
|
||
hardlink keeps inode alive. Queries work. Scrub re-creates the
|
||
canonical from slot.
|
||
|
||
---
|
||
|
||
## NIP-01 — Addressable events (kinds 30000–39999)
|
||
|
||
### Enforcement
|
||
|
||
- Slot path: `addressable/<kind>/<pubkey>/<sha256(d_tag)>.json`
|
||
- SHA-256 of d-tag value avoids filesystem-unsafe characters and
|
||
collisions. Parity with `AddressableModule.kt:25-63`.
|
||
- Empty d-tag is valid Nostr — hashed as `sha256("")`.
|
||
|
||
### Flow
|
||
|
||
Same as replaceable, keyed by (kind, pubkey, d_tag). Guard reads
|
||
slot's createdAt; install via `rename REPLACE_EXISTING`; cascade-
|
||
unlink old canonical and its index hardlinks.
|
||
|
||
### Query shortcut
|
||
|
||
If a filter supplies `kinds`, `authors`, and a d-tag, the planner
|
||
bypasses the driver indexes and opens the slot directly. O(1).
|
||
|
||
---
|
||
|
||
## NIP-09 — Event deletion (kind 5)
|
||
|
||
### Deletion event insert
|
||
|
||
When a kind-5 event is inserted (inside T6 of the pipeline):
|
||
|
||
```
|
||
for target in event.deleteEventIds():
|
||
# NIP-09: same author as the deletion
|
||
targetPath = events/<aa>/<bb>/<target>.json
|
||
if targetPath exists AND read(targetPath).pubkey == event.pubkey:
|
||
runDeleteLinksFor(target) # cascade unlink
|
||
|
||
# Install tombstone (hardlink to the kind-5 event):
|
||
tomb = tombstones/id/<target>
|
||
ln event_canonical <tomb>.tmp
|
||
Files.move(<tomb>.tmp, tomb, ATOMIC_MOVE, REPLACE_EXISTING_IF_NEWER)
|
||
|
||
for addr in event.deleteAddresses():
|
||
hash = sha256(addr.dTag)
|
||
slot = addressable/<addr.kind>/<addr.pubkey>/<hash>.json
|
||
if slot exists AND read(slot).pubkey == event.pubkey:
|
||
Told = read slot.createdAt
|
||
if Told <= event.createdAt:
|
||
oldId = read slot.id
|
||
unlink slot
|
||
runDeleteLinksFor(oldId)
|
||
|
||
tomb = tombstones/addr/<addr.kind>/<addr.pubkey>/<hash>
|
||
ln event_canonical <tomb>.tmp
|
||
Files.move(<tomb>.tmp, tomb, ATOMIC_MOVE, REPLACE_EXISTING_IF_NEWER)
|
||
|
||
for replaceable_kind in event.deleteReplaceableKinds():
|
||
# kinds 0,3,10000-19999 — no d-tag
|
||
slot = replaceable/<kind>/<event.pubkey>.json
|
||
similar cascade + tombstone
|
||
```
|
||
|
||
`REPLACE_EXISTING_IF_NEWER` is a helper we implement: if an older
|
||
kind-5 already owns the tombstone, and the new one has a later
|
||
createdAt, swap it in; otherwise keep the existing. Ensures
|
||
tombstone holds the strongest (latest) deletion cutoff, matching
|
||
SQLite's "OR" of all deletion events
|
||
(`DeletionRequestModule.kt:102-184`).
|
||
|
||
### Blocking re-insertion
|
||
|
||
Part of T2 guards:
|
||
|
||
```
|
||
TombstoneIdCheck:
|
||
if tombstones/id/<event.id> exists → REJECT
|
||
|
||
TombstoneAddrCheck (addressable only):
|
||
if tombstones/addr/<k>/<pk>/<hash(d)> exists:
|
||
Tdel = read tombstone.createdAt
|
||
if event.createdAt <= Tdel → REJECT
|
||
```
|
||
|
||
Parity with `DeletionRequestModule.kt:61-75` (BEFORE INSERT trigger
|
||
rejecting events whose etag_hash/atag_hash match a kind-5 entry).
|
||
|
||
### Gift-wrap deletion by p-tag
|
||
|
||
`EventIndexesModule.kt:161-166` uses `pubkey_owner_hash` that, for
|
||
`GiftWrapEvent`, is the recipient's p-tag (not the outer pubkey).
|
||
We mirror this: `idx/owner/<owner_hash>/<ts>-<id>` points at every
|
||
event where `ownerHash = TagNameValueHasher.hash(recipient)`.
|
||
|
||
A kind-5 from the recipient deleting by `e`-tag still resolves via
|
||
`runDeleteLinksFor(id)`, which walks all hardlinks (including
|
||
`idx/owner/`) and unlinks them.
|
||
|
||
### Security: author check
|
||
|
||
NIP-09 requires deletions be authored by the same pubkey as the
|
||
deleted event. SQLite enforces this via `pubkey_owner_hash` in the
|
||
DELETE WHERE clause (`DeletionRequestModule.kt:107-115`). We mirror
|
||
by reading the target's JSON and comparing `pubkey` — done inside
|
||
`runDeleteLinksFor`.
|
||
|
||
---
|
||
|
||
## NIP-40 — Expiration
|
||
|
||
### Read at insert
|
||
|
||
`event.expiration()` returns `Long?` (tag value as unix seconds).
|
||
|
||
```
|
||
if exp != null && exp > 0:
|
||
if exp <= now: throw ExpiredEventException # T1 guard, mirrors trigger
|
||
ln canonical idx/expires_at/<zero_padded(exp)>-<id>
|
||
```
|
||
|
||
Parity with `ExpirationModule.kt:27-95`.
|
||
|
||
### Sweep
|
||
|
||
`deleteExpiredEvents()` listed in Part 2. Uses the sorted
|
||
`idx/expires_at/` — early termination when `<exp>` ≥ now.
|
||
|
||
Recommended caller: cron or shell loop.
|
||
|
||
```sh
|
||
while :; do amy store sweep-expired; sleep 900; done
|
||
```
|
||
|
||
Matches the `ExpirationWorker` WorkManager pattern from the SQLite
|
||
README.
|
||
|
||
### Query-time filtering
|
||
|
||
Belt-and-suspenders: queries also filter out events where
|
||
`exp != null && exp <= now`. Covers sweep-missed windows and
|
||
clock skew between writer and reader.
|
||
|
||
---
|
||
|
||
## NIP-62 — Right to vanish (kind 62)
|
||
|
||
### Insert
|
||
|
||
```
|
||
if !event.shouldVanishFrom(relayUrl): return # same scoping as SQLite
|
||
|
||
ownerHash = TagNameValueHasher.hash(event.pubkey)
|
||
|
||
# Install vanish tombstone (one per pubkey; latest wins)
|
||
tomb = tombstones/vanish/<ownerHash>
|
||
existing = read tomb if exists
|
||
if existing == null || existing.createdAt < event.createdAt:
|
||
ln canonical <tomb>.tmp
|
||
Files.move(<tomb>.tmp, tomb, ATOMIC_MOVE, REPLACE_EXISTING)
|
||
|
||
# Cascade: delete every event from this owner with createdAt < tomb.createdAt
|
||
for entry in idx/owner/<ownerHash>/:
|
||
(ts, id) = parse entry filename
|
||
if ts < event.createdAt:
|
||
runDeleteLinksFor(id)
|
||
```
|
||
|
||
Parity with `RightToVanishModule.kt:46-71`.
|
||
|
||
### Blocking future inserts
|
||
|
||
Part of T2 guards (VanishCheck):
|
||
|
||
```
|
||
tomb = tombstones/vanish/<TagNameValueHasher.hash(event.pubkey)>
|
||
if tomb exists:
|
||
Tvanish = read tomb.createdAt
|
||
if event.createdAt <= Tvanish → REJECT
|
||
```
|
||
|
||
For GiftWraps, owner is the recipient — a vanish request from the
|
||
recipient cascades their received gift-wraps too, matching SQLite's
|
||
behaviour via `pubkey_owner_hash`.
|
||
|
||
---
|
||
|
||
## NIP-45 — Count
|
||
|
||
`count(filter)` / `count(filters)` runs the exact query plan from
|
||
Part 2 and returns the number of surviving entries. Parity with
|
||
SQLite `SELECT count(*)`.
|
||
|
||
Optimisation: if the filter is driver-only (no post-driver code
|
||
filter), we just count directory entries — `Files.list().count()` —
|
||
without opening any JSON. Linear in the driver's listing size.
|
||
|
||
---
|
||
|
||
## NIP-50 — Full-text search
|
||
|
||
### Tokenisation
|
||
|
||
Reuse Quartz's `SearchableEvent.indexableContent()` (per
|
||
`FullTextSearchModule.kt:66-78`). Tokeniser:
|
||
|
||
- Lowercase.
|
||
- Split on Unicode word boundaries.
|
||
- Filter tokens shorter than 3 chars (FTS5 default).
|
||
- Keep ASCII hex ids and bech32 identifiers whole (no splitting on
|
||
underscores/digits).
|
||
|
||
Match the Android FTS5 tokeniser behaviour; a shared helper lives
|
||
in `commons/` and is called by both backends to guarantee identical
|
||
search results.
|
||
|
||
### Index
|
||
|
||
```
|
||
idx/fts/<token>/<id> # empty hardlink into canonical
|
||
```
|
||
|
||
One empty hardlink per (token, event). For a 1000-word note with
|
||
500 unique tokens, 500 hardlinks — each costs one directory entry
|
||
(~~50 bytes on ext4), no inode. At scale this is the biggest
|
||
directory count; `idx/fts/` alone may hold millions of entries.
|
||
Still fine — each token dir stays small.
|
||
|
||
### Search query
|
||
|
||
```
|
||
tokens = tokenize(filter.search)
|
||
streams = tokens.map { idx/fts/<it>/ }
|
||
candidates = k-way intersection of streams by <id>
|
||
for candidate in candidates:
|
||
if passes all other filter predicates:
|
||
emit
|
||
```
|
||
|
||
Same semantics as SQLite `MATCH` in FTS5 AND-mode. Matches
|
||
`QueryBuilder.kt:590-657`.
|
||
|
||
### Maintenance
|
||
|
||
- On insert: write token hardlinks (T4).
|
||
- On delete (any path — direct, replaceable replace, vanish,
|
||
expiration): `runDeleteLinksFor(id)` enumerates every possible
|
||
token and unlinks. We don't store the token list, so we re-derive
|
||
it by reading the canonical content *before* unlinking the
|
||
canonical. If the canonical is already gone, we fall back to
|
||
`find -samefile` to enumerate remaining hardlinks.
|
||
|
||
### Without an index
|
||
|
||
`amy store search --raw "keywords"` can fall back to
|
||
`ripgrep events/` for forensic use. Always works, even if indexes
|
||
are corrupt.
|
||
|
||
---
|
||
|
||
## Tag indexing
|
||
|
||
### Which tags get indexed
|
||
|
||
Matches `DefaultIndexingStrategy`
|
||
(`IndexingStrategy.kt:99-102`): tag arrays with at least 2 elements
|
||
where `tag[0].length == 1` (single-letter tag names). Non-single-
|
||
letter tags are stored in the JSON and visible to queries but not
|
||
reverse-indexed.
|
||
|
||
### Hash scheme
|
||
|
||
Reuse `TagNameValueHasher`
|
||
(`quartz/.../sqlite/TagNameValueHasher.kt:30-67`). Reads `.seed`
|
||
into a `Long` salt identically to SQLite's `seeds` table. The hash
|
||
becomes a 16-char hex string that names the directory:
|
||
|
||
```
|
||
idx/tag/<name>/<hex(hash(name, value))>/<ts>-<id>
|
||
```
|
||
|
||
Parity with `event_tags.tag_hash`. d-tag, e-tag, a-tag, p-tag,
|
||
owner: see below.
|
||
|
||
### Special tags
|
||
|
||
| Tag | Where indexed | Notes |
|
||
|---|---|---|
|
||
| `d` | Only as part of `addressable/<k>/<pk>/<sha256(d)>.json` slot | Not in `idx/tag/d/` |
|
||
| `e` | `idx/tag/e/<hash>/` | Normal |
|
||
| `a` | `idx/tag/a/<hash>/` + addressable slot look-aside | Normal |
|
||
| `p` | `idx/tag/p/<hash>/` | Recipient for GiftWrap also goes to `idx/owner/` |
|
||
| owner (synthetic) | `idx/owner/<owner_hash>/` | Event pubkey for normal events, recipient for GiftWrap — matches `pubkey_owner_hash` (`EventIndexesModule.kt:161-166`) |
|
||
|
||
---
|
||
|
||
## Seed file
|
||
|
||
`.seed` is 8 random bytes generated on store creation. Read into a
|
||
`Long` salt for `TagNameValueHasher`, matching SQLite's `seeds` table
|
||
(`SeedModule.kt:26-84`).
|
||
|
||
- Written atomically (tmp-rename) on first open if absent.
|
||
- Never updated. Corrupt or mismatched seeds invalidate `idx/` —
|
||
scrub rebuilds from canonical.
|
||
- Permissions `400`.
|
||
|
||
---
|
||
|
||
## Scrub / rebuild
|
||
|
||
`FsEventStore.scrub()`:
|
||
|
||
```
|
||
1. Lock .lock exclusively.
|
||
2. Delete .staging/ leftovers.
|
||
3. Enumerate all events/<aa>/<bb>/<id>.json.
|
||
4. For each, re-derive:
|
||
idx/kind/, idx/author/, idx/owner/, idx/tag/*/,
|
||
idx/fts/*/, idx/expires_at/
|
||
using the same code as T4. createLink is idempotent — NOACT on
|
||
collision (or unlink + recreate).
|
||
5. Walk idx/ trees; drop entries whose target inode count is zero
|
||
(dangling — canonical was deleted).
|
||
6. Rebuild replaceable/ and addressable/ slots from canonicals by
|
||
grouping (kind,pubkey[,d]) and picking max createdAt.
|
||
7. Keep tombstones as-is. (Removing a tombstone file is a user-
|
||
intended action; scrub does not resurrect them.)
|
||
8. Release lock.
|
||
```
|
||
|
||
Cost: O(N) disk reads once per scrub. Acceptable as a manual
|
||
`amy store scrub` command. Not run automatically.
|
||
|
||
### `compact()`
|
||
|
||
Lighter: walk `idx/` only, drop dangling entries. No canonical
|
||
reads. O(size of idx trees).
|
||
|
||
---
|
||
|
||
## Testing strategy
|
||
|
||
### Location
|
||
|
||
`quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/`
|
||
(sibling to the SQLite reference tests). Uses kotlin.test (matches
|
||
the rest of the quartz `jvmTest` source set). Temp-dir fixture
|
||
creates a fresh store per test.
|
||
|
||
### Test categories
|
||
|
||
1. **Parity tests.** Drive both `SQLiteEventStore` and `FsEventStore`
|
||
with identical event streams and filters; assert query results
|
||
match. Existing SQLite tests under
|
||
`quartz/src/jvmTest/.../sqlite/` become the golden set. This is
|
||
the highest-value tier.
|
||
|
||
2. **Per-module tests.** One file per enforcement area:
|
||
- `ReplaceableSlotTest` — newer wins, older rejected, race
|
||
deposes both cleanly.
|
||
- `AddressableSlotTest` — same, plus d-tag hash collisions
|
||
cannot occur (SHA-256).
|
||
- `DeletionTombstoneTest` — by id, by address, by replaceable
|
||
kind; re-insert blocked; gift-wrap by owner.
|
||
- `VanishTest` — cascade + block, GiftWrap recipient.
|
||
- `ExpirationTest` — sweep, early termination, belt-and-
|
||
suspenders query filter.
|
||
- `SearchTest` — tokeniser parity with SQLite FTS, AND of tokens,
|
||
maintenance across replace/delete/expire/vanish.
|
||
- `TagAndTest` — NIP-91 intersection, three-tag filters.
|
||
- `EphemeralTest` — never written.
|
||
- `TransactionTest` — all-or-nothing batch insert, rollback on
|
||
exception.
|
||
|
||
3. **Crash-safety tests.** Inject failures after each pipeline step
|
||
(T1–T8) via a testing hook; reopen; assert scrub converges.
|
||
|
||
4. **Concurrency tests.** Two threads / two processes hitting the
|
||
same store: assert no corruption, writes serialise, readers see
|
||
consistent snapshots.
|
||
|
||
5. **External edit tests.** Mid-run, delete a canonical file / a
|
||
slot / a tombstone; assert store behaves as spec'd (dangling
|
||
index skipped; slot rebuilds; tombstone stops enforcing).
|
||
|
||
6. **Property tests.** For a random sequence of (insert, delete,
|
||
query, vanish, expire, replace) operations, assert the file
|
||
store and SQLite store produce identical `query()` results.
|
||
Seed = test parameter.
|
||
|
||
### Running
|
||
|
||
```
|
||
./gradlew :commons:jvmTest --tests "*.store.fs.*"
|
||
```
|
||
|
||
Target: all tests pass before the CLI starts depending on the new
|
||
store.
|
||
|
||
---
|
||
|
||
## Rollout plan
|
||
|
||
1. **Step 1 — skeleton.** `FsEventStore` class, directory layout,
|
||
`insert` + `query(ids only)` + `delete(id)`. Round-trip test.
|
||
2. **Step 2 — indexes.** kind/author/owner/tag hardlinks; query by
|
||
kind, author, tag; `count`.
|
||
3. **Step 3 — slots.** Replaceable + addressable enforcement +
|
||
tests.
|
||
4. **Step 4 — tombstones.** NIP-09 deletion by id and address;
|
||
block re-insert; gift-wrap via owner.
|
||
5. **Step 5 — expirations.** `idx/expires_at/`, `deleteExpiredEvents`.
|
||
6. **Step 6 — vanish.** NIP-62 insert + cascade + block.
|
||
7. **Step 7 — FTS.** Tokeniser in `commons/`, `idx/fts/`, search
|
||
queries.
|
||
8. **Step 8 — transactions, concurrency, scrub.**
|
||
9. **Step 9 — wire into `amy`.** `Context` gains a `store:
|
||
IEventStore` property; default = `FsEventStore` rooted at
|
||
`$AMY_HOME/events/`.
|
||
10. **Step 10 — parity test matrix** driven by the existing SQLite
|
||
golden tests.
|
||
|
||
Each step is a separate commit. Each is behind `AMY_EXPERIMENTAL_
|
||
STORE=1` env guard until step 10 passes.
|
||
|
||
---
|
||
|
||
## Out of scope for this plan
|
||
|
||
- Android integration — stays on SQLite.
|
||
- Desktop opt-in — separate plan once CLI is stable.
|
||
- Cross-host sync — users can rsync the directory; that's it.
|
||
- Encryption at rest — future plan; would wrap each event file.
|
||
|
||
---
|
||
|
||
## Summary of plan split
|
||
|
||
| File | Covers |
|
||
|---|---|
|
||
| `2026-04-24-file-event-store-overview.md` | Goals, layout, feature matrix, API |
|
||
| `2026-04-24-file-event-store-pipelines.md` | Insert, query, delete, transaction, concurrency |
|
||
| `2026-04-24-file-event-store-nips.md` (this file) | Per-NIP enforcement, slots, tombstones, FTS, scrub, testing |
|