Use query - #83
Open
KidkArolis wants to merge 127 commits into
Open
Conversation
…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>
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>
Add built-in Figbird devtools
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.