Using a bus type in the Hub
Import generated bus types in the Education Hub — never hand-mirror API enums or websocket payloads.
The API has exported a type on the bus. You need it in the Education Hub: to type a websocket handler, to switch on InvalidationAction, to use UserType without copying the enum. This page is that import, plus the two patterns already in the tree.
Step 1: Regenerate
API server running, manifest extracted. In the Hub:
bun api:watch
# or one-shot:
bun api:generateYou should see files under src/services/api/_generated/bus/:
_generated/bus/
index.ts
libs.shared.src.permission.permission.ts
libs.shared.src.realtime.realtime.ts
libs.shared.src.user.user.tsindex.ts re-exports every barrel. Prefer importing from @/services/api/_generated/bus.
If the file is missing, [bus].endpoint is wrong or the well-known route 503'd — Keeping the Hub client in sync.
Step 2: Import
import type {
RealtimeEventMap,
InvalidateChange,
} from '@/services/api/_generated/bus'
import { InvalidationAction, Action, Resource, UserType } from '@/services/api/_generated/bus'Use import type for types. Use a value import for enums — they are runtime objects. Object.values(Action) works. Do not recreate const Action = { READ: 'read', … }.
Never edit the generated file to "fix" a field. Change the API barrel and regenerate.
Websocket payloads
src/lib/realtime/realtime-channels.ts used to hand-write event payloads. It now aliases the bus:
import type {
RealtimeEventName as BusRealtimeEventName,
RealtimeEventMap,
} from '@/services/api/_generated/bus'
import { SubscriptionTokenRequestDtoKind } from '@/services/api/_generated/api.contracts'
export const RealtimeChannel = SubscriptionTokenRequestDtoKind
export type RealtimeEvents = RealtimeEventMap
export type RealtimeEventName = BusRealtimeEventNameChannel kinds still come from OpenAPI (the subscription-token DTO enum) because they are on a REST body. Event payloads come from the bus because they are not. Two pipes, one feature.
Handlers then type as RealtimeEvents['invalidate'] — Subscribing from the Hub.
Domain enums
Action and Resource are the API's permission vocabulary. The Hub needs the same runtime enum CASL and the UI use. They arrive on the bus; a thin adapter keeps CASL's string-literal unions happy:
import {
Action as BusAction,
Resource as BusResource,
UserType as BusUserType,
} from '@/services/api/_generated/bus'
const Action = BusAction as {
[K in keyof typeof BusAction]: `${(typeof BusAction)[K]}`
} satisfies Record<string, ActionType>The satisfies is a tripwire: if the bus enum drifts from what /auth/me rules actually carry, TypeScript fails here — not in a random dropdown. Copy this pattern if you need a bus enum to match a generated REST union; do not start a third copy of the values.
UserType is the same idea for routing (staff vs student vs platform_admin).
Invalidation tables
import { InvalidationAction } from '@/services/api/_generated/bus'
registerInvalidation(InvalidationAction.DEPARTMENT_CREATE, () => [
departmentQueryKeys.list,
])Switch on the enum; do not string-compare 'department.create'. A renamed action becomes a compile error after regen.
What not to do
- Do not import bus types from
api.contracts"because the name looks similar." Contracts are HTTP. Bus is everything else.AuthMeRuleDto['action']is a REST union;Actionis the domain enum. - Do not add
@ApiExtraModelson the API just to leak a websocket type into Swagger. That is the old workaround this pipe replaced. - Do not type a Hub handler as
Record<string, unknown>once the bus has the payload. You paid for the extract.
Checklist
- Symbol exists in
_generated/busafter watch. - App code imports from
@/services/api/_generated/bus(or a one-line re-export likeRealtimeEvents). - Enums imported as values.
- No leftover hand-written mirror in
src/lib. Delete it in the same PR.