Skip to content
Open
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
83 changes: 56 additions & 27 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -532,43 +532,72 @@ export interface StoreSearchResource<T extends CardDef | FileDef = CardDef> {
// the server's own result set rather than the reconciled one, so a locally
// edited or created card can't be mistaken for a short page.
readonly isPartial: boolean;
// Hand a running resource the result set a document fetched since it started
// carries. Optional: a store whose resources hold no state worth superseding
// implements no supersession.
reseed?(seed: StoreSearchSeed<T>): void;
// The identity of the seeded result set the resource holds, and `undefined`
// once a search has re-derived that set for itself. Read it to decide whether
// a seed is worth handing over: a remembered "last seed applied" would go on
// claiming a set the resource has since replaced, and would then skip a
// document restoring the earlier one.
readonly appliedSeedIdentity?: string;
}

export type GetSearchResourceFuncOpts = {
// A result set a producer already resolved, handed to a search resource in
// place of running the query. Generic in the row type so a `FileDef` search
// seeds with file-meta rows rather than being narrowed to `CardDef`.
export type StoreSearchSeed<T extends CardDef | FileDef = CardDef> = {
cards: T[];
// What this result set is, as against any other the same query could
// produce: two seeds sharing an identity assert the same thing, so a
// resource already holding one ignores the other. It has to cover
// everything the seed asserts and not just its rows — a page-clamped
// field whose match count moved holds the same row and a different
// answer.
identity?: string;
// The index generation this set was resolved at. Separate from the identity
// and doing a different job: the identity says whether two sets differ, this
// says which of them is newer. Keeping the generation out of the identity is
// deliberate — a realm generation moves on every write anywhere in the realm,
// so folding it in would make every set look different from every other and
// re-apply answers that had not changed.
generation?: number;
searchURL?: string;
realms?: string[];
queryErrors?: Array<{
realm: string;
type: string;
message: string;
status?: number;
}>;
// IDs the parent doc named in `relationships.{field}.data`. Used
// by the SearchResource when `cards` is empty and the parent
// skipped query-backed expansion — the resource loads each ID by
// URL instead of running a live re-query.
cardURLs?: string[];
// The result meta the seed was resolved under, chiefly `page.total` —
// the query's match count, which exceeds `cards.length` when the page
// ceiling clamped the expansion. Absent it, the resource takes the
// record count for the total and a truncated seed reads as complete.
meta?: QueryResultsMeta;
// The seed's match count is not knowable and must not be inferred from its
// rows — the producer resolved the field but deliberately reported no
// total, as a query-backed field does when one of its realms failed.
totalUnknown?: boolean;
};

export type GetSearchResourceFuncOpts<T extends CardDef | FileDef = CardDef> = {
isLive?: boolean;
doWhileRefreshing?: (() => void) | undefined;
dependencyTracking?: RuntimeDependencyTrackingContext;
seed?: {
cards: CardDef[];
searchURL?: string;
realms?: string[];
queryErrors?: Array<{
realm: string;
type: string;
message: string;
status?: number;
}>;
// IDs the parent doc named in `relationships.{field}.data`. Used
// by the SearchResource when `cards` is empty and the parent
// skipped query-backed expansion — the resource loads each ID by
// URL instead of running a live re-query.
cardURLs?: string[];
// The result meta the seed was resolved under, chiefly `page.total` —
// the query's match count, which exceeds `cards.length` when the page
// ceiling clamped the expansion. Absent it, the resource takes the
// record count for the total and a truncated seed reads as complete.
meta?: QueryResultsMeta;
// The seed's match count is not knowable and must not be inferred from its
// rows — the producer resolved the field but deliberately reported no
// total, as a query-backed field does when one of its realms failed.
totalUnknown?: boolean;
};
seed?: StoreSearchSeed<T>;
};
export type GetSearchResourceFunc<T extends CardDef | FileDef = CardDef> = (
parent: object,
getQuery: () => Query | undefined,
getRealms?: () => string[] | undefined,
opts?: GetSearchResourceFuncOpts,
opts?: GetSearchResourceFuncOpts<T>,
) => StoreSearchResource<T>;

export interface CardStore {
Expand Down
186 changes: 148 additions & 38 deletions packages/base/query-field-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,27 @@ interface QueryFieldState {
message: string;
status?: number;
}>;
// Identity of the result set the owner's most recent document carried. Only
// an indexer-resolved umbrella gets one — a raw source document carries no
// authoritative answer, so it can never supersede one — and comparing it
// against the identity the search resource holds is what lets a document
// fetched after the resource started hand it a fresher result set.
seedIdentity?: string;
// The index generation the owner's document was serialized at, off
// `meta.generation`. What the resource compares against its own result set to
// order the two, so a document read before a search that has since completed
// does not overwrite it.
seedGeneration?: number;
// A document has been captured that no resource has been offered yet. Set by
// every capture and cleared the first time a read acts on it, so a running
// resource is offered a result set once per document fetched for the owner.
//
// This is what keeps the offer tied to a document arriving rather than to a
// field being read: a search re-derives the set for itself and reports no
// seeded identity afterwards, and without this gate the next read would hand
// the last document's answer straight back over the fresher one the search
// just produced.
seedHandoverPending?: boolean;
searchResource?: StoreSearchResource;
renderCycleBarrier?: Promise<void>;
// The sentinel `surfaceSearchResourceErrorState` planted on the most
Expand Down Expand Up @@ -140,6 +161,38 @@ export function ensureQueryFieldSearchResource(
log.debug(
`ensureQueryFieldSearchResource: reusing existing resource from fieldState for field=${field.name}`,
);
// A document fetched after the resource started carries this field resolved
// as of that read, which supersedes what the resource holds: the resource's
// own refresh is driven by realm events for the realms its query targets,
// so it is behind for a write it never heard about — a subscription gap, or
// a query whose realms don't include the one the owner was written to.
// Handing that result set over costs nothing, because the document already
// paid for the resolution.
//
// Two gates, and both are needed. The outer one spends the document: the
// offer belongs to a document arriving, so a plain read never hands an
// answer back over a search that has since produced a fresher one. The
// inner one declines an offer the resource is already holding, asking the
// resource rather than remembering the last identity handed over — a
// memory would go on claiming a set a search had replaced, and would then
// turn away the document that corrects it.
if (fieldState.seedHandoverPending) {
fieldState.seedHandoverPending = false;
let seedIdentity = fieldState.seedIdentity;
if (
seedIdentity &&
searchResource.reseed &&
seedIdentity !== searchResource.appliedSeedIdentity
) {
let seed = queryFieldSeed(fieldState);
if (seed) {
log.info(
`ensureQueryFieldSearchResource: applying refreshed seed for field=${field.name}; count=${seed.cards.length}`,
);
searchResource.reseed(seed);
}
}
}
surfaceSearchResourceErrorState(
fieldState,
instance,
Expand All @@ -149,8 +202,7 @@ export function ensureQueryFieldSearchResource(
return searchResource;
}

let seedRecords = fieldState?.seedRecords;
let seedSearchURL = fieldState?.seedSearchURL;
let seedRecords = fieldState.seedRecords;
let args = () => {
return resolveQueryAndRealm(store, instance, field, fieldDefinition);
};
Expand Down Expand Up @@ -204,45 +256,12 @@ export function ensureQueryFieldSearchResource(
{
isLive,
dependencyTracking: trackingContext,
seed: seedRecords
? {
cards: seedRecords,
searchURL: seedSearchURL ?? undefined,
realms: fieldState?.seedRealms,
queryErrors: fieldState?.seedErrors,
cardURLs: fieldState?.seedCardURLs,
// What the resource is allowed to believe about the match count,
// in order of how much is known. A count the indexer reported
// passes through as the count. Where it reported none but recorded
// a realm failure, the rows in hand are labelled a floor — that
// says both that the count is unknown and why, which is what turns
// into the field's shortfall signal. Where it reported none and no
// realm failed, the count is simply unknowable and says so.
//
// The ordering matters because the fallback is inference: absent
// any of these the resource takes the total from the record count,
// and a set short by a realm nobody could count would read as the
// whole of it — a confident number over an incomplete set, which is
// the failure this field's status exists to report rather than
// reproduce. An ordinary seed reaches that inference legitimately,
// because there nothing was withheld.
...(fieldState?.seedTotal != null
? { meta: { page: { total: fieldState.seedTotal } } }
: fieldState?.seedErrors?.length
? {
meta: {
page: { total: seedRecords.length },
incomplete: true,
},
}
: seedSearchURL != null
? { totalUnknown: true }
: {}),
}
: undefined,
seed: queryFieldSeed(fieldState),
},
);
fieldState.searchResource = searchResource;
// The document's result set went in with the resource, so it is spent.
fieldState.seedHandoverPending = false;
trackQueryFieldLoads(store, field.name, fieldState);
surfaceSearchResourceErrorState(fieldState, instance, field, searchResource);
// Bridge `getRelationshipMembershipState(...).isLoading` to this freshly-created resource:
Expand Down Expand Up @@ -689,6 +708,97 @@ export function captureQueryFieldSeedData(
Number.isFinite(seedTotal)
? seedTotal
: undefined;
fieldState.seedIdentity = seedIdentityFor(fieldState);
// The generation the row this document was serialized from was written at.
// Absent where the serialization did not come off the index — a freshly built
// resource that was never persisted — in which case the field's result set is
// ordered by identity alone, as it was before any generation was available.
let generation = (resource.meta as { generation?: unknown } | undefined)
?.generation;
fieldState.seedGeneration =
typeof generation === 'number' ? generation : undefined;
fieldState.seedHandoverPending = true;
}

// The identity of an authoritative result set. An unauthoritative one has no
// identity, so it can never be mistaken for a fresher answer than the one a
// resource holds.
//
// It covers every part of the answer `queryFieldSeed` builds, not just the
// rows: a page-clamped field gains a match it cannot surface and reports the
// same row against a higher count, and a realm that stops answering leaves the
// rows it did contribute while turning the count into a floor. Identifying a
// result set by its rows alone would call both of those the answer already in
// hand and leave the field reporting a shortfall of none.
function seedIdentityFor(fieldState: QueryFieldState): string | undefined {
if (!fieldState.seedSearchURL) {
return undefined;
}
let ids =
fieldState.seedCardURLs ??
(fieldState.seedRecords ?? [])
.map((card) => card.id)
.filter((id) => Boolean(id));
let unreachableRealms = (fieldState.seedErrors ?? [])
.map((error) => error.realm)
.sort();
return [
fieldState.seedSearchURL,
ids.join(','),
fieldState.seedTotal ?? '',
unreachableRealms.join(','),
].join('\n');
}

// The result set the owner's most recent document produced, in the shape the
// search resource consumes. `undefined` when the document resolved nothing for
// this field, which is the resource's signal to answer from a live query.
//
// Shared by resource creation and supersession so both describe the same set
// the same way: the count semantics below are what the field's shortfall signal
// reads, and a supersession that inferred a different count would report a
// shortfall the document never claimed.
function queryFieldSeed(fieldState: QueryFieldState) {
let seedRecords = fieldState.seedRecords;
if (!seedRecords) {
return undefined;
}
let seedSearchURL = fieldState.seedSearchURL;
return {
cards: seedRecords,
identity: fieldState.seedIdentity,
generation: fieldState.seedGeneration,
searchURL: seedSearchURL ?? undefined,
realms: fieldState.seedRealms,
queryErrors: fieldState.seedErrors,
cardURLs: fieldState.seedCardURLs,
// What the resource is allowed to believe about the match count, in order
// of how much is known. A count the indexer reported passes through as the
// count. Where it reported none but recorded a realm failure, the rows in
// hand are labelled a floor — that says both that the count is unknown and
// why, which is what turns into the field's shortfall signal. Where it
// reported none and no realm failed, the count is simply unknowable and
// says so.
//
// The ordering matters because the fallback is inference: absent any of
// these the resource takes the total from the record count, and a set short
// by a realm nobody could count would read as the whole of it — a confident
// number over an incomplete set, which is the failure this field's status
// exists to report rather than reproduce. An ordinary seed reaches that
// inference legitimately, because there nothing was withheld.
...(fieldState.seedTotal != null
? { meta: { page: { total: fieldState.seedTotal } } }
: fieldState.seedErrors?.length
? {
meta: {
page: { total: seedRecords.length },
incomplete: true,
},
}
: seedSearchURL != null
? { totalUnknown: true }
: {}),
};
}

function resolveQueryAndRealm(
Expand Down
8 changes: 7 additions & 1 deletion packages/host/app/lib/gc-card-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ type StoreHooks = {
// so a hop that forwards the seed field by field rather than whole
// would drop it and restore exactly the inference it prevents.
totalUnknown?: boolean;
// Here for the same reason: the identity is what stops a result set
// being re-applied over one the resource already holds, and a hop
// that dropped it would put that back. The generation is what
// orders the two when they do differ.
identity?: string;
generation?: number;
}
| undefined;
},
Expand Down Expand Up @@ -1457,7 +1463,7 @@ export default class CardStoreWithGarbageCollection implements CardStore {
parent: object,
getQuery: () => Query | undefined,
getRealms?: () => string[] | undefined,
opts?: GetSearchResourceFuncOpts,
opts?: GetSearchResourceFuncOpts<T>,
) {
if (!this.#storeHooks?.getSearchResource) {
return {
Expand Down
Loading
Loading