feat: integrate issued collaboration presence leases

Use server-issued presence handles, preserve drafts and unavailable states, and invalidate scoped realtime hints. Align the bundled collaboration 1.3 contract and retain focused/browser acceptance coverage. Carry forward current main CI and navigation test fixes.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
james.maesandCodex committed 2026-10-21 15:48:31 -05:00
1 parent 09828ab1cc
commit de5c1bd066
26 files changed
+4478 -3193

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.7.12
qqq-orb: kingsrook/qqq-orb@0.7.17
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: 0.7.12
orb_version: 0.7.17
filters:
branches:
ignore: /.*/
+2
View File
@@ -10,6 +10,8 @@
# next.js
/.next/
/.next-collaboration/
/.next-presence-live/
/out/
# production
+1 -1
View File
@@ -1,3 +1,3 @@
profile: frontend-next
orb_version: 0.7.12
orb_version: 0.7.17
default_branch: main
+2
View File
@@ -20,6 +20,8 @@ const BACKEND_PREFIXES: string[] = backendPrefixes
const nextConfig: NextConfig = {
reactStrictMode: true,
// Allow acceptance servers to run without sharing the developer server's lock/cache.
distDir: process.env.QQQ_NEXT_DIST_DIR || '.next',
// Reported to the backend as frontendVersion and shown on the developer page.
env: { NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version },
...(exportBuild
+1 -1
View File
@@ -34,7 +34,7 @@ export default defineConfig({
webServer: {
command: `pnpm dev --port ${port}`,
url: baseURL,
env: { NEXT_PUBLIC_MOCK_API: 'true' },
env: { NEXT_PUBLIC_MOCK_API: 'true', QQQ_NEXT_DIST_DIR: '.next-collaboration' },
reuseExistingServer: false,
timeout: 120_000,
},
+2 -4
View File
@@ -180,10 +180,8 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
// Sync user ID to QContext
useEffect(() => {
if (user?.email) {
setUserId(user.email)
}
}, [user?.email, setUserId])
setUserId(user?.id ?? user?.email)
}, [user?.id, user?.email, setUserId])
// Redirect to login if not authenticated
useEffect(() => {
@@ -167,6 +167,7 @@ export function CollaborationEdit({
return (
<div className="space-y-3">
<PresenceAvatars viewers={presence.viewers} />
{presence.unavailable && <span role="status">Presence temporarily unavailable</span>}
{conflict && (
<div
role="alert"
@@ -49,11 +49,9 @@ vi.mock('@/lib/hooks/use-metadata', () => ({
}))
vi.mock('@/lib/api/tables', () => ({
getRecord: vi.fn(),
queryRecords: vi
.fn()
.mockResolvedValue({
records: [{ tableName: 'person', values: { id: 1 }, recordLabel: 'Alice' }],
}),
queryRecords: vi.fn().mockResolvedValue({
records: [{ tableName: 'person', values: { id: 1 }, recordLabel: 'Alice' }],
}),
}))
vi.mock('@/lib/api/collaboration', () => ({
searchPeople: vi.fn(),
@@ -66,6 +64,7 @@ vi.mock('@/lib/api/collaboration', () => ({
createTask: vi.fn(),
patchTask: vi.fn(),
getMyWork: vi.fn(),
markMentionsRead: vi.fn().mockResolvedValue({ success: true }),
listMyTasks: vi.fn(),
}))
const record = { table: 'person', pk: '1' }
+18 -2
View File
@@ -17,11 +17,11 @@
/** @file Personal tasks, assigned record links and unread mentions. */
'use client'
import Link from 'next/link'
import { useQuery, useInfiniteQuery } from '@tanstack/react-query'
import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMetaData, useTableMetaData } from '@/lib/hooks/use-metadata'
import { useCollaborationAvailable } from '@/lib/hooks/use-collaboration-core'
import { useRealtimeChannel } from '@/lib/hooks/use-realtime-channel'
import { getMyWork, listMyTasks } from '@/lib/api/collaboration'
import { getMyWork, listMyTasks, markMentionsRead } from '@/lib/api/collaboration'
import { queryRecords } from '@/lib/api/tables'
import { queryKeys } from '@/lib/query-client'
import { collaborationButton } from './CollaborationDialog'
@@ -78,6 +78,11 @@ function AssignedTable({ name, label, count }: { name: string; label: string; co
* @returns Work page.
*/
export function MyWork() {
const cache = useQueryClient()
const read = useMutation({
mutationFn: markMentionsRead,
onSuccess: () => cache.invalidateQueries({ queryKey: queryKeys.collaborationMyWork() }),
})
const available = useCollaborationAvailable()
const metadata = useMetaData()
const work = useQuery({
@@ -158,6 +163,17 @@ export function MyWork() {
</section>
<section className="rounded-xl border border-border bg-card p-4">
<h2 className="text-lg font-semibold">Unread mentions</h2>
{!!work.data.unreadMentionCount && (
<button
type="button"
className={collaborationButton}
disabled={read.isPending}
onClick={() => read.mutate()}
>
Mark all mentions read
</button>
)}
{read.isError && <p role="alert">Could not mark mentions read.</p>}
<Link
href="/app/notifications?status=unread"
className="text-sm underline"
@@ -24,6 +24,8 @@ import { useCollaborationPresence } from '@/lib/hooks/use-collaboration-presence
import { useRealtimeChannel } from '@/lib/hooks/use-realtime-channel'
import { getRecordFeatures, putAssignment } from '@/lib/api/collaboration'
import { queryKeys } from '@/lib/query-client'
import { updateRecord } from '@/lib/api/tables'
import { recordVersion } from '@/lib/utils/record-version'
import { pluralize } from '@/lib/utils/string-utils'
import { PresenceAvatars } from './PresenceAvatars'
import { CollaborationDialog, collaborationButton } from './CollaborationDialog'
@@ -66,7 +68,23 @@ export function RecordCollaborationHeader({
enabled: available && !!features,
})
const assignment = useMutation({
mutationFn: () => putAssignment(target, { [role]: selected[0] ?? null }),
mutationFn: async () => {
const nativeField =
role === 'ownerUserId'
? features?.ownership?.ownerFieldName
: features?.ownership?.assigneeFieldName
if (nativeField) {
await updateRecord(
table.name,
pk,
{ [nativeField]: selected[0] ?? null },
undefined,
recordVersion(table, record)
)
} else {
await putAssignment(target, { [role]: selected[0] ?? null })
}
},
onSuccess: () => {
setAssign(false)
void cache.invalidateQueries({ queryKey: queryKeys.collaborationRecord(table.name, pk) })
@@ -89,6 +107,7 @@ export function RecordCollaborationHeader({
data-qqq-id="record-collaboration-header"
>
{presence.viewers.length > 0 && <PresenceAvatars viewers={presence.viewers} />}
{presence.unavailable && <span role="status">Presence temporarily unavailable</span>}
{(owner != null || assignee != null) && (
<dl className="flex flex-wrap items-center gap-x-3 gap-y-1">
{owner != null && (
@@ -151,11 +170,11 @@ export function RecordCollaborationHeader({
<PeoplePicker record={target} selected={selected} onChange={setSelected} requireRead />
<button
className={collaborationButton}
disabled={assignment.isPending || !selected.length}
disabled={assignment.isPending}
onClick={() => assignment.mutate()}
data-qqq-id="assignment-save"
>
Assign
{selected.length ? 'Assign' : 'Clear assignment'}
</button>
{assignment.isError && <p role="alert">{assignment.error.message}</p>}
</CollaborationDialog>
@@ -176,7 +195,11 @@ export function TablePresence({ table, available }: { table: QTableMetaData; ava
return (
<div className="flex flex-wrap items-center gap-2 text-sm" data-qqq-id="table-presence">
<PresenceAvatars viewers={presence.viewers} />
<span>{pluralize(presence.viewers.length, 'person', 'people')} viewing</span>
<span role={presence.unavailable ? 'status' : undefined}>
{presence.unavailable
? 'Presence temporarily unavailable'
: `${pluralize(presence.viewers.length, 'person', 'people')} viewing`}
</span>
</div>
)
}
+10 -8
View File
@@ -46,14 +46,16 @@ it('searches the directory with a bounded page and normalizes each person', asyn
expect(result.items[1].userId).toBeNull()
})
it('sends the last presence lease as a JSON beacon, and tolerates browsers without one', () => {
const sendBeacon = vi.fn()
Object.defineProperty(navigator, 'sendBeacon', { value: sendBeacon, configurable: true })
beaconPresence({ table: 'person', pk: '1', mode: 'VIEWING' } as never)
expect(sendBeacon).toHaveBeenCalledWith('/qqq/v1/qbit/collaboration/presence', expect.any(Blob))
expect((sendBeacon.mock.calls[0][1] as Blob).type).toBe('application/json')
Object.defineProperty(navigator, 'sendBeacon', { value: undefined, configurable: true })
expect(() => beaconPresence({ table: 'person', pk: '1', mode: 'VIEWING' } as never)).not.toThrow()
it('sends an ordered DELETE that survives page teardown and tolerates network failure', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response())
beaconPresence({ connectionId: 'tab', table: 'person', pk: '1', mode: 'VIEWING', sequence: 4 })
expect(fetch).toHaveBeenCalledWith('/qqq/v1/qbit/collaboration/presence', expect.objectContaining({
method: 'DELETE', credentials: 'include', keepalive: true,
body: JSON.stringify({ connectionId: 'tab', table: 'person', pk: '1', sequence: 4 }),
}))
fetch.mockRejectedValue(new Error('offline'))
expect(() => beaconPresence({ connectionId: 'tab', table: 'person', mode: 'VIEWING', sequence: 5 })).not.toThrow()
await Promise.resolve()
})
it('reads open personal tasks and rejects a task without an identifier', async () => {
+26 -7
View File
@@ -649,28 +649,47 @@ export async function listTimeline(record: CollaborationRecord, cursor?: string,
* @returns Current merged viewers.
*/
export const renewPresence = (input: PresenceRequest) =>
apiClient.post<PresenceSnapshot>(`${BASE}/presence`, input)
apiClient.post<PresenceSnapshot>(`${BASE}/presence/leases/renew`, input)
/** Reserves an invisible lease for activation by a visible surface.
* @param target - Authorized table or record.
* @returns Server-issued connection handle.
*/
export const openPresenceLease = (target: { table: string; pk?: string }) =>
apiClient.post<{ connectionId: string }>(`${BASE}/presence/leases`, target)
/** Leaves a target.
* @param input - Lease identity.
* @returns Acknowledgement.
*/
export const leavePresence = (input: PresenceTarget) =>
apiClient.delete(`${BASE}/presence`, { data: input })
/** Acknowledges only the caller's currently visible unread mentions.
* @returns Mutation acknowledgement.
*/
export const markMentionsRead = () => apiClient.post(`${BASE}/my-work/mentions/read`, {})
/** Reads current viewers.
* @param target - Target identity.
* @returns Snapshot.
*/
export const getPresence = (target: { table: string; pk?: string }) =>
apiClient.get<PresenceSnapshot>(`${BASE}/presence`, { params: target })
/** Sends the last heartbeat on pagehide. Beacon only supports POST; DELETE remains the
* unmount cleanup, and the server's 45-second TTL handles a closed document.
/** Sends a best-effort departure that survives page teardown; expiry covers network loss.
* @param input - Last presence lease.
*/
export function beaconPresence(input: PresenceRequest) {
navigator.sendBeacon?.(
`${process.env.NEXT_PUBLIC_API_BASE_URL || '/qqq/v1'}${BASE}/presence`,
new Blob([JSON.stringify(input)], { type: 'application/json' })
)
void fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL || '/qqq/v1'}${BASE}/presence`, {
method: 'DELETE',
credentials: 'include',
keepalive: true,
headers: { 'Content-Type': 'application/json', 'X-QQQ-Client': 'next-ui' },
body: JSON.stringify({
connectionId: input.connectionId,
table: input.table,
pk: input.pk,
sequence: input.sequence,
}),
}).catch(() => undefined)
}
/** Reads directory candidates.
* @param q - Search text.
File diff suppressed because it is too large. Load diff
+45 -12
View File
@@ -43,6 +43,16 @@ afterEach(() => {
})
describe('auth-storage', () => {
it('preserves the canonical identity independently of the display email', () => {
const user = userFromSessionValues({
user: { id: 'alice', name: 'Alice', email: 'alice@example.test' },
})
expect(user).toEqual({ id: 'alice', name: 'Alice', email: 'alice@example.test' })
storeUser(user!)
expect(getStoredUser()?.id).toBe('alice')
expect(userFromSessionValues({ user: { id: 'service-user' } })?.id).toBe('service-user')
})
it.each([null, 'true', 'false'])('preserves appearance preference %s on logout', (preference) => {
if (preference !== null) localStorage.setItem('qqq-dark-mode', preference)
clearUserClientData()
@@ -50,14 +60,22 @@ describe('auth-storage', () => {
})
it('reads the displayed user from manageSession values (QRun-IO/qqq#332)', () => {
expect(userFromSessionValues({ user: { name: 'Alice (sample)', email: 'sample:alice' } })).toEqual({ name: 'Alice (sample)', email: 'sample:alice' })
expect(
userFromSessionValues({ user: { name: 'Alice (sample)', email: 'sample:alice' } })
).toEqual({ name: 'Alice (sample)', email: 'sample:alice' })
expect(userFromSessionValues({ user: { name: '', email: 7 } })).toBeNull()
expect(userFromSessionValues(undefined)).toBeNull()
})
it('shows a TABLE_BASED username as the identity line (QRun-IO/qqq#700)', () => {
expect(userFromSessionValues({ user: { name: 'Tess Table', username: 'tess.table' } })).toEqual({ name: 'Tess Table', email: 'tess.table' })
expect(userFromSessionValues({ user: { name: 'Tess Table', email: 'tess@example.test', username: 'tess.table' } })).toEqual({ name: 'Tess Table', email: 'tess@example.test' })
expect(userFromSessionValues({ user: { name: 'Tess Table', username: 'tess.table' } })).toEqual(
{ name: 'Tess Table', email: 'tess.table' }
)
expect(
userFromSessionValues({
user: { name: 'Tess Table', email: 'tess@example.test', username: 'tess.table' },
})
).toEqual({ name: 'Tess Table', email: 'tess@example.test' })
})
it('persists and validates the stored user', () => {
@@ -70,7 +88,10 @@ describe('auth-storage', () => {
})
it('keeps the full session values for analytics identity until sign-out (QRun-IO/qqq#730)', () => {
const values = { user: { name: 'Alice (sample)', email: 'sample:alice' }, analyticsValues: { user_id: 'u-1' } }
const values = {
user: { name: 'Alice (sample)', email: 'sample:alice' },
analyticsValues: { user_id: 'u-1' },
}
storeSessionValues(values)
expect(getStoredSessionValues()).toEqual(values)
storeSessionValues(undefined)
@@ -109,15 +130,19 @@ describe('auth-storage', () => {
expect(localStorage.getItem('accessToken')).toBeNull()
})
it('clears every table\'s remembered filters and saved-view selection without resetting machine preferences (QRun-IO/qqq#1006)', () => {
it("clears every table's remembered filters and saved-view selection without resetting machine preferences (QRun-IO/qqq#1006)", () => {
for (const table of ['person', 'pet']) {
writeStoredQueryView(table, {
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
queryFilter: {
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
},
viewIdentity: 'empty',
})
writeCurrentSavedViewId(table, 7)
expect(readStoredQueryView(table)).toMatchObject({
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
queryFilter: {
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
},
})
expect(readCurrentSavedViewId(table)).toBe(7)
}
@@ -136,20 +161,25 @@ describe('auth-storage', () => {
expect(readStoredQueryView(table)).toBeNull()
expect(readCurrentSavedViewId(table)).toBeNull()
}
for (const [key, value] of Object.entries(preferences)) expect(localStorage.getItem(key)).toBe(value)
for (const [key, value] of Object.entries(preferences))
expect(localStorage.getItem(key)).toBe(value)
})
it('keeps remembered query state for the same identity and clears it before another identity claims the browser (QRun-IO/qqq#1006)', () => {
claimClientData({ name: 'Alice', email: 'alice@example.test' })
writeStoredQueryView('person', {
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
queryFilter: {
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
},
viewIdentity: 'savedView:7',
})
writeCurrentSavedViewId('person', 7)
claimClientData({ name: 'Alice', email: 'alice@example.test' })
expect(readStoredQueryView('person')).toMatchObject({
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
queryFilter: {
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
},
viewIdentity: 'savedView:7',
})
expect(readCurrentSavedViewId('person')).toBe(7)
@@ -160,9 +190,12 @@ describe('auth-storage', () => {
expect(localStorage.getItem('qqq.clientDataOwner')).toBe('bob@example.test')
})
it('clears the previous user\'s data when someone else signs in in this browser (QRun-IO/qqq#696)', () => {
it("clears the previous user's data when someone else signs in in this browser (QRun-IO/qqq#696)", () => {
claimClientData({ name: 'Alice', email: 'alice@example.com' })
localStorage.setItem('qqq-recent-records', JSON.stringify([{ path: '/app/pet/1', recordLabel: 'Alice private' }]))
localStorage.setItem(
'qqq-recent-records',
JSON.stringify([{ path: '/app/pet/1', recordLabel: 'Alice private' }])
)
storeUser({ name: 'Alice', email: 'alice@example.com' })
claimClientData({ name: 'Alice', email: 'alice@example.com' })
+19 -6
View File
@@ -31,6 +31,7 @@ const SESSION_HINT_KEY = 'qqq.sessionHint'
/** The displayed identity of the signed-in user. */
export interface StoredUser {
id?: string
name?: string
email?: string
}
@@ -102,16 +103,24 @@ export function setSessionHint(present: boolean): void {
* @param values - The session values for the frontend.
* @returns The user when it has a name or email.
*/
export function userFromSessionValues(values: Record<string, unknown> | undefined): StoredUser | null {
export function userFromSessionValues(
values: Record<string, unknown> | undefined
): StoredUser | null {
const user = values?.user
if (!user || typeof user !== 'object') return null
const { name, email, username } = user as Record<string, unknown>
const identity = typeof email === 'string' && email ? email : typeof username === 'string' && username ? username : undefined
const { id, name, email, username } = user as Record<string, unknown>
const identity =
typeof email === 'string' && email
? email
: typeof username === 'string' && username
? username
: undefined
const result: StoredUser = {
...(typeof id === 'string' && id ? { id } : {}),
name: typeof name === 'string' && name ? name : undefined,
email: identity,
}
return result.name || result.email ? result : null
return result.id || result.name || result.email ? result : null
}
/**
@@ -162,7 +171,9 @@ export function storeSessionValues(values: Record<string, unknown> | undefined):
export function getStoredSessionValues(): Record<string, unknown> | null {
try {
const parsed = JSON.parse(storage('local')?.getItem(SESSION_VALUES_KEY) ?? 'null') as unknown
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null
} catch {
return null
}
@@ -179,7 +190,9 @@ export function recordReauthAttempt(now = Date.now()): boolean {
const session = storage('session')
let attempts: number[] = []
try {
attempts = (JSON.parse(session?.getItem(REAUTH_KEY) ?? '[]') as number[]).filter((time) => now - time < 60_000)
attempts = (JSON.parse(session?.getItem(REAUTH_KEY) ?? '[]') as number[]).filter(
(time) => now - time < 60_000
)
} catch {
attempts = []
}
@@ -26,6 +26,7 @@ vi.mock('@/lib/context/q-context', async () => {
})
vi.mock('./use-realtime-channel', () => ({ useRealtimeChannel: vi.fn() }))
vi.mock('@/lib/api/collaboration', () => ({
openPresenceLease: vi.fn(),
renewPresence: vi.fn(),
leavePresence: vi.fn(),
getPresence: vi.fn(),
@@ -33,6 +34,10 @@ vi.mock('@/lib/api/collaboration', () => ({
}))
beforeEach(() => {
vi.useFakeTimers()
let nextLease = 0
vi.mocked(api.openPresenceLease).mockImplementation(async () => ({
connectionId: `lease:${++nextLease}`,
}))
vi.mocked(api.getPresence).mockResolvedValue({ table: 'person', viewers: [] })
vi.mocked(api.renewPresence).mockResolvedValue({ table: 'person', viewers: [] })
vi.mocked(api.leavePresence).mockResolvedValue({})
@@ -50,12 +55,15 @@ it('renews on mount and focus changes, every 20 seconds; beacons and leaves its
wrapper,
initialProps: { field: 'firstName' },
})
await act(() => vi.advanceTimersByTimeAsync(0))
expect(api.renewPresence).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'EDITING', fieldName: 'firstName' })
)
await act(() => vi.advanceTimersByTimeAsync(20000))
expect(api.renewPresence).toHaveBeenCalledTimes(2)
expect(vi.mocked(api.renewPresence).mock.calls.map(([input]) => input.sequence)).toEqual([1, 2])
hook.rerender({ field: 'lastName' })
expect(api.openPresenceLease).toHaveBeenCalledTimes(1)
expect(api.renewPresence).toHaveBeenLastCalledWith(
expect.objectContaining({ fieldName: 'lastName' })
)
@@ -64,9 +72,9 @@ it('renews on mount and focus changes, every 20 seconds; beacons and leaves its
expect.objectContaining({ fieldName: 'lastName' })
)
hook.unmount()
expect(api.leavePresence).toHaveBeenCalledTimes(1)
expect(api.leavePresence).toHaveBeenCalledWith(
expect.objectContaining({ table: 'person', pk: '1' })
expect(api.leavePresence).not.toHaveBeenCalled()
expect(api.beaconPresence).toHaveBeenCalledWith(
expect.objectContaining({ table: 'person', pk: '1', sequence: 4 })
)
})
it('merges tabs and excludes my own tabs, preferring editors', () => {
@@ -81,3 +89,155 @@ it('merges tabs and excludes my own tabs, preferring editors', () => {
)
).toEqual([{ userId: 'sam', displayName: 'Sam', mode: 'EDITING', fieldName: 'name' }])
})
it('reports failed renewals even when reads succeed, and recovers on a successful renewal', async () => {
vi.mocked(api.renewPresence).mockRejectedValueOnce(new Error('Unavailable'))
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
await act(() => vi.advanceTimersByTimeAsync(10))
expect(hook.result.current.unavailable).toBe(true)
expect(hook.result.current.viewers).toEqual([])
await act(() => vi.advanceTimersByTimeAsync(20000))
expect(hook.result.current.unavailable).toBe(false)
expect(api.openPresenceLease).toHaveBeenCalledTimes(1)
hook.unmount()
})
it('ignores a failed renewal from the previous record', async () => {
let rejectOld: (reason: Error) => void = () => undefined
vi.mocked(api.renewPresence).mockReturnValueOnce(
new Promise((_, reject) => {
rejectOld = reject
})
)
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(({ pk }) => useCollaborationPresence('person', pk), {
wrapper,
initialProps: { pk: '1' },
})
await act(() => vi.advanceTimersByTimeAsync(0))
hook.rerender({ pk: '2' })
await act(() => vi.advanceTimersByTimeAsync(10))
await act(async () => {
rejectOld(new Error('Old request failed'))
})
expect(hook.result.current.unavailable).toBe(false)
hook.unmount()
})
it('releases a late reservation without activating a closed surface', async () => {
let resolveOpen!: (value: { connectionId: string }) => void
vi.mocked(api.openPresenceLease).mockReturnValueOnce(
new Promise((resolve) => {
resolveOpen = resolve
})
)
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
hook.unmount()
await act(async () => {
resolveOpen({ connectionId: 'lease:late' })
})
expect(api.renewPresence).not.toHaveBeenCalled()
expect(api.leavePresence).toHaveBeenCalledWith({
table: 'person',
pk: '1',
connectionId: 'lease:late',
sequence: 1,
})
})
it('rejoins once with a new handle after an explicit lease expiry', async () => {
vi.mocked(api.renewPresence).mockRejectedValueOnce({
isAxiosError: true,
response: { status: 409, data: { code: 'PRESENCE_LEASE_EXPIRED' } },
})
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
await act(() => vi.advanceTimersByTimeAsync(0))
expect(api.openPresenceLease).toHaveBeenCalledTimes(2)
expect(
vi.mocked(api.renewPresence).mock.calls.map(([input]) => [input.connectionId, input.sequence])
).toEqual([
['lease:1', 1],
['lease:2', 1],
])
expect(hook.result.current.unavailable).toBe(false)
hook.unmount()
})
it('opens a fresh lease on pageshow instead of reviving the departed handle', async () => {
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
await act(() => vi.advanceTimersByTimeAsync(0))
act(() => window.dispatchEvent(new Event('pagehide')))
act(() => window.dispatchEvent(new Event('pageshow')))
await act(() => vi.advanceTimersByTimeAsync(0))
expect(api.openPresenceLease).toHaveBeenCalledTimes(2)
expect(api.renewPresence).toHaveBeenLastCalledWith(
expect.objectContaining({ connectionId: 'lease:2', sequence: 1 })
)
hook.unmount()
})
it('bounds immediate rejoin attempts when replacement leases also expire', async () => {
vi.mocked(api.renewPresence).mockRejectedValue({
isAxiosError: true,
response: { status: 409, data: { code: 'PRESENCE_LEASE_EXPIRED' } },
})
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
await act(() => vi.advanceTimersByTimeAsync(0))
expect(api.openPresenceLease).toHaveBeenCalledTimes(2)
expect(hook.result.current.unavailable).toBe(true)
hook.unmount()
})
it('discards an old pending open after hide and show while preserving the new handle', async () => {
let resolveOld!: (value: { connectionId: string }) => void
vi.mocked(api.openPresenceLease).mockReturnValueOnce(
new Promise((resolve) => {
resolveOld = resolve
})
)
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
act(() => window.dispatchEvent(new Event('pagehide')))
act(() => window.dispatchEvent(new Event('pageshow')))
await act(() => vi.advanceTimersByTimeAsync(0))
await act(async () => {
resolveOld({ connectionId: 'lease:old' })
})
expect(api.renewPresence).toHaveBeenCalledTimes(1)
expect(api.renewPresence).toHaveBeenLastCalledWith(
expect.objectContaining({ connectionId: 'lease:1', sequence: 1 })
)
expect(api.leavePresence).toHaveBeenCalledWith(
expect.objectContaining({ connectionId: 'lease:old', sequence: 1 })
)
hook.unmount()
expect(api.leavePresence).toHaveBeenLastCalledWith(
expect.objectContaining({ connectionId: 'lease:1', sequence: 2 })
)
})
+121 -26
View File
@@ -16,10 +16,17 @@
/** @file Ephemeral presence leases with focus changes, polling fallback and realtime refresh. */
'use client'
import { useContext, useEffect, useState } from 'react'
import { useContext, useEffect, useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { isAxiosError } from 'axios'
import type { PresenceSnapshot, PresenceViewer } from '@/types/collaboration'
import { renewPresence, leavePresence, getPresence, beaconPresence } from '@/lib/api/collaboration'
import {
openPresenceLease,
renewPresence,
leavePresence,
getPresence,
beaconPresence,
} from '@/lib/api/collaboration'
import { queryKeys } from '@/lib/query-client'
import { QContext } from '@/lib/context/q-context'
import { useRealtimeChannel } from './use-realtime-channel'
@@ -51,8 +58,11 @@ export function useCollaborationPresence(
fieldName?: string,
enabled = true
) {
const [connectionId] = useState(() => crypto.randomUUID())
const focus = useRef({ editing, fieldName })
focus.current = { editing, fieldName }
const refresh = useRef<() => void>(() => undefined)
const [ended, setEnded] = useState(false)
const [renewalFailed, setRenewalFailed] = useState(false)
const userId = useContext(QContext)?.userId
const cache = useQueryClient()
const key = queryKeys.collaborationResource(table, pk ?? '', 'presence')
@@ -65,7 +75,14 @@ export function useCollaborationPresence(
useRealtimeChannel(
pk ? `record:${table}:${pk}` : `table:${table}`,
({ event, data }) => {
if (event === 'session-ended') setEnded(true)
if (
event === 'session-ended' ||
(event === 'channel-revoked' &&
data.channel === (pk ? `record:${table}:${pk}` : `table:${table}`))
) {
setEnded(true)
cache.setQueryData(key, { table, pk, viewers: [] })
}
if (
event === 'presence.changed' &&
data.table === table &&
@@ -78,34 +95,112 @@ export function useCollaborationPresence(
)
useEffect(() => {
if (!enabled || ended) return
const input = {
connectionId,
table,
pk,
mode: editing ? ('EDITING' as const) : ('VIEWING' as const),
fieldName,
}
const heartbeat = () => {
void renewPresence(input)
.then((snapshot) =>
let active = true
let visible = document.visibilityState !== 'hidden'
let connectionId: string | undefined
let sequence = 0
let generation = 0
let opening = false
const heartbeat = async (rejoining = false) => {
if (!active || !visible || opening) return
const requestGeneration = generation
let requestSequence = sequence
try {
if (!connectionId) {
opening = true
const lease = await openPresenceLease({ table, pk })
if (!active || !visible || generation !== requestGeneration) {
void leavePresence({ table, pk, connectionId: lease.connectionId, sequence: 1 }).catch(
() => undefined
)
return
}
opening = false
connectionId = lease.connectionId
sequence = 0
}
requestSequence = ++sequence
const snapshot = await renewPresence({
table,
pk,
connectionId,
sequence: requestSequence,
mode: focus.current.editing ? 'EDITING' : 'VIEWING',
fieldName: focus.current.fieldName,
})
if (active && visible && generation === requestGeneration && sequence === requestSequence) {
setRenewalFailed(false)
cache.setQueryData(queryKeys.collaborationResource(table, pk ?? '', 'presence'), snapshot)
)
.catch(() => undefined)
}
} catch (error) {
if (!active || !visible || generation !== requestGeneration || sequence !== requestSequence)
return
opening = false
setRenewalFailed(true)
if (
isAxiosError(error) &&
error.response?.status === 409 &&
error.response.data?.code === 'PRESENCE_LEASE_EXPIRED'
) {
connectionId = undefined
generation++
if (!rejoining) void heartbeat(true)
}
}
}
heartbeat()
const timer = setInterval(heartbeat, 20000)
const hide = () => beaconPresence(input)
refresh.current = () => {
void heartbeat()
}
void heartbeat()
const timer = setInterval(() => {
void heartbeat()
}, 20000)
const release = (beacon: boolean) => {
generation++
opening = false
const previous = connectionId
connectionId = undefined
if (!previous) return
const input = { table, pk, connectionId: previous, sequence: ++sequence }
if (beacon)
beaconPresence({
...input,
mode: focus.current.editing ? 'EDITING' : 'VIEWING',
fieldName: focus.current.fieldName,
})
else void leavePresence(input).catch(() => undefined)
}
const hide = () => {
visible = false
release(true)
}
const show = () => {
visible = document.visibilityState !== 'hidden'
void heartbeat()
}
const visibility = () => (document.visibilityState === 'hidden' ? hide() : show())
window.addEventListener('pagehide', hide)
window.addEventListener('pageshow', show)
document.addEventListener('visibilitychange', visibility)
return () => {
active = false
release(false)
refresh.current = () => undefined
clearInterval(timer)
window.removeEventListener('pagehide', hide)
window.removeEventListener('pageshow', show)
document.removeEventListener('visibilitychange', visibility)
}
}, [connectionId, table, pk, editing, fieldName, enabled, ended, cache])
}, [table, pk, enabled, ended, cache])
useEffect(() => {
if (!enabled || ended) return
return () => {
void leavePresence({ connectionId, table, pk }).catch(() => undefined)
}
}, [connectionId, table, pk, enabled, ended])
return { ...query, viewers: otherViewers(query.data?.viewers ?? [], userId) }
refresh.current()
}, [editing, fieldName])
return {
...query,
unavailable: enabled && !ended && (query.isError || renewalFailed),
viewers:
enabled && !ended && !query.isError && !renewalFailed
? otherViewers(query.data?.viewers ?? [], userId)
: [],
}
}
+17
View File
@@ -122,6 +122,23 @@ it('invalidates records, panel counts, tasks, notifications and resync queries',
invalidateRealtime(cache, { event: 'resync', data: {} })
expect(invalidate).toHaveBeenLastCalledWith({ queryKey: queryKeys.all() })
})
it('presence hints invalidate only their record or table room without trusting viewer payloads', () => {
const cache = new QueryClient()
const invalidate = vi.spyOn(cache, 'invalidateQueries')
const recordKey = queryKeys.collaborationResource('person', '1', 'presence')
cache.setQueryData(recordKey, { viewers: [] })
invalidateRealtime(cache, {
event: 'presence.invalidated',
data: { table: 'person', pk: 1, viewers: [{ userId: 'untrusted' }] },
})
expect(invalidate).toHaveBeenCalledExactlyOnceWith({ queryKey: recordKey })
expect(cache.getQueryData(recordKey)).toEqual({ viewers: [] })
invalidate.mockClear()
invalidateRealtime(cache, { event: 'presence.invalidated', data: { table: 'person' } })
expect(invalidate).toHaveBeenCalledExactlyOnceWith({
queryKey: queryKeys.collaborationResource('person', '', 'presence'),
})
})
class Bus {
static peers = new Set<Bus>()
onmessage?: (event: MessageEvent) => void
+5
View File
@@ -41,6 +41,11 @@ export function invalidateRealtime(client: QueryClient, { event, data }: Realtim
}
if (event === 'task.changed') invalidate(queryKeys.collaborationMyWork())
if (typeof data.table !== 'string') return
if (event === 'presence.invalidated') {
// Hints contain no viewer state; reread through the authenticated presence API.
invalidate(queryKeys.collaborationResource(data.table, String(data.pk ?? ''), 'presence'))
return
}
if (event === 'record.changed' || event === 'record.deleted' || event === 'table.changed')
invalidate(queryKeys.tableRecords(data.table))
if (data.pk === undefined || data.pk === null) return
+27 -4
View File
@@ -601,6 +601,8 @@ const viewers = [
{ userId: 'sam', displayName: 'Sam Parker', mode: 'EDITING', fieldName: 'firstName' },
{ userId: 'alex', displayName: 'Alex Rivera', mode: 'VIEWING' },
]
// Demo peers are fixed fixtures; only the current browser's issued handles are tracked.
const presenceLeases = new Map<string, { table: string; pk?: string }>()
const taskStore: import('@/types/collaboration').CollaborationTaskV1[] = [
{
id: 8001,
@@ -671,11 +673,28 @@ collaborationHandlers.push(
const params = new URL(request.url).searchParams
return HttpResponse.json({ table: params.get('table'), pk: params.get('pk'), viewers })
}),
http.post(`${BASE}/presence`, async ({ request }) => {
const input = (await request.json()) as Record<string, unknown>
http.post(`${BASE}/presence/leases`, async ({ request }) => {
const target = (await request.json()) as { table: string; pk?: string }
const connectionId = `lease:${crypto.randomUUID()}`
presenceLeases.set(connectionId, target)
return HttpResponse.json({ connectionId })
}),
http.post(`${BASE}/presence/leases/renew`, async ({ request }) => {
const input = (await request.json()) as {
table: string
pk?: string
connectionId: string
}
const target = presenceLeases.get(input.connectionId)
if (!target || target.table !== input.table || target.pk !== input.pk)
return HttpResponse.json({ code: 'PRESENCE_LEASE_EXPIRED' }, { status: 409 })
return HttpResponse.json({ table: input.table, pk: input.pk, viewers })
}),
http.delete(`${BASE}/presence`, () => HttpResponse.json({ success: true })),
http.delete(`${BASE}/presence`, async ({ request }) => {
const input = (await request.json()) as { connectionId: string }
presenceLeases.delete(input.connectionId)
return HttpResponse.json({ success: true })
}),
http.get(`${BASE}/people`, ({ request }) => {
const q = new URL(request.url).searchParams.get('q')?.toLowerCase() ?? ''
return HttpResponse.json({
@@ -770,7 +789,11 @@ collaborationHandlers.push(
completedByDisplayName: nameOf('me'),
})
if (input.status === 'OPEN')
Object.assign(task, { completedAt: null, completedByUserId: null, completedByDisplayName: null })
Object.assign(task, {
completedAt: null,
completedByUserId: null,
completedByDisplayName: null,
})
publishCollaborationEvent('task.changed', {
table: task.tableName,
pk: task.recordId,
+1
View File
@@ -597,6 +597,7 @@ export interface PresenceViewer {
}
/** Presence target and client lease identity. */
export interface PresenceTarget {
sequence?: number
connectionId: string
table: string
pk?: string
@@ -136,10 +136,12 @@ test.describe('command palette and search', () => {
await page.locator('[data-qqq-id="button-search"]').click()
const dialog = page.getByRole('dialog', { name: 'Command palette' })
await dialog.getByRole('combobox').fill('clone')
const clone = dialog.getByRole('option', { name: /Clone People/ })
// Help arrives asynchronously and can share the process label; it is not a local page match.
await expect(dialog.locator('[data-qqq-id="help-result-process:clonePeople;slot:help"]')).toBeVisible()
const clone = dialog.locator('[data-qqq-id="command-item-clonePeople"]')
await expect(clone).toHaveCount(1)
await expect(clone).toContainText('People App')
await expect(dialog.getByRole('option')).toHaveCount(2)
await expect(dialog.locator('[data-qqq-id^="command-item-"]')).toHaveCount(1)
await expect(dialog.locator('[data-qqq-id="command-search-all"]')).toContainText('Search everything for “clone”')
await expectTouchTargets(dialog)
await expect(clone).toHaveAttribute('aria-selected', 'true')
@@ -7,6 +7,7 @@
/** @file Exercises the real variant lifecycle with only process and filesystem boundaries replaced. */
import { EventEmitter } from 'node:events'
import path from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
const state = vi.hoisted(() => ({
@@ -39,7 +40,7 @@ vi.mock('node:child_process', () => {
// Existing stale export and build files cannot determine the browser's selected server.
vi.mock('node:fs', async (original) => {
const fs = {
...await original<typeof import('node:fs')>(), existsSync: (file: string) => file.endsWith('next-dashboard/index.html') ? state.hasExport : true, mkdirSync: () => {}, rmSync: () => {},
...await original<typeof import('node:fs')>(), existsSync: (file: string) => file.endsWith(path.join('next-dashboard', 'index.html')) ? state.hasExport : true, mkdirSync: () => {}, rmSync: () => {},
readdirSync: () => ['Fixture.java'],
readFileSync: (file: string) => file.endsWith('routes-manifest.json')
? JSON.stringify({ rewrites: { beforeFiles: [], afterFiles: ['qqq', 'data', 'widget', 'metaData', 'download', 'processes', 'possibleValues', 'reports', 'manageSession', 'apis.json', 'api', 'qa'].map((prefix) => ({ source: `/${prefix}/:path*`, destination: `http://127.0.0.1:${state.wrongTarget ? '18765' : '18775'}/${prefix}/:path*` })), fallback: [] } })
@@ -19,9 +19,25 @@ import path from 'node:path'
import { test, expect, type Page } from '@playwright/test'
async function openRecord(page: Page) {
const opened = page.waitForResponse(
(r) => r.url().endsWith('/presence/leases') && r.request().method() === 'POST'
)
const renewed = page.waitForResponse(
(r) => r.url().endsWith('/presence/leases/renew') && r.request().method() === 'POST'
)
await page.goto('/app/person/1')
expect((await opened).status()).toBe(200)
const renewal = await renewed
expect(renewal.status()).toBe(200)
expect(renewal.request().postDataJSON()).toMatchObject({
table: 'person',
pk: '1',
mode: 'VIEWING',
connectionId: expect.stringMatching(/^lease:/),
})
await expect(page.locator('[data-qqq-id="record-view-title-person"]')).toHaveText('Alice Johnson')
await expect(page.locator('[data-qqq-id="presence-sam"]')).toBeVisible()
await expect(page.getByText('Presence temporarily unavailable', { exact: true })).toHaveCount(0)
}
async function concurrentEdit(page: Page) {
await page.evaluate(async () => {
+5 -3
View File
@@ -49,10 +49,12 @@ it('keeps the variant build, static assets and entrypoint after the main build d
expect(readFileSync(path.join(staged, 'public/icon.svg'), 'utf8')).toBe('<svg/>')
expect(readFileSync(path.join(staged, 'qqq-server.mjs'), 'utf8')).toBe('container entrypoint')
expect(readFileSync(path.join(staged, 'branding-assets.mjs'), 'utf8')).toBe('declared branding aliases')
expect(readlinkSync(path.join(staged, 'node_modules/example'))).toBe('.pnpm/example')
expect(readlinkSync(path.join(staged, 'node_modules/example'))).toBe(path.join('.pnpm', 'example'))
expect(readFileSync(path.join(staged, 'node_modules/example/index.js'), 'utf8')).toBe('independent dependency')
expect(statSync(path.join(staged, 'server.js')).mode & 0o777).toBe(0o600)
expect(statSync(path.join(staged, '.next/static')).mode & 0o777).toBe(0o700)
if (process.platform !== 'win32') {
expect(statSync(path.join(staged, 'server.js')).mode & 0o777).toBe(0o600)
expect(statSync(path.join(staged, '.next/static')).mode & 0o777).toBe(0o700)
}
})
it('rejects an old shared-backend rewrite even with a new valid variant marker', () => {
+5 -1
View File
@@ -37,7 +37,11 @@
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
".next/dev/types/**/*.ts",
".next-collaboration/types/**/*.ts",
".next-collaboration/dev/types/**/*.ts",
".next-presence-live/types/**/*.ts",
".next-presence-live/dev/types/**/*.ts"
],
"exclude": [
"node_modules"