mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
## Summary
This PR adds kind 30180, `KIND_EMOJI_ENTRY`. One event holds one custom
emoji.
The relay accepts, validates, stores, and replaces these events.
The SDK gets `build_emoji_entry`.
`docs/nips/NIP-CE.md` documents the kind.
This is the relay part of the custom-emoji scale-up (Option C).
It does not change kind 30030 sets, message `emoji` tags, or reaction
`emoji` tags.
It adds no migration and no schema change.
**Read this before you deploy.** Prefix search for kind 30180 works only
on a database whose `events.search_tsv` indexes that kind. See "Search
index" below.
## Design
- `crates/buzz-core/src/kind.rs`: `KIND_EMOJI_ENTRY = 30180`. It is in
`ALL_KINDS`. A compile-time check proves it is parameterized
replaceable. 30180 was free on `origin/main`.
- `crates/buzz-relay/src/handlers/ingest.rs`:
- The kind needs `UsersWrite`.
- The kind is global-only. It is never channel-scoped.
- `validate_emoji_entry` runs before storage. It requires exactly one
`d` tag and exactly one `emoji` tag.
- It rejects any `h` tag.
- It requires `d` to equal the shortcode in the `emoji` tag, and both to
equal the normalized shortcode.
- It requires an `http://` or `https://` URL of at most 2048 bytes. It
uses the SDK check `check_custom_emoji_url`, which is now public.
- It caps `content` at 256 bytes (`MAX_EMOJI_ENTRY_CONTENT_LEN`). A
shortcode is at most 64 bytes, so the cap leaves room.
- Every rejection starts with `invalid:`.
- `crates/buzz-sdk/src/builders.rs`: `build_emoji_entry(shortcode, url)`
and `emoji_entry_search_text`. The content is the shortcode with `_` and
`-` replaced by spaces.
- Replace semantics use the existing parameterized-replaceable path. A
second publish with the same `(pubkey, d)` leaves one live event. Two
authors with the same `d` keep two events. The NIP tells clients to
prefer the newest event and report the conflict.
### Decisions
- **WebSocket `search_mode`: not changed.** Prefix search works through
`POST /query`, which already reads `search_mode`. This is the smallest
correct change. The `REQ` search path still uses full-word matching. The
NIP says so. A client that needs typeahead uses `POST /query`.
- **Search text.** Postgres `simple` already splits `party_parrot` at
the underscore. It keeps `sad-parrot` as one token and also indexes
`sad` and `parrot`. So the space replacement is not needed for matching
today. It is kept because it is harmless and does not depend on parser
details.
- **Content cap of 256 bytes.** The cap is documented in the NIP and the
code doc comment.
### Search index (important)
The design doc says kind 30030 is not excluded from the full-text index.
That is true for `schema/` (the desired state): it indexes every kind
except a deny list. It is not true for a database that was created by
`migrations/`:
- Migration 0008 gives a fresh install a positive allowlist: kinds 0, 9,
40002, 45001, 45003.
- `scripts/maintenance/nip_rs_search_allowlist.sql` moves a populated
database to the same allowlist.
On an allowlist database, kind 30180 is **stored and resolvable by `#d`,
but not searchable**.
I did not change this. A change to `search_tsv` rewrites the partitioned
`events` table. That needs the reviewed out-of-band procedure in
`docs/database.md` (rules 6 and 5), and this task forbids migrations and
schema files.
Before clients depend on search, a human must check the deployed
expression:
```sql
SELECT pg_get_expr(adbin, adrelid) FROM pg_attrdef d JOIN pg_attribute a
ON a.attrelid = d.adrelid AND a.attnum = d.adnum
WHERE a.attname = 'search_tsv' AND d.adrelid = 'events'::regclass;
```
If the result lists `kind IN (0, 9, ...)`, add 30180 to the allowlist
through the out-of-band procedure first.
## Spike
I ran this on a throwaway local Postgres 17 (Docker), desired-state
schema from `pgschema`.
The loader inserted 100,000 kind 30180 events from 200 authors through
`Db::replace_parameterized_event`. It used 16 parallel tasks, and it did
not use the network.
The loader was a scratch example. It is not in this PR.
Rows landed in `events_p_future` because the local partitions end at
2026-06. Real deployments have monthly partitions, so scans split across
partitions.
**Insert rate.** 100,000 inserts took 33.9 s (about 2,900 per second) at
the DB layer. The relay adds rate limits on top (see the design doc,
section 2.5).
**`#d` batch of 50 names** (actual relay SQL shape, warm cache, `LIMIT
100`):
| Case | Plan | Execution time |
|------|------|----------------|
| No `authors` | Index scan on `idx_events_parameterized`. It uses
`community_id` and `kind` only. `d_tag` is not a leading key, so it
scans the kind range. | 12 ms warm, 28 ms cold |
| 50 names, all their authors (50 distinct) | Index scan with `pubkey`
and `d_tag` in the index condition | 1.5 ms |
| 50 names, one author | Same index, tight condition | 0.13 ms |
Result: with `authors`, the lookup is fast. Without `authors`, the cost
grows with the number of entries of the kind. At 100,000 rows it is
still tens of milliseconds. No index change is needed now. Re-measure at
1,000,000 rows.
**Prefix search**, `kinds=[30180]`, relevance order (the default),
`LIMIT 50`:
| Query | Matching rows | Execution time (warm) |
|-------|---------------|-----------------------|
| `parr` | 8,169 | 115 ms (124 ms cold) |
| `p` | 15,990 | 219 ms |
| `party par` | 8,169 | 266 ms (cold) |
| `zap blob 999` (rare) | 1 | 0.4 ms |
| `parr` with `search_sort: "newest"` | 8,169 | 0.8 ms |
Result: the GIN index finds matches fast. Relevance order then scores
every matching row (`ts_rank_cd`) and sorts them. The cost grows with
the number of matches, not with `limit`. My synthetic words are few
(24), so each prefix matches many rows. Real names are more varied, but
a one- or two-letter query will still match many rows. The `search_sort:
"newest"` option avoids the scoring and was 150 times faster here.
Clients should use `search_sort: "newest"` for typeahead, or require at
least 3 characters. This is a recommendation for the client work. It
needs no relay change.
**Side effects of bulk insert** (from reading `ingest.rs` and
`handlers/event.rs`):
- Audit log: each accepted event queues one `EventCreated` audit entry.
The audit worker writes them one at a time under an advisory lock. The
queue holds 1000 entries and uses `send().await`, so a bulk run slows
the writer down instead of dropping entries. 96,000 events add 96,000
audit rows.
- Live fan-out: each event is published to the global topic and matched
against open subscriptions in `sub_registry`. A client with an open
filter for kind 30180 receives every event.
- Workflow triggers: each event spawns a task that calls
`workflow_engine.on_event`. That is one trigger lookup per event.
- Push: the DB push enqueue only matches kinds 9, 40002, 45001, and
45003 (`PUSH_MATCH_KINDS`). Kind 30180 does not wake push subscribers.
- No `channel_id`, so the `events_created_at_floor_guard` trigger does
not apply.
## Testing
What ran (all on the final tree):
- `cargo test -p buzz-core -p buzz-sdk -p buzz-relay -p buzz-search`:
pass.
- `cargo clippy -p buzz-core -p buzz-sdk -p buzz-relay -p buzz-search
--all-targets -- -D warnings`: pass.
- `cargo fmt --all --check`: pass.
- `just file-size-check`: pass.
- Postgres lane, desired-state schema (`BUZZ_TEST_SCHEMA_MODE=desired`)
with a local Redis: `cargo test -p buzz-relay --lib handlers::ingest --
--include-ignored`: 205 passed, including the four new tests.
New tests:
- `emoji_entry_validation_table` (no infra): one row for each rule.
- `emoji_entry_is_a_global_users_write_kind` (no infra).
- `emoji_entry_ingest_validates_and_replaces` (Postgres): goes through
`ingest_event`. It rejects a bad shortcode, `d` not equal to shortcode,
an `h` tag, and a non-http URL. It checks that nothing is stored. It
then publishes twice with the same `d` and checks that one live event
remains and that it is the newer one.
- `emoji_entry_prefix_search_finds_by_word_prefix` (Postgres): `parr`
finds `party parrot` and `sad parrot`. `party par` finds one. `joy`
finds `bufo joy`. It fails with a clear message on an allowlist
database.
- SDK: `emoji_entry_happy_path` and `emoji_entry_rejects_bad_input`.
Not run:
- `just test` was not run in full. I ran the relevant Postgres tests
directly.
- `cargo test -p buzz-search --test postgres_fts_integration` fails 23
of 23 on my shared local database. Those fixtures use fixed hostnames
and need the fresh-database lane (`scripts/postgres-test-run.sh`). I did
not change that crate. This is an environment limit, not a result of
this change.
- The new search test fails on a database built by `buzz-admin migrate`
(allowlist). That is the finding above. It passes on the desired-state
schema that the CI lane uses (`postgres-test-run.sh`).
- I ran no live relay and no end-to-end client.
Interop evidence: none for this item. The desktop and mobile constants
are out of scope here. `/home/bloxer/ref/interop.mjs` was not needed
because no TypeScript changed.
Mutation checks (break the guard, confirm a test fails, restore):
| Mutation | Result |
|----------|--------|
| Remove the `validate_emoji_entry` call |
`emoji_entry_ingest_validates_and_replaces` fails |
| Remove the `h` tag check | table test and ingest test fail |
| Remove the `d == shortcode` check | table test and ingest test fail |
| Remove the URL check | table test and ingest test fail |
| Remove the content cap | table test fails |
| Allow more than one `d` tag | table test fails |
| Allow more than one `emoji` tag | table test fails |
| Remove the kind from `required_scope_for_kind` | scope test, ingest
test, and search test fail |
| Remove the kind from `is_global_only_kind` | global test and ingest
test fail |
| SDK: skip shortcode normalization | both SDK tests fail |
| SDK: skip the URL check | `emoji_entry_rejects_bad_input` fails |
| SDK: skip the space replacement | `emoji_entry_happy_path` fails |
One guard has no relay-level test: the space replacement does not change
search results, because Postgres already splits `_`. The SDK test covers
it.
## Manual test steps
You need Docker and a local checkout. Use a local relay only.
1. Start the services and apply the desired-state schema, so that search
indexes kind 30180:
```bash
cd ~/buzz && . ./bin/activate-hermit
docker run -d --name buzz-pg -e POSTGRES_USER=buzz -e
POSTGRES_PASSWORD=buzz_dev -e POSTGRES_DB=buzz -p 127.0.0.1:5432:5432
postgres:17-alpine
redis-server --daemonize yes --save ""
PGHOST=localhost PGPORT=5432 PGUSER=buzz PGPASSWORD=buzz_dev
PGDATABASE=buzz ./bin/pgschema apply --file schema/schema.sql
--auto-approve
docker exec -i buzz-pg psql -U buzz -d buzz -v ON_ERROR_STOP=1 <
scripts/reconcile-schema-after-pgschema.sql
```
Success: `Changes applied successfully!`.
2. Run the new tests:
```bash
export
BUZZ_TEST_DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz
BUZZ_TEST_SCHEMA_MODE=desired
cargo test -p buzz-relay --lib emoji_entry -- --include-ignored
cargo test -p buzz-sdk --lib emoji_entry
```
Success: 4 relay tests and 2 SDK tests pass.
3. Optional, with a local relay (`just relay`) and a local key. Send
these through `POST /events` with the SDK or a small script that calls
`build_emoji_entry("party_parrot", "https://example.com/p.gif")`.
Success: the relay returns `accepted: true`.
- Publish a second entry with the same shortcode. Query `POST /query`
with `{"kinds":[30180],"#d":["party_parrot"]}`. Success: one event, the
newer one.
- Query
`{"kinds":[30180],"search":"parr","search_mode":"prefix","limit":50}`.
Success: the entry is in the result.
- Publish an entry that has an `h` tag or an `ftp://` URL. Success: the
relay returns `invalid:`.
4. Check your target database before you rely on search (the SQL in
"Search index"). Success: the expression does not list an allowlist such
as `kind IN (0, 9, 40002, 45001, 45003)`. If it does, search does not
find kind 30180 yet.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Signed-off-by: bmcanally <bmcanally@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>