Skip to content

Commit 61a2074

Browse files
jerryjrxieclaude
andcommitted
feat: add optional description field for links
Adds an optional description to links, shown on the home page, /.all, /.search, and the link detail page, and settable via the create/edit forms and the API. Schema changes are now managed with goose migrations applied automatically on startup; the new Description column is nullable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Jerry Xie <jerryjrxie@gmail.com>
1 parent b85eec3 commit 61a2074

14 files changed

Lines changed: 367 additions & 169 deletions

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,20 @@ You can also resolve links locally using a snapshot file:
311311

312312
golink -resolve-from-backup links.json go/link
313313

314+
## Database migrations
315+
316+
The SQLite schema is managed with [goose]. Migrations live in the `migrations`
317+
directory, are embedded into the binary at build time, and are applied
318+
automatically on startup, so upgrading golink requires no manual database
319+
steps. Existing databases created before goose was introduced are picked up
320+
transparently: the initial migration uses `CREATE TABLE IF NOT EXISTS`, so it is
321+
a no-op against their existing tables and their data is preserved.
322+
323+
To add a schema change, create a new timestamped/numbered file in `migrations`
324+
with `-- +goose Up` / `-- +goose Down` sections; it will run on the next start.
325+
326+
[goose]: https://github.com/pressly/goose
327+
314328
## Firefox configuration
315329

316330
If you're using Firefox, you might want to configure two options to make it easy to load links:

db.go

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ package golink
66
import (
77
"context"
88
"database/sql"
9-
_ "embed"
9+
"embed"
1010
"errors"
1111
"fmt"
1212
"io/fs"
@@ -15,17 +15,19 @@ import (
1515
"sync"
1616
"time"
1717

18+
"github.com/pressly/goose/v3"
1819
_ "modernc.org/sqlite"
1920
"tailscale.com/tstime"
2021
)
2122

2223
// Link is the structure stored for each go short link.
2324
type Link struct {
24-
Short string // the "foo" part of http://go/foo
25-
Long string // the target URL or text/template pattern to run
26-
Created time.Time
27-
LastEdit time.Time // when the link was last edited
28-
Owner string // user@domain
25+
Short string // the "foo" part of http://go/foo
26+
Long string // the target URL or text/template pattern to run
27+
Description string // optional human-readable description of the link
28+
Created time.Time
29+
LastEdit time.Time // when the link was last edited
30+
Owner string // user@domain
2931
}
3032

3133
// ClickStats is the number of clicks a set of links have received in a given
@@ -47,8 +49,8 @@ type SQLiteDB struct {
4749
clock tstime.Clock // allow overriding time for tests
4850
}
4951

50-
//go:embed schema.sql
51-
var sqlSchema string
52+
//go:embed migrations/*.sql
53+
var migrationsFS embed.FS
5254

5355
// NewSQLiteDB returns a new SQLiteDB that stores links in a SQLite database stored at f.
5456
func NewSQLiteDB(f string) (*SQLiteDB, error) {
@@ -60,18 +62,53 @@ func NewSQLiteDB(f string) (*SQLiteDB, error) {
6062
return nil, err
6163
}
6264

63-
if _, err = db.Exec(sqlSchema); err != nil {
65+
if err := migrateDB(db); err != nil {
6466
return nil, err
6567
}
6668

6769
return &SQLiteDB{db: db}, nil
6870
}
6971

72+
// migrateDB applies any pending schema migrations to db. Migrations are embedded
73+
// from the migrations directory and applied in version order; goose records
74+
// applied versions in a goose_db_version table, so it is safe to run on every
75+
// startup.
76+
func migrateDB(db *sql.DB) error {
77+
migrationFiles, err := fs.Sub(migrationsFS, "migrations")
78+
if err != nil {
79+
return err
80+
}
81+
provider, err := goose.NewProvider(goose.DialectSQLite3, db, migrationFiles)
82+
if err != nil {
83+
return err
84+
}
85+
_, err = provider.Up(context.Background())
86+
return err
87+
}
88+
7089
// Now returns the current time.
7190
func (s *SQLiteDB) Now() time.Time {
7291
return tstime.DefaultClock{Clock: s.clock}.Now()
7392
}
7493

94+
// linkColumns is the column list, in scan order, shared by every query that
95+
// loads Links. Description is read through COALESCE so a NULL surfaces as the
96+
// empty string.
97+
const linkColumns = `Short, Long, COALESCE(Description, ''), Created, LastEdit, Owner`
98+
99+
// scanLink scans a single Link row (in linkColumns order) from s, which is
100+
// satisfied by both *sql.Row and *sql.Rows.
101+
func scanLink(s interface{ Scan(...any) error }) (*Link, error) {
102+
link := new(Link)
103+
var created, lastEdit int64
104+
if err := s.Scan(&link.Short, &link.Long, &link.Description, &created, &lastEdit, &link.Owner); err != nil {
105+
return nil, err
106+
}
107+
link.Created = time.Unix(created, 0).UTC()
108+
link.LastEdit = time.Unix(lastEdit, 0).UTC()
109+
return link, nil
110+
}
111+
75112
// LoadAll returns all stored Links.
76113
//
77114
// The caller owns the returned values.
@@ -80,19 +117,15 @@ func (s *SQLiteDB) LoadAll() ([]*Link, error) {
80117
defer s.mu.RUnlock()
81118

82119
var links []*Link
83-
rows, err := s.db.Query("SELECT Short, Long, Created, LastEdit, Owner FROM Links")
120+
rows, err := s.db.Query("SELECT " + linkColumns + " FROM Links")
84121
if err != nil {
85122
return nil, err
86123
}
87124
for rows.Next() {
88-
link := new(Link)
89-
var created, lastEdit int64
90-
err := rows.Scan(&link.Short, &link.Long, &created, &lastEdit, &link.Owner)
125+
link, err := scanLink(rows)
91126
if err != nil {
92127
return nil, err
93128
}
94-
link.Created = time.Unix(created, 0).UTC()
95-
link.LastEdit = time.Unix(lastEdit, 0).UTC()
96129
links = append(links, link)
97130
}
98131
return links, rows.Err()
@@ -107,18 +140,14 @@ func (s *SQLiteDB) Load(short string) (*Link, error) {
107140
s.mu.RLock()
108141
defer s.mu.RUnlock()
109142

110-
link := new(Link)
111-
var created, lastEdit int64
112-
row := s.db.QueryRow("SELECT Short, Long, Created, LastEdit, Owner FROM Links WHERE ID = ?1 LIMIT 1", linkID(short))
113-
err := row.Scan(&link.Short, &link.Long, &created, &lastEdit, &link.Owner)
143+
row := s.db.QueryRow("SELECT "+linkColumns+" FROM Links WHERE ID = ?1 LIMIT 1", linkID(short))
144+
link, err := scanLink(row)
114145
if err != nil {
115146
if errors.Is(err, sql.ErrNoRows) {
116147
err = fs.ErrNotExist
117148
}
118149
return nil, err
119150
}
120-
link.Created = time.Unix(created, 0).UTC()
121-
link.LastEdit = time.Unix(lastEdit, 0).UTC()
122151
return link, nil
123152
}
124153

@@ -127,7 +156,9 @@ func (s *SQLiteDB) Save(link *Link) error {
127156
s.mu.Lock()
128157
defer s.mu.Unlock()
129158

130-
result, err := s.db.Exec("INSERT OR REPLACE INTO Links (ID, Short, Long, Created, LastEdit, Owner) VALUES (?, ?, ?, ?, ?, ?)", linkID(link.Short), link.Short, link.Long, link.Created.Unix(), link.LastEdit.Unix(), link.Owner)
159+
// Store an absent description as NULL rather than an empty string.
160+
description := sql.NullString{String: link.Description, Valid: link.Description != ""}
161+
result, err := s.db.Exec("INSERT OR REPLACE INTO Links (ID, Short, Long, Description, Created, LastEdit, Owner) VALUES (?, ?, ?, ?, ?, ?, ?)", linkID(link.Short), link.Short, link.Long, description, link.Created.Unix(), link.LastEdit.Unix(), link.Owner)
131162
if err != nil {
132163
return err
133164
}
@@ -232,19 +263,15 @@ func (s *SQLiteDB) GetLinksByOwner(owner string) ([]*Link, error) {
232263
defer s.mu.RUnlock()
233264

234265
var links []*Link
235-
rows, err := s.db.Query("SELECT Short, Long, Created, LastEdit, Owner FROM Links WHERE LOWER(Owner) = LOWER(?)", owner)
266+
rows, err := s.db.Query("SELECT "+linkColumns+" FROM Links WHERE LOWER(Owner) = LOWER(?)", owner)
236267
if err != nil {
237268
return nil, err
238269
}
239270
for rows.Next() {
240-
link := new(Link)
241-
var created, lastEdit int64
242-
err := rows.Scan(&link.Short, &link.Long, &created, &lastEdit, &link.Owner)
271+
link, err := scanLink(rows)
243272
if err != nil {
244273
return nil, err
245274
}
246-
link.Created = time.Unix(created, 0).UTC()
247-
link.LastEdit = time.Unix(lastEdit, 0).UTC()
248275
links = append(links, link)
249276
}
250277
return links, rows.Err()

db_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,27 @@ func Test_SQLiteDB_SaveLoadDeleteLinks(t *testing.T) {
6565
}
6666
}
6767

68+
// Test that a Link's Description is persisted and loaded.
69+
func Test_SQLiteDB_Description(t *testing.T) {
70+
db, err := NewSQLiteDB(path.Join(t.TempDir(), "links.db"))
71+
if err != nil {
72+
t.Fatal(err)
73+
}
74+
75+
link := &Link{Short: "desc", Long: "long", Description: "a helpful description"}
76+
if err := db.Save(link); err != nil {
77+
t.Fatal(err)
78+
}
79+
80+
got, err := db.Load("desc")
81+
if err != nil {
82+
t.Fatal(err)
83+
}
84+
if got.Description != link.Description {
85+
t.Errorf("Description = %q; want %q", got.Description, link.Description)
86+
}
87+
}
88+
6889
// Test saving, loading, and deleting stats for SQLiteDB.
6990
func Test_SQLiteDB_SaveLoadDeleteStats(t *testing.T) {
7091
db, err := NewSQLiteDB(path.Join(t.TempDir(), "links.db"))

go.mod

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ go 1.26.1
44

55
require (
66
github.com/google/go-cmp v0.7.0
7+
github.com/pressly/goose/v3 v3.27.1
78
github.com/prometheus/client_golang v1.23.2
8-
golang.org/x/net v0.48.0
9-
modernc.org/sqlite v1.39.1
9+
golang.org/x/net v0.53.0
10+
modernc.org/sqlite v1.49.1
1011
tailscale.com v1.96.1
1112
)
1213

@@ -29,7 +30,7 @@ require (
2930
github.com/aws/smithy-go v1.24.0 // indirect
3031
github.com/beorn7/perks v1.0.1 // indirect
3132
github.com/cespare/xxhash/v2 v2.3.0 // indirect
32-
github.com/coder/websocket v1.8.12 // indirect
33+
github.com/coder/websocket v1.8.14 // indirect
3334
github.com/creachadair/msync v0.7.1 // indirect
3435
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
3536
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -43,43 +44,46 @@ require (
4344
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
4445
github.com/huin/goupnp v1.3.0 // indirect
4546
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
46-
github.com/klauspost/compress v1.18.2 // indirect
47-
github.com/mattn/go-isatty v0.0.20 // indirect
47+
github.com/klauspost/compress v1.18.5 // indirect
48+
github.com/mattn/go-isatty v0.0.21 // indirect
4849
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
4950
github.com/mdlayher/socket v0.5.0 // indirect
51+
github.com/mfridman/interpolate v0.0.2 // indirect
5052
github.com/mitchellh/go-ps v1.0.0 // indirect
5153
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
52-
github.com/ncruces/go-strftime v0.1.9 // indirect
54+
github.com/ncruces/go-strftime v1.0.0 // indirect
5355
github.com/pires/go-proxyproto v0.8.1 // indirect
5456
github.com/prometheus-community/pro-bing v0.4.0 // indirect
5557
github.com/prometheus/client_model v0.6.2 // indirect
5658
github.com/prometheus/common v0.66.1 // indirect
57-
github.com/prometheus/procfs v0.16.1 // indirect
59+
github.com/prometheus/procfs v0.20.1 // indirect
5860
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
5961
github.com/safchain/ethtool v0.3.0 // indirect
62+
github.com/sethvargo/go-retry v0.3.0 // indirect
6063
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
6164
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
6265
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect
6366
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
6467
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
6568
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
6669
github.com/x448/float16 v0.8.4 // indirect
70+
go.uber.org/multierr v1.11.0 // indirect
6771
go.yaml.in/yaml/v2 v2.4.2 // indirect
6872
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
6973
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
70-
golang.org/x/crypto v0.46.0 // indirect
71-
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
74+
golang.org/x/crypto v0.50.0 // indirect
75+
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
7276
golang.org/x/oauth2 v0.33.0 // indirect
73-
golang.org/x/sync v0.19.0 // indirect
74-
golang.org/x/sys v0.40.0 // indirect
75-
golang.org/x/term v0.38.0 // indirect
76-
golang.org/x/text v0.32.0 // indirect
77+
golang.org/x/sync v0.20.0 // indirect
78+
golang.org/x/sys v0.43.0 // indirect
79+
golang.org/x/term v0.42.0 // indirect
80+
golang.org/x/text v0.36.0 // indirect
7781
golang.org/x/time v0.12.0 // indirect
7882
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
7983
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
8084
google.golang.org/protobuf v1.36.11 // indirect
8185
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect
82-
modernc.org/libc v1.66.10 // indirect
86+
modernc.org/libc v1.72.1 // indirect
8387
modernc.org/mathutil v1.7.1 // indirect
8488
modernc.org/memory v1.11.0 // indirect
8589
)

0 commit comments

Comments
 (0)