feat: support different default pg schema (#222)

* feat: support different default pg schema

* feat: support different default pg schema
This commit is contained in:
Nicolò Boschi 2026-01-28 18:14:44 +01:00 committed by GitHub
parent d2b797fff8
commit 2b72e1fd68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 43 additions and 13 deletions

View file

@ -26,6 +26,7 @@ HINDSIGHT_API_LOG_LEVEL=info
# Database (Optional - uses embedded pg0 by default) # Database (Optional - uses embedded pg0 by default)
# HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/db # HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/db
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
# Embeddings Configuration (Optional - uses local by default) # Embeddings Configuration (Optional - uses local by default)
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference) # Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)

View file

@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
# Environment variable names # Environment variable names
ENV_DATABASE_URL = "HINDSIGHT_API_DATABASE_URL" ENV_DATABASE_URL = "HINDSIGHT_API_DATABASE_URL"
ENV_DATABASE_SCHEMA = "HINDSIGHT_API_DATABASE_SCHEMA"
ENV_LLM_PROVIDER = "HINDSIGHT_API_LLM_PROVIDER" ENV_LLM_PROVIDER = "HINDSIGHT_API_LLM_PROVIDER"
ENV_LLM_API_KEY = "HINDSIGHT_API_LLM_API_KEY" ENV_LLM_API_KEY = "HINDSIGHT_API_LLM_API_KEY"
ENV_LLM_MODEL = "HINDSIGHT_API_LLM_MODEL" ENV_LLM_MODEL = "HINDSIGHT_API_LLM_MODEL"
@ -125,6 +126,7 @@ ENV_REFLECT_MAX_ITERATIONS = "HINDSIGHT_API_REFLECT_MAX_ITERATIONS"
# Default values # Default values
DEFAULT_DATABASE_URL = "pg0" DEFAULT_DATABASE_URL = "pg0"
DEFAULT_DATABASE_SCHEMA = "public"
DEFAULT_LLM_PROVIDER = "openai" DEFAULT_LLM_PROVIDER = "openai"
DEFAULT_LLM_MODEL = "gpt-5-mini" DEFAULT_LLM_MODEL = "gpt-5-mini"
DEFAULT_LLM_MAX_CONCURRENT = 32 DEFAULT_LLM_MAX_CONCURRENT = 32
@ -270,6 +272,7 @@ class HindsightConfig:
# Database # Database
database_url: str database_url: str
database_schema: str
# LLM (default, used as fallback for per-operation config) # LLM (default, used as fallback for per-operation config)
llm_provider: str llm_provider: str
@ -367,6 +370,7 @@ class HindsightConfig:
return cls( return cls(
# Database # Database
database_url=os.getenv(ENV_DATABASE_URL, DEFAULT_DATABASE_URL), database_url=os.getenv(ENV_DATABASE_URL, DEFAULT_DATABASE_URL),
database_schema=os.getenv(ENV_DATABASE_SCHEMA, DEFAULT_DATABASE_SCHEMA),
# LLM # LLM
llm_provider=os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER), llm_provider=os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER),
llm_api_key=os.getenv(ENV_LLM_API_KEY), llm_api_key=os.getenv(ENV_LLM_API_KEY),
@ -515,7 +519,7 @@ class HindsightConfig:
def log_config(self) -> None: def log_config(self) -> None:
"""Log the current configuration (without sensitive values).""" """Log the current configuration (without sensitive values)."""
logger.info(f"Database: {self.database_url}") logger.info(f"Database: {self.database_url} (schema: {self.database_schema})")
logger.info(f"LLM: provider={self.llm_provider}, model={self.llm_model}") logger.info(f"LLM: provider={self.llm_provider}, model={self.llm_model}")
if self.retain_llm_provider or self.retain_llm_model: if self.retain_llm_provider or self.retain_llm_model:
retain_provider = self.retain_llm_provider or self.llm_provider retain_provider = self.retain_llm_provider or self.llm_provider

View file

@ -23,12 +23,17 @@ from ..metrics import get_metrics_collector
from .db_budget import budgeted_operation from .db_budget import budgeted_operation
# Context variable for current schema (async-safe, per-task isolation) # Context variable for current schema (async-safe, per-task isolation)
_current_schema: contextvars.ContextVar[str] = contextvars.ContextVar("current_schema", default="public") # Note: default is None, actual default comes from config via get_current_schema()
_current_schema: contextvars.ContextVar[str | None] = contextvars.ContextVar("current_schema", default=None)
def get_current_schema() -> str: def get_current_schema() -> str:
"""Get the current schema from context (default: 'public').""" """Get the current schema from context (falls back to config default)."""
return _current_schema.get() schema = _current_schema.get()
if schema is None:
# Fall back to configured default schema
return get_config().database_schema
return schema
def fq_table(table_name: str) -> str: def fq_table(table_name: str) -> str:
@ -881,11 +886,12 @@ class MemoryEngine(MemoryEngineInterface):
if not self.db_url: if not self.db_url:
raise ValueError("Database URL is required for migrations") raise ValueError("Database URL is required for migrations")
logger.info("Running database migrations...") logger.info("Running database migrations...")
run_migrations(self.db_url) # Use configured database schema for migrations (defaults to "public")
run_migrations(self.db_url, schema=get_config().database_schema)
# Ensure embedding column dimension matches the model's dimension # Ensure embedding column dimension matches the model's dimension
# This is done after migrations and after embeddings.initialize() # This is done after migrations and after embeddings.initialize()
ensure_embedding_dimension(self.db_url, self.embeddings.dimension) ensure_embedding_dimension(self.db_url, self.embeddings.dimension, schema=get_config().database_schema)
logger.info(f"Connecting to PostgreSQL at {self.db_url}") logger.info(f"Connecting to PostgreSQL at {self.db_url}")

