Skip to content

Bootstrap chat-app skeleton and core chat domain - #1

Merged
lesnik512 merged 34 commits into
mainfrom
feat/bootstrap
Aug 21, 2026
Merged

Bootstrap chat-app skeleton and core chat domain#1
lesnik512 merged 34 commits into
mainfrom
feat/bootstrap

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Implements planning/changes/2026-08-21.01-chat-app-bootstrap.md — see that file for the design and its rationale.

Stands up the repository and the synchronous half of the domain: package skeleton derived from litestar-sqlalchemy-template, one modern-di container spanning app and request scopes, JWT cookie authentication, and the chats / members / messages model over REST.

What's here

  • AuthJWTCookieAuth with argon2, cookie-based specifically because EventSource cannot send an Authorization header, so the SSE stream added by the realtime change will authenticate identically to every other endpoint.
  • Chats — direct chats keyed on a canonical direct_key, created through a DuplicateKeyError-and-re-read upsert rather than a read-then-race.
  • Messages — idempotent send scoped to (chat_id, idempotency_key), cursor pagination in both directions on (chat_id, id), author-and-member-gated edit and soft delete.
  • Unread — one last_read_message_id per member rather than per-message receipt rows, counted with IS DISTINCT FROM so system messages are not silently dropped.

Notes for review

  • Repositories run auto_commit=False; use cases own the transaction boundary, because the realtime change must write a domain row and an outbox row in one commit.
  • modern-di 3.x uses cache=, not the 2.x cache_settings= the sibling template still uses.
  • 108 tests, 100% coverage, zero warnings (filterwarnings = ["error"]). No coverage pragmas in app/, tests/ or migrations/.
  • Known limitations with revisit triggers are in planning/deferred.md.

Realtime delivery (outbox, SSE, typing and presence) and the browser client are scoped to follow-on changes.

Brief's ignore list was derived from litestar-sqlalchemy-template on an
older ruff that did not yet select CPY001 under ALL. rchat already
carries this exact ignore line on current ruff; requiring a copyright
header per file in an MIT repo with a root LICENSE is noise.
…e it

- reset_override() cleared the whole overrides registry instead of just the
  overridden database_engine provider; scope it so a later per-test override
  (a fake hasher, a stubbed use case) can't be silently discarded.
- add a test that resolves Database.database_session from a request-scoped
  child container and asserts it sees an uncommitted write made through the
  db_session fixture, proving the override is shared through the real DI path
  the app uses in production, not just the fixture's own session.
- drop a tautological __main__ import-only test in favor of omitting
  app/api/__main__.py from coverage, and a vacuous engine/session isinstance
  check in favor of one asserting settings-derived pool/url config.
- de-duplicate db_session's manual AsyncSession construction by calling
  create_session(connection) instead, widening its type to accept a
  connection as well as an engine.
- patch migrations/script.py.mako with the import and return-annotation
  fixes hand-applied to the initial migration, so future autogenerated
  migrations don't need the same manual patch.
… exclude anchoring)

- Add Settings.jwt_cookie_secure and Settings.ensure_jwt_secret_is_configured() startup guard
- Fix Collection.from_models annotation, drop the ty suppression it required
- Anchor JWT auth-exclude patterns, drop dead /metrics entry, single-home register/login exclusion
- Guard retrieve_user_handler against non-numeric token subjects (401, not 500)
- Correct login (200) and logout (204) status codes
- Move JWTCookieAuthPlugin next to jwt_cookie_auth in app/api/auth.py
- Stop capturing SQL bind parameters for asyncpg spans (password hashes were leaking into OTel)
- Add auth-boundary tests for tampered cookies and tokens for deleted users
- Eliminate all test warnings at the root cause (NamedDependency, longer JWT secrets)
Adds ChatsTable/ChatMembersTable, the create-chat and fetch-chat use
cases, and their endpoints. Restructures CreateChatUseCase so both
read paths (direct-chat lookup and the post-commit refetch) run
outside the Transaction context manager, since its __aexit__ rolls
back and closes the session on any query left uncommitted inside the
block, detaching the returned row. Adds create_constraint=True to the
chat_type enum column so the migration emits the expected CHECK
constraint alongside the VARCHAR storage.
…g, enum values)

