Skip to content

Use query - #83

Open
KidkArolis wants to merge 127 commits into
masterfrom
use-query
Open

Use query#83
KidkArolis wants to merge 127 commits into
masterfrom
use-query

Conversation

@KidkArolis

Copy link
Copy Markdown
Collaborator

No description provided.

KidkArolis and others added 30 commits July 7, 2026 19:41
…core

Reconciles the use-query branch (relational query builder, useQuery/Suspense,
paginate, defineQuery/prepare, optimistic mutations, observability events,
relational filters) with master's decomposed core (queryStore/queryRef/
queryTypes/queryIdentity) and post-fork fixes:

- keeps master's stale-fetch protection in #fetched, re-entrant event queue
  draining, matcher-based query identity isolation + vacuum, and
  normalizeQueryConfig defaults
- splits the relational engine out of the old figbird.ts monolith into
  relationalQuery.ts, relationalFilters.ts, queryDefinition.ts, events.ts,
  and queryClassification.ts
- useQueryByDesc adopts the queryIdentityKey scoping mechanism (replacing
  the old network-only uid hack) and now also scopes custom-matcher queries
- createHooks regains typed useService/useMethod and the path-resolving
  useFeathers proxy; index.ts re-exports useMethod/useService and the
  query state helpers
- use-method-inference fixture ported to the createSchema API;
  generated-schema-helpers fixture dropped with the defineSchema API it
  tested

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A single 32-bit FNV-1a hash keys the entire cache identity story — query
dedup, relational ref interning, exact Suspense reads. Birthday math puts
collision odds around 1% at 10k distinct keys per session, and a collision
silently serves one query's data to another. Two independently-seeded FNV-1a
accumulators give 64 bits, pushing the same odds past 1e-11.

Also removes the Date.now() error fallback: a random hash silently breaks
ref interning and cache identity. Unserializable input now throws at query
creation instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A get query whose entity was removed used to flip to 'loading' with
isFetching: false — a dead end nothing ever completes, which made relational
consumers serve the stale previous snapshot with isFetching: true forever.
Removal now produces a refetchable ItemRemoved error state (the resource is
gone — same shape as a server NotFound), and a subsequent created event for
the same resource id restores the query, which also makes optimistic-remove
rollback work for get queries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
optimistic: true on create uses the request body as the synthetic item; with
no id the created event is silently dropped by the entity cache and the
optimistic write becomes an invisible no-op. Warn with the actionable fix
(pass a client-generated id via the optimistic option) until client-generated
ids land properly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#assembleRelations ran relationItems.find()/filter-style scans inside the
per-parent loop for the one/many/embedded/junction paths — O(parents ×
relation rows) per reassembly, re-run on every relation data change. Build
per-relation Maps keyed by dest field (and junction rows grouped by parent
join value) once per assembly pass instead: O(parents + relation rows).
First-match and result-order semantics are preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Event batches were applied and notified service by service, so a relational
query spanning services A and B computed a wasted intermediate snapshot after
A's events but before B's, and non-React subscribers observed the
intermediate state. Apply all services' events silently while collecting
touched query ids, then notify each listener (and the global listeners) once
— the batch is now the atomicity unit for observers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The local-exact / server-window / server-authoritative decision was spread
across hasServerMaintainedQuerySemantics, #hasRelationWindowing, and the
allPages toggle in #buildRelationQueryRef — three sites that had to agree by
convention. classifyQueryNode(query, { server, allPages }) is now the single
authority, with hasWindowFilters as the shared window-detection helper. This
also fixes a latent misclassification: a window filter encountered before a
server-only operator used to short-circuit the scan under allPages, letting
e.g. { $limit, $search } pass as local-exact.