View file

@ -1,5 +1,6 @@
"""Built-in tenant extension implementations.""" """Built-in tenant extension implementations."""
from hindsight_api.config import get_config
from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension
from hindsight_api.models import RequestContext from hindsight_api.models import RequestContext
@ -10,11 +11,13 @@ class ApiKeyTenantExtension(TenantExtension):
This is a simple implementation that: This is a simple implementation that:
1. Validates the API key matches HINDSIGHT_API_TENANT_API_KEY 1. Validates the API key matches HINDSIGHT_API_TENANT_API_KEY
2. Returns 'public' as the schema for all authenticated requests 2. Returns the configured schema (HINDSIGHT_API_DATABASE_SCHEMA, default 'public')
for all authenticated requests
Configuration: Configuration:
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
HINDSIGHT_API_TENANT_API_KEY=your-secret-key HINDSIGHT_API_TENANT_API_KEY=your-secret-key
HINDSIGHT_API_DATABASE_SCHEMA=your-schema (optional, defaults to 'public')
For multi-tenant setups with separate schemas per tenant, implement a custom For multi-tenant setups with separate schemas per tenant, implement a custom
TenantExtension that looks up the schema based on the API key or token claims. TenantExtension that looks up the schema based on the API key or token claims.
@ -27,11 +30,11 @@ class ApiKeyTenantExtension(TenantExtension):
raise ValueError("HINDSIGHT_API_TENANT_API_KEY is required when using ApiKeyTenantExtension") raise ValueError("HINDSIGHT_API_TENANT_API_KEY is required when using ApiKeyTenantExtension")
async def authenticate(self, context: RequestContext) -> TenantContext: async def authenticate(self, context: RequestContext) -> TenantContext:
"""Validate API key and return public schema context.""" """Validate API key and return configured schema context."""
if context.api_key != self.expected_api_key: if context.api_key != self.expected_api_key:
raise AuthenticationError("Invalid API key") raise AuthenticationError("Invalid API key")
return TenantContext(schema_name="public") return TenantContext(schema_name=get_config().database_schema)
async def list_tenants(self) -> list[Tenant]: async def list_tenants(self) -> list[Tenant]:
"""Return public schema for single-tenant setup.""" """Return configured schema for single-tenant setup."""
return [Tenant(schema="public")] return [Tenant(schema=get_config().database_schema)]

View file

@ -170,6 +170,7 @@ def main():
if args.log_level != config.log_level: if args.log_level != config.log_level:
config = HindsightConfig( config = HindsightConfig(
database_url=config.database_url, database_url=config.database_url,
database_schema=config.database_schema,
llm_provider=config.llm_provider, llm_provider=config.llm_provider,
llm_api_key=config.llm_api_key, llm_api_key=config.llm_api_key,
llm_model=config.llm_model, llm_model=config.llm_model,

View file

@ -20,10 +20,24 @@ The API service handles all memory operations (retain, recall, reflect).
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| |----------|-------------|---------|
| `HINDSIGHT_API_DATABASE_URL` | PostgreSQL connection string | `pg0` (embedded) | | `HINDSIGHT_API_DATABASE_URL` | PostgreSQL connection string | `pg0` (embedded) |
| `HINDSIGHT_API_DATABASE_SCHEMA` | PostgreSQL schema name for tables | `public` |
| `HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP` | Run database migrations on API startup | `true` | | `HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP` | Run database migrations on API startup | `true` |
If not provided, the server uses embedded `pg0` — convenient for development but not recommended for production. If not provided, the server uses embedded `pg0` — convenient for development but not recommended for production.
The `DATABASE_SCHEMA` setting allows you to use a custom PostgreSQL schema instead of the default `public` schema. This is useful for:
- Multi-database setups where you want Hindsight tables in a dedicated schema
- Hosting platforms (e.g., Supabase) where `public` schema is reserved or shared
- Organizational preferences for schema naming conventions
```bash
# Example: Using a custom schema
export HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/dbname
export HINDSIGHT_API_DATABASE_SCHEMA=hindsight
```
Migrations will automatically create the schema if it doesn't exist and create all tables in the configured schema.
### Database Connection Pool ### Database Connection Pool
| Variable | Description | Default | | Variable | Description | Default |
@ -439,6 +453,7 @@ export HINDSIGHT_CP_DATAPLANE_API_URL=http://api.example.com:8888
```bash ```bash
# API Service # API Service
HINDSIGHT_API_DATABASE_URL=postgresql://hindsight:hindsight_dev@localhost:5432/hindsight HINDSIGHT_API_DATABASE_URL=postgresql://hindsight:hindsight_dev@localhost:5432/hindsight
# HINDSIGHT_API_DATABASE_SCHEMA=public # optional, defaults to 'public'
HINDSIGHT_API_LLM_PROVIDER=groq HINDSIGHT_API_LLM_PROVIDER=groq
HINDSIGHT_API_LLM_API_KEY=gsk_xxxxxxxxxxxx HINDSIGHT_API_LLM_API_KEY=gsk_xxxxxxxxxxxx

4
package-lock.json generated
View file

@ -13,7 +13,7 @@
}, },
"hindsight-clients/typescript": { "hindsight-clients/typescript": {
"name": "@vectorize-io/hindsight-client", "name": "@vectorize-io/hindsight-client",
"version": "0.3.0", "version": "0.4.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.88.0", "@hey-api/openapi-ts": "0.88.0",
@ -131,7 +131,7 @@
}, },
"hindsight-control-plane": { "hindsight-control-plane": {
"name": "@vectorize-io/hindsight-control-plane", "name": "@vectorize-io/hindsight-control-plane",
"version": "0.3.0", "version": "0.4.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",