Handles concurrent direct-chat creation via DuplicateKeyError catch and
re-read (mirroring Task 5's message-idempotency pattern), keeping the
re-read outside the Transaction block for the same detachment reason
already established for the other two reads. Adds ForeignKeyError (400)
and ValidationError (400) handlers so a bad member_ids reference and a
malformed direct-chat request no longer surface as 500/403. Returns
tuple[ChatsTable, bool] from CreateChatUseCase so the endpoint can
distinguish 201 (created) from 200 (already existed). Fixes the enum
column to store lowercase values via values_callable, amending the
existing migration in place, and drops the redundant chat_id index.
Removes the pragma: no cover markers that had excluded the entire
DuplicateKeyError recovery path from coverage, and adds tests that
exercise it for real by swapping in a ChatsRepository stub that
simulates the race window while everything else (Transaction, the
real committed winner row, the session/savepoint machinery) stays
real. Restructures the impossible-state guard into the except clause
so an unexpected DuplicateKeyError on a group chat re-raises and maps
to 409 instead of narrowing into the direct-chat recovery path. Fixes
the Justfile migration recipe's argument quoting and drops a redundant
coverage pragma already covered by an omit entry.
Adds the messages table, CreateMessageUseCase (idempotency-key dedup with a
DuplicateKeyError race-recovery path) and FetchMessagesUseCase (before_id/
after_id cursor pagination, index-only on ix_messages_chat_id_id), plus the
send/list endpoints and DI wiring.
…oping, index cleanup)

- reject limit < 1 with ValidationError instead of letting Postgres 500 on it
- scope the idempotency-key lookup to (chat_id, idempotency_key) so a reused
  key from a different chat can no longer return that chat's message; the
  now-deterministic cross-chat mismatch raises ValidationError instead of an
  unreachable-pragma'd RuntimeError
- drop the redundant ix_messages_chat_id index, superseded by the composite
  (chat_id, id) index, amending the not-yet-deployed migration in place
- drop the extra get_one() re-fetch on the happy send path (no relationship
  to load, unlike create_chat's members)
- make list_messages' cursor/limit params keyword-only to retire the
  PLR0917 suppression
Aligns the DB constraint with the already chat-scoped lookup so the
DuplicateKeyError recovery guard's "the unique constraint guarantees a
match" pragma is honest again, matching create_chat.py's precedent.
Cross-chat key reuse is now a legitimate independent send rather than an
error.
Edit and delete require message authorship, not just chat membership.
Editing a deleted message is a 409 ConflictError (the author is
authorized; the request conflicts with resource state), while deleting
an already-deleted message is idempotent and returns 204.
…n check

Edit and delete previously checked only message authorship, unlike
every other actor-scoped use case (FetchMessagesUseCase,
FetchChatUseCase), which check chat membership first. Extract the
shared lookup+authorization block into fetch_message_for_author so
the check order (existence, membership, authorship) is defined once.
carol/mallory non-member tests pass identically with or without the
membership check, since the authorship check alone already rejects
them. Add a test that removes alice's own chat_members row after she
authored a message, the one state where authorship and membership
disagree, so edit/delete are genuinely exercised by a check that
would otherwise be silently deletable.
Adds GET /api/chats/ (unread counts and last message, no N+1) and
POST /api/chats/{id}/read/ with a monotonic, chat-scoped marker.
…read-marker, listing tests)

Repoints chats.last_message_id when the deleted message was the pointer, so the
listing preview and ordering stay consistent with a delete instead of surviving
it half-effective. Makes mark-read monotonicity atomic via GREATEST() in the
UPDATE instead of a Python read-modify-write, and tightens the listing tests
(per-row unread counts, delete-repoint coverage, dropped an untestable case).
Documents the app as it actually shipped (error vocabulary, author-and-member
message gating, per-chat idempotency scoping, atomic last_message_id repoint)
rather than the original spec, wires the portable planning convention
(templates, .convention-version, index.py, check-planning/index recipes), and
carries the reviewed-and-deferred execution findings into planning/deferred.md.
…nto CI

