From 077cf7601aea2627e01de4589ca775eb25a0f081 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 16 Jul 2026 19:14:01 -0700 Subject: [PATCH] Initial phase 1 baseline implementation --- .forgejo/workflows/ci.yml | 22 + Dockerfile | 8 +- README.md | 2 +- api/openapi.yaml | 11 +- cmd/drive/main.go | 19 +- ...oundation.sql => 000001_foundation.up.sql} | 6 +- db/migrations/000002_phase1_core.up.sql | 387 ++++++++++++++++++ db/migrations/migrations.go | 9 + db/queries/health.sql | 2 + docs/dependencies.md | 5 +- docs/development.md | 2 +- go.mod | 13 + go.sum | 83 ++++ internal/adapters/postgres/generated/db.go | 32 ++ .../adapters/postgres/generated/health.sql.go | 32 ++ .../adapters/postgres/generated/models.go | 266 ++++++++++++ .../adapters/postgres/generated/querier.go | 16 + internal/adapters/postgres/migrate.go | 56 +++ .../postgres/migrate_integration_test.go | 41 ++ internal/adapters/postgres/store.go | 46 +++ internal/application/system.go | 41 ++ internal/application/system_test.go | 44 ++ internal/architecture/architecture_test.go | 15 +- internal/transport/httpapi/server.go | 70 +++- internal/transport/httpapi/server_test.go | 66 ++- scripts/smoke-image.sh | 55 +++ scripts/verify.sh | 15 +- web/src/app/App.tsx | 7 +- .../features/setup/api/getSetupStatus.test.ts | 7 +- 29 files changed, 1339 insertions(+), 39 deletions(-) rename db/migrations/{000001_foundation.sql => 000001_foundation.up.sql} (64%) create mode 100644 db/migrations/000002_phase1_core.up.sql create mode 100644 db/migrations/migrations.go create mode 100644 go.sum create mode 100644 internal/adapters/postgres/generated/db.go create mode 100644 internal/adapters/postgres/generated/health.sql.go create mode 100644 internal/adapters/postgres/generated/models.go create mode 100644 internal/adapters/postgres/generated/querier.go create mode 100644 internal/adapters/postgres/migrate.go create mode 100644 internal/adapters/postgres/migrate_integration_test.go create mode 100644 internal/adapters/postgres/store.go create mode 100644 internal/application/system.go create mode 100644 internal/application/system_test.go create mode 100644 scripts/smoke-image.sh diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 8eb62fd..e6f6711 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -13,6 +13,22 @@ jobs: backend: name: Backend runs-on: ubuntu-latest + services: + postgres: + image: postgres:18.4-bookworm + env: + POSTGRES_DB: drive_test + POSTGRES_USER: drive + POSTGRES_PASSWORD: drive-test-only + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U drive -d drive_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DRIVE_TEST_DATABASE_URL: postgres://drive:drive-test-only@127.0.0.1:5432/drive_test?sslmode=disable steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -50,6 +66,10 @@ jobs: uses: docker://redocly/cli:1.34.5 with: args: lint api/openapi.yaml + - name: Verify generated PostgreSQL repository + run: | + docker run --rm -v "$PWD:/src" -w /src sqlc/sqlc:1.31.1 generate + git diff --exit-code -- internal/adapters/postgres/generated - name: Reject committed secrets and local data run: | test ! -e .env @@ -63,3 +83,5 @@ jobs: - uses: actions/checkout@v4 - name: Build application image run: docker build --build-arg VERSION=${{ github.sha }} -t drive-v2:${{ github.sha }} . + - name: Smoke application image + run: sh scripts/smoke-image.sh drive-v2:${{ github.sha }} diff --git a/Dockerfile b/Dockerfile index eddd154..679cf85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,8 @@ FROM golang:1.26-bookworm AS go-base WORKDIR /src +FROM sqlc/sqlc:1.31.1 AS sqlc-bin + FROM node:24-bookworm AS web-dependencies WORKDIR /src/web COPY web/package.json web/package-lock.json ./ @@ -13,13 +15,16 @@ RUN npm run build FROM go-base AS backend-build ARG VERSION=dev -COPY go.mod ./ +COPY go.mod go.sum ./ +RUN go mod download +COPY db/ ./db/ COPY cmd/ ./cmd/ COPY internal/ ./internal/ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/drive ./cmd/drive FROM node:24-bookworm AS verify COPY --from=go-base /usr/local/go /usr/local/go +COPY --from=sqlc-bin /workspace/sqlc /usr/local/bin/sqlc ENV PATH="/usr/local/go/bin:${PATH}" WORKDIR /src COPY . . @@ -46,4 +51,3 @@ USER 65532:65532 EXPOSE 8080 ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/drive"] CMD ["serve"] - diff --git a/README.md b/README.md index 0a73125..929c8ba 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The approved product and architecture baseline is [`Drive_v2_final_plan.md`](Dri ## Repository status -Phase 0 establishes the repository, module boundaries, contracts, development environment, and CI. The running application currently exposes only bootstrap status and health endpoints; product functionality begins in Phase 1. +Phase 0 established the repository, module boundaries, contracts, development environment, and CI. Phase 1 is underway with embedded forward-only migrations, the core persistence schema, generated PostgreSQL queries, database-backed readiness, and persistent setup status. Product endpoints are not implemented yet. ## Prerequisites diff --git a/api/openapi.yaml b/api/openapi.yaml index 39b9bbd..e0d48c1 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -3,8 +3,9 @@ info: title: Drive v2 API version: 0.0.0 description: >- - Contract-first API for Drive v2. Phase 0 contains only foundation and - health operations; product endpoints are added with their implementation. + Contract-first API for Drive v2. The current Phase 1 persistence foundation + exposes only setup status and health operations; product endpoints are added + with their implementation. servers: - url: / paths: @@ -20,6 +21,12 @@ paths: application/json: schema: $ref: "#/components/schemas/SetupStatus" + "503": + description: Setup state cannot be read because a required dependency is unavailable + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" /health/live: get: security: [] diff --git a/cmd/drive/main.go b/cmd/drive/main.go index d462c5f..7e2cb70 100644 --- a/cmd/drive/main.go +++ b/cmd/drive/main.go @@ -10,7 +10,9 @@ import ( "syscall" "time" + "drive.local/drivev2/internal/adapters/postgres" "drive.local/drivev2/internal/adapters/webassets" + "drive.local/drivev2/internal/application" "drive.local/drivev2/internal/config" "drive.local/drivev2/internal/transport/httpapi" ) @@ -46,11 +48,26 @@ func serve() error { if err != nil { return err } + if cfg.DatabaseURL == "" { + return errors.New("DRIVE_DATABASE_URL is required for serve") + } logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel})) slog.SetDefault(logger) - server := httpapi.NewServer(cfg, version, logger, webassets.New(cfg.WebRoot)) + startupCtx, startupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer startupCancel() + if err := postgres.Migrate(cfg.DatabaseURL); err != nil { + return err + } + store, err := postgres.Open(startupCtx, cfg.DatabaseURL) + if err != nil { + return err + } + defer store.Close() + + systemService := application.NewSystemService(store, version) + server := httpapi.NewServer(cfg, logger, systemService, webassets.New(cfg.WebRoot)) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() diff --git a/db/migrations/000001_foundation.sql b/db/migrations/000001_foundation.up.sql similarity index 64% rename from db/migrations/000001_foundation.sql rename to db/migrations/000001_foundation.up.sql index 0c8d9c4..580df1c 100644 --- a/db/migrations/000001_foundation.sql +++ b/db/migrations/000001_foundation.up.sql @@ -1,10 +1,10 @@ -- Drive v2 uses forward-only migrations. Rollback is restore-or-fix-forward. CREATE EXTENSION IF NOT EXISTS pg_trgm; -CREATE TABLE drive_metadata ( +CREATE TABLE IF NOT EXISTS drive_metadata ( singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), installed_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -INSERT INTO drive_metadata (singleton) VALUES (TRUE); - +INSERT INTO drive_metadata (singleton) VALUES (TRUE) +ON CONFLICT (singleton) DO NOTHING; diff --git a/db/migrations/000002_phase1_core.up.sql b/db/migrations/000002_phase1_core.up.sql new file mode 100644 index 0000000..53c5743 --- /dev/null +++ b/db/migrations/000002_phase1_core.up.sql @@ -0,0 +1,387 @@ +-- Phase 1 persistence foundation. UUIDv7 is provided by PostgreSQL 18. + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + username TEXT NOT NULL, + username_key TEXT NOT NULL, + display_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + root_node_id UUID, + setup_completed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + password_changed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT users_username_not_blank CHECK (btrim(username) <> ''), + CONSTRAINT users_username_key_not_blank CHECK (btrim(username_key) <> ''), + CONSTRAINT users_display_name_not_blank CHECK (btrim(display_name) <> ''), + CONSTRAINT users_username_key_unique UNIQUE (username_key) +); + +CREATE UNIQUE INDEX users_single_owner ON users ((TRUE)); + +CREATE TABLE totp_credentials ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + encrypted_secret BYTEA NOT NULL, + enabled_at TIMESTAMPTZ, + verified_step BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT totp_secret_not_empty CHECK (octet_length(encrypted_secret) > 0), + CONSTRAINT totp_verified_step_nonnegative CHECK (verified_step IS NULL OR verified_step >= 0) +); + +CREATE TABLE recovery_codes ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash BYTEA NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT recovery_codes_hash_not_empty CHECK (octet_length(code_hash) > 0), + CONSTRAINT recovery_codes_hash_unique UNIQUE (user_id, code_hash) +); + +CREATE TABLE client_instances ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + display_name TEXT NOT NULL, + client_type TEXT NOT NULL, + platform TEXT, + software_version TEXT, + protocol_version TEXT, + last_seen_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT client_instances_display_name_not_blank CHECK (btrim(display_name) <> ''), + CONSTRAINT client_instances_type_not_blank CHECK (btrim(client_type) <> '') +); + +CREATE INDEX client_instances_owner_active_idx + ON client_instances (owner_id, created_at DESC) + WHERE revoked_at IS NULL; + +CREATE TABLE sessions ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_instance_id UUID REFERENCES client_instances(id) ON DELETE SET NULL, + token_hash BYTEA NOT NULL UNIQUE, + csrf_token_hash BYTEA NOT NULL, + remote_address INET, + user_agent TEXT, + authenticated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT sessions_token_hash_not_empty CHECK (octet_length(token_hash) > 0), + CONSTRAINT sessions_csrf_hash_not_empty CHECK (octet_length(csrf_token_hash) > 0), + CONSTRAINT sessions_expiry_after_creation CHECK (expires_at > created_at) +); + +CREATE INDEX sessions_owner_active_idx + ON sessions (owner_id, expires_at) + WHERE revoked_at IS NULL; + +CREATE TABLE api_tokens ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_instance_id UUID REFERENCES client_instances(id) ON DELETE SET NULL, + name TEXT NOT NULL, + token_hash BYTEA NOT NULL UNIQUE, + actions TEXT[] NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + CONSTRAINT api_tokens_name_not_blank CHECK (btrim(name) <> ''), + CONSTRAINT api_tokens_hash_not_empty CHECK (octet_length(token_hash) > 0), + CONSTRAINT api_tokens_actions_not_empty CHECK (cardinality(actions) > 0), + CONSTRAINT api_tokens_expiry_after_creation CHECK (expires_at IS NULL OR expires_at > created_at) +); + +CREATE INDEX api_tokens_owner_active_idx + ON api_tokens (owner_id, created_at DESC) + WHERE revoked_at IS NULL; + +CREATE TABLE blobs ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + object_id UUID NOT NULL DEFAULT uuidv7(), + sha256 BYTEA NOT NULL, + size_bytes BIGINT NOT NULL, + state TEXT NOT NULL, + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT blobs_object_id_unique UNIQUE (object_id), + CONSTRAINT blobs_owner_sha256_unique UNIQUE (owner_id, sha256), + CONSTRAINT blobs_sha256_length CHECK (octet_length(sha256) = 32), + CONSTRAINT blobs_size_nonnegative CHECK (size_bytes >= 0), + CONSTRAINT blobs_state_valid CHECK (state IN ('staged', 'available', 'unavailable')), + CONSTRAINT blobs_available_verified CHECK (state <> 'available' OR verified_at IS NOT NULL) +); + +CREATE INDEX blobs_gc_candidates_idx ON blobs (created_at) WHERE state <> 'available'; + +CREATE TABLE nodes ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + parent_id UUID REFERENCES nodes(id) ON DELETE RESTRICT DEFERRABLE INITIALLY IMMEDIATE, + name TEXT NOT NULL, + name_key TEXT NOT NULL, + node_type TEXT NOT NULL, + current_blob_id UUID REFERENCES blobs(id) ON DELETE RESTRICT, + size_bytes BIGINT NOT NULL DEFAULT 0, + mime_type TEXT, + node_revision BIGINT NOT NULL DEFAULT 1, + content_revision BIGINT NOT NULL DEFAULT 0, + content_modified_at TIMESTAMPTZ NOT NULL DEFAULT now(), + metadata_updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + favorite BOOLEAN NOT NULL DEFAULT FALSE, + trash_root_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT nodes_name_not_blank CHECK (btrim(name) <> ''), + CONSTRAINT nodes_name_key_not_blank CHECK (btrim(name_key) <> ''), + CONSTRAINT nodes_type_valid CHECK (node_type IN ('directory', 'file')), + CONSTRAINT nodes_file_blob_shape CHECK ( + (node_type = 'directory' AND current_blob_id IS NULL AND size_bytes = 0 AND content_revision = 0) + OR (node_type = 'file' AND current_blob_id IS NOT NULL) + ), + CONSTRAINT nodes_size_nonnegative CHECK (size_bytes >= 0), + CONSTRAINT nodes_node_revision_positive CHECK (node_revision > 0), + CONSTRAINT nodes_content_revision_nonnegative CHECK (content_revision >= 0), + CONSTRAINT nodes_not_own_parent CHECK (parent_id IS NULL OR parent_id <> id) +); + +CREATE UNIQUE INDEX nodes_sibling_name_unique + ON nodes (owner_id, parent_id, name_key) NULLS NOT DISTINCT; +CREATE INDEX nodes_parent_listing_idx ON nodes (owner_id, parent_id, name_key, id); +CREATE INDEX nodes_current_blob_idx ON nodes (current_blob_id) WHERE current_blob_id IS NOT NULL; +CREATE INDEX nodes_trash_roots_idx ON nodes (owner_id, trash_root_at) WHERE trash_root_at IS NOT NULL; +CREATE INDEX nodes_name_trgm_idx ON nodes USING gin (name gin_trgm_ops); + +ALTER TABLE users + ADD CONSTRAINT users_root_node_fk + FOREIGN KEY (root_node_id) REFERENCES nodes(id) ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED; + +CREATE TABLE credential_root_grants ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_id UUID REFERENCES sessions(id) ON DELETE CASCADE, + api_token_id UUID REFERENCES api_tokens(id) ON DELETE CASCADE, + root_node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + actions TEXT[] NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT credential_root_grants_one_credential CHECK (num_nonnulls(session_id, api_token_id) = 1), + CONSTRAINT credential_root_grants_actions_not_empty CHECK (cardinality(actions) > 0) +); + +CREATE UNIQUE INDEX credential_root_grants_session_unique + ON credential_root_grants (session_id, root_node_id) WHERE session_id IS NOT NULL; +CREATE UNIQUE INDEX credential_root_grants_token_unique + ON credential_root_grants (api_token_id, root_node_id) WHERE api_token_id IS NOT NULL; + +CREATE TABLE idempotency_records ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_kind TEXT NOT NULL, + credential_id UUID NOT NULL, + client_instance_id UUID REFERENCES client_instances(id) ON DELETE SET NULL, + idempotency_key TEXT NOT NULL, + request_fingerprint BYTEA NOT NULL, + operation_type TEXT NOT NULL, + state TEXT NOT NULL, + response_reference JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL, + CONSTRAINT idempotency_key_not_blank CHECK (btrim(idempotency_key) <> ''), + CONSTRAINT idempotency_fingerprint_not_empty CHECK (octet_length(request_fingerprint) > 0), + CONSTRAINT idempotency_operation_not_blank CHECK (btrim(operation_type) <> ''), + CONSTRAINT idempotency_credential_kind_valid CHECK (credential_kind IN ('session', 'api_token', 'internal')), + CONSTRAINT idempotency_state_valid CHECK (state IN ('in_progress', 'completed', 'failed')), + CONSTRAINT idempotency_expiry_after_creation CHECK (expires_at > created_at), + CONSTRAINT idempotency_scope_unique UNIQUE (owner_id, credential_kind, credential_id, idempotency_key) +); + +CREATE INDEX idempotency_records_expiry_idx ON idempotency_records (expires_at); + +CREATE TABLE changes ( + sequence BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + event_id UUID NOT NULL DEFAULT uuidv7() UNIQUE, + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + event_type TEXT NOT NULL, + schema_version INTEGER NOT NULL DEFAULT 1, + node_id UUID NOT NULL, + node_revision BIGINT, + content_revision BIGINT, + subtree_effect BOOLEAN NOT NULL DEFAULT FALSE, + resulting_state JSONB, + tombstone JSONB, + previous_state JSONB, + mutation_origin JSONB NOT NULL, + committed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT changes_event_type_not_blank CHECK (btrim(event_type) <> ''), + CONSTRAINT changes_schema_version_positive CHECK (schema_version > 0), + CONSTRAINT changes_node_revision_positive CHECK (node_revision IS NULL OR node_revision > 0), + CONSTRAINT changes_content_revision_nonnegative CHECK (content_revision IS NULL OR content_revision >= 0), + CONSTRAINT changes_result_shape CHECK (num_nonnulls(resulting_state, tombstone) = 1) +); + +CREATE INDEX changes_owner_sequence_idx ON changes (owner_id, sequence); +CREATE INDEX changes_node_history_idx ON changes (owner_id, node_id, sequence DESC); + +CREATE TABLE replacement_recovery ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + blob_id UUID NOT NULL REFERENCES blobs(id) ON DELETE RESTRICT, + replaced_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + restored_at TIMESTAMPTZ, + CONSTRAINT replacement_recovery_expiry_after_replace CHECK (expires_at > replaced_at) +); + +CREATE INDEX replacement_recovery_active_idx + ON replacement_recovery (owner_id, expires_at) + WHERE restored_at IS NULL; +CREATE INDEX replacement_recovery_blob_idx ON replacement_recovery (blob_id); + +CREATE TABLE commit_intents ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + operation_id UUID NOT NULL, + node_id UUID REFERENCES nodes(id) ON DELETE RESTRICT, + parent_id UUID REFERENCES nodes(id) ON DELETE RESTRICT, + expected_node_revision BIGINT, + target_name TEXT NOT NULL, + target_name_key TEXT NOT NULL, + object_id UUID NOT NULL, + sha256 BYTEA NOT NULL, + size_bytes BIGINT NOT NULL, + state TEXT NOT NULL, + promoted_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + failure_code TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT commit_intents_operation_unique UNIQUE (owner_id, operation_id), + CONSTRAINT commit_intents_target_name_not_blank CHECK (btrim(target_name) <> ''), + CONSTRAINT commit_intents_target_key_not_blank CHECK (btrim(target_name_key) <> ''), + CONSTRAINT commit_intents_expected_revision_positive CHECK (expected_node_revision IS NULL OR expected_node_revision > 0), + CONSTRAINT commit_intents_sha256_length CHECK (octet_length(sha256) = 32), + CONSTRAINT commit_intents_size_nonnegative CHECK (size_bytes >= 0), + CONSTRAINT commit_intents_state_valid CHECK (state IN ('pending', 'promoted', 'completed', 'failed')) +); + +CREATE INDEX commit_intents_recovery_idx + ON commit_intents (state, created_at) + WHERE state IN ('pending', 'promoted'); + +CREATE TABLE resource_leases ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + purpose TEXT NOT NULL, + resource_kind TEXT NOT NULL, + resource_id UUID NOT NULL, + holder_id UUID NOT NULL, + acquired_at TIMESTAMPTZ NOT NULL DEFAULT now(), + heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + released_at TIMESTAMPTZ, + CONSTRAINT resource_leases_purpose_not_blank CHECK (btrim(purpose) <> ''), + CONSTRAINT resource_leases_kind_not_blank CHECK (btrim(resource_kind) <> ''), + CONSTRAINT resource_leases_expiry_after_acquire CHECK (expires_at > acquired_at) +); + +CREATE UNIQUE INDEX resource_leases_active_unique + ON resource_leases (owner_id, purpose, resource_kind, resource_id) + WHERE released_at IS NULL; +CREATE INDEX resource_leases_expiry_idx + ON resource_leases (expires_at) + WHERE released_at IS NULL; + +CREATE TABLE jobs ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + owner_id UUID REFERENCES users(id) ON DELETE CASCADE, + job_type TEXT NOT NULL, + state TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + progress JSONB NOT NULL DEFAULT '{}'::jsonb, + attempt_count BIGINT NOT NULL DEFAULT 0, + max_attempts BIGINT NOT NULL DEFAULT 5, + lease_holder UUID, + lease_expires_at TIMESTAMPTZ, + heartbeat_at TIMESTAMPTZ, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + cancel_requested_at TIMESTAMPTZ, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + last_error_code TEXT, + last_error_detail TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT jobs_type_not_blank CHECK (btrim(job_type) <> ''), + CONSTRAINT jobs_state_valid CHECK (state IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')), + CONSTRAINT jobs_attempt_count_nonnegative CHECK (attempt_count >= 0), + CONSTRAINT jobs_max_attempts_positive CHECK (max_attempts > 0) +); + +CREATE INDEX jobs_claim_idx ON jobs (next_attempt_at, created_at) + WHERE state = 'queued'; +CREATE INDEX jobs_lease_idx ON jobs (lease_expires_at) + WHERE state = 'running'; + +CREATE TABLE settings ( + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + setting_key TEXT NOT NULL, + value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (owner_id, setting_key), + CONSTRAINT settings_key_not_blank CHECK (btrim(setting_key) <> '') +); + +CREATE TABLE audit_events ( + sequence BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + event_id UUID NOT NULL DEFAULT uuidv7() UNIQUE, + owner_id UUID REFERENCES users(id) ON DELETE SET NULL, + actor JSONB NOT NULL, + action TEXT NOT NULL, + resource_type TEXT, + resource_id UUID, + outcome TEXT NOT NULL, + request_id UUID, + operation_id UUID, + details JSONB NOT NULL DEFAULT '{}'::jsonb, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT audit_events_action_not_blank CHECK (btrim(action) <> ''), + CONSTRAINT audit_events_outcome_valid CHECK (outcome IN ('succeeded', 'denied', 'failed')) +); + +CREATE INDEX audit_events_owner_time_idx ON audit_events (owner_id, occurred_at DESC); +CREATE INDEX audit_events_resource_idx ON audit_events (resource_type, resource_id, occurred_at DESC); + +CREATE TABLE export_leases ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + operation_id UUID NOT NULL, + state TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + released_at TIMESTAMPTZ, + CONSTRAINT export_leases_operation_unique UNIQUE (owner_id, operation_id), + CONSTRAINT export_leases_state_valid CHECK (state IN ('active', 'released', 'expired')), + CONSTRAINT export_leases_expiry_after_creation CHECK (expires_at > created_at) +); + +CREATE TABLE export_items ( + export_lease_id UUID NOT NULL REFERENCES export_leases(id) ON DELETE CASCADE, + node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE RESTRICT, + blob_id UUID REFERENCES blobs(id) ON DELETE RESTRICT, + node_revision BIGINT NOT NULL, + relative_path TEXT NOT NULL, + PRIMARY KEY (export_lease_id, node_id), + CONSTRAINT export_items_node_revision_positive CHECK (node_revision > 0), + CONSTRAINT export_items_relative_path_not_blank CHECK (btrim(relative_path) <> '') +); + +CREATE INDEX export_items_blob_idx ON export_items (blob_id) WHERE blob_id IS NOT NULL; diff --git a/db/migrations/migrations.go b/db/migrations/migrations.go new file mode 100644 index 0000000..b3e9a67 --- /dev/null +++ b/db/migrations/migrations.go @@ -0,0 +1,9 @@ +// Package migrations embeds the forward-only PostgreSQL migration source. +package migrations + +import "embed" + +// Files contains every forward migration used by the application at startup. +// +//go:embed *.up.sql +var Files embed.FS diff --git a/db/queries/health.sql b/db/queries/health.sql index e42afc3..3b30967 100644 --- a/db/queries/health.sql +++ b/db/queries/health.sql @@ -1,3 +1,5 @@ -- name: DatabaseHealth :one SELECT 1::BIGINT AS result; +-- name: OwnerExists :one +SELECT EXISTS (SELECT 1 FROM users)::BOOLEAN AS exists; diff --git a/docs/dependencies.md b/docs/dependencies.md index c4d13d2..62eaf8e 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -2,13 +2,12 @@ Dependencies are pinned in `go.mod`, `web/package.json`, the npm lockfile, container tags, and CI workflow references. Add a runtime dependency only when its functionality is actively used, and record architectural additions in an ADR before merging. -## Phase 0 baseline +## Phase 1 baseline -Backend production code currently uses the Go standard library. Chi, pgx, sqlc, and the migration runner are approved by the architecture baseline and will be pinned when their first Phase 1 implementation is added. +Backend production code uses pgx v5 for PostgreSQL access and golang-migrate v4 for embedded, forward-only migrations. sqlc 1.31.1 generates the PostgreSQL repository. Chi remains approved by the architecture baseline and will be pinned when routing needs exceed the standard library multiplexer. Frontend runtime dependencies are React, React DOM, TanStack Query, and TanStack Router. Development dependencies provide TypeScript, Vite, Vitest, ESLint, React tooling, and type declarations. Container baselines are Go 1.26 on Debian Bookworm, Node 24 on Debian Bookworm, PostgreSQL 18.4 on Debian Bookworm, and a Debian Bookworm slim runtime. Production deployment should pin tested image digests as part of its release configuration. Dependency updates must pass the full repository check and production image build. Avoid packages that duplicate standard-library functionality or introduce background network behavior, telemetry, install scripts, or unreviewed native binaries. - diff --git a/docs/development.md b/docs/development.md index cdc63a8..9a7b4ee 100644 --- a/docs/development.md +++ b/docs/development.md @@ -30,7 +30,7 @@ docker compose up --build ## Contract workflow -Do not add an API handler before its OpenAPI operation exists. Do not write SQL directly in transport or application code; add a named sqlc query. Generated files are outputs and are never edited by hand. CI will add strict generation-drift checks when the first generated client and repository are introduced in Phase 1. +Do not add an API handler before its OpenAPI operation exists. Do not write SQL directly in transport or application code; add a named sqlc query. Generated files are outputs and are never edited by hand. CI regenerates the PostgreSQL repository and rejects drift; the same check applies to the generated API client when it is introduced. ## Architectural changes diff --git a/go.mod b/go.mod index 1d9afd3..de600e6 100644 --- a/go.mod +++ b/go.mod @@ -2,3 +2,16 @@ module drive.local/drivev2 go 1.26.0 +require ( + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/jackc/pgx/v5 v5.10.0 +) + +require ( + github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/text v0.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2718037 --- /dev/null +++ b/go.sum @@ -0,0 +1,83 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/adapters/postgres/generated/db.go b/internal/adapters/postgres/generated/db.go new file mode 100644 index 0000000..9db2f37 --- /dev/null +++ b/internal/adapters/postgres/generated/db.go @@ -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, + } +} diff --git a/internal/adapters/postgres/generated/health.sql.go b/internal/adapters/postgres/generated/health.sql.go new file mode 100644 index 0000000..e329a2d --- /dev/null +++ b/internal/adapters/postgres/generated/health.sql.go @@ -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 +} diff --git a/internal/adapters/postgres/generated/models.go b/internal/adapters/postgres/generated/models.go new file mode 100644 index 0000000..79b16af --- /dev/null +++ b/internal/adapters/postgres/generated/models.go @@ -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"` +} diff --git a/internal/adapters/postgres/generated/querier.go b/internal/adapters/postgres/generated/querier.go new file mode 100644 index 0000000..0958516 --- /dev/null +++ b/internal/adapters/postgres/generated/querier.go @@ -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) diff --git a/internal/adapters/postgres/migrate.go b/internal/adapters/postgres/migrate.go new file mode 100644 index 0000000..16304ac --- /dev/null +++ b/internal/adapters/postgres/migrate.go @@ -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 +} diff --git a/internal/adapters/postgres/migrate_integration_test.go b/internal/adapters/postgres/migrate_integration_test.go new file mode 100644 index 0000000..bb31a6b --- /dev/null +++ b/internal/adapters/postgres/migrate_integration_test.go @@ -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") + } +} diff --git a/internal/adapters/postgres/store.go b/internal/adapters/postgres/store.go new file mode 100644 index 0000000..cfb52c6 --- /dev/null +++ b/internal/adapters/postgres/store.go @@ -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) +} diff --git a/internal/application/system.go b/internal/application/system.go new file mode 100644 index 0000000..a424674 --- /dev/null +++ b/internal/application/system.go @@ -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 +} diff --git a/internal/application/system_test.go b/internal/application/system_test.go new file mode 100644 index 0000000..7218710 --- /dev/null +++ b/internal/application/system_test.go @@ -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) + } +} diff --git a/internal/architecture/architecture_test.go b/internal/architecture/architecture_test.go index d977af7..ba116c7 100644 --- a/internal/architecture/architecture_test.go +++ b/internal/architecture/architecture_test.go @@ -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+"/") +} diff --git a/internal/transport/httpapi/server.go b/internal/transport/httpapi/server.go index b53751c..6021d61 100644 --- a/internal/transport/httpapi/server.go +++ b/internal/transport/httpapi/server.go @@ -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) } } diff --git a/internal/transport/httpapi/server_test.go b/internal/transport/httpapi/server_test.go index 832aa07..ae42195 100644 --- a/internal/transport/httpapi/server_test.go +++ b/internal/transport/httpapi/server_test.go @@ -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()) + } +} diff --git a/scripts/smoke-image.sh b/scripts/smoke-image.sh new file mode 100644 index 0000000..877cd66 --- /dev/null +++ b/scripts/smoke-image.sh @@ -0,0 +1,55 @@ +#!/bin/sh +set -eu + +image="${1:-drive-v2:dev}" +suffix="$$" +network="drive-v2-smoke-${suffix}" +database_container="drive-v2-smoke-db-${suffix}" +application_container="drive-v2-smoke-app-${suffix}" + +cleanup() { + docker rm -f "$application_container" "$database_container" >/dev/null 2>&1 || true + docker network rm "$network" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +docker network create "$network" >/dev/null +docker run -d --name "$database_container" --network "$network" \ + -e POSTGRES_DB=drive \ + -e POSTGRES_USER=drive \ + -e POSTGRES_PASSWORD=drive-smoke-only \ + --tmpfs /var/lib/postgresql \ + postgres:18.4-bookworm >/dev/null + +attempt=0 +until docker exec "$database_container" pg_isready -U drive -d drive >/dev/null 2>&1; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 30 ]; then + docker logs "$database_container" + exit 1 + fi + sleep 1 +done + +docker run -d --name "$application_container" --network "$network" \ + --read-only --tmpfs /tmp:size=64m,mode=1777 \ + --cap-drop ALL --security-opt no-new-privileges:true \ + -e DRIVE_DATABASE_URL="postgres://drive:drive-smoke-only@${database_container}:5432/drive?sslmode=disable" \ + -p 127.0.0.1::8080 \ + "$image" >/dev/null + +published="$(docker port "$application_container" 8080/tcp)" +port="${published##*:}" +attempt=0 +until curl --fail --silent "http://127.0.0.1:${port}/health/ready" | grep -q '"status":"ready"'; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 30 ]; then + docker logs "$application_container" + exit 1 + fi + sleep 1 +done + +curl --fail --silent "http://127.0.0.1:${port}/api/v1/setup/status" \ + | grep -q '"initialized":false' +test "$(docker inspect --format '{{.Config.User}}' "$application_container")" = "65532:65532" diff --git a/scripts/verify.sh b/scripts/verify.sh index 5c5d0f4..e98cf77 100644 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -1,7 +1,20 @@ #!/bin/sh set -eu -test -z "$(gofmt -l cmd internal)" +test -z "$(gofmt -l cmd internal db)" + +generated_hash() { + find internal/adapters/postgres/generated -type f -print0 \ + | sort -z \ + | xargs -0 sha256sum \ + | sha256sum +} + +generated_before="$(generated_hash)" +sqlc generate +generated_after="$(generated_hash)" +test "$generated_before" = "$generated_after" + go vet ./cmd/... ./internal/... go test -race ./cmd/... ./internal/... npm --prefix web run check diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index bb7692a..887135f 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -10,10 +10,10 @@ export function App() { return (
-

