generated from coulomb/repo-seed
feat: add postgres user engine store
This commit is contained in:
@@ -256,3 +256,9 @@ into `StoreRecord` envelopes with deterministic keys and index metadata, while
|
|||||||
`domain_record_from_store_record` restores those payloads to domain objects.
|
`domain_record_from_store_record` restores those payloads to domain objects.
|
||||||
These payloads are durable state and may contain sensitive values, so they must
|
These payloads are durable state and may contain sensitive values, so they must
|
||||||
not be emitted as diagnostics.
|
not be emitted as diagnostics.
|
||||||
|
|
||||||
|
`user_engine.adapters.postgres.PostgresUserEngineStore` is the optional
|
||||||
|
Postgres implementation. It accepts a provider-owned DB-API or psycopg-like
|
||||||
|
connection, applies the bootstrap SQL in `migrate`, and persists generic
|
||||||
|
records, audit records, and pending outbox events without depending on a
|
||||||
|
specific driver package.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ tested immediately in local and agent environments.
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
src/user_engine/
|
src/user_engine/
|
||||||
adapters/ local standalone adapters and deterministic test doubles
|
adapters/ local standalone adapters, Postgres adapter, and test doubles
|
||||||
domain/ transport- and persistence-neutral domain schemas
|
domain/ transport- and persistence-neutral domain schemas
|
||||||
errors.py typed service exceptions for callers and future transports
|
errors.py typed service exceptions for callers and future transports
|
||||||
migrations.py ordered durable-store migration manifest
|
migrations.py ordered durable-store migration manifest
|
||||||
|
|||||||
@@ -301,9 +301,15 @@ values, profile values, prepared-account matches, and access-profile defaults.
|
|||||||
Adapters must avoid logging payloads and should use `record_counts` or other
|
Adapters must avoid logging payloads and should use `record_counts` or other
|
||||||
redacted diagnostics for observability.
|
redacted diagnostics for observability.
|
||||||
|
|
||||||
|
USER-WP-0018 adds `PostgresUserEngineStore`, a dependency-free adapter that
|
||||||
|
accepts a provider-supplied DB-API or psycopg-like connection. It writes generic
|
||||||
|
records through `StoreRecord`, keeps audit and outbox payloads in their
|
||||||
|
dedicated bootstrap tables, applies the bootstrap SQL through `migrate`, and
|
||||||
|
uses the shared conformance harness with a fake Postgres connection for local
|
||||||
|
unit coverage.
|
||||||
|
|
||||||
Likely future follow-up work should be:
|
Likely future follow-up work should be:
|
||||||
|
|
||||||
- Add a Postgres adapter behind the existing store boundary.
|
|
||||||
- Add provider-backed conformance tests for locking, uniqueness races,
|
- Add provider-backed conformance tests for locking, uniqueness races,
|
||||||
migration readiness, outbox claiming, redacted diagnostics, and restore
|
migration readiness, outbox claiming, redacted diagnostics, and restore
|
||||||
validation.
|
validation.
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ from user_engine.adapters.local import (
|
|||||||
InMemoryUserEngineStore,
|
InMemoryUserEngineStore,
|
||||||
LocalAuthorizationCheckPort,
|
LocalAuthorizationCheckPort,
|
||||||
)
|
)
|
||||||
|
from user_engine.adapters.postgres import PostgresUserEngineStore
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"InMemoryUserEngineStore",
|
"InMemoryUserEngineStore",
|
||||||
"LocalAuthorizationCheckPort",
|
"LocalAuthorizationCheckPort",
|
||||||
|
"PostgresUserEngineStore",
|
||||||
]
|
]
|
||||||
|
|||||||
626
src/user_engine/adapters/postgres.py
Normal file
626
src/user_engine/adapters/postgres.py
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
"""Postgres-backed store adapter.
|
||||||
|
|
||||||
|
The adapter is dependency-free: callers provide a DB-API or psycopg-like
|
||||||
|
connection object. Provider repositories remain responsible for creating,
|
||||||
|
pooling, securing, and observing those connections.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable, Iterator, Mapping, Protocol, cast
|
||||||
|
|
||||||
|
from user_engine.domain import (
|
||||||
|
Account,
|
||||||
|
AccessProfile,
|
||||||
|
ActiveAccessContext,
|
||||||
|
Application,
|
||||||
|
ApplicationBinding,
|
||||||
|
AuditRecord,
|
||||||
|
Catalog,
|
||||||
|
ExternalIdentity,
|
||||||
|
FamilyInvitation,
|
||||||
|
IdentityFactor,
|
||||||
|
Membership,
|
||||||
|
OnboardingJourney,
|
||||||
|
OutboxEvent,
|
||||||
|
PreparedAccount,
|
||||||
|
ProfileValue,
|
||||||
|
RegistrationSession,
|
||||||
|
TenantAccount,
|
||||||
|
User,
|
||||||
|
WelcomeProtocol,
|
||||||
|
)
|
||||||
|
from user_engine.migrations import LATEST_SCHEMA_VERSION, USER_ENGINE_RECORD_COUNT_KEYS
|
||||||
|
from user_engine.store_records import (
|
||||||
|
StoreRecord,
|
||||||
|
composite_record_key,
|
||||||
|
domain_record_from_store_record,
|
||||||
|
store_record_for,
|
||||||
|
)
|
||||||
|
|
||||||
|
_RECORD_COLUMNS = (
|
||||||
|
"record_type, record_key, tenant, user_id, application_id, "
|
||||||
|
"scope_type, scope_id, payload"
|
||||||
|
)
|
||||||
|
_RECORD_COUNT_KEY_BY_TYPE = {
|
||||||
|
"application_bindings": "bindings",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresCursor(Protocol):
|
||||||
|
def execute(self, sql: str, params: Iterable[Any] | None = None) -> Any:
|
||||||
|
"""Execute a SQL statement."""
|
||||||
|
|
||||||
|
def fetchone(self) -> Any | None:
|
||||||
|
"""Fetch one row from the previous query."""
|
||||||
|
|
||||||
|
def fetchall(self) -> Iterable[Any]:
|
||||||
|
"""Fetch all rows from the previous query."""
|
||||||
|
|
||||||
|
def close(self) -> Any:
|
||||||
|
"""Close the cursor."""
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresConnection(Protocol):
|
||||||
|
def cursor(self) -> PostgresCursor:
|
||||||
|
"""Return a DB-API-like cursor."""
|
||||||
|
|
||||||
|
def commit(self) -> Any:
|
||||||
|
"""Commit the current transaction."""
|
||||||
|
|
||||||
|
def rollback(self) -> Any:
|
||||||
|
"""Roll back the current transaction."""
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresUserEngineStore:
|
||||||
|
"""Postgres implementation of the `UserEngineStore` protocol."""
|
||||||
|
|
||||||
|
def __init__(self, connection: PostgresConnection) -> None:
|
||||||
|
self.connection = connection
|
||||||
|
|
||||||
|
@property
|
||||||
|
def schema_version(self) -> str | None:
|
||||||
|
return LATEST_SCHEMA_VERSION if self._has_latest_schema() else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
return self.schema_version == LATEST_SCHEMA_VERSION
|
||||||
|
|
||||||
|
def migrate(self) -> None:
|
||||||
|
sql = _load_bootstrap_sql()
|
||||||
|
with self._cursor() as cursor:
|
||||||
|
cursor.execute(sql)
|
||||||
|
self.connection.commit()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def transaction(self) -> Iterator[None]:
|
||||||
|
begin = getattr(self.connection, "begin", None)
|
||||||
|
if callable(begin):
|
||||||
|
begin()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except Exception:
|
||||||
|
self.connection.rollback()
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
self.connection.commit()
|
||||||
|
|
||||||
|
def save_user(self, user: User) -> None:
|
||||||
|
self._upsert_record(user)
|
||||||
|
|
||||||
|
def user(self, user_id: str) -> User | None:
|
||||||
|
return cast(User | None, self._get_record("users", user_id))
|
||||||
|
|
||||||
|
def save_account(self, account: Account) -> None:
|
||||||
|
self._upsert_record(account)
|
||||||
|
|
||||||
|
def user_account(self, user_id: str) -> Account | None:
|
||||||
|
return cast(Account | None, self._get_record("accounts", user_id))
|
||||||
|
|
||||||
|
def save_identity(self, identity: ExternalIdentity) -> None:
|
||||||
|
self._upsert_record(identity)
|
||||||
|
|
||||||
|
def find_identity(self, issuer: str, subject: str) -> ExternalIdentity | None:
|
||||||
|
key = composite_record_key(issuer, subject)
|
||||||
|
return cast(ExternalIdentity | None, self._get_record("external_identities", key))
|
||||||
|
|
||||||
|
def identities_for_user(self, user_id: str) -> tuple[ExternalIdentity, ...]:
|
||||||
|
return cast(
|
||||||
|
tuple[ExternalIdentity, ...],
|
||||||
|
self._query_records("external_identities", user_id=user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_tenant_account(self, account: TenantAccount) -> None:
|
||||||
|
self._upsert_record(account)
|
||||||
|
|
||||||
|
def tenant_account(self, tenant: str, user_id: str) -> TenantAccount | None:
|
||||||
|
key = composite_record_key(tenant, user_id)
|
||||||
|
return cast(TenantAccount | None, self._get_record("tenant_accounts", key))
|
||||||
|
|
||||||
|
def save_membership(self, membership: Membership) -> None:
|
||||||
|
self._upsert_record(membership)
|
||||||
|
|
||||||
|
def memberships_for_user(
|
||||||
|
self, user_id: str, *, tenant: str | None = None
|
||||||
|
) -> tuple[Membership, ...]:
|
||||||
|
return cast(
|
||||||
|
tuple[Membership, ...],
|
||||||
|
self._query_records("memberships", user_id=user_id, tenant=tenant),
|
||||||
|
)
|
||||||
|
|
||||||
|
def memberships_for_tenant(self, tenant: str) -> tuple[Membership, ...]:
|
||||||
|
return cast(
|
||||||
|
tuple[Membership, ...],
|
||||||
|
self._query_records("memberships", tenant=tenant),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_application(self, application: Application) -> None:
|
||||||
|
self._upsert_record(application)
|
||||||
|
|
||||||
|
def application(self, application_id: str) -> Application | None:
|
||||||
|
return cast(Application | None, self._get_record("applications", application_id))
|
||||||
|
|
||||||
|
def save_binding(self, binding: ApplicationBinding) -> None:
|
||||||
|
self._upsert_record(binding)
|
||||||
|
|
||||||
|
def binding(self, application_id: str) -> ApplicationBinding | None:
|
||||||
|
return cast(
|
||||||
|
ApplicationBinding | None,
|
||||||
|
self._get_record("application_bindings", application_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_catalog(self, catalog: Catalog) -> None:
|
||||||
|
self._upsert_record(catalog)
|
||||||
|
|
||||||
|
def catalog(self, catalog_id: str) -> Catalog | None:
|
||||||
|
return cast(Catalog | None, self._get_record("catalogs", catalog_id))
|
||||||
|
|
||||||
|
def all_catalogs(self) -> tuple[Catalog, ...]:
|
||||||
|
return cast(tuple[Catalog, ...], self._query_records("catalogs"))
|
||||||
|
|
||||||
|
def save_family_invitation(self, invitation: FamilyInvitation) -> None:
|
||||||
|
self._upsert_record(invitation)
|
||||||
|
|
||||||
|
def family_invitation(self, invitation_id: str) -> FamilyInvitation | None:
|
||||||
|
return cast(
|
||||||
|
FamilyInvitation | None,
|
||||||
|
self._get_record("family_invitations", invitation_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def family_invitations_for_user(
|
||||||
|
self, user_id: str
|
||||||
|
) -> tuple[FamilyInvitation, ...]:
|
||||||
|
return cast(
|
||||||
|
tuple[FamilyInvitation, ...],
|
||||||
|
self._query_records("family_invitations", user_id=user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_registration_session(self, session: RegistrationSession) -> None:
|
||||||
|
self._upsert_record(session)
|
||||||
|
|
||||||
|
def registration_session(
|
||||||
|
self, registration_id: str
|
||||||
|
) -> RegistrationSession | None:
|
||||||
|
return cast(
|
||||||
|
RegistrationSession | None,
|
||||||
|
self._get_record("registration_sessions", registration_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def all_registration_sessions(self) -> tuple[RegistrationSession, ...]:
|
||||||
|
return cast(
|
||||||
|
tuple[RegistrationSession, ...],
|
||||||
|
self._query_records("registration_sessions"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_identity_factor(self, factor: IdentityFactor) -> None:
|
||||||
|
self._upsert_record(factor)
|
||||||
|
|
||||||
|
def identity_factor(self, factor_id: str) -> IdentityFactor | None:
|
||||||
|
return cast(
|
||||||
|
IdentityFactor | None,
|
||||||
|
self._get_record("identity_factors", factor_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def factors_for_registration(
|
||||||
|
self, registration_id: str
|
||||||
|
) -> tuple[IdentityFactor, ...]:
|
||||||
|
return cast(
|
||||||
|
tuple[IdentityFactor, ...],
|
||||||
|
self._query_records(
|
||||||
|
"identity_factors",
|
||||||
|
scope_type="registration",
|
||||||
|
scope_id=registration_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def factors_for_user(self, user_id: str) -> tuple[IdentityFactor, ...]:
|
||||||
|
return cast(
|
||||||
|
tuple[IdentityFactor, ...],
|
||||||
|
self._query_records("identity_factors", user_id=user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_prepared_account(self, account: PreparedAccount) -> None:
|
||||||
|
self._upsert_record(account)
|
||||||
|
|
||||||
|
def prepared_account(self, prepared_account_id: str) -> PreparedAccount | None:
|
||||||
|
return cast(
|
||||||
|
PreparedAccount | None,
|
||||||
|
self._get_record("prepared_accounts", prepared_account_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def prepared_accounts_for_tenant(
|
||||||
|
self, tenant: str
|
||||||
|
) -> tuple[PreparedAccount, ...]:
|
||||||
|
return cast(
|
||||||
|
tuple[PreparedAccount, ...],
|
||||||
|
self._query_records("prepared_accounts", tenant=tenant),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_access_profile(self, profile: AccessProfile) -> None:
|
||||||
|
self._upsert_record(profile)
|
||||||
|
|
||||||
|
def access_profile(self, access_profile_id: str) -> AccessProfile | None:
|
||||||
|
return cast(
|
||||||
|
AccessProfile | None,
|
||||||
|
self._get_record("access_profiles", access_profile_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def access_profiles_for_tenant(self, tenant: str) -> tuple[AccessProfile, ...]:
|
||||||
|
return cast(
|
||||||
|
tuple[AccessProfile, ...],
|
||||||
|
self._query_records("access_profiles", tenant=tenant),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_active_access_context(self, context: ActiveAccessContext) -> None:
|
||||||
|
self._upsert_record(context)
|
||||||
|
|
||||||
|
def active_access_context(
|
||||||
|
self, user_id: str, tenant: str
|
||||||
|
) -> ActiveAccessContext | None:
|
||||||
|
key = composite_record_key(user_id, tenant)
|
||||||
|
return cast(
|
||||||
|
ActiveAccessContext | None,
|
||||||
|
self._get_record("active_access_contexts", key),
|
||||||
|
)
|
||||||
|
|
||||||
|
def active_access_contexts_for_tenant(
|
||||||
|
self, tenant: str
|
||||||
|
) -> tuple[ActiveAccessContext, ...]:
|
||||||
|
return cast(
|
||||||
|
tuple[ActiveAccessContext, ...],
|
||||||
|
self._query_records("active_access_contexts", tenant=tenant),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_welcome_protocol(self, protocol: WelcomeProtocol) -> None:
|
||||||
|
self._upsert_record(protocol)
|
||||||
|
|
||||||
|
def welcome_protocol(self, protocol_id: str) -> WelcomeProtocol | None:
|
||||||
|
return cast(
|
||||||
|
WelcomeProtocol | None,
|
||||||
|
self._get_record("welcome_protocols", protocol_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def welcome_protocols_for_tenant(
|
||||||
|
self, tenant: str
|
||||||
|
) -> tuple[WelcomeProtocol, ...]:
|
||||||
|
return cast(
|
||||||
|
tuple[WelcomeProtocol, ...],
|
||||||
|
self._query_records("welcome_protocols", tenant=tenant),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_onboarding_journey(self, journey: OnboardingJourney) -> None:
|
||||||
|
self._upsert_record(journey)
|
||||||
|
|
||||||
|
def onboarding_journey(self, journey_id: str) -> OnboardingJourney | None:
|
||||||
|
return cast(
|
||||||
|
OnboardingJourney | None,
|
||||||
|
self._get_record("onboarding_journeys", journey_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def onboarding_journeys_for_user(
|
||||||
|
self, user_id: str, *, tenant: str | None = None
|
||||||
|
) -> tuple[OnboardingJourney, ...]:
|
||||||
|
return cast(
|
||||||
|
tuple[OnboardingJourney, ...],
|
||||||
|
self._query_records("onboarding_journeys", user_id=user_id, tenant=tenant),
|
||||||
|
)
|
||||||
|
|
||||||
|
def onboarding_journeys_for_tenant(
|
||||||
|
self, tenant: str
|
||||||
|
) -> tuple[OnboardingJourney, ...]:
|
||||||
|
return cast(
|
||||||
|
tuple[OnboardingJourney, ...],
|
||||||
|
self._query_records("onboarding_journeys", tenant=tenant),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_profile_value(self, value: ProfileValue) -> None:
|
||||||
|
self._upsert_record(value)
|
||||||
|
|
||||||
|
def values_for_user(self, user_id: str) -> tuple[ProfileValue, ...]:
|
||||||
|
return cast(
|
||||||
|
tuple[ProfileValue, ...],
|
||||||
|
self._query_records("profile_values", user_id=user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def append_audit(self, record: AuditRecord) -> None:
|
||||||
|
store_record = store_record_for(record)
|
||||||
|
with self._cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_engine_audit_records (
|
||||||
|
audit_id, tenant, actor_issuer, actor_subject, action,
|
||||||
|
subject, correlation_id, summary, payload
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||||
|
ON CONFLICT (audit_id) DO NOTHING
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
record.audit_id,
|
||||||
|
record.tenant,
|
||||||
|
record.actor.issuer,
|
||||||
|
record.actor.subject,
|
||||||
|
record.action,
|
||||||
|
record.subject,
|
||||||
|
record.correlation_id,
|
||||||
|
record.summary,
|
||||||
|
json.dumps(store_record.payload),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def audit_log(self) -> tuple[AuditRecord, ...]:
|
||||||
|
with self._cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT payload
|
||||||
|
FROM user_engine_audit_records
|
||||||
|
ORDER BY recorded_at, audit_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
cast(AuditRecord, self._decode_payload_row("audit_records", row))
|
||||||
|
for row in cursor.fetchall()
|
||||||
|
)
|
||||||
|
|
||||||
|
def append_outbox(self, event: OutboxEvent) -> None:
|
||||||
|
store_record = store_record_for(event)
|
||||||
|
with self._cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_engine_outbox_events (
|
||||||
|
event_id, tenant, event_type, aggregate_id, correlation_id,
|
||||||
|
payload, occurred_at
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s)
|
||||||
|
ON CONFLICT (event_id) DO NOTHING
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
event.event_id,
|
||||||
|
event.tenant,
|
||||||
|
event.event_type,
|
||||||
|
event.aggregate_id,
|
||||||
|
event.correlation_id,
|
||||||
|
json.dumps(store_record.payload),
|
||||||
|
event.occurred_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def pending_outbox(self) -> tuple[OutboxEvent, ...]:
|
||||||
|
with self._cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT payload
|
||||||
|
FROM user_engine_outbox_events
|
||||||
|
WHERE claimed_at IS NULL AND delivered_at IS NULL
|
||||||
|
ORDER BY occurred_at, event_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
cast(OutboxEvent, self._decode_payload_row("outbox_events", row))
|
||||||
|
for row in cursor.fetchall()
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_counts(self) -> Mapping[str, int]:
|
||||||
|
counts = {key: 0 for key in USER_ENGINE_RECORD_COUNT_KEYS}
|
||||||
|
with self._cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT record_type, COUNT(*)
|
||||||
|
FROM user_engine_records
|
||||||
|
GROUP BY record_type
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for record_type, count in cursor.fetchall():
|
||||||
|
key = _RECORD_COUNT_KEY_BY_TYPE.get(record_type, record_type)
|
||||||
|
if key in counts:
|
||||||
|
counts[key] = int(count)
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM user_engine_audit_records")
|
||||||
|
counts["audit_records"] = int(_first_column(cursor.fetchone()) or 0)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM user_engine_outbox_events
|
||||||
|
WHERE claimed_at IS NULL AND delivered_at IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
counts["pending_outbox_events"] = int(
|
||||||
|
_first_column(cursor.fetchone()) or 0
|
||||||
|
)
|
||||||
|
return counts
|
||||||
|
|
||||||
|
def _upsert_record(self, value: Any) -> None:
|
||||||
|
record = store_record_for(value)
|
||||||
|
with self._cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO user_engine_records (
|
||||||
|
record_type, record_key, tenant, user_id, application_id,
|
||||||
|
scope_type, scope_id, payload
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||||
|
ON CONFLICT (record_type, record_key) DO UPDATE SET
|
||||||
|
tenant = EXCLUDED.tenant,
|
||||||
|
user_id = EXCLUDED.user_id,
|
||||||
|
application_id = EXCLUDED.application_id,
|
||||||
|
scope_type = EXCLUDED.scope_type,
|
||||||
|
scope_id = EXCLUDED.scope_id,
|
||||||
|
payload = EXCLUDED.payload,
|
||||||
|
updated_at = now()
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
record.record_type,
|
||||||
|
record.record_key,
|
||||||
|
record.tenant,
|
||||||
|
record.user_id,
|
||||||
|
record.application_id,
|
||||||
|
record.scope_type,
|
||||||
|
record.scope_id,
|
||||||
|
json.dumps(record.payload),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_record(self, record_type: str, record_key: str) -> Any | None:
|
||||||
|
with self._cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_RECORD_COLUMNS}
|
||||||
|
FROM user_engine_records
|
||||||
|
WHERE record_type = %s AND record_key = %s
|
||||||
|
""",
|
||||||
|
(record_type, record_key),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return domain_record_from_store_record(_store_record_from_row(row))
|
||||||
|
|
||||||
|
def _query_records(
|
||||||
|
self,
|
||||||
|
record_type: str,
|
||||||
|
*,
|
||||||
|
tenant: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
application_id: str | None = None,
|
||||||
|
scope_type: str | None = None,
|
||||||
|
scope_id: str | None = None,
|
||||||
|
) -> tuple[Any, ...]:
|
||||||
|
filters = {
|
||||||
|
"tenant": tenant,
|
||||||
|
"user_id": user_id,
|
||||||
|
"application_id": application_id,
|
||||||
|
"scope_type": scope_type,
|
||||||
|
"scope_id": scope_id,
|
||||||
|
}
|
||||||
|
clauses = ["record_type = %s"]
|
||||||
|
params: list[Any] = [record_type]
|
||||||
|
for column, value in filters.items():
|
||||||
|
if value is not None:
|
||||||
|
clauses.append(f"{column} = %s")
|
||||||
|
params.append(value)
|
||||||
|
where_clause = " AND ".join(clauses)
|
||||||
|
with self._cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_RECORD_COLUMNS}
|
||||||
|
FROM user_engine_records
|
||||||
|
WHERE {where_clause}
|
||||||
|
ORDER BY record_key
|
||||||
|
""",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return tuple(
|
||||||
|
domain_record_from_store_record(_store_record_from_row(row))
|
||||||
|
for row in rows
|
||||||
|
)
|
||||||
|
|
||||||
|
def _decode_payload_row(self, record_type: str, row: Any) -> Any:
|
||||||
|
payload = _first_column(row)
|
||||||
|
if isinstance(payload, str):
|
||||||
|
payload = json.loads(payload)
|
||||||
|
record_key = str(cast(Mapping[str, Any], payload).get("event_id") or "")
|
||||||
|
if record_type == "audit_records":
|
||||||
|
record_key = str(cast(Mapping[str, Any], payload).get("audit_id") or "")
|
||||||
|
return domain_record_from_store_record(
|
||||||
|
StoreRecord(record_type=record_type, record_key=record_key, payload=payload)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _has_latest_schema(self) -> bool:
|
||||||
|
with self._cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM user_engine_schema_versions
|
||||||
|
WHERE version = %s
|
||||||
|
""",
|
||||||
|
(LATEST_SCHEMA_VERSION,),
|
||||||
|
)
|
||||||
|
return cursor.fetchone() is not None
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _cursor(self) -> Iterator[PostgresCursor]:
|
||||||
|
cursor = self.connection.cursor()
|
||||||
|
try:
|
||||||
|
yield cursor
|
||||||
|
finally:
|
||||||
|
close = getattr(cursor, "close", None)
|
||||||
|
if callable(close):
|
||||||
|
close()
|
||||||
|
|
||||||
|
|
||||||
|
def _store_record_from_row(row: Any) -> StoreRecord:
|
||||||
|
if isinstance(row, Mapping):
|
||||||
|
payload = row["payload"]
|
||||||
|
if isinstance(payload, str):
|
||||||
|
payload = json.loads(payload)
|
||||||
|
return StoreRecord(
|
||||||
|
record_type=str(row["record_type"]),
|
||||||
|
record_key=str(row["record_key"]),
|
||||||
|
tenant=cast(str | None, row.get("tenant")),
|
||||||
|
user_id=cast(str | None, row.get("user_id")),
|
||||||
|
application_id=cast(str | None, row.get("application_id")),
|
||||||
|
scope_type=cast(str | None, row.get("scope_type")),
|
||||||
|
scope_id=cast(str | None, row.get("scope_id")),
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
record_type,
|
||||||
|
record_key,
|
||||||
|
tenant,
|
||||||
|
user_id,
|
||||||
|
application_id,
|
||||||
|
scope_type,
|
||||||
|
scope_id,
|
||||||
|
payload,
|
||||||
|
) = row
|
||||||
|
if isinstance(payload, str):
|
||||||
|
payload = json.loads(payload)
|
||||||
|
return StoreRecord(
|
||||||
|
record_type=str(record_type),
|
||||||
|
record_key=str(record_key),
|
||||||
|
tenant=tenant,
|
||||||
|
user_id=user_id,
|
||||||
|
application_id=application_id,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_column(row: Any) -> Any:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if isinstance(row, Mapping):
|
||||||
|
return next(iter(row.values()))
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_bootstrap_sql() -> str:
|
||||||
|
repo_root = Path(__file__).resolve().parents[3]
|
||||||
|
return (repo_root / "migrations/postgres/0001_user_engine_store.sql").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
@@ -77,6 +77,11 @@ def store_record_for(value: Any) -> StoreRecord:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def composite_record_key(*parts: str | None) -> str:
|
||||||
|
"""Return the deterministic composite key used by durable store records."""
|
||||||
|
return _composite_key(*parts)
|
||||||
|
|
||||||
|
|
||||||
def domain_record_from_store_record(record: StoreRecord) -> Any:
|
def domain_record_from_store_record(record: StoreRecord) -> Any:
|
||||||
"""Decode a durable-store record payload into its domain dataclass."""
|
"""Decode a durable-store record payload into its domain dataclass."""
|
||||||
codec = _CODECS_BY_RECORD_TYPE.get(record.record_type)
|
codec = _CODECS_BY_RECORD_TYPE.get(record.record_type)
|
||||||
@@ -271,7 +276,11 @@ _CODECS = (
|
|||||||
"identity_factors",
|
"identity_factors",
|
||||||
IdentityFactor,
|
IdentityFactor,
|
||||||
lambda value: _single_key(value.factor_id),
|
lambda value: _single_key(value.factor_id),
|
||||||
lambda value: {"user_id": value.user_id},
|
lambda value: {
|
||||||
|
"user_id": value.user_id,
|
||||||
|
"scope_type": "registration" if value.registration_id else None,
|
||||||
|
"scope_id": value.registration_id,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
StoreRecordCodec(
|
StoreRecordCodec(
|
||||||
"prepared_accounts",
|
"prepared_accounts",
|
||||||
|
|||||||
180
tests/test_postgres_store_adapter.py
Normal file
180
tests/test_postgres_store_adapter.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
from user_engine.adapters.postgres import PostgresUserEngineStore
|
||||||
|
from user_engine.migrations import LATEST_SCHEMA_VERSION
|
||||||
|
from user_engine.store_records import StoreRecord
|
||||||
|
from user_engine.testing.store_conformance import (
|
||||||
|
assert_user_engine_store_conformance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresStoreAdapterTests(unittest.TestCase):
|
||||||
|
def test_fake_postgres_store_satisfies_store_conformance(self):
|
||||||
|
assert_user_engine_store_conformance(
|
||||||
|
self,
|
||||||
|
lambda: PostgresUserEngineStore(_FakePostgresConnection()),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ready_is_false_before_migration(self):
|
||||||
|
store = PostgresUserEngineStore(_FakePostgresConnection())
|
||||||
|
|
||||||
|
self.assertFalse(store.ready)
|
||||||
|
self.assertIsNone(store.schema_version)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePostgresConnection:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.schema_versions: set[str] = set()
|
||||||
|
self.records: dict[tuple[str, str], StoreRecord] = {}
|
||||||
|
self.audit_payloads: list[dict[str, Any]] = []
|
||||||
|
self.outbox_payloads: list[dict[str, Any]] = []
|
||||||
|
self._snapshot: tuple[
|
||||||
|
set[str],
|
||||||
|
dict[tuple[str, str], StoreRecord],
|
||||||
|
list[dict[str, Any]],
|
||||||
|
list[dict[str, Any]],
|
||||||
|
] | None = None
|
||||||
|
|
||||||
|
def cursor(self) -> "_FakePostgresCursor":
|
||||||
|
return _FakePostgresCursor(self)
|
||||||
|
|
||||||
|
def begin(self) -> None:
|
||||||
|
self._snapshot = (
|
||||||
|
copy.deepcopy(self.schema_versions),
|
||||||
|
copy.deepcopy(self.records),
|
||||||
|
copy.deepcopy(self.audit_payloads),
|
||||||
|
copy.deepcopy(self.outbox_payloads),
|
||||||
|
)
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
self._snapshot = None
|
||||||
|
|
||||||
|
def rollback(self) -> None:
|
||||||
|
if self._snapshot is None:
|
||||||
|
return
|
||||||
|
(
|
||||||
|
self.schema_versions,
|
||||||
|
self.records,
|
||||||
|
self.audit_payloads,
|
||||||
|
self.outbox_payloads,
|
||||||
|
) = self._snapshot
|
||||||
|
self._snapshot = None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePostgresCursor:
|
||||||
|
def __init__(self, connection: _FakePostgresConnection) -> None:
|
||||||
|
self.connection = connection
|
||||||
|
self._rows: list[Any] = []
|
||||||
|
|
||||||
|
def execute(self, sql: str, params: Iterable[Any] | None = None) -> None:
|
||||||
|
normalized = " ".join(sql.lower().split())
|
||||||
|
values = tuple(params or ())
|
||||||
|
|
||||||
|
if "insert into user_engine_schema_versions" in normalized:
|
||||||
|
self.connection.schema_versions.add(LATEST_SCHEMA_VERSION)
|
||||||
|
self._rows = []
|
||||||
|
return
|
||||||
|
if "from user_engine_schema_versions" in normalized:
|
||||||
|
self._rows = [(1,)] if values[0] in self.connection.schema_versions else []
|
||||||
|
return
|
||||||
|
if normalized.startswith("insert into user_engine_records"):
|
||||||
|
payload = json.loads(values[7])
|
||||||
|
record = StoreRecord(
|
||||||
|
record_type=values[0],
|
||||||
|
record_key=values[1],
|
||||||
|
tenant=values[2],
|
||||||
|
user_id=values[3],
|
||||||
|
application_id=values[4],
|
||||||
|
scope_type=values[5],
|
||||||
|
scope_id=values[6],
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
self.connection.records[(record.record_type, record.record_key)] = record
|
||||||
|
self._rows = []
|
||||||
|
return
|
||||||
|
if "from user_engine_records" in normalized:
|
||||||
|
self._select_records(normalized, values)
|
||||||
|
return
|
||||||
|
if normalized.startswith("insert into user_engine_audit_records"):
|
||||||
|
self.connection.audit_payloads.append(json.loads(values[8]))
|
||||||
|
self._rows = []
|
||||||
|
return
|
||||||
|
if normalized.startswith("insert into user_engine_outbox_events"):
|
||||||
|
self.connection.outbox_payloads.append(json.loads(values[5]))
|
||||||
|
self._rows = []
|
||||||
|
return
|
||||||
|
if "from user_engine_audit_records" in normalized:
|
||||||
|
if "count(*)" in normalized:
|
||||||
|
self._rows = [(len(self.connection.audit_payloads),)]
|
||||||
|
else:
|
||||||
|
self._rows = [
|
||||||
|
(json.dumps(payload),) for payload in self.connection.audit_payloads
|
||||||
|
]
|
||||||
|
return
|
||||||
|
if "from user_engine_outbox_events" in normalized:
|
||||||
|
if "count(*)" in normalized:
|
||||||
|
self._rows = [(len(self.connection.outbox_payloads),)]
|
||||||
|
else:
|
||||||
|
self._rows = [
|
||||||
|
(json.dumps(payload),) for payload in self.connection.outbox_payloads
|
||||||
|
]
|
||||||
|
return
|
||||||
|
self._rows = []
|
||||||
|
|
||||||
|
def fetchone(self) -> Any | None:
|
||||||
|
return self._rows[0] if self._rows else None
|
||||||
|
|
||||||
|
def fetchall(self) -> list[Any]:
|
||||||
|
return self._rows
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _select_records(self, normalized: str, values: tuple[Any, ...]) -> None:
|
||||||
|
if "group by record_type" in normalized:
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for record_type, _record_key in self.connection.records:
|
||||||
|
counts[record_type] = counts.get(record_type, 0) + 1
|
||||||
|
self._rows = sorted(counts.items())
|
||||||
|
return
|
||||||
|
|
||||||
|
record_type = values[0]
|
||||||
|
filter_columns = [
|
||||||
|
column
|
||||||
|
for column in (
|
||||||
|
"record_key",
|
||||||
|
"tenant",
|
||||||
|
"user_id",
|
||||||
|
"application_id",
|
||||||
|
"scope_type",
|
||||||
|
"scope_id",
|
||||||
|
)
|
||||||
|
if f"{column} = %s" in normalized
|
||||||
|
]
|
||||||
|
filters = dict(zip(filter_columns, values[1:]))
|
||||||
|
rows = []
|
||||||
|
for (stored_type, _key), record in self.connection.records.items():
|
||||||
|
if stored_type != record_type:
|
||||||
|
continue
|
||||||
|
if any(getattr(record, column) != value for column, value in filters.items()):
|
||||||
|
continue
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
record.record_type,
|
||||||
|
record.record_key,
|
||||||
|
record.tenant,
|
||||||
|
record.user_id,
|
||||||
|
record.application_id,
|
||||||
|
record.scope_type,
|
||||||
|
record.scope_id,
|
||||||
|
json.dumps(record.payload),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._rows = sorted(rows, key=lambda row: row[1])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
107
workplans/USER-WP-0018-postgres-store-adapter.md
Normal file
107
workplans/USER-WP-0018-postgres-store-adapter.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
id: USER-WP-0018
|
||||||
|
type: workplan
|
||||||
|
title: "Postgres Store Adapter"
|
||||||
|
domain: netkingdom
|
||||||
|
repo: user-engine
|
||||||
|
status: finished
|
||||||
|
owner: codex
|
||||||
|
topic_slug: netkingdom
|
||||||
|
planning_priority: medium
|
||||||
|
planning_order: 18
|
||||||
|
created: "2026-06-16"
|
||||||
|
updated: "2026-06-16"
|
||||||
|
depends_on:
|
||||||
|
- USER-WP-0016
|
||||||
|
- USER-WP-0017
|
||||||
|
---
|
||||||
|
|
||||||
|
# USER-WP-0018 - Postgres Store Adapter
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a dependency-free Postgres store adapter behind `UserEngineStore` that uses
|
||||||
|
the migration and serialization contracts from USER-WP-0016 and USER-WP-0017.
|
||||||
|
|
||||||
|
## Scope Direction
|
||||||
|
|
||||||
|
The adapter should accept a provider-supplied DB-API or psycopg-like connection
|
||||||
|
and avoid owning credentials, pooling, deployment, backups, or platform
|
||||||
|
observability.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Do not vendor or require a Postgres driver in the core package.
|
||||||
|
- Do not add Docker or live database requirements to the unit test suite.
|
||||||
|
- Do not implement outbox claim/ack/retry or provider restore validation yet.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: USER-WP-0018-T1
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a Postgres adapter that implements the `UserEngineStore` protocol using the
|
||||||
|
generic record, audit, and outbox tables.
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: USER-WP-0018-T2
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire the adapter to `StoreRecord` serialization and deterministic record keys.
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: USER-WP-0018-T3
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Support schema readiness, migration execution, transactions, audit reads,
|
||||||
|
pending outbox reads, and adapter-neutral record counts.
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: USER-WP-0018-T4
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a fake Postgres connection that runs the shared conformance harness without
|
||||||
|
requiring production infrastructure.
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: USER-WP-0018-T5
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
```
|
||||||
|
|
||||||
|
Document the provider boundary and remaining provider-backed conformance work.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- The adapter has no hard runtime dependency on a specific Postgres driver.
|
||||||
|
- The adapter passes the same store conformance harness as the in-memory store.
|
||||||
|
- Migration readiness uses the shared latest schema version.
|
||||||
|
- Record counts stay redacted and adapter-neutral.
|
||||||
|
- Docs explain that provider repositories still own live-driver, lock, restore,
|
||||||
|
and outbox claiming validation.
|
||||||
|
|
||||||
|
## Expected Outputs
|
||||||
|
|
||||||
|
- `user_engine.adapters.postgres.PostgresUserEngineStore`.
|
||||||
|
- Fake Postgres adapter tests.
|
||||||
|
- Durable-store documentation updates.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
Implemented on 2026-06-16:
|
||||||
|
|
||||||
|
- Added `PostgresUserEngineStore` using provider-supplied DB-API/psycopg-like
|
||||||
|
connections.
|
||||||
|
- Reused `StoreRecord` serialization for all generic record writes and reads.
|
||||||
|
- Added transaction, migration, readiness, audit, outbox, and record count
|
||||||
|
support.
|
||||||
|
- Added fake connection tests that run the shared store conformance harness.
|
||||||
Reference in New Issue
Block a user