diff --git a/shared/src/index.ts b/shared/src/index.ts index 53a7f346a..3c32aa6bf 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -47,6 +47,9 @@ export * from './oidc/oidc.schema'; export * from './oauth/oauth.schema'; export * from './admin/admin.schema'; +// Realtime WS event contract registry (event names + payload schemas) +export * from './realtime/events.schema'; + // Sanitisation helpers β€” used by the client today, scoped here so the server // has them ready if rich-text input ever ships. export * from './sanitize/sanitize'; diff --git a/shared/src/realtime/events.schema.spec.ts b/shared/src/realtime/events.schema.spec.ts new file mode 100644 index 000000000..ce3fa219c --- /dev/null +++ b/shared/src/realtime/events.schema.spec.ts @@ -0,0 +1,176 @@ +import { + TREK_WS_EVENTS, + TREK_WS_EVENT_NAMES, + TREK_WS_TRIP_EVENT_NAMES, + TREK_WS_USER_EVENT_NAMES, + type TrekWsEventName, +} from './events.schema'; + +import { describe, it, expect } from 'vitest'; + +/** + * One representative wire payload per event, transcribed from the emitting + * server call sites. The completeness guard below fails the moment a registry + * event has no fixture (or a fixture goes stale), i18n-parity style β€” so the + * fixture map cannot silently shrink relative to the registry. + */ +const FIXTURES: Record> = { + 'place:created': { place: { id: 3, name: 'Louvre' } }, + 'place:updated': { place: { id: 3, name: 'Louvre' } }, + 'place:deleted': { placeId: 3 }, + 'assignment:created': { assignment: { id: 9, day_id: 2 } }, + 'assignment:updated': { assignment: { id: 9, day_id: 2 } }, + 'assignment:deleted': { assignmentId: 9, dayId: 2 }, + 'assignment:moved': { assignment: { id: 9, day_id: 4 }, oldDayId: 2, newDayId: 4 }, + 'assignment:reordered': { dayId: 2, orderedIds: [3, 1, 2] }, + 'assignment:participants': { assignmentId: 9, participants: [{ user_id: 1 }] }, + 'day:created': { day: { id: 2, date: '2026-06-11' } }, + 'day:updated': { day: { id: 2, date: '2026-06-11' } }, + 'day:deleted': { dayId: 2 }, + 'day:reordered': { orderedIds: [2, 1] }, + 'dayNote:created': { dayId: 2, note: { id: 5 } }, + 'dayNote:updated': { dayId: 2, note: { id: 5 } }, + 'dayNote:deleted': { noteId: 5, dayId: 2 }, + 'packing:created': { item: { id: 7 } }, + 'packing:updated': { item: { id: 7 } }, + 'packing:deleted': { itemId: 7 }, + 'packing:reordered': { orderedIds: [7, 8] }, + 'packing:bag-created': { bag: { id: 1 } }, + 'packing:bag-updated': { bag: { id: 1 } }, + 'packing:bag-deleted': { bagId: 1 }, + 'packing:bag-members-updated': { bagId: 1, members: [{ user_id: 1 }] }, + 'packing:assignees': { category: 'clothes', assignees: [1, 2] }, + 'packing:template-applied': { items: [{ id: 7 }] }, + 'todo:created': { item: { id: 4 } }, + 'todo:updated': { item: { id: 4 } }, + 'todo:deleted': { itemId: 4 }, + 'todo:assignees': { category: 'before', assignees: [1] }, + 'budget:created': { item: { id: 11 } }, + 'budget:updated': { item: { id: 11 } }, + 'budget:deleted': { itemId: 11 }, + 'budget:reordered': { orderedIds: [11, 12] }, + 'budget:members-updated': { itemId: 11, members: [{ user_id: 1 }], persons: 2 }, + 'budget:member-paid-updated': { itemId: 11, userId: 1, paid: true }, + 'budget:settlement-created': { settlement: { id: 2 } }, + 'budget:settlement-updated': { settlement: { id: 2 } }, + 'budget:settlement-deleted': { settlementId: 2 }, + 'reservation:created': { reservation: { id: 6 } }, + 'reservation:updated': { reservation: { id: 6 } }, + 'reservation:deleted': { reservationId: 6 }, + 'reservation:positions': { positions: [{ id: 6, position: 0 }], day_id: 2 }, + 'reservation:travelers-updated': { reservationId: 6, travelers: [{ user_id: 1 }] }, + 'accommodation:created': { accommodation: { id: 8 } }, + 'accommodation:updated': { accommodation: { id: 8 } }, + 'accommodation:deleted': { accommodationId: 8 }, + 'trip:updated': { trip: { id: 1, name: 'Paris' } }, + 'trip:deleted': { id: 1 }, + 'member:added': { member: { user_id: 2 } }, + 'member:removed': { userId: 2 }, + 'file:created': { file: { id: 13 } }, + 'file:updated': { file: { id: 13 } }, + 'file:deleted': { fileId: 13 }, + 'collab:note:created': { note: { id: 3 } }, + 'collab:note:updated': { note: { id: 3 } }, + 'collab:note:deleted': { noteId: 3 }, + 'collab:poll:created': { poll: { id: 2 } }, + 'collab:poll:voted': { poll: { id: 2 } }, + 'collab:poll:closed': { poll: { id: 2 } }, + 'collab:poll:deleted': { pollId: 2 }, + 'collab:message:created': { message: { id: 14 } }, + 'collab:message:reacted': { messageId: 14, reactions: { 'πŸ‘': [1] } }, + 'collab:message:deleted': { messageId: 14, username: 'ana' }, + 'memories:updated': { userId: 1 }, + 'notification:new': { notification: { id: 21, sender_username: 'ana' } }, + 'notification:updated': { notification: { id: 21 } }, + 'collections:updated': { collectionId: 4 }, + 'collections:accepted': { collectionId: 4 }, + 'collections:declined': { collectionId: 4 }, + 'collections:left': { collectionId: 4 }, + 'collections:deleted': { collectionId: 4 }, + 'collections:cancelled': { collectionId: 4 }, + 'collections:removed': { collectionId: 4 }, + 'collections:invite': { from: { id: 1, username: 'ana' }, collectionId: 4 }, + 'vacay:update': {}, + 'vacay:settings': {}, + 'vacay:accepted': {}, + 'vacay:declined': {}, + 'vacay:cancelled': {}, + 'vacay:dissolved': {}, + 'vacay:invite': { from: { id: 1, username: 'ana' }, planId: 2 }, + 'vacay:share': { from: { id: 1 } }, + 'vacay:share-removed': {}, + 'vacay:shared-update': {}, + 'journey:trip:synced': { journeyId: 3, tripId: 1 }, + 'journey:entry:created': { journeyId: 3, entry: { id: 5 } }, + 'journey:entry:updated': { journeyId: 3, entry: { id: 5 } }, + 'journey:entry:deleted': { journeyId: 3, entryId: 5 }, + 'journey:entries:reordered': { journeyId: 3, orderedIds: [5, 4] }, + 'journey:contributor:changed': { journeyId: 3, targetUserId: 2, role: 'editor' }, + 'import:progress': { jobId: 'j1', tripId: 1, status: 'running', done: 1, total: 3, fileName: 'a.pdf' }, + 'import:done': { jobId: 'j1', tripId: 1, result: { items: [] } }, + 'import:error': { jobId: 'j1', tripId: 1, message: 'boom' }, +}; + +/** Divergent shapes emitted for the same event today (see DRIFT notes in the registry). */ +const DRIFT_VARIANTS: Partial[]>> = { + 'assignment:moved': [{ assignment: { id: 9, day_id: 4 }, oldDayId: 2 }], + 'assignment:reordered': [{ dayId: 2, assignmentIds: [3, 1, 2] }], + 'day:deleted': [{ id: 2 }], + 'day:reordered': [{ day: { id: 2 } }], + 'budget:reordered': [{ orderedCategories: ['food', 'transport'] }], + 'reservation:created': [{}], + 'reservation:updated': [{}], + 'reservation:positions': [{ positions: [], dayId: 2 }], + 'accommodation:created': [{}], + 'accommodation:updated': [{}], + 'accommodation:deleted': [{}, { id: 8, linkedReservationId: 6 }], + 'journey:entry:updated': [{ journeyId: 3, entryId: 5 }], +}; + +describe('@trek/shared realtime event registry', () => { + it('WSEVT-REG-001: pins the authoritative inventory counts (65 trip + 29 user = 94)', () => { + expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(65); + expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(29); + expect(TREK_WS_EVENT_NAMES).toHaveLength(94); + }); + + it('WSEVT-REG-002: every name is domain:action shaped and outside the reserved plugin: namespace', () => { + for (const name of TREK_WS_EVENT_NAMES) { + expect(name).toMatch(/^[a-zA-Z]+(:[a-zA-Z-]+)+$/); + expect(name.startsWith('plugin:')).toBe(false); + } + }); + + it('WSEVT-REG-003: trip/user scopes partition the registry', () => { + const union = new Set([...TREK_WS_TRIP_EVENT_NAMES, ...TREK_WS_USER_EVENT_NAMES]); + expect(union.size).toBe(TREK_WS_EVENT_NAMES.length); + }); + + it('WSEVT-REG-004: the fixture map covers every registry event (completeness guard)', () => { + expect(Object.keys(FIXTURES).sort()).toEqual([...TREK_WS_EVENT_NAMES].sort()); + }); + + it('WSEVT-REG-005: every event schema parses its representative server payload', () => { + for (const name of TREK_WS_EVENT_NAMES) { + const result = TREK_WS_EVENTS[name].payload.safeParse(FIXTURES[name]); + expect(result.success, `${name} rejected its fixture: ${JSON.stringify(FIXTURES[name])}`).toBe(true); + } + }); + + it('WSEVT-REG-006: drift unions accept every shape the server emits today', () => { + for (const [name, variants] of Object.entries(DRIFT_VARIANTS)) { + for (const variant of variants ?? []) { + const result = TREK_WS_EVENTS[name as TrekWsEventName].payload.safeParse(variant); + expect(result.success, `${name} rejected drift variant ${JSON.stringify(variant)}`).toBe(true); + } + } + }); + + it('WSEVT-REG-007: payload schemas tolerate the MCP _source marker (strip, not reject)', () => { + const result = TREK_WS_EVENTS['place:created'].payload.safeParse({ + place: { id: 3 }, + _source: 'mcp', + }); + expect(result.success).toBe(true); + }); +}); diff --git a/shared/src/realtime/events.schema.ts b/shared/src/realtime/events.schema.ts new file mode 100644 index 000000000..00c2e1c64 --- /dev/null +++ b/shared/src/realtime/events.schema.ts @@ -0,0 +1,346 @@ +import { z } from 'zod'; + +/** + * WS event contract registry β€” the single source of truth for every realtime + * event TREK broadcasts (roadmap: "Tokenization & registries", paired with the + * server's injectable RealtimeService facade). + * + * Wire envelopes (see server/src/websocket.ts): + * - Trip-scoped (`scope: 'trip'`, sent via `broadcast`): the transport wraps + * the payload as `{ type: , tripId, ...payload }` β€” `tripId` is + * always injected by the transport and is NOT part of the payload schema. + * - User-scoped (`scope: 'user'`, sent via `broadcastToUser`): the payload is + * sent verbatim and carries its own `type: ` discriminator; the + * schemas below describe the fields BESIDES `type`. + * - MCP-originated broadcasts add a `_source: 'mcp'` marker key to the payload + * (server/src/mcp/tools/_shared.ts). Zod object schemas strip unknown keys, + * so it needs no per-event modeling. + * + * Deliberately NOT in the registry: + * - The dynamic plugin namespace: trip broadcasts force-namespaced to + * `plugin::` and user messages typed `plugin:` + * (plugin-host-deps.factory.ts). Names and payloads are plugin-chosen; the + * `plugin:` prefix is the reserved escape hatch (see the template-literal + * types below) and registry names must never collide with it. + * - Transport control frames sent directly over the socket, not through + * broadcast: `welcome`, `joined`, `left`, `error` (serverβ†’client) and + * `join`, `leave` (clientβ†’server). They belong to the transport, which is + * deliberately-late roadmap work. + * + * Payload schemas are transcribed from the actual server call sites (server + * shape wins over client expectations β€” parity). Entity rows are `z.unknown()` + * scaffolding on purpose: the key structure and event names are the contract + * being pinned here; row shapes belong to their domain schemas and tighten + * opportunistically. Where the same event is emitted with divergent shapes + * today, the schema is the union of the real shapes and the divergence is + * flagged with a DRIFT comment β€” do not "fix" a drift here without fixing the + * emitting call site first. + */ + +/** An entity row whose shape is owned by its domain schema (scaffolding). */ +const entity = z.unknown(); +/** Ids cross the wire as numbers or numeric strings depending on the emitting route. */ +const id = z.union([z.number(), z.string()]); +const idList = z.array(id); +/** + * A "something changed, refetch" ping β€” no declared payload fields. Loose on + * purpose (zod v4 infers a plain `z.object({})` as `Record`, + * which would make `{ type } & payload` unsatisfiable for user-scoped events + * whose wire payload is exactly `{ type }`). + */ +const empty = z.looseObject({}); + +export interface TrekWsEventContract { + scope: 'trip' | 'user'; + payload: z.ZodType; +} + +export const TREK_WS_EVENTS = { + // ── Places ──────────────────────────────────────────────────────────────── + 'place:created': { scope: 'trip', payload: z.object({ place: entity }) }, + 'place:updated': { scope: 'trip', payload: z.object({ place: entity }) }, + 'place:deleted': { scope: 'trip', payload: z.object({ placeId: id }) }, + + // ── Assignments ────────────────────────────────────────────────────────── + 'assignment:created': { scope: 'trip', payload: z.object({ assignment: entity }) }, + 'assignment:updated': { scope: 'trip', payload: z.object({ assignment: entity }) }, + 'assignment:deleted': { scope: 'trip', payload: z.object({ assignmentId: id, dayId: id }) }, + // DRIFT: REST sends { assignment, oldDayId, newDayId }; the MCP tool omits + // newDayId (assignments.mcp.ts) β€” the client keys a day map on `undefined`. + 'assignment:moved': { + scope: 'trip', + payload: z.object({ assignment: entity, oldDayId: id, newDayId: id.optional() }), + }, + // DRIFT: REST sends { dayId, orderedIds }; the MCP tool sends + // { dayId, assignmentIds } β€” the client only reads orderedIds. + 'assignment:reordered': { + scope: 'trip', + payload: z.union([z.object({ dayId: id, orderedIds: idList }), z.object({ dayId: id, assignmentIds: idList })]), + }, + 'assignment:participants': { + scope: 'trip', + payload: z.object({ assignmentId: id, participants: z.array(z.unknown()) }), + }, + + // ── Days ───────────────────────────────────────────────────────────────── + 'day:created': { scope: 'trip', payload: z.object({ day: entity }) }, + 'day:updated': { scope: 'trip', payload: z.object({ day: entity }) }, + // DRIFT: REST + plugins send { dayId }; the MCP tool sends { id }. + 'day:deleted': { + scope: 'trip', + payload: z.union([z.object({ dayId: id }), z.object({ id })]), + }, + // Two legitimate shapes: a plain reorder sends { orderedIds }; the + // insert-at-position create path re-uses the event with { day }. + 'day:reordered': { + scope: 'trip', + payload: z.union([z.object({ orderedIds: idList }), z.object({ day: entity })]), + }, + + // ── Day notes ──────────────────────────────────────────────────────────── + 'dayNote:created': { scope: 'trip', payload: z.object({ dayId: id, note: entity }) }, + 'dayNote:updated': { scope: 'trip', payload: z.object({ dayId: id, note: entity }) }, + 'dayNote:deleted': { scope: 'trip', payload: z.object({ noteId: id, dayId: id }) }, + + // ── Packing ────────────────────────────────────────────────────────────── + 'packing:created': { scope: 'trip', payload: z.object({ item: entity }) }, + 'packing:updated': { scope: 'trip', payload: z.object({ item: entity }) }, + 'packing:deleted': { scope: 'trip', payload: z.object({ itemId: id }) }, + 'packing:reordered': { scope: 'trip', payload: z.object({ orderedIds: idList }) }, + 'packing:bag-created': { scope: 'trip', payload: z.object({ bag: entity }) }, + 'packing:bag-updated': { scope: 'trip', payload: z.object({ bag: entity }) }, + 'packing:bag-deleted': { scope: 'trip', payload: z.object({ bagId: id }) }, + 'packing:bag-members-updated': { + scope: 'trip', + payload: z.object({ bagId: id, members: z.array(z.unknown()) }), + }, + 'packing:assignees': { + scope: 'trip', + payload: z.object({ category: z.string(), assignees: z.array(z.unknown()) }), + }, + 'packing:template-applied': { scope: 'trip', payload: z.object({ items: z.array(z.unknown()) }) }, + + // ── Todo ───────────────────────────────────────────────────────────────── + 'todo:created': { scope: 'trip', payload: z.object({ item: entity }) }, + 'todo:updated': { scope: 'trip', payload: z.object({ item: entity }) }, + 'todo:deleted': { scope: 'trip', payload: z.object({ itemId: id }) }, + 'todo:assignees': { + scope: 'trip', + payload: z.object({ category: z.string(), assignees: z.array(z.unknown()) }), + }, + + // ── Budget ─────────────────────────────────────────────────────────────── + 'budget:created': { scope: 'trip', payload: z.object({ item: entity }) }, + 'budget:updated': { scope: 'trip', payload: z.object({ item: entity }) }, + 'budget:deleted': { scope: 'trip', payload: z.object({ itemId: id }) }, + // Intentional union: item reorder vs category reorder (the client handles both). + 'budget:reordered': { + scope: 'trip', + payload: z.union([z.object({ orderedIds: idList }), z.object({ orderedCategories: z.array(z.string()) })]), + }, + 'budget:members-updated': { + scope: 'trip', + payload: z.object({ itemId: id, members: z.array(z.unknown()), persons: z.unknown() }), + }, + 'budget:member-paid-updated': { + scope: 'trip', + payload: z.object({ itemId: id, userId: id, paid: z.unknown() }), + }, + 'budget:settlement-created': { scope: 'trip', payload: z.object({ settlement: entity }) }, + 'budget:settlement-updated': { scope: 'trip', payload: z.object({ settlement: entity }) }, + 'budget:settlement-deleted': { scope: 'trip', payload: z.object({ settlementId: id }) }, + + // ── Reservations ───────────────────────────────────────────────────────── + // DRIFT: the accommodation cascade paths emit an empty refetch ping `{}`; + // the client reads payload.reservation.id unguarded on the `{}` variant. + 'reservation:created': { + scope: 'trip', + payload: z.union([z.object({ reservation: entity }), empty]), + }, + 'reservation:updated': { + scope: 'trip', + payload: z.union([z.object({ reservation: entity }), empty]), + }, + 'reservation:deleted': { scope: 'trip', payload: z.object({ reservationId: id }) }, + // DRIFT: REST sends { positions, day_id }; the MCP tool sends { positions, dayId }. + 'reservation:positions': { + scope: 'trip', + payload: z.union([ + z.object({ positions: z.array(z.unknown()), day_id: id.nullish() }), + z.object({ positions: z.array(z.unknown()), dayId: id.nullish() }), + ]), + }, + 'reservation:travelers-updated': { + scope: 'trip', + payload: z.object({ reservationId: id, travelers: z.array(z.unknown()) }), + }, + + // ── Accommodations ─────────────────────────────────────────────────────── + // DRIFT: `{ accommodation }` vs empty refetch ping `{}` (reservation cascade + // paths); the client handles neither event today (refetches off trip:updated). + 'accommodation:created': { + scope: 'trip', + payload: z.union([z.object({ accommodation: entity }), empty]), + }, + 'accommodation:updated': { + scope: 'trip', + payload: z.union([z.object({ accommodation: entity }), empty]), + }, + // DRIFT: { accommodationId } vs `{}` vs the MCP day-clear path's + // { id, linkedReservationId } (days.mcp.ts). + 'accommodation:deleted': { + scope: 'trip', + payload: z.union([z.object({ accommodationId: id }), z.object({ id, linkedReservationId: id.nullish() }), empty]), + }, + + // ── Trip / members ─────────────────────────────────────────────────────── + 'trip:updated': { scope: 'trip', payload: z.object({ trip: entity }) }, + // Note the key is `id`, not `tripId` (the transport injects tripId anyway). + 'trip:deleted': { scope: 'trip', payload: z.object({ id }) }, + 'member:added': { scope: 'trip', payload: z.object({ member: entity }) }, + 'member:removed': { scope: 'trip', payload: z.object({ userId: id }) }, + + // ── Files ──────────────────────────────────────────────────────────────── + 'file:created': { scope: 'trip', payload: z.object({ file: entity }) }, + 'file:updated': { scope: 'trip', payload: z.object({ file: entity }) }, + 'file:deleted': { scope: 'trip', payload: z.object({ fileId: id }) }, + + // ── Collab ─────────────────────────────────────────────────────────────── + 'collab:note:created': { scope: 'trip', payload: z.object({ note: entity }) }, + 'collab:note:updated': { scope: 'trip', payload: z.object({ note: entity }) }, + 'collab:note:deleted': { scope: 'trip', payload: z.object({ noteId: id }) }, + 'collab:poll:created': { scope: 'trip', payload: z.object({ poll: entity }) }, + 'collab:poll:voted': { scope: 'trip', payload: z.object({ poll: entity }) }, + 'collab:poll:closed': { scope: 'trip', payload: z.object({ poll: entity }) }, + 'collab:poll:deleted': { scope: 'trip', payload: z.object({ pollId: id }) }, + 'collab:message:created': { scope: 'trip', payload: z.object({ message: entity }) }, + 'collab:message:reacted': { + scope: 'trip', + payload: z.object({ messageId: id, reactions: z.unknown() }), + }, + 'collab:message:deleted': { + scope: 'trip', + payload: z.object({ messageId: id, username: z.string().nullish() }), + }, + + // ── Memories ───────────────────────────────────────────────────────────── + 'memories:updated': { scope: 'trip', payload: z.object({ userId: id }) }, + + // ── Notifications (user-scoped) ────────────────────────────────────────── + 'notification:new': { scope: 'user', payload: z.object({ notification: entity }) }, + 'notification:updated': { scope: 'user', payload: z.object({ notification: entity }) }, + + // ── Collections (user-scoped) ──────────────────────────────────────────── + 'collections:updated': { scope: 'user', payload: z.object({ collectionId: id }) }, + 'collections:accepted': { scope: 'user', payload: z.object({ collectionId: id }) }, + 'collections:declined': { scope: 'user', payload: z.object({ collectionId: id }) }, + 'collections:left': { scope: 'user', payload: z.object({ collectionId: id }) }, + 'collections:deleted': { scope: 'user', payload: z.object({ collectionId: id }) }, + 'collections:cancelled': { scope: 'user', payload: z.object({ collectionId: id }) }, + 'collections:removed': { scope: 'user', payload: z.object({ collectionId: id }) }, + 'collections:invite': { + scope: 'user', + payload: z.object({ from: z.object({ id, username: z.string() }), collectionId: id }), + }, + + // ── Vacay (user-scoped) ────────────────────────────────────────────────── + 'vacay:update': { scope: 'user', payload: empty }, + 'vacay:settings': { scope: 'user', payload: empty }, + 'vacay:accepted': { scope: 'user', payload: empty }, + 'vacay:declined': { scope: 'user', payload: empty }, + 'vacay:cancelled': { scope: 'user', payload: empty }, + 'vacay:dissolved': { scope: 'user', payload: empty }, + 'vacay:invite': { + scope: 'user', + payload: z.object({ from: z.object({ id, username: z.string() }), planId: id }), + }, + 'vacay:share': { scope: 'user', payload: z.object({ from: z.object({ id }) }) }, + 'vacay:share-removed': { scope: 'user', payload: empty }, + 'vacay:shared-update': { scope: 'user', payload: empty }, + + // ── Journey (user-scoped; broadcastJourneyEvent injects journeyId) ────── + 'journey:trip:synced': { scope: 'user', payload: z.object({ journeyId: id, tripId: id }) }, + 'journey:entry:created': { scope: 'user', payload: z.object({ journeyId: id, entry: entity }) }, + // DRIFT: the soft-delete path emits { journeyId, entryId } under :updated + // instead of { journeyId, entry } (journeyService.ts). + 'journey:entry:updated': { + scope: 'user', + payload: z.union([z.object({ journeyId: id, entry: entity }), z.object({ journeyId: id, entryId: id })]), + }, + 'journey:entry:deleted': { scope: 'user', payload: z.object({ journeyId: id, entryId: id }) }, + 'journey:entries:reordered': { + scope: 'user', + payload: z.object({ journeyId: id, orderedIds: idList }), + }, + 'journey:contributor:changed': { + scope: 'user', + payload: z.object({ journeyId: id, targetUserId: id, role: z.unknown() }), + }, + + // ── Booking import (user-scoped; push() injects jobId + tripId) ───────── + 'import:progress': { + scope: 'user', + payload: z.object({ + jobId: z.string(), + tripId: id.nullish(), + status: z.string(), + done: z.number(), + total: z.number(), + fileName: z.string().optional(), + }), + }, + 'import:done': { + scope: 'user', + payload: z.object({ jobId: z.string(), tripId: id.nullish(), result: z.unknown() }), + }, + 'import:error': { + scope: 'user', + payload: z.object({ jobId: z.string(), tripId: id.nullish(), message: z.string() }), + }, +} as const satisfies Record; + +/** Every registered event name (65 trip-scoped + 29 user-scoped). */ +export type TrekWsEventName = keyof typeof TREK_WS_EVENTS; + +/** Names broadcast to a trip room (`{ type, tripId, ...payload }` envelope). */ +export type TrekWsTripEventName = { + [K in TrekWsEventName]: (typeof TREK_WS_EVENTS)[K]['scope'] extends 'trip' ? K : never; +}[TrekWsEventName]; + +/** Names sent to a single user's sockets (payload carries `type` itself). */ +export type TrekWsUserEventName = { + [K in TrekWsEventName]: (typeof TREK_WS_EVENTS)[K]['scope'] extends 'user' ? K : never; +}[TrekWsEventName]; + +/** The payload contract for one event (the part beside type/tripId on the wire). */ +export type TrekWsPayload = z.infer<(typeof TREK_WS_EVENTS)[E]['payload']>; + +/** + * The reserved plugin escape hatch: trip broadcasts are force-namespaced to + * `plugin::` and user-scoped plugin messages are typed + * `plugin:` by the host β€” names and payloads are plugin-chosen and + * deliberately outside the registry. + */ +export type TrekWsPluginEventName = `plugin:${string}`; + +/** All registered names, for runtime enumeration (client ratchet, audits). */ +export const TREK_WS_EVENT_NAMES = Object.keys(TREK_WS_EVENTS) as TrekWsEventName[]; + +/** Trip-scoped subset of {@link TREK_WS_EVENT_NAMES}. */ +export const TREK_WS_TRIP_EVENT_NAMES = TREK_WS_EVENT_NAMES.filter( + (name) => TREK_WS_EVENTS[name].scope === 'trip', +) as TrekWsTripEventName[]; + +/** User-scoped subset of {@link TREK_WS_EVENT_NAMES}. */ +export const TREK_WS_USER_EVENT_NAMES = TREK_WS_EVENT_NAMES.filter( + (name) => TREK_WS_EVENTS[name].scope === 'user', +) as TrekWsUserEventName[]; + +// Compile-time lockstep: the trip/user unions must partition the full name +// union exactly β€” fails `tsc` if a scope literal widens or a name lands in +// neither bucket (same pattern as the MCP access-group gate). +type AssertExact = [A] extends [B] ? ([B] extends [A] ? true : never) : never; +export const TREK_WS_SCOPES_PARTITION_EVENT_NAMES: AssertExact< + TrekWsTripEventName | TrekWsUserEventName, + TrekWsEventName +> = true;