mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
feat(shared): WS event contract registry
TREK_WS_EVENTS: 65 trip-scoped + 29 user-scoped events with Zod payload schemas transcribed from the emitting call sites; derived name unions and payload types; plugin: namespace reserved as the escape hatch. 8 payload-drift shapes modeled as unions and flagged.
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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<TrekWsEventName, Record<string, unknown>> = {
|
||||
'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<Record<TrekWsEventName, Record<string, unknown>[]>> = {
|
||||
'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<string>([...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);
|
||||
});
|
||||
});
|
||||
@@ -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: <event name>, 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: <event name>` 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:<pluginId>:<event>` and user messages typed `plugin:<pluginId>`
|
||||
* (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<string, never>`,
|
||||
* 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<string, TrekWsEventContract>;
|
||||
|
||||
/** 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<E extends TrekWsEventName> = z.infer<(typeof TREK_WS_EVENTS)[E]['payload']>;
|
||||
|
||||
/**
|
||||
* The reserved plugin escape hatch: trip broadcasts are force-namespaced to
|
||||
* `plugin:<pluginId>:<event>` and user-scoped plugin messages are typed
|
||||
* `plugin:<pluginId>` 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, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never;
|
||||
export const TREK_WS_SCOPES_PARTITION_EVENT_NAMES: AssertExact<
|
||||
TrekWsTripEventName | TrekWsUserEventName,
|
||||
TrekWsEventName
|
||||
> = true;
|
||||
Reference in New Issue
Block a user