Browse Source
- Fix createNIP98AuthHeader and createNIP98Auth in App.svelte to use userSigner.signEvent() for all auth methods instead of generating mock signatures for nsec logins - Add direct GET-based download for open relays (ACL "none") to avoid browser issues with fetch/blob programmatic downloads - Add export status feedback (disabled buttons, "Exporting..." text) - Add cmd/orly-export CLI tool for NIP-98 authenticated relay exports with progress tracking - Add vendor/ to .gitignore - Bump version to v0.58.2 Files modified: - app/web/src/App.svelte: Fix NIP-98 signing, rewrite export flow - app/web/src/ExportView.svelte: Add isExporting prop and button states - app/web/dist/: Rebuilt bundle - cmd/orly-export/main.go: New CLI export tool - .gitignore: Add vendor/ exclusion - pkg/version/version: v0.58.2 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>main v0.58.2
24 changed files with 1517 additions and 104 deletions
@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
|
||||
# SQLite databases |
||||
*.db |
||||
*.db?* |
||||
*.db-journal |
||||
*.db-wal |
||||
*.db-shm |
||||
|
||||
# Daemon runtime files |
||||
daemon.lock |
||||
daemon.log |
||||
daemon.pid |
||||
bd.sock |
||||
sync-state.json |
||||
last-touched |
||||
|
||||
# Local version tracking (prevents upgrade notification spam after git ops) |
||||
.local_version |
||||
|
||||
# Legacy database files |
||||
db.sqlite |
||||
bd.db |
||||
|
||||
# Worktree redirect file (contains relative path to main repo's .beads/) |
||||
# Must not be committed as paths would be wrong in other clones |
||||
redirect |
||||
|
||||
# Merge artifacts (temporary files from 3-way merge) |
||||
beads.base.jsonl |
||||
beads.base.meta.json |
||||
beads.left.jsonl |
||||
beads.left.meta.json |
||||
beads.right.jsonl |
||||
beads.right.meta.json |
||||
|
||||
# Sync state (local-only, per-machine) |
||||
# These files are machine-specific and should not be shared across clones |
||||
.sync.lock |
||||
sync_base.jsonl |
||||
|
||||
# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. |
||||
# They would override fork protection in .git/info/exclude, allowing |
||||
# contributors to accidentally commit upstream issue databases. |
||||
# The JSONL files (issues.jsonl, interactions.jsonl) and config files |
||||
# are tracked by git by default since no pattern above ignores them. |
||||
@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
|
||||
# Beads - AI-Native Issue Tracking |
||||
|
||||
Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. |
||||
|
||||
## What is Beads? |
||||
|
||||
Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. |
||||
|
||||
**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) |
||||
|
||||
## Quick Start |
||||
|
||||
### Essential Commands |
||||
|
||||
```bash |
||||
# Create new issues |
||||
bd create "Add user authentication" |
||||
|
||||
# View all issues |
||||
bd list |
||||
|
||||
# View issue details |
||||
bd show <issue-id> |
||||
|
||||
# Update issue status |
||||
bd update <issue-id> --status in_progress |
||||
bd update <issue-id> --status done |
||||
|
||||
# Sync with git remote |
||||
bd sync |
||||
``` |
||||
|
||||
### Working with Issues |
||||
|
||||
Issues in Beads are: |
||||
- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code |
||||
- **AI-friendly**: CLI-first design works perfectly with AI coding agents |
||||
- **Branch-aware**: Issues can follow your branch workflow |
||||
- **Always in sync**: Auto-syncs with your commits |
||||
|
||||
## Why Beads? |
||||
|
||||
✨ **AI-Native Design** |
||||
- Built specifically for AI-assisted development workflows |
||||
- CLI-first interface works seamlessly with AI coding agents |
||||
- No context switching to web UIs |
||||
|
||||
🚀 **Developer Focused** |
||||
- Issues live in your repo, right next to your code |
||||
- Works offline, syncs when you push |
||||
- Fast, lightweight, and stays out of your way |
||||
|
||||
🔧 **Git Integration** |
||||
- Automatic sync with git commits |
||||
- Branch-aware issue tracking |
||||
- Intelligent JSONL merge resolution |
||||
|
||||
## Get Started with Beads |
||||
|
||||
Try Beads in your own projects: |
||||
|
||||
```bash |
||||
# Install Beads |
||||
curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash |
||||
|
||||
# Initialize in your repo |
||||
bd init |
||||
|
||||
# Create your first issue |
||||
bd create "Try out Beads" |
||||
``` |
||||
|
||||
## Learn More |
||||
|
||||
- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) |
||||
- **Quick Start Guide**: Run `bd quickstart` |
||||
- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) |
||||
|
||||
--- |
||||
|
||||
*Beads: Issue tracking that moves at the speed of thought* ⚡ |
||||
@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
|
||||
# Beads Configuration File |
||||
# This file configures default behavior for all bd commands in this repository |
||||
# All settings can also be set via environment variables (BD_* prefix) |
||||
# or overridden with command-line flags |
||||
|
||||
# Issue prefix for this repository (used by bd init) |
||||
# If not set, bd init will auto-detect from directory name |
||||
# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. |
||||
# issue-prefix: "" |
||||
|
||||
# Use no-db mode: load from JSONL, no SQLite, write back after each command |
||||
# When true, bd will use .beads/issues.jsonl as the source of truth |
||||
# instead of SQLite database |
||||
# no-db: false |
||||
|
||||
# Disable daemon for RPC communication (forces direct database access) |
||||
# no-daemon: false |
||||
|
||||
# Disable auto-flush of database to JSONL after mutations |
||||
# no-auto-flush: false |
||||
|
||||
# Disable auto-import from JSONL when it's newer than database |
||||
# no-auto-import: false |
||||
|
||||
# Enable JSON output by default |
||||
# json: false |
||||
|
||||
# Default actor for audit trails (overridden by BD_ACTOR or --actor) |
||||
# actor: "" |
||||
|
||||
# Path to database (overridden by BEADS_DB or --db) |
||||
# db: "" |
||||
|
||||
# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) |
||||
# auto-start-daemon: true |
||||
|
||||
# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) |
||||
# flush-debounce: "5s" |
||||
|
||||
# Git branch for beads commits (bd sync will commit to this branch) |
||||
# IMPORTANT: Set this for team projects so all clones use the same sync branch. |
||||
# This setting persists across clones (unlike database config which is gitignored). |
||||
# Can also use BEADS_SYNC_BRANCH env var for local override. |
||||
# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'. |
||||
# sync-branch: "beads-sync" |
||||
|
||||
# Multi-repo configuration (experimental - bd-307) |
||||
# Allows hydrating from multiple repositories and routing writes to the correct JSONL |
||||
# repos: |
||||
# primary: "." # Primary repo (where this database lives) |
||||
# additional: # Additional repos to hydrate from (read-only) |
||||
# - ~/beads-planning # Personal planning repo |
||||
# - ~/work-planning # Work planning repo |
||||
|
||||
# Integration settings (access with 'bd config get/set') |
||||
# These are stored in the database, not in this file: |
||||
# - jira.url |
||||
# - jira.project |
||||
# - linear.url |
||||
# - linear.api-key |
||||
# - github.org |
||||
# - github.repo |
||||
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
{ |
||||
"database": "beads.db", |
||||
"jsonl_export": "issues.jsonl" |
||||
} |
||||
@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
|
||||
# Agent Instructions |
||||
|
||||
This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get started. |
||||
|
||||
## Quick Reference |
||||
|
||||
```bash |
||||
bd ready # Find available work |
||||
bd show <id> # View issue details |
||||
bd update <id> --status in_progress # Claim work |
||||
bd close <id> # Complete work |
||||
bd sync # Sync with git |
||||
``` |
||||
|
||||
## Landing the Plane (Session Completion) |
||||
|
||||
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. |
||||
|
||||
**MANDATORY WORKFLOW:** |
||||
|
||||
1. **File issues for remaining work** - Create issues for anything that needs follow-up |
||||
2. **Run quality gates** (if code changed) - Tests, linters, builds |
||||
3. **Update issue status** - Close finished work, update in-progress items |
||||
4. **PUSH TO REMOTE** - This is MANDATORY: |
||||
```bash |
||||
git pull --rebase |
||||
bd sync |
||||
git push |
||||
git status # MUST show "up to date with origin" |
||||
``` |
||||
5. **Clean up** - Clear stashes, prune remote branches |
||||
6. **Verify** - All changes committed AND pushed |
||||
7. **Hand off** - Provide context for next session |
||||
|
||||
**CRITICAL RULES:** |
||||
- Work is NOT complete until `git push` succeeds |
||||
- NEVER stop before pushing - that leaves work stranded locally |
||||
- NEVER say "ready to push when you are" - YOU must push |
||||
- If push fails, resolve and retry until it succeeds |
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,191 @@
@@ -0,0 +1,191 @@
|
||||
// Package main is a CLI tool to export all events from an ORLY relay via the
|
||||
// /api/export HTTP endpoint using NIP-98 authentication.
|
||||
package main |
||||
|
||||
import ( |
||||
"flag" |
||||
"fmt" |
||||
"io" |
||||
"net/http" |
||||
"net/url" |
||||
"os" |
||||
"strings" |
||||
"time" |
||||
|
||||
"lol.mleku.dev/chk" |
||||
|
||||
"git.mleku.dev/mleku/nostr/encoders/bech32encoding" |
||||
"git.mleku.dev/mleku/nostr/httpauth" |
||||
"git.mleku.dev/mleku/nostr/interfaces/signer" |
||||
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k" |
||||
|
||||
"next.orly.dev/pkg/version" |
||||
) |
||||
|
||||
var userAgent = fmt.Sprintf("orly-export/%s", strings.TrimSpace(version.V)) |
||||
|
||||
func fail(format string, a ...any) { |
||||
fmt.Fprintf(os.Stderr, "error: "+format+"\n", a...) |
||||
os.Exit(1) |
||||
} |
||||
|
||||
// progressWriter wraps an io.Writer and tracks bytes written and line count.
|
||||
type progressWriter struct { |
||||
w io.Writer |
||||
bytes int64 |
||||
lines int64 |
||||
lastReport time.Time |
||||
start time.Time |
||||
} |
||||
|
||||
func newProgressWriter(w io.Writer) *progressWriter { |
||||
now := time.Now() |
||||
return &progressWriter{w: w, start: now, lastReport: now} |
||||
} |
||||
|
||||
func (pw *progressWriter) Write(p []byte) (n int, err error) { |
||||
n, err = pw.w.Write(p) |
||||
pw.bytes += int64(n) |
||||
for _, b := range p[:n] { |
||||
if b == '\n' { |
||||
pw.lines++ |
||||
} |
||||
} |
||||
if time.Since(pw.lastReport) >= 2*time.Second { |
||||
pw.report() |
||||
pw.lastReport = time.Now() |
||||
} |
||||
return |
||||
} |
||||
|
||||
func (pw *progressWriter) report() { |
||||
elapsed := time.Since(pw.start) |
||||
mb := float64(pw.bytes) / 1024 / 1024 |
||||
fmt.Fprintf(os.Stderr, "\r %d events, %.2f MB downloaded (%.1fs)", |
||||
pw.lines, mb, elapsed.Seconds()) |
||||
} |
||||
|
||||
func (pw *progressWriter) final() { |
||||
elapsed := time.Since(pw.start) |
||||
mb := float64(pw.bytes) / 1024 / 1024 |
||||
fmt.Fprintf(os.Stderr, "\r %d events, %.2f MB downloaded in %.1fs\n", |
||||
pw.lines, mb, elapsed.Seconds()) |
||||
} |
||||
|
||||
func main() { |
||||
var ( |
||||
relayURL string |
||||
nsec string |
||||
output string |
||||
) |
||||
|
||||
flag.StringVar(&relayURL, "relay", "", "relay URL (e.g. https://plebeian.market)") |
||||
flag.StringVar(&nsec, "nsec", "", "nsec (bech32) for NIP-98 auth (or set NOSTR_SECRET_KEY)") |
||||
flag.StringVar(&output, "output", "", "output file path (default: auto-generated)") |
||||
flag.Parse() |
||||
|
||||
if relayURL == "" { |
||||
fail("--relay is required (e.g. --relay https://plebeian.market)") |
||||
} |
||||
|
||||
// Normalize the relay URL
|
||||
relayURL = strings.TrimRight(relayURL, "/") |
||||
if !strings.HasPrefix(relayURL, "http") { |
||||
relayURL = "https://" + relayURL |
||||
} |
||||
|
||||
// Get nsec from flag or env
|
||||
if nsec == "" { |
||||
nsec = os.Getenv("NOSTR_SECRET_KEY") |
||||
} |
||||
|
||||
var sign signer.I |
||||
if nsec != "" { |
||||
var err error |
||||
sign, err = makeSigner(nsec) |
||||
if err != nil { |
||||
fail("failed to initialize signer: %s", err) |
||||
} |
||||
fmt.Fprintf(os.Stderr, "authenticated with NIP-98\n") |
||||
} else { |
||||
fmt.Fprintf(os.Stderr, "warning: no nsec provided, attempting unauthenticated export\n") |
||||
} |
||||
|
||||
// Build export URL
|
||||
exportURL := relayURL + "/api/export" |
||||
ur, err := url.Parse(exportURL) |
||||
if err != nil { |
||||
fail("invalid URL: %s", err) |
||||
} |
||||
|
||||
// Determine output file
|
||||
if output == "" { |
||||
host := ur.Hostname() |
||||
host = strings.ReplaceAll(host, ".", "-") |
||||
output = fmt.Sprintf("export-%s-%s.jsonl", |
||||
host, |
||||
time.Now().UTC().Format("20060102-150405Z")) |
||||
} |
||||
|
||||
fmt.Fprintf(os.Stderr, "exporting from %s -> %s\n", exportURL, output) |
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequest("GET", exportURL, nil) |
||||
if err != nil { |
||||
fail("failed to create request: %s", err) |
||||
} |
||||
req.Header.Set("User-Agent", userAgent) |
||||
|
||||
if sign != nil { |
||||
if err = httpauth.AddNIP98Header(req, ur, "GET", "", sign, 0); chk.E(err) { |
||||
fail("failed to add NIP-98 header: %s", err) |
||||
} |
||||
} |
||||
|
||||
// Execute request with no timeout (export can be large)
|
||||
client := &http.Client{ |
||||
Timeout: 0, |
||||
} |
||||
resp, err := client.Do(req) |
||||
if err != nil { |
||||
fail("request failed: %s", err) |
||||
} |
||||
defer resp.Body.Close() |
||||
|
||||
if resp.StatusCode != http.StatusOK { |
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) |
||||
fail("server returned %d: %s", resp.StatusCode, string(body)) |
||||
} |
||||
|
||||
// Open output file
|
||||
f, err := os.Create(output) |
||||
if err != nil { |
||||
fail("failed to create output file: %s", err) |
||||
} |
||||
defer f.Close() |
||||
|
||||
// Stream response to file with progress tracking
|
||||
pw := newProgressWriter(f) |
||||
if _, err = io.Copy(pw, resp.Body); err != nil { |
||||
fmt.Fprintln(os.Stderr) |
||||
fail("download error: %s", err) |
||||
} |
||||
pw.final() |
||||
|
||||
fmt.Fprintf(os.Stderr, "export saved to %s\n", output) |
||||
} |
||||
|
||||
func makeSigner(nsec string) (signer.I, error) { |
||||
sk, err := bech32encoding.NsecToBytes([]byte(nsec)) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to decode nsec: %w", err) |
||||
} |
||||
s, err := p8k.New() |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to create signer: %w", err) |
||||
} |
||||
if err = s.InitSec(sk); err != nil { |
||||
return nil, fmt.Errorf("failed to init signer: %w", err) |
||||
} |
||||
return s, nil |
||||
} |
||||
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
# gRPC Negentropy Sync Service Dockerfile |
||||
|
||||
FROM golang:1.24-alpine AS builder |
||||
|
||||
RUN apk add --no-cache git make |
||||
|
||||
WORKDIR /build |
||||
COPY . . |
||||
|
||||
# Build the sync service binary |
||||
RUN GOTOOLCHAIN=auto CGO_ENABLED=0 go build -o orly-sync-negentropy ./cmd/orly-sync-negentropy |
||||
|
||||
# Runtime image |
||||
FROM alpine:3.19 |
||||
|
||||
RUN apk add --no-cache ca-certificates |
||||
|
||||
WORKDIR /app |
||||
COPY --from=builder /build/orly-sync-negentropy /app/ |
||||
|
||||
# Skip grpc-health-probe - not essential for testing |
||||
|
||||
EXPOSE 50064 |
||||
|
||||
CMD ["/app/orly-sync-negentropy"] |
||||
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
# Test Runner Dockerfile with event generator and test tools |
||||
|
||||
FROM golang:1.24-alpine AS builder |
||||
|
||||
RUN apk add --no-cache git make |
||||
|
||||
WORKDIR /build |
||||
COPY . . |
||||
|
||||
# Build event generator as a static binary (use vendored deps for local replace directives) |
||||
RUN GOTOOLCHAIN=auto CGO_ENABLED=0 go build -mod=vendor -ldflags='-extldflags=-static' -o event-generator ./tests/negentropy/event-generator |
||||
|
||||
# Runtime image |
||||
FROM alpine:3.21 |
||||
|
||||
RUN apk add --no-cache ca-certificates curl jq bash |
||||
|
||||
# Install websocat binary |
||||
RUN curl -L -o /usr/local/bin/websocat https://github.com/vi/websocat/releases/download/v1.13.0/websocat.x86_64-unknown-linux-musl && \ |
||||
chmod +x /usr/local/bin/websocat |
||||
|
||||
# Copy event-generator binary |
||||
COPY --from=builder /build/event-generator /usr/local/bin/ |
||||
|
||||
CMD ["bash"] |
||||
@ -0,0 +1,185 @@
@@ -0,0 +1,185 @@
|
||||
# Comprehensive Negentropy Sync Test Suite |
||||
|
||||
This test suite validates NIP-77 negentropy synchronization between ORLY and strfry relays in all possible configurations. |
||||
|
||||
## Test Scenarios |
||||
|
||||
### 1. Orly as Relay, Strfry as Client |
||||
Uses `strfry sync` command to test: |
||||
- **Push**: strfry → orly-relay-1 |
||||
- **Pull**: strfry ← orly-relay-1 |
||||
- **Bidirectional**: strfry ↔ orly-relay-1 |
||||
|
||||
### 2. Strfry as Relay, Orly as Client |
||||
Uses `orly sync` with gRPC client mode: |
||||
- **Push**: orly-relay-2 → strfry |
||||
- **Pull**: orly-relay-2 ← strfry |
||||
- **Bidirectional**: orly-relay-2 ↔ strfry |
||||
|
||||
### 3. Dual Orly with gRPC Control |
||||
Two ORLY relays synchronized via gRPC sync services: |
||||
- **orly-relay-1** ↔ **orly-relay-2** via gRPC-controlled sync |
||||
|
||||
## Infrastructure |
||||
|
||||
``` |
||||
┌─────────────┐ ┌─────────────┐ |
||||
│ strfry │◄───────►│ orly-relay-1│ |
||||
│ (7777) │ NIP-77 │ (3334) │ |
||||
└──────┬──────┘ └──────┬──────┘ |
||||
│ │ |
||||
│ │ gRPC |
||||
│ ┌─────┴──────┐ |
||||
│ │ orly-sync-1│ |
||||
│ │ (50064) │ |
||||
│ └────────────┘ |
||||
│ |
||||
│ NIP-77 ┌─────────────┐ |
||||
└────────────────►│ orly-relay-2│ |
||||
│ (3335) │ |
||||
└──────┬──────┘ |
||||
│ gRPC |
||||
┌─────┴──────┐ |
||||
│ orly-sync-2│ |
||||
│ (50064) │ |
||||
└────────────┘ |
||||
``` |
||||
|
||||
## Quick Start |
||||
|
||||
### Prerequisites |
||||
- Docker and Docker Compose |
||||
- Go 1.24+ (for local event generator builds) |
||||
|
||||
### Run All Tests |
||||
|
||||
```bash |
||||
cd tests/negentropy |
||||
|
||||
# Build all images |
||||
docker compose build |
||||
|
||||
# Start infrastructure |
||||
docker compose up -d |
||||
|
||||
# Run comprehensive tests |
||||
docker compose exec test-runner /tests/comprehensive-test.sh |
||||
|
||||
# Or with verbose output |
||||
docker compose exec test-runner /tests/comprehensive-test.sh --verbose |
||||
|
||||
# Clean up |
||||
docker compose down -v |
||||
``` |
||||
|
||||
### Run Individual Test Phases |
||||
|
||||
```bash |
||||
# Enter test runner container |
||||
docker compose exec test-runner bash |
||||
|
||||
# Check relay status |
||||
echo "Strfry events: $(count_events ws://strfry:7777 '{"limit": 1000}')" |
||||
echo "Orly-1 events: $(count_events ws://orly-relay-1:3334 '{"limit": 1000}')" |
||||
echo "Orly-2 events: $(count_events ws://orly-relay-2:3335 '{"limit": 1000}')" |
||||
|
||||
# Generate events manually |
||||
event-generator -count 500 -relay ws://strfry:7777 |
||||
|
||||
# Test strfry as client (pull) |
||||
docker compose exec strfry /app/strfry sync ws://orly-relay-1:3334 --dir down |
||||
|
||||
# Test orly as client via gRPC (pull) |
||||
orly sync ws://strfry:7777 --server orly-sync-2:50064 --dir down --verbose |
||||
``` |
||||
|
||||
## Test Parameters |
||||
|
||||
- **Total Events**: 1200+ per seed operation |
||||
- **Event Kinds**: 0, 1, 3, 1984, 10000, 10001, 30023, 30078 |
||||
- **Batch Size**: 100 events per batch |
||||
- **Authors**: 3 test keypairs (alice, bob, carol) |
||||
|
||||
### Event Distribution |
||||
|
||||
| Kind | Percentage | Description | |
||||
|------|------------|-------------| |
||||
| 1 | 60% | Short text notes | |
||||
| 0 | 15% | Metadata | |
||||
| 3 | 10% | Contacts | |
||||
| 1984 | 5% | Reports | |
||||
| 10000| 5% | Mute lists | |
||||
| 10001| 3% | Pin lists | |
||||
| 30023| 2% | Long-form articles | |
||||
|
||||
## Filter Testing |
||||
|
||||
The test suite validates sync with various filters: |
||||
|
||||
```bash |
||||
# Kind filter |
||||
'{"kinds": [1, 3]}' |
||||
|
||||
# Time range |
||||
'{"since": 1700000000, "until": 1800000000}' |
||||
|
||||
# Limit |
||||
'{"limit": 100}' |
||||
|
||||
# Combined |
||||
'{"kinds": [1], "since": 1700000000, "limit": 500}' |
||||
``` |
||||
|
||||
## Verification |
||||
|
||||
Tests verify: |
||||
1. Event counts match expected values |
||||
2. Bidirectional sync achieves consistency |
||||
3. Filtered sync respects constraints |
||||
4. Different event kinds sync correctly |
||||
5. No data corruption during sync |
||||
|
||||
## Troubleshooting |
||||
|
||||
### Check service health |
||||
```bash |
||||
docker compose ps |
||||
docker compose logs -f strfry |
||||
docker compose logs -f orly-relay-1 |
||||
``` |
||||
|
||||
### Manual event inspection |
||||
```bash |
||||
# Get events from strfry |
||||
echo '["REQ", "test", {"limit": 10}]' | websocat ws://localhost:7777 |
||||
|
||||
# Get events from orly |
||||
echo '["REQ", "test", {"limit": 10}]' | websocat ws://localhost:3334 |
||||
``` |
||||
|
||||
### Reset test data |
||||
```bash |
||||
docker compose down -v |
||||
docker compose up -d |
||||
``` |
||||
|
||||
## Architecture Details |
||||
|
||||
### Strfry |
||||
- Image: Built from source (Dockerfile.strfry) |
||||
- Port: 7777 |
||||
- Features: Full NIP-77 negentropy support |
||||
|
||||
### Orly Relay |
||||
- Image: Built from project (Dockerfile.orly) |
||||
- Ports: 3334, 3335 |
||||
- Features: NIP-77 negentropy + gRPC database interface |
||||
|
||||
### Orly Sync Service |
||||
- Image: Built from cmd/orly-sync-negentropy |
||||
- Ports: 50064, 50065 |
||||
- Features: gRPC-controlled negentropy sync |
||||
|
||||
### Test Runner |
||||
- Image: Built with all test tools |
||||
- Features: event-generator, websocat, orly CLI |
||||
@ -0,0 +1,347 @@
@@ -0,0 +1,347 @@
|
||||
#!/bin/bash |
||||
# |
||||
# Comprehensive Negentropy Sync Test Suite |
||||
# Tests NIP-77 negentropy sync between strfry (client) and ORLY (server). |
||||
# |
||||
# Strfry has a built-in `sync` command that uses the negentropy protocol. |
||||
# ORLY serves NIP-77 via its embedded negentropy handler. |
||||
# |
||||
# This script runs from the HOST and uses `docker compose exec` to |
||||
# interact with containers. |
||||
# |
||||
# Scenarios tested: |
||||
# 1. Seed strfry with events |
||||
# 2. Strfry pushes events to ORLY (strfry --dir up) |
||||
# 3. Seed ORLY with new events |
||||
# 4. Strfry pulls events from ORLY (strfry --dir down) |
||||
# 5. Bidirectional sync |
||||
# 6. Final consistency verification |
||||
# |
||||
# Usage: |
||||
# cd tests/negentropy |
||||
# docker compose build |
||||
# docker compose up -d |
||||
# ./comprehensive-test.sh |
||||
# docker compose down -v |
||||
|
||||
set -euo pipefail |
||||
|
||||
# Change to the directory containing docker-compose.yml |
||||
cd "$(dirname "$0")" |
||||
|
||||
# Configuration |
||||
STRFRY_WS="ws://strfry:7777" |
||||
ORLY_WS="ws://orly-relay-1:3334" |
||||
SEED_COUNT=200 |
||||
EXTRA_COUNT=100 |
||||
VERBOSE="${VERBOSE:-false}" |
||||
|
||||
# Test results |
||||
PASSED=0 |
||||
FAILED=0 |
||||
|
||||
# Colors |
||||
RED='\033[0;31m' |
||||
GREEN='\033[0;32m' |
||||
YELLOW='\033[1;33m' |
||||
BLUE='\033[0;34m' |
||||
NC='\033[0m' |
||||
|
||||
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } |
||||
log_pass() { echo -e "${GREEN}[PASS]${NC} $1"; PASSED=$((PASSED + 1)); } |
||||
log_fail() { echo -e "${RED}[FAIL]${NC} $1"; FAILED=$((FAILED + 1)); } |
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } |
||||
log_phase() { echo ""; echo "========================================"; echo -e "${YELLOW}PHASE: $1${NC}"; echo "========================================"; } |
||||
|
||||
# Run a command in the test-runner container |
||||
run_test() { |
||||
docker compose exec -T test-runner sh -c "$1" |
||||
} |
||||
|
||||
# Run a command in the strfry container |
||||
run_strfry() { |
||||
docker compose exec -T strfry sh -c "$1" |
||||
} |
||||
|
||||
# Count events on a relay via WebSocket from test-runner. |
||||
# Sends a REQ, reads until EOSE, counts EVENT messages. |
||||
# Usage: count_events <ws_url> [filter_json] |
||||
count_events() { |
||||
local url=$1 |
||||
local filter=${2:-'{}'} |
||||
|
||||
# IMPORTANT: We use { printf ...; sleep 60; } to keep stdin open. |
||||
# Without this, websocat sends a close frame when stdin EOF is hit, |
||||
# and the relay may not have sent all events yet. |
||||
# |
||||
# awk counts EVENT messages and exits on EOSE (breaking the pipe). |
||||
# timeout is a safety net in case EOSE never arrives. |
||||
local result |
||||
result=$(run_test "{ printf '[\"REQ\",\"c\",%s]\n' '${filter}'; sleep 60; } | timeout 20 websocat '${url}' 2>/dev/null | awk 'BEGIN{c=0;f=0} /EOSE/{f=1; print c; exit} /EVENT/{c++} END{if(f==0) print c}'") || true |
||||
|
||||
# Trim whitespace; default to 0 if empty |
||||
result=$(echo "${result}" | tr -d '[:space:]') |
||||
echo "${result:-0}" |
||||
} |
||||
|
||||
# Generate and send events to a relay |
||||
# Usage: generate_events <relay_ws_url> <count> |
||||
generate_events() { |
||||
local url=$1 |
||||
local count=$2 |
||||
|
||||
log_info "Generating $count events and sending to $url ..." |
||||
run_test "event-generator -count $count -relay '$url' -batch 50" 2>&1 | while IFS= read -r line; do |
||||
if [ "$VERBOSE" = "true" ]; then |
||||
echo " $line" |
||||
fi |
||||
done |
||||
|
||||
# Give the relay time to process |
||||
sleep 3 |
||||
} |
||||
|
||||
# Wait for a relay to be healthy (via docker compose health check) |
||||
wait_for_services() { |
||||
log_info "Checking service health..." |
||||
|
||||
local services=("strfry" "orly-relay-1" "test-runner") |
||||
for svc in "${services[@]}"; do |
||||
local status |
||||
status=$(docker compose ps --format '{{.Health}}' "$svc" 2>/dev/null || echo "unknown") |
||||
if [ "$status" = "healthy" ] || [ "$svc" = "test-runner" ]; then |
||||
log_info " $svc: ready" |
||||
else |
||||
log_warn " $svc: $status (may not be ready)" |
||||
fi |
||||
done |
||||
} |
||||
|
||||
# ============================================================ |
||||
# Phase 1: Seed strfry with events |
||||
# ============================================================ |
||||
phase1_seed_strfry() { |
||||
log_phase "1. SEED STRFRY - Generate $SEED_COUNT events" |
||||
|
||||
generate_events "$STRFRY_WS" "$SEED_COUNT" |
||||
|
||||
local count |
||||
count=$(count_events "$STRFRY_WS" '{"limit":10000}') |
||||
log_info "Strfry has $count events" |
||||
|
||||
# Replaceable events (kind 0, 3, 10000, 10001) get deduplicated per pubkey, |
||||
# so stored count is lower than sent count. With 3 test users and ~30% |
||||
# replaceable kinds, expect roughly 70% stored. |
||||
local min_expected=$((SEED_COUNT / 2)) |
||||
if [ "$count" -ge "$min_expected" ]; then |
||||
log_pass "Strfry seeded with $count events (sent $SEED_COUNT, some replaceable)" |
||||
else |
||||
log_fail "Strfry only has $count events (expected >= $min_expected from $SEED_COUNT sent)" |
||||
fi |
||||
} |
||||
|
||||
# ============================================================ |
||||
# Phase 2: Strfry pushes events to ORLY |
||||
# ============================================================ |
||||
phase2_strfry_push_to_orly() { |
||||
log_phase "2. STRFRY PUSH - Push events from strfry to ORLY" |
||||
|
||||
local orly_before |
||||
orly_before=$(count_events "$ORLY_WS" '{"limit":10000}') |
||||
log_info "ORLY has $orly_before events before sync" |
||||
|
||||
log_info "Running: strfry sync $ORLY_WS --dir up" |
||||
run_strfry "/app/strfry --config=/etc/strfry.conf sync $ORLY_WS --dir up" 2>&1 || true |
||||
|
||||
sleep 5 |
||||
|
||||
local orly_after |
||||
orly_after=$(count_events "$ORLY_WS" '{"limit":10000}') |
||||
log_info "ORLY has $orly_after events after sync (was $orly_before)" |
||||
|
||||
if [ "$orly_after" -gt "$orly_before" ]; then |
||||
local synced=$((orly_after - orly_before)) |
||||
log_pass "Pushed $synced events from strfry to ORLY" |
||||
else |
||||
log_fail "No events pushed to ORLY (still $orly_after)" |
||||
fi |
||||
} |
||||
|
||||
# ============================================================ |
||||
# Phase 3: Seed ORLY with new events |
||||
# ============================================================ |
||||
phase3_seed_orly() { |
||||
log_phase "3. SEED ORLY - Generate $EXTRA_COUNT new events on ORLY" |
||||
|
||||
local orly_before |
||||
orly_before=$(count_events "$ORLY_WS" '{"limit":10000}') |
||||
log_info "ORLY has $orly_before events before seeding" |
||||
|
||||
generate_events "$ORLY_WS" "$EXTRA_COUNT" |
||||
|
||||
local orly_after |
||||
orly_after=$(count_events "$ORLY_WS" '{"limit":10000}') |
||||
log_info "ORLY now has $orly_after events (was $orly_before)" |
||||
|
||||
if [ "$orly_after" -gt "$orly_before" ]; then |
||||
local added=$((orly_after - orly_before)) |
||||
log_pass "ORLY stored $added new events ($orly_after total)" |
||||
else |
||||
log_fail "ORLY count didn't increase (still $orly_after)" |
||||
fi |
||||
} |
||||
|
||||
# ============================================================ |
||||
# Phase 4: Strfry pulls new events from ORLY |
||||
# ============================================================ |
||||
phase4_strfry_pull_from_orly() { |
||||
log_phase "4. STRFRY PULL - Pull new events from ORLY to strfry" |
||||
|
||||
local strfry_before |
||||
strfry_before=$(count_events "$STRFRY_WS" '{"limit":10000}') |
||||
log_info "Strfry has $strfry_before events before sync" |
||||
|
||||
log_info "Running: strfry sync $ORLY_WS --dir down" |
||||
run_strfry "/app/strfry --config=/etc/strfry.conf sync $ORLY_WS --dir down" 2>&1 || true |
||||
|
||||
sleep 5 |
||||
|
||||
local strfry_after |
||||
strfry_after=$(count_events "$STRFRY_WS" '{"limit":10000}') |
||||
log_info "Strfry has $strfry_after events after sync (was $strfry_before)" |
||||
|
||||
if [ "$strfry_after" -gt "$strfry_before" ]; then |
||||
local synced=$((strfry_after - strfry_before)) |
||||
log_pass "Pulled $synced events from ORLY to strfry" |
||||
else |
||||
log_fail "No new events pulled to strfry (still $strfry_after)" |
||||
fi |
||||
} |
||||
|
||||
# ============================================================ |
||||
# Phase 5: Bidirectional sync |
||||
# ============================================================ |
||||
phase5_bidirectional() { |
||||
log_phase "5. BIDIRECTIONAL - Sync both directions" |
||||
|
||||
# Add unique events to both sides |
||||
log_info "Adding 50 events to strfry..." |
||||
generate_events "$STRFRY_WS" 50 |
||||
|
||||
log_info "Adding 50 events to ORLY..." |
||||
generate_events "$ORLY_WS" 50 |
||||
|
||||
local strfry_before orly_before |
||||
strfry_before=$(count_events "$STRFRY_WS" '{"limit":10000}') |
||||
orly_before=$(count_events "$ORLY_WS" '{"limit":10000}') |
||||
|
||||
log_info "Before bidirectional sync: strfry=$strfry_before, ORLY=$orly_before" |
||||
|
||||
log_info "Running: strfry sync $ORLY_WS --dir both" |
||||
run_strfry "/app/strfry --config=/etc/strfry.conf sync $ORLY_WS --dir both" 2>&1 || true |
||||
|
||||
sleep 5 |
||||
|
||||
local strfry_after orly_after |
||||
strfry_after=$(count_events "$STRFRY_WS" '{"limit":10000}') |
||||
orly_after=$(count_events "$ORLY_WS" '{"limit":10000}') |
||||
|
||||
log_info "After bidirectional sync: strfry=$strfry_after, ORLY=$orly_after" |
||||
|
||||
local diff=$((strfry_after - orly_after)) |
||||
if [ "${diff#-}" -le 50 ]; then |
||||
log_pass "Bidirectional sync achieved consistency (diff: $diff)" |
||||
else |
||||
log_fail "Event counts still differ significantly (diff: $diff)" |
||||
fi |
||||
} |
||||
|
||||
# ============================================================ |
||||
# Phase 6: Final verification |
||||
# ============================================================ |
||||
phase6_final_verification() { |
||||
log_phase "6. FINAL VERIFICATION" |
||||
|
||||
local strfry_total orly_total |
||||
strfry_total=$(count_events "$STRFRY_WS" '{"limit":10000}') |
||||
orly_total=$(count_events "$ORLY_WS" '{"limit":10000}') |
||||
|
||||
log_info "Final event counts:" |
||||
log_info " strfry: $strfry_total" |
||||
log_info " orly-relay-1: $orly_total" |
||||
|
||||
# Both should have a reasonable number of events |
||||
if [ "$strfry_total" -gt 0 ] && [ "$orly_total" -gt 0 ]; then |
||||
log_pass "Both relays have events (strfry=$strfry_total, ORLY=$orly_total)" |
||||
else |
||||
log_fail "One or both relays are empty" |
||||
fi |
||||
|
||||
# Check consistency |
||||
local diff=$((strfry_total - orly_total)) |
||||
if [ "${diff#-}" -le 50 ]; then |
||||
log_pass "Relays are consistent (diff: $diff)" |
||||
else |
||||
log_warn "Relays differ by $diff events" |
||||
fi |
||||
} |
||||
|
||||
# ============================================================ |
||||
# Main |
||||
# ============================================================ |
||||
main() { |
||||
echo "========================================" |
||||
echo "Negentropy (NIP-77) Interop Test Suite" |
||||
echo "strfry (client) <-> ORLY (server)" |
||||
echo "========================================" |
||||
echo "" |
||||
echo "Config:" |
||||
echo " Seed events: $SEED_COUNT" |
||||
echo " Extra events: $EXTRA_COUNT" |
||||
echo "" |
||||
|
||||
wait_for_services |
||||
|
||||
phase1_seed_strfry |
||||
phase2_strfry_push_to_orly |
||||
phase3_seed_orly |
||||
phase4_strfry_pull_from_orly |
||||
phase5_bidirectional |
||||
phase6_final_verification |
||||
|
||||
echo "" |
||||
echo "========================================" |
||||
echo "TEST SUMMARY" |
||||
echo "========================================" |
||||
echo -e "${GREEN}Passed: $PASSED${NC}" |
||||
echo -e "${RED}Failed: $FAILED${NC}" |
||||
echo "" |
||||
|
||||
if [ "$FAILED" -eq 0 ]; then |
||||
echo -e "${GREEN}All tests passed!${NC}" |
||||
exit 0 |
||||
else |
||||
echo -e "${RED}Some tests failed.${NC}" |
||||
exit 1 |
||||
fi |
||||
} |
||||
|
||||
case "${1:-}" in |
||||
--verbose|-v) |
||||
VERBOSE=true |
||||
main |
||||
;; |
||||
--help|-h) |
||||
echo "Usage: $0 [--verbose|-v] [--help|-h]" |
||||
echo "" |
||||
echo "Run from the tests/negentropy directory with containers up:" |
||||
echo " docker compose build" |
||||
echo " docker compose up -d" |
||||
echo " $0" |
||||
echo " docker compose down -v" |
||||
exit 0 |
||||
;; |
||||
*) |
||||
main |
||||
;; |
||||
esac |
||||
@ -0,0 +1,382 @@
@@ -0,0 +1,382 @@
|
||||
// event-generator generates properly signed Nostr events for negentropy testing.
|
||||
// Creates events of various kinds with realistic content for sync testing.
|
||||
// Sends events via a single WebSocket connection using gorilla/websocket.
|
||||
package main |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"flag" |
||||
"fmt" |
||||
"net/url" |
||||
"os" |
||||
"time" |
||||
|
||||
"github.com/gorilla/websocket" |
||||
|
||||
"git.mleku.dev/mleku/nostr/encoders/event" |
||||
"git.mleku.dev/mleku/nostr/encoders/hex" |
||||
"git.mleku.dev/mleku/nostr/encoders/kind" |
||||
"git.mleku.dev/mleku/nostr/encoders/tag" |
||||
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k" |
||||
) |
||||
|
||||
// Test key pairs (deterministic for reproducible tests)
|
||||
var testKeys = []struct { |
||||
Name string |
||||
PrivKey string |
||||
}{ |
||||
{ |
||||
Name: "alice", |
||||
PrivKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", |
||||
}, |
||||
{ |
||||
Name: "bob", |
||||
PrivKey: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", |
||||
}, |
||||
{ |
||||
Name: "carol", |
||||
PrivKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", |
||||
}, |
||||
} |
||||
|
||||
// Pre-create signers so we don't recreate them per event
|
||||
var signers []*p8k.Signer |
||||
|
||||
func init() { |
||||
signers = make([]*p8k.Signer, len(testKeys)) |
||||
for i, key := range testKeys { |
||||
s, err := p8k.New() |
||||
if err != nil { |
||||
fmt.Fprintf(os.Stderr, "Failed to create signer for %s: %v\n", key.Name, err) |
||||
os.Exit(1) |
||||
} |
||||
secretKey, err := hex.Dec(key.PrivKey) |
||||
if err != nil { |
||||
fmt.Fprintf(os.Stderr, "Failed to decode key for %s: %v\n", key.Name, err) |
||||
os.Exit(1) |
||||
} |
||||
if err := s.InitSec(secretKey); err != nil { |
||||
fmt.Fprintf(os.Stderr, "Failed to init signer for %s: %v\n", key.Name, err) |
||||
os.Exit(1) |
||||
} |
||||
signers[i] = s |
||||
} |
||||
} |
||||
|
||||
// EventKind represents a Nostr event kind with sample content
|
||||
type EventKind struct { |
||||
Kind *kind.K |
||||
Name string |
||||
Content func(author, index int) string |
||||
} |
||||
|
||||
var eventKinds = []EventKind{ |
||||
{ |
||||
Kind: kind.ProfileMetadata, |
||||
Name: "metadata", |
||||
Content: func(author, index int) string { |
||||
metadata := map[string]string{ |
||||
"name": fmt.Sprintf("TestUser%d_%d", author, index), |
||||
"about": fmt.Sprintf("Test user %d, event %d for negentropy testing", author, index), |
||||
"picture": fmt.Sprintf("https://example.com/avatar%d.png", index), |
||||
"nip05": fmt.Sprintf("user%d@example.com", index), |
||||
"displayName": fmt.Sprintf("Test Display %d", index), |
||||
} |
||||
b, _ := json.Marshal(metadata) |
||||
return string(b) |
||||
}, |
||||
}, |
||||
{ |
||||
Kind: kind.TextNote, |
||||
Name: "short_text_note", |
||||
Content: func(author, index int) string { |
||||
messages := []string{ |
||||
"Testing negentropy sync between relays!", |
||||
"This is event number %d in the test suite.", |
||||
"Nostr protocol testing for relay synchronization.", |
||||
"Event %d: checking if sync works correctly.", |
||||
"Negentropy is an efficient set reconciliation protocol.", |
||||
"Testing with kind 1 text notes.", |
||||
"Relay sync test message %d.", |
||||
"Making sure events propagate correctly between relays.", |
||||
"Test event for bidirectional sync testing.", |
||||
"NIP-77 negentropy implementation test.", |
||||
} |
||||
msg := messages[index%len(messages)] |
||||
if index%2 == 0 { |
||||
return fmt.Sprintf(msg, index) |
||||
} |
||||
return msg |
||||
}, |
||||
}, |
||||
{ |
||||
Kind: kind.FollowList, |
||||
Name: "contacts", |
||||
Content: func(author, index int) string { |
||||
return fmt.Sprintf("Contact list update %d for test user %d", index, author) |
||||
}, |
||||
}, |
||||
{ |
||||
Kind: kind.Reporting, |
||||
Name: "report", |
||||
Content: func(author, index int) string { |
||||
return fmt.Sprintf("Report content %d: testing moderation event sync", index) |
||||
}, |
||||
}, |
||||
{ |
||||
Kind: kind.MuteList, |
||||
Name: "mute_list", |
||||
Content: func(author, index int) string { |
||||
return fmt.Sprintf("Mute list update %d", index) |
||||
}, |
||||
}, |
||||
{ |
||||
Kind: kind.PinList, |
||||
Name: "pin_list", |
||||
Content: func(author, index int) string { |
||||
return fmt.Sprintf("Pinned events list %d", index) |
||||
}, |
||||
}, |
||||
{ |
||||
Kind: kind.LongFormContent, |
||||
Name: "long_form", |
||||
Content: func(author, index int) string { |
||||
return fmt.Sprintf("# Long Form Article %d\n\nThis is a test long-form article for kind 30023. Testing negentropy sync with larger content payloads. Article number %d written by test author %d.", index, index, author) |
||||
}, |
||||
}, |
||||
{ |
||||
Kind: kind.ApplicationSpecificData, |
||||
Name: "application_specific", |
||||
Content: func(author, index int) string { |
||||
appData := map[string]interface{}{ |
||||
"app": "test-suite", |
||||
"version": "1.0.0", |
||||
"test_id": index, |
||||
"data": map[string]string{ |
||||
"key1": fmt.Sprintf("value%d", index), |
||||
"key2": fmt.Sprintf("data%d", index*2), |
||||
}, |
||||
} |
||||
b, _ := json.Marshal(appData) |
||||
return string(b) |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
type Config struct { |
||||
Count int |
||||
OutputFile string |
||||
RelayURL string |
||||
BatchSize int |
||||
} |
||||
|
||||
func main() { |
||||
var cfg Config |
||||
flag.IntVar(&cfg.Count, "count", 1000, "Number of events to generate") |
||||
flag.StringVar(&cfg.OutputFile, "output", "", "Output file (JSON array)") |
||||
flag.StringVar(&cfg.RelayURL, "relay", "", "Send directly to relay WebSocket URL") |
||||
flag.IntVar(&cfg.BatchSize, "batch", 100, "Batch size for sending") |
||||
flag.Parse() |
||||
|
||||
// Generate events
|
||||
fmt.Fprintf(os.Stderr, "Generating %d events...\n", cfg.Count) |
||||
events := generateEvents(cfg.Count) |
||||
|
||||
// Handle output
|
||||
if cfg.RelayURL != "" { |
||||
if err := sendToRelay(events, cfg.RelayURL, cfg.BatchSize); err != nil { |
||||
fmt.Fprintf(os.Stderr, "Error sending to relay: %v\n", err) |
||||
os.Exit(1) |
||||
} |
||||
fmt.Fprintf(os.Stderr, "Sent %d events to %s\n", len(events), cfg.RelayURL) |
||||
} else if cfg.OutputFile != "" { |
||||
if err := writeToFile(events, cfg.OutputFile); err != nil { |
||||
fmt.Fprintf(os.Stderr, "Error writing to file: %v\n", err) |
||||
os.Exit(1) |
||||
} |
||||
fmt.Fprintf(os.Stderr, "Wrote %d events to %s\n", len(events), cfg.OutputFile) |
||||
} else { |
||||
// Print to stdout as JSON array
|
||||
output := map[string]interface{}{ |
||||
"events": events, |
||||
"count": len(events), |
||||
} |
||||
jsonBytes, _ := json.MarshalIndent(output, "", " ") |
||||
fmt.Println(string(jsonBytes)) |
||||
} |
||||
} |
||||
|
||||
func generateEvents(count int) []*event.E { |
||||
events := make([]*event.E, 0, count) |
||||
baseTime := time.Now().Add(-24 * time.Hour) |
||||
|
||||
for i := 0; i < count; i++ { |
||||
authorIdx := i % len(testKeys) |
||||
|
||||
kindIdx := getWeightedKindIndex(i) |
||||
kindDef := eventKinds[kindIdx] |
||||
|
||||
createdAt := baseTime.Add(time.Duration(i) * time.Second).Unix() |
||||
|
||||
ev, err := createEvent(authorIdx, kindDef.Kind, kindDef.Content(authorIdx, i), createdAt, i) |
||||
if err != nil { |
||||
fmt.Fprintf(os.Stderr, "Failed to create event %d: %v\n", i, err) |
||||
continue |
||||
} |
||||
events = append(events, ev) |
||||
} |
||||
|
||||
return events |
||||
} |
||||
|
||||
// kindPattern distributes event kinds in a repeating 20-event pattern.
|
||||
// This ensures variety even for small event counts while maintaining
|
||||
// approximate target proportions over larger samples.
|
||||
//
|
||||
// metadata (kind 0): 2/20 = 10%
|
||||
// text notes (kind 1): 12/20 = 60%
|
||||
// contacts (kind 3): 2/20 = 10%
|
||||
// reporting (kind 1984): 1/20 = 5%
|
||||
// mute list (kind 10000): 1/20 = 5%
|
||||
// pin list (kind 10001): 1/20 = 5%
|
||||
// long form (kind 30023): 1/20 = 5%
|
||||
var kindPattern = []int{ |
||||
1, 0, 1, 2, 1, 1, 3, 1, 1, 4, |
||||
1, 5, 1, 6, 1, 1, 0, 1, 2, 1, |
||||
} |
||||
|
||||
func getWeightedKindIndex(seed int) int { |
||||
return kindPattern[seed%len(kindPattern)] |
||||
} |
||||
|
||||
func createEvent(authorIdx int, kindDef *kind.K, content string, createdAt int64, index int) (*event.E, error) { |
||||
ev := event.New() |
||||
ev.CreatedAt = createdAt |
||||
ev.Kind = kindDef.K |
||||
ev.Content = []byte(content) |
||||
ev.Tags = tag.NewS() |
||||
|
||||
signer := signers[authorIdx] |
||||
|
||||
// Add tags based on kind
|
||||
switch kindDef.K { |
||||
case kind.FollowList.K: |
||||
// Add p-tags with hex pubkeys of other test users
|
||||
for j := 0; j < 3; j++ { |
||||
targetIdx := (index + j + 1) % len(testKeys) |
||||
targetPub := signers[targetIdx].Pub() |
||||
targetHex := hex.Enc(targetPub) |
||||
ev.Tags.Append(tag.NewFromBytesSlice([]byte("p"), []byte(targetHex))) |
||||
} |
||||
|
||||
case kind.MuteList.K, kind.PinList.K: |
||||
// Replaceable list events need a d-tag
|
||||
ev.Tags.Append(tag.NewFromBytesSlice([]byte("d"), []byte(""))) |
||||
|
||||
case kind.LongFormContent.K: |
||||
// Addressable events MUST have a d-tag
|
||||
ev.Tags.Append(tag.NewFromBytesSlice([]byte("d"), []byte(fmt.Sprintf("article-%d", index)))) |
||||
ev.Tags.Append(tag.NewFromBytesSlice([]byte("title"), []byte(fmt.Sprintf("Article %d", index)))) |
||||
ev.Tags.Append(tag.NewFromBytesSlice([]byte("published_at"), []byte(fmt.Sprintf("%d", createdAt)))) |
||||
|
||||
case kind.ApplicationSpecificData.K: |
||||
// Addressable events MUST have a d-tag
|
||||
ev.Tags.Append(tag.NewFromBytesSlice([]byte("d"), []byte(fmt.Sprintf("test-data-%d", index)))) |
||||
|
||||
case kind.Reporting.K: |
||||
targetIdx := (index + 1) % len(testKeys) |
||||
targetPub := signers[targetIdx].Pub() |
||||
targetHex := hex.Enc(targetPub) |
||||
ev.Tags.Append(tag.NewFromBytesSlice([]byte("p"), []byte(targetHex), []byte("other"), []byte("spam"))) |
||||
} |
||||
|
||||
if err := ev.Sign(signer); err != nil { |
||||
return nil, fmt.Errorf("failed to sign event: %w", err) |
||||
} |
||||
|
||||
return ev, nil |
||||
} |
||||
|
||||
// sendToRelay sends events to a relay via a single WebSocket connection.
|
||||
func sendToRelay(events []*event.E, relayURL string, batchSize int) error { |
||||
u, err := url.Parse(relayURL) |
||||
if err != nil { |
||||
return fmt.Errorf("invalid relay URL: %w", err) |
||||
} |
||||
|
||||
fmt.Fprintf(os.Stderr, "Connecting to %s...\n", u.String()) |
||||
|
||||
dialer := websocket.Dialer{ |
||||
HandshakeTimeout: 10 * time.Second, |
||||
} |
||||
conn, _, err := dialer.Dial(u.String(), nil) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to connect to relay: %w", err) |
||||
} |
||||
defer conn.Close() |
||||
|
||||
sent := 0 |
||||
rejected := 0 |
||||
|
||||
for i, ev := range events { |
||||
eventJSON, err := ev.MarshalJSON() |
||||
if err != nil { |
||||
fmt.Fprintf(os.Stderr, "Warning: failed to marshal event %d: %v\n", i, err) |
||||
continue |
||||
} |
||||
|
||||
msg := fmt.Sprintf(`["EVENT",%s]`, string(eventJSON)) |
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte(msg)); err != nil { |
||||
return fmt.Errorf("failed to send event %d: %w", i, err) |
||||
} |
||||
|
||||
// Read the OK response
|
||||
conn.SetReadDeadline(time.Now().Add(5 * time.Second)) |
||||
_, response, err := conn.ReadMessage() |
||||
if err != nil { |
||||
fmt.Fprintf(os.Stderr, "Warning: no response for event %d: %v\n", i, err) |
||||
} else { |
||||
// Check if the response indicates success
|
||||
respStr := string(response) |
||||
if len(respStr) > 10 { |
||||
// Parse ["OK","id",true/false,"message"]
|
||||
var okResp []interface{} |
||||
if json.Unmarshal(response, &okResp) == nil && len(okResp) >= 3 { |
||||
if accepted, ok := okResp[2].(bool); ok && accepted { |
||||
sent++ |
||||
} else { |
||||
rejected++ |
||||
if rejected <= 5 { |
||||
fmt.Fprintf(os.Stderr, "Rejected: %s\n", respStr) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Log progress periodically
|
||||
if (i+1)%batchSize == 0 || i == len(events)-1 { |
||||
fmt.Fprintf(os.Stderr, "Progress: %d/%d sent, %d rejected\n", sent, i+1, rejected) |
||||
} |
||||
} |
||||
|
||||
fmt.Fprintf(os.Stderr, "Total: %d sent, %d rejected out of %d\n", sent, rejected, len(events)) |
||||
return nil |
||||
} |
||||
|
||||
func writeToFile(events []*event.E, filename string) error { |
||||
f, err := os.Create(filename) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
defer f.Close() |
||||
|
||||
output := map[string]interface{}{ |
||||
"events": events, |
||||
"count": len(events), |
||||
} |
||||
|
||||
encoder := json.NewEncoder(f) |
||||
encoder.SetIndent("", " ") |
||||
return encoder.Encode(output) |
||||
} |
||||
Loading…
Reference in new issue