clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/tinyagent
tinyrelay / internal / storage / save.go
save.go 11.8K
1package storage23import (4 "context"5 "crypto/sha256"6 "database/sql"7 "encoding/hex"8 "encoding/json"9 "fmt"10 "strconv"11 "strings"1213 "github.com/FelineStateMachine/tinyrelay/internal/event"14)1516// Intent describes durable work to insert with an event save. The tuple of17// Kind, EventID and Target determines its stable ID; Payload is retained for18// the worker handling that kind.19type Intent struct {20 Kind string `json:"kind"`21 EventID string `json:"event_id"`22 Target string `json:"target"`23 Payload string `json:"payload"`24}2526// SaveOptions controls event persistence. Now is the Unix time used for27// history and intent timestamps. SearchMode is empty for the default indexed28// kinds, "full" for all public content and "off" to skip content indexing.29// For stored events, Intents and BeforeCommit run in the event transaction.30// Ephemeral events return before those hooks. A BeforeCommit error rolls back31// the transaction owned by Save; SaveTx returns it to the transaction's owner.32type SaveOptions struct {33 Now int6434 SearchMode string35 Intents []Intent36 BeforeCommit func(context.Context, *sql.Tx) error37}3839// SaveResult reports the event sequence. Ephemeral events return without a40// database row and set Ephemeral.41type SaveResult struct {42 Sequence int6443 Ephemeral bool44}4546// Save commits an event, its indexes and the supplied transaction hooks47// together. Callers validate the event before saving. Duplicate, replaced,48// deleted and vanished events return a Rejection. Ephemeral events return an49// Ephemeral result without writing rows or running the hooks.50func (s *Store) Save(ctx context.Context, e event.Event, opts SaveOptions) (SaveResult, error) {51 var result SaveResult52 err := s.WithTx(ctx, func(tx *sql.Tx) error {53 var err error54 result, err = SaveTx(ctx, tx, e, opts)55 return err56 })57 return result, err58}5960// SaveTx composes event persistence with membership or other tenant changes.61// The caller owns commit/rollback; no success may be acknowledged before commit.62func SaveTx(ctx context.Context, tx *sql.Tx, e event.Event, opts SaveOptions) (SaveResult, error) {63 if event.IsEphemeral(e.Kind) {64 return SaveResult{Ephemeral: true}, nil65 }66 var result SaveResult67 err := func() error {68 if err := checkSave(ctx, tx, e); err != nil {69 return err70 }71 if err := replaceOrDelete(ctx, tx, e, opts.Now); err != nil {72 return err73 }74 seq, err := insertEvent(ctx, tx, e, opts.SearchMode)75 if err != nil {76 return err77 }78 result.Sequence = seq79 if err := AddIntents(ctx, tx, opts.Intents, opts.Now); err != nil {80 return err81 }82 if opts.BeforeCommit != nil {83 return opts.BeforeCommit(ctx, tx)84 }85 return nil86 }()87 return result, err88}8990func checkSave(ctx context.Context, tx *sql.Tx, e event.Event) error {91 checks := []struct {92 query string93 args []any94 rejection Rejection95 }{96 {"SELECT 1 FROM events WHERE id=?", []any{e.ID}, ErrDuplicate},97 {"SELECT 1 FROM vanished WHERE pubkey=? AND until>=?", []any{e.PubKey, e.CreatedAt}, ErrVanished},98 }99 for _, check := range checks {100 has, err := exists(ctx, tx, check.query, check.args...)101 if err != nil {102 return fmt.Errorf("check event persistence: %w", err)103 }104 if has {105 return check.rejection106 }107 }108 return checkDeleted(ctx, tx, e)109}110111func checkDeleted(ctx context.Context, tx *sql.Tx, e event.Event) error {112 deleters := []string{e.PubKey}113 if e.Kind == 1059 {114 deleters = append(deleters, event.TagValues(e, "p")...)115 }116 list, err := json.Marshal(deleters)117 if err != nil {118 return fmt.Errorf("encode deletion principals: %w", err)119 }120 has, err := exists(ctx, tx, "SELECT 1 FROM deletions WHERE target_type='e' AND target=? AND author IN (SELECT value FROM json_each(?)) LIMIT 1", e.ID, string(list))121 if err != nil {122 return fmt.Errorf("check deletion tombstone: %w", err)123 }124 if has {125 return ErrDeleted126 }127 if !event.IsAddressable(e.Kind) {128 return nil129 }130 address := strconv.Itoa(e.Kind) + ":" + e.PubKey + ":" + event.Tag(e, "d")131 has, err = exists(ctx, tx, "SELECT 1 FROM deletions WHERE author=? AND target_type='a' AND target=? AND until>=?", e.PubKey, address, e.CreatedAt)132 if err != nil {133 return fmt.Errorf("check address tombstone: %w", err)134 }135 if has {136 return ErrDeleted137 }138 return nil139}140141func replaceOrDelete(ctx context.Context, tx *sql.Tx, e event.Event, now int64) error {142 if event.IsReplaceable(e.Kind) || event.IsAddressable(e.Kind) {143 return replace(ctx, tx, e, now)144 }145 if e.Kind != 5 {146 return nil147 }148 for _, tag := range e.Tags {149 if len(tag) < 2 || (tag[0] != "e" && tag[0] != "a") {150 continue151 }152 if err := applyDeletion(ctx, tx, e, tag); err != nil {153 return err154 }155 }156 return nil157}158159func replace(ctx context.Context, tx *sql.Tx, e event.Event, now int64) error {160 d := ""161 if event.IsAddressable(e.Kind) {162 d = event.Tag(e, "d")163 }164 has, err := exists(ctx, tx, "SELECT 1 FROM events WHERE pubkey=? AND kind=? AND d=? AND (created_at>? OR (created_at=? AND id<?)) LIMIT 1", e.PubKey, e.Kind, d, e.CreatedAt, e.CreatedAt, e.ID)165 if err != nil {166 return fmt.Errorf("check replaceable event: %w", err)167 }168 if has {169 return ErrReplaced170 }171 if isListKind(e.Kind) {172 _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO list_history(owner,kind,d,event_id,created_at,saved_at,expires,raw) SELECT pubkey,kind,d,id,created_at,?,expires,raw FROM events WHERE pubkey=? AND kind=? AND d=?", now, e.PubKey, e.Kind, d)173 if err != nil {174 return fmt.Errorf("archive replaced list: %w", err)175 }176 }177 if e.Kind == kindWikiArticle {178 // A wiki page keeps its history: the version being replaced moves179 // to wiki_revisions with the id of the event that replaced it.180 _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO wiki_revisions(event_id,author,d,created_at,raw,superseded_by) SELECT id,pubkey,d,created_at,raw,? FROM events WHERE pubkey=? AND kind=? AND d=?", e.ID, e.PubKey, e.Kind, d)181 if err != nil {182 return fmt.Errorf("archive replaced wiki revision: %w", err)183 }184 }185 if e.Kind == event.KIND_AGENT_GRANT {186 if _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO agent_grant_revisions(event_id,author,d,created_at,raw,superseded_by) SELECT id,pubkey,d,created_at,raw,? FROM events WHERE pubkey=? AND kind=? AND d=?", e.ID, e.PubKey, e.Kind, d); err != nil {187 return fmt.Errorf("archive replaced agent grant: %w", err)188 }189 }190 _, err = tx.ExecContext(ctx, "DELETE FROM events WHERE pubkey=? AND kind=? AND d=?", e.PubKey, e.Kind, d)191 if err != nil {192 return fmt.Errorf("replace event: %w", err)193 }194 return nil195}196197func applyDeletion(ctx context.Context, tx *sql.Tx, e event.Event, tag []string) error {198 if tag[0] == "a" {199 parts := strings.SplitN(tag[1], ":", 3)200 if len(parts) != 3 || parts[1] != e.PubKey {201 return nil202 }203 kind, err := strconv.Atoi(parts[0])204 if err != nil {205 return nil206 }207 if kind == event.KIND_AGENT_GRANT {208 if err := archiveAgentGrantDeletion(ctx, tx, e, parts[1], parts[2]); err != nil {209 return err210 }211 }212 if _, err := tx.ExecContext(ctx, "DELETE FROM events WHERE kind=? AND pubkey=? AND d=? AND created_at<=?", kind, e.PubKey, parts[2], e.CreatedAt); err != nil {213 return fmt.Errorf("delete address: %w", err)214 }215 if _, err := tx.ExecContext(ctx, "DELETE FROM list_history WHERE kind=? AND owner=? AND d=? AND created_at<=?", kind, e.PubKey, parts[2], e.CreatedAt); err != nil {216 return fmt.Errorf("delete list history: %w", err)217 }218 if kind == kindWikiArticle {219 if _, err := tx.ExecContext(ctx, "DELETE FROM wiki_revisions WHERE author=? AND d=? AND created_at<=?", e.PubKey, parts[2], e.CreatedAt); err != nil {220 return fmt.Errorf("delete wiki revisions: %w", err)221 }222 }223 } else {224 if err := archiveAgentGrantEventDeletion(ctx, tx, e, tag[1]); err != nil {225 return err226 }227 if _, err := tx.ExecContext(ctx, "DELETE FROM events WHERE id=? AND (pubkey=? OR (kind=1059 AND EXISTS(SELECT 1 FROM tags WHERE event_id=events.id AND name='p' AND value=?)))", tag[1], e.PubKey, e.PubKey); err != nil {228 return fmt.Errorf("delete event: %w", err)229 }230 if _, err := tx.ExecContext(ctx, "DELETE FROM list_history WHERE owner=? AND event_id=?", e.PubKey, tag[1]); err != nil {231 return fmt.Errorf("delete list version: %w", err)232 }233 if _, err := tx.ExecContext(ctx, "DELETE FROM wiki_revisions WHERE author=? AND event_id=?", e.PubKey, tag[1]); err != nil {234 return fmt.Errorf("delete wiki revision: %w", err)235 }236 }237 _, err := tx.ExecContext(ctx, "INSERT INTO deletions(author,target_type,target,until) VALUES(?,?,?,?) ON CONFLICT(author,target_type,target) DO UPDATE SET until=max(until,excluded.until)", e.PubKey, tag[0], tag[1], e.CreatedAt)238 if err != nil {239 return fmt.Errorf("persist deletion tombstone: %w", err)240 }241 return nil242}243244func archiveAgentGrantDeletion(ctx context.Context, tx *sql.Tx, deletion event.Event, owner, d string) error {245 _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO agent_grant_revisions(event_id,author,d,created_at,raw,superseded_by) SELECT id,pubkey,d,created_at,raw,? FROM events WHERE pubkey=? AND kind=? AND d=? AND created_at<=?", deletion.ID, owner, event.KIND_AGENT_GRANT, d, deletion.CreatedAt)246 if err != nil {247 return fmt.Errorf("archive deleted agent grant: %w", err)248 }249 return nil250}251252func archiveAgentGrantEventDeletion(ctx context.Context, tx *sql.Tx, deletion event.Event, target string) error {253 _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO agent_grant_revisions(event_id,author,d,created_at,raw,superseded_by) SELECT id,pubkey,d,created_at,raw,? FROM events WHERE id=? AND kind=? AND pubkey=?", deletion.ID, target, event.KIND_AGENT_GRANT, deletion.PubKey)254 if err != nil {255 return fmt.Errorf("archive deleted agent grant: %w", err)256 }257 return nil258}259260func insertEvent(ctx context.Context, tx *sql.Tx, e event.Event, mode string) (int64, error) {261 raw, err := event.Canonical(e)262 if err != nil {263 return 0, fmt.Errorf("encode event: %w", err)264 }265 d := ""266 if event.IsAddressable(e.Kind) {267 d = event.Tag(e, "d")268 }269 var seq int64270 err = tx.QueryRowContext(ctx, "INSERT INTO events(id,pubkey,created_at,kind,d,expires,raw) VALUES(?,?,?,?,?,?,?) RETURNING seq", e.ID, e.PubKey, e.CreatedAt, e.Kind, d, event.Expiration(e), string(raw)).Scan(&seq)271 if err != nil {272 return 0, fmt.Errorf("insert event: %w", err)273 }274 for _, tag := range e.Tags {275 if len(tag) < 2 || len(tag[0]) != 1 {276 continue277 }278 if _, err := tx.ExecContext(ctx, "INSERT INTO tags(event_id,name,value) VALUES(?,?,?)", e.ID, tag[0], tag[1]); err != nil {279 return 0, fmt.Errorf("index event tag: %w", err)280 }281 }282 if e.Content != "" && indexed(e.Kind, mode) {283 if _, err := tx.ExecContext(ctx, "INSERT INTO search(rowid,content) VALUES(?,?)", seq, e.Content); err != nil {284 return 0, fmt.Errorf("index event content: %w", err)285 }286 }287 return seq, nil288}289290func indexed(kind int, mode string) bool {291 if event.IsPrivate(kind) || mode == "off" {292 return false293 }294 if mode == "full" {295 return true296 }297 switch kind {298 case 0, 1, 11, 1111, 9802, 30023, 30024, 30818:299 return true300 }301 return false302}303304func isListKind(kind int) bool { return kind == 3 || kind == 10002 || kind == 10003 || kind == 30003 }305306// kindWikiArticle is the NIP-54 article kind, whose replaced versions are307// kept as revisions.308const kindWikiArticle = 30818309310func AddIntents(ctx context.Context, tx *sql.Tx, intents []Intent, now int64) error {311 for _, intent := range intents {312 _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", IntentID(intent.Kind, intent.EventID, intent.Target), intent.Kind, intent.EventID, intent.Target, intent.Payload, now, now, now)313 if err != nil {314 return fmt.Errorf("enqueue %s intent: %w", intent.Kind, err)315 }316 }317 return nil318}319320// IntentID returns the stable identifier shared by transactional and queued321// durable work. Payload changes do not create a second intent.322func IntentID(kind, eventID, target string) string {323 key := sha256.Sum256([]byte(kind + "\x00" + eventID + "\x00" + target))324 return hex.EncodeToString(key[:])325}