Skip to content

feat(cache): add peekRemoteState to cache to view remote state #9624

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Feb 21, 2025
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
63 changes: 63 additions & 0 deletions packages/core-types/src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,43 @@ export interface Cache {
peek<T = unknown>(identifier: StableRecordIdentifier<TypeFromInstanceOrString<T>>): T | null;
peek(identifier: StableDocumentIdentifier): ResourceDocument | null;

/**
* Peek remote resource data from the Cache.
*
* This will give the data provided from the server without any local changes.
*
* In development, if the return value
* is JSON the return value
* will be deep-cloned and deep-frozen
* to prevent mutation thereby enforcing cache
* Immutability.
*
* This form of peek is useful for implementations
* that want to feed raw-data from cache to the UI
* or which want to interact with a blob of data
* directly from the presentation cache.
*
* An implementation might want to do this because
* de-referencing records which read from their own
* blob is generally safer because the record does
* not require retainining connections to the Store
* and Cache to present data on a per-field basis.
*
* This generally takes the place of `getAttr` as
* an API and may even take the place of `getRelationship`
* depending on implementation specifics, though this
* latter usage is less recommended due to the advantages
* of the Graph handling necessary entanglements and
* notifications for relational data.
*
* @method peek
* @public
* @param {StableRecordIdentifier | StableDocumentIdentifier} identifier
* @return {ResourceDocument | ResourceBlob | null} the known resource data
*/
peekRemoteState<T = unknown>(identifier: StableRecordIdentifier<TypeFromInstanceOrString<T>>): T | null;
peekRemoteState(identifier: StableDocumentIdentifier): ResourceDocument | null;

/**
* Peek the Cache for the existing request data associated with
* a cacheable request
Expand Down Expand Up @@ -341,6 +378,17 @@ export interface Cache {
*/
getAttr(identifier: StableRecordIdentifier, field: string | string[]): Value | undefined;

/**
* Retrieve remote state without any local changes for a specific attribute
*
* @method getRemoteAttr
* @public
* @param identifier
* @param field
* @return {unknown}
*/
getRemoteAttr(identifier: StableRecordIdentifier, field: string | string[]): Value | undefined;

/**
* Mutate the data for an attribute in the cache
*
Expand Down Expand Up @@ -460,6 +508,21 @@ export interface Cache {
isCollection?: boolean
): ResourceRelationship | CollectionRelationship;

/**
* Query the cache for the server state of a relationship property without any local changes
*
* @method getRelationship
* @public
* @param {StableRecordIdentifier} identifier
* @param {string} field
* @return resource relationship object
*/
getRemoteRelationship(
identifier: StableRecordIdentifier,
field: string,
isCollection?: boolean
): ResourceRelationship | CollectionRelationship;

// Resource State
// ===============

Expand Down
68 changes: 68 additions & 0 deletions packages/experiments/src/persisted-cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,44 @@ export class PersistedCache implements Cache {
return this._cache.peek(identifier);
}

/**
* Peek resource data from the Cache.
*
* In development, if the return value
* is JSON the return value
* will be deep-cloned and deep-frozen
* to prevent mutation thereby enforcing cache
* Immutability.
*
* This form of peek is useful for implementations
* that want to feed raw-data from cache to the UI
* or which want to interact with a blob of data
* directly from the presentation cache.
*
* An implementation might want to do this because
* de-referencing records which read from their own
* blob is generally safer because the record does
* not require retainining connections to the Store
* and Cache to present data on a per-field basis.
*
* This generally takes the place of `getAttr` as
* an API and may even take the place of `getRelationship`
* depending on implementation specifics, though this
* latter usage is less recommended due to the advantages
* of the Graph handling necessary entanglements and
* notifications for relational data.
*
* @method peek
* @internal
* @param {StableRecordIdentifier | StableDocumentIdentifier} identifier
* @returns {ResourceDocument | ResourceBlob | null} the known resource data
*/
peekRemoteState<T = unknown>(identifier: StableRecordIdentifier<TypeFromInstanceOrString<T>>): T | null;
peekRemoteState(identifier: StableDocumentIdentifier): ResourceDocument | null;
peekRemoteState(identifier: StableRecordIdentifier | StableDocumentIdentifier): unknown {
return this._cache.peekRemoteState(identifier);
}

/**
* Peek the Cache for the existing request data associated with
* a cacheable request
Expand Down Expand Up @@ -374,6 +412,19 @@ export class PersistedCache implements Cache {
return this._cache.getAttr(identifier, field);
}

/**
* Retrieve the remote state for an attribute from the cache
*
* @method getAttr
* @internal
* @param identifier
* @param propertyName
* @return {unknown}
*/
getRemoteAttr(identifier: StableRecordIdentifier, field: string): Value | undefined {
return this._cache.getRemoteAttr(identifier, field);
}

/**
* Mutate the data for an attribute in the cache
*
Expand Down Expand Up @@ -502,6 +553,23 @@ export class PersistedCache implements Cache {
return this._cache.getRelationship(identifier, field, isCollection);
}

/**
* Query the remote state for the current state of a relationship property
*
* @method getRelationship
* @internal
* @param identifier
* @param propertyName
* @return resource relationship object
*/
getRemoteRelationship(
identifier: StableRecordIdentifier,
field: string,
isCollection?: boolean
): ResourceRelationship | CollectionRelationship {
return this._cache.getRemoteRelationship(identifier, field, isCollection);
}

// Resource State
// ===============

Expand Down
7 changes: 5 additions & 2 deletions packages/graph/src/-private/edges/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,15 @@ export function createCollectionEdge(definition: UpgradedMeta, identifier: Stabl
};
}

