feat: add postgres user engine store

This commit is contained in:
2026-06-16 07:14:37 +02:00
parent c494511a2e
commit 0d50ad294d
8 changed files with 939 additions and 3 deletions

View File

@@ -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.
These payloads are durable state and may contain sensitive values, so they must
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.

View File

@@ -10,7 +10,7 @@ tested immediately in local and agent environments.
```text
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
errors.py typed service exceptions for callers and future transports
migrations.py ordered durable-store migration manifest

View File

@@ -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
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:
- Add a Postgres adapter behind the existing store boundary.
- Add provider-backed conformance tests for locking, uniqueness races,
migration readiness, outbox claiming, redacted diagnostics, and restore
validation.

View File

@@ -4,8 +4,10 @@ from user_engine.adapters.local import (
InMemoryUserEngineStore,
LocalAuthorizationCheckPort,
)
from user_engine.adapters.postgres import PostgresUserEngineStore
__all__ = [
"InMemoryUserEngineStore",
"LocalAuthorizationCheckPort",
"PostgresUserEngineStore",
]

View 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"
)

View File

@@ -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:
"""Decode a durable-store record payload into its domain dataclass."""
codec = _CODECS_BY_RECORD_TYPE.get(record.record_type)
@@ -271,7 +276,11 @@ _CODECS = (
"identity_factors",
IdentityFactor,
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(
"prepared_accounts",

View 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()

View 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.