Windowed many-relations spawn one query per parent (per-parent $limit/$sort
windows can't be expressed as a single find). Past 10 parents that is almost
certainly the wrong shape for a screen, so warn once per relation and point
at the embed relation kind, which collapses the fan-out to one batched
IN(...) fetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
createHooks carried a 70-line verbatim copy of useRelationalQuery (plus a
second idleState constant) that captured the figbird instance from the
closure while the base hook reads it from context — so the typed
useRelationalQuery worked without a FigbirdProvider but the sibling typed
useQuery didn't. Delegate like useQuery already does; the duplicate and the
Provider inconsistency both disappear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
optimistic is a declared field on BaseMutationDescriptor now — the two
(desc as { optimistic?: ... }) casts in the store were fossils from before
the type existed.

useGet/useFind/useMutation and the createHooks typed variants all
pre-resolved service path aliases before handing the descriptor to
figbird.query()/mutate(), which resolve canonically — schema logic leaked
into the react layer and six call sites to keep in sync. Resolution now
happens in exactly one place; useMethod/useService keep their calls because
they talk to the adapter directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- #fetchStartedAt was an instance-level map written and read within a single
  #queue() invocation — a local variable is simpler and can't be clobbered by
  an overlapping queue for the same query id.
- The loose String() id comparison in the get-restore branch now lives in an
  isSameId helper with its rationale (route-param strings vs numeric entity
  ids), so it reads as intent rather than accident.
- Relational-filter paths are derived from the static AST; precompute them
  once at subscription time instead of re-deriving on every processed event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d sub-state

The paginate mode was a second, parallel implementation of 'where does root
data come from' woven through every method — getSnapshot carried two nearly-
duplicated flows, and subscribe/suspensePromise/refetch/cleanup all branched
on kind === 'paginate'. Three structural moves delete that duplication,
all within relationalQuery.ts (ref first, internal machinery below):

- RootSource: one snapshot contract with two implementations.
  SingleQueryRoot wraps the find/get root (including the get→array identity
  cache); PagedQueryRoot owns pages, loadMore, sticky hasMore, totalCount,
  and the memoized pagination block. getSnapshot is now a single flow; the
  pagination wrapper is one #wrap step using instance fields instead of the
  hidden __inner defineProperty trick.
- Pure assembly: assembleRelations and the keyed indexes take gathered data
  as input and never read live query state.
- Single gather pass: #gatherRelationData walks the relation subs once per
  snapshot, short-circuits loading/error, and feeds both change detection
  and assembly — replacing three independent walks over the same subs.

Supporting cleanups that fell out of the same reshape:

- RelationSub is a discriminated union (empty/fanIn/junction/perParent)
  instead of optional-field sniffing.
- RelationalQueryHost: the engine consumes a five-member structural contract
  instead of the Figbird class — the circular type import is gone and the
  per-call-site 'as unknown as QueryRef' double casts collapse to one typed
  seam (#query).
- subscribeAndSeed captures the warm-cache seeding pattern (store listeners
  only fire on change) that was repeated with an explanatory comment at five
  call sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The demo was six tabs named after figbird's API surface (Search / Activity /
Paginate / Windows / Filters), three of them running a second unrelated
domain, all behind a server that injected 700-1300ms latency to showcase
delayed spinners — which made figbird itself read as slow. It's now one
realtime issue tracker where every feature appears as product behavior:

- The issue list is a .paginate({ pageSize: 25 }) query over ~90 seeded
  issues with returnTotal, a search box (server-authoritative $regex,
  committed via startTransition), status chips, and relational-filter team
  chips ('assignee.teamId' — resolved by a join server-side, matched against
  the entity cache client-side). Comment counts come from issue.commentIds,
  a server-maintained id list — the embed pattern; no comments are fetched
  for the list.
- The Teams panel is a windowed relation (3 most recent issues per team);
  the Activity panel merges three realtime queries client-side; the console
  demonstrates optimistic create/remove. Small ⓘ popovers replace the
  tab-top prose as the didactic layer.
- Latency is now a dial, not a tax: fast (default) / realistic / slow
  profiles switchable live from the dev-tools drawer via the _demo control
  service. The background ticker became a simulated teammate (comments,
  reactions, priority nudges, close/reopen) and is ON by default.
- The dev-tools drawer gains a queries view: every live query with
  figbird's own classification badge (local-exact / server-window /
  server-authoritative / get), item counts and fetch state — powered by the
  newly exported classifyQueryNode.
- The companies/people/orgUnits/documents domain, its services and the
  three feature pages are deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The activity list used an inner flex/overflow scroll area inside a section
with no height of its own, inside a rail that already scrolls — it collapsed
to a sliver and items bled under the sticky section header. Activity now
flows naturally as the last section of the rail, entries have an explicit
line/body two-row layout, and the dead full-page feed styles from the old
Activity tab are removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eams page

- New issue lives in a compact Linear-style modal (title, description, team +
  assignee selects) opened from the nav; creation is optimistic with a
  client-generated id, so the issue is in every panel before the server
  responds, and the app navigates to it immediately.
