Search titles and sections of every page. ↑↓ to move, ↵ to open

Tutorial

3. A database

2 min read Edit on GitHub

Shelf keeps its links in SQLite: a database in one file, with nothing to install or run beside the app. Tern's own packages work with PostgreSQL just as well, if you'd rather use it later.

Add a SQLite driver written in Go, so the app still builds into one static binary:

go get modernc.org/sqlite

#A migration

The database's tables are made by migrations: SQL files that run once each, in order, when the app starts. Create the first one:

go tool tern migrate new create links

It creates migrations/<timestamp>_create_links.sql. Write the table into it:

CREATE TABLE links (
	id       INTEGER PRIMARY KEY,
	url      TEXT NOT NULL,
	title    TEXT NOT NULL,
	read     BOOLEAN NOT NULL DEFAULT FALSE,
	added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- +down
DROP TABLE links;

The part after -- +down undoes it, for migrate.Down. The app applies the migrations itself, so embed them in it. Create migrations/embed.go:

// Package migrations holds the database migrations, applied in order when
// the app starts.
package migrations

import "embed"

//go:embed *.sql
var FS embed.FS

Add the database to internal/shelf/shelf.go, with a function that lists the links:

// Package shelf keeps the links in a SQLite database.
package shelf

import (
	"context"
	"database/sql"
	"errors"
	"time"

	_ "modernc.org/sqlite" // the SQLite driver, in pure Go
)

// Link is a page saved to read later.
type Link struct {
	ID      int64     `json:"id"`
	URL     string    `json:"url"`
	Title   string    `json:"title"`
	Read    bool      `json:"read"`
	AddedAt time.Time `json:"addedAt"`
}

// ErrNotFound is returned for a link that doesn't exist.
var ErrNotFound = errors.New("link not found")

// DB is the database, opened by Open.
var DB *sql.DB

// Open opens the SQLite database at path, creating it if needed.
func Open(path string) error {
	db, err := sql.Open("sqlite", "file:"+path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)")
	if err != nil {
		return err
	}
	DB = db
	return nil
}

// List returns the links, the newest first. show is "unread", "read" or
// anything else for all of them.
func List(ctx context.Context, show string) ([]Link, error) {
	query := `SELECT id, url, title, read, added_at FROM links`
	switch show {
	case "unread":
		query += ` WHERE NOT read`
	case "read":
		query += ` WHERE read`
	}
	rows, err := DB.QueryContext(ctx, query+` ORDER BY id DESC`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var links []Link
	for rows.Next() {
		var l Link
		if err := rows.Scan(&l.ID, &l.URL, &l.Title, &l.Read, &l.AddedAt); err != nil {
			return nil, err
		}
		links = append(links, l)
	}
	return links, rows.Err()
}

The options after the file name are the usual ones for a web app on SQLite: write-ahead logging, so reads don't wait for writes; a wait of up to 5 seconds when the database is busy; and foreign keys checked. List takes show already; step 6 uses it.

This tutorial writes the SQL in Go with database/sql, to show what happens underneath. Tern can also write that Go for you: put the queries in queries/*.sql, and it generates typed functions, checked against the schema your migrations build (SQL queries). The SaaS starter template works that way.

#Opening it

Open the database in main.go, and add the migrations as a plugin, so they run before the server starts listening:

package main

import (
	"log"

	"github.com/Unfield/Tern/kit"
	"github.com/Unfield/Tern/migrate"

	"shelf/internal/shelf"
	"shelf/migrations"
)

func main() {
	if err := shelf.Open("shelf.db"); err != nil {
		log.Fatal(err)
	}
	app, err := ternApp(kit.WithPlugins(migrate.Plugin(shelf.DB, migrations.FS)))
	if err != nil {
		log.Fatal(err)
	}
	if err := app.ListenAndServe(); err != nil {
		log.Fatal(err)
	}
}

ternApp is generated by Tern from your routes, in tern.gen.go. Its options configure the app: plugins, hooks, middleware, the logger.

The load now reads from the database. In src/routes/page.server.go:

package routes

import (
	"github.com/Unfield/Tern/kit"

	"shelf/internal/shelf"
)

var Access = kit.Public

type Data struct {
	Links []shelf.Link `json:"links"`
}

func Load(ctx *kit.Ctx) (Data, error) {
	links, err := shelf.List(ctx, "")
	if err != nil {
		return Data{}, err
	}
	return Data{Links: links}, nil
}

ctx is a context.Context too, so it goes straight to the database: when the visitor goes away, the query is cancelled.

The dev server restarts and logs applied migration … name=create_links. The page says "Nothing saved yet": the table is empty. Add shelf.db* to .gitignore, so the database stays out of git.

If a migration has a mistake, the server doesn't start, and the dev server shows the error. Once a migration has run, don't edit it: Tern notices and refuses to start. Add a new migration instead, as step 7 does.

Next: Saving links.