package db import ( "context" "database/sql" "log/slog" "slices" "strings" _ "github.com/mattn/go-sqlite3" "tangled.org/core/log" "tangled.org/core/orm" ) type DB struct { *sql.DB } type DBTX interface { QueryRow(query string, args ...any) *sql.Row Exec(query string, args ...any) (sql.Result, error) } func Make(ctx context.Context, dbPath string) (*DB, error) { // https://github.com/mattn/go-sqlite3#connection-string opts := []string{ "_foreign_keys=1", "_journal_mode=WAL", "_synchronous=NORMAL", "_auto_vacuum=incremental", "_busy_timeout=5000", } logger := log.FromContext(ctx) logger = log.SubLogger(logger, "db") db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) if err != nil { return nil, err } conn, err := db.Conn(ctx) if err != nil { return nil, err } defer conn.Close() _, err = conn.ExecContext(ctx, ` create table if not exists _jetstream ( id integer primary key autoincrement, last_time_us integer not null ); create table if not exists known_dids ( did text primary key ); create table if not exists repos ( id integer primary key autoincrement, knot text not null, owner text not null, rkey text not null, repo_did text, created_at text, addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), unique(owner, rkey) ); create table if not exists repo_collaborators ( id integer primary key autoincrement, owner_did text not null, rkey text not null, subject text not null, repo_did text not null, addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), unique(owner_did, rkey) ); create table if not exists spindle_members ( -- identifiers for the record id integer primary key autoincrement, did text not null, rkey text not null, -- data instance text not null, subject text not null, created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), -- constraints unique (did, rkey) ); -- status event for a single workflow create table if not exists events ( rkey text not null, nsid text not null, event text not null, -- json created integer not null -- unix nanos ); create table if not exists nixos_toplevel_cache ( config_key text primary key, toplevel text not null, updated_at text not null ); create table if not exists pipelines ( id text primary key, repo_did text not null, commit_id text not null ); create table if not exists jobs ( id integer primary key autoincrement, repo_did text not null, pipeline_id_knot text not null, pipeline_id_rkey text not null, source_repo text, tpl text not null, created_at integer not null default (strftime('%s', 'now')) ); create table if not exists workflows ( id integer primary key autoincrement, pipeline_id text not null, name text not null, status text not null default 'pending', unique(pipeline_id, id), foreign key (pipeline_id) references pipelines(id) on delete cascade ); create table if not exists migrations ( id integer primary key autoincrement, name text unique ); `) if err != nil { return nil, err } if err := runMigrations(ctx, conn, logger); err != nil { return nil, err } return &DB{db}, nil } func runMigrations(_ context.Context, conn *sql.Conn, logger *slog.Logger) error { if err := orm.RunMigration(conn, logger, "repos-to-repo-did", func(tx *sql.Tx) error { var hasName int if err := tx.QueryRow( `select count(*) from pragma_table_info('repos') where name = 'name'`, ).Scan(&hasName); err != nil { return err } if hasName > 0 { var totalRows, copiedRows int if err := tx.QueryRow(`select count(*) from repos`).Scan(&totalRows); err != nil { return err } if err := tx.QueryRow(`select count(*) from repos where coalesce(name, '') <> ''`).Scan(&copiedRows); err != nil { return err } if dropped := totalRows - copiedRows; dropped > 0 { logger.Warn("dropping repo rows with empty name during migration", "dropped", dropped, "kept", copiedRows) } if _, err := tx.Exec(` create table if not exists repos_new ( id integer primary key autoincrement, knot text not null, owner text not null, rkey text not null, repo_did text, created_at text, addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), unique(owner, rkey) ); insert into repos_new (id, knot, owner, rkey, addedAt) select id, knot, owner, name, addedAt from repos where coalesce(name, '') <> ''; drop table repos; alter table repos_new rename to repos; `); err != nil { return err } } _, err := tx.Exec(` create index if not exists idx_repos_repo_did on repos(repo_did); create index if not exists idx_repos_owner_repo_did on repos(owner, repo_did); create index if not exists idx_repo_collaborators_repo_did on repo_collaborators(repo_did); `) return err }); err != nil { return err } if err := orm.RunMigration(conn, logger, "spindle-members-unique-on-rkey", func(tx *sql.Tx) error { hasTarget, err := hasUniqueIndex(tx, "spindle_members", []string{"did", "rkey"}) if err != nil { return err } if hasTarget { return nil } var totalRows, distinctRows int if err := tx.QueryRow(`select count(*) from spindle_members`).Scan(&totalRows); err != nil { return err } if err := tx.QueryRow(`select count(*) from (select 1 from spindle_members group by did, rkey)`).Scan(&distinctRows); err != nil { return err } if dropped := totalRows - distinctRows; dropped > 0 { logger.Warn("dropping duplicate (did, rkey) rows during spindle_members rebuild", "dropped", dropped, "kept", distinctRows) } _, err = tx.Exec(` create table spindle_members_new ( id integer primary key autoincrement, did text not null, rkey text not null, instance text not null, subject text not null, created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), unique (did, rkey) ); insert into spindle_members_new (id, did, rkey, instance, subject, created) select id, did, rkey, instance, subject, created from spindle_members sm where id = ( select max(id) from spindle_members where did = sm.did and rkey = sm.rkey ); drop table spindle_members; alter table spindle_members_new rename to spindle_members; `) return err }); err != nil { return err } if err := orm.RunMigration(conn, logger, "events-pipeline-index", func(tx *sql.Tx) error { _, err := tx.Exec(` create index if not exists idx_events_pipeline_lookup on events( coalesce(json_extract(event, '$.triggerMetadata.repo.repoDid'), json_extract(event, '$.triggerMetadata.repo.did')), coalesce(json_extract(event, '$.triggerMetadata.push.newSha'), json_extract(event, '$.triggerMetadata.pullRequest.sourceSha'), json_extract(event, '$.triggerMetadata.manual.sha')) ) where nsid = 'sh.tangled.pipeline'; create index if not exists idx_events_pipeline_status on events( json_extract(event, '$.pipeline'), json_extract(event, '$.workflow') ) where nsid = 'sh.tangled.pipeline.status'; `) return err }); err != nil { return err } if err := orm.RunMigration(conn, logger, "member-acl-table", func(tx *sql.Tx) error { _, err := tx.Exec(` CREATE TABLE members ( did TEXT PRIMARY KEY, blocked INTEGER DEFAULT 0 ); insert or ignore into members (did) select subject from spindle_members; insert or ignore into members (did) select did from spindle_members; drop table spindle_members; `) return err }); err != nil { return err } // we use casbin instead if err := orm.RunMigration(conn, logger, "drop-legacy-acl-tables", func(tx *sql.Tx) error { _, err := tx.Exec(` drop table if exists repo_collaborators; drop table if exists known_dids; `) return err }); err != nil { return err } // repo_did is required if err := orm.RunMigration(conn, logger, "enforce-repo_did", func(tx *sql.Tx) error { _, err := tx.Exec(` create table repos_new ( repo_did text primary key, knot text not null, owner text not null, rkey text not null, created_at text, addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), unique(owner, rkey) ); insert into repos_new (repo_did, knot, owner, rkey, created_at, addedAt) select repo_did, knot, owner, rkey, created_at, addedAt from repos where coalesce(repo_did, '') <> '' and rowid = ( select r2.rowid from repos r2 where r2.repo_did = repos.repo_did order by r2.created_at is null, r2.created_at desc, r2.rowid desc limit 1 ); drop table repos; alter table repos_new rename to repos; `) return err }); err != nil { return err } if err := orm.RunMigration(conn, logger, "switch-to-rbac-v2", func(tx *sql.Tx) error { // NOTE: we are manually creating casbin table because DB migration will happen before rbac/v2 initialization. if _, err := tx.Exec(` CREATE TABLE acl_2( p_type VARCHAR(32) DEFAULT '' NOT NULL, v0 VARCHAR(255) DEFAULT '' NOT NULL, v1 VARCHAR(255) DEFAULT '' NOT NULL, v2 VARCHAR(255) DEFAULT '' NOT NULL, v3 VARCHAR(255) DEFAULT '' NOT NULL, v4 VARCHAR(255) DEFAULT '' NOT NULL, v5 VARCHAR(255) DEFAULT '' NOT NULL, CHECK (TYPEOF("p_type") = "text" AND LENGTH("p_type") <= 32), CHECK (TYPEOF("v0") = "text" AND LENGTH("v0") <= 255), CHECK (TYPEOF("v1") = "text" AND LENGTH("v1") <= 255), CHECK (TYPEOF("v2") = "text" AND LENGTH("v2") <= 255), CHECK (TYPEOF("v3") = "text" AND LENGTH("v3") <= 255), CHECK (TYPEOF("v4") = "text" AND LENGTH("v4") <= 255), CHECK (TYPEOF("v5") = "text" AND LENGTH("v5") <= 255) ); `); err != nil { return err } // fresh spindles might not have acl table hasAcl, err := tableExists(tx, "acl") if err != nil { return err } if !hasAcl { return nil } for _, role := range []string{"repo:owner", "repo:collaborator"} { if _, err := tx.Exec(` insert into acl_2 (p_type, v0, v1, v2) select distinct 'g', v0, v3, v2 from acl where p_type = 'p' and v1 = 'thisserver' and v3 = ? `, role); err != nil { return err } } return nil }); err != nil { return err } return nil } func tableExists(tx *sql.Tx, name string) (bool, error) { var exists bool err := tx.QueryRow( `select exists (select 1 from sqlite_master where type = 'table' and name = ?)`, name, ).Scan(&exists) return exists, err } func hasUniqueIndex(tx *sql.Tx, table string, cols []string) (bool, error) { rows, err := tx.Query( `select name from pragma_index_list(?) where "unique" = 1`, table, ) if err != nil { return false, err } defer rows.Close() var indexNames []string for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return false, err } indexNames = append(indexNames, name) } if err := rows.Err(); err != nil { return false, err } wantSorted := slices.Clone(cols) slices.Sort(wantSorted) for _, name := range indexNames { colRows, err := tx.Query( `select name from pragma_index_info(?) order by seqno`, name, ) if err != nil { return false, err } var got []string for colRows.Next() { var c string if err := colRows.Scan(&c); err != nil { colRows.Close() return false, err } got = append(got, c) } colRows.Close() slices.Sort(got) if slices.Equal(got, wantSorted) { return true, nil } } return false, nil } func (d *DB) SaveLastTimeUs(lastTimeUs int64) error { _, err := d.Exec(` insert into _jetstream (id, last_time_us) values (1, ?) on conflict(id) do update set last_time_us = excluded.last_time_us `, lastTimeUs) return err } func (d *DB) GetLastTimeUs() (int64, error) { var lastTimeUs int64 row := d.QueryRow(`select last_time_us from _jetstream where id = 1;`) err := row.Scan(&lastTimeUs) return lastTimeUs, err }