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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Enrich incompatible update invariant violation messages with operation, path, and type diagnostics",
"packageName": "@graphitation/apollo-forest-run",
"email": "vrazuvaev@microsoft.com_msteamsmdb",
"dependentChangeType": "patch"
}
125 changes: 125 additions & 0 deletions packages/apollo-forest-run/src/__tests__/regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,131 @@ test("does not crash when object diff is applied to list field with key collisio
expect(run).not.toThrow();
});

const inconsistentEntityQuery = gql`
{
objectVariant {
__typename
id
value {
note
}
}
listVariant {
__typename
id
value {
note
}
}
}
`;
const objectVariantQuery = gql`
{
objectVariant {
__typename
id
value {
note
}
}
}
`;

const INCOMPATIBLE_OBJECT_DIFF_ERROR =
'Invariant violation: Failed to update "query {\n' +
" objectVariant {...}\n" +
" listVariant {...}\n" +
'}" at path listVariant.value (in Entity): expected CompositeList, got ObjectDifference';

const INCOMPATIBLE_OBJECT_DIFF_ERROR_KEY_COLLISION =
'Invariant violation: Failed to update "query {\n' +
" objectVariant {...}\n" +
" listVariant {...}\n" +
'}" at path listVariant.value (in ListEntity): expected CompositeList, got ObjectDifference';

function expectInvariantViolation(run: () => unknown, message: string) {
let error: unknown;
try {
run();
} catch (thrown) {
error = thrown;
}

expect(error).toBeInstanceOf(Error);
expect((error as Error).message.startsWith("Invariant violation")).toBe(true);
expect((error as Error).message).toBe(message);
}

test("reports an object diff reaching an inconsistent repeated entity", () => {
const cache = new ForestRun();

cache.write({
query: inconsistentEntityQuery,
result: {
objectVariant: {
__typename: "Entity",
id: "1",
value: { note: "old" },
},
listVariant: {
__typename: "Entity",
id: "1",
value: [{ note: "old" }],
},
},
});

const run = () =>
cache.write({
query: objectVariantQuery,
result: {
objectVariant: {
__typename: "Entity",
id: "1",
value: { note: "new" },
},
},
});

expectInvariantViolation(run, INCOMPATIBLE_OBJECT_DIFF_ERROR);
});

test("reports an object diff reaching a cache key collision", () => {
const cache = new ForestRun({
dataIdFromObject: (object: any) => object.id,
});

cache.write({
query: inconsistentEntityQuery,
result: {
objectVariant: {
__typename: "ObjectEntity",
id: "1",
value: { note: "old" },
},
listVariant: {
__typename: "ListEntity",
id: "1",
value: [{ note: "old" }],
},
},
});

const run = () =>
cache.write({
query: objectVariantQuery,
result: {
objectVariant: {
__typename: "ObjectEntity",
id: "1",
value: { note: "new" },
},
},
});

expectInvariantViolation(run, INCOMPATIBLE_OBJECT_DIFF_ERROR_KEY_COLLISION);
});

