Files
api/web/helpers/apollo-cache/index.ts
Pujit Mehrotra a09f7c935d refactor(api): delete only archived notifications in deleteAll (#983)
* refactor(api): delete only archived notifications in deleteAll

* refactor: rename deleteAllNotifications mutation to deleteArchivedNotifications

* fix: update mutation name for deleting archived notifications

* chore(web): update codegen dependencies to fix codegen issues

* chore(web): update vue-tsc to fix build typechecking
2024-12-13 14:50:33 -05:00

203 lines
8.2 KiB
TypeScript

import { InMemoryCache, type InMemoryCacheConfig } from '@apollo/client/core/index.js';
import {
getNotifications,
notificationsOverview,
} from '~/components/Notifications/graphql/notification.query';
import { NotificationType, type NotificationOverview } from '~/composables/gql/graphql';
import { NotificationType as NotificationCacheType } from '~/composables/gql/typename';
import { mergeAndDedup } from './merge';
/**------------------------------------------------------------------------
* ! Understanding Cache Type Policies
*
* The 'type' in type policies is a discriminated union of all graphql
* types recognized by Apollo (which defaults to discriminating on the
* __typename field).
*
* This means the depth or hiearchy of a field/type is irrelevant; the policy
* will always be [ParentType]: TypePolicy (where you can define the behavior
* of the field you actually care about).
*
* "Top-level" types are Query, Mutation, and Subscription. This is where your
* your top-level operations are defined. If you want to modify a sub-field or
* sub-operation, you need to look for its containing type.
*
* e.g. Query -> Notifications -> list
*
* The policy to modify `list`'s behavior will live in the policy for
* `Notifications`.
*------------------------------------------------------------------------**/
const defaultCacheConfig: InMemoryCacheConfig = {
typePolicies: {
Notifications: {
fields: {
list: {
/**----------------------------------------------
* ? Key Args Syntax
*
* the sub-list in this context means "fields from the preceding key"
*
* i.e. this means [filter.type, filter.importance],
* not [filter, type, importance]
*---------------------------------------------**/
keyArgs: ['filter', ['type', 'importance']],
/**
* Merges incoming data into the correct offset position.
*
* This lets paginated results live as a single list in the cache,
* which simplifies our client code.
*
* @param existing
* @param incoming
* @param context
* @returns the value to be cached
*/
merge(existing = [], incoming, { args }) {
const offset = args?.filter?.offset ?? 0;
return mergeAndDedup(existing, incoming, (item) => item.__ref, {
offset,
});
},
},
overview: {
/**
* Busts notification cache when new unread notifications are detected.
*
* This allows incoming notifications to appear in the sidebar without reloading the page.
*
* @param existing - Existing overview data in cache
* @param incoming - New overview data from server
* @param context - Apollo context containing cache instance
* @returns The overview data to be cached
*/
merge(
existing: Partial<NotificationOverview> | undefined,
incoming: Partial<NotificationOverview> | undefined,
{ cache }
) {
const hasNewUnreads =
isDefined(existing?.unread?.total) &&
isDefined(incoming?.unread?.total) &&
existing.unread.total < incoming.unread.total;
if (hasNewUnreads) {
cache.evict({ fieldName: 'notifications' });
cache.gc();
}
return incoming;
},
},
},
},
Mutation: {
fields: {
archiveAll: {
/**
* This clears the notifications cache when `archiveAll` is called to
* ensure that notification queries are invalidated, refetched,
* and in the correct state & sorting order after this operation.
*
* @param _ existing value in cache (irrelevant to this operation)
* @param incoming result from the server
* @param apolloContext contains the cache object
* @returns the value to cache for this operation
*/
merge(_, incoming, { cache }) {
cache.evict({ fieldName: 'notifications' });
cache.gc(); // Run garbage collection to prevent orphans & incorrect cache state
return incoming; // Return the incoming data so Apollo knows the result of the mutation
},
},
archiveNotification: {
/**
* Optimistically decrements unread total from overview &
* Ensures newly archived notifications appear in the archive list without a page reload.
*
* When a notification is archived, we need to evict the cached archive list to force a refetch.
* This ensures the archived notification appears in the correct sorted position.
*
* If the archive list is empty, we evict the entire notifications cache since evicting an empty
* list has no effect. This forces a full refetch of all notification data.
* Note: This may cause temporary jitter with infinite scroll.
*
* This function:
*
* 1. Optimistically updates notification overview
* 2. Checks if the cache has an archive list. If not, this function is a no-op.
* 3. If the list has items, evicts just the archive list
* 4. If it is empty, evicts the entire notifications cache
* 5. Runs garbage collection to clean up orphaned references
* 6. Returns the original mutation result
*
* @param _ - Existing cache value (unused)
* @param incoming - Result (i.e. the archived notification) from the server after archiving
* @param cache - Apollo cache instance
* @returns The incoming result to be cached
*/
merge(_, incoming, { cache }) {
cache.updateQuery({ query: notificationsOverview }, (data) => {
if (!data) return;
const update = structuredClone(data);
update.notifications.overview.unread.total--;
return update;
});
const archiveQuery = cache.readQuery({
query: getNotifications,
// @ts-expect-error the cache only uses the filter type; the limit & offset are superfluous.
variables: { filter: { type: NotificationType.Archive } },
});
if (!archiveQuery) return incoming;
if (archiveQuery.notifications.list.length === 0) {
cache.evict({ fieldName: 'notifications' });
} else {
cache.evict({
id: archiveQuery.notifications.id,
fieldName: 'list',
args: { filter: { type: NotificationType.Archive } },
});
}
cache.gc();
return incoming;
},
},
deleteNotification: {
/**
* Ensures that a deleted notification is removed from the cache +
* any cached items that reference it
*
* @param _ - Unused parameter representing the existing cache value.
* @param incoming - The result from the server after the mutation.
* @param cache - The Apollo cache instance.
* @param args - Arguments passed to the mutation, expected to contain the `id` of the notification to evict.
* @returns The incoming result to be cached.
*/
merge(_, incoming, { cache, args }) {
if (args?.id) {
const id = cache.identify({ id: args.id, __typename: NotificationCacheType });
cache.evict({ id });
}
// Removes references to evicted notification, preventing dangling references
cache.gc();
return incoming;
},
},
deleteArchivedNotifications: {
merge(_, incoming, { cache }) {
cache.evict({ fieldName: 'notifications' });
cache.gc();
return incoming;
},
},
},
},
},
};
export function createApolloCache(config = defaultCacheConfig) {
return new InMemoryCache(config);
}