diff --git a/src/room-presence-store.ts b/src/room-presence-store.ts index 2875bffc..96a01fa3 100644 --- a/src/room-presence-store.ts +++ b/src/room-presence-store.ts @@ -96,12 +96,12 @@ export function initRoomPresenceStore(): boolean { auth: { persistSession: false, autoRefreshToken: false }, }) + const nodePresenceKey = `node:${hostId}` const channel = state.client.channel(`room:${hostId}`, { - // Listening as a service-role client — node never publishes its own - // presence track. The presence key is required by Supabase even for - // listen-only subscribers; we use a stable node sentinel that won't - // collide with browser session ids (which are random uuids). - config: { presence: { key: `node:${hostId}` } }, + // Node must JOIN the presence channel to receive sync state. We still + // publish only a non-human sentinel payload so browsers/agents never + // mistake the listener for a participant. + config: { presence: { key: nodePresenceKey } }, }) const recompute = () => { @@ -144,6 +144,14 @@ export function initRoomPresenceStore(): boolean { .subscribe((status) => { if (status === 'SUBSCRIBED') { console.log(`[room-presence] subscribed to room:${hostId}`) + void channel.track({ + kind: 'listener', + id: nodePresenceKey, + hostId, + joinedAt: Date.now(), + }).catch((err) => { + console.warn(`[room-presence] sentinel track failed for room:${hostId}:`, err) + }) } else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') { console.warn(`[room-presence] channel ${status} for room:${hostId}`) } diff --git a/tests/room-presence-subscribe.test.ts b/tests/room-presence-subscribe.test.ts new file mode 100644 index 00000000..bc8b4393 --- /dev/null +++ b/tests/room-presence-subscribe.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +describe('room-presence-store subscribe behavior', () => { + const originalEnv = { ...process.env } + + afterEach(async () => { + process.env = { ...originalEnv } + vi.resetModules() + vi.restoreAllMocks() + }) + + it('tracks a non-human node sentinel after subscribe so presence sync can populate', async () => { + const track = vi.fn().mockResolvedValue('ok') + const unsubscribe = vi.fn().mockResolvedValue('ok') + const removeChannel = vi.fn().mockResolvedValue('ok') + let subscribeHandler: ((status: string) => void) | null = null + + const channel: any = { + on: vi.fn().mockReturnThis(), + subscribe: vi.fn((handler: (status: string) => void) => { + subscribeHandler = handler + return channel + }), + track, + presenceState: vi.fn(() => ({})), + unsubscribe, + } + + vi.doMock('@supabase/supabase-js', () => ({ + createClient: vi.fn(() => ({ + channel: vi.fn(() => channel), + removeChannel, + })), + })) + + process.env.SUPABASE_URL = 'https://example.supabase.co' + process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role' + process.env.REFLECTT_HOST_ID = 'host-123' + + const mod = await import('../src/room-presence-store.js') + expect(mod.initRoomPresenceStore()).toBe(true) + expect(track).not.toHaveBeenCalled() + + expect(subscribeHandler).toBeTypeOf('function') + subscribeHandler?.('SUBSCRIBED') + await Promise.resolve() + + expect(track).toHaveBeenCalledWith(expect.objectContaining({ + kind: 'listener', + id: 'node:host-123', + hostId: 'host-123', + })) + + await mod.shutdownRoomPresenceStore() + expect(unsubscribe).toHaveBeenCalled() + expect(removeChannel).toHaveBeenCalledWith(channel) + }) +})