let the good times roll

This commit is contained in:
Pat Garrity 2026-01-31 23:16:32 -06:00
parent 53a0114cbb
commit 5c7c33d1b5
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
8 changed files with 510 additions and 63 deletions

View file

@ -1,6 +1,7 @@
package gs.smolban.auth
import gs.smolban.model.account.AccountId
import gs.smolban.model.account.AccountType
import gs.smolban.model.metadata.CreatedAt
/** Describes some credential but does not contain the actual credential value.
@ -10,6 +11,8 @@ import gs.smolban.model.metadata.CreatedAt
* @param accountId
* The unique identifier of the account to which this credential is
* associated.
* @param accountType
* The type of account this credential is associated with.
* @param credentialType
* The type of credential.
* @param status
@ -22,6 +25,7 @@ import gs.smolban.model.metadata.CreatedAt
case class Credential(
credentialId: CredentialId,
accountId: AccountId,
accountType: AccountType,
credentialType: CredentialType,
status: CredentialStatus,
effectivity: Option[CredentialEffectivity],

View file

@ -1,50 +0,0 @@
-- sqlite3
CREATE TABLE IF NOT EXISTS groups(
id BIGINT PRIMARY KEY,
group_id BLOB NOT NULL,
slug TEXT NOT NULL,
created_at DATETIME NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_group_id ON groups(group_id);
CREATE TABLE IF NOT EXISTS tags(
id BIGINT PRIMARY KEY,
tag_value TEXT NOT NULL,
created_at DATETIME NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_value ON tags(tag_value);
CREATE TABLE IF NOT EXISTS tickets(
id BIGINT PRIMARY KEY,
ticket_number INTEGER NOT NULL,
group_id BLOB NOT NULL,
created_at DATETIME NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
status TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_number_group
ON tickets(group_id, ticket_number);
CREATE TABLE IF NOT EXISTS ticket_tags(
id BIGINT PRIMARY KEY,
ticket_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_tags_ticket_tag
ON ticket_tags(ticket_id, tag_id);
CREATE TABLE IF NOT EXISTS ticket_history(
id BIGINT PRIMARY KEY,
ticket_id BIGINT NOT NULL,
status TEXT NOT NULL,
set_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ticket_history_order_ticket_set_at
ON ticket_history(ticket_id, set_at);

View file

@ -0,0 +1,111 @@
package gs.smolban.db.doobie
import cats.data.EitherT
import doobie.*
import gs.smolban.auth.Base64
import gs.smolban.auth.Credential
import gs.smolban.auth.CredentialId
import gs.smolban.auth.NewAiAgentAccount
import gs.smolban.auth.NewServiceAccount
import gs.smolban.auth.Password
import gs.smolban.db.AuthDb
import gs.smolban.db.DbError
import gs.smolban.model.account.Account
import gs.smolban.model.account.AccountId
import gs.smolban.model.account.AccountName
import gs.smolban.model.account.AiAgent
import gs.smolban.model.account.PermissionSet
import gs.smolban.model.account.ServiceAccount
import gs.smolban.model.account.User
final class DoobieAuthDb extends AuthDb[ConnectionIO] {
/** @inheritDocs
*/
override def getUser(name: AccountName): ConnectionIO[Option[User]] = ???
/** @inheritDocs
*/
override def createUser(
name: AccountName,
initialPassword: Password,
initialPermissions: PermissionSet
): EitherT[[A] =>> ConnectionIO[A], DbError, User] = ???
/** @inheritDocs
*/
override def setUserPassword(
id: AccountId,
newPassword: Password
): EitherT[[A] =>> ConnectionIO[A], DbError, User] = ???
/** @inheritDocs
*/
override def listActiveUsers(): ConnectionIO[List[User]] = ???
/** @inheritDocs
*/
override def getServiceAccount(name: AccountName)
: ConnectionIO[Option[ServiceAccount]] = ???
/** @inheritDocs
*/
override def createServiceAccount(
name: AccountName,
owner: AccountId,
initialPermissions: PermissionSet
): EitherT[[A] =>> ConnectionIO[A], DbError, NewServiceAccount] = ???
/** @inheritDocs
*/
override def listActiveServiceAccounts(): ConnectionIO[List[ServiceAccount]] =
???
/** @inheritDocs
*/
override def getAgentAccount(name: AccountName)
: ConnectionIO[Option[AiAgent]] = ???
/** @inheritDocs
*/
override def createAgentAccount(
name: AccountName,
owner: AccountId,
initialPermissions: PermissionSet
): EitherT[[A] =>> ConnectionIO[A], DbError, NewAiAgentAccount] = ???
/** @inheritDocs
*/
override def listActiveAgentAccounts(): ConnectionIO[List[AiAgent]] = ???
/** @inheritDocs
*/
override def rotateClientSecret(
accountId: AccountId,
credentialId: CredentialId,
overlapHours: Int
): EitherT[[A] =>> ConnectionIO[A], DbError, Base64] = ???
/** @inheritDocs
*/
override def setAccountPermissions(
id: AccountId,
newPermissions: PermissionSet
): EitherT[[A] =>> ConnectionIO[A], DbError, Account] = ???
/** @inheritDocs
*/
override def listAccountCredentials(id: AccountId)
: ConnectionIO[List[Credential]] = ???
/** @inheritDocs
*/
override def revokeCredential(id: CredentialId)
: EitherT[[A] =>> ConnectionIO[A], DbError, Unit] = ???
/** @inheritDocs
*/
override def expireCredential(id: CredentialId)
: EitherT[[A] =>> ConnectionIO[A], DbError, Unit] = ???
}

View file

@ -0,0 +1,256 @@
package gs.smolban.db.doobie
import cats.effect.Sync
import cats.effect.syntax.all.*
import cats.syntax.all.*
import doobie.*
import doobie.implicits.*
/** Provisions the database for SQLite. This class reflects the current state of
* Smolban.
*/
final class DoobieSqliteDb[F[_]: Sync]:
def initializeDatabase(xa: Transactor[F]): F[Unit] =
(for
_ <- setupUser()
_ <- setupServiceAccount()
_ <- setupAgentAccount()
_ <- setupCredential()
_ <- setupTag()
yield ()).transact(xa)
def tearDownDatabase(xa: Transactor[F]): F[Unit] =
(for _ <- tearDownUser()
yield ()).transact(xa) *> withoutTransaction(vacuum()).transact(xa).as(())
private def withoutTransaction[A](p: ConnectionIO[A]): ConnectionIO[A] =
FC.setAutoCommit(true).bracket(_ => p)(_ => FC.setAutoCommit(false))
private def vacuum(): ConnectionIO[Int] =
sql"VACUUM;".update.run
def setupUser(): ConnectionIO[Unit] =
for
_ <- userTable()
_ <- userIndexStatusCreatedAt()
_ <- userPermissionGlobal()
_ <- userPermissionGroup()
yield ()
def tearDownUser(): ConnectionIO[Unit] =
for
_ <- dropUserPermissionGlobal()
_ <- dropUserPermissionGroup()
_ <- dropUserIndexStatusCreatedAt()
_ <- dropUserTable()
yield ()
def userTable(): ConnectionIO[Int] =
sql"""
CREATE TABLE IF NOT EXISTS user(
id BLOB NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
created_at DATETIME NOT NULL
);
""".update.run
def dropUserTable(): ConnectionIO[Int] =
sql"DROP TABLE IF EXISTS user;".update.run
def userIndexStatusCreatedAt(): ConnectionIO[Int] =
sql"""
CREATE INDEX IF NOT EXISTS idx_user_status_created_at ON user(status, created_at);
""".update.run
def dropUserIndexStatusCreatedAt(): ConnectionIO[Int] =
sql"""DROP INDEX IF EXISTS idx_user_status_created_at;""".update.run
def userPermissionGlobal(): ConnectionIO[Int] =
sql"""
CREATE TABLE IF NOT EXISTS user_permission_global(
user_id BLOB NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (user_id, permission)
);
""".update.run
def dropUserPermissionGlobal(): ConnectionIO[Int] =
sql"""DROP TABLE IF EXISTS user_permission_global;""".update.run
def userPermissionGroup(): ConnectionIO[Int] =
sql"""
CREATE TABLE IF NOT EXISTS user_permission_group(
user_id BLOB NOT NULL,
group_name TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (user_id, group_name, permission)
);
""".update.run
def dropUserPermissionGroup(): ConnectionIO[Int] =
sql"""DROP TABLE IF EXISTS user_permission_group;""".update.run
def setupServiceAccount(): ConnectionIO[Unit] =
for
_ <- serviceAccountTable()
_ <- serviceAccountIndexStatusCreatedAt()
_ <- serviceAccountIndexOwner()
_ <- serviceAccountPermissionGlobal()
_ <- serviceAccountPermissionGroup()
yield ()
def serviceAccountTable(): ConnectionIO[Int] =
sql"""
CREATE TABLE IF NOT EXISTS service_account(
id BLOB NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
description TEXT NOT NULL,
created_at DATETIME NOT NULL,
owner BLOB NOT NULL
);
""".update.run
def serviceAccountIndexStatusCreatedAt(): ConnectionIO[Int] =
sql"""
CREATE INDEX IF NOT EXISTS idx_service_account_status_created_at ON service_account(status, created_at);
""".update.run
def serviceAccountIndexOwner(): ConnectionIO[Int] =
sql"""
CREATE INDEX IF NOT EXISTS idx_service_account_owner ON service_account(owner);
""".update.run
def serviceAccountPermissionGlobal(): ConnectionIO[Int] =
sql"""
CREATE TABLE IF NOT EXISTS service_account_permission_global(
service_account_id BLOB NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (service_account_id, permission)
);
""".update.run
def serviceAccountPermissionGroup(): ConnectionIO[Int] =
sql"""
CREATE TABLE IF NOT EXISTS service_account_permission_group(
service_account_id BLOB NOT NULL,
group_name TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (service_account_id, group_name, permission)
);
""".update.run
def setupAgentAccount(): ConnectionIO[Unit] =
for
_ <- agentAccountTable()
_ <- agentAccountIndexStatusCreatedAt()
_ <- agentAccountIndexOwner()
_ <- agentAccountPermissionGlobal()
_ <- agentAccountPermissionGroup()
yield ()
def agentAccountTable(): ConnectionIO[Int] =
sql"""
CREATE TABLE IF NOT EXISTS agent_account(
id BLOB NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
description TEXT NOT NULL,
created_at DATETIME NOT NULL,
owner BLOB NOT NULL
);
""".update.run
def agentAccountIndexStatusCreatedAt(): ConnectionIO[Int] =
sql"""
CREATE INDEX IF NOT EXISTS idx_agent_account_status_created_at ON agent_account(status, created_at);
""".update.run
def agentAccountIndexOwner(): ConnectionIO[Int] =
sql"""
CREATE INDEX IF NOT EXISTS idx_agent_account_owner ON agent_account(owner);
""".update.run
def agentAccountPermissionGlobal(): ConnectionIO[Int] =
sql"""
CREATE TABLE IF NOT EXISTS agent_account_permission_global(
agent_account_id BLOB NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (agent_account_id, permission)
);
""".update.run
def agentAccountPermissionGroup(): ConnectionIO[Int] =
sql"""
CREATE TABLE IF NOT EXISTS agent_account_permission_group(
agent_account_id BLOB NOT NULL,
group_name TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (agent_account_id, group_name, permission)
);
""".update.run
def setupCredential(): ConnectionIO[Unit] =
for
_ <- credentialTable()
_ <- credentialIndexAccountId()
_ <- credentialIndexAccountType()
_ <- credentialIndexCredentialType()
_ <- credentialIndexCredentialStatus()
yield ()
def credentialTable(): ConnectionIO[Int] =
sql"""
CREATE TABLE IF NOT EXISTS credential(
credential_id BLOB NOT NULL PRIMARY KEY,
credential_hash TEXT NOT NULL,
account_id BLOB NOT NULL,
account_type TEXT NOT NULL,
credential_type TEXT NOT NULL,
status TEXT NOT NULL,
effective_at DATE NULL,
effective_through DATE NULL,
created_at DATETIME NOT NULL
);
""".update.run
def credentialIndexAccountId(): ConnectionIO[Int] =
sql"""
CREATE INDEX IF NOT EXISTS idx_credential_account_id ON credential(account_id, created_at);
""".update.run
def credentialIndexAccountType(): ConnectionIO[Int] =
sql"""
CREATE INDEX IF NOT EXISTS idx_credential_account_type ON credential(account_type, created_at);
""".update.run
def credentialIndexCredentialType(): ConnectionIO[Int] =
sql"""
CREATE INDEX IF NOT EXISTS idx_credential_credential_type ON credential(credential_type, created_at);
""".update.run
def credentialIndexCredentialStatus(): ConnectionIO[Int] =
sql"""
CREATE INDEX IF NOT EXISTS idx_credential_credential_status ON credential(status, created_at);
""".update.run
def setupTag(): ConnectionIO[Unit] =
for
_ <- tagTable()
_ <- tagIndexCreatedAt()
yield ()
def tagTable(): ConnectionIO[Int] =
sql"""
CREATE TABLE IF NOT EXISTS tag(
tag_value TEXT NOT NULL,
created_at DATETIME NOT NULL
);
""".update.run
def tagIndexCreatedAt(): ConnectionIO[Int] =
sql"""
CREATE INDEX IF NOT EXISTS idx_tag_created_at ON tag(created_at);
""".update.run

View file

@ -28,27 +28,38 @@ class DoobieTagDbTests extends munit.FunSuite:
): Unit =
test(name)(f.unsafeRunSync())
private val dbConfig: Config = Config(
jdbcUrl = "jdbc:sqlite:test/doobie_tag_db_tests",
driverClassName = Some("org.sqlite.JDBC")
)
private val transactor: Resource[IO, Transactor[IO]] =
HikariTransactor.fromConfig[IO](dbConfig)
private val tagDb: TagDb[ConnectionIO] = new DoobieTagDb(
CuratedSqlStates.sqlite
)
private val clock = Clock.systemDefaultZone()
iotest("should create, read, and delete a tag") {
private def dbConfig(dbName: String): Config =
Config(
jdbcUrl = s"jdbc:sqlite:test/$dbName",
driverClassName = Some("org.sqlite.JDBC")
)
private def dbTransactor(dbName: String): Resource[IO, Transactor[IO]] =
HikariTransactor.fromConfig[IO](dbConfig(dbName))
private def inDb(dbName: String): Resource[IO, TestDb] =
for
xa <- dbTransactor(dbName)
db <- provision(xa)
yield new TestDb(xa, db)
private def provision(xa: Transactor[IO]): Resource[IO, DoobieSqliteDb[IO]] =
val out = new DoobieSqliteDb[IO]
Resource.make(
out.tearDownDatabase(xa) *> out.initializeDatabase(xa).as(out)
)(db => db.tearDownDatabase(xa))
iotest("(db_0001) should create, read, and delete a tag") {
val tagValue = TagValue.unsafe("x")
val createdAt = CreatedAt.now(clock)
transactor.use { xa =>
inDb("db_0001").use { testDb =>
(for
_ <- dropTable().run
_ <- createTable().run
tag <- tagDb.createTag(tagValue, createdAt).value
t2 <- tagDb.readTag(tagValue)
result <- tagDb.deleteTag(tagValue)
@ -56,7 +67,7 @@ class DoobieTagDbTests extends munit.FunSuite:
assertEquals(tag, Right(Tag(tagValue, createdAt)))
assertEquals(t2, tag.toOption)
assert(result)
).transact(xa)
).transact(testDb.xa)
}
}

View file

@ -0,0 +1,10 @@
package gs.smolban.db.doobie
import cats.effect.IO
import doobie.util.transactor.Transactor
import gs.smolban.db.doobie.DoobieSqliteDb
final class TestDb(
val xa: Transactor[IO],
val sqlite: DoobieSqliteDb[IO]
)

8
sql/README.md Normal file
View file

@ -0,0 +1,8 @@
# Smolban SQL
This directory contains the _reference SQL_ for Smolban. Smolban supports both
SQLite and PostgreSQL.
For implementations that provision and migrate the database:
- [DoobieSqliteDb](../modules/db/src/main/scala/gs/smolban/db/doobie/DoobieSqliteDb.scala)

97
sql/sqlite/1.sql Normal file
View file

@ -0,0 +1,97 @@
CREATE TABLE IF NOT EXISTS user(
id BLOB NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
created_at DATETIME NOT NULL
);
CREATE INDEX idx_user_status_created_at ON user(status, created_at);
CREATE TABLE IF NOT EXISTS user_permission_global(
user_id BLOB NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (user_id, permission)
);
CREATE TABLE IF NOT EXISTS user_permission_group(
user_id BLOB NOT NULL,
group_name TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (user_id, group_name, permission)
);
CREATE TABLE IF NOT EXISTS service_account(
id BLOB NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
description TEXT NOT NULL,
created_at DATETIME NOT NULL,
owner BLOB NOT NULL
);
CREATE INDEX idx_service_account_status_created_at ON service_account(status, created_at);
CREATE INDEX idx_service_account_owner ON service_account(owner);
CREATE TABLE IF NOT EXISTS service_account_permission_global(
service_account_id BLOB NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (service_account_id, permission)
);
CREATE TABLE IF NOT EXISTS service_account_permission_group(
service_account_id BLOB NOT NULL,
group_name TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (service_account_id, group_name, permission)
);
CREATE TABLE IF NOT EXISTS agent_account(
id BLOB NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
description TEXT NOT NULL,
created_at DATETIME NOT NULL,
owner BLOB NOT NULL
);
CREATE INDEX idx_agent_account_status_created_at ON agent_account(status, created_at);
CREATE INDEX idx_agent_account_owner ON agent_account(owner);
CREATE TABLE IF NOT EXISTS agent_account_permission_global(
agent_account_id BLOB NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (agent_account_id, permission)
);
CREATE TABLE IF NOT EXISTS agent_account_permission_group(
agent_account_id BLOB NOT NULL,
group_name TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (agent_account_id, group_name, permission)
);
CREATE TABLE IF NOT EXISTS credential(
credential_id BLOB NOT NULL PRIMARY KEY,
credential_hash TEXT NOT NULL,
account_id BLOB NOT NULL,
account_type TEXT NOT NULL,
credential_type TEXT NOT NULL,
status TEXT NOT NULL,
effective_at DATE NULL,
effective_through DATE NULL,
created_at DATETIME NOT NULL
);
CREATE INDEX idx_credential_account_id ON credential(account_id, created_at);
CREATE INDEX idx_credential_account_type ON credential(account_type, created_at);
CREATE INDEX idx_credential_credential_type ON credential(credential_type, created_at);
CREATE INDEX idx_credential_credential_status ON credential(credential_status, created_at);
CREATE TABLE IF NOT EXISTS tag(
tag_value TEXT NOT NULL,
created_at DATETIME NOT NULL
);
CREATE INDEX idx_tag_created_at ON tag(created_at);