Initial phase 1 baseline implementation
This commit is contained in:
parent
099c53badf
commit
077cf7601a
29 changed files with 1339 additions and 39 deletions
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
387
db/migrations/000002_phase1_core.up.sql
Normal file
387
db/migrations/000002_phase1_core.up.sql
Normal file
|
|
@ -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;
|
||||
9
db/migrations/migrations.go
Normal file
9
db/migrations/migrations.go
Normal file
|
|
@ -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
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
-- name: DatabaseHealth :one
|
||||
SELECT 1::BIGINT AS result;
|
||||
|
||||
-- name: OwnerExists :one
|
||||
SELECT EXISTS (SELECT 1 FROM users)::BOOLEAN AS exists;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
13
go.mod
13
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
|
||||
)
|
||||
|
|
|
|||
83
go.sum
Normal file
83
go.sum
Normal file
|
|
@ -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=
|
||||
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) {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
55
scripts/smoke-image.sh
Normal file
55
scripts/smoke-image.sh
Normal file
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ export function App() {
|
|||
return (
|
||||
<main className="foundation-shell">
|
||||
<section className="foundation-card" aria-labelledby="foundation-title">
|
||||
<p className="eyebrow">Foundation phase</p>
|
||||
<p className="eyebrow">Phase 1 foundation</p>
|
||||
<h1 id="foundation-title">Drive v2</h1>
|
||||
<p className="summary">
|
||||
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.
|
||||
</p>
|
||||
<dl className="status-list">
|
||||
<div>
|
||||
|
|
@ -22,7 +22,7 @@ export function App() {
|
|||
</div>
|
||||
<div>
|
||||
<dt>Owner setup</dt>
|
||||
<dd>{status.data?.initialized === true ? "Complete" : "Begins in Phase 1"}</dd>
|
||||
<dd>{status.data?.initialized === true ? "Complete" : "Not initialized"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Build</dt>
|
||||
|
|
@ -33,4 +33,3 @@ export function App() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue