Add realtime sync#85
Conversation
31b7d78 to
c7cc128
Compare
| const isSaving = useStore(state => state.gameSave.isSaving); | ||
| const isSyncConnected = | ||
| useStore(state => state.gameSave.isRealtimeSyncConnected) || | ||
| import.meta.env.DEV; |
| function scheduleAutoSave() { | ||
| if (autoSaveTimer !== null) clearTimeout(autoSaveTimer); | ||
| autoSaveTimer = setTimeout(() => { | ||
| autoSaveTimer = null; | ||
| saveRemoteGame(gameId, { silent: true }).catch(err => | ||
| logger.error('Auto-save failed', err), | ||
| ); | ||
| }, AUTO_SAVE_DEBOUNCE_MS); | ||
| } |
There was a problem hiding this comment.
This should be managed by a "lock" mechanism, elseway we're going to make multiple users save the same game on DB at the same time; I was thinking about a Leader-election mechanism so that only the first of the connected users (we can use Supabase realtime presence API I think to handle this recognition) would be responsible of saving the game. This way it's easier to model the peer-to-peer connection (it would be a coordinated primary/secondary election)
| if (status === 'SUBSCRIBED') { | ||
| channel.send({ | ||
| type: 'broadcast', | ||
| event: BROADCAST_FULL_REQUEST, | ||
| payload: { senderId: SENDER_ID } satisfies FullStateRequestPayload, | ||
| }); | ||
|
|
||
| dbFallbackTimer = setTimeout(() => { | ||
| dbFallbackTimer = null; | ||
| logger.info('No peer response, loading from database'); | ||
| isApplyingRemoteRef.current = true; | ||
| loadRemoteGame(gameId, { override: true }).finally(() => { | ||
| isApplyingRemoteRef.current = false; | ||
| }); | ||
| }, DB_FALLBACK_MS); | ||
| } | ||
| }); |
There was a problem hiding this comment.
This removes local-only changes which have not been saved if no-one reply (even if technically there is no one else at all). We should check local updatedAt maybe?
Not sure on how to handle this but we'd lose previously un-uploaded edits if this is not handled
| dbFallbackTimer = setTimeout(async () => { | ||
| dbFallbackTimer = null; | ||
| logger.info( | ||
| 'No peer response, saving local state then loading from database', | ||
| ); | ||
| try { | ||
| await saveRemoteGame(gameId, { silent: true }); | ||
| } catch (err) { | ||
| logger.error('Pre-fallback save failed', err); | ||
| } | ||
| isApplyingRemoteRef.current = true; | ||
| loadRemoteGame(gameId, { override: true }).finally(() => { | ||
| isApplyingRemoteRef.current = false; | ||
| }); |
There was a problem hiding this comment.
This sounds strange; if we're saving it, why are we loading it right after?
I think we're going in the right direction, but we should define a pattern like:
- we are falling back to DB state, we need to fetch it and compare the
updated_at - if the
updated_atis more recent than our, we **load it ** - elseway (if it's the same or we've got a more recent version, not sure on how to check this exactly) we save it
what do you think?
…r db saves. Resolves: #86
Summary
Ports the patch-based realtime sync system from dev to mainline. This enables cross-device live synchronization of game state via Supabase Realtime broadcast channels.
How it works
Immer patches as the sync primitive: Every Zustand state mutation now goes through
produceWithPatches, which emits fine-grained Immer patches. Only patches touching game-related slices (games,factories,solvers) are broadcast — unrelated store changes (auth, UI state) are ignored, preventing the feedback loops that plagued the earlier full-state approach.Debounced broadcasting: Outgoing patches are batched over 150ms before being sent, reducing network chatter during rapid edits.
Sequence numbers for ordering: Each broadcast carries an incrementing sequence number. If a receiver detects a gap (missed patches), it requests a full state snapshot from peers rather than applying stale data.
Full-state fallback: On channel join, each client requests a full state from any connected peer. If no peer responds within 3 seconds, it falls back to loading from the Supabase database — so reconnecting after all peers have gone offline still works.
Silent auto-save: Local changes trigger a debounced (5s) auto-save to the database. The
silentflag prevents the save button spinner from firing during these background saves. On unmount, any pending auto-save flushes immediately.updatedAttracking: TheGamemodel now carriesupdatedAtfrom the database, persisted through load/save cycles but excluded fromserializeGameso it doesn't affect content-based change detection.Sync status indicator: A green dot appears on the game menu when the realtime channel is connected (always visible in dev mode).
Resolves #69