test("matches apollo InMemoryCache behavior on incorrect cache overwrites", () => {
const listQuery = gql`
query ListQuery {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { getEmbeddedObjectChunks } from "../draftHelpers";
import { generateChunk } from "../../__tests__/helpers/values";
import {
createParentLocator,
getGraphValueReference,
} from "../../values/traverse";
import { resolveFieldValue } from "../../values/resolve";
import { CompositeListChunk, NodeChunk } from "../../values/types";

describe("getEmbeddedObjectChunks invariant", () => {
it("reports operation and path when the embedded value is a list", () => {
const query = `query EmbeddedListTest {
node {
__typename @mock(value: "Node")
id
items @mock(count: 2) {
__typename @mock(value: "Item")
value
}
}
}`;
const { value: root, dataMap } = generateChunk(query);
const env = { findParent: createParentLocator(dataMap) };

const node = resolveFieldValue(root, "node") as NodeChunk;
const items = resolveFieldValue(node, "items") as CompositeListChunk;

// The reference resolves to a composite list, but getEmbeddedObjectChunks
// expects an embedded object at that location.
const ref = getGraphValueReference(env, items);

expect(() => [...getEmbeddedObjectChunks(env, [node], ref)]).toThrow(
'Invariant violation: Failed to resolve embedded object in "query EmbeddedListTest" at path node.items (in Node): expected an embedded object, got a list of Item',
);
});
});
71 changes: 69 additions & 2 deletions packages/apollo-forest-run/src/cache/draftHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type {
ChunkMatcher,
ChunkProvider,
CompositeListChunk,
CompositeListValue,
GraphValue,
GraphValueReference,
NodeChunk,
ObjectChunk,
Expand All @@ -21,6 +23,7 @@ import {
isNodeValue,
isObjectValue,
findClosestNode,
getDataPathForDebugging,
resolveGraphValueReference,
retrieveEmbeddedValue,
TraverseEnv,
Expand Down Expand Up @@ -59,7 +62,9 @@ export function getObjectChunks(
);
}

function* getEmbeddedObjectChunks(
// Exported for unit testing of the invariant message only (defensive guard,
// not reachable through the public cache API).
export function* getEmbeddedObjectChunks(
pathEnv: TraverseEnv,
nodeChunks: Iterable<NodeChunk>,
ref: GraphValueReference,
Expand All @@ -69,7 +74,18 @@ function* getEmbeddedObjectChunks(
if (value === undefined || isMissingValue(value)) {
continue;
}
assert(isObjectValue(value) && value.key === false);
if (!isObjectValue(value) || value.key !== false) {
const at = embeddedPathClause(pathEnv, value);
const nodeType = chunk.type ? ` (in ${chunk.type})` : "";
assert(
false,
`Failed to resolve embedded object in "${
chunk.operation.debugName
}"${at}${nodeType}: expected an embedded object, got ${describeEmbeddedValue(
value,
)}`,
);
}
if (value.isAggregate) {
for (const embeddedChunk of value.chunks) {
yield embeddedChunk;
Expand All @@ -80,6 +96,57 @@ function* getEmbeddedObjectChunks(
}
}

// Builds a " at path <path>" clause for invariant messages. Wrapped in try/catch because
// it runs while reporting a violation, when the tree may already be inconsistent.
function embeddedPathClause(env: TraverseEnv, value: GraphValue): string {
if (
(!isObjectValue(value) && !isCompositeListValue(value)) ||
value.isAggregate
) {
return "";
}
try {
const path = getDataPathForDebugging(env, value);
return path.length ? ` at path ${path.join(".")}` : "";
} catch {
return "";
}
}

function describeEmbeddedValue(value: GraphValue): string {
if (isCompositeListValue(value)) {
const itemType = listItemTypeName(value);
return itemType ? `a list of ${itemType}` : "a list";
}
if (isObjectValue(value)) {
// An object value with a key is a normalized node, not an embedded object.
return value.key !== false
? `a node (${value.type || "unknown type"})`
: "an object";
}
return "a scalar";
}

// Best-effort __typename of the first typed item in a list, for diagnostics.
// Defensive: wrapped in try/catch and skips aggregates; returns undefined when no
// typed item is found (lists of plain objects/scalars carry no __typename).
function listItemTypeName(value: CompositeListValue): string | undefined {
if (value.isAggregate) {
return undefined;
}
try {
for (const item of value.itemChunks) {
const itemValue = item?.value;
if (itemValue && isObjectValue(itemValue) && itemValue.type) {
return itemValue.type;
}
}
} catch {
// ignore - diagnostics only
}
return undefined;
}

export function* getNodeChunks(
layers: IndexedForest[],
key: NodeKey,
Expand Down
Loading
Loading