fix: resolve deferred joined fields from the List stream itself - #21
Merged
Conversation
Query.List deferred the display-only joined groups and returned their names as a third tuple element for the list route to materialize on the page. Consumers that destructure [query, total] alone (the cms buildFilteredQuery among them) lost that set, so the deferred @joined fields never resolved: lists came back without the joined columns and search filters on them matched nothing. Wrap the returned stream instead: it materializes the deferred groups itself as soon as an operation can observe them, keeps the lookups after slice/nth so they still only run on the returned page, skips them for bare count() calls and for field aggregates on non-joined fields (left-join lookups change neither the row count nor other fields, and a joined-field aggregate only materializes its own group), and attaches changes() to the raw stream since change feeds cannot carry the lookups. The third tuple element is now empty by default; the list route opts back into the raw stream with exposeDeferredJoined to keep restricting the page lookups to the plucked fields, and a db-less call also returns the raw stream with the unresolved names since no lookup can be built without a database.
Member
Author
|
@greptile review |
Routing nth() through the datum form of Joined left the temporary __joined_orig_* key on the returned row: only the stream form drops it, since neither Datum nor ValueProxy exposes without(). Slice the single row first and join the stream instead, which also keeps the lookup on that row alone. Also correct the docs: the page-only benefit holds while paging is the first operation the caller applies. A filter, a sort or the lookup Query.Foreign stages for a @foreign field materializes the groups over the whole matched set (still outside the count pipeline); callers that need them strictly on the page have exposeDeferredJoined.
Member
Author
|
@greptile review |
Upd4ting
pushed a commit
that referenced
this pull request
Aug 5, 2026
…#22) * fix: resolve deferred joined fields from the List stream itself Query.List deferred the display-only joined groups and returned their names as a third tuple element for the list route to materialize on the page. Consumers that destructure [query, total] alone (the cms buildFilteredQuery among them) lost that set, so the deferred @joined fields never resolved: lists came back without the joined columns and search filters on them matched nothing. Wrap the returned stream instead: it materializes the deferred groups itself as soon as an operation can observe them, keeps the lookups after slice/nth so they still only run on the returned page, skips them for bare count() calls and for field aggregates on non-joined fields (left-join lookups change neither the row count nor other fields, and a joined-field aggregate only materializes its own group), and attaches changes() to the raw stream since change feeds cannot carry the lookups. The third tuple element is now empty by default; the list route opts back into the raw stream with exposeDeferredJoined to keep restricting the page lookups to the plucked fields, and a db-less call also returns the raw stream with the unresolved names since no lookup can be built without a database. * docs: document deferred joined resolution on lists * fix: keep nth() joins on the stream form and document the paging caveat Routing nth() through the datum form of Joined left the temporary __joined_orig_* key on the returned row: only the stream form drops it, since neither Datum nor ValueProxy exposes without(). Slice the single row first and join the stream instead, which also keeps the lookup on that row alone. Also correct the docs: the page-only benefit holds while paging is the first operation the caller applies. A filter, a sort or the lookup Query.Foreign stages for a @foreign field materializes the groups over the whole matched set (still outside the count pipeline); callers that need them strictly on the page have exposeDeferredJoined.
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.
🔗 Linked issue
Follow-up of #17 / #18 — fixes the regression they introduced for direct
Query.Listconsumers (AntelopeJS/cms#268 was the closed CMS-side attempt; per review verdict the resolution belongs to this layer, not to each consumer).❓ Type of change
📚 Description
Regression. Since v0.1.6 (#18),
Query.Listdefers the@Joinedgroups that no sort or filter references and returns their names as a third tuple element, leaving their page-time resolution to the caller. Consumers that destructure[query, total]alone — the cmsbuildFilteredQueryamong them — silently lost that set: member lists came back without name/email and the global search on joined fields matched nothing (template-cms-demo onboarding repro).Fix — the layer resolves deferred fields itself.
Query.Listnow returns a self-resolving stream (internalDeferredJoinedStream, prototype wired fromStream.prototypeso any future stream operation defaults to the safe path):slice/nthappend the lookups after the page boundary — the perf win of perf: defer unused joined-field lookups to the paginated page #18 is preserved for plain pagination, now for every consumer;count()and field aggregates on non-joined fields skip the lookups entirely; a joined-field aggregate only materializes its own group;changes()attaches to the raw stream (change feeds cannot carry lookups);filter,orderBy,map, …) materializes the deferred groups first, since it may observe them;build()serializes the materialized pipeline, so a wrapper embedded as an argument of another query does not leak unjoined rows.The third tuple element is now empty by default (nothing left for the caller to resolve). The default list route keeps its finer pluck-aware page lookups by passing the new
{ exposeDeferredJoined: true }option, which returns the raw stream and the real set — the 0.1.6 contract, now opt-in. A db-less call also returns the raw stream with the unresolved names, since no lookup can be built without a database. A consumer written against the 0.1.6 contract that feeds the (now empty) set toQuery.Joinedgets a no-op — no double joins.Validation.
slice, none in the count pipeline —, caller-added filter/orderBy, direct await, async iteration,cast,nth, union-as-argument, aggregates,changes, the no-db case and theexposeDeferredJoinedcontract).main(876e9e0e, zero cms changes) + this branch linked in place of the published package, plus only the two HTTP repro test commits from AntelopeJS/cms#268 (onboarding → login → members list/search). Result: members 3 passing (they fail against published 0.1.6/0.1.7), full integration 6 passing, unit 198 passing.Deployment note — publishing is not part of this PR. npm publishing permissions belong to Thomas: this package must be released, and the shell module
@antelopejs/data-apimust re-resolve its pinned dependency and be republished, since the AntelopeJS resolver serves every consumer's interface imports from the provider module's copy (a consumer-side dependency bump alone does not deploy the fix).Out of scope, noticed while testing: the mongodb module's
readCursorcrashes withTypeError: Cannot read properties of null (reading '_wrapped')when a cursor over any map/merge pipeline is read to exhaustion — pre-existing driver bug, worth its own issue.Review follow-up (3rd commit).
nth()now resolves through the stream form ofJoined: the datum form cannot drop its staged__joined_orig_*key (neitherDatumnorValueProxyexposeswithout()), so a rawnth()consumer saw it on the row. The docs also now state the honest boundary of the page-only benefit — it holds while paging is the caller's first operation; a filter, a sort, or thelookupthatQuery.Foreignstages for a@Foreignfield materializes the groups over the whole matched set (still outside the count pipeline). Callers needing them strictly on the page haveexposeDeferredJoined.📝 Checklist
Greptile Summary
Implements self-resolving deferred joined fields for
Query.List.DeferredJoinedStreamwrapper that materializes joins according to the requested stream operation.Confidence Score: 5/5
The PR appears safe to merge.
No blocking failure remains.
Important Files Changed
Query.List.Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Query.List] --> B{Expose deferred joins<br/>or database unavailable?} B -->|Yes| C[Return raw stream and deferred names] B -->|No| D[Return DeferredJoinedStream] D --> E{Consumer operation} E -->|slice or nth| F[Page raw stream, then resolve joins] E -->|count or plain-field aggregate| G[Use raw stream] E -->|changes| H[Attach to raw stream] E -->|Other operation| I[Resolve joins before operation]Reviews (3): Last reviewed commit: "fix: keep nth() joins on the stream form..." | Re-trigger Greptile