Foundation phase

+

Phase 1 foundation

Drive v2

- The repository, contracts, architecture boundaries, and deployment shell are ready for product development. + The persistence foundation is connected and ready for owner setup and authentication.

@@ -22,7 +22,7 @@ export function App() {
Owner setup
-
{status.data?.initialized === true ? "Complete" : "Begins in Phase 1"}
+
{status.data?.initialized === true ? "Complete" : "Not initialized"}
Build
@@ -33,4 +33,3 @@ export function App() {
); } - diff --git a/web/src/features/setup/api/getSetupStatus.test.ts b/web/src/features/setup/api/getSetupStatus.test.ts index a2723ce..d2fb7c0 100644 --- a/web/src/features/setup/api/getSetupStatus.test.ts +++ b/web/src/features/setup/api/getSetupStatus.test.ts @@ -2,10 +2,9 @@ import { describe, expect, it } from "vitest"; import type { SetupStatus } from "./getSetupStatus"; describe("SetupStatus", () => { - it("represents the foundation state", () => { - const status: SetupStatus = { initialized: false, phase: "foundation", version: "test" }; - expect(status.phase).toBe("foundation"); + it("represents the phase 1 setup state", () => { + const status: SetupStatus = { initialized: false, phase: "phase-1", version: "test" }; + expect(status.phase).toBe("phase-1"); expect(status.initialized).toBe(false); }); }); -