export function legacyGetCollectionRelationshipData(source: CollectionEdge): CollectionRelationship {
export function legacyGetCollectionRelationshipData(
source: CollectionEdge,
getRemoteState: boolean
): CollectionRelationship {
source.accessed = true;
const payload: CollectionRelationship = {};

if (source.state.hasReceivedData) {
payload.data = computeLocalState(source);
payload.data = getRemoteState ? source.remoteState.slice() : computeLocalState(source);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love that this means we dont flush the computation unless needed <3

}

if (source.links) {
Expand Down
9 changes: 6 additions & 3 deletions packages/graph/src/-private/edges/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,19 @@ export function createResourceEdge(definition: UpgradedMeta, identifier: StableR
};
}

export function legacyGetResourceRelationshipData(source: ResourceEdge): ResourceRelationship {
export function legacyGetResourceRelationshipData(source: ResourceEdge, getRemoteState: boolean): ResourceRelationship {
source.accessed = true;
let data: StableRecordIdentifier | null | undefined;
const payload: ResourceRelationship = {};
if (source.localState) {
if (getRemoteState && source.remoteState) {
data = source.remoteState;
} else if (!getRemoteState && source.localState) {
data = source.localState;
}
if (source.localState === null && source.state.hasReceivedData) {
if (((getRemoteState && source.remoteState === null) || source.localState === null) && source.state.hasReceivedData) {
Copy link
Contributor

@robbytx robbytx May 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@richgt in my testing (on 4.13, backported from 5.3), this line seems incorrect in the situation where a relationship was non-null, but is set to null locally and not yet saved. I think the line should be:

if (((getRemoteState ? source.remoteState : source.localState) === null) && source.state.hasReceivedData) {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assuming:

  • remoteState !== null
  • localState === null

for getRemoteState === true

  • line 48 gives us the remote value
  • check on line 52 becomes: (((true && false) || true) && true) which evals to true
  • we assign null to the data incorrectly

for getRemoteState === false

  • conditions on lines 47 and 49 are false
  • line 52 evals to (((false && false) || true) && true) which evals to true
  • we assign null to the data correctly

I believe the mistake here is actually that the check on line 52 is an if instead of an else if

data = null;
}

if (source.links) {
payload.links = source.links;
}
Expand Down
21 changes: 18 additions & 3 deletions packages/graph/src/-private/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,25 @@ export class Graph {
assert(`Cannot getData() on an implicit relationship`, !isImplicit(relationship));

if (isBelongsTo(relationship)) {
return legacyGetResourceRelationshipData(relationship);
return legacyGetResourceRelationshipData(relationship, false);
}

return legacyGetCollectionRelationshipData(relationship);
return legacyGetCollectionRelationshipData(relationship, false);
}

getRemoteData(
identifier: StableRecordIdentifier,
propertyName: string
): ResourceRelationship | CollectionRelationship {
const relationship = this.get(identifier, propertyName);

assert(`Cannot getRemoteData() on an implicit relationship`, !isImplicit(relationship));

if (isBelongsTo(relationship)) {
return legacyGetResourceRelationshipData(relationship, true);
}

return legacyGetCollectionRelationshipData(relationship, true);
}

/*
Expand Down Expand Up @@ -334,7 +349,7 @@ export class Graph {
additions: new Set(relationship.additions),
removals: new Set(relationship.removals),
remoteState: relationship.remoteState,
localState: legacyGetCollectionRelationshipData(relationship).data || [],
localState: legacyGetCollectionRelationshipData(relationship, false).data || [],
reordered,
});
}
Expand Down
122 changes: 122 additions & 0 deletions packages/json-api/src/-private/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,64 @@ export default class JSONAPICache implements Cache {
return null;
}

peekRemoteState(identifier: StableRecordIdentifier): ResourceObject | null;
peekRemoteState(identifier: StableDocumentIdentifier): ResourceDocument | null;
peekRemoteState(
identifier: StableDocumentIdentifier | StableRecordIdentifier
): ResourceObject | ResourceDocument | null {
if ('type' in identifier) {
const peeked = this.__safePeek(identifier, false);

if (!peeked) {
return null;
}

const { type, id, lid } = identifier;
const attributes = Object.assign({}, peeked.remoteAttrs) as ObjectValue;
const relationships: ResourceObject['relationships'] = {};

const rels = this.__graph.identifiers.get(identifier);
if (rels) {
Object.keys(rels).forEach((key) => {
const rel = rels[key];
if (rel.definition.isImplicit) {
return;
} else {
relationships[key] = this.__graph.getData(identifier, key);
}
});
}

upgradeCapabilities(this._capabilities);
const store = this._capabilities._store;
const attrs = this._capabilities.schema.fields(identifier);
attrs.forEach((attr, key) => {
if (key in attributes && attributes[key] !== undefined) {
return;
}
const defaultValue = getDefaultValue(attr, identifier, store);

if (defaultValue !== undefined) {
attributes[key] = defaultValue;
}
});

return {
type,
id,
lid,
attributes,
relationships,
};
}

const document = this.peekRequest(identifier);

if (document) {
if ('content' in document) return document.content!;
}
return null;
}
/**
* Peek the Cache for the existing request data associated with
* a cacheable request.
Expand Down Expand Up @@ -1176,6 +1234,63 @@ export default class JSONAPICache implements Cache {
return current;
}

getRemoteAttr(identifier: StableRecordIdentifier, attr: string | string[]): Value | undefined {
const isSimplePath = !Array.isArray(attr) || attr.length === 1;
if (Array.isArray(attr) && attr.length === 1) {
attr = attr[0];
}

if (isSimplePath) {
const attribute = attr as string;
const cached = this.__peek(identifier, true);
assert(
`Cannot retrieve remote attributes for identifier ${String(identifier)} as it is not present in the cache`,
cached
);

// in Prod we try to recover when accessing something that
// doesn't exist
if (!cached) {
return undefined;
}

if (cached.remoteAttrs && attribute in cached.remoteAttrs) {
return cached.remoteAttrs[attribute];
} else if (cached.defaultAttrs && attribute in cached.defaultAttrs) {
return cached.defaultAttrs[attribute];
} else {
const attrSchema = this._capabilities.schema.fields(identifier).get(attribute);

upgradeCapabilities(this._capabilities);
const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store);
if (schemaHasLegacyDefaultValueFn(attrSchema)) {
cached.defaultAttrs = cached.defaultAttrs || (Object.create(null) as Record<string, Value>);
cached.defaultAttrs[attribute] = defaultValue;
}
return defaultValue;
}
}

// TODO @runspired consider whether we need a defaultValue cache in SchemaRecord
// like we do for the simple case above.
const path: string[] = attr as string[];
const cached = this.__peek(identifier, true);
const basePath = path[0];
let current = cached.remoteAttrs && basePath in cached.remoteAttrs ? cached.remoteAttrs[basePath] : undefined;

if (current === undefined) {
return undefined;
}

for (let i = 1; i < path.length; i++) {
current = (current as ObjectValue)[path[i]];
if (current === undefined) {
return undefined;
}
}
return current;
}

/**
* Mutate the data for an attribute in the cache
*
Expand Down Expand Up @@ -1471,6 +1586,13 @@ export default class JSONAPICache implements Cache {
return this.__graph.getData(identifier, field);
}

getRemoteRelationship(
identifier: StableRecordIdentifier,
field: string
): ResourceRelationship | CollectionRelationship {
return this.__graph.getRemoteData(identifier, field);
}

// Resource State
// ===============

Expand Down
Loading
Loading