# SQLite Repository Implementation — Design & Session State > **For Claude:** This document captures the full project state and in-progress design decisions > for the SQLite repository implementation. Resume from "Next Steps" section. ## Project State (as of 2026-02-12) ### Git - **Branch:** `main` (10 commits, no feature branches) - **HEAD:** `9d7a67b fix: add collision retry for userid generation` - **Working tree:** Clean (untracked: `.idea/`, `README.md`, `docs/`) - **All 17 tests passing**, all quality checks green ### What Exists | File | Purpose | |------|---------| | `src/fastapi_oidc_op/config.py` | `Settings(BaseSettings)` with `env_prefix="OIDC_OP_"`, `StorageBackend` enum (sqlite/mongodb), `sqlite_path` default `data/oidc_op.db` | | `src/fastapi_oidc_op/models.py` | `User`, `WebAuthnCredential`, `PasswordCredential`, `MagicLink`, `CredentialType` enum. Datetimes use `datetime.UTC`. `MagicLink.expires_at` is required (no default). | | `src/fastapi_oidc_op/store/protocols.py` | `@runtime_checkable` Protocol interfaces: `UserRepository`, `CredentialRepository`, `MagicLinkRepository` | | `src/fastapi_oidc_op/userid.py` | `generate_userid()` (pure, proquint 32-bit), `generate_unique_userid(user_repo, max_retries=10)` (async, collision retry) | | `src/fastapi_oidc_op/app.py` | `create_app(settings)` factory. `/health` endpoint. Settings on `app.state`. Docs only in debug mode. | | `tests/conftest.py` | `settings` fixture, async `client` fixture via httpx `ASGITransport` | | `scripts/check.sh` | Quality gate: ruff format, ruff check, ty check, pytest | | `pyproject.toml` | Python 3.13, hatchling build, src layout. Deps: fastapi, idpyoidc, pydantic-settings, fido2, argon2-cffi, motor, aiosqlite, proquint, httpx. Dev: ruff, ty, pytest, pytest-asyncio. | ### Package Structure ``` src/fastapi_oidc_op/ ├── __init__.py ├── app.py ├── config.py ├── models.py ├── userid.py ├── oidc/__init__.py ├── authn/__init__.py ├── manage/__init__.py ├── invite/__init__.py └── store/ ├── __init__.py ├── protocols.py ├── mongodb/__init__.py └── sqlite/__init__.py ``` ### Existing Protocol Signatures (must implement exactly) ```python class UserRepository(Protocol): async def create(self, user: User) -> User async def get_by_userid(self, userid: str) -> User | None async def get_by_username(self, username: str) -> User | None async def update(self, user: User) -> User async def list_users(self, offset: int = 0, limit: int = 100) -> list[User] async def delete(self, userid: str) -> bool class CredentialRepository(Protocol): async def create_webauthn(self, credential: WebAuthnCredential) -> WebAuthnCredential async def create_password(self, credential: PasswordCredential) -> PasswordCredential async def get_webauthn_by_user(self, user_id: str) -> list[WebAuthnCredential] async def get_webauthn_by_credential_id(self, credential_id: bytes) -> WebAuthnCredential | None async def get_password_by_user(self, user_id: str) -> PasswordCredential | None async def update_webauthn(self, credential: WebAuthnCredential) -> WebAuthnCredential async def delete_webauthn(self, user_id: str, credential_id: bytes) -> bool async def delete_password(self, user_id: str) -> bool class MagicLinkRepository(Protocol): async def create(self, link: MagicLink) -> MagicLink async def get_by_token(self, token: str) -> MagicLink | None async def mark_used(self, token: str) -> bool async def delete_expired(self) -> int ``` ### Design Document Full design doc at `docs/plans/2026-02-11-oidc-op-design.md`. Key points: - SQLite uses `aiosqlite` with raw SQL - Storage backend selected at startup via config - SQLite is the default test backend (no external deps) ### Known Quirks - **ty 0.0.16:** `python-version` goes under `[tool.ty.environment]`, no `src` field at top level - **ruff 0.15:** version command is `ruff version` not `ruff --version` - **User.userid** (no underscore) vs **credential.user_id** (underscore) — intentional, matches design doc --- ## Design Decisions (confirmed by user) ### 1. Schema Initialization: SQL Migration Files - Directory: `src/fastapi_oidc_op/store/sqlite/migrations/` - Numbered SQL files: `001_initial.sql`, `002_...`, etc. - Tracker table `_migrations` records applied files - Migration runner at startup: read files, skip applied, execute in order, record ### 2. Connection Strategy: Single Shared Connection - One `aiosqlite` connection created at startup, shared by all repositories - WAL mode enabled for concurrent reads - Foreign keys enabled (`PRAGMA foreign_keys = ON`) - Connection closed on shutdown ### 3. Dependency Injection: Lifespan + app.state - App lifespan (`@asynccontextmanager`) creates DB connection and repositories - Stored on `app.state` (e.g. `app.state.user_repo`) - FastAPI `Depends()` retrieves from `request.app.state` ### 4. Groups Storage: Separate Join Table - `user_groups(userid TEXT, group_name TEXT)` with composite PK - `ON DELETE CASCADE` from users table - Repository handles join/split when reading/writing User models ### 5. Bytes Columns: BLOB - `credential_id` and `public_key` stored as BLOB - aiosqlite handles bytes natively ### 6. Datetimes: ISO 8601 TEXT - All datetime columns stored as ISO 8601 text strings - Converted to/from `datetime` objects in the repository layer ### 7. Booleans: INTEGER 0/1 - SQLite has no native bool; use INTEGER with 0/1 --- ## Schema (approved — Section 1) ```sql -- _migrations tracker CREATE TABLE IF NOT EXISTS _migrations ( id INTEGER PRIMARY KEY, filename TEXT NOT NULL UNIQUE, applied_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- 001_initial.sql CREATE TABLE users ( userid TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, preferred_username TEXT, given_name TEXT, family_name TEXT, nickname TEXT, email TEXT, email_verified INTEGER NOT NULL DEFAULT 0, phone_number TEXT, phone_number_verified INTEGER NOT NULL DEFAULT 0, picture TEXT, locale TEXT, active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE user_groups ( userid TEXT NOT NULL REFERENCES users(userid) ON DELETE CASCADE, group_name TEXT NOT NULL, PRIMARY KEY (userid, group_name) ); CREATE TABLE webauthn_credentials ( user_id TEXT NOT NULL REFERENCES users(userid) ON DELETE CASCADE, credential_id BLOB NOT NULL, public_key BLOB NOT NULL, sign_count INTEGER NOT NULL DEFAULT 0, device_name TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, PRIMARY KEY (user_id, credential_id) ); CREATE TABLE password_credentials ( user_id TEXT PRIMARY KEY REFERENCES users(userid) ON DELETE CASCADE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE magic_links ( token TEXT PRIMARY KEY, username TEXT NOT NULL, expires_at TEXT NOT NULL, used INTEGER NOT NULL DEFAULT 0, created_by TEXT, note TEXT ); ``` **Status:** Schema approved. Implementation complete. --- ## Implementation Status All SQLite repository tasks are **complete**. See `docs/plans/2026-02-13-sqlite-repositories-plan.md` for the full implementation plan. ### Completed (commits `bfa5b2e` through `a45604f`) 1. SQL migration file (`001_initial.sql`) 2. Migration runner (`run_migrations()` with `_migrations` tracker table) 3. `DuplicateError` domain exception 4. `SQLiteUserRepository` with tests 5. `SQLiteCredentialRepository` with tests 6. `SQLiteMagicLinkRepository` with tests 7. Lifespan integration and dependency injection (`dependencies.py`) ### Quality Gate All 64 tests passing. `./scripts/check.sh` all green (ruff format, ruff check, ty check, pytest). ### Roadmap Per the design doc, the roadmap is: 1. ~~Scaffolding~~ (done) 2. ~~SQLite repositories~~ (done) 3. **Authentication** (WebAuthn + password) — next phase 4. OIDC provider integration (idpyoidc) 5. Management UI (OIDC RP that dogfoods the OP) --- ## Process Notes - User prefers to be asked design questions before implementation begins - Use **brainstorming** skill: one question at a time, present design in 200-300 word sections, validate each - Use **subagent-driven development** for implementation - Plans live in `docs/plans/` - Quality gate: `./scripts/check.sh` (ruff format, ruff check, ty check, pytest)