readme.md and the change file's finalized summary said "read receipts", the
exact term architecture/glossary.md tells readers to avoid; reworded to read
marker / unread counts, which is what the code actually implements. The
Justfile's check-planning comment claimed CI runs the validator when it
didn't; added the planning/index.py --check step to the lint job so the
claim is true rather than correcting the comment down to match reality.
echo=True/echo_pool=True logs every SQL statement with its bound
parameters, including argon2 password_hash values on every
registration, and makes Litestar return stack traces in responses.
This directly contradicted the AsyncPGInstrumentor(capture_parameters=False)
already in app/api/app.py. Document the risk on Settings.service_debug
so it isn't re-enabled by pattern-matching on the template.
swagger_offline_docs=True serves Swagger's assets from /static/*, which
the JWT auth middleware's exclude list didn't anchor - /docs loaded but
every asset request 401ed for an anonymous visitor. /metrics (registered
by lite-bootstrap's prometheus_client integration) had the same problem:
a scrape target returning 401 is a broken feature, and the endpoint
carries no user data.
login's decorator declared no status_code, so the published schema said
201 even though jwt_cookie_auth.login(response_status_code=...) always
returns 200 at runtime; declare it explicitly like register/mark_read
already do.

POST /api/chats/ and POST /api/chats/{id}/messages/ return 201 on create
and 200 on an idempotent hit - the dual-status behaviour this repo exists
to demonstrate - but Swagger only documented the decorator's default.
Declare the 200 case via responses={...} on both handlers.
list_chats hand-built ChatListItem.model_validate(row.chat).model_copy(
update={...}) to inject unread_count/last_message - model_copy(update=)
skips validation, unlike every other collection response in this repo.
Give ChatListItem a from_row classmethod that validates chat, last_message
and unread_count together, and build the response with
schemas.Chats.from_models(...) like messages.py's list_messages already
does.
app/settings.py referenced "(Task 3)", and edit_message.py/mark_read.py
each claimed to share a strategy with "Task 5's" sibling use case - none
of that numbering means anything to a reader of the published repo, and
edit_message.py's comparison was also wrong: CreateMessageUseCase returns
from outside the async with block, the opposite of EditMessageUseCase's
return-inside-right-after-commit shape. Drop the cross-references rather
than replace them with another brittle inter-file comparison.
_register, _login, _create_direct_chat and _send were copy-pasted across
four tests/api/*.py modules, and _send had silently diverged - only
test_messages_api.py's version took a key parameter. Move all four into
a shared module and reconcile _send on the key-accepting signature; each
test module imports what it needs with the existing leading-underscore
call-site names.
orm.DeclarativeBase.metadata = METADATA (app/database/tables.py) mutates
a third-party base class at import time with no in-file explanation -
the reasoning only existed in CLAUDE.md and planning/deferred.md. Put it
in the file itself, and drop the now-redundant deferred.md entry.
Both use cases carried a `# pragma: no cover` on the "recovery re-read
found nothing" branch, excused as unreachable given the unique constraint
that guarantees a winner row exists. Add repository doubles whose
fetch_direct_by_key/fetch_by_idempotency_key always return None while
create() always raises DuplicateKeyError, proving each use case raises
RuntimeError instead of returning None silently, and drop both pragmas.
"0 warnings" has been a stated constraint through every prior task with
nothing in pytest config actually guarding it. Turning warnings into
errors surfaced none in this suite - just wires the enforcement up.
Copyright year (2021) and license = "MIT License" (not a valid SPDX
expression per PEP 639) were both inherited from a sibling repo this one
was bootstrapped from. This is a repository people are meant to copy
from, so fix both: the year to 2026, and the SPDX expression to "MIT".
It's 183 lines with zero coverage, escaping the 100% gate only because
planning/ has no __init__.py so coverage's package walk never reaches it
- an accident of discovery, not a stated exemption. Add it to
[tool.coverage.run] omit. migrations/env.py's `# pragma: no cover` on
is_offline_mode() was redundant with migrations/* already being in that
same omit list; drop it.
@lesnik512
lesnik512 merged commit 2145519 into main Aug 21, 2026
2 checks passed
@lesnik512
lesnik512 deleted the feat/bootstrap branch August 21, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant