mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
feat(client): handle WS events against the shared registry
remoteEventHandler's two switches become enumerable lookups keyed by TrekWsTripEventName (bodies unchanged); WebSocketEvent.type derives from the registry. wsEventPolicy.ts makes the client's non-reactions explicit (HANDLED_OUTSIDE_TRIP_STORE + IGNORED_WS_EVENTS) and the registry-parity test turns an unclassified server event into a red test instead of silence. Zero behavior change.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import type { TrekWsEventName } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Client-side handling policy for every event in the shared WS registry
|
||||
* (`TREK_WS_EVENTS` in @trek/shared). Together with the tripStore lookups
|
||||
* (DEXIE_WRITERS / STATE_APPLIERS in store/slices/remoteEventHandler.ts),
|
||||
* these lists partition the registry exactly — the registry-parity test
|
||||
* fails if a registry event is missing from all of them, or listed twice.
|
||||
* A new server event therefore forces an explicit client decision (handle
|
||||
* it, or add it here) instead of being dropped by a silent `default:`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Events consumed by dedicated listeners outside the tripStore reducer.
|
||||
* Every entry names real handling code — if that code is removed, remove
|
||||
* the entry (the event then needs a new home or an IGNORED_WS_EVENTS slot).
|
||||
*/
|
||||
export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
|
||||
'collab:note:created',
|
||||
'collab:note:updated',
|
||||
'collab:note:deleted',
|
||||
'collab:poll:created',
|
||||
'collab:poll:voted',
|
||||
'collab:poll:closed',
|
||||
'collab:poll:deleted',
|
||||
'collab:message:created',
|
||||
'collab:message:reacted',
|
||||
'collab:message:deleted',
|
||||
// In-app notifications — hooks/useInAppNotificationListener
|
||||
'notification:new',
|
||||
'notification:updated',
|
||||
// Collections — pages/collections/useCollections ('collections:' prefix listener)
|
||||
'collections:updated',
|
||||
'collections:accepted',
|
||||
'collections:declined',
|
||||
'collections:left',
|
||||
'collections:deleted',
|
||||
'collections:cancelled',
|
||||
'collections:removed',
|
||||
'collections:invite',
|
||||
// Vacay — pages/vacay/useVacay
|
||||
'vacay:update',
|
||||
'vacay:settings',
|
||||
'vacay:accepted',
|
||||
'vacay:declined',
|
||||
'vacay:cancelled',
|
||||
'vacay:dissolved',
|
||||
'vacay:invite',
|
||||
'vacay:share',
|
||||
'vacay:share-removed',
|
||||
'vacay:shared-update',
|
||||
// Journey — pages/journeyDetail/useJourneyDetail ('journey:' prefix listener)
|
||||
'journey:trip:synced',
|
||||
'journey:entry:created',
|
||||
'journey:entry:updated',
|
||||
'journey:entry:deleted',
|
||||
'journey:entries:reordered',
|
||||
'journey:contributor:changed',
|
||||
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
|
||||
'import:progress',
|
||||
'import:done',
|
||||
'import:error',
|
||||
] as const satisfies readonly TrekWsEventName[]
|
||||
|
||||
/**
|
||||
* Events the client deliberately does not act on today (state of the world
|
||||
* when the registry landed — every one of these was already dropped by the
|
||||
* old silent `default:` branches). Removing an entry means the event is now
|
||||
* handled somewhere; ADDING an entry is a product decision that a new server
|
||||
* event should have no client reaction — never add one just to silence the
|
||||
* registry-parity test.
|
||||
*/
|
||||
export const IGNORED_WS_EVENTS = [
|
||||
'assignment:participants',
|
||||
'packing:reordered',
|
||||
'packing:bag-created',
|
||||
'packing:bag-updated',
|
||||
'packing:bag-deleted',
|
||||
'packing:bag-members-updated',
|
||||
'packing:assignees',
|
||||
'packing:template-applied',
|
||||
'todo:assignees',
|
||||
'budget:settlement-created',
|
||||
'budget:settlement-updated',
|
||||
'budget:settlement-deleted',
|
||||
'reservation:positions',
|
||||
// Accommodations live in page-local planner state; the client refetches
|
||||
// them off trip:updated date changes, never off these events.
|
||||
'accommodation:created',
|
||||
'accommodation:updated',
|
||||
'accommodation:deleted',
|
||||
'trip:deleted',
|
||||
'member:added',
|
||||
'member:removed',
|
||||
] as const satisfies readonly TrekWsEventName[]
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TrekWsTripEventName } from '@trek/shared'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { Assignment, Place, Day, DayNote, PackingItem, TodoItem, BudgetItem, BudgetItemMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types'
|
||||
import { offlineDb } from '../../db/offlineDb'
|
||||
@@ -8,6 +9,132 @@ type GetState = StoreApi<TripStoreState>['getState']
|
||||
|
||||
// ── Dexie write-through ───────────────────────────────────────────────────────
|
||||
|
||||
type DexieWriter = (payload: Record<string, unknown>, state: TripStoreState) => Promise<void>
|
||||
|
||||
// Shared writer bodies (one function referenced by every event that used to
|
||||
// share a switch fallthrough group).
|
||||
const putPlace: DexieWriter = async payload => {
|
||||
await offlineDb.places.put(payload.place as Place)
|
||||
}
|
||||
const writeAssignmentDay: DexieWriter = async (payload, state) => {
|
||||
const assignment = payload.assignment as Assignment
|
||||
await _writeDayToDb(assignment.day_id, state)
|
||||
}
|
||||
const writePayloadDay: DexieWriter = async (payload, state) => {
|
||||
await _writeDayToDb(payload.dayId as number, state)
|
||||
}
|
||||
const writeDayById: DexieWriter = async (payload, state) => {
|
||||
const day = payload.day as Day
|
||||
await _writeDayToDb(day.id, state)
|
||||
}
|
||||
const putPackingItem: DexieWriter = async payload => {
|
||||
await offlineDb.packingItems.put(payload.item as PackingItem)
|
||||
}
|
||||
const putTodoItem: DexieWriter = async payload => {
|
||||
await offlineDb.todoItems.put(payload.item as TodoItem)
|
||||
}
|
||||
const putBudgetItem: DexieWriter = async payload => {
|
||||
await offlineDb.budgetItems.put(payload.item as BudgetItem)
|
||||
}
|
||||
// Partial update — read the canonical item from the updated Zustand state.
|
||||
const putCanonicalBudgetItem: DexieWriter = async (payload, state) => {
|
||||
const item = state.budgetItems.find(i => i.id === (payload.itemId as number))
|
||||
if (item) await offlineDb.budgetItems.put(item)
|
||||
}
|
||||
const putReservation: DexieWriter = async payload => {
|
||||
await offlineDb.reservations.put(payload.reservation as Reservation)
|
||||
}
|
||||
const putTripFile: DexieWriter = async payload => {
|
||||
await offlineDb.tripFiles.put(payload.file as TripFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-event IndexedDB write-through, keyed by the shared WS event registry.
|
||||
* The key set is enumerable on purpose: the registry-parity test asserts every
|
||||
* registry event is either handled here/in STATE_APPLIERS, handled by a
|
||||
* dedicated listener elsewhere, or explicitly listed as ignored — a new server
|
||||
* event without a client decision fails that test instead of dropping silently.
|
||||
*/
|
||||
export const DEXIE_WRITERS: Partial<Record<TrekWsTripEventName, DexieWriter>> = {
|
||||
// ── Places ──────────────────────────────────────────────────────────────
|
||||
'place:created': putPlace,
|
||||
'place:updated': putPlace,
|
||||
'place:deleted': async payload => {
|
||||
await offlineDb.places.delete(payload.placeId as number)
|
||||
},
|
||||
|
||||
// ── Assignments (embedded in Day rows) ──────────────────────────────────
|
||||
// Read the already-updated Day from the Zustand state and persist it.
|
||||
'assignment:created': writeAssignmentDay,
|
||||
'assignment:updated': writeAssignmentDay,
|
||||
'assignment:deleted': writePayloadDay,
|
||||
'assignment:moved': async (payload, state) => {
|
||||
const movedAssignment = payload.assignment as Assignment
|
||||
await Promise.all([
|
||||
_writeDayToDb(payload.oldDayId as number, state),
|
||||
_writeDayToDb(movedAssignment.day_id, state),
|
||||
])
|
||||
},
|
||||
'assignment:reordered': writePayloadDay,
|
||||
|
||||
// ── Days ─────────────────────────────────────────────────────────────────
|
||||
'day:created': writeDayById,
|
||||
'day:updated': writeDayById,
|
||||
'day:deleted': async payload => {
|
||||
await offlineDb.days.delete(payload.dayId as number)
|
||||
},
|
||||
|
||||
// ── Day notes (embedded in Day rows) ─────────────────────────────────────
|
||||
'dayNote:created': writePayloadDay,
|
||||
'dayNote:updated': writePayloadDay,
|
||||
'dayNote:deleted': writePayloadDay,
|
||||
|
||||
// ── Packing ──────────────────────────────────────────────────────────────
|
||||
'packing:created': putPackingItem,
|
||||
'packing:updated': putPackingItem,
|
||||
'packing:deleted': async payload => {
|
||||
await offlineDb.packingItems.delete(payload.itemId as number)
|
||||
},
|
||||
|
||||
// ── Todo ─────────────────────────────────────────────────────────────────
|
||||
'todo:created': putTodoItem,
|
||||
'todo:updated': putTodoItem,
|
||||
'todo:deleted': async payload => {
|
||||
await offlineDb.todoItems.delete(payload.itemId as number)
|
||||
},
|
||||
|
||||
// ── Budget ───────────────────────────────────────────────────────────────
|
||||
'budget:created': putBudgetItem,
|
||||
'budget:updated': putBudgetItem,
|
||||
'budget:deleted': async payload => {
|
||||
await offlineDb.budgetItems.delete(payload.itemId as number)
|
||||
},
|
||||
'budget:members-updated': putCanonicalBudgetItem,
|
||||
'budget:member-paid-updated': putCanonicalBudgetItem,
|
||||
'budget:reordered': async (_payload, state) => {
|
||||
await offlineDb.budgetItems.bulkPut(state.budgetItems)
|
||||
},
|
||||
|
||||
// ── Reservations ─────────────────────────────────────────────────────────
|
||||
'reservation:created': putReservation,
|
||||
'reservation:updated': putReservation,
|
||||
'reservation:deleted': async payload => {
|
||||
await offlineDb.reservations.delete(payload.reservationId as number)
|
||||
},
|
||||
|
||||
// ── Trip ─────────────────────────────────────────────────────────────────
|
||||
'trip:updated': async payload => {
|
||||
await offlineDb.trips.put(payload.trip as Trip)
|
||||
},
|
||||
|
||||
// ── Files ─────────────────────────────────────────────────────────────────
|
||||
'file:created': putTripFile,
|
||||
'file:updated': putTripFile,
|
||||
'file:deleted': async payload => {
|
||||
await offlineDb.tripFiles.delete(payload.fileId as number)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist remote event to IndexedDB so the data is available offline.
|
||||
* Fire-and-forget: errors are swallowed to never block the Zustand update.
|
||||
@@ -20,123 +147,7 @@ function writeToDexie(
|
||||
): void {
|
||||
;(async () => {
|
||||
try {
|
||||
switch (type) {
|
||||
// ── Places ──────────────────────────────────────────────────────────
|
||||
case 'place:created':
|
||||
case 'place:updated':
|
||||
await offlineDb.places.put(payload.place as Place)
|
||||
break
|
||||
case 'place:deleted':
|
||||
await offlineDb.places.delete(payload.placeId as number)
|
||||
break
|
||||
|
||||
// ── Assignments (embedded in Day rows) ──────────────────────────────
|
||||
// Read the already-updated Day from the Zustand state and persist it.
|
||||
case 'assignment:created':
|
||||
case 'assignment:updated': {
|
||||
const assignment = payload.assignment as Assignment
|
||||
await _writeDayToDb(assignment.day_id, state)
|
||||
break
|
||||
}
|
||||
case 'assignment:deleted': {
|
||||
await _writeDayToDb(payload.dayId as number, state)
|
||||
break
|
||||
}
|
||||
case 'assignment:moved': {
|
||||
const movedAssignment = payload.assignment as Assignment
|
||||
await Promise.all([
|
||||
_writeDayToDb(payload.oldDayId as number, state),
|
||||
_writeDayToDb(movedAssignment.day_id, state),
|
||||
])
|
||||
break
|
||||
}
|
||||
case 'assignment:reordered':
|
||||
await _writeDayToDb(payload.dayId as number, state)
|
||||
break
|
||||
|
||||
// ── Days ─────────────────────────────────────────────────────────────
|
||||
case 'day:created':
|
||||
case 'day:updated': {
|
||||
const day = payload.day as Day
|
||||
await _writeDayToDb(day.id, state)
|
||||
break
|
||||
}
|
||||
case 'day:deleted':
|
||||
await offlineDb.days.delete(payload.dayId as number)
|
||||
break
|
||||
|
||||
// ── Day notes (embedded in Day rows) ─────────────────────────────────
|
||||
case 'dayNote:created':
|
||||
case 'dayNote:updated':
|
||||
case 'dayNote:deleted':
|
||||
await _writeDayToDb(payload.dayId as number, state)
|
||||
break
|
||||
|
||||
// ── Packing ──────────────────────────────────────────────────────────
|
||||
case 'packing:created':
|
||||
case 'packing:updated':
|
||||
await offlineDb.packingItems.put(payload.item as PackingItem)
|
||||
break
|
||||
case 'packing:deleted':
|
||||
await offlineDb.packingItems.delete(payload.itemId as number)
|
||||
break
|
||||
|
||||
// ── Todo ─────────────────────────────────────────────────────────────
|
||||
case 'todo:created':
|
||||
case 'todo:updated':
|
||||
await offlineDb.todoItems.put(payload.item as TodoItem)
|
||||
break
|
||||
case 'todo:deleted':
|
||||
await offlineDb.todoItems.delete(payload.itemId as number)
|
||||
break
|
||||
|
||||
// ── Budget ───────────────────────────────────────────────────────────
|
||||
case 'budget:created':
|
||||
case 'budget:updated':
|
||||
await offlineDb.budgetItems.put(payload.item as BudgetItem)
|
||||
break
|
||||
case 'budget:deleted':
|
||||
await offlineDb.budgetItems.delete(payload.itemId as number)
|
||||
break
|
||||
case 'budget:members-updated':
|
||||
case 'budget:member-paid-updated':
|
||||
case 'budget:reordered': {
|
||||
// Partial update — read canonical item(s) from updated Zustand state
|
||||
if (type === 'budget:reordered') {
|
||||
await offlineDb.budgetItems.bulkPut(state.budgetItems)
|
||||
} else {
|
||||
const item = state.budgetItems.find(i => i.id === (payload.itemId as number))
|
||||
if (item) await offlineDb.budgetItems.put(item)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// ── Reservations ─────────────────────────────────────────────────────
|
||||
case 'reservation:created':
|
||||
case 'reservation:updated':
|
||||
await offlineDb.reservations.put(payload.reservation as Reservation)
|
||||
break
|
||||
case 'reservation:deleted':
|
||||
await offlineDb.reservations.delete(payload.reservationId as number)
|
||||
break
|
||||
|
||||
// ── Trip ─────────────────────────────────────────────────────────────
|
||||
case 'trip:updated':
|
||||
await offlineDb.trips.put(payload.trip as Trip)
|
||||
break
|
||||
|
||||
// ── Files ─────────────────────────────────────────────────────────────
|
||||
case 'file:created':
|
||||
case 'file:updated':
|
||||
await offlineDb.tripFiles.put(payload.file as TripFile)
|
||||
break
|
||||
case 'file:deleted':
|
||||
await offlineDb.tripFiles.delete(payload.fileId as number)
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
await DEXIE_WRITERS[type as TrekWsTripEventName]?.(payload, state)
|
||||
} catch {
|
||||
// Dexie write failures are non-fatal — online state is source of truth
|
||||
}
|
||||
@@ -156,6 +167,301 @@ async function _writeDayToDb(dayId: number, state: TripStoreState): Promise<void
|
||||
|
||||
// ── Zustand event reducer ─────────────────────────────────────────────────────
|
||||
|
||||
type StateApplier = (payload: Record<string, unknown>, state: TripStoreState) => Partial<TripStoreState>
|
||||
|
||||
/**
|
||||
* Per-event immutable state update (create/update/delete for the relevant
|
||||
* entity), keyed by the shared WS event registry. An event with no applier is
|
||||
* a no-op here — but only because the registry-parity test proves it is either
|
||||
* handled by a dedicated listener elsewhere or explicitly ignored.
|
||||
*/
|
||||
export const STATE_APPLIERS: Partial<Record<TrekWsTripEventName, StateApplier>> = {
|
||||
// Places
|
||||
'place:created': (payload, state) => {
|
||||
if (state.places.some(p => p.id === (payload.place as Place).id)) return {}
|
||||
return { places: [payload.place as Place, ...state.places] }
|
||||
},
|
||||
'place:updated': (payload, state) => ({
|
||||
places: state.places.map(p => p.id === (payload.place as Place).id ? payload.place as Place : p),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.map(a => a.place?.id === (payload.place as Place).id ? { ...a, place: payload.place as Place } : a)
|
||||
])
|
||||
),
|
||||
}),
|
||||
'place:deleted': (payload, state) => ({
|
||||
places: state.places.filter(p => p.id !== payload.placeId),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.filter(a => a.place?.id !== payload.placeId)
|
||||
])
|
||||
),
|
||||
}),
|
||||
|
||||
// Assignments
|
||||
'assignment:created': (payload, state) => {
|
||||
const incoming = payload.assignment as Assignment
|
||||
const dayKey = String(incoming.day_id)
|
||||
const existing = state.assignments[dayKey] || []
|
||||
const placeId = incoming.place?.id ?? incoming.place_id
|
||||
|
||||
// Already have this exact assignment id → duplicate broadcast or the
|
||||
// echo of an already-committed assignment. No-op.
|
||||
if (existing.some(a => a.id === incoming.id)) return {}
|
||||
|
||||
// Reconcile our own optimistic create: replace the temp (negative-id)
|
||||
// assignment of the same place on this day with the real one. Guarded on
|
||||
// a real placeId so an assignment with no place can never collapse onto
|
||||
// another place-less one (undefined === undefined).
|
||||
if (placeId != null) {
|
||||
const tempIdx = existing.findIndex(a => a.id < 0 && a.place?.id === placeId)
|
||||
if (tempIdx !== -1) {
|
||||
const next = existing.slice()
|
||||
next[tempIdx] = incoming
|
||||
return { assignments: { ...state.assignments, [dayKey]: next } }
|
||||
}
|
||||
}
|
||||
|
||||
// Genuinely new — including a legitimate second assignment of a place
|
||||
// already on this day (no temp version to reconcile). Append.
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: [...existing, incoming],
|
||||
}
|
||||
}
|
||||
},
|
||||
'assignment:updated': (payload, state) => {
|
||||
const dayKey = String((payload.assignment as Assignment).day_id)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: (state.assignments[dayKey] || []).map(a =>
|
||||
a.id === (payload.assignment as Assignment).id ? { ...a, ...(payload.assignment as Assignment) } : a
|
||||
),
|
||||
}
|
||||
}
|
||||
},
|
||||
'assignment:deleted': (payload, state) => {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: (state.assignments[dayKey] || []).filter(a => a.id !== payload.assignmentId),
|
||||
}
|
||||
}
|
||||
},
|
||||
'assignment:moved': (payload, state) => {
|
||||
const oldKey = String(payload.oldDayId)
|
||||
const newKey = String(payload.newDayId)
|
||||
const movedAssignment = payload.assignment as Assignment
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[oldKey]: (state.assignments[oldKey] || []).filter(a => a.id !== movedAssignment.id),
|
||||
[newKey]: [...(state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id), movedAssignment],
|
||||
}
|
||||
}
|
||||
},
|
||||
'assignment:reordered': (payload, state) => {
|
||||
const dayKey = String(payload.dayId)
|
||||
const currentItems = state.assignments[dayKey] || []
|
||||
const orderedIds: number[] = (payload.orderedIds as number[] | undefined) || []
|
||||
const reordered = orderedIds.map((id, idx) => {
|
||||
const item = currentItems.find(a => a.id === id)
|
||||
return item ? { ...item, order_index: idx } : null
|
||||
}).filter((item): item is Assignment => item !== null)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: reordered,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Days
|
||||
'day:created': (payload, state) => {
|
||||
if (state.days.some(d => d.id === (payload.day as Day).id)) return {}
|
||||
return { days: [...state.days, payload.day as Day] }
|
||||
},
|
||||
'day:updated': (payload, state) => ({
|
||||
days: state.days.map(d => d.id === (payload.day as Day).id ? payload.day as Day : d),
|
||||
}),
|
||||
'day:deleted': (payload, state) => {
|
||||
const removedDayId = String(payload.dayId)
|
||||
const newAssignments = { ...state.assignments }
|
||||
delete newAssignments[removedDayId]
|
||||
const newDayNotes = { ...state.dayNotes }
|
||||
delete newDayNotes[removedDayId]
|
||||
return {
|
||||
days: state.days.filter(d => d.id !== payload.dayId),
|
||||
assignments: newAssignments,
|
||||
dayNotes: newDayNotes,
|
||||
}
|
||||
},
|
||||
'day:reordered': (payload, state) => {
|
||||
// Apply the new order instantly when we know all ids; the authoritative
|
||||
// dates + re-stamped booking times are pulled by the refresh below.
|
||||
const orderedIds = payload.orderedIds as number[] | undefined
|
||||
if (!orderedIds || orderedIds.length !== state.days.length) return {}
|
||||
const byId = new Map(state.days.map(d => [d.id, d]))
|
||||
if (!orderedIds.every(id => byId.has(id))) return {}
|
||||
return { days: orderedIds.map((id, i) => ({ ...byId.get(id)!, day_number: i + 1 })) }
|
||||
},
|
||||
|
||||
// Day Notes
|
||||
'dayNote:created': (payload, state) => {
|
||||
const dayKey = String(payload.dayId)
|
||||
const existingNotes = (state.dayNotes[dayKey] || [])
|
||||
if (existingNotes.some(n => n.id === (payload.note as DayNote).id)) return {}
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: [...existingNotes, payload.note as DayNote],
|
||||
}
|
||||
}
|
||||
},
|
||||
'dayNote:updated': (payload, state) => {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: (state.dayNotes[dayKey] || []).map(n => n.id === (payload.note as DayNote).id ? payload.note as DayNote : n),
|
||||
}
|
||||
}
|
||||
},
|
||||
'dayNote:deleted': (payload, state) => {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: (state.dayNotes[dayKey] || []).filter(n => n.id !== payload.noteId),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Packing
|
||||
'packing:created': (payload, state) => {
|
||||
if (state.packingItems.some(i => i.id === (payload.item as PackingItem).id)) return {}
|
||||
return { packingItems: [...state.packingItems, payload.item as PackingItem] }
|
||||
},
|
||||
'packing:updated': (payload, state) => ({
|
||||
packingItems: state.packingItems.map(i => i.id === (payload.item as PackingItem).id ? payload.item as PackingItem : i),
|
||||
}),
|
||||
'packing:deleted': (payload, state) => ({
|
||||
packingItems: state.packingItems.filter(i => i.id !== payload.itemId),
|
||||
}),
|
||||
|
||||
// Todo
|
||||
'todo:created': (payload, state) => {
|
||||
if (state.todoItems.some(i => i.id === (payload.item as TodoItem).id)) return {}
|
||||
return { todoItems: [...state.todoItems, payload.item as TodoItem] }
|
||||
},
|
||||
'todo:updated': (payload, state) => ({
|
||||
todoItems: state.todoItems.map(i => i.id === (payload.item as TodoItem).id ? payload.item as TodoItem : i),
|
||||
}),
|
||||
'todo:deleted': (payload, state) => ({
|
||||
todoItems: state.todoItems.filter(i => i.id !== payload.itemId),
|
||||
}),
|
||||
|
||||
// Budget
|
||||
'budget:created': (payload, state) => {
|
||||
if (state.budgetItems.some(i => i.id === (payload.item as BudgetItem).id)) return {}
|
||||
return { budgetItems: [...state.budgetItems, payload.item as BudgetItem] }
|
||||
},
|
||||
'budget:updated': (payload, state) => ({
|
||||
budgetItems: state.budgetItems.map(i => i.id === (payload.item as BudgetItem).id ? payload.item as BudgetItem : i),
|
||||
}),
|
||||
'budget:deleted': (payload, state) => ({
|
||||
budgetItems: state.budgetItems.filter(i => i.id !== payload.itemId),
|
||||
}),
|
||||
'budget:members-updated': (payload, state) => ({
|
||||
budgetItems: state.budgetItems.map(i =>
|
||||
i.id === payload.itemId ? { ...i, members: payload.members as BudgetItemMember[], persons: payload.persons as number } : i
|
||||
),
|
||||
}),
|
||||
'budget:member-paid-updated': (payload, state) => ({
|
||||
budgetItems: state.budgetItems.map(i =>
|
||||
i.id === payload.itemId
|
||||
// `paid` arrives over the wire as the raw value the server emits;
|
||||
// it's stored verbatim. The member type models it as a number, so
|
||||
// narrow without changing the value.
|
||||
? { ...i, members: (i.members || []).map(m => m.user_id === payload.userId ? { ...m, paid: payload.paid as number } : m) }
|
||||
: i
|
||||
),
|
||||
}),
|
||||
'budget:reordered': (payload, state) => {
|
||||
if (payload.orderedIds) {
|
||||
const orderedIds = payload.orderedIds as number[]
|
||||
const byId = new Map(state.budgetItems.map(i => [i.id, i]))
|
||||
const reordered = orderedIds.map((id, idx): BudgetItem | null => {
|
||||
const item = byId.get(id)
|
||||
return item ? { ...item, sort_order: idx } : null
|
||||
}).filter((i): i is BudgetItem => i !== null)
|
||||
const remaining = state.budgetItems.filter(i => !orderedIds.includes(i.id))
|
||||
return { budgetItems: [...reordered, ...remaining] }
|
||||
}
|
||||
if (payload.orderedCategories) {
|
||||
const orderedCategories = payload.orderedCategories as string[]
|
||||
const grouped = new Map<string, BudgetItem[]>()
|
||||
for (const item of state.budgetItems) {
|
||||
const cat = item.category || 'Other'
|
||||
if (!grouped.has(cat)) grouped.set(cat, [])
|
||||
grouped.get(cat)!.push(item)
|
||||
}
|
||||
const reordered: BudgetItem[] = []
|
||||
for (const cat of orderedCategories) {
|
||||
const items = grouped.get(cat)
|
||||
if (items) reordered.push(...items)
|
||||
}
|
||||
for (const [cat, items] of grouped) {
|
||||
if (!orderedCategories.includes(cat)) reordered.push(...items)
|
||||
}
|
||||
return { budgetItems: reordered }
|
||||
}
|
||||
return {}
|
||||
},
|
||||
|
||||
// Reservations
|
||||
'reservation:created': (payload, state) => {
|
||||
if (state.reservations.some(r => r.id === (payload.reservation as Reservation).id)) return {}
|
||||
return { reservations: [payload.reservation as Reservation, ...state.reservations] }
|
||||
},
|
||||
'reservation:updated': (payload, state) => ({
|
||||
reservations: state.reservations.map(r => r.id === (payload.reservation as Reservation).id ? payload.reservation as Reservation : r),
|
||||
}),
|
||||
'reservation:travelers-updated': (payload, state) => ({
|
||||
reservations: state.reservations.map(r => r.id === (payload.reservationId as number)
|
||||
? { ...r, travelers: payload.travelers as Reservation['travelers'] } : r),
|
||||
}),
|
||||
'reservation:deleted': (payload, state) => ({
|
||||
reservations: state.reservations.filter(r => r.id !== payload.reservationId),
|
||||
}),
|
||||
|
||||
// Trip
|
||||
'trip:updated': payload => ({ trip: payload.trip as Trip }),
|
||||
|
||||
// Files
|
||||
'file:created': (payload, state) => {
|
||||
if (state.files.some(f => f.id === (payload.file as TripFile).id)) return {}
|
||||
return { files: [payload.file as TripFile, ...state.files] }
|
||||
},
|
||||
'file:updated': (payload, state) => ({
|
||||
files: state.files.map(f => f.id === (payload.file as TripFile).id ? payload.file as TripFile : f),
|
||||
}),
|
||||
'file:deleted': (payload, state) => ({
|
||||
files: state.files.filter(f => f.id !== payload.fileId),
|
||||
}),
|
||||
|
||||
// Memories / Photos
|
||||
'memories:updated': payload => {
|
||||
window.dispatchEvent(new CustomEvent('memories:updated', { detail: payload }))
|
||||
return {}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a remote WebSocket event to the local Zustand store, keeping state in sync across collaborators.
|
||||
* Each event type maps to an immutable state update (create/update/delete) for the relevant entity.
|
||||
@@ -168,306 +474,7 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
|
||||
// date-change check made after it would compare the new trip against itself.
|
||||
const prevTrip = get().trip
|
||||
|
||||
set(state => {
|
||||
switch (type) {
|
||||
// Places
|
||||
case 'place:created':
|
||||
if (state.places.some(p => p.id === (payload.place as Place).id)) return {}
|
||||
return { places: [payload.place as Place, ...state.places] }
|
||||
case 'place:updated':
|
||||
return {
|
||||
places: state.places.map(p => p.id === (payload.place as Place).id ? payload.place as Place : p),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.map(a => a.place?.id === (payload.place as Place).id ? { ...a, place: payload.place as Place } : a)
|
||||
])
|
||||
),
|
||||
}
|
||||
case 'place:deleted':
|
||||
return {
|
||||
places: state.places.filter(p => p.id !== payload.placeId),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.filter(a => a.place?.id !== payload.placeId)
|
||||
])
|
||||
),
|
||||
}
|
||||
|
||||
// Assignments
|
||||
case 'assignment:created': {
|
||||
const incoming = payload.assignment as Assignment
|
||||
const dayKey = String(incoming.day_id)
|
||||
const existing = state.assignments[dayKey] || []
|
||||
const placeId = incoming.place?.id ?? incoming.place_id
|
||||
|
||||
// Already have this exact assignment id → duplicate broadcast or the
|
||||
// echo of an already-committed assignment. No-op.
|
||||
if (existing.some(a => a.id === incoming.id)) return {}
|
||||
|
||||
// Reconcile our own optimistic create: replace the temp (negative-id)
|
||||
// assignment of the same place on this day with the real one. Guarded on
|
||||
// a real placeId so an assignment with no place can never collapse onto
|
||||
// another place-less one (undefined === undefined).
|
||||
if (placeId != null) {
|
||||
const tempIdx = existing.findIndex(a => a.id < 0 && a.place?.id === placeId)
|
||||
if (tempIdx !== -1) {
|
||||
const next = existing.slice()
|
||||
next[tempIdx] = incoming
|
||||
return { assignments: { ...state.assignments, [dayKey]: next } }
|
||||
}
|
||||
}
|
||||
|
||||
// Genuinely new — including a legitimate second assignment of a place
|
||||
// already on this day (no temp version to reconcile). Append.
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: [...existing, incoming],
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:updated': {
|
||||
const dayKey = String((payload.assignment as Assignment).day_id)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: (state.assignments[dayKey] || []).map(a =>
|
||||
a.id === (payload.assignment as Assignment).id ? { ...a, ...(payload.assignment as Assignment) } : a
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:deleted': {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: (state.assignments[dayKey] || []).filter(a => a.id !== payload.assignmentId),
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:moved': {
|
||||
const oldKey = String(payload.oldDayId)
|
||||
const newKey = String(payload.newDayId)
|
||||
const movedAssignment = payload.assignment as Assignment
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[oldKey]: (state.assignments[oldKey] || []).filter(a => a.id !== movedAssignment.id),
|
||||
[newKey]: [...(state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id), movedAssignment],
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:reordered': {
|
||||
const dayKey = String(payload.dayId)
|
||||
const currentItems = state.assignments[dayKey] || []
|
||||
const orderedIds: number[] = (payload.orderedIds as number[] | undefined) || []
|
||||
const reordered = orderedIds.map((id, idx) => {
|
||||
const item = currentItems.find(a => a.id === id)
|
||||
return item ? { ...item, order_index: idx } : null
|
||||
}).filter((item): item is Assignment => item !== null)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: reordered,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Days
|
||||
case 'day:created':
|
||||
if (state.days.some(d => d.id === (payload.day as Day).id)) return {}
|
||||
return { days: [...state.days, payload.day as Day] }
|
||||
case 'day:updated':
|
||||
return {
|
||||
days: state.days.map(d => d.id === (payload.day as Day).id ? payload.day as Day : d),
|
||||
}
|
||||
case 'day:deleted': {
|
||||
const removedDayId = String(payload.dayId)
|
||||
const newAssignments = { ...state.assignments }
|
||||
delete newAssignments[removedDayId]
|
||||
const newDayNotes = { ...state.dayNotes }
|
||||
delete newDayNotes[removedDayId]
|
||||
return {
|
||||
days: state.days.filter(d => d.id !== payload.dayId),
|
||||
assignments: newAssignments,
|
||||
dayNotes: newDayNotes,
|
||||
}
|
||||
}
|
||||
case 'day:reordered': {
|
||||
// Apply the new order instantly when we know all ids; the authoritative
|
||||
// dates + re-stamped booking times are pulled by the refresh below.
|
||||
const orderedIds = payload.orderedIds as number[] | undefined
|
||||
if (!orderedIds || orderedIds.length !== state.days.length) return {}
|
||||
const byId = new Map(state.days.map(d => [d.id, d]))
|
||||
if (!orderedIds.every(id => byId.has(id))) return {}
|
||||
return { days: orderedIds.map((id, i) => ({ ...byId.get(id)!, day_number: i + 1 })) }
|
||||
}
|
||||
|
||||
// Day Notes
|
||||
case 'dayNote:created': {
|
||||
const dayKey = String(payload.dayId)
|
||||
const existingNotes = (state.dayNotes[dayKey] || [])
|
||||
if (existingNotes.some(n => n.id === (payload.note as DayNote).id)) return {}
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: [...existingNotes, payload.note as DayNote],
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'dayNote:updated': {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: (state.dayNotes[dayKey] || []).map(n => n.id === (payload.note as DayNote).id ? payload.note as DayNote : n),
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'dayNote:deleted': {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: (state.dayNotes[dayKey] || []).filter(n => n.id !== payload.noteId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Packing
|
||||
case 'packing:created':
|
||||
if (state.packingItems.some(i => i.id === (payload.item as PackingItem).id)) return {}
|
||||
return { packingItems: [...state.packingItems, payload.item as PackingItem] }
|
||||
case 'packing:updated':
|
||||
return {
|
||||
packingItems: state.packingItems.map(i => i.id === (payload.item as PackingItem).id ? payload.item as PackingItem : i),
|
||||
}
|
||||
case 'packing:deleted':
|
||||
return {
|
||||
packingItems: state.packingItems.filter(i => i.id !== payload.itemId),
|
||||
}
|
||||
|
||||
// Todo
|
||||
case 'todo:created':
|
||||
if (state.todoItems.some(i => i.id === (payload.item as TodoItem).id)) return {}
|
||||
return { todoItems: [...state.todoItems, payload.item as TodoItem] }
|
||||
case 'todo:updated':
|
||||
return {
|
||||
todoItems: state.todoItems.map(i => i.id === (payload.item as TodoItem).id ? payload.item as TodoItem : i),
|
||||
}
|
||||
case 'todo:deleted':
|
||||
return {
|
||||
todoItems: state.todoItems.filter(i => i.id !== payload.itemId),
|
||||
}
|
||||
|
||||
// Budget
|
||||
case 'budget:created':
|
||||
if (state.budgetItems.some(i => i.id === (payload.item as BudgetItem).id)) return {}
|
||||
return { budgetItems: [...state.budgetItems, payload.item as BudgetItem] }
|
||||
case 'budget:updated':
|
||||
return {
|
||||
budgetItems: state.budgetItems.map(i => i.id === (payload.item as BudgetItem).id ? payload.item as BudgetItem : i),
|
||||
}
|
||||
case 'budget:deleted':
|
||||
return {
|
||||
budgetItems: state.budgetItems.filter(i => i.id !== payload.itemId),
|
||||
}
|
||||
case 'budget:members-updated':
|
||||
return {
|
||||
budgetItems: state.budgetItems.map(i =>
|
||||
i.id === payload.itemId ? { ...i, members: payload.members as BudgetItemMember[], persons: payload.persons as number } : i
|
||||
),
|
||||
}
|
||||
case 'budget:member-paid-updated':
|
||||
return {
|
||||
budgetItems: state.budgetItems.map(i =>
|
||||
i.id === payload.itemId
|
||||
// `paid` arrives over the wire as the raw value the server emits;
|
||||
// it's stored verbatim. The member type models it as a number, so
|
||||
// narrow without changing the value.
|
||||
? { ...i, members: (i.members || []).map(m => m.user_id === payload.userId ? { ...m, paid: payload.paid as number } : m) }
|
||||
: i
|
||||
),
|
||||
}
|
||||
case 'budget:reordered': {
|
||||
if (payload.orderedIds) {
|
||||
const orderedIds = payload.orderedIds as number[]
|
||||
const byId = new Map(state.budgetItems.map(i => [i.id, i]))
|
||||
const reordered = orderedIds.map((id, idx): BudgetItem | null => {
|
||||
const item = byId.get(id)
|
||||
return item ? { ...item, sort_order: idx } : null
|
||||
}).filter((i): i is BudgetItem => i !== null)
|
||||
const remaining = state.budgetItems.filter(i => !orderedIds.includes(i.id))
|
||||
return { budgetItems: [...reordered, ...remaining] }
|
||||
}
|
||||
if (payload.orderedCategories) {
|
||||
const orderedCategories = payload.orderedCategories as string[]
|
||||
const grouped = new Map<string, BudgetItem[]>()
|
||||
for (const item of state.budgetItems) {
|
||||
const cat = item.category || 'Other'
|
||||
if (!grouped.has(cat)) grouped.set(cat, [])
|
||||
grouped.get(cat)!.push(item)
|
||||
}
|
||||
const reordered: BudgetItem[] = []
|
||||
for (const cat of orderedCategories) {
|
||||
const items = grouped.get(cat)
|
||||
if (items) reordered.push(...items)
|
||||
}
|
||||
for (const [cat, items] of grouped) {
|
||||
if (!orderedCategories.includes(cat)) reordered.push(...items)
|
||||
}
|
||||
return { budgetItems: reordered }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// Reservations
|
||||
case 'reservation:created':
|
||||
if (state.reservations.some(r => r.id === (payload.reservation as Reservation).id)) return {}
|
||||
return { reservations: [payload.reservation as Reservation, ...state.reservations] }
|
||||
case 'reservation:updated':
|
||||
return {
|
||||
reservations: state.reservations.map(r => r.id === (payload.reservation as Reservation).id ? payload.reservation as Reservation : r),
|
||||
}
|
||||
case 'reservation:travelers-updated':
|
||||
return {
|
||||
reservations: state.reservations.map(r => r.id === (payload.reservationId as number)
|
||||
? { ...r, travelers: payload.travelers as Reservation['travelers'] } : r),
|
||||
}
|
||||
case 'reservation:deleted':
|
||||
return {
|
||||
reservations: state.reservations.filter(r => r.id !== payload.reservationId),
|
||||
}
|
||||
|
||||
// Trip
|
||||
case 'trip:updated':
|
||||
return { trip: payload.trip as Trip }
|
||||
|
||||
// Files
|
||||
case 'file:created':
|
||||
if (state.files.some(f => f.id === (payload.file as TripFile).id)) return {}
|
||||
return { files: [payload.file as TripFile, ...state.files] }
|
||||
case 'file:updated':
|
||||
return {
|
||||
files: state.files.map(f => f.id === (payload.file as TripFile).id ? payload.file as TripFile : f),
|
||||
}
|
||||
case 'file:deleted':
|
||||
return {
|
||||
files: state.files.filter(f => f.id !== payload.fileId),
|
||||
}
|
||||
|
||||
// Memories / Photos
|
||||
case 'memories:updated':
|
||||
window.dispatchEvent(new CustomEvent('memories:updated', { detail: payload }))
|
||||
return {}
|
||||
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
})
|
||||
set(state => STATE_APPLIERS[type as TrekWsTripEventName]?.(payload, state) ?? {})
|
||||
|
||||
// A reorder/insert re-pins dates and re-stamps booking times server-side, so
|
||||
// pull the authoritative days + reservations for collaborators.
|
||||
|
||||
+9
-2
@@ -6,6 +6,8 @@
|
||||
// each documented against the producing service). Re-exported here so the rest
|
||||
// of the client keeps importing from '../types' unchanged.
|
||||
import type {
|
||||
TrekWsEventName,
|
||||
TrekWsPluginEventName,
|
||||
Trip,
|
||||
TripMember,
|
||||
Day,
|
||||
@@ -256,9 +258,14 @@ export interface AppConfig {
|
||||
// Translation function type
|
||||
export type TranslationFn = (key: string, params?: Record<string, string | number | null>) => string
|
||||
|
||||
// WebSocket event type
|
||||
// WebSocket event type — `type` is derived from the shared WS event registry
|
||||
// (TREK_WS_EVENTS): a registered name, the reserved plugin namespace, or (via
|
||||
// the `string & {}` widening) a transport control frame the registry
|
||||
// deliberately excludes (welcome/joined/left/error). Payload fields stay
|
||||
// index-typed at this boundary; per-event payload contracts live in
|
||||
// TrekWsPayload<E> from @trek/shared.
|
||||
export interface WebSocketEvent {
|
||||
type: string
|
||||
type: TrekWsEventName | TrekWsPluginEventName | (string & {})
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TREK_WS_EVENT_NAMES, TREK_WS_TRIP_EVENT_NAMES } from '@trek/shared';
|
||||
import { DEXIE_WRITERS, STATE_APPLIERS } from '../../../src/store/slices/remoteEventHandler';
|
||||
import { HANDLED_OUTSIDE_TRIP_STORE, IGNORED_WS_EVENTS } from '../../../src/api/wsEventPolicy';
|
||||
|
||||
/**
|
||||
* The exhaustiveness ratchet (roadmap: WS event contract registry). Every
|
||||
* event in the shared registry must be classified on the client — applied by
|
||||
* the tripStore reducer, consumed by a dedicated listener, or explicitly
|
||||
* ignored. A server-side event added without a client decision turns this
|
||||
* test red instead of vanishing into the old silent `default:` branches.
|
||||
*/
|
||||
|
||||
const handledInStore = new Set<string>([
|
||||
...Object.keys(STATE_APPLIERS),
|
||||
...Object.keys(DEXIE_WRITERS),
|
||||
]);
|
||||
const handledElsewhere = new Set<string>(HANDLED_OUTSIDE_TRIP_STORE);
|
||||
const ignored = new Set<string>(IGNORED_WS_EVENTS);
|
||||
|
||||
describe('remoteEventHandler > registry parity', () => {
|
||||
it('FE-WSEVT-REGISTRY-001: every registry event is handled or explicitly ignored', () => {
|
||||
const unclassified = TREK_WS_EVENT_NAMES.filter(
|
||||
name => !handledInStore.has(name) && !handledElsewhere.has(name) && !ignored.has(name),
|
||||
);
|
||||
expect(
|
||||
unclassified,
|
||||
`New WS event(s) with no client decision: ${unclassified.join(', ')}. ` +
|
||||
'Handle them (remoteEventHandler / a dedicated listener) or add them to ' +
|
||||
'IGNORED_WS_EVENTS in src/api/wsEventPolicy.ts as a deliberate choice.',
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-REGISTRY-002: the three buckets are disjoint (exactly one decision per event)', () => {
|
||||
const doubleClassified = TREK_WS_EVENT_NAMES.filter(name => {
|
||||
const buckets =
|
||||
Number(handledInStore.has(name)) + Number(handledElsewhere.has(name)) + Number(ignored.has(name));
|
||||
return buckets > 1;
|
||||
});
|
||||
expect(
|
||||
doubleClassified,
|
||||
`Event(s) classified in more than one bucket: ${doubleClassified.join(', ')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-REGISTRY-003: no stale names — every classified event still exists in the registry', () => {
|
||||
const registry = new Set<string>(TREK_WS_EVENT_NAMES);
|
||||
const stale = [...handledInStore, ...handledElsewhere, ...ignored].filter(
|
||||
name => !registry.has(name),
|
||||
);
|
||||
expect(stale, `Name(s) not in the shared registry (renamed or removed?): ${stale.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-REGISTRY-004: the tripStore lookups only handle trip-scoped events', () => {
|
||||
const tripScoped = new Set<string>(TREK_WS_TRIP_EVENT_NAMES);
|
||||
const misScoped = [...handledInStore].filter(name => !tripScoped.has(name));
|
||||
expect(misScoped, `User-scoped event(s) in the tripStore lookups: ${misScoped.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-REGISTRY-005: every Dexie write-through event also has a state applier', () => {
|
||||
// writeToDexie reads the post-set() Zustand state, so a Dexie writer for an
|
||||
// event the reducer ignores would persist stale state.
|
||||
const writersWithoutApplier = Object.keys(DEXIE_WRITERS).filter(
|
||||
name => !(name in STATE_APPLIERS),
|
||||
);
|
||||
expect(writersWithoutApplier).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user