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:
|
backend:
|
||||||
name: Backend
|
name: Backend
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
|
|
@ -50,6 +66,10 @@ jobs:
|
||||||
uses: docker://redocly/cli:1.34.5
|
uses: docker://redocly/cli:1.34.5
|
||||||
with:
|
with:
|
||||||
args: lint api/openapi.yaml
|
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
|
- name: Reject committed secrets and local data
|
||||||
run: |
|
run: |
|
||||||
test ! -e .env
|
test ! -e .env
|
||||||
|
|
@ -63,3 +83,5 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Build application image
|
- name: Build application image
|
||||||
run: docker build --build-arg VERSION=${{ github.sha }} -t drive-v2:${{ github.sha }} .
|
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
|
FROM golang:1.26-bookworm AS go-base
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
FROM sqlc/sqlc:1.31.1 AS sqlc-bin
|
||||||
|
|
||||||
FROM node:24-bookworm AS web-dependencies
|
FROM node:24-bookworm AS web-dependencies
|
||||||
WORKDIR /src/web
|
WORKDIR /src/web
|
||||||
COPY web/package.json web/package-lock.json ./
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
|
@ -13,13 +15,16 @@ RUN npm run build
|
||||||
|
|
||||||
FROM go-base AS backend-build
|
FROM go-base AS backend-build
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
COPY go.mod ./
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY db/ ./db/
|
||||||
COPY cmd/ ./cmd/
|
COPY cmd/ ./cmd/
|
||||||
COPY internal/ ./internal/
|
COPY internal/ ./internal/
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/drive ./cmd/drive
|
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
|
FROM node:24-bookworm AS verify
|
||||||
COPY --from=go-base /usr/local/go /usr/local/go
|
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}"
|
ENV PATH="/usr/local/go/bin:${PATH}"
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
@ -46,4 +51,3 @@ USER 65532:65532
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/drive"]
|
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/drive"]
|
||||||
CMD ["serve"]
|
CMD ["serve"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ The approved product and architecture baseline is [`Drive_v2_final_plan.md`](Dri
|
||||||
|
|
||||||
## Repository status
|
## 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
|
## Prerequisites
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ info:
|
||||||
title: Drive v2 API
|
title: Drive v2 API
|
||||||
version: 0.0.0
|
version: 0.0.0
|
||||||
description: >-
|
description: >-
|
||||||
Contract-first API for Drive v2. Phase 0 contains only foundation and
|
Contract-first API for Drive v2. The current Phase 1 persistence foundation
|
||||||
health operations; product endpoints are added with their implementation.
|
exposes only setup status and health operations; product endpoints are added
|
||||||
|
with their implementation.
|
||||||
servers:
|
servers:
|
||||||
- url: /
|
- url: /
|
||||||
paths:
|
paths:
|
||||||
|
|
@ -20,6 +21,12 @@ paths:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/SetupStatus"
|
$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:
|
/health/live:
|
||||||
get:
|
get:
|
||||||
security: []
|
security: []
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ import (
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"drive.local/drivev2/internal/adapters/postgres"
|
||||||
"drive.local/drivev2/internal/adapters/webassets"
|
"drive.local/drivev2/internal/adapters/webassets"
|
||||||
|
"drive.local/drivev2/internal/application"
|
||||||
"drive.local/drivev2/internal/config"
|
"drive.local/drivev2/internal/config"
|
||||||
"drive.local/drivev2/internal/transport/httpapi"
|
"drive.local/drivev2/internal/transport/httpapi"
|
||||||
)
|
)
|
||||||
|
|
@ -46,11 +48,26 @@ func serve() error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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}))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel}))
|
||||||
slog.SetDefault(logger)
|
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)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
-- Drive v2 uses forward-only migrations. Rollback is restore-or-fix-forward.
|
-- Drive v2 uses forward-only migrations. Rollback is restore-or-fix-forward.
|
||||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
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),
|
singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton),
|
||||||
installed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
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
|
-- name: DatabaseHealth :one
|
||||||
SELECT 1::BIGINT AS result;
|
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.
|
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.
|
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.
|
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.
|
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
|
## 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
|
## Architectural changes
|
||||||
|
|
||||||
|
|
|
||||||
13
go.mod
13
go.mod
|
|
@ -2,3 +2,16 @@ module drive.local/drivev2
|
||||||
|
|
||||||
go 1.26.0
|
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",
|
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",
|
directory: "internal/transport",
|
||||||
forbidden: []string{"database/sql", "github.com/jackc/pgx", "os"},
|
forbidden: []string{"database/sql", "github.com/jackc/pgx", "os"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
directory: "internal/adapters/postgres",
|
||||||
|
forbidden: []string{"/internal/transport/"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, rule := range rules {
|
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)
|
t.Fatalf("unquote import in %s: %v", path, err)
|
||||||
}
|
}
|
||||||
for _, pattern := range forbidden {
|
for _, pattern := range forbidden {
|
||||||
if importPath == pattern || strings.Contains(importPath, pattern) {
|
if forbiddenImportMatches(importPath, pattern) {
|
||||||
t.Errorf("%s imports forbidden package %q", path, importPath)
|
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"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"drive.local/drivev2/internal/application"
|
||||||
"drive.local/drivev2/internal/config"
|
"drive.local/drivev2/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -16,15 +17,16 @@ type Server struct {
|
||||||
httpServer *http.Server
|
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 := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /health/live", jsonResponse(http.StatusOK, map[string]string{"status": "live"}))
|
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 /health/ready", readinessHandler(logger, system))
|
||||||
mux.HandleFunc("GET /api/v1/setup/status", jsonResponse(http.StatusOK, map[string]any{
|
mux.HandleFunc("GET /api/v1/setup/status", setupStatusHandler(logger, system))
|
||||||
"initialized": false,
|
|
||||||
"phase": "foundation",
|
|
||||||
"version": version,
|
|
||||||
}))
|
|
||||||
mux.Handle("/", frontend)
|
mux.Handle("/", frontend)
|
||||||
|
|
||||||
return &Server{httpServer: &http.Server{
|
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 {
|
func (s *Server) ListenAndServe() error {
|
||||||
return s.httpServer.ListenAndServe()
|
return s.httpServer.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
|
@ -44,13 +69,30 @@ func (s *Server) Shutdown(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func jsonResponse(status int, payload any) http.HandlerFunc {
|
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) }
|
||||||
w.Header().Set("Content-Type", "application/json")
|
}
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
w.WriteHeader(status)
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
w.Header().Set("Content-Type", "application/json")
|
||||||
slog.Error("encode response", "error", err)
|
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
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -8,16 +10,31 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"drive.local/drivev2/internal/application"
|
||||||
"drive.local/drivev2/internal/config"
|
"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) {
|
func TestLiveHealth(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
server := NewServer(
|
server := NewServer(
|
||||||
config.Config{HTTPAddress: ":0", WebRoot: t.TempDir()},
|
config.Config{HTTPAddress: ":0", WebRoot: t.TempDir()},
|
||||||
"test",
|
|
||||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
|
systemServiceStub{},
|
||||||
http.NotFoundHandler(),
|
http.NotFoundHandler(),
|
||||||
)
|
)
|
||||||
request := httptest.NewRequest(http.MethodGet, "/health/live", nil)
|
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())
|
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
|
#!/bin/sh
|
||||||
set -eu
|
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 vet ./cmd/... ./internal/...
|
||||||
go test -race ./cmd/... ./internal/...
|
go test -race ./cmd/... ./internal/...
|
||||||
npm --prefix web run check
|
npm --prefix web run check
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@ export function App() {
|
||||||
return (
|
return (
|
||||||
<main className="foundation-shell">
|
<main className="foundation-shell">
|
||||||
<section className="foundation-card" aria-labelledby="foundation-title">
|
<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>
|
<h1 id="foundation-title">Drive v2</h1>
|
||||||
<p className="summary">
|
<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>
|
</p>
|
||||||
<dl className="status-list">
|
<dl className="status-list">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -22,7 +22,7 @@ export function App() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Owner setup</dt>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<dt>Build</dt>
|
<dt>Build</dt>
|
||||||
|
|
@ -33,4 +33,3 @@ export function App() {
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,9 @@ import { describe, expect, it } from "vitest";
|
||||||
import type { SetupStatus } from "./getSetupStatus";
|
import type { SetupStatus } from "./getSetupStatus";
|
||||||
|
|
||||||
describe("SetupStatus", () => {
|
describe("SetupStatus", () => {
|
||||||
it("represents the foundation state", () => {
|
it("represents the phase 1 setup state", () => {
|
||||||
const status: SetupStatus = { initialized: false, phase: "foundation", version: "test" };
|
const status: SetupStatus = { initialized: false, phase: "phase-1", version: "test" };
|
||||||
expect(status.phase).toBe("foundation");
|
expect(status.phase).toBe("phase-1");
|
||||||
expect(status.initialized).toBe(false);
|
expect(status.initialized).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue