mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Brings in the automatic host-side PR read sharing (#42033) and finishes the auto-refresh setting on top of it, so one owner per resource decides when PRs and checks are read again: - The setting now stretches the host's own interval, including the five-minute quiet interval for settled open PRs (Default 5, Reduced 25, Minimal 50 min, Off none), both for paired clients' timer reads and this host's own queue. - The earlier duplicate PR answer reuse for paired clients is removed; the shared host result owner now serves those reads. - Switching cadence re-times errors, their shared retry time, host gates and queued host reads from when the read failed; Off drops them without pinning anything forever, and Default restores the original retry time. - Window return reads data older than the Default interval in every mode and never sooner, so Off no longer re-reads no-PR or closed rows on each return. - Checks keep their own per-head result, now recorded by the actual checks read in start order, so a manual refresh cannot be overwritten by an older read and a read after a PR change never joins one from before it. - Phones send timer reads as revalidations, keep what is shown on a receipt and read again soon; returning to the app reads fresh. No foreground wire flag. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
import { useMemo } from 'react'
|
|
import {
|
|
hashKey,
|
|
notifyManager,
|
|
queryOptions,
|
|
skipToken,
|
|
useQueries,
|
|
useQuery,
|
|
type QueryClient,
|
|
type StaleTimeFunction,
|
|
type UseQueryOptions,
|
|
type UseQueryResult
|
|
} from '@tanstack/react-query'
|
|
import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events'
|
|
import type { RpcClient } from '../transport/rpc/rpc-client-contract'
|
|
import { useHostConnectionInvalidation } from './host-connection-invalidation'
|
|
import { useHostEventRouter } from './host-event-router'
|
|
import {
|
|
hostQueryKey,
|
|
type HostQueryKey,
|
|
type HostQueryParams,
|
|
type HostQueryTarget
|
|
} from './host-query-key'
|
|
import { holdsRestoredHostQueryData, type HostQueryPersist } from './host-query-persistence'
|
|
import { hostReadClient } from './host-read-client'
|
|
|
|
export type HostQuery<TInput extends HostQueryParams, TData> = {
|
|
family: string
|
|
// Calls the domain's loader. `previous` is the cached data a revalidation may build on; live reads pass none.
|
|
// Absent for a family only its screen can read (its inputs live in the screen); that screen passes `read`.
|
|
fetch?: HostQueryFetch<TInput, TData>
|
|
staleTime?: StaleTimeFunction<TData, Error, TData, HostQueryKey>
|
|
gcTime?: number
|
|
// Reads a stale answer on foreground: one with no change signal, or one an event marked while hidden.
|
|
refetchOnWindowFocus?: boolean
|
|
// For a screen that offers its own Retry, so a lost read is reported instead of sent again.
|
|
retry?: false
|
|
// Kept fresh by a subscription, so the connected edge leaves it alone.
|
|
streamBacked?: boolean
|
|
// Desktop change events that make this family stale (host-event-router), and the least time between reads they cause.
|
|
signal?: HostQuerySignal
|
|
// Kept on the phone between launches (host-query-persistence); absent means memory only.
|
|
persist?: HostQueryPersist<TData>
|
|
}
|
|
|
|
export type HostSignalEvent = Exclude<RuntimeClientEventStreamMessage, { type: 'ready' | 'end' }>
|
|
|
|
export type HostQuerySignal = {
|
|
events: readonly HostSignalEvent['type'][]
|
|
floorMs?: number
|
|
// Narrows a listed event to the queries it names; without it every query in the family is stale.
|
|
matches?: (event: HostSignalEvent, target: HostQueryTarget) => boolean
|
|
}
|
|
|
|
type HostQueryFetch<TInput extends HostQueryParams, TData> = (
|
|
client: RpcClient,
|
|
target: HostQueryTarget,
|
|
input: TInput,
|
|
previous: TData | undefined,
|
|
read: HostQueryRead
|
|
) => Promise<TData>
|
|
|
|
// `live` marks an action's read (readHostQueryLive), which must not be served from a host-side cache.
|
|
// `previousFromStore` says `previous` is an earlier launch's copy that no read in this document confirmed.
|
|
export type HostQueryRead = { live: boolean; previousFromStore: boolean }
|
|
|
|
export function defineHostQuery<TInput extends HostQueryParams, TData>(
|
|
query: HostQuery<TInput, TData> & { fetch: HostQueryFetch<TInput, TData> }
|
|
): HostQuery<TInput, TData> & { fetch: HostQueryFetch<TInput, TData> }
|
|
export function defineHostQuery<TInput extends HostQueryParams, TData>(
|
|
query: HostQuery<TInput, TData>
|
|
): HostQuery<TInput, TData>
|
|
export function defineHostQuery<TInput extends HostQueryParams, TData>(
|
|
query: HostQuery<TInput, TData>
|
|
): HostQuery<TInput, TData> {
|
|
return query
|
|
}
|
|
|
|
function hostQueryOptions<TInput extends HostQueryParams, TData>(
|
|
query: HostQuery<TInput, TData>,
|
|
client: RpcClient,
|
|
hostId: string,
|
|
target: HostQueryTarget,
|
|
input: TInput
|
|
) {
|
|
const { family, fetch, streamBacked, signal, persist, ...freshness } = query
|
|
const queryKey = hostQueryKey(hostId, family, target, input)
|
|
return queryOptions({
|
|
...freshness,
|
|
queryKey,
|
|
// No abort signal: the host has no cancel, and a screen that unmounts still wants the answer cached.
|
|
queryFn: fetch
|
|
? ({ client: queryClient }) =>
|
|
fetch(hostReadClient(client), target, input, queryClient.getQueryData<TData>(queryKey), {
|
|
live: false,
|
|
previousFromStore: holdsRestoredHostQueryData(queryClient, hashKey(queryKey))
|
|
})
|
|
: skipToken,
|
|
meta: { streamBacked, signal, persist }
|
|
})
|
|
}
|
|
|
|
// An action's read: no stale copy and nothing built on the cached data; resolves once observers hold the answer.
|
|
// `replace` cancels a read already out (its caller rejects rather than resolving with old data) instead of joining it.
|
|
// `read` stands in for the family's fetch when the screen holds the inputs; it may stop early once `signal` aborts.
|
|
export async function readHostQueryLive<TInput extends HostQueryParams, TData>(
|
|
queryClient: QueryClient,
|
|
query: HostQuery<TInput, TData>,
|
|
host: { hostId: string; client: RpcClient },
|
|
target: HostQueryTarget,
|
|
input: TInput,
|
|
options: { replace: boolean; read?: (signal: AbortSignal) => Promise<TData> }
|
|
): Promise<TData> {
|
|
const { hostId, client } = host
|
|
if (options.replace) {
|
|
const queryKey = hostQueryKey(hostId, query.family, target, input)
|
|
await queryClient.cancelQueries({ queryKey, exact: true }, { silent: true })
|
|
}
|
|
const { fetch } = query
|
|
const { read } = options
|
|
const live =
|
|
fetch &&
|
|
(() =>
|
|
fetch(hostReadClient(client), target, input, undefined, {
|
|
live: true,
|
|
previousFromStore: false
|
|
}))
|
|
if (!read && !live) {
|
|
throw new Error(`${query.family} is read by its screen`)
|
|
}
|
|
const data = await queryClient.query({
|
|
...hostQueryOptions(query, client, hostId, target, input),
|
|
// Only a call-site reader takes the signal: taking it lets TanStack cancel the read once no screen observes it.
|
|
queryFn: read ? ({ signal }) => read(signal) : live,
|
|
staleTime: 0
|
|
})
|
|
await new Promise<void>((resolve) => notifyManager.schedule(resolve))
|
|
return data
|
|
}
|
|
|
|
// A cached read through the screen's `read`: reuses a fresh answer and joins a read already out. An answer
|
|
// an event marked stale as it landed predates that change, so it goes to `onStale` (still the newest one) and is
|
|
// read again while `isCurrent` holds: at once after a quiet spell (a write's own event lands mid-read), else
|
|
// `floorMs` after it landed.
|
|
export async function fetchHostQuery<TInput extends HostQueryParams, TData>(
|
|
queryClient: QueryClient,
|
|
query: HostQuery<TInput, TData>,
|
|
host: { hostId: string; client: RpcClient },
|
|
target: HostQueryTarget,
|
|
input: TInput,
|
|
reader: { read: () => Promise<TData>; isCurrent: () => boolean; onStale: (data: TData) => void }
|
|
): Promise<TData> {
|
|
const { isCurrent } = reader
|
|
const options = {
|
|
...hostQueryOptions(query, host.client, host.hostId, target, input),
|
|
queryFn: reader.read
|
|
}
|
|
const floorMs = query.signal?.floorMs ?? 0
|
|
const before = queryClient.getQueryState(options.queryKey)
|
|
let quiet =
|
|
before?.fetchStatus !== 'fetching' && (before?.dataUpdatedAt ?? 0) + floorMs <= Date.now()
|
|
let data = await queryClient.fetchQuery(options)
|
|
for (;;) {
|
|
const state = queryClient.getQueryState(options.queryKey)
|
|
// A caller that moved on never shows this answer, so it is not read again.
|
|
if (!state?.isInvalidated || !isCurrent()) {
|
|
return data
|
|
}
|
|
reader.onStale(data)
|
|
if (state.fetchStatus === 'idle') {
|
|
const wait = quiet ? 0 : state.dataUpdatedAt + floorMs - Date.now()
|
|
quiet = false
|
|
if (wait > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, wait))
|
|
if (!isCurrent()) {
|
|
return data
|
|
}
|
|
}
|
|
}
|
|
data = await queryClient.fetchQuery(options)
|
|
}
|
|
}
|
|
|
|
// A read ahead of the user: skipped while the key holds current data, joins a read already out.
|
|
export function prefetchHostQuery<TInput extends HostQueryParams, TData>(
|
|
queryClient: QueryClient,
|
|
query: HostQuery<TInput, TData>,
|
|
host: { hostId: string; client: RpcClient },
|
|
target: HostQueryTarget,
|
|
input: TInput
|
|
): Promise<void> {
|
|
return queryClient.prefetchQuery({
|
|
...hostQueryOptions(query, host.client, host.hostId, target, input),
|
|
staleTime: Infinity
|
|
})
|
|
}
|
|
|
|
// Without a client the observer still reads the cached entry, but never fetches.
|
|
// Exported for a screen that reads one query per host through useQueries (native Home); it holds each host's router itself.
|
|
export function hostObserverOptions<TInput extends HostQueryParams, TData>(
|
|
query: HostQuery<TInput, TData>,
|
|
client: RpcClient | null,
|
|
hostId: string,
|
|
target: HostQueryTarget,
|
|
input: TInput
|
|
): UseQueryOptions<TData, Error, TData, HostQueryKey> {
|
|
return client
|
|
? hostQueryOptions(query, client, hostId, target, input)
|
|
: {
|
|
queryKey: hostQueryKey(hostId, query.family, target, input),
|
|
queryFn: skipToken,
|
|
// A family that keeps its answer longer keeps it while no client reads it, too.
|
|
...(query.gcTime === undefined ? {} : { gcTime: query.gcTime }),
|
|
meta: { streamBacked: query.streamBacked, signal: query.signal, persist: query.persist }
|
|
}
|
|
}
|
|
|
|
export function useHostQuery<TInput extends HostQueryParams, TData>(
|
|
query: HostQuery<TInput, TData>,
|
|
host: { hostId: string; client: RpcClient | null },
|
|
target: HostQueryTarget,
|
|
input: TInput,
|
|
options?: {
|
|
enabled?: boolean
|
|
subscribed?: boolean
|
|
// A function decides from the data each read leaves, so one read's answer can time the next.
|
|
refetchInterval?: number | false | ((data: TData | undefined) => number | false)
|
|
// Replaces the family's own, e.g. once the host is known to signal every change.
|
|
staleTime?: number
|
|
}
|
|
): UseQueryResult<TData> {
|
|
const { hostId, client } = host
|
|
const enabled = client !== null && options?.enabled !== false
|
|
const refetchInterval = options?.refetchInterval
|
|
useHostConnectionInvalidation(hostId, enabled ? client : null)
|
|
// A screen alone on its page has no host list to keep the router; its signalled reads hold one.
|
|
useHostEventRouter(hostId, enabled && query.signal ? client : null)
|
|
// Same options object while nothing changed (target and input count by value), so TanStack's per-render setOptions is a no-op.
|
|
const keyHash = hashKey([target, input])
|
|
const queryOptions = useMemo((): UseQueryOptions<TData, Error, TData, HostQueryKey> => {
|
|
const observer = hostObserverOptions(query, client, hostId, target, input)
|
|
// Disable through the observer, not skipToken: observers share the query's options, so a disabled
|
|
// screen's skipToken would leave an enabled one's key with no fetch for invalidation or intervals.
|
|
return enabled ? observer : { ...observer, enabled: false }
|
|
}, [query, client, hostId, keyHash, enabled])
|
|
return useQuery({
|
|
...queryOptions,
|
|
...(options?.staleTime === undefined ? {} : { staleTime: options.staleTime }),
|
|
subscribed: options?.subscribed,
|
|
refetchInterval:
|
|
typeof refetchInterval === 'function'
|
|
? (query) => refetchInterval(query.state.data)
|
|
: refetchInterval
|
|
})
|
|
}
|
|
|
|
// One query per input (e.g. one per open folder), combined into one value; keep `combine` stable so the value is memoized.
|
|
export function useHostQueries<TInput extends HostQueryParams, TData, TCombined>(
|
|
query: HostQuery<TInput, TData>,
|
|
host: { hostId: string; client: RpcClient | null },
|
|
target: HostQueryTarget,
|
|
inputs: readonly TInput[],
|
|
combine: (results: UseQueryResult<TData>[]) => TCombined
|
|
): TCombined {
|
|
const { hostId, client } = host
|
|
useHostConnectionInvalidation(hostId, client)
|
|
useHostEventRouter(hostId, query.signal ? client : null)
|
|
const keyHash = hashKey([target, inputs])
|
|
const queries = useMemo(
|
|
() => inputs.map((input) => hostObserverOptions(query, client, hostId, target, input)),
|
|
[query, client, hostId, keyHash]
|
|
)
|
|
return useQueries({ queries, combine })
|
|
}
|