Commit 18224b8fd1

18224b8fd1f5347553d21ee07ee7759aa912ce02

parent: 7df6e615d1

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-12 00:34 UTC

store: snippets and snippet_files (migration 0053)

Ref #195
internal/store/migrations/0053_snippets.down.sql added +2
@@ -0,0 +1,2 @@
1DROP TABLE snippet_files;
2DROP TABLE snippets;
internal/store/migrations/0053_snippets.up.sql added +20
@@ -0,0 +1,20 @@
1-- Snippets: named text files a user owns and shares by URL, outside any
2-- repository. public_id is the opaque id in URLs and commands.
3CREATE TABLE snippets (
4 id INTEGER PRIMARY KEY,
5 public_id TEXT NOT NULL UNIQUE,
6 owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
7 description TEXT NOT NULL DEFAULT '',
8 visibility TEXT NOT NULL CHECK (visibility IN ('public','unlisted','private')),
9 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
10 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
11);
12CREATE INDEX snippets_owner ON snippets(owner_id, id);
13
14CREATE TABLE snippet_files (
15 snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
16 name TEXT NOT NULL,
17 content BLOB NOT NULL,
18 size INTEGER NOT NULL,
19 PRIMARY KEY (snippet_id, name)
20);
internal/store/snippets.go added +223
@@ -0,0 +1,223 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6)
7
8type Snippet struct {
9 ID int64
10 PublicID string
11 OwnerID int64
12 OwnerName string
13 Description string
14 Visibility string // public | unlisted | private
15 CreatedAt string
16 UpdatedAt string
17 // Files carries names and sizes. Content is filled by SnippetFiles and
18 // SnippetFile only, so a listing does not read every body.
19 Files []SnippetFile
20}
21
22type SnippetFile struct {
23 Name string
24 Size int64
25 Content []byte
26}
27
28const snippetSelect = `
29 SELECT s.id, s.public_id, s.owner_id, u.username, s.description, s.visibility, s.created_at, s.updated_at
30 FROM snippets s JOIN users u ON u.id = s.owner_id`
31
32func scanSnippet(row interface{ Scan(...any) error }) (Snippet, error) {
33 var sn Snippet
34 err := row.Scan(&sn.ID, &sn.PublicID, &sn.OwnerID, &sn.OwnerName, &sn.Description, &sn.Visibility, &sn.CreatedAt, &sn.UpdatedAt)
35 return sn, err
36}
37
38// CreateSnippet inserts the snippet and its first file in one transaction.
39// A public_id collision is ErrExists so the caller can draw another.
40func (s *Store) CreateSnippet(ownerID int64, publicID, description, visibility, name string, content []byte) (int64, error) {
41 tx, err := s.DB.Begin()
42 if err != nil {
43 return 0, err
44 }
45 defer tx.Rollback()
46 res, err := tx.Exec(
47 "INSERT INTO snippets (public_id, owner_id, description, visibility) VALUES (?, ?, ?, ?)",
48 publicID, ownerID, description, visibility)
49 if err != nil {
50 if isUniqueErr(err) {
51 return 0, ErrExists
52 }
53 return 0, err
54 }
55 id, err := res.LastInsertId()
56 if err != nil {
57 return 0, err
58 }
59 if _, err := tx.Exec("INSERT INTO snippet_files (snippet_id, name, content, size) VALUES (?, ?, ?, ?)",
60 id, name, content, len(content)); err != nil {
61 return 0, err
62 }
63 return id, tx.Commit()
64}
65
66func (s *Store) SnippetByPublicID(publicID string) (Snippet, error) {
67 sn, err := scanSnippet(s.DB.QueryRow(snippetSelect+" WHERE s.public_id = ?", publicID))
68 if errors.Is(err, sql.ErrNoRows) {
69 return sn, ErrNotFound
70 }
71 if err != nil {
72 return sn, err
73 }
74 sn.Files, err = s.snippetFileNames(sn.ID)
75 return sn, err
76}
77
78func (s *Store) snippetFileNames(id int64) ([]SnippetFile, error) {
79 rows, err := s.DB.Query("SELECT name, size FROM snippet_files WHERE snippet_id = ? ORDER BY name", id)
80 if err != nil {
81 return nil, err
82 }
83 defer rows.Close()
84 var out []SnippetFile
85 for rows.Next() {
86 var f SnippetFile
87 if err := rows.Scan(&f.Name, &f.Size); err != nil {
88 return nil, err
89 }
90 out = append(out, f)
91 }
92 return out, rows.Err()
93}
94
95// SnippetFiles returns every file with its content, by name.
96func (s *Store) SnippetFiles(id int64) ([]SnippetFile, error) {
97 rows, err := s.DB.Query("SELECT name, size, content FROM snippet_files WHERE snippet_id = ? ORDER BY name", id)
98 if err != nil {
99 return nil, err
100 }
101 defer rows.Close()
102 var out []SnippetFile
103 for rows.Next() {
104 var f SnippetFile
105 if err := rows.Scan(&f.Name, &f.Size, &f.Content); err != nil {
106 return nil, err
107 }
108 out = append(out, f)
109 }
110 return out, rows.Err()
111}
112
113func (s *Store) SnippetFile(id int64, name string) (SnippetFile, error) {
114 var f SnippetFile
115 err := s.DB.QueryRow("SELECT name, size, content FROM snippet_files WHERE snippet_id = ? AND name = ?", id, name).
116 Scan(&f.Name, &f.Size, &f.Content)
117 if errors.Is(err, sql.ErrNoRows) {
118 return f, ErrNotFound
119 }
120 return f, err
121}
122
123// ListSnippets lists an owner's snippets newest first. all=false keeps
124// public ones only. afterID is the keyset cursor: rows older than it.
125// Ids grow with creation, so ordering by id is creation order.
126func (s *Store) ListSnippets(ownerID int64, all bool, limit int, afterID int64) ([]Snippet, error) {
127 q := snippetSelect + " WHERE s.owner_id = ?"
128 args := []any{ownerID}
129 if !all {
130 q += " AND s.visibility = 'public'"
131 }
132 if afterID > 0 {
133 q += " AND s.id < ?"
134 args = append(args, afterID)
135 }
136 q += " ORDER BY s.id DESC"
137 if limit > 0 {
138 q += " LIMIT ?"
139 args = append(args, limit)
140 }
141 rows, err := s.DB.Query(q, args...)
142 if err != nil {
143 return nil, err
144 }
145 defer rows.Close()
146 var out []Snippet
147 for rows.Next() {
148 sn, err := scanSnippet(rows)
149 if err != nil {
150 return nil, err
151 }
152 out = append(out, sn)
153 }
154 if err := rows.Err(); err != nil {
155 return nil, err
156 }
157 // One query per row for the names; pages are at most 200 rows.
158 for i := range out {
159 if out[i].Files, err = s.snippetFileNames(out[i].ID); err != nil {
160 return nil, err
161 }
162 }
163 return out, nil
164}
165
166func (s *Store) CountSnippets(ownerID int64, all bool) (int, error) {
167 q := "SELECT COUNT(*) FROM snippets WHERE owner_id = ?"
168 if !all {
169 q += " AND visibility = 'public'"
170 }
171 var n int
172 err := s.DB.QueryRow(q, ownerID).Scan(&n)
173 return n, err
174}
175
176func (s *Store) UpdateSnippet(id int64, description, visibility string) error {
177 _, err := s.DB.Exec(
178 "UPDATE snippets SET description = ?, visibility = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
179 description, visibility, id)
180 return err
181}
182
183func (s *Store) DeleteSnippet(id int64) error {
184 _, err := s.DB.Exec("DELETE FROM snippets WHERE id = ?", id)
185 return err
186}
187
188// SetSnippetFile adds the file or replaces one of the same name.
189func (s *Store) SetSnippetFile(id int64, name string, content []byte) error {
190 tx, err := s.DB.Begin()
191 if err != nil {
192 return err
193 }
194 defer tx.Rollback()
195 if _, err := tx.Exec(`INSERT INTO snippet_files (snippet_id, name, content, size) VALUES (?, ?, ?, ?)
196 ON CONFLICT (snippet_id, name) DO UPDATE SET content = excluded.content, size = excluded.size`,
197 id, name, content, len(content)); err != nil {
198 return err
199 }
200 if _, err := tx.Exec("UPDATE snippets SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id); err != nil {
201 return err
202 }
203 return tx.Commit()
204}
205
206func (s *Store) RemoveSnippetFile(id int64, name string) error {
207 tx, err := s.DB.Begin()
208 if err != nil {
209 return err
210 }
211 defer tx.Rollback()
212 res, err := tx.Exec("DELETE FROM snippet_files WHERE snippet_id = ? AND name = ?", id, name)
213 if err != nil {
214 return err
215 }
216 if n, _ := res.RowsAffected(); n == 0 {
217 return ErrNotFound
218 }
219 if _, err := tx.Exec("UPDATE snippets SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id); err != nil {
220 return err
221 }
222 return tx.Commit()
223}
internal/store/snippets_test.go added +108
@@ -0,0 +1,108 @@
1package store
2
3import (
4 "errors"
5 "testing"
6)
7
8func TestSnippets(t *testing.T) {
9 s := open(t)
10 if err := s.MigrateUp(); err != nil {
11 t.Fatal(err)
12 }
13 alice, err := s.CreateUser("alice", false)
14 if err != nil {
15 t.Fatal(err)
16 }
17 id, err := s.CreateSnippet(alice, "abcdef012345", "a log", "unlisted", "build.log", []byte("ok\n"))
18 if err != nil {
19 t.Fatal(err)
20 }
21 if _, err := s.CreateSnippet(alice, "abcdef012345", "", "public", "x", []byte("x")); !errors.Is(err, ErrExists) {
22 t.Fatalf("duplicate public id: %v", err)
23 }
24 sn, err := s.SnippetByPublicID("abcdef012345")
25 if err != nil {
26 t.Fatal(err)
27 }
28 if sn.ID != id || sn.OwnerName != "alice" || sn.Visibility != "unlisted" || sn.Description != "a log" {
29 t.Fatalf("snippet: %+v", sn)
30 }
31 if len(sn.Files) != 1 || sn.Files[0].Name != "build.log" || sn.Files[0].Size != 3 || sn.Files[0].Content != nil {
32 t.Fatalf("files on lookup: %+v", sn.Files)
33 }
34
35 // Set adds, then replaces; remove drops; the file read carries content.
36 if err := s.SetSnippetFile(id, "notes.txt", []byte("one\n")); err != nil {
37 t.Fatal(err)
38 }
39 if err := s.SetSnippetFile(id, "notes.txt", []byte("two\n")); err != nil {
40 t.Fatal(err)
41 }
42 f, err := s.SnippetFile(id, "notes.txt")
43 if err != nil || string(f.Content) != "two\n" || f.Size != 4 {
44 t.Fatalf("file after replace: %+v %v", f, err)
45 }
46 files, err := s.SnippetFiles(id)
47 if err != nil || len(files) != 2 || files[0].Name != "build.log" || string(files[1].Content) != "two\n" {
48 t.Fatalf("files: %+v %v", files, err)
49 }
50 if err := s.RemoveSnippetFile(id, "notes.txt"); err != nil {
51 t.Fatal(err)
52 }
53 if err := s.RemoveSnippetFile(id, "notes.txt"); !errors.Is(err, ErrNotFound) {
54 t.Fatalf("remove missing file: %v", err)
55 }
56 if _, err := s.SnippetFile(id, "notes.txt"); !errors.Is(err, ErrNotFound) {
57 t.Fatalf("read removed file: %v", err)
58 }
59
60 // Listing: public only unless all; newest first; keyset by id.
61 pub, err := s.CreateSnippet(alice, "000000000001", "", "public", "a", []byte("a"))
62 if err != nil {
63 t.Fatal(err)
64 }
65 if _, err := s.CreateSnippet(alice, "000000000002", "", "private", "b", []byte("b")); err != nil {
66 t.Fatal(err)
67 }
68 got, err := s.ListSnippets(alice, false, 0, 0)
69 if err != nil || len(got) != 1 || got[0].ID != pub {
70 t.Fatalf("public list: %+v %v", got, err)
71 }
72 got, err = s.ListSnippets(alice, true, 0, 0)
73 if err != nil || len(got) != 3 || got[0].PublicID != "000000000002" || got[2].ID != id {
74 t.Fatalf("all list: %+v %v", got, err)
75 }
76 got, err = s.ListSnippets(alice, true, 2, got[0].ID)
77 if err != nil || len(got) != 2 || got[0].ID != pub {
78 t.Fatalf("paged list: %+v %v", got, err)
79 }
80 if n, err := s.CountSnippets(alice, false); err != nil || n != 1 {
81 t.Fatalf("public count: %d %v", n, err)
82 }
83 if n, err := s.CountSnippets(alice, true); err != nil || n != 3 {
84 t.Fatalf("all count: %d %v", n, err)
85 }
86
87 // Update, delete, and the owner cascade.
88 if err := s.UpdateSnippet(id, "renamed", "public"); err != nil {
89 t.Fatal(err)
90 }
91 sn, _ = s.SnippetByPublicID("abcdef012345")
92 if sn.Description != "renamed" || sn.Visibility != "public" {
93 t.Fatalf("after update: %+v", sn)
94 }
95 if err := s.DeleteSnippet(id); err != nil {
96 t.Fatal(err)
97 }
98 if _, err := s.SnippetByPublicID("abcdef012345"); !errors.Is(err, ErrNotFound) {
99 t.Fatalf("after delete: %v", err)
100 }
101 if err := s.DeleteUser(alice); err != nil {
102 t.Fatal(err)
103 }
104 var n int
105 if err := s.DB.QueryRow("SELECT COUNT(*) FROM snippet_files").Scan(&n); err != nil || n != 0 {
106 t.Fatalf("files after user delete: %d %v", n, err)
107 }
108}