-- 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;