Skip to content

fix: resolve deferred joined fields from the List stream itself - #21

Merged
Upd4ting merged 3 commits into
tmp/fixes-batchfrom
fix/self-resolving-deferred-joined
Jul 28, 2026
Merged

fix: resolve deferred joined fields from the List stream itself#21
Upd4ting merged 3 commits into
tmp/fixes-batchfrom
fix/self-resolving-deferred-joined

Conversation

@MrSociety404

@MrSociety404 MrSociety404 commented Jul 27, 2026

Copy link
Copy Markdown
Member

🔗 Linked issue

Follow-up of #17 / #18 — fixes the regression they introduced for direct Query.List consumers (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

  • 📖 Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality like performance)
  • ✨ New feature (a non-breaking change that adds functionality)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

Regression. Since v0.1.6 (#18), Query.List defers the @Joined groups 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 cms buildFilteredQuery among 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.List now returns a self-resolving stream (internal DeferredJoinedStream, prototype wired from Stream.prototype so any future stream operation defaults to the safe path):

  • slice/nth append 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;
  • bare 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);
  • any other operation (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 to Query.Joined gets a no-op — no double joins.

Validation.

  • This repo: 89 passing (75 existing + 14 new unit tests covering the two-element destructure, pipeline shape — lookups after 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 the exposeDeferredJoined contract).
  • CMS harness: virgin cms 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-api must 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 readCursor crashes with TypeError: 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 of Joined: the datum form cannot drop its staged __joined_orig_* key (neither Datum nor ValueProxy exposes without()), so a raw nth() 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 the lookup that Query.Foreign stages for a @Foreign field materializes the groups over the whole matched set (still outside the count pipeline). Callers needing them strictly on the page have exposeDeferredJoined.

📝 Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

Greptile Summary

Implements self-resolving deferred joined fields for Query.List.

  • Adds a DeferredJoinedStream wrapper that materializes joins according to the requested stream operation.
  • Preserves raw deferred-field exposure for the default list route and database-less callers.
  • Documents deferred list resolution and adds coverage for pagination, aggregates, change feeds, composition, and iteration.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/components.ts Adds operation-aware deferred-join stream wrapping and integrates it into Query.List.
src/index.ts Makes the default list route explicitly retain the raw deferred-join contract.
src/tests/components/deferred_joined.test.ts Adds broad behavioral and pipeline-shape coverage for deferred joined fields.
docs/10.joined.md Documents deferred resolution, operation behavior, and the opt-in raw-stream contract.

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]
Loading

Reviews (3): Last reviewed commit: "fix: keep nth() joins on the stream form..." | Re-trigger Greptile

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.
@MrSociety404

Copy link
Copy Markdown
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.
@MrSociety404

Copy link
Copy Markdown
Member Author

@greptile review

@Upd4ting
Upd4ting merged commit ce7eb71 into tmp/fixes-batch Jul 28, 2026
3 checks passed
@Upd4ting
Upd4ting deleted the fix/self-resolving-deferred-joined branch July 28, 2026 23:59
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.
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.

2 participants