feat(db): add migration logic #34

merged
opened by brookjeynes.dev targeting master from push-zqozurktxrpx
Changed files
+121 -43
internal
migrations
+108 -4
internal/db/db.go
···
"strings"
_ "github.com/mattn/go-sqlite3"
+
"yoten.app/internal/server/log"
)
type DB struct {
···
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
}
-
func Make(ctx context.Context, dbPath string, logger *slog.Logger) (*DB, error) {
+
func Make(ctx context.Context, dbPath string) (*DB, error) {
opts := []string{
"_foreign_keys=1",
"_journal_mode=WAL",
"_synchronous=NORMAL",
"_auto_vacuum=incremental",
}
+
+
logger := log.FromContext(ctx)
+
logger = log.SubLogger(logger, "db")
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
if err != nil {
···
actor_did text not null,
subject_uri text not null,
-
state text not null default 'unread' check(state in ('unread', 'read')),
-
type text not null check(type in ('follow', 'reaction', 'comment')),
+
state integer not null default 0,
+
type text not null,
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
···
is_deleted boolean not null default false,
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
-
foreign key (did) references profiles(did) on delete cascade
+
foreign key (did) references profiles(did) on delete cascade,
unique (did, rkey)
);
···
return nil, fmt.Errorf("failed to execute db create statement: %w", err)
}
+
// This migration removes the type constraint on the notification type as
+
// it was painful to add new types. It also changes state to an integer
+
// check instead of text.
+
runMigration(conn, logger, "simplify-notification-constraints", func(tx *sql.Tx) error {
+
// Create new table with state as integer and no type constraint
+
_, err := tx.Exec(`
+
create table if not exists notifications_new (
+
id integer primary key autoincrement,
+
+
recipient_did text not null,
+
actor_did text not null,
+
subject_uri text not null,
+
+
state integer not null default 0,
+
type text not null,
+
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
+
foreign key (recipient_did) references profiles(did) on delete cascade,
+
foreign key (actor_did) references profiles(did) on delete cascade
+
);
+
`)
+
if err != nil {
+
return err
+
}
+
+
// Copy data, converting state from text to integer
+
_, err = tx.Exec(`
+
insert into notifications_new (id, recipient_did, actor_did, subject_uri, state, type, created_at)
+
select
+
id,
+
recipient_did,
+
actor_did,
+
subject_uri,
+
case state
+
when 'unread' then 0
+
when 'read' then 1
+
else 0
+
end,
+
type,
+
created_at
+
from notifications;
+
`)
+
if err != nil {
+
return err
+
}
+
+
// Drop old table
+
_, err = tx.Exec(`drop table notifications`)
+
if err != nil {
+
return err
+
}
+
+
// Rename new table
+
_, err = tx.Exec(`alter table notifications_new rename to notifications`)
+
return err
+
})
+
return &DB{
db,
logger,
}, nil
}
+
+
type migrationFn = func(*sql.Tx) error
+
+
func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error {
+
logger = logger.With("migration", name)
+
+
tx, err := c.BeginTx(context.Background(), nil)
+
if err != nil {
+
return err
+
}
+
defer tx.Rollback()
+
+
var exists bool
+
err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists)
+
if err != nil {
+
return err
+
}
+
+
if !exists {
+
err = migrationFn(tx)
+
if err != nil {
+
logger.Error("failed to run migration", "err", err)
+
return err
+
}
+
+
_, err = tx.Exec("insert into migrations (name) values (?)", name)
+
if err != nil {
+
logger.Error("failed to mark migration as complete", "err", err)
+
return err
+
}
+
+
if err := tx.Commit(); err != nil {
+
return err
+
}
+
+
logger.Info("migration applied successfully")
+
} else {
+
logger.Warn("skipped migration, already applied")
+
}
+
+
return nil
+
}
+6 -6
internal/db/notification.go
···
NotificationTypeReply NotificationType = "reply"
)
-
type NotificationState string
+
type NotificationState int
const (
-
NotificationStateUnread NotificationState = "unread"
-
NotificationStateRead NotificationState = "read"
+
NotificationStateUnread NotificationState = 0
+
NotificationStateRead NotificationState = 1
)
type NotificationWithBskyHandle struct {
···
}
func GetUnreadNotificationCount(e Execer, recipientDid string) (int, error) {
-
query := `select count(*) from notifications where recipient_did = ? and state = 'unread';`
+
query := `select count(*) from notifications where recipient_did = ? and state = 0;`
var count int
row := e.QueryRow(query, recipientDid)
···
func MarkAllNotificationsAsRead(e Execer, did string) error {
query := `
update notifications
-
set state = 'read'
-
where recipient_did = ? and state = 'unread';
+
set state = 1
+
where recipient_did = ? and state = 0;
`
_, err := e.Exec(query, did)
+1 -1
internal/server/app.go
···
func Make(ctx context.Context, config *config.Config) (*Server, error) {
logger := log.FromContext(ctx)
-
d, err := db.Make(ctx, config.Core.DbPath, log.SubLogger(logger, "db"))
+
d, err := db.Make(ctx, config.Core.DbPath)
if err != nil {
return nil, err
}
+1 -1
internal/server/oauth/consts.go
···
const (
ClientName = "Yoten"
-
ClientURI = "https://yoten.app"
+
ClientURI = "yoten.app"
SessionName = "yoten-oauth-session-v2"
SessionHandle = "handle"
SessionDid = "did"
+5 -5
internal/server/oauth/handler.go
···
clientName := ClientName
clientUri := ClientURI
-
meta := o.ClientApp.Config.ClientMetadata()
-
meta.JWKSURI = &o.JwksUri
-
meta.ClientName = &clientName
-
meta.ClientURI = &clientUri
+
doc := o.ClientApp.Config.ClientMetadata()
+
doc.JWKSURI = &o.JwksUri
+
doc.ClientName = &clientName
+
doc.ClientURI = &clientUri
w.Header().Set("Content-Type", "application/json")
-
if err := json.NewEncoder(w).Encode(meta); err != nil {
+
if err := json.NewEncoder(w).Encode(doc); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
-26
migrations/update_notification_type.sql
···
-
-- This script should be used and updated whenever a new notification type
-
-- constraint needs to be added.
-
-
BEGIN TRANSACTION;
-
-
ALTER TABLE notifications RENAME TO notifications_old;
-
-
CREATE TABLE notifications (
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
-
recipient_did TEXT NOT NULL,
-
actor_did TEXT NOT NULL,
-
subject_uri TEXT NOT NULL,
-
state TEXT NOT NULL DEFAULT 'unread' CHECK(state IN ('unread', 'read')),
-
type TEXT NOT NULL CHECK(type IN ('follow', 'reaction', 'comment', 'reply')),
-
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
-
FOREIGN KEY (recipient_did) REFERENCES profiles(did) ON DELETE CASCADE,
-
FOREIGN KEY (actor_did) REFERENCES profiles(did) ON DELETE CASCADE
-
);
-
-
INSERT INTO notifications (id, recipient_did, actor_did, subject_uri, state, type, created_at)
-
SELECT id, recipient_did, actor_did, subject_uri, state, type, created_at
-
FROM notifications_old;
-
-
DROP TABLE notifications_old;
-
-
COMMIT;