refactor(msg-bus): prefer MessageBus as Realtime

This commit is contained in:
Edouard Vanbelle
2026-09-11 00:25:25 +02:00
parent 1d280c161c
commit 7918fff47b
51 changed files with 295 additions and 227 deletions
+15 -15
View File
@@ -26,7 +26,7 @@ jobs:
wasm: ${{ steps.filter.outputs.wasm }} wasm: ${{ steps.filter.outputs.wasm }}
plugins: ${{ steps.filter.outputs.plugins }} plugins: ${{ steps.filter.outputs.plugins }}
migrations: ${{ steps.filter.outputs.migrations }} migrations: ${{ steps.filter.outputs.migrations }}
realtime_spec: ${{ steps.filter.outputs.realtime_spec }} message_bus_spec: ${{ steps.filter.outputs.message_bus_spec }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dorny/paths-filter@v3 - uses: dorny/paths-filter@v3
@@ -53,12 +53,12 @@ jobs:
- 'src/application/adapters/plugin_user_lifecycle_hook.rs' - 'src/application/adapters/plugin_user_lifecycle_hook.rs'
migrations: migrations:
- 'migrations/**' - 'migrations/**'
realtime_spec: message_bus_spec:
- 'src/application/ports/realtime_ports.rs' - 'src/application/ports/message_bus_ports.rs'
- 'src/bin/generate-asyncapi.rs' - 'src/bin/generate-asyncapi.rs'
- 'resources/gen/asyncapi.json' - 'resources/gen/asyncapi.json'
- 'frontend/scripts/gen-realtime-types.mjs' - 'frontend/scripts/gen-message-bus-types.mjs'
- 'frontend/src/lib/generated/realtime/**' - 'frontend/src/lib/generated/message-bus/**'
- 'frontend/package.json' - 'frontend/package.json'
frontend-check: frontend-check:
@@ -92,18 +92,18 @@ jobs:
# scratch, then fails the PR if either output drifts from what was # scratch, then fails the PR if either output drifts from what was
# committed. Same discipline as the OpenAPI + wasm-fixture approach # committed. Same discipline as the OpenAPI + wasm-fixture approach
# elsewhere in this file — the wire spec is a compile-time artefact # elsewhere in this file — the wire spec is a compile-time artefact
# of the Rust source (`realtime_ports.rs`), and the TS DTOs are a # of the Rust source (`message_bus_ports.rs`), and the TS DTOs are
# compile-time artefact of the spec, so both must be reproducible. # a compile-time artefact of the spec, so both must be reproducible.
# #
# Scoped by the `realtime_spec` path filter so a PR that doesn't # Scoped by the `message_bus_spec` path filter so a PR that doesn't
# touch the wire (or its generator scripts, or the Modelina version) # touch the wire (or its generator scripts, or the Modelina version)
# skips this job entirely. Needs BOTH Rust and Node toolchains, so # skips this job entirely. Needs BOTH Rust and Node toolchains, so
# it's slightly heavier than a single-toolchain job — the filter # it's slightly heavier than a single-toolchain job — the filter
# keeps it off the hot path. # keeps it off the hot path.
realtime-spec-drift: message-bus-spec-drift:
name: Realtime spec — AsyncAPI + TypeScript DTO drift name: Message-bus spec — AsyncAPI + TypeScript DTO drift
needs: changes needs: changes
if: needs.changes.outputs.realtime_spec == 'true' if: needs.changes.outputs.message_bus_spec == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -112,7 +112,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
# Node for `just asyncapi-ts` — Modelina projects the spec into # Node for `just asyncapi-ts` — Modelina projects the spec into
# the FE `src/lib/generated/realtime/` folder. # the FE `src/lib/generated/message-bus/` folder.
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
@@ -127,7 +127,7 @@ jobs:
run: cargo run --features dev_tools --bin generate-asyncapi run: cargo run --features dev_tools --bin generate-asyncapi
- name: Regenerate TS DTOs (Modelina) - name: Regenerate TS DTOs (Modelina)
working-directory: frontend working-directory: frontend
run: npm run gen:realtime run: npm run gen:message-bus
- name: Fail if committed files drifted - name: Fail if committed files drifted
# A non-empty diff means a contributor edited the Rust wire # A non-empty diff means a contributor edited the Rust wire
# source (or Modelina config) without regenerating, or hand- # source (or Modelina config) without regenerating, or hand-
@@ -136,9 +136,9 @@ jobs:
run: | run: |
if ! git diff --exit-code \ if ! git diff --exit-code \
resources/gen/asyncapi.json \ resources/gen/asyncapi.json \
frontend/src/lib/generated/realtime/; then frontend/src/lib/generated/message-bus/; then
echo "" echo ""
echo "::error::Realtime spec drift: the committed files differ from what the" echo "::error::Message-bus spec drift: the committed files differ from what the"
echo "::error::generator produces from source. Run \`just asyncapi-ts\` locally" echo "::error::generator produces from source. Run \`just asyncapi-ts\` locally"
echo "::error::and commit the result — that recipe re-runs both stages." echo "::error::and commit the result — that recipe re-runs both stages."
exit 1 exit 1
+2 -2
View File
@@ -271,7 +271,7 @@ name = "generate-asyncapi"
path = "src/bin/generate-asyncapi.rs" path = "src/bin/generate-asyncapi.rs"
# Dev-only: regenerates `resources/gen/asyncapi.json` — the WS surface's # Dev-only: regenerates `resources/gen/asyncapi.json` — the WS surface's
# analogue of openapi.json. Constructed from the same `error_code` # analogue of openapi.json. Constructed from the same `error_code`
# constants + `Topic`/`RealtimeEvent` shapes the server uses, so the # constants + `Topic`/`MessageBusEvent` shapes the server uses, so the
# spec stays in sync with the implementation by construction. Same # spec stays in sync with the implementation by construction. Same
# gating as `generate-openapi`. Invoked by `just asyncapi`. # gating as `generate-openapi`. Invoked by `just asyncapi`.
required-features = ["dev_tools"] required-features = ["dev_tools"]
@@ -309,7 +309,7 @@ required-features = ["load_seed_bin"]
[[bin]] [[bin]]
name = "rt-hurl-helper" name = "rt-hurl-helper"
path = "src/bin/rt-hurl-helper.rs" path = "src/bin/rt-hurl-helper.rs"
# Test-suite WebSocket client for the realtime message bus. Hurl is # Test-suite WebSocket client for the message bus. Hurl is
# HTTP-only and cannot drive a WS handshake or read frames; this bin # HTTP-only and cannot drive a WS handshake or read frames; this bin
# supplies the two modes the smoke test needs — `subscribe-and-collect` # supplies the two modes the smoke test needs — `subscribe-and-collect`
# (background subscriber that captures events to JSON) and # (background subscriber that captures events to JSON) and
+1 -1
View File
@@ -355,7 +355,7 @@ Today's shipped locales: `ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru
Example: `OXICLOUD_TRUST_PROXY_CIDR=127.0.0.1/32,10.0.0.0/8,172.16.0.0/12` Example: `OXICLOUD_TRUST_PROXY_CIDR=127.0.0.1/32,10.0.0.0/8,172.16.0.0/12`
## Realtime WebSocket ## Message bus WebSocket
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
+38 -37
View File
@@ -1,4 +1,4 @@
# Plan — Realtime message bus over WebSocket # Plan — Message bus over WebSocket
## Context ## Context
@@ -10,7 +10,7 @@ notifications, sync-client push invalidation — and it makes existing
surfaces feel dated compared to Google Drive, Notion, Nextcloud, and surfaces feel dated compared to Google Drive, Notion, Nextcloud, and
M365. M365.
This plan introduces a single realtime bus over WebSocket that any This plan introduces a single message bus over WebSocket that any
service can publish facts to and any client can subscribe to. Collab service can publish facts to and any client can subscribe to. Collab
editing is one consumer on top; folder-live updates, notifications, editing is one consumer on top; folder-live updates, notifications,
job progress, presence, and sync-client push invalidation follow with job progress, presence, and sync-client push invalidation follow with
@@ -41,13 +41,13 @@ almost no extra scaffolding.
│ CollabSessionService.apply() ─────────────────▶ bus.publish(...) │ │ CollabSessionService.apply() ─────────────────▶ bus.publish(...) │
│ │ │ │
└───────────────────────────────┬──────────────────────────────────────┘ └───────────────────────────────┬──────────────────────────────────────┘
│ publish(&Topic, RealtimeEvent) │ publish(&Topic, MessageBusEvent)
▼ ▼
┌──────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────┐
│ REALTIME BUS (RealtimeBus trait — application/ports) │ │ MESSAGE BUS (MessageBus trait — application/ports) │
│ │ │ │
│ InProcessRealtimeBus (v1) │ │ InProcessMessageBus (v1) │
│ DashMap<Topic, broadcast::Sender<RealtimeEvent>> │ │ DashMap<Topic, broadcast::Sender<MessageBusEvent>> │
│ │ │ │
└──────┬───────────────────────────────────────────────────────────────┘ └──────┬───────────────────────────────────────────────────────────────┘
│ │
@@ -59,7 +59,7 @@ almost no extra scaffolding.
│ │ - v2: PgListenReplicator (pg_notify) │ │ │ - v2: PgListenReplicator (pg_notify) │
│ │ - v3: BrokerReplicator (RabbitMQ / NATS) │ │ │ - v3: BrokerReplicator (RabbitMQ / NATS) │
│ │ │ │ │ │
│ │ Sits BESIDE InProcessRealtimeBus, forwards │ │ │ Sits BESIDE InProcessMessageBus, forwards │
│ │ local publishes outbound + inbound events │ │ │ local publishes outbound + inbound events │
│ │ from the broker back into local publish. │ │ │ from the broker back into local publish. │
│ └──────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────┘
@@ -67,7 +67,7 @@ almost no extra scaffolding.
┌──────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────┐
│ WS HANDLER (interfaces/api/handlers/rt_ws.rs) │ │ WS HANDLER (interfaces/api/handlers/rt_ws.rs) │
│ │ │ │
│ One RealtimeSession per WS: HashSet<Topic> + outbound mpsc │ │ One BusSession per WS: HashSet<Topic> + outbound mpsc │
│ - subscribe/unsubscribe frames → bus.subscribe(topic) │ │ - subscribe/unsubscribe frames → bus.subscribe(topic) │
│ - each subscribed stream drains into the outbound mpsc │ │ - each subscribed stream drains into the outbound mpsc │
│ - AuthZ at subscribe (once), evict on grant-revoked │ │ - AuthZ at subscribe (once), evict on grant-revoked │
@@ -77,12 +77,12 @@ almost no extra scaffolding.
**The seam that keeps RabbitMQ/NATS doors open is the replicator, not **The seam that keeps RabbitMQ/NATS doors open is the replicator, not
the bus.** Services and the WS handler only ever see the local the bus.** Services and the WS handler only ever see the local
`RealtimeBus`. A future `BrokerReplicator` publishes outbound + injects `MessageBus`. A future `BrokerReplicator` publishes outbound + injects
inbound. Zero touch to callers. inbound. Zero touch to callers.
## Backend components ## Backend components
### 1. Port + event types (`application/ports/realtime_ports.rs`) ### 1. Port + event types (`application/ports/message_bus_ports.rs`)
```rust ```rust
// Topic is a typed enum, not a string. Prevents typos, gives // Topic is a typed enum, not a string. Prevents typos, gives
@@ -127,7 +127,7 @@ pub enum PrincipalRef {
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")] #[serde(tag = "event", rename_all = "snake_case")]
pub enum RealtimeEvent { pub enum MessageBusEvent {
// Folder / File verbs — thin facts only, client refetches details. // Folder / File verbs — thin facts only, client refetches details.
FileCreated { file_id: FileId, name: String, parent_id: FolderId, actor: UserId }, FileCreated { file_id: FileId, name: String, parent_id: FolderId, actor: UserId },
FileDeleted { file_id: FileId, parent_id: FolderId, actor: UserId }, FileDeleted { file_id: FileId, parent_id: FolderId, actor: UserId },
@@ -168,21 +168,21 @@ pub enum RealtimeEvent {
} }
#[async_trait] #[async_trait]
pub trait RealtimeBus: Send + Sync { pub trait MessageBus: Send + Sync {
/// Fire-and-forget. SYNC (not async) — services must not await /// Fire-and-forget. SYNC (not async) — services must not await
/// under a DB transaction. /// under a DB transaction.
fn publish(&self, topic: &Topic, event: RealtimeEvent); fn publish(&self, topic: &Topic, event: MessageBusEvent);
/// Returns a Stream so the impl can change (broadcast, mpsc, /// Returns a Stream so the impl can change (broadcast, mpsc,
/// pg listener) without churn. /// pg listener) without churn.
fn subscribe(&self, topic: &Topic) -> Pin<Box<dyn Stream<Item = RealtimeEvent> + Send>>; fn subscribe(&self, topic: &Topic) -> Pin<Box<dyn Stream<Item = MessageBusEvent> + Send>>;
} }
/// Kept SEPARATE from RealtimeBus so v2/v3 wiring is drop-in. /// Kept SEPARATE from MessageBus so v2/v3 wiring is drop-in.
#[async_trait] #[async_trait]
pub trait BusReplicator: Send + Sync { pub trait BusReplicator: Send + Sync {
/// Called whenever the local bus publishes; may forward to broker. /// Called whenever the local bus publishes; may forward to broker.
fn on_local_publish(&self, topic: &Topic, event: &RealtimeEvent); fn on_local_publish(&self, topic: &Topic, event: &MessageBusEvent);
/// Long-running consumer task: reads remote messages and /// Long-running consumer task: reads remote messages and
/// re-publishes locally. Started by DI, returns on shutdown. /// re-publishes locally. Started by DI, returns on shutdown.
@@ -203,7 +203,7 @@ paths**:
| Each affected user (persistent "shared with you") | `user:{member}:notifications` (one publish per member) | Becomes a `notif.notifications` row via `NotificationService::create` | | Each affected user (persistent "shared with you") | `user:{member}:notifications` (one publish per member) | Becomes a `notif.notifications` row via `NotificationService::create` |
The bus **never expands groups**. `NotificationService` is the The bus **never expands groups**. `NotificationService` is the
group-expansion boundary. `RealtimeBus` only fans out topics that group-expansion boundary. `MessageBus` only fans out topics that
already exist as concrete `user:*` streams. already exist as concrete `user:*` streams.
Post-commit sequence for `ShareService::grant(file=F, principal=Group(G), role=R)`: Post-commit sequence for `ShareService::grant(file=F, principal=Group(G), role=R)`:
@@ -262,9 +262,9 @@ Coalescing: `NotificationService::create` de-dupes on
window. Alice in both `G1` and `G2`, both granted `F`, gets one window. Alice in both `G1` and `G2`, both granted `F`, gets one
notification, not two. notification, not two.
### 2. In-process impl (`infrastructure/services/in_process_realtime_bus.rs`) ### 2. In-process impl (`infrastructure/services/in_process_message_bus.rs`)
- `DashMap<Topic, broadcast::Sender<RealtimeEvent>>`, capacity 256 per topic. - `DashMap<Topic, broadcast::Sender<MessageBusEvent>>`, capacity 256 per topic.
- `subscribe` creates the entry lazily; wraps `Receiver` in - `subscribe` creates the entry lazily; wraps `Receiver` in
`BroadcastStream` (converts `Lagged` into a stream-level marker; WS `BroadcastStream` (converts `Lagged` into a stream-level marker; WS
handler kills that session with a `revoked` frame, reason handler kills that session with a `revoked` frame, reason
@@ -275,7 +275,7 @@ notification, not two.
### 3. Replicator scaffolding (day-1) ### 3. Replicator scaffolding (day-1)
- `NoopReplicator` in v1. Wired in DI as `Arc<dyn BusReplicator>`. - `NoopReplicator` in v1. Wired in DI as `Arc<dyn BusReplicator>`.
- `InProcessRealtimeBus::publish` calls - `InProcessMessageBus::publish` calls
`replicator.on_local_publish(...)` **after** local fan-out. `replicator.on_local_publish(...)` **after** local fan-out.
Futures: Futures:
@@ -313,7 +313,7 @@ Futures:
- Extract `caller_id` from the auth mechanism above. - Extract `caller_id` from the auth mechanism above.
- Auto-subscribe to `user:{caller}:notifications`, - Auto-subscribe to `user:{caller}:notifications`,
`user:{caller}:authz`, `user:{caller}:sessions`. `user:{caller}:authz`, `user:{caller}:sessions`.
- Spawn `RealtimeSession` actor: owns `HashSet<Topic>`, outbound - Spawn `BusSession` actor: owns `HashSet<Topic>`, outbound
`mpsc::Sender<WsMessage>` (bounded 512), one reader task per `mpsc::Sender<WsMessage>` (bounded 512), one reader task per
subscribed topic. subscribed topic.
- Per-frame: - Per-frame:
@@ -324,7 +324,7 @@ Futures:
admin bypass), role-scoped (`caller.role == Admin`); plus the admin bypass), role-scoped (`caller.role == Admin`); plus the
bespoke job-originator-or-admin check for `job:{id}`. Full bespoke job-originator-or-admin check for `job:{id}`. Full
matrix in **§ AuthZ model**. Deny → `denied` frame + audit matrix in **§ AuthZ model**. Deny → `denied` frame + audit
`event = "realtime.subscribe_denied"`. Allow → subscribe on bus, `event = "message_bus.subscribe_denied"`. Allow → subscribe on bus,
ack. ack.
- `unsubscribe`: drop the reader task for that topic. - `unsubscribe`: drop the reader task for that topic.
- `ping/pong` for keepalive. - `ping/pong` for keepalive.
@@ -345,14 +345,14 @@ never before, never inside**. If publish were inside the tx, a
rollback would still fan out to clients. If publish were async and rollback would still fan out to clients. If publish were async and
awaited, a slow subscriber could hold the tx open. awaited, a slow subscriber could hold the tx open.
Pattern: services return `(result, Vec<RealtimeEvent>)` from the tx Pattern: services return `(result, Vec<MessageBusEvent>)` from the tx
boundary; the calling layer publishes after commit. Or a boundary; the calling layer publishes after commit. Or a
`TxCommitHook` queues events and flushes on commit. Pick one, apply `TxCommitHook` queues events and flushes on commit. Pick one, apply
everywhere. everywhere.
## Frontend components ## Frontend components
### 1. Singleton client (`lib/stores/realtime.svelte.ts`) ### 1. Singleton client (`lib/stores/message-bus.svelte.ts`)
- Fetches a ticket via `POST /api/rt/ticket` (through `apiFetch`, so - Fetches a ticket via `POST /api/rt/ticket` (through `apiFetch`, so
DPoP is applied). DPoP is applied).
@@ -390,9 +390,10 @@ Two wire formats share the same WS connection:
- **CRDT binary frames: Yjs sync protocol** — de-facto standard in the - **CRDT binary frames: Yjs sync protocol** — de-facto standard in the
Yjs ecosystem, kept as-is because it's the reason we picked Yjs. Yjs ecosystem, kept as-is because it's the reason we picked Yjs.
Method namespace for our JSON-RPC methods: `rt.*` (short for Method namespace for our JSON-RPC methods: `rt.*` — a short opaque
realtime). Prevents collisions if we ever expose additional RPCs on prefix reserved for message-bus methods. Prevents collisions if we
the same WS (not planned, but the namespace costs nothing). ever expose additional RPCs on the same WS (not planned, but the
namespace costs nothing).
### JSON-RPC frames (control + events) ### JSON-RPC frames (control + events)
@@ -511,7 +512,7 @@ implementation by construction — no hand-written spec that drifts.
- **Message schemas** — the JSON-RPC envelope and one schema per - **Message schemas** — the JSON-RPC envelope and one schema per
`event` variant (`file_created`, `folder_created`, `event` variant (`file_created`, `folder_created`,
`share_granted`, `notification`, …). Generated via `schemars` from `share_granted`, `notification`, …). Generated via `schemars` from
the same Rust `RealtimeEvent` enum the server publishes, so the the same Rust `MessageBusEvent` enum the server publishes, so the
schema is authoritative, not aspirational. schema is authoritative, not aspirational.
- **Error object shape + `code`/`message` catalog** — the JSON-RPC - **Error object shape + `code`/`message` catalog** — the JSON-RPC
error table above becomes an AsyncAPI-declared `errors` block on error table above becomes an AsyncAPI-declared `errors` block on
@@ -529,8 +530,8 @@ implementation by construction — no hand-written spec that drifts.
Follows the same shape as `generate-openapi`: Follows the same shape as `generate-openapi`:
- New binary `src/bin/generate_asyncapi.rs` that constructs the - New binary `src/bin/generate_asyncapi.rs` that constructs the
spec from `Topic`, `RealtimeEvent`, `AuthzCheck`, and the JSON-RPC spec from `Topic`, `MessageBusEvent`, `AuthzCheck`, and the JSON-RPC
method/error tables — all live in `application/ports/realtime_ports.rs` method/error tables — all live in `application/ports/message_bus_ports.rs`
as the single source of truth. as the single source of truth.
- Uses `schemars` for JSON Schema of each event variant (already - Uses `schemars` for JSON Schema of each event variant (already
compatible with `serde` derives; no re-annotation needed). compatible with `serde` derives; no re-annotation needed).
@@ -618,8 +619,8 @@ it once, avoid hand-maintaining a growing catalog of message types.
stays; only the message DTOs come from codegen. stays; only the message DTOs come from codegen.
- **Wiring:** - **Wiring:**
- `frontend/package.json` dev-dep: `@asyncapi/modelina`. - `frontend/package.json` dev-dep: `@asyncapi/modelina`.
- Script `frontend/scripts/gen-realtime-types.mjs` invokes Modelina, - Script `frontend/scripts/gen-message-bus-types.mjs` invokes Modelina,
writes to `frontend/src/lib/generated/realtime/`. writes to `frontend/src/lib/generated/message-bus/`.
- `just asyncapi-ts` recipe alongside `just asyncapi`. - `just asyncapi-ts` recipe alongside `just asyncapi`.
- CI dirty-tree check — regenerate on every build, fail if `git - CI dirty-tree check — regenerate on every build, fail if `git
diff` on the generated folder is non-empty. Same discipline as diff` on the generated folder is non-empty. Same discipline as
@@ -747,10 +748,10 @@ confirms the anti-enumeration collapse rules the wire honours.
- **Connect reject** — `event = "auth.rt_ticket_rejected"`, - **Connect reject** — `event = "auth.rt_ticket_rejected"`,
`reason ∈ {expired, unknown, ip_mismatch, replay}`. `reason ∈ {expired, unknown, ip_mismatch, replay}`.
- **Subscribe deny** — `event = "realtime.subscribe_denied"`, `reason` - **Subscribe deny** — `event = "message_bus.subscribe_denied"`, `reason`
from the audit column above, plus `caller_id`, `topic`. Emitted from the audit column above, plus `caller_id`, `topic`. Emitted
BEFORE the wire `denied` frame. BEFORE the wire `denied` frame.
- **Evict** — `event = "realtime.subscription_evicted"`, - **Evict** — `event = "message_bus.subscription_evicted"`,
`reason ∈ {grant_revoked, resource_deleted, admin_kick, group_membership_lost}`, `reason ∈ {grant_revoked, resource_deleted, admin_kick, group_membership_lost}`,
plus `caller_id`, `topic`. plus `caller_id`, `topic`.
- **Collab edit rejected** — `event = "collab.write_denied"`, - **Collab edit rejected** — `event = "collab.write_denied"`,
@@ -796,7 +797,7 @@ larger (notifications table, presence, collab) rides on top later.
### Scope in ### Scope in
- `RealtimeBus` port + `InProcessRealtimeBus`. - `MessageBus` port + `InProcessMessageBus`.
- WS handler at `GET /api/rt/ws` with `subscribe` / `unsubscribe` / - WS handler at `GET /api/rt/ws` with `subscribe` / `unsubscribe` /
`ping` frames only (no CRDT binary frames yet). `ping` frames only (no CRDT binary frames yet).
- Auth: reuse existing `auth_middleware` — session cookie for - Auth: reuse existing `auth_middleware` — session cookie for
@@ -954,7 +955,7 @@ subscribe to but didn't.
Verifies: a user without `Read` on a folder cannot subscribe to Verifies: a user without `Read` on a folder cannot subscribe to
its topic. Denial wire reason is `no_read`; audit line records its topic. Denial wire reason is `no_read`; audit line records
`realtime.subscribe_denied` with `reason ∈ {no_read, `message_bus.subscribe_denied` with `reason ∈ {no_read,
no_such_resource}`. no_such_resource}`.
``` ```
@@ -1046,7 +1047,7 @@ this baseline once the baseline is green.
Ships the infrastructure and the two most visible consumers together. Ships the infrastructure and the two most visible consumers together.
- Bus port + `InProcessRealtimeBus` + `NoopReplicator` + WS handler - Bus port + `InProcessMessageBus` + `NoopReplicator` + WS handler
+ ticket endpoint. + ticket endpoint.
- Frontend singleton + `useTopic` composable. - Frontend singleton + `useTopic` composable.
- Topics live: `folder:{id}`, `user:{u}:notifications`, `job:{id}`, - Topics live: `folder:{id}`, `user:{u}:notifications`, `job:{id}`,
+67
View File
@@ -9,3 +9,70 @@ Prefix `oxi-`, kebab-case separators. Example: `oxi-view-mode`.
Enforced by `$lib/utils/localStoragePrefs::wipeAppKeys()` which sweeps Enforced by `$lib/utils/localStoragePrefs::wipeAppKeys()` which sweeps
every `oxi-*` key on user-account switches — any other prefix leaks the every `oxi-*` key on user-account switches — any other prefix leaks the
previous user's state into the new one. previous user's state into the new one.
## Logging — `loglevel` with `oxi:*` namespaces
**Never use bare `console.debug/info/warn/error` in `$lib` or route code.**
Route through the shared [`loglevel`](https://github.com/pimterry/loglevel)
logger so users and support can dial verbosity per subsystem from the
browser console without a redeploy.
```ts
import log from 'loglevel';
const bus = log.getLogger('oxi:message-bus');
bus.debug('subscribed', { topic });
bus.warn('reconnect scheduled', { attempt, backoffMs });
bus.error('unexpected frame', { raw });
```
Convention:
- **Namespace = `oxi:<subsystem>`** in kebab-case. One namespace per
subsystem/module boundary — e.g. `oxi:upload` (delta + direct
uploader), `oxi:message-bus` (WS client + `useTopic`). Do not create
finer-grained per-file namespaces; users tune subsystems, not files.
- **Level is user-controlled** via the DevTools helper installed in
`src/hooks.client.ts`:
```js
oxi.setLogLevel('oxi:message-bus', 'debug');
oxi.listLogLevels();
```
Choices persist to `localStorage['loglevel:<namespace>']`. Default is
loglevel's `warn` — production stays quiet unless the user opts in.
- **Add every new namespace to the DevTools comment block** in
`hooks.client.ts` (the `Log levels — namespaces used today: …` line)
so users have a discoverable list.
- **No `console.log` at all** — Stylelint/ESLint don't flag it, but the
codebase convention does. `console.error` is only acceptable in
boot-time paths (`hooks.client.ts`, generator scripts, worker
bootstraps) where the shared logger isn't reachable yet.
- **Workers can't `import log` from a static path** — see
`lib/api/endpoints/deltaUpload.ts`: the worker `postMessage`s a
`{type: 'log', level, msg, extra}` envelope and the main thread relays
it through the shared logger. Mirror this pattern for any new worker.
## Message bus naming
The realtime channel is the **message bus** everywhere — backend port
`MessageBus`, plan doc `docs/plan/message-bus.md`, generated DTOs under
`$lib/generated/message-bus/`, FE store/composables named accordingly.
Only two things keep the older `rt`/`Rt` shorthand, and both for wire-
protocol reasons:
- **JSON-RPC method prefix** — `rt.subscribe`, `rt.event`, `rt.revoked`,
`rt.ping`, `rt.error`. The prefix is opaque wire vocabulary and does
not have to expand to "realtime"; treat it as a short namespace tag
reserved for message-bus methods.
- **Generated type names** — `RtSubscribeParams`, `RtEventBody`, etc.
Modelina keys off the AsyncAPI schema names, which mirror the JSON-RPC
method names.
When adding FE code around the bus, use `message-bus` in file names,
store names, and logger namespaces:
- Store: `$lib/stores/message-bus.svelte.ts`
- Composables: `$lib/composables/useTopic.svelte.ts` (topic-generic — no
bus name in the file)
- Logger namespace: `oxi:message-bus`
- localStorage keys (if any): `oxi-message-bus-*`
+1 -1
View File
@@ -18,7 +18,7 @@
"test:unit": "LANG=C vitest run", "test:unit": "LANG=C vitest run",
"test:unit:watch": "LANG=C vitest", "test:unit:watch": "LANG=C vitest",
"test:unit:coverage": "rm -rf ../tests/e2e/.nyc_output_unit && LANG=C COVERAGE=1 vitest run", "test:unit:coverage": "rm -rf ../tests/e2e/.nyc_output_unit && LANG=C COVERAGE=1 vitest run",
"gen:realtime": "node scripts/gen-realtime-types.mjs" "gen:message-bus": "node scripts/gen-message-bus-types.mjs"
}, },
"devDependencies": { "devDependencies": {
"@asyncapi/modelina": "^5.5.0", "@asyncapi/modelina": "^5.5.0",
@@ -1,5 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
// Realtime bus — TypeScript DTOs generated from `resources/gen/asyncapi.json`. // Message bus — TypeScript DTOs generated from `resources/gen/asyncapi.json`.
// //
// Sits on the same axis as `resources/gen/openapi.json`: the wire spec // Sits on the same axis as `resources/gen/openapi.json`: the wire spec
// (authored by `cargo run --features dev_tools --bin generate-asyncapi`) // (authored by `cargo run --features dev_tools --bin generate-asyncapi`)
@@ -7,14 +7,14 @@
// interfaces so `lib/composables/useTopic.ts` and every folder-view // interfaces so `lib/composables/useTopic.ts` and every folder-view
// switch statement is compile-time exhaustive over the `rt.event` variants. // switch statement is compile-time exhaustive over the `rt.event` variants.
// //
// Regenerate: `just asyncapi-ts` (or `npm run gen:realtime`). // Regenerate: `just asyncapi-ts` (or `npm run gen:message-bus`).
// CI is expected to run the same command and fail if the working tree is // CI is expected to run the same command and fail if the working tree is
// dirty afterwards — same discipline `just openapi` follows. // dirty afterwards — same discipline `just openapi` follows.
// //
// Design notes: // Design notes:
// * `modelType: 'interface'` — plain records, not classes-with-getters. // * `modelType: 'interface'` — plain records, not classes-with-getters.
// Matches the FE codebase style (see `lib/api/types.ts`). // Matches the FE codebase style (see `lib/api/types.ts`).
// * Output goes to `src/lib/generated/realtime/` — a directory reserved // * Output goes to `src/lib/generated/message-bus/` — a directory reserved
// for auto-generated files. Never hand-edit anything inside. // for auto-generated files. Never hand-edit anything inside.
// * Every file gets a `AUTO-GENERATED` banner via a preset so a stray // * Every file gets a `AUTO-GENERATED` banner via a preset so a stray
// edit is obvious at review time. // edit is obvious at review time.
@@ -32,13 +32,13 @@ import { TypeScriptFileGenerator } from '@asyncapi/modelina';
const execFile = promisify(execFileCb); const execFile = promisify(execFileCb);
// Anchor everything on this script's location so `just asyncapi-ts` from // Anchor everything on this script's location so `just asyncapi-ts` from
// the repo root and `npm run gen:realtime` from the frontend both work. // the repo root and `npm run gen:message-bus` from the frontend both work.
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const frontendRoot = resolve(__dirname, '..'); const frontendRoot = resolve(__dirname, '..');
const repoRoot = resolve(frontendRoot, '..'); const repoRoot = resolve(frontendRoot, '..');
const specPath = resolve(repoRoot, 'resources/gen/asyncapi.json'); const specPath = resolve(repoRoot, 'resources/gen/asyncapi.json');
const outputDir = resolve(frontendRoot, 'src/lib/generated/realtime'); const outputDir = resolve(frontendRoot, 'src/lib/generated/message-bus');
// Load the spec. Failing here means the wire spec hasn't been generated // Load the spec. Failing here means the wire spec hasn't been generated
// yet — hint the operator at the right command. // yet — hint the operator at the right command.
@@ -47,7 +47,7 @@ try {
spec = JSON.parse(await readFile(specPath, 'utf8')); spec = JSON.parse(await readFile(specPath, 'utf8'));
} catch (err) { } catch (err) {
console.error( console.error(
`gen-realtime-types: cannot read ${specPath}: ${err.message}\n` + `gen-message-bus-types: cannot read ${specPath}: ${err.message}\n` +
`\nDid you run \`just asyncapi\` first? The Rust generator writes\n` + `\nDid you run \`just asyncapi\` first? The Rust generator writes\n` +
`resources/gen/asyncapi.json; this script consumes it.` `resources/gen/asyncapi.json; this script consumes it.`
); );
@@ -77,7 +77,7 @@ const generator = new TypeScriptFileGenerator({
const banner = const banner =
'// AUTO-GENERATED — do not edit by hand.\n' + '// AUTO-GENERATED — do not edit by hand.\n' +
'// Regenerate with `just asyncapi-ts` (which runs\n' + '// Regenerate with `just asyncapi-ts` (which runs\n' +
'// `node frontend/scripts/gen-realtime-types.mjs`).\n' + '// `node frontend/scripts/gen-message-bus-types.mjs`).\n' +
'// Source of truth: resources/gen/asyncapi.json,\n' + '// Source of truth: resources/gen/asyncapi.json,\n' +
'// authored by the Rust `generate-asyncapi` binary.\n'; '// authored by the Rust `generate-asyncapi` binary.\n';
return `${banner}${content}`; return `${banner}${content}`;
@@ -171,7 +171,7 @@ for (const f of files) {
const anonymous = files.filter((f) => f.endsWith('.ts') && /^AnonymousSchema_/i.test(f)); const anonymous = files.filter((f) => f.endsWith('.ts') && /^AnonymousSchema_/i.test(f));
if (anonymous.length > 0) { if (anonymous.length > 0) {
console.error( console.error(
`gen-realtime-types: FAIL — Modelina produced ${anonymous.length} ` + `gen-message-bus-types: FAIL — Modelina produced ${anonymous.length} ` +
`AnonymousSchema_N file(s):` `AnonymousSchema_N file(s):`
); );
for (const f of anonymous) console.error(` - ${f}`); for (const f of anonymous) console.error(` - ${f}`);
@@ -198,7 +198,7 @@ try {
}); });
} catch (err) { } catch (err) {
console.error( console.error(
`gen-realtime-types: prettier --write failed: ${err.message}\n` + `gen-message-bus-types: prettier --write failed: ${err.message}\n` +
`The generated files may still be usable but will fail\n` + `The generated files may still be usable but will fail\n` +
`\`npm run check\` on the prettier step. Fix prettier setup\n` + `\`npm run check\` on the prettier step. Fix prettier setup\n` +
`(is @prettier installed in frontend/node_modules?) then\n` + `(is @prettier installed in frontend/node_modules?) then\n` +
@@ -208,7 +208,7 @@ try {
} }
console.log( console.log(
`gen-realtime-types: wrote ${models.length} model(s) to ${outputDir}` + `gen-message-bus-types: wrote ${models.length} model(s) to ${outputDir}` +
` (rewrote ${rewritten} for verbatimModuleSyntax, 0 AnonymousSchema,` + ` (rewrote ${rewritten} for verbatimModuleSyntax, 0 AnonymousSchema,` +
` prettier-formatted)` ` prettier-formatted)`
); );
+18 -18
View File
@@ -195,8 +195,8 @@ openapi:
cargo run --features dev_tools --bin generate-openapi cargo run --features dev_tools --bin generate-openapi
# Regenerate `resources/gen/asyncapi.json` — the WS surface's spec, # Regenerate `resources/gen/asyncapi.json` — the WS surface's spec,
# analogue of openapi.json. Built from the `Topic`, `RealtimeEvent`, # analogue of openapi.json. Built from the `Topic`, `MessageBusEvent`,
# and `error_code` constants in `application/ports/realtime_ports.rs` # and `error_code` constants in `application/ports/message_bus_ports.rs`
# so the spec stays in sync with the wire by construction. # so the spec stays in sync with the wire by construction.
asyncapi: asyncapi:
cargo run --features dev_tools --bin generate-asyncapi cargo run --features dev_tools --bin generate-asyncapi
@@ -210,34 +210,34 @@ asyncapi:
# .ts files (idempotent — same input → same output, CI dirty-tree # .ts files (idempotent — same input → same output, CI dirty-tree
# check catches genuine drift). # check catches genuine drift).
# #
# Output lands in `frontend/src/lib/generated/realtime/`; consumers # Output lands in `frontend/src/lib/generated/message-bus/`; consumers
# import from there but never edit those files. # import from there but never edit those files.
asyncapi-ts: asyncapi asyncapi-ts: asyncapi
cd frontend && npm run gen:realtime cd frontend && npm run gen:message-bus
# Local mirror of the `realtime-spec-drift` CI job. Regenerates both # Local mirror of the `message-bus-spec-drift` CI job. Regenerates
# artefacts and fails if the committed files differ from the fresh # both artefacts and fails if the committed files differ from the
# generator output. Included in `pre-pull-request` so developers # fresh generator output. Included in `pre-pull-request` so
# catch drift BEFORE pushing — the CI job is belt-and-braces, not the # developers catch drift BEFORE pushing — the CI job is
# only defence. # belt-and-braces, not the only defence.
# #
# Depends on `asyncapi-ts` which itself depends on `asyncapi`, so the # Depends on `asyncapi-ts` which itself depends on `asyncapi`, so the
# whole chain runs; then we assert on `git diff --exit-code` over # whole chain runs; then we assert on `git diff --exit-code` over
# the two paths we care about. # the two paths we care about.
check-realtime-spec: asyncapi-ts check-message-bus-spec: asyncapi-ts
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
if ! git diff --exit-code \ if ! git diff --exit-code \
resources/gen/asyncapi.json \ resources/gen/asyncapi.json \
frontend/src/lib/generated/realtime/; then frontend/src/lib/generated/message-bus/; then
echo "" echo ""
echo "❌ realtime spec drift: committed files differ from the fresh" echo "❌ message-bus spec drift: committed files differ from the fresh"
echo " generator output. Fix:" echo " generator output. Fix:"
echo " git add resources/gen/asyncapi.json frontend/src/lib/generated/realtime/" echo " git add resources/gen/asyncapi.json frontend/src/lib/generated/message-bus/"
echo " git commit -m 'chore(rt): regenerate spec + DTOs'" echo " git commit -m 'chore(bus): regenerate spec + DTOs'"
exit 1 exit 1
fi fi
echo "✅ realtime spec: committed files match generator output" echo "✅ message-bus spec: committed files match generator output"
db: db:
docker compose up -d postgres docker compose up -d postgres
@@ -374,7 +374,7 @@ fe-dev: asyncapi-ts
# build the SPA (Phase 0: -> frontend/build; Phase 5: -> static-dist). # build the SPA (Phase 0: -> frontend/build; Phase 5: -> static-dist).
# `asyncapi-ts` prerequisite (which itself depends on `asyncapi`) # `asyncapi-ts` prerequisite (which itself depends on `asyncapi`)
# guarantees `frontend/src/lib/generated/realtime/*.ts` is in sync # guarantees `frontend/src/lib/generated/message-bus/*.ts` is in sync
# with the Rust-side wire spec before Vite compiles — no stale-DTO # with the Rust-side wire spec before Vite compiles — no stale-DTO
# window in local dev. CI still runs a dirty-tree check on the # window in local dev. CI still runs a dirty-tree check on the
# generated files as belt-and-braces. # generated files as belt-and-braces.
@@ -406,7 +406,7 @@ fe-check: asyncapi-ts
cd frontend && npm run check cd frontend && npm run check
# Vitest unit/component tests. Same asyncapi-ts prereq — tests that # Vitest unit/component tests. Same asyncapi-ts prereq — tests that
# import from `lib/generated/realtime` need it fresh. # import from `lib/generated/message-bus` need it fresh.
fe-test: asyncapi-ts fe-test: asyncapi-ts
cd frontend && npm run test:unit cd frontend && npm run test:unit
@@ -447,4 +447,4 @@ test-docker-tags:
# Check and test everything # Check and test everything
# recommanded before pull request # recommanded before pull request
pre-pull-request: test-docker-tags check fe-check audit check-migrations check-realtime-spec test test-integration fe-test build test-bundle test-api fe-build-e2e front-test pre-pull-request: test-docker-tags check fe-check audit check-migrations check-message-bus-spec test test-integration fe-test build test-bundle test-api fe-build-e2e front-test
@@ -1,13 +1,13 @@
//! Realtime message-bus port — the seam every service publishes through and //! Message-bus port — the seam every service publishes through and every WS
//! every WS session subscribes on. //! session subscribes on.
//! //!
//! # Design (see `docs/plan/message-bus.md`) //! # Design (see `docs/plan/message-bus.md`)
//! //!
//! - [`RealtimeBus`] is the **local-facing** trait: services publish, the WS //! - [`MessageBus`] is the **local-facing** trait: services publish, the WS
//! handler subscribes. It never involves the network. //! handler subscribes. It never involves the network.
//! - [`BusReplicator`] is the OPTIONAL seam that mirrors local publishes to //! - [`BusReplicator`] is the OPTIONAL seam that mirrors local publishes to
//! and from a broker (pg `LISTEN/NOTIFY`, RabbitMQ, NATS). Callers see only //! and from a broker (pg `LISTEN/NOTIFY`, RabbitMQ, NATS). Callers see only
//! [`RealtimeBus`]; a real replicator plugs into the in-process impl without //! [`MessageBus`]; a real replicator plugs into the in-process impl without
//! touching consumers. Day-1 impl is [`NoopReplicator`]. //! touching consumers. Day-1 impl is [`NoopReplicator`].
//! //!
//! # MVP scope //! # MVP scope
@@ -17,10 +17,10 @@
//! to folders the caller can't `Read`: //! to folders the caller can't `Read`:
//! //!
//! - Topics: [`Topic::Folder`] and [`Topic::UserAuthz`] //! - Topics: [`Topic::Folder`] and [`Topic::UserAuthz`]
//! - Events: [`RealtimeEvent::FileCreated`], [`RealtimeEvent::FileRenamed`], //! - Events: [`MessageBusEvent::FileCreated`], [`MessageBusEvent::FileRenamed`],
//! [`RealtimeEvent::FileMoved`], [`RealtimeEvent::FileDeleted`], //! [`MessageBusEvent::FileMoved`], [`MessageBusEvent::FileDeleted`],
//! [`RealtimeEvent::FolderCreated`], [`RealtimeEvent::FolderRenamed`], //! [`MessageBusEvent::FolderCreated`], [`MessageBusEvent::FolderRenamed`],
//! [`RealtimeEvent::FolderMoved`], [`RealtimeEvent::FolderDeleted`] //! [`MessageBusEvent::FolderMoved`], [`MessageBusEvent::FolderDeleted`]
//! //!
//! Adding a variant is a one-line change plus a match arm in `to_wire_key` / //! Adding a variant is a one-line change plus a match arm in `to_wire_key` /
//! `parse` / `required_perm`. Other topics (`file:{id}`, `job:{id}`, //! `parse` / `required_perm`. Other topics (`file:{id}`, `job:{id}`,
@@ -47,7 +47,7 @@ use crate::common::errors::DomainError;
// Topic — a typed key on the bus // Topic — a typed key on the bus
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
/// A topic on the realtime bus. Typed enum, not a string — prevents typos /// A topic on the message bus. Typed enum, not a string — prevents typos
/// and gives exhaustive matching in the AuthZ dispatch and the wire encoder. /// and gives exhaustive matching in the AuthZ dispatch and the wire encoder.
/// ///
/// Encodes to a stable dotted wire key that maps naturally onto RabbitMQ /// Encodes to a stable dotted wire key that maps naturally onto RabbitMQ
@@ -157,7 +157,7 @@ pub enum AuthzCheck {
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// RealtimeEvent — the payload // MessageBusEvent — the payload
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
/// A fact that has just become true. Emitted by services AFTER commit, /// A fact that has just become true. Emitted by services AFTER commit,
@@ -176,7 +176,7 @@ pub enum AuthzCheck {
/// per project convention. /// per project convention.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "event", rename_all = "snake_case")] #[serde(tag = "event", rename_all = "snake_case")]
pub enum RealtimeEvent { pub enum MessageBusEvent {
/// A file was created inside `parent_id`. /// A file was created inside `parent_id`.
FileCreated { FileCreated {
file_id: Uuid, file_id: Uuid,
@@ -315,7 +315,7 @@ pub mod error_code {
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// RealtimeBus — the port // MessageBus — the port
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
/// The local-facing message bus. Fire-and-forget publish, stream subscribe. /// The local-facing message bus. Fire-and-forget publish, stream subscribe.
@@ -326,11 +326,11 @@ pub mod error_code {
/// ///
/// `subscribe` returns a `Stream` so the impl can change (broadcast, mpsc, /// `subscribe` returns a `Stream` so the impl can change (broadcast, mpsc,
/// pg listener) without churn at the consumer. /// pg listener) without churn at the consumer.
pub trait RealtimeBus: Send + Sync + 'static { pub trait MessageBus: Send + Sync + 'static {
/// Fan an event out to every current subscriber of `topic`. Never /// Fan an event out to every current subscriber of `topic`. Never
/// blocks; slow subscribers are dropped by the impl (they'll reconnect /// blocks; slow subscribers are dropped by the impl (they'll reconnect
/// and refetch). /// and refetch).
fn publish(&self, topic: &Topic, event: RealtimeEvent); fn publish(&self, topic: &Topic, event: MessageBusEvent);
/// Subscribe to `topic`. The returned stream yields events until the /// Subscribe to `topic`. The returned stream yields events until the
/// subscriber is dropped or the impl kicks it out (e.g. for lagging /// subscriber is dropped or the impl kicks it out (e.g. for lagging
@@ -338,15 +338,15 @@ pub trait RealtimeBus: Send + Sync + 'static {
fn subscribe(&self, topic: &Topic) -> BusStream; fn subscribe(&self, topic: &Topic) -> BusStream;
} }
/// Boxed stream returned by [`RealtimeBus::subscribe`]. Aliased so /// Boxed stream returned by [`MessageBus::subscribe`]. Aliased so
/// consumers don't need to spell out the `Pin<Box<...>>` shape. /// consumers don't need to spell out the `Pin<Box<...>>` shape.
pub type BusStream = Pin<Box<dyn Stream<Item = RealtimeEvent> + Send>>; pub type BusStream = Pin<Box<dyn Stream<Item = MessageBusEvent> + Send>>;
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// BusReplicator — the multi-instance seam (day-1 noop) // BusReplicator — the multi-instance seam (day-1 noop)
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
/// Cross-instance replicator. Sits BESIDE [`RealtimeBus`], not in front of /// Cross-instance replicator. Sits BESIDE [`MessageBus`], not in front of
/// it — the bus does the local fan-out; the replicator forwards outbound /// it — the bus does the local fan-out; the replicator forwards outbound
/// publishes to the broker (pg NOTIFY, RabbitMQ, NATS) and injects inbound /// publishes to the broker (pg NOTIFY, RabbitMQ, NATS) and injects inbound
/// broker messages back into the local bus. /// broker messages back into the local bus.
@@ -358,7 +358,7 @@ pub trait BusReplicator: Send + Sync + 'static {
/// Called by the local bus for every publish. Fire-and-forget — must not /// Called by the local bus for every publish. Fire-and-forget — must not
/// block or await; forwarding to the broker happens on a background task /// block or await; forwarding to the broker happens on a background task
/// owned by the impl. /// owned by the impl.
fn on_local_publish(&self, topic: &Topic, event: &RealtimeEvent); fn on_local_publish(&self, topic: &Topic, event: &MessageBusEvent);
/// Long-running consumer task: reads remote messages and re-publishes /// Long-running consumer task: reads remote messages and re-publishes
/// locally. Returns when `shutdown` is notified — DI calls /// locally. Returns when `shutdown` is notified — DI calls
@@ -382,7 +382,7 @@ pub struct NoopReplicator;
#[async_trait::async_trait] #[async_trait::async_trait]
impl BusReplicator for NoopReplicator { impl BusReplicator for NoopReplicator {
fn on_local_publish(&self, _topic: &Topic, _event: &RealtimeEvent) { fn on_local_publish(&self, _topic: &Topic, _event: &MessageBusEvent) {
// Intentionally empty. Local fan-out already happened in the bus. // Intentionally empty. Local fan-out already happened in the bus.
} }
@@ -466,9 +466,9 @@ mod tests {
// every variant's discriminator with a snapshot so an accidental // every variant's discriminator with a snapshot so an accidental
// rename fails the test instead of silently breaking clients — // rename fails the test instead of silently breaking clients —
// the AsyncAPI spec's `event` enum mirrors these exact strings. // the AsyncAPI spec's `event` enum mirrors these exact strings.
let cases: &[(RealtimeEvent, &str)] = &[ let cases: &[(MessageBusEvent, &str)] = &[
( (
RealtimeEvent::FileCreated { MessageBusEvent::FileCreated {
file_id: Uuid::nil(), file_id: Uuid::nil(),
name: "notes.md".into(), name: "notes.md".into(),
parent_id: Uuid::nil(), parent_id: Uuid::nil(),
@@ -477,7 +477,7 @@ mod tests {
"file_created", "file_created",
), ),
( (
RealtimeEvent::FileRenamed { MessageBusEvent::FileRenamed {
file_id: Uuid::nil(), file_id: Uuid::nil(),
old_name: "a.md".into(), old_name: "a.md".into(),
new_name: "b.md".into(), new_name: "b.md".into(),
@@ -487,7 +487,7 @@ mod tests {
"file_renamed", "file_renamed",
), ),
( (
RealtimeEvent::FileMoved { MessageBusEvent::FileMoved {
file_id: Uuid::nil(), file_id: Uuid::nil(),
name: "a.md".into(), name: "a.md".into(),
from: Uuid::nil(), from: Uuid::nil(),
@@ -497,7 +497,7 @@ mod tests {
"file_moved", "file_moved",
), ),
( (
RealtimeEvent::FileDeleted { MessageBusEvent::FileDeleted {
file_id: Uuid::nil(), file_id: Uuid::nil(),
parent_id: Uuid::nil(), parent_id: Uuid::nil(),
actor: Uuid::nil(), actor: Uuid::nil(),
@@ -505,7 +505,7 @@ mod tests {
"file_deleted", "file_deleted",
), ),
( (
RealtimeEvent::FolderCreated { MessageBusEvent::FolderCreated {
folder_id: Uuid::nil(), folder_id: Uuid::nil(),
name: "docs".into(), name: "docs".into(),
parent_id: Uuid::nil(), parent_id: Uuid::nil(),
@@ -514,7 +514,7 @@ mod tests {
"folder_created", "folder_created",
), ),
( (
RealtimeEvent::FolderRenamed { MessageBusEvent::FolderRenamed {
folder_id: Uuid::nil(), folder_id: Uuid::nil(),
old_name: "old".into(), old_name: "old".into(),
new_name: "new".into(), new_name: "new".into(),
@@ -524,7 +524,7 @@ mod tests {
"folder_renamed", "folder_renamed",
), ),
( (
RealtimeEvent::FolderMoved { MessageBusEvent::FolderMoved {
folder_id: Uuid::nil(), folder_id: Uuid::nil(),
name: "docs".into(), name: "docs".into(),
from: Uuid::nil(), from: Uuid::nil(),
@@ -534,7 +534,7 @@ mod tests {
"folder_moved", "folder_moved",
), ),
( (
RealtimeEvent::FolderDeleted { MessageBusEvent::FolderDeleted {
folder_id: Uuid::nil(), folder_id: Uuid::nil(),
parent_id: Uuid::nil(), parent_id: Uuid::nil(),
actor: Uuid::nil(), actor: Uuid::nil(),
@@ -542,7 +542,7 @@ mod tests {
"folder_deleted", "folder_deleted",
), ),
( (
RealtimeEvent::AuthzChanged { MessageBusEvent::AuthzChanged {
affected_folders: vec![Uuid::nil()], affected_folders: vec![Uuid::nil()],
}, },
"authz_changed", "authz_changed",
@@ -562,14 +562,14 @@ mod tests {
let file_id = Uuid::new_v4(); let file_id = Uuid::new_v4();
let parent_id = Uuid::new_v4(); let parent_id = Uuid::new_v4();
let actor = Uuid::new_v4(); let actor = Uuid::new_v4();
let original = RealtimeEvent::FileCreated { let original = MessageBusEvent::FileCreated {
file_id, file_id,
name: "a.txt".into(), name: "a.txt".into(),
parent_id, parent_id,
actor, actor,
}; };
let json = serde_json::to_string(&original).unwrap(); let json = serde_json::to_string(&original).unwrap();
let decoded: RealtimeEvent = serde_json::from_str(&json).unwrap(); let decoded: MessageBusEvent = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, original); assert_eq!(decoded, original);
} }
@@ -618,7 +618,7 @@ mod tests {
// on_local_publish is a no-op that should not panic or spawn work. // on_local_publish is a no-op that should not panic or spawn work.
repl.on_local_publish( repl.on_local_publish(
&Topic::Folder(Uuid::nil()), &Topic::Folder(Uuid::nil()),
&RealtimeEvent::FileCreated { &MessageBusEvent::FileCreated {
file_id: Uuid::nil(), file_id: Uuid::nil(),
name: "x".into(), name: "x".into(),
parent_id: Uuid::nil(), parent_id: Uuid::nil(),
+1 -1
View File
@@ -18,11 +18,11 @@ pub mod file_lifecycle;
pub mod file_ports; pub mod file_ports;
pub mod folder_ports; pub mod folder_ports;
pub mod inbound; pub mod inbound;
pub mod message_bus_ports;
pub mod music_ports; pub mod music_ports;
pub mod opaque_ports; pub mod opaque_ports;
pub mod outbound; pub mod outbound;
pub mod plugin_ports; pub mod plugin_ports;
pub mod realtime_ports;
pub mod recent_ports; pub mod recent_ports;
pub mod resource_access_hook; pub mod resource_access_hook;
pub mod share_ports; pub mod share_ports;
@@ -57,12 +57,12 @@ pub struct FileManagementService {
/// (stub/test builders); production DI wires it in. /// (stub/test builders); production DI wires it in.
storage_usage: storage_usage:
Option<Arc<crate::application::services::storage_usage_service::StorageUsageService>>, Option<Arc<crate::application::services::storage_usage_service::StorageUsageService>>,
/// Realtime message bus. When wired, delete / rename / move /// Message bus. When wired, delete / rename / move
/// mutations publish their corresponding `RealtimeEvent` on /// mutations publish their corresponding `MessageBusEvent` on
/// `Topic::Folder(parent_id)` (both source AND destination for /// `Topic::Folder(parent_id)` (both source AND destination for
/// move) after the DB commit. `None` silently no-ops the publish /// move) after the DB commit. `None` silently no-ops the publish
/// path — same pattern as `bus` on FileUploadService. /// path — same pattern as `bus` on FileUploadService.
bus: Option<Arc<dyn crate::application::ports::realtime_ports::RealtimeBus>>, bus: Option<Arc<dyn crate::application::ports::message_bus_ports::MessageBus>>,
/// Read repository — needed by the mutation publish path /// Read repository — needed by the mutation publish path
/// (delete / rename / move) to snapshot the file's pre-mutation /// (delete / rename / move) to snapshot the file's pre-mutation
/// parent folder BEFORE the write commits: delete removes the row, /// parent folder BEFORE the write commits: delete removes the row,
@@ -102,11 +102,11 @@ impl FileManagementService {
} }
} }
/// Wire the realtime message bus. When set, delete / rename / move /// Wire the message bus. When set, delete / rename / move
/// mutations publish on the affected folder topics after commit. /// mutations publish on the affected folder topics after commit.
pub fn with_realtime_bus( pub fn with_message_bus(
mut self, mut self,
bus: Arc<dyn crate::application::ports::realtime_ports::RealtimeBus>, bus: Arc<dyn crate::application::ports::message_bus_ports::MessageBus>,
) -> Self { ) -> Self {
self.bus = Some(bus); self.bus = Some(bus);
self self
@@ -208,7 +208,7 @@ impl FileManagementService {
} }
/// Snapshot the (uuid, name, parent-folder-uuid) of a file BEFORE /// Snapshot the (uuid, name, parent-folder-uuid) of a file BEFORE
/// a mutation, so the realtime publish path has a stable /// a mutation, so the message-bus publish path has a stable
/// `Topic::Folder(parent)` to address even after the write commits /// `Topic::Folder(parent)` to address even after the write commits
/// (delete removes the row; move rewrites `folder_id`). /// (delete removes the row; move rewrites `folder_id`).
/// ///
@@ -239,10 +239,10 @@ impl FileManagementService {
/// file, mount, unwired `file_read`). /// file, mount, unwired `file_read`).
fn publish_file_deleted(&self, caller_id: Uuid, snapshot: Option<(Uuid, String, Uuid)>) { fn publish_file_deleted(&self, caller_id: Uuid, snapshot: Option<(Uuid, String, Uuid)>) {
if let (Some(bus), Some((file_uuid, _name, parent_uuid))) = (&self.bus, snapshot) { if let (Some(bus), Some((file_uuid, _name, parent_uuid))) = (&self.bus, snapshot) {
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
bus.publish( bus.publish(
&Topic::Folder(parent_uuid), &Topic::Folder(parent_uuid),
RealtimeEvent::FileDeleted { MessageBusEvent::FileDeleted {
file_id: file_uuid, file_id: file_uuid,
parent_id: parent_uuid, parent_id: parent_uuid,
actor: caller_id, actor: caller_id,
@@ -539,7 +539,7 @@ impl FileManagementUseCase for FileManagementService {
let dto = self.move_file(file_id, folder_id, caller_id).await?; let dto = self.move_file(file_id, folder_id, caller_id).await?;
// Realtime fan-out on BOTH source and destination folder // Bus fan-out on BOTH source and destination folder
// topics. Subscribers to the source see the file "gone" from // topics. Subscribers to the source see the file "gone" from
// their view; subscribers to the destination see it "appear". // their view; subscribers to the destination see it "appear".
// Silent no-op when the bus isn't wired, the source snapshot // Silent no-op when the bus isn't wired, the source snapshot
@@ -552,8 +552,8 @@ impl FileManagementUseCase for FileManagementService {
&& let Ok(dest_uuid) = Uuid::parse_str(dest_str) && let Ok(dest_uuid) = Uuid::parse_str(dest_str)
&& source_uuid != dest_uuid && source_uuid != dest_uuid
{ {
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
let event = RealtimeEvent::FileMoved { let event = MessageBusEvent::FileMoved {
file_id: file_uuid, file_id: file_uuid,
name, name,
from: source_uuid, from: source_uuid,
@@ -674,7 +674,7 @@ impl FileManagementUseCase for FileManagementService {
let dto = self.rename_file(file_id, new_name, caller_id).await?; let dto = self.rename_file(file_id, new_name, caller_id).await?;
// Realtime publish AFTER commit. Silent no-op when the bus // Bus publish AFTER commit. Silent no-op when the bus
// isn't wired, the pre-fetch failed (old_name = None), or the // isn't wired, the pre-fetch failed (old_name = None), or the
// file has no folder (`dto.folder_id = None` — drive-root). // file has no folder (`dto.folder_id = None` — drive-root).
if let (Some(bus), Some(old_name), Some(parent_str)) = if let (Some(bus), Some(old_name), Some(parent_str)) =
@@ -682,10 +682,10 @@ impl FileManagementUseCase for FileManagementService {
&& let (Ok(file_uuid), Ok(parent_uuid)) = && let (Ok(file_uuid), Ok(parent_uuid)) =
(Uuid::parse_str(&dto.id), Uuid::parse_str(parent_str)) (Uuid::parse_str(&dto.id), Uuid::parse_str(parent_str))
{ {
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
bus.publish( bus.publish(
&Topic::Folder(parent_uuid), &Topic::Folder(parent_uuid),
RealtimeEvent::FileRenamed { MessageBusEvent::FileRenamed {
file_id: file_uuid, file_id: file_uuid,
old_name, old_name,
new_name: dto.name.clone(), new_name: dto.name.clone(),
@@ -56,13 +56,13 @@ pub struct FileUploadService {
/// (`create_file_from_owned_blob_with_perms`); `None` in minimal test /// (`create_file_from_owned_blob_with_perms`); `None` in minimal test
/// wiring. /// wiring.
instant_upload: Option<InstantUploadDeps>, instant_upload: Option<InstantUploadDeps>,
/// Realtime message bus. When wired, `upload_file_streaming` /// Message bus. When wired, `upload_file_streaming`
/// publishes a `FileCreated` event on `Topic::Folder(parent_id)` /// publishes a `FileCreated` event on `Topic::Folder(parent_id)`
/// after the DB commit — subscribers see the new file appear in /// after the DB commit — subscribers see the new file appear in
/// their live folder view. Optional so stub / test factories can /// their live folder view. Optional so stub / test factories can
/// build the service without a bus; a `None` bus is a silent no-op /// build the service without a bus; a `None` bus is a silent no-op
/// on the publish path. /// on the publish path.
bus: Option<Arc<dyn crate::application::ports::realtime_ports::RealtimeBus>>, bus: Option<Arc<dyn crate::application::ports::message_bus_ports::MessageBus>>,
} }
/// Everything the instant-upload path needs beyond the upload service's own /// Everything the instant-upload path needs beyond the upload service's own
@@ -117,13 +117,13 @@ impl FileUploadService {
self self
} }
/// Wire the realtime message bus. Enables live folder-view updates: /// Wire the message bus. Enables live folder-view updates:
/// after `upload_file_streaming` commits, a `FileCreated` event /// after `upload_file_streaming` commits, a `FileCreated` event
/// fires on `Topic::Folder(parent_id)` — subscribers see the new /// fires on `Topic::Folder(parent_id)` — subscribers see the new
/// file appear without polling. /// file appear without polling.
pub fn with_realtime_bus( pub fn with_message_bus(
mut self, mut self,
bus: Arc<dyn crate::application::ports::realtime_ports::RealtimeBus>, bus: Arc<dyn crate::application::ports::message_bus_ports::MessageBus>,
) -> Self { ) -> Self {
self.bus = Some(bus); self.bus = Some(bus);
self self
@@ -476,7 +476,7 @@ impl FileUploadUseCase for FileUploadService {
// "I just uploaded X" UX matches the pre-SvelteKit behaviour. // "I just uploaded X" UX matches the pre-SvelteKit behaviour.
self.notify_file_accessed(caller_id, &dto.id); self.notify_file_accessed(caller_id, &dto.id);
// Realtime fan-out AFTER commit — subscribers to the parent // Bus fan-out AFTER commit — subscribers to the parent
// folder's topic see the new file appear live. Silent no-op if // folder's topic see the new file appear live. Silent no-op if
// the bus isn't wired (stubs / tests) or the file landed at // the bus isn't wired (stubs / tests) or the file landed at
// drive-root (no folder id → nothing to publish on). // drive-root (no folder id → nothing to publish on).
@@ -484,10 +484,10 @@ impl FileUploadUseCase for FileUploadService {
&& let (Ok(parent_uuid), Ok(file_uuid)) = && let (Ok(parent_uuid), Ok(file_uuid)) =
(Uuid::parse_str(parent_folder_id), Uuid::parse_str(&dto.id)) (Uuid::parse_str(parent_folder_id), Uuid::parse_str(&dto.id))
{ {
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
bus.publish( bus.publish(
&Topic::Folder(parent_uuid), &Topic::Folder(parent_uuid),
RealtimeEvent::FileCreated { MessageBusEvent::FileCreated {
file_id: file_uuid, file_id: file_uuid,
name: dto.name.clone(), name: dto.name.clone(),
parent_id: parent_uuid, parent_id: parent_uuid,
+16 -16
View File
@@ -49,13 +49,13 @@ pub struct FolderService {
/// on cross-drive MOVE. Silently skipped when unwired (stubs). /// on cross-drive MOVE. Silently skipped when unwired (stubs).
storage_usage: storage_usage:
Option<Arc<crate::application::services::storage_usage_service::StorageUsageService>>, Option<Arc<crate::application::services::storage_usage_service::StorageUsageService>>,
/// Realtime message bus. When wired, `create_folder_with_perms` /// Message bus. When wired, `create_folder_with_perms`
/// publishes a `FolderCreated` event on `Topic::Folder(parent_id)` /// publishes a `FolderCreated` event on `Topic::Folder(parent_id)`
/// after the DB commit — subscribers see the new folder appear in /// after the DB commit — subscribers see the new folder appear in
/// their live folder view. Optional so stub / test factories can /// their live folder view. Optional so stub / test factories can
/// build the service without a bus; a `None` bus is a silent no-op /// build the service without a bus; a `None` bus is a silent no-op
/// on the publish path (no fan-out, no audit). /// on the publish path (no fan-out, no audit).
bus: Option<Arc<dyn crate::application::ports::realtime_ports::RealtimeBus>>, bus: Option<Arc<dyn crate::application::ports::message_bus_ports::MessageBus>>,
} }
impl FolderService { impl FolderService {
@@ -77,12 +77,12 @@ impl FolderService {
} }
} }
/// Wire the realtime message bus. Enables live folder-view updates: /// Wire the message bus. Enables live folder-view updates:
/// after `create_folder_with_perms` commits, a `FolderCreated` event /// after `create_folder_with_perms` commits, a `FolderCreated` event
/// fires on `Topic::Folder(parent_id)`. Off in stubs / tests. /// fires on `Topic::Folder(parent_id)`. Off in stubs / tests.
pub fn with_realtime_bus( pub fn with_message_bus(
mut self, mut self,
bus: Arc<dyn crate::application::ports::realtime_ports::RealtimeBus>, bus: Arc<dyn crate::application::ports::message_bus_ports::MessageBus>,
) -> Self { ) -> Self {
self.bus = Some(bus); self.bus = Some(bus);
self self
@@ -406,10 +406,10 @@ impl FolderUseCase for FolderService {
parent_uuid_for_publish, parent_uuid_for_publish,
Uuid::parse_str(folder.id()), Uuid::parse_str(folder.id()),
) { ) {
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
bus.publish( bus.publish(
&Topic::Folder(parent_uuid), &Topic::Folder(parent_uuid),
RealtimeEvent::FolderCreated { MessageBusEvent::FolderCreated {
folder_id: folder_uuid, folder_id: folder_uuid,
name: folder.name().to_owned(), name: folder.name().to_owned(),
parent_id: parent_uuid, parent_id: parent_uuid,
@@ -813,7 +813,7 @@ impl FolderUseCase for FolderService {
drive_repo.invalidate_default_drive_all(); drive_repo.invalidate_default_drive_all();
} }
// Realtime publish AFTER commit. Root folders (`parent_id() = None`) // Bus publish AFTER commit. Root folders (`parent_id() = None`)
// have no parent folder topic to publish on — the drive's // have no parent folder topic to publish on — the drive's
// display-name change is handled by the readable/default-drive // display-name change is handled by the readable/default-drive
// cache invalidations above, not the bus. Silent no-op if the // cache invalidations above, not the bus. Silent no-op if the
@@ -822,10 +822,10 @@ impl FolderUseCase for FolderService {
&& let (Ok(folder_uuid), Ok(parent_uuid)) = && let (Ok(folder_uuid), Ok(parent_uuid)) =
(Uuid::parse_str(renamed.id()), Uuid::parse_str(parent_str)) (Uuid::parse_str(renamed.id()), Uuid::parse_str(parent_str))
{ {
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
bus.publish( bus.publish(
&Topic::Folder(parent_uuid), &Topic::Folder(parent_uuid),
RealtimeEvent::FolderRenamed { MessageBusEvent::FolderRenamed {
folder_id: folder_uuid, folder_id: folder_uuid,
old_name: folder.name().to_owned(), old_name: folder.name().to_owned(),
new_name: renamed.name().to_owned(), new_name: renamed.name().to_owned(),
@@ -984,7 +984,7 @@ impl FolderUseCase for FolderService {
) )
})?; })?;
// Realtime fan-out on BOTH source and destination folder // Bus fan-out on BOTH source and destination folder
// topics. Same shape as `FileMoved` — subscribers to either // topics. Same shape as `FileMoved` — subscribers to either
// see the event exactly once. Silent no-op when the bus isn't // see the event exactly once. Silent no-op when the bus isn't
// wired, the source snapshot failed, or the destination is // wired, the source snapshot failed, or the destination is
@@ -995,8 +995,8 @@ impl FolderUseCase for FolderService {
(Uuid::parse_str(folder.id()), Uuid::parse_str(dest_str)) (Uuid::parse_str(folder.id()), Uuid::parse_str(dest_str))
&& source_uuid != dest_uuid && source_uuid != dest_uuid
{ {
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
let event = RealtimeEvent::FolderMoved { let event = MessageBusEvent::FolderMoved {
folder_id: folder_uuid, folder_id: folder_uuid,
name: folder.name().to_owned(), name: folder.name().to_owned(),
from: source_uuid, from: source_uuid,
@@ -1106,15 +1106,15 @@ impl FolderUseCase for FolderService {
self.file_lifecycle.on_file_deleted(file_id); self.file_lifecycle.on_file_deleted(file_id);
} }
// Realtime publish AFTER the DELETE commits. Root folders // Bus publish AFTER the DELETE commits. Root folders
// (no parent) can't be deleted through this endpoint per the // (no parent) can't be deleted through this endpoint per the
// mount / drive-root guards above, so `publish_snapshot` is // mount / drive-root guards above, so `publish_snapshot` is
// effectively always Some for regular deletes. // effectively always Some for regular deletes.
if let (Some(bus), Some((folder_uuid, parent_uuid))) = (&self.bus, publish_snapshot) { if let (Some(bus), Some((folder_uuid, parent_uuid))) = (&self.bus, publish_snapshot) {
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic}; use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
bus.publish( bus.publish(
&Topic::Folder(parent_uuid), &Topic::Folder(parent_uuid),
RealtimeEvent::FolderDeleted { MessageBusEvent::FolderDeleted {
folder_id: folder_uuid, folder_id: folder_uuid,
parent_id: parent_uuid, parent_id: parent_uuid,
actor: caller_id, actor: caller_id,
+7 -7
View File
@@ -1,7 +1,7 @@
//! AsyncAPI 3.0 spec generator for the realtime message bus. //! AsyncAPI 3.0 spec generator for the message bus.
//! //!
//! Mirrors `generate-openapi.rs`: constructs the spec from the same //! Mirrors `generate-openapi.rs`: constructs the spec from the same
//! Rust enums the server uses (`Topic`, `RealtimeEvent`, JSON-RPC //! Rust enums the server uses (`Topic`, `MessageBusEvent`, JSON-RPC
//! error codes) and writes `resources/gen/asyncapi.json`. //! error codes) and writes `resources/gen/asyncapi.json`.
//! //!
//! This is the first-PR MVP surface — the two topics and two events //! This is the first-PR MVP surface — the two topics and two events
@@ -24,7 +24,7 @@
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use oxicloud::application::ports::realtime_ports::error_code; use oxicloud::application::ports::message_bus_ports::error_code;
use serde_json::{Value, json}; use serde_json::{Value, json};
fn main() { fn main() {
@@ -48,7 +48,7 @@ fn build_asyncapi() -> Value {
json!({ json!({
"asyncapi": "3.0.0", "asyncapi": "3.0.0",
"info": { "info": {
"title": "OxiCloud realtime message bus", "title": "OxiCloud message bus",
"version": env!("CARGO_PKG_VERSION"), "version": env!("CARGO_PKG_VERSION"),
"description": r#" "description": r#"
JSON-RPC 2.0 over WebSocket for control + events, Yjs sync protocol for JSON-RPC 2.0 over WebSocket for control + events, Yjs sync protocol for
@@ -68,7 +68,7 @@ Phase C (sync-client push, album live) extend the same channels — see
"host": "{host}", "host": "{host}",
"pathname": "/api/rt/ws", "pathname": "/api/rt/ws",
"protocol": "wss", "protocol": "wss",
"description": "OxiCloud realtime bus WebSocket endpoint. Text frames are JSON-RPC 2.0. Binary frames (out of AsyncAPI scope) are Yjs sync protocol for the collab editor — see `docs/plan/markdown-collab.md`.", "description": "OxiCloud message bus WebSocket endpoint. Text frames are JSON-RPC 2.0. Binary frames (out of AsyncAPI scope) are Yjs sync protocol for the collab editor — see `docs/plan/markdown-collab.md`.",
"variables": { "variables": {
"host": { "host": {
"description": "Server host — replace with the deployment domain", "description": "Server host — replace with the deployment domain",
@@ -437,7 +437,7 @@ fn rpc_pong_result_schema() -> Value {
fn rpc_error_response_schema() -> Value { fn rpc_error_response_schema() -> Value {
// The `code`/`message` catalog is the stable public vocabulary — // The `code`/`message` catalog is the stable public vocabulary —
// any change here IS a wire break. Every entry mirrors // any change here IS a wire break. Every entry mirrors
// `application/ports/realtime_ports.rs::error_code`. The inner // `application/ports/message_bus_ports.rs::error_code`. The inner
// error object is hoisted to `RtErrorObject` so Modelina emits a // error object is hoisted to `RtErrorObject` so Modelina emits a
// named type instead of `AnonymousSchema_N`. // named type instead of `AnonymousSchema_N`.
json!({ json!({
@@ -533,7 +533,7 @@ fn event_params_schema() -> Value {
fn event_kind_schema() -> Value { fn event_kind_schema() -> Value {
json!({ json!({
"type": "string", "type": "string",
"description": "Discriminator for the `data` payload. Mirrors the `#[serde(tag = \"event\", rename_all = \"snake_case\")]` variants of the Rust `RealtimeEvent` enum — a new event kind is a new enum variant on both sides.", "description": "Discriminator for the `data` payload. Mirrors the `#[serde(tag = \"event\", rename_all = \"snake_case\")]` variants of the Rust `MessageBusEvent` enum — a new event kind is a new enum variant on both sides.",
"enum": [ "enum": [
"file_created", "file_renamed", "file_moved", "file_deleted", "file_created", "file_renamed", "file_moved", "file_deleted",
"folder_created", "folder_renamed", "folder_moved", "folder_deleted", "folder_created", "folder_renamed", "folder_moved", "folder_deleted",
+1 -1
View File
@@ -402,7 +402,7 @@ async fn main() -> ExitCode {
Err(e) => return fail(format!("/api/admin/sessions network: {e}")), Err(e) => return fail(format!("/api/admin/sessions network: {e}")),
} }
// ── OPAQUE-minted JWT works against the realtime WS ───────────── // ── OPAQUE-minted JWT works against the WebSocket ─────────────
// //
// Regression guard: `auth_middleware` doesn't inspect how a JWT // Regression guard: `auth_middleware` doesn't inspect how a JWT
// was minted, so an OPAQUE-issued access_token must Just Work on // was minted, so an OPAQUE-issued access_token must Just Work on
+1 -1
View File
@@ -1,4 +1,4 @@
//! WebSocket-side smoke-test helper for the realtime message bus. //! WebSocket-side smoke-test helper for the message bus.
//! //!
//! Hurl is HTTP-only — it can't do a WS upgrade, let alone read frames //! Hurl is HTTP-only — it can't do a WS upgrade, let alone read frames
//! for later assertion. This binary is the WS half of the smoke test: //! for later assertion. This binary is the WS half of the smoke test:
+15 -15
View File
@@ -703,12 +703,12 @@ impl AppServiceFactory {
resource_access_hook: Option< resource_access_hook: Option<
Arc<dyn crate::application::ports::resource_access_hook::ResourceAccessHook>, Arc<dyn crate::application::ports::resource_access_hook::ResourceAccessHook>,
>, >,
bus: &Arc<crate::infrastructure::services::in_process_realtime_bus::InProcessRealtimeBus>, bus: &Arc<crate::infrastructure::services::in_process_message_bus::InProcessMessageBus>,
) -> ApplicationServices { ) -> ApplicationServices {
// Upcast the concrete bus once — service builders take the // Upcast the concrete bus once — service builders take the
// trait object so the wire remains stable across future bus // trait object so the wire remains stable across future bus
// impls. // impls.
let bus_trait: Arc<dyn crate::application::ports::realtime_ports::RealtimeBus> = let bus_trait: Arc<dyn crate::application::ports::message_bus_ports::MessageBus> =
bus.clone(); bus.clone();
// Main services // Main services
@@ -732,9 +732,9 @@ impl AppServiceFactory {
// already runs. Without this, a Move that would push the // already runs. Without this, a Move that would push the
// destination past its cap succeeds silently. // destination past its cap succeeds silently.
.with_storage_usage(storage_usage.clone()) .with_storage_usage(storage_usage.clone())
// Realtime fan-out on `create_folder_with_perms` — the // Bus fan-out on `create_folder_with_perms` — the
// parent-folder subscribers see new sub-folders live. // parent-folder subscribers see new sub-folders live.
.with_realtime_bus(bus_trait.clone()), .with_message_bus(bus_trait.clone()),
); );
// Built before the upload/management services so the plugin lifecycle // Built before the upload/management services so the plugin lifecycle
@@ -782,10 +782,10 @@ impl AppServiceFactory {
core.dedup_service.clone(), core.dedup_service.clone(),
storage_usage.clone(), storage_usage.clone(),
) )
// Realtime fan-out — every successful `upload_file_streaming` // Bus fan-out — every successful `upload_file_streaming`
// publishes a `FileCreated` event on the parent folder's // publishes a `FileCreated` event on the parent folder's
// topic so open folder views refresh live. // topic so open folder views refresh live.
.with_realtime_bus(bus_trait.clone()); .with_message_bus(bus_trait.clone());
if let Some(hook) = resource_access_hook.clone() { if let Some(hook) = resource_access_hook.clone() {
svc = svc.with_resource_access_hook(hook); svc = svc.with_resource_access_hook(hook);
} }
@@ -826,11 +826,11 @@ impl AppServiceFactory {
// Destination-drive quota pre-check on cross-drive file // Destination-drive quota pre-check on cross-drive file
// MOVE. Same rationale as the folder side above. // MOVE. Same rationale as the folder side above.
.with_storage_usage(storage_usage.clone()) .with_storage_usage(storage_usage.clone())
// Realtime fan-out on delete / rename / move — each hook // Bus fan-out on delete / rename / move — each hook
// publishes on the affected folder topic (move fans out on // publishes on the affected folder topic (move fans out on
// BOTH source and destination) so folder-view subscribers // BOTH source and destination) so folder-view subscribers
// see the mutation live. // see the mutation live.
.with_realtime_bus(bus_trait.clone()); .with_message_bus(bus_trait.clone());
if let Some(hook) = resource_access_hook.clone() { if let Some(hook) = resource_access_hook.clone() {
svc = svc.with_resource_access_hook(hook); svc = svc.with_resource_access_hook(hook);
} }
@@ -1794,15 +1794,15 @@ impl AppServiceFactory {
crate::application::services::external_mount_router::MountRouter::new(mount_registry), crate::application::services::external_mount_router::MountRouter::new(mount_registry),
); );
// Realtime bus: single instance for the app lifetime, wired // Message bus: single instance for the app lifetime, wired
// with a no-op replicator (multi-instance broker is a follow-up // with a no-op replicator (multi-instance broker is a follow-up
// per `docs/plan/message-bus.md § Roadmap`). Constructed here // per `docs/plan/message-bus.md § Roadmap`). Constructed here
// so `create_application_services` can hand it to services that // so `create_application_services` can hand it to services that
// publish after their DB commits (`FolderService`, // publish after their DB commits (`FolderService`,
// `FileUploadService`, …). Spawns its own GC task in // `FileUploadService`, …). Spawns its own GC task in
// `with_replicator` — no supervisor setup required. // `with_replicator` — no supervisor setup required.
let bus = crate::infrastructure::services::in_process_realtime_bus::InProcessRealtimeBus::with_replicator( let bus = crate::infrastructure::services::in_process_message_bus::InProcessMessageBus::with_replicator(
Arc::new(crate::application::ports::realtime_ports::NoopReplicator), Arc::new(crate::application::ports::message_bus_ports::NoopReplicator),
); );
let mut apps = self.create_application_services( let mut apps = self.create_application_services(
@@ -3209,19 +3209,19 @@ pub struct AppState {
/// method (which still owns the authorization check). /// method (which still owns the authorization check).
pub mount_router: pub mount_router:
Arc<crate::application::services::external_mount_router::MountRouter>, Arc<crate::application::services::external_mount_router::MountRouter>,
/// Realtime message bus. Always present — an empty bus (no /// Message bus. Always present — an empty bus (no
/// subscribers, no publishes) costs a single `DashMap` allocation. /// subscribers, no publishes) costs a single `DashMap` allocation.
/// The WS handler reads `subscribe`; service publish hooks /// The WS handler reads `subscribe`; service publish hooks
/// (`FolderService::create_folder_with_perms`, /// (`FolderService::create_folder_with_perms`,
/// `FileManagementService`'s file-create commit) call `publish` /// `FileManagementService`'s file-create commit) call `publish`
/// AFTER their DB transaction commits. /// AFTER their DB transaction commits.
/// ///
/// Stored as the concrete type (not `Arc<dyn RealtimeBus>`) so the /// Stored as the concrete type (not `Arc<dyn MessageBus>`) so the
/// GC task's `Weak<Self>` lifecycle is legible from di.rs. Consumers /// GC task's `Weak<Self>` lifecycle is legible from di.rs. Consumers
/// that only need the trait obtain it via /// that only need the trait obtain it via
/// `Arc::clone(&state.bus) as Arc<dyn RealtimeBus>`. /// `Arc::clone(&state.bus) as Arc<dyn MessageBus>`.
pub bus: Arc< pub bus: Arc<
crate::infrastructure::services::in_process_realtime_bus::InProcessRealtimeBus, crate::infrastructure::services::in_process_message_bus::InProcessMessageBus,
>, >,
pub auth_service: Option<AuthServices>, pub auth_service: Option<AuthServices>,
/// OPAQUE aPAKE substrate (RFC 9807). Populated only when /// OPAQUE aPAKE substrate (RFC 9807). Populated only when
@@ -1,4 +1,4 @@
//! In-process `RealtimeBus` — one `broadcast::Sender` per active topic, //! In-process `MessageBus` — one `broadcast::Sender` per active topic,
//! held in a [`DashMap`] keyed by [`Topic`]. Publish is fire-and-forget, //! held in a [`DashMap`] keyed by [`Topic`]. Publish is fire-and-forget,
//! subscribe returns a `Stream` backed by [`BroadcastStream`]. //! subscribe returns a `Stream` backed by [`BroadcastStream`].
//! //!
@@ -28,8 +28,8 @@ use futures::StreamExt;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::BroadcastStream;
use crate::application::ports::realtime_ports::{ use crate::application::ports::message_bus_ports::{
BusReplicator, BusStream, RealtimeBus, RealtimeEvent, Topic, BusReplicator, BusStream, MessageBus, MessageBusEvent, Topic,
}; };
/// Per-topic ring-buffer size for slow subscribers. When a subscriber lags /// Per-topic ring-buffer size for slow subscribers. When a subscriber lags
@@ -44,20 +44,20 @@ pub const BROADCAST_RING_CAPACITY: usize = 256;
/// long enough that GC overhead stays trivial. /// long enough that GC overhead stays trivial.
pub const GC_INTERVAL: Duration = Duration::from_secs(60); pub const GC_INTERVAL: Duration = Duration::from_secs(60);
/// The in-process implementation of [`RealtimeBus`]. /// The in-process implementation of [`MessageBus`].
/// ///
/// Callers hold `Arc<InProcessRealtimeBus>` (or `Arc<dyn RealtimeBus>`). /// Callers hold `Arc<InProcessMessageBus>` (or `Arc<dyn MessageBus>`).
/// The struct owns its topic map and — when constructed via /// The struct owns its topic map and — when constructed via
/// [`InProcessRealtimeBus::with_replicator`] — an [`Arc<dyn BusReplicator>`] /// [`InProcessMessageBus::with_replicator`] — an [`Arc<dyn BusReplicator>`]
/// that gets fed every local publish for outbound broker forwarding. /// that gets fed every local publish for outbound broker forwarding.
pub struct InProcessRealtimeBus { pub struct InProcessMessageBus {
topics: DashMap<Topic, broadcast::Sender<RealtimeEvent>>, topics: DashMap<Topic, broadcast::Sender<MessageBusEvent>>,
replicator: Arc<dyn BusReplicator>, replicator: Arc<dyn BusReplicator>,
} }
impl InProcessRealtimeBus { impl InProcessMessageBus {
/// Construct with a replicator. In v1 that's a /// Construct with a replicator. In v1 that's a
/// [`crate::application::ports::realtime_ports::NoopReplicator`]; when /// [`crate::application::ports::message_bus_ports::NoopReplicator`]; when
/// multi-instance ships, it becomes the pg-NOTIFY or broker impl. /// multi-instance ships, it becomes the pg-NOTIFY or broker impl.
/// ///
/// The GC task holds a [`Weak`] handle so it exits naturally when the /// The GC task holds a [`Weak`] handle so it exits naturally when the
@@ -111,7 +111,7 @@ impl InProcessRealtimeBus {
/// receiver. Used by both `publish` (for the sender) and `subscribe` /// receiver. Used by both `publish` (for the sender) and `subscribe`
/// (for the receiver) — one code path for the map insert avoids a race /// (for the receiver) — one code path for the map insert avoids a race
/// where publish creates a sender concurrent subscribers miss. /// where publish creates a sender concurrent subscribers miss.
fn sender_for(&self, topic: &Topic) -> broadcast::Sender<RealtimeEvent> { fn sender_for(&self, topic: &Topic) -> broadcast::Sender<MessageBusEvent> {
self.topics self.topics
.entry(*topic) .entry(*topic)
.or_insert_with(|| broadcast::channel(BROADCAST_RING_CAPACITY).0) .or_insert_with(|| broadcast::channel(BROADCAST_RING_CAPACITY).0)
@@ -119,8 +119,8 @@ impl InProcessRealtimeBus {
} }
} }
impl RealtimeBus for InProcessRealtimeBus { impl MessageBus for InProcessMessageBus {
fn publish(&self, topic: &Topic, event: RealtimeEvent) { fn publish(&self, topic: &Topic, event: MessageBusEvent) {
// Feed the replicator FIRST — if it were called after local fan-out, // Feed the replicator FIRST — if it were called after local fan-out,
// an unwind on a broken subscriber could skip broker forwarding. // an unwind on a broken subscriber could skip broker forwarding.
// `on_local_publish` is a sync fire-and-forget contract; slow // `on_local_publish` is a sync fire-and-forget contract; slow
@@ -162,23 +162,23 @@ impl RealtimeBus for InProcessRealtimeBus {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::application::ports::realtime_ports::NoopReplicator; use crate::application::ports::message_bus_ports::NoopReplicator;
use futures::StreamExt; use futures::StreamExt;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration; use std::time::Duration;
use tokio::sync::Notify; use tokio::sync::Notify;
use uuid::Uuid; use uuid::Uuid;
fn make_bus() -> Arc<InProcessRealtimeBus> { fn make_bus() -> Arc<InProcessMessageBus> {
InProcessRealtimeBus::with_replicator(Arc::new(NoopReplicator)) InProcessMessageBus::with_replicator(Arc::new(NoopReplicator))
} }
fn folder_topic() -> Topic { fn folder_topic() -> Topic {
Topic::Folder(Uuid::new_v4()) Topic::Folder(Uuid::new_v4())
} }
fn file_created(parent_id: Uuid) -> RealtimeEvent { fn file_created(parent_id: Uuid) -> MessageBusEvent {
RealtimeEvent::FileCreated { MessageBusEvent::FileCreated {
file_id: Uuid::new_v4(), file_id: Uuid::new_v4(),
name: "a.txt".into(), name: "a.txt".into(),
parent_id, parent_id,
@@ -299,7 +299,7 @@ mod tests {
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl BusReplicator for CountingReplicator { impl BusReplicator for CountingReplicator {
fn on_local_publish(&self, _topic: &Topic, _event: &RealtimeEvent) { fn on_local_publish(&self, _topic: &Topic, _event: &MessageBusEvent) {
self.count.fetch_add(1, Ordering::SeqCst); self.count.fetch_add(1, Ordering::SeqCst);
} }
async fn run( async fn run(
@@ -314,7 +314,7 @@ mod tests {
let counter = Arc::new(CountingReplicator { let counter = Arc::new(CountingReplicator {
count: AtomicUsize::new(0), count: AtomicUsize::new(0),
}); });
let bus = InProcessRealtimeBus::with_replicator(Arc::clone(&counter) as Arc<_>); let bus = InProcessMessageBus::with_replicator(Arc::clone(&counter) as Arc<_>);
let topic = folder_topic(); let topic = folder_topic();
let _sub = bus.subscribe(&topic); let _sub = bus.subscribe(&topic);
let parent = match topic { let parent = match topic {
+1 -1
View File
@@ -27,7 +27,7 @@ pub mod files_consistency_service;
pub mod folders_consistency_service; pub mod folders_consistency_service;
pub mod grant_cleanup_service; pub mod grant_cleanup_service;
pub mod image_transcode_service; pub mod image_transcode_service;
pub mod in_process_realtime_bus; pub mod in_process_message_bus;
pub mod jwt_service; pub mod jwt_service;
pub mod last_seen_tracker; pub mod last_seen_tracker;
pub mod local_blob_backend; pub mod local_blob_backend;
+4 -4
View File
@@ -505,7 +505,7 @@ pub async fn revoke_grant(
"🗑️ grant revoked", "🗑️ grant revoked",
); );
// Realtime eviction cascade — the revoke committed, so any WS // Message-bus eviction cascade — the revoke committed, so any WS
// session that had the affected user auto-subscribed to // session that had the affected user auto-subscribed to
// `user:{u}:authz` gets an AuthzChanged event and drops any live // `user:{u}:authz` gets an AuthzChanged event and drops any live
// subscriptions to the affected resource. Silent no-op when the // subscriptions to the affected resource. Silent no-op when the
@@ -514,11 +514,11 @@ pub async fn revoke_grant(
// membership expansion ships). Folder resources only for MVP; // membership expansion ships). Folder resources only for MVP;
// File/Drive topics don't exist yet. // File/Drive topics don't exist yet.
if let (Subject::User(target_user), Resource::Folder(folder_id)) = (subject, resource) { if let (Subject::User(target_user), Resource::Folder(folder_id)) = (subject, resource) {
use crate::application::ports::realtime_ports::{RealtimeBus, RealtimeEvent, Topic}; use crate::application::ports::message_bus_ports::{MessageBus, MessageBusEvent, Topic};
RealtimeBus::publish( MessageBus::publish(
state.bus.as_ref(), state.bus.as_ref(),
&Topic::UserAuthz(target_user), &Topic::UserAuthz(target_user),
RealtimeEvent::AuthzChanged { MessageBusEvent::AuthzChanged {
affected_folders: vec![folder_id], affected_folders: vec![folder_id],
}, },
); );
+16 -16
View File
@@ -1,4 +1,4 @@
//! Realtime bus WebSocket handler — the endpoint every WS session //! Message bus WebSocket handler — the endpoint every WS session
//! multiplexes over. See `docs/plan/message-bus.md § Wire protocol`. //! multiplexes over. See `docs/plan/message-bus.md § Wire protocol`.
//! //!
//! # Wire //! # Wire
@@ -50,8 +50,8 @@ use tokio::time::MissedTickBehavior;
use uuid::Uuid; use uuid::Uuid;
use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::realtime_ports::{ use crate::application::ports::message_bus_ports::{
AuthzCheck, BusResource, ParseTopicErr, RealtimeBus, RealtimeEvent, Topic, error_code, AuthzCheck, BusResource, MessageBus, MessageBusEvent, ParseTopicErr, Topic, error_code,
}; };
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::domain::services::authorization::{Permission, Resource, Subject}; use crate::domain::services::authorization::{Permission, Resource, Subject};
@@ -192,7 +192,7 @@ impl Drop for Sub {
/// the socket. /// the socket.
/// - `EvictFolders` — internal control signal. The reader for the /// - `EvictFolders` — internal control signal. The reader for the
/// session's auto-subscribed `user:{caller}:authz` topic translates /// session's auto-subscribed `user:{caller}:authz` topic translates
/// inbound [`RealtimeEvent::AuthzChanged`] events into this rather /// inbound [`MessageBusEvent::AuthzChanged`] events into this rather
/// than a client-visible frame. Main loop walks its subs, drops any /// than a client-visible frame. Main loop walks its subs, drops any
/// whose resource is in the list, and emits one `rt.revoked` frame /// whose resource is in the list, and emits one `rt.revoked` frame
/// per evicted topic. /// per evicted topic.
@@ -522,7 +522,7 @@ fn handle_unsubscribe(id: Value, params: Value, subs: &mut HashMap<String, Sub>)
/// ///
/// The reader interprets bus events differently by topic class: /// The reader interprets bus events differently by topic class:
/// ///
/// - For `Topic::UserAuthz(_)`: an incoming `RealtimeEvent::AuthzChanged` /// - For `Topic::UserAuthz(_)`: an incoming `MessageBusEvent::AuthzChanged`
/// is translated to `SessionOut::EvictFolders(affected)` — the main /// is translated to `SessionOut::EvictFolders(affected)` — the main
/// loop then walks the sub set and drops matching topics. Any other /// loop then walks the sub set and drops matching topics. Any other
/// event kind on this topic is ignored (defensive; shouldn't happen /// event kind on this topic is ignored (defensive; shouldn't happen
@@ -536,7 +536,7 @@ fn install_subscription(
state: &Arc<AppState>, state: &Arc<AppState>,
) { ) {
let topic_wire = topic.to_wire_key(); let topic_wire = topic.to_wire_key();
let mut stream = RealtimeBus::subscribe(state.bus.as_ref(), &topic); let mut stream = MessageBus::subscribe(state.bus.as_ref(), &topic);
let out_tx_task = out_tx.clone(); let out_tx_task = out_tx.clone();
let translate_authz = matches!(topic, Topic::UserAuthz(_)); let translate_authz = matches!(topic, Topic::UserAuthz(_));
// Clone for the reader closure; keep the original to key `subs`. // Clone for the reader closure; keep the original to key `subs`.
@@ -546,7 +546,7 @@ fn install_subscription(
while let Some(event) = stream.next().await { while let Some(event) = stream.next().await {
let message = if translate_authz { let message = if translate_authz {
match event { match event {
RealtimeEvent::AuthzChanged { affected_folders } => { MessageBusEvent::AuthzChanged { affected_folders } => {
SessionOut::EvictFolders(affected_folders) SessionOut::EvictFolders(affected_folders)
} }
// The authz topic only carries AuthzChanged in // The authz topic only carries AuthzChanged in
@@ -597,15 +597,15 @@ fn error_response(id: Value, code: i32, message: &str, data: Option<Value>) -> S
/// Build an `rt.event` JSON-RPC notification for a bus event. /// Build an `rt.event` JSON-RPC notification for a bus event.
/// ///
/// Payload discipline (see plan): thin facts only. The `RealtimeEvent`'s /// Payload discipline (see plan): thin facts only. The `MessageBusEvent`'s
/// own `#[serde(tag = "event")]` shape provides `event` + variant fields /// own `#[serde(tag = "event")]` shape provides `event` + variant fields
/// under one flat object; we lift them into `params.data` alongside a /// under one flat object; we lift them into `params.data` alongside a
/// `topic` selector for the client. /// `topic` selector for the client.
fn event_notification(topic_wire: &str, event: &RealtimeEvent) -> String { fn event_notification(topic_wire: &str, event: &MessageBusEvent) -> String {
// Serialize the event to extract `event` (discriminator) and the // Serialize the event to extract `event` (discriminator) and the
// remaining fields as `data`. Two-step to avoid re-inventing the // remaining fields as `data`. Two-step to avoid re-inventing the
// enum's discriminator string here. // enum's discriminator string here.
let event_json = serde_json::to_value(event).expect("RealtimeEvent always serializes"); let event_json = serde_json::to_value(event).expect("MessageBusEvent always serializes");
let (event_name, data) = split_event_discriminator(event_json); let (event_name, data) = split_event_discriminator(event_json);
let params = serde_json::json!({ let params = serde_json::json!({
@@ -622,7 +622,7 @@ fn event_notification(topic_wire: &str, event: &RealtimeEvent) -> String {
.expect("RpcNotification always serializes") .expect("RpcNotification always serializes")
} }
/// Given a `RealtimeEvent` serialised as `{ "event": "file_created", ...rest }`, /// Given a `MessageBusEvent` serialised as `{ "event": "file_created", ...rest }`,
/// split into `(event_name, rest)`. Falls back to `("unknown", full)` if /// split into `(event_name, rest)`. Falls back to `("unknown", full)` if
/// the shape doesn't match (defensive — shouldn't happen given the enum /// the shape doesn't match (defensive — shouldn't happen given the enum
/// derive, but a future untagged variant would land here). /// derive, but a future untagged variant would land here).
@@ -658,11 +658,11 @@ fn revoked_notification(topic_wire: &str, reason: &'static str) -> String {
fn audit_denied(caller_id: Uuid, topic: &str, reason: &'static str) { fn audit_denied(caller_id: Uuid, topic: &str, reason: &'static str) {
tracing::info!( tracing::info!(
target: "audit", target: "audit",
event = "realtime.subscribe_denied", event = "message_bus.subscribe_denied",
reason = reason, reason = reason,
caller_id = %caller_id, caller_id = %caller_id,
topic = %topic, topic = %topic,
"👮🏻‍♂️ realtime subscribe rejected", "👮🏻‍♂️ message-bus subscribe rejected",
); );
} }
@@ -672,11 +672,11 @@ fn audit_denied(caller_id: Uuid, topic: &str, reason: &'static str) {
fn audit_evicted(caller_id: Uuid, topic: &str, reason: &'static str) { fn audit_evicted(caller_id: Uuid, topic: &str, reason: &'static str) {
tracing::info!( tracing::info!(
target: "audit", target: "audit",
event = "realtime.subscription_evicted", event = "message_bus.subscription_evicted",
reason = reason, reason = reason,
caller_id = %caller_id, caller_id = %caller_id,
topic = %topic, topic = %topic,
"🚫 realtime subscription evicted", "🚫 message-bus subscription evicted",
); );
} }
@@ -720,7 +720,7 @@ mod tests {
#[test] #[test]
fn event_notification_shape() { fn event_notification_shape() {
let event = RealtimeEvent::FileCreated { let event = MessageBusEvent::FileCreated {
file_id: Uuid::nil(), file_id: Uuid::nil(),
name: "notes.md".into(), name: "notes.md".into(),
parent_id: Uuid::nil(), parent_id: Uuid::nil(),
+1 -1
View File
@@ -674,7 +674,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
.with_state(app_state.clone()); .with_state(app_state.clone());
router = router.nest("/users", users_router); router = router.nest("/users", users_router);
// Realtime bus WebSocket. Auth (session cookie or bearer JWT) via // Message bus WebSocket. Auth (session cookie or bearer JWT) via
// the same `auth_middleware` the rest of `/api/*` gets; the handler // the same `auth_middleware` the rest of `/api/*` gets; the handler
// extracts `CurrentUserId` from the extension the middleware // extracts `CurrentUserId` from the extension the middleware
// installs. See `docs/plan/message-bus.md` and the module doc on // installs. See `docs/plan/message-bus.md` and the module doc on
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Realtime bus smoke test — the parts Hurl can't drive. # Message bus smoke test — the parts Hurl can't drive.
# #
# Hurl is HTTP-only and cannot open a WebSocket, so the WS half of the test # Hurl is HTTP-only and cannot open a WebSocket, so the WS half of the test
# runs through `rt-hurl-helper` (a small Rust bin gated on `test_utils`). # runs through `rt-hurl-helper` (a small Rust bin gated on `test_utils`).
@@ -496,4 +496,4 @@ sub_count=$(jq -r '.subscribed | length' "$out_s8")
log "S8 OK" log "S8 OK"
log "All eight realtime-bus scenarios passed." log "All eight message-bus scenarios passed."
+3 -3
View File
@@ -267,7 +267,7 @@ bash "$API_DIR/thumb_import_check.sh"
bash "$API_DIR/storage_cleanup_check.sh" bash "$API_DIR/storage_cleanup_check.sh"
# ── 5. Realtime message bus — WebSocket smoke test ────────────────────── # ── 5. Message bus — WebSocket smoke test ───────────────────────────────
# Runs BEFORE the OPAQUE helper so its user registration + login uses # Runs BEFORE the OPAQUE helper so its user registration + login uses
# the legacy password path (opaque_substrate.hurl migrates the admin # the legacy password path (opaque_substrate.hurl migrates the admin
# account, but by running first this check is unaffected by whatever # account, but by running first this check is unaffected by whatever
@@ -275,9 +275,9 @@ bash "$API_DIR/storage_cleanup_check.sh"
# positive delivery, topic isolation, AuthZ denial on subscribe, # positive delivery, topic isolation, AuthZ denial on subscribe,
# anti-enumeration parity. See `tests/api/rt_bus_check.sh` and # anti-enumeration parity. See `tests/api/rt_bus_check.sh` and
# `docs/plan/message-bus.md`. # `docs/plan/message-bus.md`.
log "Running realtime-bus smoke test..." log "Running message-bus smoke test..."
BUILD_TARGET="$BUILD_TARGET" bash "$REPO_ROOT/tests/api/rt_bus_check.sh" \ BUILD_TARGET="$BUILD_TARGET" bash "$REPO_ROOT/tests/api/rt_bus_check.sh" \
|| die "realtime-bus smoke test failed" || die "message-bus smoke test failed"
# ── 6. OPAQUE crypto handshake — the parts Hurl can't drive ───────────── # ── 6. OPAQUE crypto handshake — the parts Hurl can't drive ─────────────
# Full OPAQUE register + login handshake against the running server, # Full OPAQUE register + login handshake against the running server,