diff --git a/.env.example b/.env.example index baa5572..b1f9ccc 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,20 @@ ICHAT_DB_URL=jdbc:postgresql://localhost:5432/intellistream_chat ICHAT_DB_USERNAME=ichat_role ICHAT_DB_PASSWORD=CHANGE-ME +# Optional read replica. Off by default, and off means there is no second pool at all. Enabled, +# every @Transactional(readOnly = true) is served from the replica, while writes, Flyway and raw +# JDBC access stay on the primary above. +# +# A replica lags, so a read-only transaction may see a slightly older world than the write that +# just returned. That is fine for history and search, and it is why the Lucene reconcile sweeps +# stay on the primary. Blank username/password inherit the primary's, which is what a streaming +# replica normally wants. Size the read pool with SPRING_-style Hikari keys under +# ichat.datasource.replica.hikari.*; the shipped config sets none, so it is Boot's default of 10. +# ICHAT_DB_REPLICA_ENABLED=false +# ICHAT_DB_REPLICA_URL=jdbc:postgresql://replica.internal:5432/intellistream_chat +# ICHAT_DB_REPLICA_USERNAME= +# ICHAT_DB_REPLICA_PASSWORD= + # --- Keycloak / OIDC --- KEYCLOAK_ISSUER_URI=https://auth.your-domain/realms/ichat-realm KEYCLOAK_CLIENT_ID=ichat-client @@ -239,10 +253,12 @@ ICHAT_ASSETS_UNBUNDLED=false # Optional: Vault / OpenBao # ============================================================================================= -# Credentials only. The processor reads secret/data/${path} and maps exactly five keys — -# db.username, db.password, keycloak.client-id, keycloak.client-secret, keycloak.issuer-uri — -# into their Spring property names before anything consumes them. Any other key in the record is -# ignored on purpose, so tuning stays here in the env file. +# Connection details and credentials. The processor reads secret/data/${path} and maps exactly ten +# keys — db.url, db.username, db.password, db.replica-enabled, db.replica-url, +# db.replica-username, db.replica-password, keycloak.client-id, keycloak.client-secret, +# keycloak.issuer-uri — into their Spring property names before anything consumes them. Any other +# key in the record is ignored on purpose, so tuning stays here in the env file. Every one of them +# is optional: a record holding only db.password overrides only that. # ICHAT_VAULT_ENABLED=false # ICHAT_VAULT_URI= # ICHAT_VAULT_TOKEN= diff --git a/AGENTS.md b/AGENTS.md index e7c80cc..dd033dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,7 @@ src/main/java/ai/intellistream/chat/ │ CallService, TurnCredentialService, CallScheduler, CallSessionListener ├── config/ SecurityConfig (two filter chains), WebSocketConfig, │ StompAuthorizationConfig, MultipartConfig, VaultEnvironmentPostProcessor, +│ ReadReplicaDataSourceConfig / ReadReplicaProperties (optional replica), │ RegistrationAuthorizationRequestResolver ├── domain/ JPA entities — User, Channel, Message, Conversation, │ Attachment / ConversationAttachment, MessageReaction / ConversationReaction, @@ -108,6 +109,8 @@ Several autoconfigurations that lived inside `spring-boot-autoconfigure` in 3.x - A client subscribes once per **joined channel**, not once per rendered sidebar row, so notification coverage cannot narrow when the sidebar's rendering changes. The per-session SUBSCRIBE budget is 2000/min because over-budget frames are *dropped, not refused* — at the old 200 the tail of a large account's own subscriptions vanished with no error anywhere. - **Authorisation happens once, at SUBSCRIBE.** The broker never re-checks, so revoking access needs the subscription taken away too: `ChannelSubscriptionRevoker` (channels) and its conversation sibling do that on leave, kick, and channel delete. Evicting the access cache alone only stops the *next* subscribe. - **`CurrentUser`** is the single bridge between Spring Security principals and the domain `User`. It provisions/upserts a `User` row from the OIDC subject the first time it sees a principal. Always go through it; don't read JWT/OidcUser claims in controllers. + **It resolves in two steps, and the order matters.** `UserService.findUnchanged` is a read-only single-`select` lookup that answers only when the row already agrees with the token on *every* field the write path sets — handle, email, display name, admin. Anything else returns empty and falls through to `UserService.upsert`, which is unchanged and still the only thing that writes. This runs on every authenticated request, so the difference is the whole per-request database cost of being logged in: one replica-eligible read instead of two primary reads inside a writable transaction. Both halves read their claims through `UserService.claimsOf`, one shared `ClaimView` — a second copy of the claim-reading logic would let the two disagree about whether anything changed, which is a write on every request and no error anywhere. The fall-through is stable, not repeating: `upsert` settles the row into exactly the shape `findUnchanged` tests for. `UserProvisioningFastPathTest` pins the per-field cases, including that a revoked `admin` claim must not be served from the existing row. + One consequence worth knowing: with a read replica configured, the suspension backstop in `CurrentUser.resolve` reads a row that may be up to the replication lag old. A ban issued through `BanService` is unaffected — it updates `SuspensionRegistry` before writing the row, and the filter refuses the request before it reaches the backstop. What lags is what the registry cannot see: a `suspended_at` edited directly in psql, or a ban issued by another node. - **Channel types.** `PUBLIC` channels are joinable by anyone via `ChannelService.join` **unless archived**. `PRIVATE` channels require `ChannelService.invite` by an admin. The creator becomes the first `ADMIN` member automatically, and `join` promotes the first person into an *empty* channel — otherwise a channel everyone left could never have an admin again. Members can `leave`; the last admin leaving hands the role to the longest-standing remaining member rather than being refused. - **Slug rule.** `Channel.slug` is generated from the name in `ChannelService.create` **and `ChannelService.rename`**: lowercased, non-alphanumerics collapsed to `-`, trimmed to 80 chars. A name with no alphanumerics is rejected. Renaming moves the slug, which is safe because nothing user-facing resolves a channel by slug — pages are `/channels/{id}` and every API route is id-keyed. - **UI icons come from the SVG sprite, never from emoji.** `templates/fragments/icon-sprite.html` holds every symbol; use it as `` (`.icon` = 20px buttons, `.icon-sm` = 14px inline markers), and add a new 24×24 symbol rather than reaching for a glyph. Emoji as icons look wrong for three reasons: they render in the font's own colours so they ignore the theme and can't be dimmed or turned red for a destructive action, they're drawn differently on every platform, and they vanish entirely on hosts with no emoji font. Real emoji stay emoji — reactions, the picker, and custom status are content. Letterforms (`B`, `I`, `S`, `{ }`) in the composer toolbar are labels, not icons; leave them. Note that a message's action row has **one builder and several readers**: `attachActions` in `js/chat/index.js` builds it for channel messages (including server-rendered ones — `channels.html` renders no action row at all) and `js/conversation.js` does the same for DMs. The desktop `⋯` overflow menu and the mobile long-press sheet in `chat-kit.js` both *derive* from the buttons that builder produced, reading their `data-action` and `title`. Add an action in the builder and the other two inherit it; add a second builder and they won't. @@ -127,6 +130,9 @@ Several autoconfigurations that lived inside `spring-boot-autoconfigure` in 3.x - **Sidebar.** `SidebarService.joinedFor(user)` returns a `SidebarView` of **every channel the user is a member of** — not a ranked shortlist — alphabetical (case-insensitive, ties by id), excluding archived ones, rendered as Favourites then Channels. Entries carry `joined` and `favourite`; there is no `admin` flag, because being a channel admin is not something you need in a list you scan fifty times a day (the members panel and the cog show it). The markup is `templates/fragments/sidebar.html`, one copy shared by `channels.html` *and* `conversation.html`, in the **left** `