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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Skill/dev-relationship-loading-state.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"cardInfo": {
"notes": null,
"name": "Relationship Loading State",
"summary": "Read relationship status via getRelationshipMembershipState() — isLoading to drive a progress indicator, isLoaded to know a count or reduction over the field can be trusted; plus the eager option on a query-backed field",
"summary": "Read relationship status via getRelationshipMembershipState() — isLoading to drive a progress indicator, isLoaded to know membership is settled, and the totalMatchCount / isPartial that say whether a query-backed field holds its whole result set or only a bounded page; plus the eager option on a query-backed field",
"cardThumbnailURL": null
},
"commands": []
Expand Down
16 changes: 12 additions & 4 deletions Skill/dev-relationship-loading-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,27 @@
A `linksTo` / `linksToMany` field loads its linked card(s) lazily, and a **query-backed** `linksToMany` resolves by running a search. Until that load or search finishes, the field has no data to show. `getRelationshipMembershipState` lets a card author render a live progress indicator for that window — a spinner that appears while the field is in flight and clears the moment it resolves.

```ts
import { getRelationshipMembershipState } from '@cardstack/base/card-api';
import { getRelationshipMembershipState } from "@cardstack/base/card-api";
```

## The shape

`getRelationshipMembershipState(instance, fieldName)` returns one object for **every** `linksTo` / `linksToMany` field — query-backed or not:

```ts
{ isLoading: boolean; isLoaded: boolean; membership: RelationshipState[] | undefined }
{
isLoading: boolean;
isLoaded: boolean;
membership: RelationshipState[] | undefined;
totalMatchCount: number | undefined;
isPartial: boolean;
}
```

- **`isLoading`** — a whole-field boolean, `true` while the field's data is actually being fetched (a declared link still loading, or a query field's search running). It is **live**: backed by tracked state, so a template bound to it re-renders the instant the load settles.
- **`isLoaded`** — a whole-field boolean, `true` once membership is known and nothing is in flight. This is the one to gate on before reading a rollup over the field and trusting the number.
- **`isLoaded`** — a whole-field boolean, `true` once membership is known and nothing is in flight. It is the pair `isLoading` needs: a query-backed field nothing has resolved reports `isLoading: false` with no membership, which `isLoading` alone can't tell from a settled empty result. Settled is not the same as complete, so it is not on its own permission to trust a rollup — see `isPartial` below.
- **`membership`** — the per-element resolution(s). For the loading-indicator use case you read `isLoading`; the per-slot states in `membership` are covered in the **Defensive Link Traversal** skill.
- **`totalMatchCount` / `isPartial`** — query-backed fields only. A query field holds a _bounded page_ of its query, so a settled membership can still be a prefix: `totalMatchCount` is what the query matches and `isPartial` says the rows fall short of it. `isLoaded` alone is therefore not permission to reduce over the rows. Sizing a field, and reading a count without holding the rows, are covered in the **Query-Backed Relationships** skill; a declared link reports `totalMatchCount: undefined` / `isPartial: false`.

### Why there are two booleans

Expand Down Expand Up @@ -119,7 +126,7 @@ get petLoading() {
<span>{{@model.pet.firstName}}</span>
```

For a declared `linksToMany`, **`isLoading` stays `true` until *every* element has settled** — a half-loaded list still reports loading.
For a declared `linksToMany`, **`isLoading` stays `true` until _every_ element has settled** — a half-loaded list still reports loading.

## Live queries re-enter the loading state

Expand All @@ -132,6 +139,7 @@ A query-backed field is **live**: when its inputs change (here, `cardTitle`) the
- It is **observe-only**: reading the status never starts a load. **Always render the field alongside its status, and never gate the read on it** — a query-backed field usually resolves with its owner, but not during indexing or prerender, not on a query field declared on a contained `FieldDef`, not on a card with no id yet, and not under `eager: false`.
- The flagship use case is a **query-backed `linksToMany`** (a search-driven list): show a spinner while the search runs.
- A declared `linksToMany` reports `isLoading: true` until **every** element settles.
- `isLoaded` means settled, not complete — a query-backed field truncated by its page ceiling is settled too. Check `isPartial`, and read `totalMatchCount` for a count. See the **Query-Backed Relationships** skill.
- A live query **re-enters** loading on each re-run; the spinner reappears for free.
- `eager: false` defers an expensive query-backed field to first access; the rule above then applies to it too.
- To read per-element state (present / loading / broken), see the **Defensive Link Traversal** skill — `membership` and `RelationshipState` are covered there.
38 changes: 38 additions & 0 deletions Skill/query-backed-relationships.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"data": {
"meta": {
"adoptsFrom": {
"name": "SkillPlusMarkdown",
"module": "@cardstack/base/skill-plus"
}
},
"type": "card",
"attributes": {
"cardTitle": "Query-Backed Relationships",
"cardInfo": {
"notes": null,
"name": "Query-Backed Relationships",
"summary": "Declaring and sizing a query-backed linksTo/linksToMany: the bounded page it actually holds, reading totalMatchCount instead of counting rows, opting into a larger page, and when to reach for a search component instead.",
"cardThumbnailURL": null
},
"commands": [],
"cardDescription": "Declaring and sizing a query-backed linksTo/linksToMany: the bounded page it actually holds, reading totalMatchCount instead of counting rows, opting into a larger page, and when to reach for a search component instead."
},
"relationships": {
"cardInfo.theme": {
"links": {
"self": "@cardstack/base/Theme/cardstack-brand-guide"
}
},
"instructionsSource": {
"links": {
"self": "../skills/query-backed-relationships/SKILL.md"
},
"data": {
"type": "file",
"id": "../skills/query-backed-relationships/SKILL.md"
}
}
}
}
}
1 change: 1 addition & 0 deletions index.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Every skill lives in `skills/` and auto-activates on its description triggers
- **[`boxel/`](skills/boxel/SKILL.md)** — Cardinal rules + 18 references for CardDef, FieldDef, templates, queries, formats, commands.
- **[`boxel-workspace-cardinal-rules/`](skills/boxel-workspace-cardinal-rules/SKILL.md)** — Silent-failure trap checklist: rules that pass lint (and often indexing), then corrupt the realm index, crash at render, or drop data with no error. Mandatory pre-flight read for any card work (see Pre-flight above); check every card/field against it before finishing.
- **[`bxl-authoring/`](skills/bxl-authoring/SKILL.md)** — Writing BXL in a card's `computeVia`: which of the three call-site forms to reach for, what the `derive` profile refuses, aggregating over linked and query-backed collections, and the traps that produce a plausible wrong value instead of an error.
- **[`query-backed-relationships/`](skills/query-backed-relationships/SKILL.md)** — The `{ query }` form of `linksTo`/`linksToMany`: the bounded page it holds rather than the whole match set, reading `totalMatchCount` instead of counting rows, declaring a larger page, `eager: false`, and when a search component is the right tool instead.
- **[`source-code-editing/`](skills/source-code-editing/SKILL.md)** — SEARCH/REPLACE block format. Required before any `.gts` edit.
- **[`ember-best-practices/`](skills/ember-best-practices/SKILL.md)** — Ember.js performance + accessibility rules (59 rules across 10 categories) for writing, reviewing, or refactoring Ember code.

Expand Down
12 changes: 10 additions & 2 deletions skills/boxel/references/relationship-loading-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,19 @@ import { getRelationshipMembershipState } from '@cardstack/base/card-api';
`getRelationshipMembershipState(instance, fieldName)` returns one object for **every** `linksTo` / `linksToMany` field — query-backed or not:

```ts
{ isLoading: boolean; isLoaded: boolean; membership: RelationshipState[] | undefined }
{
isLoading: boolean;
isLoaded: boolean;
membership: RelationshipState[] | undefined;
Comment thread
habdelra marked this conversation as resolved.
totalMatchCount: number | undefined;
isPartial: boolean;
}
```

- **`isLoading`** — a whole-field boolean, `true` while the field's data is actually being fetched (a declared link still loading, or a query field's search running). It is **live**: backed by tracked state, so a template bound to it re-renders the instant the load settles.
- **`isLoaded`** — a whole-field boolean, `true` once membership is known and nothing is in flight. This is the one to gate on before reading a rollup over the field and trusting the number.
- **`isLoaded`** — a whole-field boolean, `true` once membership is known and nothing is in flight. It is the pair `isLoading` needs: a query-backed field nothing has resolved reports `isLoading: false` with no membership, which `isLoading` alone can't tell from a settled empty result. Settled is not the same as complete, so it is not on its own permission to trust a rollup — see `isPartial` below.
- **`membership`** — the per-element resolution(s). For the loading-indicator use case you read `isLoading`; the per-slot states in `membership` are covered in [`defensive-link-traversal.md`](defensive-link-traversal.md).
- **`totalMatchCount` / `isPartial`** — query-backed fields only. A query field holds a *bounded page* of its query, so a settled membership can still be a prefix: `totalMatchCount` is what the query matches and `isPartial` says the rows fall short of it. `isLoaded` alone is therefore not permission to reduce over the rows. Sizing a field, and reading a count without holding the rows, are covered in [`query-backed-relationships`](../../query-backed-relationships/SKILL.md); a declared link reports `totalMatchCount: undefined` / `isPartial: false`.

### Why there are two booleans

Expand Down Expand Up @@ -120,6 +127,7 @@ A query-backed field is **live**: when its inputs change (here, `cardTitle`) the
- It is **observe-only**: reading the status never starts a load. **Always render the field alongside its status, and never gate the read on it** — a query-backed field usually resolves with its owner, but not during indexing or prerender, not on a query field declared on a contained `FieldDef`, not on a card with no id yet, and not under `eager: false`.
- The flagship use case is a **query-backed `linksToMany`** (a search-driven list): show a spinner while the search runs.
- A declared `linksToMany` reports `isLoading: true` until **every** element settles.
- `isLoaded` means settled, not complete — a query-backed field truncated by its page ceiling is settled too. Check `isPartial`, and read `totalMatchCount` for a count. → [`query-backed-relationships`](../../query-backed-relationships/SKILL.md)
- A live query **re-enters** loading on each re-run; the spinner reappears for free.
- `eager: false` defers an expensive query-backed field to first access; the rule above then applies to it too.
- To read per-element state (present / loading / broken), see [`defensive-link-traversal.md`](defensive-link-traversal.md) — `membership` and `RelationshipState` are covered there.
21 changes: 17 additions & 4 deletions skills/bxl-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,12 +221,22 @@ the part worth understanding before you aggregate over one:
- The browser resolves the inverse live during render, so the number a viewer
sees can be the converged one while the indexed value — the one search filters
and sorts on — is still from the last visit.
- It resolves a **bounded page** of its query, not the whole match set — 500
rows by default. An aggregate over an inverse with more matches than that
reduces over the first page and reports a confidently wrong number, with
nothing in the card to say so. `COUNT([Claims[]])` over 600 claims is 500.

Guidance: aggregate over query-backed inverses for display and reporting; do not
treat such a field as a promptly-correct index-time fact, and do not build a
filter or sort that depends on it being current. When the aggregate must be
index-accurate, put the edge on the aggregating card (a stored `linksToMany`)
so a write to either side invalidates it. Where you accept the lag, say so in a
filter or sort that depends on it being current. Before reducing over one,
check the match count against the rows in hand:
`getRelationshipMembershipState(this, 'claims')` reports `totalMatchCount` and
`isPartial`. A **count-shaped** rollup should read `totalMatchCount` rather than
aggregate at all, which sidesteps the page ceiling entirely; where the rows
themselves are needed, the field must declare a page big enough to hold them —
see [`query-backed-relationships`](../query-backed-relationships/SKILL.md).
When the aggregate must be index-accurate, put the edge on the aggregating card
(a stored `linksToMany`) so a write to either side invalidates it. Where you accept the lag, say so in a
comment at the field — the indexed value is server-computed and may differ from
the one on screen, and the next reader has no other way to tell that was a
choice.
Expand Down Expand Up @@ -344,7 +354,10 @@ and fails to identify.
is guarded or rewritten as `CONCAT` / `TEXTJOIN`.
4. Date output is a serial or a span, not a rendered phrase.
5. Aggregates over query-backed inverses are display values, not filter or sort
keys — and a field left to lag says so in a comment.
keys — and a field left to lag says so in a comment. The inverse holds a
bounded page (500 by default), so an aggregate over one that can exceed it is
short unless the field declares a bigger page; a count reads
`totalMatchCount` instead of aggregating.
6. Output shaped as an object or an array of objects for a `FieldDef`-typed field
has `{ as: … }`.
7. Every function name is spelled the way the catalog spells it — a typo indexes
Expand Down
5 changes: 4 additions & 1 deletion skills/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ Entry shape: `**Term** — one-sentence definition + (optional) where it's cover
- **`linksTo(CardDef)`** — Single link to another card. Serializes under `relationships.<field>.links.self`.
- **`linksToMany(CardDef)`** — Multiple links. Serializes as indexed keys `field.0`, `field.1` (NOT a JSON:API `data` array).
- **`searchable` field option** — Per-field `true | string | string[]` on a `linksTo`/`linksToMany` deciding which linked targets are followed into the card's **search doc** (contained fields are always in; links are opt-in). Dotted-path routing; querying a non-searchable path errors. → `boxel/references/searchable-fields.md`
- **`getRelationshipMembershipState(this, 'field')`** — Live `{ isLoading, isLoaded, membership }` for a `linksTo`/`linksToMany`; bind `.isLoading` to drive a spinner, gate on `.isLoaded` before trusting a count or reduction (flagship: query-backed `linksToMany`). Observe-only — always read the field itself, never behind its own status. → `boxel/references/relationship-loading-state.md`
- **`getRelationshipMembershipState(this, 'field')`** — Live `{ isLoading, isLoaded, membership, totalMatchCount, isPartial }` for a `linksTo`/`linksToMany`; bind `.isLoading` to drive a spinner (flagship: query-backed `linksToMany`). Observe-only — always read the field itself, never behind its own status, or the load never starts. `isLoaded` means settled, which a truncated set also is; `isPartial` is what licenses a reduction over the rows. → `boxel/references/relationship-loading-state.md`, `query-backed-relationships/SKILL.md`
- **`totalMatchCount` / `isPartial`** — On a query-backed relationship's status: how many instances the query **matches** (from the search's own `COUNT(*)`, which no page bounds) and whether the rows in hand fall short of it. A count-shaped rollup reads `totalMatchCount` instead of `field.length` and sidesteps the page ceiling entirely. Absent means *unknown* (unresolved, or a targeted realm failed), never zero. → `query-backed-relationships/SKILL.md`
- **query-field page ceiling** — A query-backed `linksTo`/`linksToMany` holds a bounded page of its query, not the whole match set: 500 by default, up to 2000 if the field declares `page: { size: N }`, clamped (and logged by the realm) above that. Counting `field.length` over a larger match set is the silent trap. → `query-backed-relationships/SKILL.md`
- **linked-slot `undefined` contract** — Reading a `linksTo`/`linksToMany` is not like `contains`: a slot is `undefined` while loading and forever if broken; `linksToMany` keeps broken/unloaded slots as `undefined` holes (`arr.length` unchanged). `.filter(Boolean)` before count/render; guard every traversal. Per-slot state via `getRelationshipMembershipState` / `RelationshipState` (`present | not-loaded | error | not-found | not-set`). → `boxel/references/defensive-link-traversal.md`
- **broken-link placeholder** — The DOM placeholder Boxel renders for a broken `linksTo`/`linksToMany` target — the canonical "something's wrong" signal, exposed via `data-test-broken-link-*` attributes (`error` vs `not-found`). Follow the URL to the linked instance to remediate. → `boxel-environment/references/diagnosing-broken-links.md`
- **`computeVia: fn`** — Derive a field's value from other fields. Function runs on each access; mark with `cacheable: true` for expensive computations.
Expand Down Expand Up @@ -347,6 +349,7 @@ Use the namespaced CLI published from the Boxel monorepo through `npx boxel`. Th
- **`boxel-skill-authoring`** — SKILL.md format contract for user-authored skills: `boxel.kind: skill` frontmatter, tool declarations, verify loop.
- **`boxel-workspace-cardinal-rules`** — Silent-failure trap checklist (DateField vs DateTimeField formats, external URLs in relationship links, `linksToMany` indexed keys, …); partially overlaps the `boxel` skill's cardinal rules under its own numbering.
- **`bxl-authoring`** — Writing BXL in a card's `computeVia`: tag choice (plain string / `fx` / `jq`), what the `derive` profile refuses at field-definition time, collecting an aggregate's iterating argument, blank-input and error-value behavior, query-backed aggregation staleness, cyclic graphs, dates, memoization, `{ as: FieldDef }` materialization.
- **`query-backed-relationships`** — Declaring and sizing the `{ query }` form of `linksTo`/`linksToMany`: the bounded page it holds, `totalMatchCount` vs counting rows, declaring a larger page, `eager: false`, singular-`linksTo` arity, and when a search component is the right tool instead.
- **`boxel-ui-component-discovery`** — Mandatory catalog Spec search before hand-rolling UI primitives; enumerate → one broad `boxel search` query → read `attributes.readMe` → self-audit.
- **`ember-best-practices`** — Ember.js performance + accessibility rules, 59 `rules/*.md` files across 10 prefix-keyed categories, indexed in its SKILL.md.
- **`catalog-listing`** — Catalog operations + submission via `SubmissionWorkflowCard`.
Expand Down
Loading