Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ return `ItemRemovedError`; use `isItemRemovedError()` to handle this case.

Also included:

- `useSyncStatus()` for one canonical application-facing view of connectivity, active reads,
queued and retryable failed writes, event/reconnect reconciliation, and the last fully synced
time.
- Import-safe schema bindings through `createHooks(schema)`. The generated hooks resolve their
runtime from `FigbirdProvider`, and `useMutations()` returns that instance's typed write proxy.
Imperative code uses `figbird.m`, `figbird.prepare`, and other instance methods directly, so
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const figbird = new Figbird({
schema,
})

export const { useQuery, useMutations, useAction, q } = createHooks(schema)
export const { useQuery, useMutations, useAction, useSyncStatus, q } = createHooks(schema)

function Notes() {
const { data: notes } = useQuery(q.notes.where({ read: false }).related('author'))
Expand Down
3 changes: 3 additions & 0 deletions demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ If you're here to learn figbird, read in this order — each file teaches one id
8. **`src/components/DemoControls.tsx`** — demo-server switches for latency, background
traffic, forced failures, socket reconnects, and resets. The Figbird browser
extension provides the query, timeline, event, and write views.
9. **`src/components/SyncStatusIndicator.tsx`** — one `useSyncStatus()` snapshot rendered
as the nav's offline/saving/restoring/saved indicator.

Structure rule: `src/` root is wiring (`main`, `figbird`, `demoControl`, `App`);
`src/components/` is the shell UI the workspace composes; `src/pages/` is routed screens.
Expand All @@ -76,6 +78,7 @@ The bottom-right **Demo controls** menu changes server behavior:
queue pauses instead, keeping its projection visible until you choose Retry or Discard.
- **Drop socket** — kills the transport; socket.io auto-reconnects and figbird refetches
every active query (and the materialized reference sets) to reconcile anything missed.
The nav indicator moves through offline → restoring → saved from `useSyncStatus()`.

Install the extension from `extensions/build/chrome` or `extensions/build/firefox`, open
the browser's developer tools, and select the **Figbird** panel. It exposes live queries,
Expand Down
2 changes: 2 additions & 0 deletions demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { figbird } from './figbird'
import { issueDetailRouteQueries } from './pages/IssueDetail/queries'
import { TeamsPage } from './pages/Teams/screen'
import { DetailSkeleton, SkeletonRows } from './components/ui'
import { SyncStatusIndicator } from './components/SyncStatusIndicator'

function EmptyDetail() {
return (
Expand Down Expand Up @@ -106,6 +107,7 @@ function Workspace({ children }: { children?: ReactNode }) {
+ New issue
</button>
<span className='spacer' />
<SyncStatusIndicator />
<span className='nav-hint'>tip: open two windows side by side</span>
</nav>
{isFull ? (
Expand Down
40 changes: 40 additions & 0 deletions demo/src/components/SyncStatusIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useSyncStatus } from '../figbird'

function plural(count: number, word: string): string {
return `${count} ${word}${count === 1 ? '' : 's'}`
}

export function SyncStatusIndicator() {
const sync = useSyncStatus()
const label =
sync.phase === 'offline'
? 'Working offline'
: sync.phase === 'error'
? sync.failedWrites > 0
? `Couldn’t sync ${plural(sync.failedWrites, 'change')}`
: 'Couldn’t refresh data'
: sync.phase === 'restoring'
? 'Refreshing stale data…'
: sync.phase === 'syncing'
? `Saving ${plural(sync.pendingWrites, 'change')}…`
: 'Everything saved'

const detail = [
`${plural(sync.pendingWrites, 'pending write')}`,
`${plural(sync.failedWrites, 'failed write')}`,
`${plural(sync.fetchingQueries, 'fetching query')}`,
`${plural(sync.pendingReconciliations, 'pending reconciliation')}`,
sync.lastSyncedAt === null
? 'Not synced yet'
: `Last synced ${new Date(sync.lastSyncedAt).toLocaleTimeString()}`,
].join(' · ')

return (
<div className={`sync-status ${sync.phase}`} title={detail} aria-live='polite'>
<span className='sync-status-orbit' aria-hidden='true'>
<span className='sync-status-core' />
</span>
<span>{label}</span>
</div>
)
}
1 change: 1 addition & 0 deletions demo/src/figbird.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export const {
useAction,
useMutating,
useMutationQueue,
useSyncStatus,
} = createHooks(schema)

// Reference data: preload the complete sets once — realtime maintains them, and every
Expand Down
77 changes: 77 additions & 0 deletions demo/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,83 @@ body {
font-size: 12px;
}

.sync-status {
--sync-color: var(--text-dim);
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 118px;
padding: 3px 9px 3px 6px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-muted);
background: var(--bg);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
transition:
color 140ms ease,
border-color 140ms ease,
background 140ms ease;
}

.sync-status.synced {
--sync-color: var(--open);
}

.sync-status.offline {
--sync-color: #d97706;
color: #92400e;
border-color: #fde2b7;
background: #fffaf0;
}

.sync-status.error {
--sync-color: var(--danger);
color: #991b1b;
border-color: #fecaca;
background: #fff5f5;
}

.sync-status-orbit {
position: relative;
width: 14px;
height: 14px;
flex: 0 0 auto;
border: 1px solid color-mix(in srgb, var(--sync-color) 42%, transparent);
border-radius: 50%;
}

.sync-status-core {
position: absolute;
inset: 4px;
border-radius: 50%;
background: var(--sync-color);
}

.sync-status.syncing .sync-status-orbit,
.sync-status.restoring .sync-status-orbit {
border-top-color: var(--sync-color);
animation: sync-orbit 800ms linear infinite;
}

.sync-status.error .sync-status-core {
border-radius: 1px;
transform: rotate(45deg);
}

@keyframes sync-orbit {
to {
transform: rotate(1turn);
}
}

@media (prefers-reduced-motion: reduce) {
.sync-status.syncing .sync-status-orbit,
.sync-status.restoring .sync-status-orbit {
animation: none;
}
}

/* ----- Grid ----- */

.grid {
Expand Down
45 changes: 42 additions & 3 deletions docs/content/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1511,6 +1511,35 @@ Returns a boolean, live via `useSyncExternalStore` over `figbird.mutating`, the
synchronous tracker. It's correct for components that mount mid-mutation and it sees
writes from any surface. See [Entity-level activity](#entity-level-activity-usemutating).

## useSyncStatus

```ts
const sync = useSyncStatus()

sync.phase // 'restoring' | 'offline' | 'syncing' | 'synced' | 'error'
sync.pendingWrites
sync.failedWrites
sync.fetchingQueries
sync.pendingReconciliations
sync.lastSyncedAt // epoch milliseconds, or null before the first successful settle
```

Returns Figbird's canonical, instance-wide sync snapshot. Unlike the observability event
stream, this state replays: a component mounting halfway through a fetch, scheduled mutation,
paused queue failure, disconnect, or hidden-tab reconciliation sees the correct answer
immediately. `offline` takes priority while the adapter transport is disconnected; `error`
covers failed writes or reconciliation refreshes; `restoring` covers connection setup and
event/reconnect reconciliation; pending writes are `syncing`; otherwise the instance is `synced`.
Ordinary query fetches update `fetchingQueries` without changing `phase`, so a global saved/saving
indicator does not flash during normal screen-level data loading.

`pendingWrites` includes scheduled queue work and a failed queue item that still needs retry or
discard. `failedWrites` counts that retryable queue work; an ordinary rejected action settles out
of the global snapshot because its caller owns the error and Figbird no longer has work to retry.
`lastSyncedAt` advances only when successful work leaves the whole instance clean after a write or
reconciliation. The hook is backed by `figbird.sync` and
`useSyncExternalStore`, so it is also available from a schema-bound `createHooks` kit.

## defineQuery

```ts
Expand Down Expand Up @@ -1665,6 +1694,7 @@ const figbird = new Figbird({
| `m` | The instance’s write proxy: `figbird.m.issues.patch(...)`, or `figbird.m(service)` for dynamic names. In React, access the provider instance through `useMutations()`. See [m](#m). |
| `createMutationQueue(config?)` | Explicitly owned serial writes across records or services. See [figbird.createMutationQueue](#figbirdcreatemutationqueue). |
| `mutating` | Synchronous active-mutation tracker (`subscribe`/`getSnapshot`) — `useMutating` is its React binding. |
| `sync` | Canonical aggregate sync tracker (`subscribe`/`getSnapshot`) — `useSyncStatus` is its React binding. |
| `explain(...)` | Static classification report — see [figbird.explain](#figbirdexplain). |
| `inspect()` | Live-query snapshot — see [figbird.inspect](#figbirdinspect). |
| `events` | Observability channel — see [figbird.events](#figbirdevents). |
Expand Down Expand Up @@ -1699,13 +1729,22 @@ Meta behavior: `find` returns `{ data, meta }` (`FindMeta`: `{ total, limit, ski
Binds a schema to import-safe, typed React hooks:

```ts
export const { useQuery, useFigbird, useMutations, q, defineQuery, useAction, useMutating } =
createHooks(schema)
export const {
useQuery,
useFigbird,
useMutations,
q,
defineQuery,
useAction,
useMutating,
useSyncStatus,
} = createHooks(schema)
```

Returns the daily-use kit: `useQuery`, `q` (the read proxy), schema-typed
`defineQuery`, and the write side — `useMutations` (the provider instance's write proxy),
`useAction` (per-action state), and `useMutating` (in-flight activity). It also includes
`useAction` (per-action state), `useMutating` (in-flight activity), and `useSyncStatus`
(instance-wide connectivity and sync state). It also includes
typed `useFigbird`, `useFeathers` (the raw-client
escape hatch), and the deprecated legacy hooks (`useMutation`, `useFind`, `useGet`) for
older codebases.
Expand Down
9 changes: 9 additions & 0 deletions lib/adapters/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ export interface EventHandlers {
removed: (item: unknown) => void
}

/** Adapter transport state consumed by Figbird's canonical sync snapshot. */
export type AdapterConnectionState = 'connecting' | 'connected' | 'disconnected'

/** Service context supplied when the adapter evaluates a query locally. */
export interface MatcherContext {
serviceName: string
Expand Down Expand Up @@ -102,6 +105,12 @@ export interface Adapter<
// reconnects after a period where realtime events may have been missed.
subscribeToReconnect?(handler: () => void): () => void

/** Current transport state. Omit when the adapter has no meaningful connection lifecycle. */
getConnectionState?(): AdapterConnectionState

/** Notify whenever `getConnectionState()` may have changed. */
subscribeToConnectionState?(handler: () => void): () => void

/**
* Read an item's id, or `undefined` when absent. Pure extraction — whether a
* missing id is noteworthy is the store's call (it warns on event/fetch paths
Expand Down
59 changes: 59 additions & 0 deletions lib/adapters/feathers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
Adapter,
AdapterConnectionState,
EventHandlers,
MatcherContext,
PageCursor,
Expand Down Expand Up @@ -228,6 +229,11 @@ interface ReconnectEventSource {
removeListener?: (event: string, listener: () => void) => void
}

interface ConnectionEventSource extends ReconnectEventSource {
connected?: boolean
active?: boolean
}

/**
* Typed Feathers service for a specific service in the schema.
* Provides full type safety for CRUD methods and custom methods.
Expand Down Expand Up @@ -353,6 +359,7 @@ export class FeathersAdapter<TQuery = Record<string, unknown>> implements Adapte
#operators: Record<string, CustomOperatorRegistration>
#defaultPagination: FeathersPagination | undefined
#pagination: Record<string, FeathersPagination>
#observedConnectionState: AdapterConnectionState | undefined

/** Names of custom operators registered for every service. */
get customOperators(): readonly string[] {
Expand Down Expand Up @@ -633,6 +640,39 @@ export class FeathersAdapter<TQuery = Record<string, unknown>> implements Adapte
}
}

getConnectionState(): AdapterConnectionState {
if (this.#observedConnectionState) return this.#observedConnectionState
const socket = this.#getConnectionEventSource()
if (!socket) return 'connected'
if (socket.connected === true) return 'connected'
if (socket.active === true) return 'connecting'
return socket.connected === false ? 'disconnected' : 'connected'
}

subscribeToConnectionState(handler: () => void): () => void {
const socket = this.#getConnectionEventSource()
if (!socket) return () => {}
const connected = () => {
this.#observedConnectionState = 'connected'
handler()
}
const disconnected = () => {
this.#observedConnectionState = 'disconnected'
handler()
}
socket.on('connect', connected)
socket.on('disconnect', disconnected)
return () => {
if (socket.off) {
socket.off('connect', connected)
socket.off('disconnect', disconnected)
} else {
socket.removeListener?.('connect', connected)
socket.removeListener?.('disconnect', disconnected)
}
}
}

#getReconnectEventSource(): ReconnectEventSource | null {
const io = (this.feathers as { io?: { io?: unknown } }).io
const candidates = [
Expand All @@ -659,6 +699,25 @@ export class FeathersAdapter<TQuery = Record<string, unknown>> implements Adapte
return null
}

#getConnectionEventSource(): ConnectionEventSource | null {
const candidates = [
(this.feathers as { io?: unknown }).io,
(this.feathers as { socket?: unknown }).socket,
(this.feathers as { primus?: unknown }).primus,
]
for (const candidate of candidates) {
if (
candidate &&
typeof candidate === 'object' &&
'on' in candidate &&
typeof candidate.on === 'function'
) {
return candidate as ConnectionEventSource
}
}
return null
}

getId(item: unknown): string | number | undefined {
return typeof this.#idField === 'string'
? ((item as Record<string, unknown>)[this.#idField] as string | number | undefined)
Expand Down
7 changes: 7 additions & 0 deletions lib/core/figbird.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type MutationQueueHost,
} from './mutationQueue.js'
import type { MutationActivity } from './mutationTracker.js'
import type { SyncActivity } from './syncTracker.js'
import {
createQueryBuilderProxy,
queryBuilderUsesSchema,
Expand Down Expand Up @@ -108,6 +109,7 @@ export type {
MutationSchedule,
} from './mutationQueue.js'
export type { InFlightMutation, MutationActivity } from './mutationTracker.js'
export type { SyncActivity, SyncPhase, SyncStatus } from './syncTracker.js'
export {
defineQuery,
isQueryDefinition,
Expand Down Expand Up @@ -792,6 +794,11 @@ export class Figbird<
return this.queryStore.mutations
}

/** Canonical aggregate state behind `useSyncStatus()`. */
get sync(): SyncActivity {
return this.queryStore.sync
}

/**
* Manually refetch cached queries — the escape hatch for changes figbird cannot
* observe (custom methods on services without realtime events, out-of-band
Expand Down
Loading