Initial phase 1 baseline implementation
This commit is contained in:
parent
099c53badf
commit
077cf7601a
29 changed files with 1339 additions and 39 deletions
32
internal/adapters/postgres/generated/db.go
Normal file
32
internal/adapters/postgres/generated/db.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package dbgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
32
internal/adapters/postgres/generated/health.sql.go
Normal file
32
internal/adapters/postgres/generated/health.sql.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: health.sql
|
||||
|
||||
package dbgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const databaseHealth = `-- name: DatabaseHealth :one
|
||||
SELECT 1::BIGINT AS result
|
||||
`
|
||||
|
||||
func (q *Queries) DatabaseHealth(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, databaseHealth)
|
||||
var result int64
|
||||
err := row.Scan(&result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
const ownerExists = `-- name: OwnerExists :one
|
||||
SELECT EXISTS (SELECT 1 FROM users)::BOOLEAN AS exists
|
||||
`
|
||||
|
||||
func (q *Queries) OwnerExists(ctx context.Context) (bool, error) {
|
||||
row := q.db.QueryRow(ctx, ownerExists)
|
||||
var exists bool
|
||||
err := row.Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
266
internal/adapters/postgres/generated/models.go
Normal file
266
internal/adapters/postgres/generated/models.go
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package dbgen
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type ApiToken struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"`
|
||||
ClientInstanceID pgtype.UUID `db:"client_instance_id" json:"client_instance_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
TokenHash []byte `db:"token_hash" json:"token_hash"`
|
||||
Actions []string `db:"actions" json:"actions"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
LastUsedAt pgtype.Timestamptz `db:"last_used_at" json:"last_used_at"`
|
||||
ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"`
|
||||
RevokedAt pgtype.Timestamptz `db:"revoked_at" json:"revoked_at"`
|
||||
}
|
||||
|
||||
type AuditEvent struct {
|
||||
Sequence int64 `db:"sequence" json:"sequence"`
|
||||
EventID pgtype.UUID `db:"event_id" json:"event_id"`
|
||||
OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"`
|
||||
Actor []byte `db:"actor" json:"actor"`
|
||||
Action string `db:"action" json:"action"`
|
||||
ResourceType *string `db:"resource_type" json:"resource_type"`
|
||||
ResourceID pgtype.UUID `db:"resource_id" json:"resource_id"`
|
||||
Outcome string `db:"outcome" json:"outcome"`
|
||||
RequestID pgtype.UUID `db:"request_id" json:"request_id"`
|
||||
OperationID pgtype.UUID `db:"operation_id" json:"operation_id"`
|
||||
Details []byte `db:"details" json:"details"`
|
||||
OccurredAt pgtype.Timestamptz `db:"occurred_at" json:"occurred_at"`
|
||||
}
|
||||
|
||||
type Blob struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"`
|
||||
ObjectID pgtype.UUID `db:"object_id" json:"object_id"`
|
||||
Sha256 []byte `db:"sha256" json:"sha256"`
|
||||
SizeBytes int64 `db:"size_bytes" json:"size_bytes"`
|
||||
State string `db:"state" json:"state"`
|
||||
VerifiedAt pgtype.Timestamptz `db:"verified_at" json:"verified_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type Change struct {
|
||||
Sequence int64 `db:"sequence" json:"sequence"`
|
||||
EventID pgtype.UUID `db:"event_id" json:"event_id"`
|
||||
OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"`
|
||||
EventType string `db:"event_type" json:"event_type"`
|
||||
SchemaVersion int32 `db:"schema_version" json:"schema_version"`
|
||||
NodeID pgtype.UUID `db:"node_id" json:"node_id"`
|
||||
NodeRevision *int64 `db:"node_revision" json:"node_revision"`
|
||||
ContentRevision *int64 `db:"content_revision" json:"content_revision"`
|
||||
SubtreeEffect bool `db:"subtree_effect" json:"subtree_effect"`
|
||||
ResultingState []byte `db:"resulting_state" json:"resulting_state"`
|
||||
Tombstone []byte `db:"tombstone" json:"tombstone"`
|
||||
PreviousState []byte `db:"previous_state" json:"previous_state"`
|
||||
MutationOrigin []byte `db:"mutation_origin" json:"mutation_origin"`
|
||||
CommittedAt pgtype.Timestamptz `db:"committed_at" json:"committed_at"`
|
||||
}
|
||||
|
||||
type ClientInstance struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
ClientType string `db:"client_type" json:"client_type"`
|
||||
Platform *string `db:"platform" json:"platform"`
|
||||
SoftwareVersion *string `db:"software_version" json:"software_version"`
|
||||
ProtocolVersion *string `db:"protocol_version" json:"protocol_version"`
|
||||
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
||||
RevokedAt pgtype.Timestamptz `db:"revoked_at" json:"revoked_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type CommitIntent struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"`
|
||||
OperationID pgtype.UUID `db:"operation_id" json:"operation_id"`
|
||||
NodeID pgtype.UUID `db:"node_id" json:"node_id"`
|
||||
ParentID pgtype.UUID `db:"parent_id" json:"parent_id"`
|
||||
ExpectedNodeRevision *int64 `db:"expected_node_revision" json:"expected_node_revision"`
|
||||
TargetName string `db:"target_name" json:"target_name"`
|
||||
TargetNameKey string `db:"target_name_key" json:"target_name_key"`
|
||||
ObjectID pgtype.UUID `db:"object_id" json:"object_id"`
|
||||
Sha256 []byte `db:"sha256" json:"sha256"`
|
||||
SizeBytes int64 `db:"size_bytes" json:"size_bytes"`
|
||||
State string `db:"state" json:"state"`
|
||||
PromotedAt pgtype.Timestamptz `db:"promoted_at" json:"promoted_at"`
|
||||
CompletedAt pgtype.Timestamptz `db:"completed_at" json:"completed_at"`
|
||||
FailureCode *string `db:"failure_code" json:"failure_code"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type CredentialRootGrant struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"`
|
||||
SessionID pgtype.UUID `db:"session_id" json:"session_id"`
|
||||
ApiTokenID pgtype.UUID `db:"api_token_id" json:"api_token_id"`
|
||||
RootNodeID pgtype.UUID `db:"root_node_id" json:"root_node_id"`
|
||||
Actions []string `db:"actions" json:"actions"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type DriveMetadatum struct {
|
||||
Singleton bool `db:"singleton" json:"singleton"`
|
||||
InstalledAt pgtype.Timestamptz `db:"installed_at" json:"installed_at"`
|
||||
}
|
||||
|
||||
type ExportItem struct {
|
||||
ExportLeaseID pgtype.UUID `db:"export_lease_id" json:"export_lease_id"`
|
||||
NodeID pgtype.UUID `db:"node_id" json:"node_id"`
|
||||
BlobID pgtype.UUID `db:"blob_id" json:"blob_id"`
|
||||
NodeRevision int64 `db:"node_revision" json:"node_revision"`
|
||||
RelativePath string `db:"relative_path" json:"relative_path"`
|
||||
}
|
||||
|
||||
type ExportLease struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"`
|
||||
OperationID pgtype.UUID `db:"operation_id" json:"operation_id"`
|
||||
State string `db:"state" json:"state"`
|
||||
ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
ReleasedAt pgtype.Timestamptz `db:"released_at" json:"released_at"`
|
||||
}
|
||||
|
||||
type IdempotencyRecord struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"`
|
||||
CredentialKind string `db:"credential_kind" json:"credential_kind"`
|
||||
CredentialID pgtype.UUID `db:"credential_id" json:"credential_id"`
|
||||
ClientInstanceID pgtype.UUID `db:"client_instance_id" json:"client_instance_id"`
|
||||
IdempotencyKey string `db:"idempotency_key" json:"idempotency_key"`
|
||||
RequestFingerprint []byte `db:"request_fingerprint" json:"request_fingerprint"`
|
||||
OperationType string `db:"operation_type" json:"operation_type"`
|
||||
State string `db:"state" json:"state"`
|
||||
ResponseReference []byte `db:"response_reference" json:"response_reference"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
CompletedAt pgtype.Timestamptz `db:"completed_at" json:"completed_at"`
|
||||
ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"`
|
||||
JobType string `db:"job_type" json:"job_type"`
|
||||
State string `db:"state" json:"state"`
|
||||
Payload []byte `db:"payload" json:"payload"`
|
||||
Progress []byte `db:"progress" json:"progress"`
|
||||
AttemptCount int64 `db:"attempt_count" json:"attempt_count"`
|
||||
MaxAttempts int64 `db:"max_attempts" json:"max_attempts"`
|
||||
LeaseHolder pgtype.UUID `db:"lease_holder" json:"lease_holder"`
|
||||
LeaseExpiresAt pgtype.Timestamptz `db:"lease_expires_at" json:"lease_expires_at"`
|
||||
HeartbeatAt pgtype.Timestamptz `db:"heartbeat_at" json:"heartbeat_at"`
|
||||
NextAttemptAt pgtype.Timestamptz `db:"next_attempt_at" json:"next_attempt_at"`
|
||||
CancelRequestedAt pgtype.Timestamptz `db:"cancel_requested_at" json:"cancel_requested_at"`
|
||||
StartedAt pgtype.Timestamptz `db:"started_at" json:"started_at"`
|
||||
CompletedAt pgtype.Timestamptz `db:"completed_at" json:"completed_at"`
|
||||
LastErrorCode *string `db:"last_error_code" json:"last_error_code"`
|
||||
LastErrorDetail *string `db:"last_error_detail" json:"last_error_detail"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"`
|
||||
ParentID pgtype.UUID `db:"parent_id" json:"parent_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameKey string `db:"name_key" json:"name_key"`
|
||||
NodeType string `db:"node_type" json:"node_type"`
|
||||
CurrentBlobID pgtype.UUID `db:"current_blob_id" json:"current_blob_id"`
|
||||
SizeBytes int64 `db:"size_bytes" json:"size_bytes"`
|
||||
MimeType *string `db:"mime_type" json:"mime_type"`
|
||||
NodeRevision int64 `db:"node_revision" json:"node_revision"`
|
||||
ContentRevision int64 `db:"content_revision" json:"content_revision"`
|
||||
ContentModifiedAt pgtype.Timestamptz `db:"content_modified_at" json:"content_modified_at"`
|
||||
MetadataUpdatedAt pgtype.Timestamptz `db:"metadata_updated_at" json:"metadata_updated_at"`
|
||||
Favorite bool `db:"favorite" json:"favorite"`
|
||||
TrashRootAt pgtype.Timestamptz `db:"trash_root_at" json:"trash_root_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type RecoveryCode struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
CodeHash []byte `db:"code_hash" json:"code_hash"`
|
||||
UsedAt pgtype.Timestamptz `db:"used_at" json:"used_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type ReplacementRecovery struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"`
|
||||
NodeID pgtype.UUID `db:"node_id" json:"node_id"`
|
||||
BlobID pgtype.UUID `db:"blob_id" json:"blob_id"`
|
||||
ReplacedAt pgtype.Timestamptz `db:"replaced_at" json:"replaced_at"`
|
||||
ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"`
|
||||
RestoredAt pgtype.Timestamptz `db:"restored_at" json:"restored_at"`
|
||||
}
|
||||
|
||||
type ResourceLease struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"`
|
||||
Purpose string `db:"purpose" json:"purpose"`
|
||||
ResourceKind string `db:"resource_kind" json:"resource_kind"`
|
||||
ResourceID pgtype.UUID `db:"resource_id" json:"resource_id"`
|
||||
HolderID pgtype.UUID `db:"holder_id" json:"holder_id"`
|
||||
AcquiredAt pgtype.Timestamptz `db:"acquired_at" json:"acquired_at"`
|
||||
HeartbeatAt pgtype.Timestamptz `db:"heartbeat_at" json:"heartbeat_at"`
|
||||
ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"`
|
||||
ReleasedAt pgtype.Timestamptz `db:"released_at" json:"released_at"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"`
|
||||
ClientInstanceID pgtype.UUID `db:"client_instance_id" json:"client_instance_id"`
|
||||
TokenHash []byte `db:"token_hash" json:"token_hash"`
|
||||
CsrfTokenHash []byte `db:"csrf_token_hash" json:"csrf_token_hash"`
|
||||
RemoteAddress *netip.Addr `db:"remote_address" json:"remote_address"`
|
||||
UserAgent *string `db:"user_agent" json:"user_agent"`
|
||||
AuthenticatedAt pgtype.Timestamptz `db:"authenticated_at" json:"authenticated_at"`
|
||||
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
||||
ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"`
|
||||
RevokedAt pgtype.Timestamptz `db:"revoked_at" json:"revoked_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type Setting struct {
|
||||
OwnerID pgtype.UUID `db:"owner_id" json:"owner_id"`
|
||||
SettingKey string `db:"setting_key" json:"setting_key"`
|
||||
Value []byte `db:"value" json:"value"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type TotpCredential struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
EncryptedSecret []byte `db:"encrypted_secret" json:"encrypted_secret"`
|
||||
EnabledAt pgtype.Timestamptz `db:"enabled_at" json:"enabled_at"`
|
||||
VerifiedStep *int64 `db:"verified_step" json:"verified_step"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Username string `db:"username" json:"username"`
|
||||
UsernameKey string `db:"username_key" json:"username_key"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||
RootNodeID pgtype.UUID `db:"root_node_id" json:"root_node_id"`
|
||||
SetupCompletedAt pgtype.Timestamptz `db:"setup_completed_at" json:"setup_completed_at"`
|
||||
PasswordChangedAt pgtype.Timestamptz `db:"password_changed_at" json:"password_changed_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
16
internal/adapters/postgres/generated/querier.go
Normal file
16
internal/adapters/postgres/generated/querier.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package dbgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
DatabaseHealth(ctx context.Context) (int64, error)
|
||||
OwnerExists(ctx context.Context) (bool, error)
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
56
internal/adapters/postgres/migrate.go
Normal file
56
internal/adapters/postgres/migrate.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"drive.local/drivev2/db/migrations"
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
pgx5migrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
)
|
||||
|
||||
// Migrate applies every pending forward migration using an advisory lock.
|
||||
func Migrate(databaseURL string) (returnErr error) {
|
||||
sourceDriver, err := iofs.New(migrations.Files, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open embedded migrations: %w", err)
|
||||
}
|
||||
|
||||
migrationURL, err := url.Parse(databaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse database URL for migrations: %w", err)
|
||||
}
|
||||
migrationURL.Scheme = "pgx5"
|
||||
query := migrationURL.Query()
|
||||
query.Set("x-multi-statement", "true")
|
||||
query.Set("x-statement-timeout", "30000")
|
||||
if query.Get("connect_timeout") == "" {
|
||||
query.Set("connect_timeout", "30")
|
||||
}
|
||||
migrationURL.RawQuery = query.Encode()
|
||||
|
||||
databaseDriver, err := (&pgx5migrate.Postgres{}).Open(migrationURL.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("open migration database: %w", err)
|
||||
}
|
||||
|
||||
runner, err := migrate.NewWithInstance("iofs", sourceDriver, "pgx5", databaseDriver)
|
||||
if err != nil {
|
||||
return errors.Join(
|
||||
fmt.Errorf("create migration runner: %w", err),
|
||||
sourceDriver.Close(),
|
||||
databaseDriver.Close(),
|
||||
)
|
||||
}
|
||||
defer func() {
|
||||
sourceErr, databaseErr := runner.Close()
|
||||
returnErr = errors.Join(returnErr, sourceErr, databaseErr)
|
||||
}()
|
||||
|
||||
if err := runner.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("apply database migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
41
internal/adapters/postgres/migrate_integration_test.go
Normal file
41
internal/adapters/postgres/migrate_integration_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMigrationsAndStore(t *testing.T) {
|
||||
databaseURL := os.Getenv("DRIVE_TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("DRIVE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
|
||||
if err := Migrate(databaseURL); err != nil {
|
||||
t.Fatalf("first migration run failed: %v", err)
|
||||
}
|
||||
if err := Migrate(databaseURL); err != nil {
|
||||
t.Fatalf("second migration run failed: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
store, err := Open(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned an error: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := store.DatabaseHealth(ctx); err != nil {
|
||||
t.Fatalf("DatabaseHealth returned an error: %v", err)
|
||||
}
|
||||
initialized, err := store.OwnerExists(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("OwnerExists returned an error: %v", err)
|
||||
}
|
||||
if initialized {
|
||||
t.Fatal("OwnerExists = true on a fresh database, want false")
|
||||
}
|
||||
}
|
||||
46
internal/adapters/postgres/store.go
Normal file
46
internal/adapters/postgres/store.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
dbgen "drive.local/drivev2/internal/adapters/postgres/generated"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Store owns the PostgreSQL connection pool and generated repositories.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
queries *dbgen.Queries
|
||||
}
|
||||
|
||||
func Open(ctx context.Context, databaseURL string) (*Store, error) {
|
||||
poolConfig, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse database configuration: %w", err)
|
||||
}
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create database pool: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
return &Store{pool: pool, queries: dbgen.New(pool)}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() {
|
||||
s.pool.Close()
|
||||
}
|
||||
|
||||
func (s *Store) DatabaseHealth(ctx context.Context) error {
|
||||
_, err := s.queries.DatabaseHealth(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) OwnerExists(ctx context.Context) (bool, error) {
|
||||
return s.queries.OwnerExists(ctx)
|
||||
}
|
||||
41
internal/application/system.go
Normal file
41
internal/application/system.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type SystemRepository interface {
|
||||
DatabaseHealth(context.Context) error
|
||||
OwnerExists(context.Context) (bool, error)
|
||||
}
|
||||
|
||||
type SetupStatus struct {
|
||||
Initialized bool `json:"initialized"`
|
||||
Phase string `json:"phase"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type SystemService struct {
|
||||
repository SystemRepository
|
||||
version string
|
||||
}
|
||||
|
||||
func NewSystemService(repository SystemRepository, version string) *SystemService {
|
||||
return &SystemService{repository: repository, version: version}
|
||||
}
|
||||
|
||||
func (s *SystemService) Readiness(ctx context.Context) error {
|
||||
if err := s.repository.DatabaseHealth(ctx); err != nil {
|
||||
return fmt.Errorf("database health: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SystemService) SetupStatus(ctx context.Context) (SetupStatus, error) {
|
||||
initialized, err := s.repository.OwnerExists(ctx)
|
||||
if err != nil {
|
||||
return SetupStatus{}, fmt.Errorf("read owner setup state: %w", err)
|
||||
}
|
||||
return SetupStatus{Initialized: initialized, Phase: "phase-1", Version: s.version}, nil
|
||||
}
|
||||
44
internal/application/system_test.go
Normal file
44
internal/application/system_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type systemRepositoryStub struct {
|
||||
healthErr error
|
||||
ownerExists bool
|
||||
ownerErr error
|
||||
}
|
||||
|
||||
func (s systemRepositoryStub) DatabaseHealth(context.Context) error {
|
||||
return s.healthErr
|
||||
}
|
||||
|
||||
func (s systemRepositoryStub) OwnerExists(context.Context) (bool, error) {
|
||||
return s.ownerExists, s.ownerErr
|
||||
}
|
||||
|
||||
func TestSystemServiceReadinessPropagatesDatabaseFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := errors.New("database unavailable")
|
||||
service := NewSystemService(systemRepositoryStub{healthErr: want}, "test")
|
||||
if err := service.Readiness(context.Background()); !errors.Is(err, want) {
|
||||
t.Fatalf("Readiness error = %v, want wrapped %v", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemServiceSetupStatusUsesPersistentOwnerState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := NewSystemService(systemRepositoryStub{ownerExists: true}, "test")
|
||||
status, err := service.SetupStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("SetupStatus returned an error: %v", err)
|
||||
}
|
||||
if !status.Initialized || status.Phase != "phase-1" || status.Version != "test" {
|
||||
t.Fatalf("unexpected setup status: %#v", status)
|
||||
}
|
||||
}
|
||||
|
|
@ -21,12 +21,16 @@ func TestImportBoundaries(t *testing.T) {
|
|||
}{
|
||||
{
|
||||
directory: "internal/domain",
|
||||
forbidden: []string{"net/http", "database/sql", "github.com/jackc/pgx", "/internal/adapters/", "/internal/transport/"},
|
||||
forbidden: []string{"net/http", "database/sql", "github.com/jackc/pgx", "os", "io/fs", "path/filepath", "/internal/adapters/", "/internal/transport/"},
|
||||
},
|
||||
{
|
||||
directory: "internal/transport",
|
||||
forbidden: []string{"database/sql", "github.com/jackc/pgx", "os"},
|
||||
},
|
||||
{
|
||||
directory: "internal/adapters/postgres",
|
||||
forbidden: []string{"/internal/transport/"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
|
|
@ -70,8 +74,15 @@ func assertImportAllowed(t *testing.T, path string, spec *ast.ImportSpec, forbid
|
|||
t.Fatalf("unquote import in %s: %v", path, err)
|
||||
}
|
||||
for _, pattern := range forbidden {
|
||||
if importPath == pattern || strings.Contains(importPath, pattern) {
|
||||
if forbiddenImportMatches(importPath, pattern) {
|
||||
t.Errorf("%s imports forbidden package %q", path, importPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func forbiddenImportMatches(importPath, pattern string) bool {
|
||||
if strings.HasPrefix(pattern, "/") {
|
||||
return strings.Contains(importPath, pattern)
|
||||
}
|
||||
return importPath == pattern || strings.HasPrefix(importPath, pattern+"/")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"drive.local/drivev2/internal/application"
|
||||
"drive.local/drivev2/internal/config"
|
||||
)
|
||||
|
||||
|
|
@ -16,15 +17,16 @@ type Server struct {
|
|||
httpServer *http.Server
|
||||
}
|
||||
|
||||
func NewServer(cfg config.Config, version string, logger *slog.Logger, frontend http.Handler) *Server {
|
||||
type SystemService interface {
|
||||
Readiness(context.Context) error
|
||||
SetupStatus(context.Context) (application.SetupStatus, error)
|
||||
}
|
||||
|
||||
func NewServer(cfg config.Config, logger *slog.Logger, system SystemService, frontend http.Handler) *Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health/live", jsonResponse(http.StatusOK, map[string]string{"status": "live"}))
|
||||
mux.HandleFunc("GET /health/ready", jsonResponse(http.StatusOK, map[string]string{"status": "ready"}))
|
||||
mux.HandleFunc("GET /api/v1/setup/status", jsonResponse(http.StatusOK, map[string]any{
|
||||
"initialized": false,
|
||||
"phase": "foundation",
|
||||
"version": version,
|
||||
}))
|
||||
mux.HandleFunc("GET /health/ready", readinessHandler(logger, system))
|
||||
mux.HandleFunc("GET /api/v1/setup/status", setupStatusHandler(logger, system))
|
||||
mux.Handle("/", frontend)
|
||||
|
||||
return &Server{httpServer: &http.Server{
|
||||
|
|
@ -35,6 +37,29 @@ func NewServer(cfg config.Config, version string, logger *slog.Logger, frontend
|
|||
}}
|
||||
}
|
||||
|
||||
func readinessHandler(logger *slog.Logger, system SystemService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := system.Readiness(r.Context()); err != nil {
|
||||
logger.Error("readiness check failed", "error", err)
|
||||
problemResponse(w, http.StatusServiceUnavailable, "dependency_unavailable", "A required dependency is unavailable")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
}
|
||||
}
|
||||
|
||||
func setupStatusHandler(logger *slog.Logger, system SystemService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
status, err := system.SetupStatus(r.Context())
|
||||
if err != nil {
|
||||
logger.Error("setup status failed", "error", err)
|
||||
problemResponse(w, http.StatusServiceUnavailable, "dependency_unavailable", "A required dependency is unavailable")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) ListenAndServe() error {
|
||||
return s.httpServer.ListenAndServe()
|
||||
}
|
||||
|
|
@ -44,13 +69,30 @@ func (s *Server) Shutdown(ctx context.Context) error {
|
|||
}
|
||||
|
||||
func jsonResponse(status int, payload any) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
slog.Error("encode response", "error", err)
|
||||
}
|
||||
return func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, status, payload) }
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
slog.Error("encode response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func problemResponse(w http.ResponseWriter, status int, code, detail string) {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"type": "about:blank",
|
||||
"title": http.StatusText(status),
|
||||
"status": status,
|
||||
"detail": detail,
|
||||
"code": code,
|
||||
}); err != nil {
|
||||
slog.Error("encode problem response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
|
@ -8,16 +10,31 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"drive.local/drivev2/internal/application"
|
||||
"drive.local/drivev2/internal/config"
|
||||
)
|
||||
|
||||
type systemServiceStub struct {
|
||||
readinessErr error
|
||||
setupStatus application.SetupStatus
|
||||
setupErr error
|
||||
}
|
||||
|
||||
func (s systemServiceStub) Readiness(context.Context) error {
|
||||
return s.readinessErr
|
||||
}
|
||||
|
||||
func (s systemServiceStub) SetupStatus(context.Context) (application.SetupStatus, error) {
|
||||
return s.setupStatus, s.setupErr
|
||||
}
|
||||
|
||||
func TestLiveHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := NewServer(
|
||||
config.Config{HTTPAddress: ":0", WebRoot: t.TempDir()},
|
||||
"test",
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
systemServiceStub{},
|
||||
http.NotFoundHandler(),
|
||||
)
|
||||
request := httptest.NewRequest(http.MethodGet, "/health/live", nil)
|
||||
|
|
@ -32,3 +49,50 @@ func TestLiveHealth(t *testing.T) {
|
|||
t.Fatalf("unexpected body: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadinessReportsDependencyFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := NewServer(
|
||||
config.Config{HTTPAddress: ":0", WebRoot: t.TempDir()},
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
systemServiceStub{readinessErr: errors.New("database unavailable")},
|
||||
http.NotFoundHandler(),
|
||||
)
|
||||
request := httptest.NewRequest(http.MethodGet, "/health/ready", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.httpServer.Handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
|
||||
}
|
||||
if contentType := recorder.Header().Get("Content-Type"); contentType != "application/problem+json" {
|
||||
t.Fatalf("Content-Type = %q, want application/problem+json", contentType)
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), `"code":"dependency_unavailable"`) {
|
||||
t.Fatalf("unexpected body: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupStatusUsesApplicationService(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := NewServer(
|
||||
config.Config{HTTPAddress: ":0", WebRoot: t.TempDir()},
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
systemServiceStub{setupStatus: application.SetupStatus{Initialized: true, Phase: "phase-1", Version: "test"}},
|
||||
http.NotFoundHandler(),
|
||||
)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/setup/status", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.httpServer.Handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), `"initialized":true`) {
|
||||
t.Fatalf("unexpected body: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue