| 1 | diff --git a/internal/storage/maintenance.go b/internal/storage/maintenance.go |
| 2 | index 325e8c0..324405c 100644 |
| 3 | --- a/internal/storage/maintenance.go |
| 4 | +++ b/internal/storage/maintenance.go |
| 5 | @@ -50,6 +50,7 @@ func (s *Store) Vanish(ctx context.Context, pubkey string, until int64) error { |
| 6 | {"DELETE FROM events WHERE pubkey=? AND created_at<=?", []any{pubkey, until}}, |
| 7 | {"DELETE FROM events WHERE kind=1059 AND id IN(SELECT event_id FROM tags WHERE name='p' AND value=?)", []any{pubkey}}, |
| 8 | {"DELETE FROM list_history WHERE owner=?", []any{pubkey}}, |
| 9 | + {"DELETE FROM wiki_revisions WHERE author=?", []any{pubkey}}, |
| 10 | } |
| 11 | for _, q := range statements { |
| 12 | if _, err := tx.ExecContext(ctx, q.sql, q.args...); err != nil { |
| 13 | @@ -93,7 +94,10 @@ func (s *Store) EraseAuthor(ctx context.Context, pubkey string) (int64, error) { |
| 14 | if err != nil { |
| 15 | return err |
| 16 | } |
| 17 | - _, err = tx.ExecContext(ctx, "DELETE FROM list_history WHERE owner=?", pubkey) |
| 18 | + if _, err = tx.ExecContext(ctx, "DELETE FROM list_history WHERE owner=?", pubkey); err != nil { |
| 19 | + return err |
| 20 | + } |
| 21 | + _, err = tx.ExecContext(ctx, "DELETE FROM wiki_revisions WHERE author=?", pubkey) |
| 22 | return err |
| 23 | }) |
| 24 | return n, err |
| 25 | diff --git a/internal/storage/save.go b/internal/storage/save.go |
| 26 | index 9f8835e..8543a0c 100644 |
| 27 | --- a/internal/storage/save.go |
| 28 | +++ b/internal/storage/save.go |
| 29 | @@ -159,6 +159,14 @@ func replace(ctx context.Context, tx *sql.Tx, e event.Event, now int64) error { |
| 30 | return fmt.Errorf("archive replaced list: %w", err) |
| 31 | } |
| 32 | } |
| 33 | + if e.Kind == kindWikiArticle { |
| 34 | + // A wiki page keeps its history: the version being replaced moves |
| 35 | + // to wiki_revisions with the id of the event that replaced it. |
| 36 | + _, 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) |
| 37 | + if err != nil { |
| 38 | + return fmt.Errorf("archive replaced wiki revision: %w", err) |
| 39 | + } |
| 40 | + } |
| 41 | _, err = tx.ExecContext(ctx, "DELETE FROM events WHERE pubkey=? AND kind=? AND d=?", e.PubKey, e.Kind, d) |
| 42 | if err != nil { |
| 43 | return fmt.Errorf("replace event: %w", err) |
| 44 | @@ -182,6 +190,11 @@ func applyDeletion(ctx context.Context, tx *sql.Tx, e event.Event, tag []string) |
| 45 | 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 { |
| 46 | return fmt.Errorf("delete list history: %w", err) |
| 47 | } |
| 48 | + if kind == kindWikiArticle { |
| 49 | + if _, err := tx.ExecContext(ctx, "DELETE FROM wiki_revisions WHERE author=? AND d=? AND created_at<=?", e.PubKey, parts[2], e.CreatedAt); err != nil { |
| 50 | + return fmt.Errorf("delete wiki revisions: %w", err) |
| 51 | + } |
| 52 | + } |
| 53 | } else { |
| 54 | 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 { |
| 55 | return fmt.Errorf("delete event: %w", err) |
| 56 | @@ -189,6 +202,9 @@ func applyDeletion(ctx context.Context, tx *sql.Tx, e event.Event, tag []string) |
| 57 | if _, err := tx.ExecContext(ctx, "DELETE FROM list_history WHERE owner=? AND event_id=?", e.PubKey, tag[1]); err != nil { |
| 58 | return fmt.Errorf("delete list version: %w", err) |
| 59 | } |
| 60 | + if _, err := tx.ExecContext(ctx, "DELETE FROM wiki_revisions WHERE author=? AND event_id=?", e.PubKey, tag[1]); err != nil { |
| 61 | + return fmt.Errorf("delete wiki revision: %w", err) |
| 62 | + } |
| 63 | } |
| 64 | _, 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) |
| 65 | if err != nil { |
| 66 | @@ -243,6 +259,10 @@ func indexed(kind int, mode string) bool { |
| 67 | |
| 68 | func isListKind(kind int) bool { return kind == 3 || kind == 10002 || kind == 10003 || kind == 30003 } |
| 69 | |
| 70 | +// kindWikiArticle is the NIP-54 article kind, whose replaced versions are |
| 71 | +// kept as revisions. |
| 72 | +const kindWikiArticle = 30818 |
| 73 | + |
| 74 | func AddIntents(ctx context.Context, tx *sql.Tx, intents []Intent, now int64) error { |
| 75 | for _, intent := range intents { |
| 76 | key := sha256.Sum256([]byte(intent.Kind + "\x00" + intent.EventID + "\x00" + intent.Target)) |
| 77 | diff --git a/internal/storage/schema.go b/internal/storage/schema.go |
| 78 | index 75e4e0d..293c9c4 100644 |
| 79 | --- a/internal/storage/schema.go |
| 80 | +++ b/internal/storage/schema.go |
| 81 | @@ -27,6 +27,9 @@ CREATE TABLE IF NOT EXISTS marmot_principals(event_id TEXT PRIMARY KEY REFERENCE |
| 82 | CREATE INDEX IF NOT EXISTS marmot_principal_pk ON marmot_principals(pubkey); |
| 83 | CREATE TABLE IF NOT EXISTS list_history(owner TEXT NOT NULL,kind INTEGER NOT NULL,d TEXT NOT NULL DEFAULT '',event_id TEXT NOT NULL,created_at INTEGER NOT NULL,saved_at INTEGER NOT NULL,expires INTEGER NOT NULL DEFAULT 0,raw TEXT NOT NULL,PRIMARY KEY(owner,kind,d,event_id)); |
| 84 | CREATE INDEX IF NOT EXISTS list_history_owner ON list_history(owner,saved_at DESC); |
| 85 | +CREATE TABLE IF NOT EXISTS wiki_revisions(event_id TEXT PRIMARY KEY,author TEXT NOT NULL,d TEXT NOT NULL,created_at INTEGER NOT NULL,raw TEXT NOT NULL,superseded_by TEXT NOT NULL); |
| 86 | +CREATE INDEX IF NOT EXISTS wiki_revisions_page ON wiki_revisions(d,created_at DESC); |
| 87 | +CREATE INDEX IF NOT EXISTS wiki_revisions_author ON wiki_revisions(author,d,created_at DESC); |
| 88 | CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT NOT NULL); |
| 89 | CREATE TABLE IF NOT EXISTS work_intents ( |
| 90 | id TEXT PRIMARY KEY,kind TEXT NOT NULL,event_id TEXT NOT NULL,target TEXT NOT NULL,payload TEXT NOT NULL, |
| 91 | diff --git a/internal/storage/wiki.go b/internal/storage/wiki.go |
| 92 | new file mode 100644 |
| 93 | index 0000000..b217746 |
| 94 | --- /dev/null |
| 95 | +++ b/internal/storage/wiki.go |
| 96 | @@ -0,0 +1,66 @@ |
| 97 | +package storage |
| 98 | + |
| 99 | +// Wiki revisions. A kind 30818 article is addressable, so a newer version |
| 100 | +// from the same author replaces the older one in events. The replaced |
| 101 | +// event is kept here with the id of the event that replaced it, until the |
| 102 | +// author deletes it with a kind 5 naming the revision or the page. |
| 103 | + |
| 104 | +import ( |
| 105 | + "context" |
| 106 | + "database/sql" |
| 107 | + "encoding/json" |
| 108 | + "errors" |
| 109 | + "fmt" |
| 110 | + |
| 111 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 112 | +) |
| 113 | + |
| 114 | +// WikiRevision is one archived version of a wiki page. |
| 115 | +type WikiRevision struct { |
| 116 | + ID string |
| 117 | + Author string |
| 118 | + D string |
| 119 | + CreatedAt int64 |
| 120 | + SupersededBy string |
| 121 | + Event event.Event |
| 122 | +} |
| 123 | + |
| 124 | +// WikiRevisions lists archived revisions newest first. An empty author or |
| 125 | +// d matches every author or page. |
| 126 | +func (s *Store) WikiRevisions(ctx context.Context, author, d string) ([]WikiRevision, error) { |
| 127 | + rows, err := s.db.QueryContext(ctx, "SELECT event_id,author,d,created_at,superseded_by,raw FROM wiki_revisions WHERE (?='' OR author=?) AND (?='' OR d=?) ORDER BY created_at DESC,event_id", author, author, d, d) |
| 128 | + if err != nil { |
| 129 | + return nil, fmt.Errorf("query wiki revisions: %w", err) |
| 130 | + } |
| 131 | + defer rows.Close() |
| 132 | + result := []WikiRevision{} |
| 133 | + for rows.Next() { |
| 134 | + var row WikiRevision |
| 135 | + var raw string |
| 136 | + if err := rows.Scan(&row.ID, &row.Author, &row.D, &row.CreatedAt, &row.SupersededBy, &raw); err != nil { |
| 137 | + return nil, err |
| 138 | + } |
| 139 | + if err := json.Unmarshal([]byte(raw), &row.Event); err != nil { |
| 140 | + return nil, fmt.Errorf("decode wiki revision %s: %w", row.ID, err) |
| 141 | + } |
| 142 | + result = append(result, row) |
| 143 | + } |
| 144 | + return result, rows.Err() |
| 145 | +} |
| 146 | + |
| 147 | +// WikiRevision reads one archived revision by event id. |
| 148 | +func (s *Store) WikiRevision(ctx context.Context, id string) (WikiRevision, bool, error) { |
| 149 | + var row WikiRevision |
| 150 | + var raw string |
| 151 | + err := s.db.QueryRowContext(ctx, "SELECT event_id,author,d,created_at,superseded_by,raw FROM wiki_revisions WHERE event_id=?", id).Scan(&row.ID, &row.Author, &row.D, &row.CreatedAt, &row.SupersededBy, &raw) |
| 152 | + if errors.Is(err, sql.ErrNoRows) { |
| 153 | + return row, false, nil |
| 154 | + } |
| 155 | + if err != nil { |
| 156 | + return row, false, fmt.Errorf("read wiki revision: %w", err) |
| 157 | + } |
| 158 | + if err := json.Unmarshal([]byte(raw), &row.Event); err != nil { |
| 159 | + return row, false, fmt.Errorf("decode wiki revision %s: %w", id, err) |
| 160 | + } |
| 161 | + return row, true, nil |
| 162 | +} |