- Issues gain a description — inline editable with the same optimistic-patch
  pattern as the title.
- Comments are threaded, Linear-style: one level of nesting, a composer at
  the bottom of the thread, and per-thread reply composers. The comments
  query is deliberately unwindowed so it classifies local-exact — new
  comments and replies (yours or the teammate's) merge straight from the
  socket event. Replies are seeded and the teammate sometimes replies too.
- The Explain popovers render into a body portal with computed fixed
  positioning (flipping above near the viewport bottom), so pane overflow
  can no longer clip them.
- Teams moves out of the sidebar onto its own dedicated page (nav: Issues /
  Teams): one card per team with the live member roster (fan-in relation)
  and the 5 most recently touched issues (per-team window). The sidebar is
  now just the Activity feed; the console panel is gone — its actions moved
  to the nav (+ New issue) and the detail toolbar (Delete).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Subscribing to a query can start its fetch synchronously - including from
useQuery suspensePromise() during a React render (root/relation setup ->
store subscribe -> #queue -> #fetching). The #fetching transition notified
listeners synchronously, so other already-mounted components sharing the
same store query got their useSyncExternalStore onStoreChange (a setState)
invoked mid-render, tripping React "Cannot update a component while
rendering a different component" dev warning.

#fetching is the only listener-notifying transition reachable synchronously
from a render - everything past the awaited #fetch is already async. Its
notification now goes through a coalescing microtask (the state write and
the adapter call stay synchronous, so warm-cache reads and fetch timing are
unchanged, and listeners read current state at invoke time so nothing stale
is ever delivered).

Verified in the demo with a console.error interceptor across issue
navigation, filter changes, the new-issue modal (cold users query), and the
teams page: zero warnings, previously dozens. All 176 tests unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rounds out the feature coverage and makes the query shapes themselves part
of the didactic layer:

- Every Explain popover now shows the actual builder/mutation shape in a
  code block below the description (Explain moved to a shared module with a
  query prop).
- Issue rows prefetch their detail + comments on hover/focus via
  figbird.prepare() with a small LRU of release handles — clicking a row is
  usually a warm, synchronous render (verified: comments query success
  before click, detail title painted within 80ms).
- Labels now resolve through the transparent two-hop junction relation
  (many(issues → issueLabels, issueLabels → labels)) — consumers say
  .related('labels') and get Label[]; the hand-written issueLabels → label
  traversal is gone from the list and detail.
- Dev tools gains 'Fail next mutation': a one-shot server chaos switch
  (internal teammate/maintenance writes exempt) that makes the next user
  action fail — the optimistic rollback becomes visible, with the mutate →
  rollback sequence in the event log. Verified end to end.
- Dev tools gains 'Drop socket': closes the engine, socket.io auto-
  reconnects, and figbird refetches every active query (21-query burst
  verified).

Includes an adapter fix this surfaced: socket.io v3+ emits 'reconnect' on
the Manager (socket.io), not the Socket, so FeathersAdapter's reconnect
refetch never fired with modern socket.io clients. The adapter now prefers
feathers.io.io (the Manager) and falls back to the socket/primus for older
clients.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The detail pane was the one panel without an Explain — it now has one in the
header meta line: the full route-prepared issue graph (defineQuery with the
one() root and its creator/assignee/team/labels relations, including the
two-hop junction) plus the prepare() call the router and row-hover both
fire, and a note on live relation leaves (Reassign swapping in the newly
fetched assignee).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last undemoed headline feature: nothing resolved a relation through
embed(). Each team card now shows two strategies for 'top N per team' side
by side:

- Recent — the existing windowed relation (.orderBy().limit(5)): the client
  asks for each team's window, one query per team.
- Spotlight — the embed() pattern: the server maintains
  team.spotlightIssueIds (top open issues by priority), recomputes it after
  any issue create/patch/remove, and re-emits the team only when the list
  actually changes. The client declares
  spotlight: embed({ sourceField: ['spotlightIssueIds'], … }) and figbird
  resolves every team's spotlight in ONE batched IN(...) fetch, preserving
  the server-chosen order.

Verified live: spotlights render in priority order, and boosting an
off-list issue to 99 reshuffles the team's spotlight within ~1s — server
recompute → team patched event → embed re-resolution — with no windowed
refetch anywhere. The Teams page Explain now contrasts both shapes, and the
spotlight-maintenance writes are internal (exempt from the chaos switch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Popovers go 300px → 440px (CSS width and the portal positioning clamp now
share the value, with a max-width guard for narrow viewports) and the code
font steps up to 11px — the widest snippet (the issue-detail graph) fits
without truncation.

The relational-filter snippet also hardcoded status: 'open', which was
inaccurate — status is its own chip. The snippet is now generated from the
live filter state: it shows exactly the where clause your active chips
produce (with the team name as a comment), and tells you to toggle chips
when nothing is active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hover prefetch re-prepared issues whenever the mouse returned to a row the
LRU had evicted — and every fresh subscription to a warm query triggers an
SWR revalidation, so sweeping the mouse up and down the list hammered the
server with redundant refetches of data it already had.

Two changes:

- A session-level prepared-once set: the first hover warms the exact
  detail + comments queries; after that, re-hovering never re-prepares.
  Freshness doesn't need it — the QueryStore keeps merge-class queries
  updated from realtime events even with no subscribers, and an actual
  navigation performs one SWR revalidation.
- A 100ms hover-intent delay (cancelled on mouseleave): sweeping across the
  list no longer fires drive-by prepares for rows merely passed over.
  Keyboard focus prefetches immediately — focusing is deliberate.

Verified: a 10-row sweep fetches nothing; a resting hover fetches exactly
the prepared graph; six re-hovers of a settled row fetch zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…skeleton

Implements the error contract DESIGN.md always specified: a refetch failure
with previous data keeps serving the last successful snapshot with `error`
attached instead of tearing the screen down. The error persists across
retries (no banner flicker), clears on the next successful fetch, and a
removed get-root surfaces as data + ItemRemoved rather than an unmount.
`status: 'error'` (and the useQuery throw to the ErrorBoundary) is now
reserved for cold failures where no data was ever produced.

Ref lifecycle fixes that fell out of removing the hook-level useMemo
interning (which was masking them):
- RelationalQueryRef teardown is deferred by a microtask so StrictMode's
  unsubscribe/resubscribe cycle no longer evicts and resets a live ref —
  previously that could spiral into a re-suspend/remount loop once the
  hook stopped pinning the stale instance.
- Cache eviction is instance-aware: a superseded ref cleaning up no longer
  deletes its successor's cache entry.

Both hooks now share one internal useRelationalQueryRef skeleton instead of
duplicating the intern/subscribe/getSnapshot block.

Also fixes .get() pinning TRelated to Record<string, never>, whose keyof is
string — .get(id).related(...) silently Omit-stripped every field from the
item type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
resolveRelatedItem linear-scanned the destination service's entity map per
item per relation path — and it runs inside the matcher, so merge decisions
on a busy service were O(items × entities). The entity cache is keyed by
adapter id and destField is nearly always that id field, so try a direct
map hit first (verified against destField, since the two are not guaranteed
to be the same field) and keep the scan as the fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
App.tsx held five unrelated features (~1220 lines); it is now the layout and
routing shell only. TeamsPage moves to pages/Teams/ matching the IssueDetail
pattern; the issue list pane (search, filters, pagination, hover prefetch),
activity feed, new-issue modal, and the dev-tools panel each get their own
module. Shared bits — StatusDot, escapeRegExp, the list/detail skeletons —
live in ui.tsx instead of being duplicated per file, and the dev-tools
optimistic control patches collapse into one applyDemoPatch helper.

The issue detail toolbar now queries users/teams/labels instead of mirroring
the server seed's id ranges, so the actions can't silently drift when the
seed changes (the create modal already worked this way).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Eight create*App() factories in relational-query.test.tsx repeated the same
adapter/Figbird/StrictMode/FigbirdProvider tail, and paginate.test.tsx
reimplemented the filter-honoring find mock inline. Both now live in
test/helpers: createTestApp(schema, services, { queryAwareFind }) builds the
standard app wrapper, and installQueryAwareFind/matchesQuery/sortRows are
shared (now also respecting the mock's skipTotal option, which lets the
paginate tests use them).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…noise

- #areExpectedRelationsSynced threaded a parent-data argument through three
  call sites without ever reading it — dropped.
- #processEvent/#queueEvent shared an identical enqueue-and-emit prefix; it
  now lives in one #enqueueEvent with the immediate/batched split on top.
- The five `as QueryConfig<unknown, unknown>` casts in relationalQuery.ts
  were stale — #query already takes that type; removed, tsc-verified.
- useQueryByDesc/useMutation carried ~15 inline `any`s each with their own
  lint-disable line; restored the single documented UntypedData alias.
- useQuery's overload dispatch used a multi-line any-list cast that the
  disable comment only half covered; replaced with one AnyQueryBuilder alias
  (which also made the `validatedArgs as any` cast unnecessary).
- Removed a leftover chat-artifact comment in index.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ook-level optimistic

Implements the calibration batch of DESIGN.md's July 2026 API decisions:

- defineQuery's Standard Schema validator is now optional: defineQuery(name,
  build) types args from the build function; the validated three-arg form
  remains for URL-driven args. Kills the passthrough() stub.
- figbird.events emission is deferred to a microtask (batched, ordered), so
  React-bound subscribers no longer need to defer manually.
- .where() accepts typed known fields plus an open index signature (dotted
  relational paths, $regex and friends — no more `as never`); .orderBy()
  autocompletes item fields without rejecting computed ones.
- useQuery's skip option now honestly types data as T | undefined.
- Relationship declarations take string fields with destField defaulting to
  'id'; arrays remain for compound keys.
- useMutation(service, { optimistic }) declares optimistic intent once per
  surface; per-call options override in both directions. Library default
  remains non-optimistic. remove() coerces to boolean (no payload).
- PreparedQuery no longer carries priority — router vocabulary is attached
  by route-prepare functions.
- Demo: drops the redundant .server() on search ($regex already classifies
  server-authoritative), adopts hook-level optimistic and the schema
  shorthand, updates Explain popovers to teach the new forms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- figbird.prefetch(def, args, { staleTime = 30s }): the idempotent,
  fire-and-forget sibling of prepare(). Repeated calls within staleTime
  no-op; the internal pin auto-releases (unref'd timer) while the data
  stays warm in the QueryStore. The demo's hover dedupe-set + pin-LRU
  collapses to two prefetch calls. Queries now record fetchedAt on every
  successful fetch — the seed of staleness decisions.
- useQuery(builder, { suspense: false }) returns the tagged union
  ({ status, data, error, isFetching, refetch }) and never suspends or
  throws. One implementation with a stable hook order serves both modes;
  useRelationalQuery is now a deprecated alias slated for removal.
- The no-flash kit ships in main exports: useDebouncedTransition (debounce
  committed inside a transition) and DelayedFallback (fallbacks that only
  appear when loading is actually slow), joining useDelayedFlag; plus a
  "no-flash checklist" docs section covering the three failure modes. The
  demo consumes both from the library instead of local copies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cy markers

- Hooks from createHooks(figbird) now resolve their instance as: context if a
  FigbirdProvider is present (SSR/tests), else the bound instance — the
  provider becomes optional for singleton apps, and a dev-mode error fires
  when a provider holds a different instance than the bound one (previously a
  silent divergence). Internal hook implementations take the instance as a
  parameter; context-bound base exports are unchanged.
- createHooks also returns q, the builder proxy off the bound instance, so
  call sites read useQuery(q.issues.where(...)) with one import. Figbird.q is
  now memoized. The demo drops FigbirdProvider entirely and reads q from its
  hooks module.
- figbird.explain(builderOrDef, args?): static per-node classification report
  with structured reasons ({ code, detail }) and the resulting realtime mode;
  explainQueryNode() joins classifyQueryNode() in the classification module.
- figbird.inspect(): stable read-only snapshot of live queries (classification,
  status, itemCount, fetchedAt, subscriberCount) — the contract devtools build
  on. The demo's query inspector drops all its internal casts for it, and the
  store exposes getSubscriberCount().
- Legacy hooks (useFind, useGet, useMethod, useService, useFeathers) carry
  @deprecated markers, index.ts groups them under a legacy banner, and the
  docs gained a "current vs legacy API" orientation note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
useRelationalQuery is gone from the base exports, createHooks, and the typed
surface — useQuery(builder, { suspense: false }) is the one way to get the
tagged union, and the typed UseQueryForSchema interface now mirrors the base
hook's non-suspense and skip-aware overloads. The hook module is renamed
useRelationalQuery.ts → useQuery.ts to match its export (the internal ref
helper is now useQueryRef).

useQueryByDesc becomes module-internal: it only ever served useFind/useGet in
its own file (the deprecated shims keep working unchanged); createHooks uses
the instance-taking impl. Tests exercise the tagged union through the public
useQuery API via a local useStatusQuery helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KidkArolis and others added 30 commits July 7, 2026 19:41
A successful unfiltered allPages fetch materializes its service, after
which finds against it are answered locally from the entity cache
(#tryLocalFind). But when the materialization root refetched, #fetched
updated the entity cache without telling any of those locally-answered
queries — rows created or removed out-of-band (no realtime event)
stayed invisible to them forever.

Now a complete-set fetch diffs its result against the pre-fetch cache
and routes the changes through updateQueriesFromEvents, the same
machinery realtime events use: created rows merge into matching local
queries, vanished rows are dropped from the cache and query results,
server-maintained queries reconcile, and the changes feed
relational-filter invalidation. The fetched query itself is excluded
since its state is set from the result directly.

Two cycle guards: .server() fetches don't diff, and diffs don't
trigger realtime:'refetch' queries — either would let a refetch-on-diff
produce more diffs and loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Self-contained spec for figbird/devtools: product vision (screen-centric
query table, liveness, cost counters, fetch waterfall with N+1
highlighting, correlated writes lane), the exact observability contracts
it builds on (events union, InspectedQuery, explain, mutating), the four
small core instrumentation gaps with proposed event shapes (reconcile
visibility, preparation spans, relational grouping, event attribution),
collector + drawer architecture, data model, UI spec per tab,
performance/correctness rules, testing plan against figbird/testing,
and milestones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An unfiltered .all() materializes the complete row set, but only finds
consulted it — get(id) still hit the server on every cold-ref SWR pass,
which broke the declare-anywhere/reuse-aggressively story for its most
natural spelling (20 components reading q.people.get(x) against a
prepared q.people.all()).

#tryLocalGet mirrors #tryLocalFind's soundness argument: a present
entity in a complete set is the answer — realtime events, the reconnect
sweep, and complete-set fetch diffs keep it fresh. Deliberately
conservative at the edges: a miss still asks the server (completeness
makes local not-found sound in principle, but the miss is rare and a
roundtrip there avoids manufacturing NotFound errors from event-arrival
races), .get(id).where(...) conditions are always server-evaluated, and
network-only / .server() reads always go out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nestly

.get(id).where(...) conditions are exactly as locally evaluable as find
filters — same sift matcher, same custom-operator registry — so a
materialized service now answers conditional gets locally when the
predicate is decidable and matches. The genuinely-server cases remain:
non-local operators, and any local answer that would be an error
(missing id, failing predicate) — the server owns the adapter's real
error shape, so we don't synthesize one.

Writing the test exposed a latent pre-existing bug: gets always
classified as 'get', so a conditional get with a server-only operator
(.get(id).where({ $regex })) slipped past the server-maintained guard
and threw at materialize when #createItemFilter fed $regex to the sift
matcher. Conditional gets whose conditions aren't local-exact now
classify server-authoritative — the merge path never builds a matcher
for them, and realtime reconciles them by refetch like any other
non-local query.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… queries

Server-window queries previously refetched on every realtime event. The
visible page is a contiguous run of the server result and the window's
predicate is locally evaluable by construction, so many event effects are
provable from local state alone. mergeEventIntoWindow now merges those:

- in-place patches to visible rows (membership and sort position stable)
- underfilled windows resolve creates/removes/moves like local-exact
- items sorting strictly inside a full window insert and evict the overflow
- membership changes provably beyond the window adjust total only

Anything unprovable (removals from full windows, page-start shifts of $skip
windows, boundary ties) still reconciles by refetch.

New defaultSort constructor option declares the backend's implicit ordering
for queries without $sort so window maintenance can place items into
unsorted windows; materialized-service local finds apply it too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ten contracts

Structural fixes from a deep code-quality review of the branch:

- Fetch-plan policy is stated once: planRelation()/rootAllPages() in
  queryClassification.ts drive both the runtime (relation dispatch,
  allPages) and explain(), which no longer hand-mirrors the rules or
  mutates classification results (explainQueryNode grew snapshot/
  paginatedRoot options).
- Stored classification is trusted: classifyStoredQuery() owns the
  materialize-time policy; #tryLocalGet/#tryLocalFind check the stored
  class instead of re-deriving it. #tryLocalFind now honors
  fetchPolicy: 'network-only' like its get twin.
- Pure algebra extracted: window maintenance + event application to
  windowMaintenance.ts, relational assembly to relationalAssembly.ts,
  the canonical $sort comparator to sort.ts (now shared with
  figbird/testing, whose localeCompare copy could disagree with window
  maintenance ordering).
- figbird/testing cleaned before the API freezes: skipTotal moved out of
  the services map into options, queryAwareFind folded into
  MockService.find (fixing the lost delay support), installQueryAwareFind
  deleted.
- Contracts tightened: Adapter.peekId is required (optional broke every
  optimistic create on custom adapters), .related() with an unknown name
  throws at the builder instead of warn-and-guess, undeclared methods no
  longer type-check on the typed feathers client (use .call()).
- PagedQueryRoot: one subscription per page with a one-shot settle hook
  replaces the dual-subscription + isLoadingMore coordination; failed
  load-more pages are no longer transiently visible as root errors, and
  refetch() resets isLoadingMore instead of stranding it.
- Dedup/deletions: method-to-event map, matcher resolution,
  queryOfParams() for the params casts, useGetImpl/useFindImpl shared by
  root + kit hooks, ServiceRelationships/RelationshipOf type tower,
  SKIPPED_BUILDER, useAction's hand-copied event shape, unnecessary
  MutationDescriptor casts, DeriveMethods & AnyMethodsType /
  DeclaredKeys pair, dead builder accessors, dead guards. QueryStore
  solely owns events/mutations; Figbird reads through getters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ntract

Remaining nits from the review pass:

- queryClassification: collapse the three near-identical deep-walk
  skeletons (collectWindowReasons / collectServerOnlyReasons /
  hasWindowFilters) into a single walkQueryKeys visitor; explainQueryNode
  walks the query once, reason ordering unchanged.
- relationKey() in relationalAssembly names the dotted relation-path
  derivation once instead of four hand-spelled copies — the "every walk
  must key identically" invariant is structural now.
- Fan-in reuse guard spelled positively (fanIn/empty), matching its
  junction/perParent siblings.
- collectRelationalFilterDependencies takes the filter paths its caller
  already holds instead of re-deriving them (one walk, not two).
- FigbirdLike.query is generic over the builder: Figbird satisfies the
  slice structurally, so the `as unknown as FigbirdLike` hop and the
  lint-disabled getSnapshot(): any are gone — snapshots flow fully typed.
- launch.json: demo:client-preview config (second vite instance on 5273)
  for verifying changes without touching the main dev server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Whitespace-only reflow of files that drifted against the installed prettier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A component calling useQuery more than once fetches sequentially under
Suspense — the first call throws its promise before the second runs, so N
queries cost N round-trips. useQueries interns and subscribes every ref
first (which starts every fetch), then throws one Promise.all of the
pending suspense promises: one suspension for the whole set, all fetches
in flight at once.

Suspense-only by design — { suspense: false } useQuery calls never throw,
so N of them already run in parallel. Each result element carries the
useQuery suspense contract for its builder (data/error/isFetching/refetch):
a cold error on any query throws to the boundary (releasing every errored
ref for a clean retry), a refetch failure keeps last-good data with error
set. A .paginate() element widens with its own loadMore/hasMore/... family,
keyed off that builder's TKind, exactly like the single hook.

Wired into the createHooks kit and exported alongside useQuery; docs and
changelog updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blockers from the deep quality review:
- Optimistic remove now applies: one #planOptimistic decision point
  replaces the null-sentinel/skipUncached scatter; rollback path is live
- Custom operators classify local only at the top level (matching the
  matcher's peel) — nested usage refetches instead of crashing materialize()
- Compound keys deleted: hop fields are plain strings end to end; the
  tuple encoding and destField[0] fetch sites are gone
- Matcher OPERATORS/FILTERS derive from classification's canonical sets
- { suspense: false } carries the pagination surface (shared TKind widening)
- Drop the package-lock.json exports regression; pin ./testing explicitly

Follow-ups:
- Hooks share one suspense projection (ref.kind(), projectSuspenseResult);
  useQueryImpl delegates definition resolution to figbird.query()
- suspensePromiseAll owns the abandoned-suspension release in core
- Complete-set diff extracted to windowMaintenance.diffCompleteSet
- explain() walkers moved to queryClassification.explainQuery
- Junction dest hop rides the shared fan-in reconcile at `${key}#dest` —
  refreshDest and the two-null-mode dest object are deleted
- Adapter.findMeta replaces the fabricated Feathers envelope in core
- getId/peekId collapse to one warn-free getId; the store owns the warn
- Builder hash computed lazily, once per final builder

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant